From a47da5bb74f07d4ca70a7c128e8f54f320cc46f2 Mon Sep 17 00:00:00 2001 From: chazmm Date: Sun, 6 Sep 2026 03:44:14 +0100 Subject: [PATCH] Import supplied skater project and standalone Windows setup --- .gitignore | 4 + BUILD.bat | 4 + Build.ps1 | 53 + Cargo.lock | 5937 +++++++++++++++++ Cargo.toml | 21 + Launch.ps1 | 19 + PLAY.bat | 4 + README.md | 45 + SETUP-PROVENANCE.json | 24 + crates/skate-core/Cargo.toml | 5 + crates/skate-core/src/air/body_flip.rs | 107 + crates/skate-core/src/air/body_spin.rs | 203 + crates/skate-core/src/air/ground_jump/math.rs | 65 + crates/skate-core/src/air/ground_jump/mod.rs | 77 + .../skate-core/src/air/ground_jump/types.rs | 44 + crates/skate-core/src/air/ground_normal.rs | 85 + crates/skate-core/src/air/known/body.rs | 155 + crates/skate-core/src/air/known/data.rs | 193 + crates/skate-core/src/air/known/footplant.rs | 34 + crates/skate-core/src/air/known/lifecycle.rs | 73 + crates/skate-core/src/air/known/mod.rs | 33 + crates/skate-core/src/air/known/output.rs | 62 + .../skate-core/src/air/known/post_physics.rs | 55 + crates/skate-core/src/air/known/runtime.rs | 98 + crates/skate-core/src/air/known/tests/mock.rs | 376 ++ crates/skate-core/src/air/known/tests/mod.rs | 404 ++ crates/skate-core/src/air/known/trajectory.rs | 165 + crates/skate-core/src/air/known/update.rs | 106 + crates/skate-core/src/air/mod.rs | 9 + crates/skate-core/src/air/reckoning/data.rs | 75 + crates/skate-core/src/air/reckoning/math.rs | 105 + crates/skate-core/src/air/reckoning/mod.rs | 129 + crates/skate-core/src/air/reckoning/tests.rs | 176 + crates/skate-core/src/air/state/board.rs | 32 + crates/skate-core/src/air/state/data.rs | 117 + .../skate-core/src/air/state/jump_velocity.rs | 65 + crates/skate-core/src/air/state/lifecycle.rs | 95 + crates/skate-core/src/air/state/math.rs | 46 + crates/skate-core/src/air/state/mod.rs | 29 + .../skate-core/src/air/state/post_physics.rs | 15 + crates/skate-core/src/air/state/runtime.rs | 76 + crates/skate-core/src/air/state/tests/mod.rs | 551 ++ crates/skate-core/src/air/state/update.rs | 193 + .../skate-core/src/air/trajectory/launch.rs | 170 + crates/skate-core/src/air/trajectory/math.rs | 80 + crates/skate-core/src/air/trajectory/mod.rs | 13 + .../src/air/trajectory/prediction.rs | 90 + crates/skate-core/src/air/trajectory/query.rs | 152 + .../skate-core/src/air/trajectory/scoring.rs | 193 + .../skate-core/src/air/trajectory/selector.rs | 313 + crates/skate-core/src/air/trajectory/types.rs | 105 + .../src/animation/air_leg_extension.rs | 159 + crates/skate-core/src/animation/body_tilt.rs | 66 + .../src/animation/channel_playback.rs | 138 + crates/skate-core/src/animation/clip_clock.rs | 124 + .../src/animation/commands/batch.rs | 182 + .../src/animation/commands/buffers.rs | 138 + .../skate-core/src/animation/commands/mod.rs | 4 + .../src/animation/commands/set_data.rs | 60 + crates/skate-core/src/animation/crouching.rs | 108 + .../skate-core/src/animation/foot_ik/blend.rs | 71 + .../src/animation/foot_ik/contact.rs | 159 + .../skate-core/src/animation/foot_ik/drive.rs | 231 + .../src/animation/foot_ik/external.rs | 91 + .../skate-core/src/animation/foot_ik/math.rs | 224 + .../skate-core/src/animation/foot_ik/mod.rs | 17 + .../src/animation/foot_ik/physical_solve.rs | 72 + .../src/animation/foot_ik/post_contact.rs | 113 + .../src/animation/foot_ik/post_physics.rs | 159 + .../src/animation/foot_ik/settings.rs | 21 + .../skate-core/src/animation/foot_ik/state.rs | 174 + .../src/animation/foot_ik/status.rs | 194 + .../skate-core/src/animation/foot_ik/tests.rs | 94 + .../src/animation/foot_ik/transforms.rs | 97 + .../src/animation/foot_ik/two_bone.rs | 202 + .../src/animation/ground_acceleration.rs | 121 + .../skate-core/src/animation/intent_filter.rs | 109 + .../src/animation/intent_filter/tests.rs | 66 + crates/skate-core/src/animation/kickturn.rs | 109 + .../src/animation/landing_quality.rs | 148 + crates/skate-core/src/animation/mod.rs | 33 + .../src/animation/output/actor_packet.rs | 128 + .../src/animation/output/attributes.rs | 120 + .../src/animation/output/hierarchy.rs | 101 + .../src/animation/output/intents.rs | 155 + crates/skate-core/src/animation/output/mod.rs | 27 + .../animation/output/motion_graph_packet.rs | 45 + .../src/animation/output/packet_reset.rs | 95 + .../src/animation/output/physics_packet.rs | 149 + .../src/animation/output/setup_physics.rs | 100 + crates/skate-core/src/animation/output/sqt.rs | 103 + .../src/animation/output/tests/intents.rs | 72 + .../skate-core/src/animation/phase_blend.rs | 133 + .../src/animation/physical_feedback.rs | 125 + crates/skate-core/src/animation/playback.rs | 140 + .../src/animation/playback_attributes.rs | 64 + .../skate-core/src/animation/playback_clip.rs | 106 + .../src/animation/playback_parameters.rs | 121 + .../src/animation/playback_transition.rs | 66 + .../skate-core/src/animation/playback_tree.rs | 278 + .../playback_tree/selection_space.rs | 159 + crates/skate-core/src/animation/pose_add.rs | 18 + crates/skate-core/src/animation/pose_blend.rs | 62 + .../skate-core/src/animation/pose_mirror.rs | 37 + .../skate-core/src/animation/pose_sample.rs | 76 + .../src/animation/pose_trajectory.rs | 61 + crates/skate-core/src/animation/posture.rs | 86 + .../skate-core/src/animation/posture/tests.rs | 70 + .../src/animation/posture/tree_tests.rs | 69 + .../src/animation/pumping_channel.rs | 30 + .../skate-core/src/animation/riding_fakie.rs | 66 + .../skeleton_input/attribute_finalization.rs | 105 + .../src/animation/skeleton_input/catalog.rs | 191 + .../skeleton_input/contact_events.rs | 67 + .../skeleton_input/extended_attributes.rs | 150 + .../src/animation/skeleton_input/mod.rs | 12 + .../src/animation/skeleton_input/name.rs | 33 + .../skeleton_input/process_attributes.rs | 35 + .../skeleton_input/scalar_attributes.rs | 201 + .../src/animation/skeleton_input/tests.rs | 48 + .../src/animation/tests/kickturn.rs | 56 + .../src/animation/tests/pose_blend.rs | 39 + .../src/animation/tests/pose_sample_vbr.rs | 20 + crates/skate-core/src/animation/vbr/bits.rs | 64 + crates/skate-core/src/animation/vbr/header.rs | 175 + crates/skate-core/src/animation/vbr/mod.rs | 85 + .../src/animation/vbr/tests/bits.rs | 24 + .../skate-core/src/animation/vbr/transform.rs | 106 + crates/skate-core/src/camera/anchors.rs | 100 + crates/skate-core/src/camera/angle_tracker.rs | 88 + crates/skate-core/src/camera/avoidance.rs | 262 + crates/skate-core/src/camera/compass.rs | 168 + .../skate-core/src/camera/compass_movement.rs | 63 + crates/skate-core/src/camera/compass_orbit.rs | 66 + crates/skate-core/src/camera/direction.rs | 60 + .../skate-core/src/camera/drop_predictor.rs | 258 + .../skate-core/src/camera/frame_composer.rs | 169 + crates/skate-core/src/camera/look_input.rs | 32 + crates/skate-core/src/camera/manager.rs | 172 + crates/skate-core/src/camera/manager_air.rs | 85 + .../skate-core/src/camera/manager_blends.rs | 70 + .../skate-core/src/camera/manager_controls.rs | 41 + crates/skate-core/src/camera/manager_frame.rs | 54 + .../skate-core/src/camera/manager_incline.rs | 63 + .../skate-core/src/camera/manager_output.rs | 43 + crates/skate-core/src/camera/manager_rig.rs | 82 + crates/skate-core/src/camera/manager_state.rs | 88 + .../skate-core/src/camera/manager_subject.rs | 82 + crates/skate-core/src/camera/mod.rs | 84 + .../skate-core/src/camera/orientation_math.rs | 96 + .../skate-core/src/camera/path_evaluator.rs | 122 + .../skate-core/src/camera/path_prediction.rs | 49 + crates/skate-core/src/camera/positioner.rs | 289 + crates/skate-core/src/camera/rig.rs | 312 + crates/skate-core/src/camera/rig_framing.rs | 90 + .../src/camera/rig_initialization.rs | 219 + .../skate-core/src/camera/rig_orientation.rs | 203 + .../skate-core/src/camera/rig_positioning.rs | 176 + crates/skate-core/src/camera/rig_tracking.rs | 317 + crates/skate-core/src/camera/shake.rs | 85 + crates/skate-core/src/camera/shake_curve.rs | 23 + crates/skate-core/src/camera/shake_update.rs | 56 + crates/skate-core/src/camera/shot.rs | 201 + crates/skate-core/src/camera/shot_manager.rs | 307 + .../skate-core/src/camera/shot_orientation.rs | 38 + crates/skate-core/src/camera/slow_motion.rs | 71 + crates/skate-core/src/camera/subject.rs | 161 + crates/skate-core/src/camera/subject_pose.rs | 51 + crates/skate-core/src/camera/tracker.rs | 90 + .../skate-core/src/camera/trajectory_query.rs | 107 + .../skate-core/src/camera/vector_tracker.rs | 92 + crates/skate-core/src/graph/activation.rs | 213 + crates/skate-core/src/graph/conditions.rs | 206 + crates/skate-core/src/graph/controller.rs | 268 + crates/skate-core/src/graph/expression.rs | 60 + .../skate-core/src/graph/intent_handlers.rs | 86 + crates/skate-core/src/graph/intents.rs | 47 + crates/skate-core/src/graph/mod.rs | 8 + crates/skate-core/src/graph/selection.rs | 204 + .../skate-core/src/graph/tests/conditions.rs | 102 + .../src/graph/tests/intent_handlers.rs | 44 + crates/skate-core/src/input/angle.rs | 78 + .../skate-core/src/input/animation_packet.rs | 58 + .../skate-core/src/input/body_flip_signal.rs | 44 + crates/skate-core/src/input/controller.rs | 166 + .../src/input/controller_startup.rs | 20 + crates/skate-core/src/input/gameplay_map.rs | 52 + crates/skate-core/src/input/graph_intents.rs | 134 + crates/skate-core/src/input/history.rs | 98 + crates/skate-core/src/input/history_tests.rs | 79 + crates/skate-core/src/input/mod.rs | 19 + .../src/input/offboard_intentions.rs | 138 + .../src/input/offboard_intentions/tests.rs | 138 + crates/skate-core/src/input/pad.rs | 79 + crates/skate-core/src/input/power_sliding.rs | 119 + .../skate-core/src/input/riding_intentions.rs | 139 + .../src/input/riding_intentions_tests.rs | 88 + crates/skate-core/src/input/set_turning.rs | 233 + .../src/input/steering_intentions.rs | 49 + .../skate-core/src/input/turn_conditioner.rs | 144 + crates/skate-core/src/input/turn_remap.rs | 104 + .../src/input/wipeout_intentions.rs | 51 + crates/skate-core/src/input/xbox.rs | 70 + crates/skate-core/src/lib.rs | 17 + crates/skate-core/src/math.rs | 26 + crates/skate-core/src/physics/assembly.rs | 204 + crates/skate-core/src/physics/board.rs | 35 + .../skate-core/src/physics/board_animation.rs | 123 + .../src/physics/board_dynamic_normal.rs | 120 + crates/skate-core/src/physics/board_ground.rs | 312 + .../src/physics/board_input_output.rs | 111 + .../src/physics/board_motion_output.rs | 78 + crates/skate-core/src/physics/board_pose.rs | 25 + .../src/physics/board_pose/arithmetic.rs | 79 + .../src/physics/board_pose/normalization.rs | 54 + .../skate-core/src/physics/board_pose/part.rs | 107 + crates/skate-core/src/physics/board_probes.rs | 154 + .../skate-core/src/physics/board_runtime.rs | 403 ++ crates/skate-core/src/physics/board_step.rs | 320 + .../skate-core/src/physics/board_toolkit.rs | 144 + crates/skate-core/src/physics/board_world.rs | 336 + .../src/physics/board_world/query_metadata.rs | 139 + .../src/physics/centre_of_mass_filter.rs | 83 + .../src/physics/collision/arithmetic.rs | 39 + .../skate-core/src/physics/collision/edge.rs | 21 + .../skate-core/src/physics/collision/fixup.rs | 238 + .../skate-core/src/physics/collision/mod.rs | 13 + .../src/physics/collision/sphere_triangle.rs | 249 + .../src/physics/collision/world_contact.rs | 95 + .../src/physics/constraint_batch.rs | 20 + .../src/physics/constraint_frames.rs | 190 + .../construction/authored_transforms.rs | 85 + .../src/physics/construction/board_parts.rs | 104 + .../src/physics/construction/deck_geometry.rs | 331 + .../src/physics/construction/mass_moments.rs | 127 + .../physics/construction/primitive_mass.rs | 142 + .../physics/construction/principal_axes.rs | 84 + crates/skate-core/src/physics/contact.rs | 330 + .../physics/contact_feedback/board_reports.rs | 144 + .../contact_feedback/collision_info.rs | 73 + .../physics/contact_feedback/compression.rs | 111 + .../src/physics/contact_feedback/mod.rs | 20 + .../src/physics/contact_feedback/reports.rs | 130 + .../src/physics/contact_feedback/spy.rs | 63 + .../skate-core/src/physics/contact_layout.rs | 39 + .../skate-core/src/physics/contact_solver.rs | 87 + .../src/physics/deck_angular_correction.rs | 218 + crates/skate-core/src/physics/drive_frames.rs | 431 ++ .../src/physics/drive_parameters.rs | 189 + .../src/physics/drive_preparation/mod.rs | 38 + crates/skate-core/src/physics/drive_solver.rs | 128 + .../src/physics/drive_solver/builder.rs | 296 + .../skate-core/src/physics/filtered_state.rs | 164 + .../src/physics/foot_physical_output.rs | 136 + crates/skate-core/src/physics/force_queue.rs | 100 + .../src/physics/ground_hang_geometry.rs | 189 + .../src/physics/hook_drive/frames.rs | 71 + .../skate-core/src/physics/hook_drive/mod.rs | 92 + .../skate-core/src/physics/joint_builder.rs | 447 ++ .../src/physics/joint_builder/arithmetic.rs | 259 + .../skate-core/src/physics/joint_records.rs | 241 + crates/skate-core/src/physics/joint_solver.rs | 82 + crates/skate-core/src/physics/manual.rs | 7 + crates/skate-core/src/physics/manual/angle.rs | 30 + .../src/physics/manual/controller.rs | 213 + .../skate-core/src/physics/manual/settings.rs | 42 + crates/skate-core/src/physics/manual/state.rs | 60 + crates/skate-core/src/physics/mass.rs | 354 + crates/skate-core/src/physics/mod.rs | 70 + .../src/physics/native_arithmetic.rs | 33 + crates/skate-core/src/physics/point_force.rs | 51 + .../skate-core/src/physics/reciprocal_sqrt.rs | 5 + crates/skate-core/src/physics/rigid_body.rs | 431 ++ .../src/physics/rigid_body/dynamic_update.rs | 192 + .../src/physics/skateboard_controller.rs | 47 + .../src/physics/skeleton_air_frames.rs | 105 + .../src/physics/skeleton_animation_record.rs | 205 + .../src/physics/skeleton_board_frames.rs | 152 + .../src/physics/skeleton_board_offset.rs | 107 + .../skeleton_body/collision_feedback.rs | 223 + .../physics/skeleton_body/collision_filter.rs | 71 + .../physics/skeleton_body/collision_mode.rs | 342 + .../physics/skeleton_body/collision_update.rs | 356 + .../physics/skeleton_body/collision_vector.rs | 66 + .../src/physics/skeleton_body/construction.rs | 181 + .../physics/skeleton_body/drive_dynamics.rs | 134 + .../src/physics/skeleton_body/drive_frames.rs | 42 + .../src/physics/skeleton_body/drives.rs | 226 + .../src/physics/skeleton_body/errors.rs | 97 + .../src/physics/skeleton_body/joints.rs | 156 + .../src/physics/skeleton_body/mod.rs | 38 + .../src/physics/skeleton_body/record.rs | 68 + .../src/physics/skeleton_body/runtime.rs | 142 + .../physics/skeleton_body/target_update.rs | 105 + .../src/physics/skeleton_body/targets.rs | 154 + .../skeleton_body/tests/collision_feedback.rs | 134 + .../src/physics/skeleton_general.rs | 87 + .../src/physics/skeleton_landing.rs | 250 + .../skate-core/src/physics/skeleton_motion.rs | 96 + .../src/physics/skeleton_output/board.rs | 179 + .../src/physics/skeleton_output/correction.rs | 79 + .../src/physics/skeleton_output/mod.rs | 67 + .../src/physics/skeleton_output/tests.rs | 111 + .../src/physics/skeleton_output/wobble.rs | 97 + .../skate-core/src/physics/skeleton_root.rs | 174 + crates/skate-core/src/physics/solver.rs | 66 + .../skate-core/src/physics/solver/contact.rs | 73 + .../src/physics/solver/contact_build.rs | 95 + .../physics/solver/contact_build/geometry.rs | 119 + .../src/physics/solver/contact_build/input.rs | 58 + .../solver/contact_build/publication.rs | 94 + .../src/physics/solver/contact_data.rs | 121 + crates/skate-core/src/physics/solver/drive.rs | 65 + crates/skate-core/src/physics/solver/joint.rs | 75 + .../src/physics/solver/joint_data.rs | 171 + .../skate-core/src/physics/solver/packed.rs | 58 + .../skate-core/src/physics/solver/packing.rs | 136 + crates/skate-core/src/physics/startup/mod.rs | 7 + .../skate-core/src/physics/startup/reset.rs | 76 + .../src/physics/startup/skateboard.rs | 61 + .../skate-core/src/physics/tests/assembly.rs | 134 + .../src/physics/tests/board_ground.rs | 132 + .../src/physics/tests/board_runtime.rs | 207 + .../src/physics/tests/board_step.rs | 331 + .../src/physics/tests/board_world.rs | 261 + .../skate-core/src/physics/tests/contact.rs | 134 + .../src/physics/tests/contact_reports.rs | 102 + .../src/physics/tests/contact_solver.rs | 1 + .../src/physics/tests/deck_geometry.rs | 174 + .../src/physics/tests/drive_frames.rs | 102 + .../src/physics/tests/drive_solver.rs | 213 + .../src/physics/tests/filtered_state.rs | 52 + .../skate-core/src/physics/tests/hook_step.rs | 67 + .../src/physics/tests/joint_builder.rs | 131 + .../src/physics/tests/joint_records.rs | 146 + .../src/physics/tests/joint_solver.rs | 38 + .../src/physics/tests/mass_moments.rs | 141 + .../src/physics/tests/native_arithmetic.rs | 22 + .../src/physics/tests/primitive_query.rs | 277 + .../src/physics/tests/rigid_body.rs | 274 + .../tests/skeleton_animation_record.rs | 55 + .../physics/tests/skeleton_board_offset.rs | 32 + .../src/physics/tests/skeleton_landing.rs | 40 + .../src/physics/tests/triangle_query.rs | 114 + .../src/physics/tests/truck_frames.rs | 16 + .../src/physics/tests/world_dispatch.rs | 351 + .../src/physics/triangle_closest.rs | 92 + .../skate-core/src/physics/triangle_query.rs | 123 + .../skate-core/src/physics/triangle_sweep.rs | 104 + .../src/physics/triangle_sweep_roots.rs | 89 + .../src/physics/triangle_sweep_walk.rs | 130 + crates/skate-core/src/physics/truck_frames.rs | 46 + .../src/physics/world_contact/arithmetic.rs | 25 + .../src/physics/world_contact/axes.rs | 120 + .../src/physics/world_contact/box_box_sat.rs | 51 + .../src/physics/world_contact/box_feature.rs | 195 + .../src/physics/world_contact/buffer.rs | 95 + .../src/physics/world_contact/dispatch.rs | 79 + .../src/physics/world_contact/face_clip.rs | 106 + .../src/physics/world_contact/face_edge.rs | 111 + .../src/physics/world_contact/face_face.rs | 153 + .../physics/world_contact/face_specialized.rs | 116 + .../src/physics/world_contact/feature.rs | 79 + .../src/physics/world_contact/mod.rs | 48 + .../physics/world_contact/point_feature.rs | 78 + .../world_contact/primitive_geometry.rs | 164 + .../physics/world_contact/primitive_pair.rs | 278 + .../physics/world_contact/primitive_query.rs | 182 + .../src/physics/world_contact/prism_math.rs | 57 + .../src/physics/world_contact/projection.rs | 128 + .../src/physics/world_contact/reduction.rs | 152 + .../src/physics/world_contact/segment_face.rs | 92 + .../physics/world_contact/segment_prism.rs | 104 + .../physics/world_contact/triangle_box_sat.rs | 67 + .../physics/world_contact/triangle_feature.rs | 134 + crates/skate-core/src/player/adjust.rs | 65 + .../src/player/conditioner_capabilities.rs | 40 + .../player/conditioner_capabilities/tests.rs | 35 + crates/skate-core/src/player/frame.rs | 225 + .../src/player/input_phase/air_output.rs | 37 + .../skate-core/src/player/input_phase/mod.rs | 21 + .../src/player/input_phase/motion_math.rs | 58 + .../src/player/input_phase/pose_output.rs | 40 + .../src/player/input_phase/publication.rs | 340 + .../src/player/input_phase/runtime.rs | 368 + .../src/player/input_phase/tests.rs | 460 ++ .../src/player/input_phase/types.rs | 473 ++ crates/skate-core/src/player/lifecycle.rs | 229 + crates/skate-core/src/player/logic.rs | 96 + crates/skate-core/src/player/mod.rs | 24 + .../src/player/offboard/board_possession.rs | 59 + .../offboard/board_possession/lifecycle.rs | 132 + .../offboard/board_possession/manager.rs | 38 + .../player/offboard/board_possession/math.rs | 46 + .../offboard/board_possession/motion.rs | 79 + .../skate-core/src/player/offboard/cadence.rs | 303 + .../src/player/offboard/cadence/tests.rs | 180 + .../src/player/offboard/contact_correction.rs | 91 + .../offboard/contact_correction/tests.rs | 51 + .../src/player/offboard/controller.rs | 87 + .../src/player/offboard/controller/output.rs | 66 + .../player/offboard/controller/placement.rs | 61 + .../src/player/offboard/controller/state.rs | 132 + .../src/player/offboard/controller/step.rs | 194 + .../src/player/offboard/controller/tests.rs | 190 + .../src/player/offboard/ground_branch.rs | 61 + .../player/offboard/ground_branch/tests.rs | 44 + .../src/player/offboard/ground_entry.rs | 86 + .../src/player/offboard/ground_entry/math.rs | 40 + .../src/player/offboard/ground_entry/tests.rs | 42 + .../src/player/offboard/ground_input.rs | 71 + .../src/player/offboard/ground_input/math.rs | 133 + .../src/player/offboard/ground_input/tests.rs | 185 + .../src/player/offboard/ground_lifecycle.rs | 24 + .../offboard/ground_lifecycle/collision.rs | 66 + .../player/offboard/ground_lifecycle/exit.rs | 20 + .../player/offboard/ground_lifecycle/post.rs | 54 + .../offboard/ground_lifecycle/publication.rs | 65 + .../player/offboard/ground_lifecycle/tests.rs | 215 + .../src/player/offboard/ground_motion.rs | 90 + .../player/offboard/ground_motion/approach.rs | 120 + .../src/player/offboard/ground_motion/math.rs | 130 + .../offboard/ground_motion/publication.rs | 28 + .../player/offboard/ground_motion/support.rs | 101 + .../player/offboard/ground_motion/tests.rs | 270 + .../src/player/offboard/ground_query.rs | 118 + .../player/offboard/ground_query/consume.rs | 129 + .../player/offboard/ground_query/geometry.rs | 62 + .../src/player/offboard/ground_query/math.rs | 63 + .../player/offboard/ground_query/packet.rs | 56 + .../src/player/offboard/ground_query/tests.rs | 138 + .../src/player/offboard/ground_reckoning.rs | 101 + .../player/offboard/ground_reckoning/tests.rs | 98 + .../src/player/offboard/ground_sync.rs | 217 + .../src/player/offboard/ground_sync/math.rs | 21 + .../src/player/offboard/ground_sync/tests.rs | 130 + crates/skate-core/src/player/offboard/mod.rs | 16 + .../src/player/offboard/movement_intent.rs | 134 + .../player/offboard/movement_intent/math.rs | 56 + .../player/offboard/movement_intent/tests.rs | 95 + .../src/player/offboard/movement_velocity.rs | 115 + .../player/offboard/movement_velocity/math.rs | 41 + .../offboard/movement_velocity/tests.rs | 95 + .../src/player/offboard/pose_adjust.rs | 40 + .../src/player/offboard/pose_adjust/tests.rs | 25 + .../src/player/offboard/position_output.rs | 109 + .../player/offboard/position_output/tests.rs | 61 + .../src/player/offboard/reset_animation.rs | 48 + .../player/offboard/reset_animation/tests.rs | 32 + .../skate-core/src/player/offboard/slide.rs | 92 + .../src/player/offboard/slide/tests.rs | 56 + .../src/player/offboard/surface_frame.rs | 85 + .../src/player/offboard/surface_frame/math.rs | 54 + .../player/offboard/surface_frame/tests.rs | 42 + .../src/player/offboard/twist_lean.rs | 50 + .../src/player/offboard/twist_lean/tests.rs | 31 + crates/skate-core/src/player/post_input.rs | 219 + crates/skate-core/src/player/post_output.rs | 33 + crates/skate-core/src/player/post_state.rs | 18 + crates/skate-core/src/player/pre_state.rs | 75 + crates/skate-core/src/player/selector/air.rs | 150 + .../src/player/selector/conditions.rs | 57 + .../skate-core/src/player/selector/ground.rs | 142 + .../skate-core/src/player/selector/input.rs | 73 + crates/skate-core/src/player/selector/mod.rs | 250 + .../src/player/selector/offboard.rs | 110 + .../skate-core/src/player/selector/tests.rs | 270 + .../src/player/slide_state/forces.rs | 249 + .../skate-core/src/player/slide_state/mod.rs | 34 + crates/skate-core/src/player/state.rs | 185 + crates/skate-core/src/player/state_phase.rs | 62 + .../skate-core/src/player/teleport_state.rs | 71 + crates/skate-core/src/player/tests/adjust.rs | 117 + crates/skate-core/src/player/tests/frame.rs | 205 + .../skate-core/src/player/tests/lifecycle.rs | 378 ++ crates/skate-core/src/player/tests/logic.rs | 180 + .../skate-core/src/player/tests/post_input.rs | 191 + .../src/player/tests/post_output.rs | 30 + .../skate-core/src/player/tests/post_state.rs | 21 + .../skate-core/src/player/tests/pre_state.rs | 80 + .../src/player/tests/state_phase.rs | 117 + crates/skate-core/src/player/wipeout/air.rs | 95 + .../skate-core/src/player/wipeout/common.rs | 60 + crates/skate-core/src/player/wipeout/data.rs | 109 + .../skate-core/src/player/wipeout/ground.rs | 128 + crates/skate-core/src/player/wipeout/mod.rs | 14 + .../skate-core/src/player/wipeout/requests.rs | 71 + crates/skate-core/src/player/wipeout/tests.rs | 230 + .../src/player/wipeout_state/body.rs | 73 + .../player/wipeout_state/contact_response.rs | 85 + .../contact_response/material10.rs | 105 + .../contact_response/material11.rs | 113 + .../src/player/wipeout_state/control_air.rs | 39 + .../player/wipeout_state/control_ground.rs | 43 + .../wipeout_state/control_profile_air.rs | 155 + .../src/player/wipeout_state/data.rs | 90 + .../src/player/wipeout_state/drives.rs | 108 + .../src/player/wipeout_state/math.rs | 47 + .../src/player/wipeout_state/mod.rs | 18 + .../src/player/wipeout_state/orientation.rs | 59 + .../src/player/wipeout_state/output.rs | 69 + .../src/player/wipeout_state/profiles.rs | 70 + .../src/player/wipeout_state/recovery.rs | 82 + .../src/player/wipeout_state/response.rs | 74 + .../src/player/wipeout_state/skeleton.rs | 36 + .../src/player/wipeout_state/torque.rs | 32 + .../src/player/wipeout_state/weights.rs | 65 + crates/skate-core/src/point_graph.rs | 34 + crates/skate-core/src/riding/anti_flip.rs | 49 + crates/skate-core/src/riding/braking.rs | 96 + .../src/riding/collision_response.rs | 189 + .../src/riding/ground_contact_response.rs | 148 + .../src/riding/ground_correction_math.rs | 184 + crates/skate-core/src/riding/ground_force.rs | 43 + .../src/riding/ground_orientation.rs | 190 + crates/skate-core/src/riding/grounded/drag.rs | 103 + .../src/riding/grounded/manual_entry.rs | 111 + crates/skate-core/src/riding/grounded/mod.rs | 6 + crates/skate-core/src/riding/grounded/mode.rs | 24 + .../src/riding/grounded/propulsion.rs | 136 + .../src/riding/grounded/state/board.rs | 160 + .../src/riding/grounded/state/board_types.rs | 246 + .../riding/grounded/state/contact_state.rs | 210 + .../src/riding/grounded/state/corrections.rs | 392 ++ .../src/riding/grounded/state/data.rs | 207 + .../src/riding/grounded/state/lifecycle.rs | 243 + .../src/riding/grounded/state/mod.rs | 19 + .../src/riding/grounded/state/motion.rs | 89 + .../src/riding/grounded/state/ordinary.rs | 293 + .../src/riding/grounded/state/output.rs | 321 + .../src/riding/grounded/state/post_physics.rs | 43 + .../src/riding/grounded/state/update.rs | 428 ++ crates/skate-core/src/riding/heading.rs | 114 + crates/skate-core/src/riding/mod.rs | 23 + crates/skate-core/src/riding/pumping.rs | 31 + .../src/riding/pumping/controller.rs | 174 + .../skate-core/src/riding/pumping/geometry.rs | 69 + .../skate-core/src/riding/pumping/settings.rs | 37 + crates/skate-core/src/riding/pumping/state.rs | 83 + crates/skate-core/src/riding/push.rs | 112 + .../skate-core/src/riding/push_animation.rs | 100 + .../skate-core/src/riding/push_behaviors.rs | 365 + .../skate-core/src/riding/reckoning_frames.rs | 196 + .../skate-core/src/riding/slide_friction.rs | 46 + .../skate-core/src/riding/speed_and_slope.rs | 62 + crates/skate-core/src/riding/speed_model.rs | 188 + crates/skate-core/src/riding/speed_wobble.rs | 101 + crates/skate-core/src/riding/steering.rs | 130 + crates/skate-core/src/riding/straighten.rs | 37 + .../src/riding/tests/ground_orientation.rs | 39 + crates/skate-core/src/riding/tests/push.rs | 82 + .../src/riding/tests/push_behaviors.rs | 222 + .../src/riding/tests/speed_and_slope.rs | 31 + .../skate-core/src/riding/tests/steering.rs | 104 + crates/skate-core/src/riding/vector.rs | 40 + crates/skate-core/src/trigonometry.rs | 141 + .../tests/animation_actor_composition.rs | 620 ++ crates/skate-core/tests/animation_commands.rs | 358 + .../tests/animation_output_bounds.rs | 23 + .../tests/animation_packet_merge.rs | 125 + .../tests/animation_packet_reset.rs | 138 + .../tests/animation_physics_publication.rs | 219 + .../tests/animation_skeleton_attributes.rs | 237 + crates/skate-core/tests/constraint_batch.rs | 35 + crates/skate-core/tests/contact_iteration.rs | 136 + .../physics_rebuild_authored_transforms.rs | 128 + .../tests/physics_rebuild_constraints.rs | 436 ++ .../tests/physics_rebuild_contact_build.rs | 273 + .../skate-core/tests/physics_rebuild_mass.rs | 297 + crates/skate-core/tests/point_graph.rs | 45 + .../tests/riding_grounded_propulsion.rs | 382 ++ crates/skate-core/tests/riding_manual.rs | 374 ++ .../skate-core/tests/riding_manual_entry.rs | 248 + .../skate-core/tests/riding_mode_binding.rs | 19 + .../tests/riding_pumping_controller.rs | 338 + .../skate-core/tests/wheel_ground_solver.rs | 190 + crates/skate-data/Cargo.toml | 10 + crates/skate-data/src/abin/bank.rs | 316 + crates/skate-data/src/abin/mod.rs | 168 + crates/skate-data/src/abin/reader.rs | 113 + crates/skate-data/src/animation_banks.rs | 46 + crates/skate-data/src/animation_frames.rs | 252 + .../skate-data/src/animation_frames/native.rs | 231 + crates/skate-data/src/animation_metadata.rs | 376 ++ .../src/animation_metadata/native.rs | 153 + .../src/animation_metadata/selection_space.rs | 111 + crates/skate-data/src/collections.rs | 137 + crates/skate-data/src/input_config.rs | 100 + crates/skate-data/src/lib.rs | 13 + crates/skate-data/src/manifest.rs | 79 + crates/skate-data/src/physics_skeleton.rs | 64 + crates/skate-data/src/sha256.rs | 79 + crates/skate-data/src/state_graph.rs | 143 + .../skate-data/src/state_graph/attributes.rs | 84 + crates/skate-data/src/state_graph/binding.rs | 426 ++ crates/skate-data/src/tests/input_config.rs | 36 + crates/skate-data/tests/graph_attributes.rs | 27 + crates/skate-data/tests/graph_binding.rs | 111 + crates/skate-data/tests/graph_operations.rs | 122 + crates/skate-data/tests/manifest.rs | 61 + crates/skate-data/tests/state_graph.rs | 76 + crates/skate-game/Cargo.toml | 10 + crates/skate-game/src/animation.rs | 141 + crates/skate-game/src/animation_pose.rs | 195 + crates/skate-game/src/app.rs | 100 + crates/skate-game/src/assets.rs | 46 + crates/skate-game/src/camera.rs | 67 + crates/skate-game/src/camera/collision.rs | 66 + crates/skate-game/src/camera/graph.rs | 117 + .../skate-game/src/camera/graph_conditions.rs | 131 + crates/skate-game/src/camera/graph_subject.rs | 34 + crates/skate-game/src/camera/publication.rs | 343 + crates/skate-game/src/camera/runtime.rs | 91 + crates/skate-game/src/camera/settings.rs | 207 + crates/skate-game/src/camera/shake_data.rs | 19 + crates/skate-game/src/camera/shot_data.rs | 178 + crates/skate-game/src/camera/stock_names.rs | 42 + crates/skate-game/src/camera/subject.rs | 62 + crates/skate-game/src/camera/tests/runtime.rs | 10 + crates/skate-game/src/camera/trajectory.rs | 47 + crates/skate-game/src/camera/world_query.rs | 37 + crates/skate-game/src/config.rs | 55 + crates/skate-game/src/graph_host/action.rs | 349 + .../src/graph_host/action_conditions.rs | 88 + .../skate-game/src/graph_host/action_nodes.rs | 225 + .../src/graph_host/condition_nodes.rs | 151 + .../src/graph_host/crouching_settings.rs | 17 + crates/skate-game/src/graph_host/mod.rs | 38 + crates/skate-game/src/graph_host/motion.rs | 361 + .../src/graph_host/motion_air_execute.rs | 100 + .../src/graph_host/motion_air_leg.rs | 46 + .../src/graph_host/motion_animation.rs | 439 ++ .../motion_animation/selection_space_host.rs | 50 + .../motion_animation/tree_builder.rs | 90 + .../src/graph_host/motion_channels.rs | 277 + .../graph_host/motion_character_gesture.rs | 334 + .../src/graph_host/motion_conditions.rs | 351 + .../src/graph_host/motion_execute.rs | 503 ++ .../src/graph_host/motion_filter_execute.rs | 39 + .../graph_host/motion_gameplay_conditions.rs | 175 + .../src/graph_host/motion_ground_slope.rs | 34 + .../src/graph_host/motion_hand_services.rs | 90 + .../graph_host/motion_hand_services/tests.rs | 89 + .../skate-game/src/graph_host/motion_hooks.rs | 32 + .../src/graph_host/motion_intent_filter.rs | 72 + .../src/graph_host/motion_kickturn.rs | 80 + .../src/graph_host/motion_landing.rs | 113 + .../src/graph_host/motion_landing_execute.rs | 115 + .../src/graph_host/motion_native.rs | 138 + .../skate-game/src/graph_host/motion_nodes.rs | 242 + .../src/graph_host/motion_offboard/cadence.rs | 99 + .../motion_offboard/cadence/tests.rs | 63 + .../graph_host/motion_offboard/push_off.rs | 20 + .../src/graph_host/motion_offboard/reset.rs | 45 + .../graph_host/motion_offboard/twist_lean.rs | 49 + .../src/graph_host/motion_riding.rs | 230 + .../graph_host/motion_riding_conditions.rs | 167 + .../skate-game/src/graph_host/motion_shove.rs | 213 + .../src/graph_host/motion_sliding.rs | 251 + .../src/graph_host/motion_sliding_execute.rs | 139 + .../skate-game/src/graph_host/motion_spin.rs | 297 + .../src/graph_host/motion_wipeout.rs | 124 + .../src/graph_host/motion_wipeout_execute.rs | 194 + .../src/graph_host/pumping_settings.rs | 8 + crates/skate-game/src/graph_host/pushing.rs | 234 + .../src/graph_host/pushing_settings.rs | 188 + .../src/graph_host/tests/motion_slide.rs | 251 + .../src/graph_host/tests/motion_stock.rs | 158 + .../src/graph_host/tests/pushing.rs | 387 ++ .../src/graph_host/tests/pushing_metadata.rs | 23 + .../src/graph_host/turning_settings.rs | 35 + crates/skate-game/src/graph_runtime.rs | 329 + crates/skate-game/src/input.rs | 42 + crates/skate-game/src/input/controllers.rs | 98 + .../skate-game/src/input/gesture_catalog.rs | 153 + crates/skate-game/src/input/platform.rs | 104 + .../skate-game/src/input/tests/controllers.rs | 112 + crates/skate-game/src/main.rs | 60 + crates/skate-game/src/physics.rs | 312 + crates/skate-game/src/physics/air_phase.rs | 149 + .../skate-game/src/physics/air_phase/board.rs | 23 + .../skate-game/src/physics/air_phase/input.rs | 78 + .../src/physics/air_phase/skeleton.rs | 25 + .../skate-game/src/physics/air_reckoning.rs | 84 + .../src/physics/air_reckoning/settings.rs | 85 + .../src/physics/air_trajectory/mod.rs | 76 + .../src/physics/air_trajectory/settings.rs | 122 + .../src/physics/air_trajectory/world.rs | 142 + .../src/physics/animated_skeleton.rs | 244 + .../src/physics/animation_feedback.rs | 75 + .../physics/animation_feedback_settings.rs | 60 + .../skate-game/src/physics/animation_input.rs | 139 + .../skate-game/src/physics/animation_phase.rs | 340 + .../src/physics/animation_phase_packet.rs | 199 + .../skate-game/src/physics/camera_output.rs | 175 + crates/skate-game/src/physics/clock.rs | 70 + crates/skate-game/src/physics/colliders.rs | 148 + crates/skate-game/src/physics/controls.rs | 81 + crates/skate-game/src/physics/foot_ik.rs | 229 + .../skate-game/src/physics/foot_ik_queries.rs | 151 + .../src/physics/foot_physical_output.rs | 36 + crates/skate-game/src/physics/footplant.rs | 126 + .../skate-game/src/physics/footplant/math.rs | 74 + .../skate-game/src/physics/footplant/pose.rs | 68 + .../src/physics/footplant/settings.rs | 23 + .../src/physics/footplant/update.rs | 161 + crates/skate-game/src/physics/frame.rs | 147 + crates/skate-game/src/physics/ground.rs | 196 + .../src/physics/ground_animation.rs | 84 + .../src/physics/ground_animation/board.rs | 192 + .../src/physics/ground_animation/settings.rs | 66 + .../src/physics/ground_animation/skeleton.rs | 27 + crates/skate-game/src/physics/ground_exit.rs | 18 + crates/skate-game/src/physics/ground_phase.rs | 289 + .../src/physics/ground_runtime/corrections.rs | 109 + .../src/physics/ground_runtime/entry.rs | 153 + .../src/physics/ground_runtime/input.rs | 274 + .../src/physics/ground_runtime/launch.rs | 83 + .../src/physics/ground_runtime/mod.rs | 170 + .../src/physics/ground_runtime/pumping.rs | 125 + .../src/physics/ground_runtime/services.rs | 293 + .../src/physics/ground_runtime/settings.rs | 277 + .../src/physics/ground_runtime/state.rs | 103 + .../src/physics/ground_runtime/surface.rs | 30 + .../src/physics/ground_runtime/trajectory.rs | 22 + .../src/physics/ground_runtime/update.rs | 121 + .../src/physics/ground_runtime/world_grab.rs | 59 + crates/skate-game/src/physics/input_phase.rs | 214 + .../skate-game/src/physics/input_teleport.rs | 150 + crates/skate-game/src/physics/known_air.rs | 200 + .../skate-game/src/physics/known_air/flip.rs | 48 + .../skate-game/src/physics/known_air/math.rs | 106 + .../src/physics/known_air/output.rs | 68 + .../src/physics/known_air/runtime.rs | 364 + .../src/physics/known_air/settings.rs | 50 + .../skate-game/src/physics/landing_quality.rs | 17 + .../src/physics/offboard/ground_query.rs | 83 + .../physics/offboard/ground_query/edges.rs | 71 + .../physics/offboard/ground_query/lines.rs | 99 + .../physics/offboard/ground_query/tests.rs | 132 + .../offboard/ground_query/transform.rs | 66 + .../physics/offboard/ground_query/world.rs | 36 + crates/skate-game/src/physics/offboard/mod.rs | 3 + .../src/physics/offboard/pose_adjust.rs | 30 + .../src/physics/offboard/settings.rs | 67 + .../src/physics/offboard/settings/board.rs | 31 + .../src/physics/offboard/settings/curves.rs | 41 + .../src/physics/offboard/settings/metrics.rs | 45 + .../src/physics/offboard/settings/tests.rs | 88 + .../src/physics/offboard/skeleton_ground.rs | 81 + .../offboard/skeleton_ground/frames.rs | 33 + .../offboard/skeleton_ground/frames_tests.rs | 21 + .../physics/player_input/dynamic_normal.rs | 20 + .../src/physics/player_input/grind.rs | 131 + .../physics/player_input/ground_position.rs | 70 + .../src/physics/player_input/initial.rs | 28 + .../src/physics/player_input/mod.rs | 166 + .../src/physics/player_input/output_reset.rs | 43 + .../src/physics/player_input/pre_input.rs | 67 + .../src/physics/player_input/reset.rs | 60 + .../src/physics/player_input/services.rs | 177 + crates/skate-game/src/physics/player_state.rs | 132 + .../src/physics/player_state/post_input.rs | 231 + .../src/physics/player_state/pre_state.rs | 52 + .../src/physics/player_state/publication.rs | 181 + .../src/physics/player_state/selection.rs | 55 + .../src/physics/player_state/transition.rs | 165 + .../physics/player_state/wipeout_output.rs | 45 + crates/skate-game/src/physics/render_pose.rs | 100 + .../skate-game/src/physics/riding_outputs.rs | 350 + .../src/physics/riding_outputs/probes.rs | 120 + crates/skate-game/src/physics/settings.rs | 149 + .../src/physics/skateboard_controller.rs | 12 + crates/skate-game/src/physics/skater.rs | 243 + crates/skate-game/src/physics/skeleton_air.rs | 178 + .../skate-game/src/physics/skeleton_body.rs | 309 + .../skeleton_body_collision_settings.rs | 75 + .../physics/skeleton_body_drive_settings.rs | 56 + .../src/physics/skeleton_colliders.rs | 80 + .../src/physics/skeleton_controller.rs | 84 + .../src/physics/skeleton_feedback.rs | 177 + .../src/physics/skeleton_input_general.rs | 146 + .../src/physics/skeleton_input_runtime.rs | 231 + .../src/physics/skeleton_input_teleport.rs | 90 + .../skate-game/src/physics/skeleton_output.rs | 177 + crates/skate-game/src/physics/slide_state.rs | 72 + .../src/physics/slide_state/settings.rs | 61 + .../src/physics/slide_state/update.rs | 232 + crates/skate-game/src/physics/solve.rs | 95 + .../src/physics/solve/assembly_contacts.rs | 96 + .../skate-game/src/physics/teleport_state.rs | 73 + crates/skate-game/src/physics/tests/clock.rs | 56 + crates/skate-game/src/physics/wipeout.rs | 98 + .../src/physics/wipeout/observations.rs | 95 + .../src/physics/wipeout/settings.rs | 79 + .../src/physics/wipeout_states/contact.rs | 61 + .../physics/wipeout_states/drive_settings.rs | 39 + .../src/physics/wipeout_states/lifecycle.rs | 130 + .../src/physics/wipeout_states/mod.rs | 52 + .../src/physics/wipeout_states/output.rs | 18 + .../src/physics/wipeout_states/prediction.rs | 86 + .../src/physics/wipeout_states/ragdoll.rs | 71 + .../wipeout_states/ragdoll/settings.rs | 97 + .../src/physics/wipeout_states/settings.rs | 100 + .../src/physics/wipeout_states/skeleton.rs | 93 + .../src/physics/wipeout_states/update.rs | 228 + crates/skate-game/src/skater_animation.rs | 278 + .../skate-game/src/skater_animation/state.rs | 87 + crates/skate-game/src/tests/action_host.rs | 184 + crates/skate-game/src/tests/air_playback.rs | 174 + crates/skate-game/src/tests/animation_pose.rs | 133 + crates/skate-game/src/tests/graph_runtime.rs | 105 + .../skate-game/src/tests/physics_startup.rs | 456 ++ .../skate-game/src/tests/wipeout_playback.rs | 221 + crates/skate-game/src/verification.rs | 93 + crates/skate-game/src/world.rs | 71 + 816 files changed, 105573 insertions(+) create mode 100644 .gitignore create mode 100644 BUILD.bat create mode 100644 Build.ps1 create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Launch.ps1 create mode 100644 PLAY.bat create mode 100644 README.md create mode 100644 SETUP-PROVENANCE.json create mode 100644 crates/skate-core/Cargo.toml create mode 100644 crates/skate-core/src/air/body_flip.rs create mode 100644 crates/skate-core/src/air/body_spin.rs create mode 100644 crates/skate-core/src/air/ground_jump/math.rs create mode 100644 crates/skate-core/src/air/ground_jump/mod.rs create mode 100644 crates/skate-core/src/air/ground_jump/types.rs create mode 100644 crates/skate-core/src/air/ground_normal.rs create mode 100644 crates/skate-core/src/air/known/body.rs create mode 100644 crates/skate-core/src/air/known/data.rs create mode 100644 crates/skate-core/src/air/known/footplant.rs create mode 100644 crates/skate-core/src/air/known/lifecycle.rs create mode 100644 crates/skate-core/src/air/known/mod.rs create mode 100644 crates/skate-core/src/air/known/output.rs create mode 100644 crates/skate-core/src/air/known/post_physics.rs create mode 100644 crates/skate-core/src/air/known/runtime.rs create mode 100644 crates/skate-core/src/air/known/tests/mock.rs create mode 100644 crates/skate-core/src/air/known/tests/mod.rs create mode 100644 crates/skate-core/src/air/known/trajectory.rs create mode 100644 crates/skate-core/src/air/known/update.rs create mode 100644 crates/skate-core/src/air/mod.rs create mode 100644 crates/skate-core/src/air/reckoning/data.rs create mode 100644 crates/skate-core/src/air/reckoning/math.rs create mode 100644 crates/skate-core/src/air/reckoning/mod.rs create mode 100644 crates/skate-core/src/air/reckoning/tests.rs create mode 100644 crates/skate-core/src/air/state/board.rs create mode 100644 crates/skate-core/src/air/state/data.rs create mode 100644 crates/skate-core/src/air/state/jump_velocity.rs create mode 100644 crates/skate-core/src/air/state/lifecycle.rs create mode 100644 crates/skate-core/src/air/state/math.rs create mode 100644 crates/skate-core/src/air/state/mod.rs create mode 100644 crates/skate-core/src/air/state/post_physics.rs create mode 100644 crates/skate-core/src/air/state/runtime.rs create mode 100644 crates/skate-core/src/air/state/tests/mod.rs create mode 100644 crates/skate-core/src/air/state/update.rs create mode 100644 crates/skate-core/src/air/trajectory/launch.rs create mode 100644 crates/skate-core/src/air/trajectory/math.rs create mode 100644 crates/skate-core/src/air/trajectory/mod.rs create mode 100644 crates/skate-core/src/air/trajectory/prediction.rs create mode 100644 crates/skate-core/src/air/trajectory/query.rs create mode 100644 crates/skate-core/src/air/trajectory/scoring.rs create mode 100644 crates/skate-core/src/air/trajectory/selector.rs create mode 100644 crates/skate-core/src/air/trajectory/types.rs create mode 100644 crates/skate-core/src/animation/air_leg_extension.rs create mode 100644 crates/skate-core/src/animation/body_tilt.rs create mode 100644 crates/skate-core/src/animation/channel_playback.rs create mode 100644 crates/skate-core/src/animation/clip_clock.rs create mode 100644 crates/skate-core/src/animation/commands/batch.rs create mode 100644 crates/skate-core/src/animation/commands/buffers.rs create mode 100644 crates/skate-core/src/animation/commands/mod.rs create mode 100644 crates/skate-core/src/animation/commands/set_data.rs create mode 100644 crates/skate-core/src/animation/crouching.rs create mode 100644 crates/skate-core/src/animation/foot_ik/blend.rs create mode 100644 crates/skate-core/src/animation/foot_ik/contact.rs create mode 100644 crates/skate-core/src/animation/foot_ik/drive.rs create mode 100644 crates/skate-core/src/animation/foot_ik/external.rs create mode 100644 crates/skate-core/src/animation/foot_ik/math.rs create mode 100644 crates/skate-core/src/animation/foot_ik/mod.rs create mode 100644 crates/skate-core/src/animation/foot_ik/physical_solve.rs create mode 100644 crates/skate-core/src/animation/foot_ik/post_contact.rs create mode 100644 crates/skate-core/src/animation/foot_ik/post_physics.rs create mode 100644 crates/skate-core/src/animation/foot_ik/settings.rs create mode 100644 crates/skate-core/src/animation/foot_ik/state.rs create mode 100644 crates/skate-core/src/animation/foot_ik/status.rs create mode 100644 crates/skate-core/src/animation/foot_ik/tests.rs create mode 100644 crates/skate-core/src/animation/foot_ik/transforms.rs create mode 100644 crates/skate-core/src/animation/foot_ik/two_bone.rs create mode 100644 crates/skate-core/src/animation/ground_acceleration.rs create mode 100644 crates/skate-core/src/animation/intent_filter.rs create mode 100644 crates/skate-core/src/animation/intent_filter/tests.rs create mode 100644 crates/skate-core/src/animation/kickturn.rs create mode 100644 crates/skate-core/src/animation/landing_quality.rs create mode 100644 crates/skate-core/src/animation/mod.rs create mode 100644 crates/skate-core/src/animation/output/actor_packet.rs create mode 100644 crates/skate-core/src/animation/output/attributes.rs create mode 100644 crates/skate-core/src/animation/output/hierarchy.rs create mode 100644 crates/skate-core/src/animation/output/intents.rs create mode 100644 crates/skate-core/src/animation/output/mod.rs create mode 100644 crates/skate-core/src/animation/output/motion_graph_packet.rs create mode 100644 crates/skate-core/src/animation/output/packet_reset.rs create mode 100644 crates/skate-core/src/animation/output/physics_packet.rs create mode 100644 crates/skate-core/src/animation/output/setup_physics.rs create mode 100644 crates/skate-core/src/animation/output/sqt.rs create mode 100644 crates/skate-core/src/animation/output/tests/intents.rs create mode 100644 crates/skate-core/src/animation/phase_blend.rs create mode 100644 crates/skate-core/src/animation/physical_feedback.rs create mode 100644 crates/skate-core/src/animation/playback.rs create mode 100644 crates/skate-core/src/animation/playback_attributes.rs create mode 100644 crates/skate-core/src/animation/playback_clip.rs create mode 100644 crates/skate-core/src/animation/playback_parameters.rs create mode 100644 crates/skate-core/src/animation/playback_transition.rs create mode 100644 crates/skate-core/src/animation/playback_tree.rs create mode 100644 crates/skate-core/src/animation/playback_tree/selection_space.rs create mode 100644 crates/skate-core/src/animation/pose_add.rs create mode 100644 crates/skate-core/src/animation/pose_blend.rs create mode 100644 crates/skate-core/src/animation/pose_mirror.rs create mode 100644 crates/skate-core/src/animation/pose_sample.rs create mode 100644 crates/skate-core/src/animation/pose_trajectory.rs create mode 100644 crates/skate-core/src/animation/posture.rs create mode 100644 crates/skate-core/src/animation/posture/tests.rs create mode 100644 crates/skate-core/src/animation/posture/tree_tests.rs create mode 100644 crates/skate-core/src/animation/pumping_channel.rs create mode 100644 crates/skate-core/src/animation/riding_fakie.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/attribute_finalization.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/catalog.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/contact_events.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/extended_attributes.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/mod.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/name.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/process_attributes.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/scalar_attributes.rs create mode 100644 crates/skate-core/src/animation/skeleton_input/tests.rs create mode 100644 crates/skate-core/src/animation/tests/kickturn.rs create mode 100644 crates/skate-core/src/animation/tests/pose_blend.rs create mode 100644 crates/skate-core/src/animation/tests/pose_sample_vbr.rs create mode 100644 crates/skate-core/src/animation/vbr/bits.rs create mode 100644 crates/skate-core/src/animation/vbr/header.rs create mode 100644 crates/skate-core/src/animation/vbr/mod.rs create mode 100644 crates/skate-core/src/animation/vbr/tests/bits.rs create mode 100644 crates/skate-core/src/animation/vbr/transform.rs create mode 100644 crates/skate-core/src/camera/anchors.rs create mode 100644 crates/skate-core/src/camera/angle_tracker.rs create mode 100644 crates/skate-core/src/camera/avoidance.rs create mode 100644 crates/skate-core/src/camera/compass.rs create mode 100644 crates/skate-core/src/camera/compass_movement.rs create mode 100644 crates/skate-core/src/camera/compass_orbit.rs create mode 100644 crates/skate-core/src/camera/direction.rs create mode 100644 crates/skate-core/src/camera/drop_predictor.rs create mode 100644 crates/skate-core/src/camera/frame_composer.rs create mode 100644 crates/skate-core/src/camera/look_input.rs create mode 100644 crates/skate-core/src/camera/manager.rs create mode 100644 crates/skate-core/src/camera/manager_air.rs create mode 100644 crates/skate-core/src/camera/manager_blends.rs create mode 100644 crates/skate-core/src/camera/manager_controls.rs create mode 100644 crates/skate-core/src/camera/manager_frame.rs create mode 100644 crates/skate-core/src/camera/manager_incline.rs create mode 100644 crates/skate-core/src/camera/manager_output.rs create mode 100644 crates/skate-core/src/camera/manager_rig.rs create mode 100644 crates/skate-core/src/camera/manager_state.rs create mode 100644 crates/skate-core/src/camera/manager_subject.rs create mode 100644 crates/skate-core/src/camera/mod.rs create mode 100644 crates/skate-core/src/camera/orientation_math.rs create mode 100644 crates/skate-core/src/camera/path_evaluator.rs create mode 100644 crates/skate-core/src/camera/path_prediction.rs create mode 100644 crates/skate-core/src/camera/positioner.rs create mode 100644 crates/skate-core/src/camera/rig.rs create mode 100644 crates/skate-core/src/camera/rig_framing.rs create mode 100644 crates/skate-core/src/camera/rig_initialization.rs create mode 100644 crates/skate-core/src/camera/rig_orientation.rs create mode 100644 crates/skate-core/src/camera/rig_positioning.rs create mode 100644 crates/skate-core/src/camera/rig_tracking.rs create mode 100644 crates/skate-core/src/camera/shake.rs create mode 100644 crates/skate-core/src/camera/shake_curve.rs create mode 100644 crates/skate-core/src/camera/shake_update.rs create mode 100644 crates/skate-core/src/camera/shot.rs create mode 100644 crates/skate-core/src/camera/shot_manager.rs create mode 100644 crates/skate-core/src/camera/shot_orientation.rs create mode 100644 crates/skate-core/src/camera/slow_motion.rs create mode 100644 crates/skate-core/src/camera/subject.rs create mode 100644 crates/skate-core/src/camera/subject_pose.rs create mode 100644 crates/skate-core/src/camera/tracker.rs create mode 100644 crates/skate-core/src/camera/trajectory_query.rs create mode 100644 crates/skate-core/src/camera/vector_tracker.rs create mode 100644 crates/skate-core/src/graph/activation.rs create mode 100644 crates/skate-core/src/graph/conditions.rs create mode 100644 crates/skate-core/src/graph/controller.rs create mode 100644 crates/skate-core/src/graph/expression.rs create mode 100644 crates/skate-core/src/graph/intent_handlers.rs create mode 100644 crates/skate-core/src/graph/intents.rs create mode 100644 crates/skate-core/src/graph/mod.rs create mode 100644 crates/skate-core/src/graph/selection.rs create mode 100644 crates/skate-core/src/graph/tests/conditions.rs create mode 100644 crates/skate-core/src/graph/tests/intent_handlers.rs create mode 100644 crates/skate-core/src/input/angle.rs create mode 100644 crates/skate-core/src/input/animation_packet.rs create mode 100644 crates/skate-core/src/input/body_flip_signal.rs create mode 100644 crates/skate-core/src/input/controller.rs create mode 100644 crates/skate-core/src/input/controller_startup.rs create mode 100644 crates/skate-core/src/input/gameplay_map.rs create mode 100644 crates/skate-core/src/input/graph_intents.rs create mode 100644 crates/skate-core/src/input/history.rs create mode 100644 crates/skate-core/src/input/history_tests.rs create mode 100644 crates/skate-core/src/input/mod.rs create mode 100644 crates/skate-core/src/input/offboard_intentions.rs create mode 100644 crates/skate-core/src/input/offboard_intentions/tests.rs create mode 100644 crates/skate-core/src/input/pad.rs create mode 100644 crates/skate-core/src/input/power_sliding.rs create mode 100644 crates/skate-core/src/input/riding_intentions.rs create mode 100644 crates/skate-core/src/input/riding_intentions_tests.rs create mode 100644 crates/skate-core/src/input/set_turning.rs create mode 100644 crates/skate-core/src/input/steering_intentions.rs create mode 100644 crates/skate-core/src/input/turn_conditioner.rs create mode 100644 crates/skate-core/src/input/turn_remap.rs create mode 100644 crates/skate-core/src/input/wipeout_intentions.rs create mode 100644 crates/skate-core/src/input/xbox.rs create mode 100644 crates/skate-core/src/lib.rs create mode 100644 crates/skate-core/src/math.rs create mode 100644 crates/skate-core/src/physics/assembly.rs create mode 100644 crates/skate-core/src/physics/board.rs create mode 100644 crates/skate-core/src/physics/board_animation.rs create mode 100644 crates/skate-core/src/physics/board_dynamic_normal.rs create mode 100644 crates/skate-core/src/physics/board_ground.rs create mode 100644 crates/skate-core/src/physics/board_input_output.rs create mode 100644 crates/skate-core/src/physics/board_motion_output.rs create mode 100644 crates/skate-core/src/physics/board_pose.rs create mode 100644 crates/skate-core/src/physics/board_pose/arithmetic.rs create mode 100644 crates/skate-core/src/physics/board_pose/normalization.rs create mode 100644 crates/skate-core/src/physics/board_pose/part.rs create mode 100644 crates/skate-core/src/physics/board_probes.rs create mode 100644 crates/skate-core/src/physics/board_runtime.rs create mode 100644 crates/skate-core/src/physics/board_step.rs create mode 100644 crates/skate-core/src/physics/board_toolkit.rs create mode 100644 crates/skate-core/src/physics/board_world.rs create mode 100644 crates/skate-core/src/physics/board_world/query_metadata.rs create mode 100644 crates/skate-core/src/physics/centre_of_mass_filter.rs create mode 100644 crates/skate-core/src/physics/collision/arithmetic.rs create mode 100644 crates/skate-core/src/physics/collision/edge.rs create mode 100644 crates/skate-core/src/physics/collision/fixup.rs create mode 100644 crates/skate-core/src/physics/collision/mod.rs create mode 100644 crates/skate-core/src/physics/collision/sphere_triangle.rs create mode 100644 crates/skate-core/src/physics/collision/world_contact.rs create mode 100644 crates/skate-core/src/physics/constraint_batch.rs create mode 100644 crates/skate-core/src/physics/constraint_frames.rs create mode 100644 crates/skate-core/src/physics/construction/authored_transforms.rs create mode 100644 crates/skate-core/src/physics/construction/board_parts.rs create mode 100644 crates/skate-core/src/physics/construction/deck_geometry.rs create mode 100644 crates/skate-core/src/physics/construction/mass_moments.rs create mode 100644 crates/skate-core/src/physics/construction/primitive_mass.rs create mode 100644 crates/skate-core/src/physics/construction/principal_axes.rs create mode 100644 crates/skate-core/src/physics/contact.rs create mode 100644 crates/skate-core/src/physics/contact_feedback/board_reports.rs create mode 100644 crates/skate-core/src/physics/contact_feedback/collision_info.rs create mode 100644 crates/skate-core/src/physics/contact_feedback/compression.rs create mode 100644 crates/skate-core/src/physics/contact_feedback/mod.rs create mode 100644 crates/skate-core/src/physics/contact_feedback/reports.rs create mode 100644 crates/skate-core/src/physics/contact_feedback/spy.rs create mode 100644 crates/skate-core/src/physics/contact_layout.rs create mode 100644 crates/skate-core/src/physics/contact_solver.rs create mode 100644 crates/skate-core/src/physics/deck_angular_correction.rs create mode 100644 crates/skate-core/src/physics/drive_frames.rs create mode 100644 crates/skate-core/src/physics/drive_parameters.rs create mode 100644 crates/skate-core/src/physics/drive_preparation/mod.rs create mode 100644 crates/skate-core/src/physics/drive_solver.rs create mode 100644 crates/skate-core/src/physics/drive_solver/builder.rs create mode 100644 crates/skate-core/src/physics/filtered_state.rs create mode 100644 crates/skate-core/src/physics/foot_physical_output.rs create mode 100644 crates/skate-core/src/physics/force_queue.rs create mode 100644 crates/skate-core/src/physics/ground_hang_geometry.rs create mode 100644 crates/skate-core/src/physics/hook_drive/frames.rs create mode 100644 crates/skate-core/src/physics/hook_drive/mod.rs create mode 100644 crates/skate-core/src/physics/joint_builder.rs create mode 100644 crates/skate-core/src/physics/joint_builder/arithmetic.rs create mode 100644 crates/skate-core/src/physics/joint_records.rs create mode 100644 crates/skate-core/src/physics/joint_solver.rs create mode 100644 crates/skate-core/src/physics/manual.rs create mode 100644 crates/skate-core/src/physics/manual/angle.rs create mode 100644 crates/skate-core/src/physics/manual/controller.rs create mode 100644 crates/skate-core/src/physics/manual/settings.rs create mode 100644 crates/skate-core/src/physics/manual/state.rs create mode 100644 crates/skate-core/src/physics/mass.rs create mode 100644 crates/skate-core/src/physics/mod.rs create mode 100644 crates/skate-core/src/physics/native_arithmetic.rs create mode 100644 crates/skate-core/src/physics/point_force.rs create mode 100644 crates/skate-core/src/physics/reciprocal_sqrt.rs create mode 100644 crates/skate-core/src/physics/rigid_body.rs create mode 100644 crates/skate-core/src/physics/rigid_body/dynamic_update.rs create mode 100644 crates/skate-core/src/physics/skateboard_controller.rs create mode 100644 crates/skate-core/src/physics/skeleton_air_frames.rs create mode 100644 crates/skate-core/src/physics/skeleton_animation_record.rs create mode 100644 crates/skate-core/src/physics/skeleton_board_frames.rs create mode 100644 crates/skate-core/src/physics/skeleton_board_offset.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/collision_feedback.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/collision_filter.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/collision_mode.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/collision_update.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/collision_vector.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/construction.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/drive_dynamics.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/drive_frames.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/drives.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/errors.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/joints.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/mod.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/record.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/runtime.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/target_update.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/targets.rs create mode 100644 crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs create mode 100644 crates/skate-core/src/physics/skeleton_general.rs create mode 100644 crates/skate-core/src/physics/skeleton_landing.rs create mode 100644 crates/skate-core/src/physics/skeleton_motion.rs create mode 100644 crates/skate-core/src/physics/skeleton_output/board.rs create mode 100644 crates/skate-core/src/physics/skeleton_output/correction.rs create mode 100644 crates/skate-core/src/physics/skeleton_output/mod.rs create mode 100644 crates/skate-core/src/physics/skeleton_output/tests.rs create mode 100644 crates/skate-core/src/physics/skeleton_output/wobble.rs create mode 100644 crates/skate-core/src/physics/skeleton_root.rs create mode 100644 crates/skate-core/src/physics/solver.rs create mode 100644 crates/skate-core/src/physics/solver/contact.rs create mode 100644 crates/skate-core/src/physics/solver/contact_build.rs create mode 100644 crates/skate-core/src/physics/solver/contact_build/geometry.rs create mode 100644 crates/skate-core/src/physics/solver/contact_build/input.rs create mode 100644 crates/skate-core/src/physics/solver/contact_build/publication.rs create mode 100644 crates/skate-core/src/physics/solver/contact_data.rs create mode 100644 crates/skate-core/src/physics/solver/drive.rs create mode 100644 crates/skate-core/src/physics/solver/joint.rs create mode 100644 crates/skate-core/src/physics/solver/joint_data.rs create mode 100644 crates/skate-core/src/physics/solver/packed.rs create mode 100644 crates/skate-core/src/physics/solver/packing.rs create mode 100644 crates/skate-core/src/physics/startup/mod.rs create mode 100644 crates/skate-core/src/physics/startup/reset.rs create mode 100644 crates/skate-core/src/physics/startup/skateboard.rs create mode 100644 crates/skate-core/src/physics/tests/assembly.rs create mode 100644 crates/skate-core/src/physics/tests/board_ground.rs create mode 100644 crates/skate-core/src/physics/tests/board_runtime.rs create mode 100644 crates/skate-core/src/physics/tests/board_step.rs create mode 100644 crates/skate-core/src/physics/tests/board_world.rs create mode 100644 crates/skate-core/src/physics/tests/contact.rs create mode 100644 crates/skate-core/src/physics/tests/contact_reports.rs create mode 100644 crates/skate-core/src/physics/tests/contact_solver.rs create mode 100644 crates/skate-core/src/physics/tests/deck_geometry.rs create mode 100644 crates/skate-core/src/physics/tests/drive_frames.rs create mode 100644 crates/skate-core/src/physics/tests/drive_solver.rs create mode 100644 crates/skate-core/src/physics/tests/filtered_state.rs create mode 100644 crates/skate-core/src/physics/tests/hook_step.rs create mode 100644 crates/skate-core/src/physics/tests/joint_builder.rs create mode 100644 crates/skate-core/src/physics/tests/joint_records.rs create mode 100644 crates/skate-core/src/physics/tests/joint_solver.rs create mode 100644 crates/skate-core/src/physics/tests/mass_moments.rs create mode 100644 crates/skate-core/src/physics/tests/native_arithmetic.rs create mode 100644 crates/skate-core/src/physics/tests/primitive_query.rs create mode 100644 crates/skate-core/src/physics/tests/rigid_body.rs create mode 100644 crates/skate-core/src/physics/tests/skeleton_animation_record.rs create mode 100644 crates/skate-core/src/physics/tests/skeleton_board_offset.rs create mode 100644 crates/skate-core/src/physics/tests/skeleton_landing.rs create mode 100644 crates/skate-core/src/physics/tests/triangle_query.rs create mode 100644 crates/skate-core/src/physics/tests/truck_frames.rs create mode 100644 crates/skate-core/src/physics/tests/world_dispatch.rs create mode 100644 crates/skate-core/src/physics/triangle_closest.rs create mode 100644 crates/skate-core/src/physics/triangle_query.rs create mode 100644 crates/skate-core/src/physics/triangle_sweep.rs create mode 100644 crates/skate-core/src/physics/triangle_sweep_roots.rs create mode 100644 crates/skate-core/src/physics/triangle_sweep_walk.rs create mode 100644 crates/skate-core/src/physics/truck_frames.rs create mode 100644 crates/skate-core/src/physics/world_contact/arithmetic.rs create mode 100644 crates/skate-core/src/physics/world_contact/axes.rs create mode 100644 crates/skate-core/src/physics/world_contact/box_box_sat.rs create mode 100644 crates/skate-core/src/physics/world_contact/box_feature.rs create mode 100644 crates/skate-core/src/physics/world_contact/buffer.rs create mode 100644 crates/skate-core/src/physics/world_contact/dispatch.rs create mode 100644 crates/skate-core/src/physics/world_contact/face_clip.rs create mode 100644 crates/skate-core/src/physics/world_contact/face_edge.rs create mode 100644 crates/skate-core/src/physics/world_contact/face_face.rs create mode 100644 crates/skate-core/src/physics/world_contact/face_specialized.rs create mode 100644 crates/skate-core/src/physics/world_contact/feature.rs create mode 100644 crates/skate-core/src/physics/world_contact/mod.rs create mode 100644 crates/skate-core/src/physics/world_contact/point_feature.rs create mode 100644 crates/skate-core/src/physics/world_contact/primitive_geometry.rs create mode 100644 crates/skate-core/src/physics/world_contact/primitive_pair.rs create mode 100644 crates/skate-core/src/physics/world_contact/primitive_query.rs create mode 100644 crates/skate-core/src/physics/world_contact/prism_math.rs create mode 100644 crates/skate-core/src/physics/world_contact/projection.rs create mode 100644 crates/skate-core/src/physics/world_contact/reduction.rs create mode 100644 crates/skate-core/src/physics/world_contact/segment_face.rs create mode 100644 crates/skate-core/src/physics/world_contact/segment_prism.rs create mode 100644 crates/skate-core/src/physics/world_contact/triangle_box_sat.rs create mode 100644 crates/skate-core/src/physics/world_contact/triangle_feature.rs create mode 100644 crates/skate-core/src/player/adjust.rs create mode 100644 crates/skate-core/src/player/conditioner_capabilities.rs create mode 100644 crates/skate-core/src/player/conditioner_capabilities/tests.rs create mode 100644 crates/skate-core/src/player/frame.rs create mode 100644 crates/skate-core/src/player/input_phase/air_output.rs create mode 100644 crates/skate-core/src/player/input_phase/mod.rs create mode 100644 crates/skate-core/src/player/input_phase/motion_math.rs create mode 100644 crates/skate-core/src/player/input_phase/pose_output.rs create mode 100644 crates/skate-core/src/player/input_phase/publication.rs create mode 100644 crates/skate-core/src/player/input_phase/runtime.rs create mode 100644 crates/skate-core/src/player/input_phase/tests.rs create mode 100644 crates/skate-core/src/player/input_phase/types.rs create mode 100644 crates/skate-core/src/player/lifecycle.rs create mode 100644 crates/skate-core/src/player/logic.rs create mode 100644 crates/skate-core/src/player/mod.rs create mode 100644 crates/skate-core/src/player/offboard/board_possession.rs create mode 100644 crates/skate-core/src/player/offboard/board_possession/lifecycle.rs create mode 100644 crates/skate-core/src/player/offboard/board_possession/manager.rs create mode 100644 crates/skate-core/src/player/offboard/board_possession/math.rs create mode 100644 crates/skate-core/src/player/offboard/board_possession/motion.rs create mode 100644 crates/skate-core/src/player/offboard/cadence.rs create mode 100644 crates/skate-core/src/player/offboard/cadence/tests.rs create mode 100644 crates/skate-core/src/player/offboard/contact_correction.rs create mode 100644 crates/skate-core/src/player/offboard/contact_correction/tests.rs create mode 100644 crates/skate-core/src/player/offboard/controller.rs create mode 100644 crates/skate-core/src/player/offboard/controller/output.rs create mode 100644 crates/skate-core/src/player/offboard/controller/placement.rs create mode 100644 crates/skate-core/src/player/offboard/controller/state.rs create mode 100644 crates/skate-core/src/player/offboard/controller/step.rs create mode 100644 crates/skate-core/src/player/offboard/controller/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_branch.rs create mode 100644 crates/skate-core/src/player/offboard/ground_branch/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_entry.rs create mode 100644 crates/skate-core/src/player/offboard/ground_entry/math.rs create mode 100644 crates/skate-core/src/player/offboard/ground_entry/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_input.rs create mode 100644 crates/skate-core/src/player/offboard/ground_input/math.rs create mode 100644 crates/skate-core/src/player/offboard/ground_input/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_lifecycle.rs create mode 100644 crates/skate-core/src/player/offboard/ground_lifecycle/collision.rs create mode 100644 crates/skate-core/src/player/offboard/ground_lifecycle/exit.rs create mode 100644 crates/skate-core/src/player/offboard/ground_lifecycle/post.rs create mode 100644 crates/skate-core/src/player/offboard/ground_lifecycle/publication.rs create mode 100644 crates/skate-core/src/player/offboard/ground_lifecycle/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_motion.rs create mode 100644 crates/skate-core/src/player/offboard/ground_motion/approach.rs create mode 100644 crates/skate-core/src/player/offboard/ground_motion/math.rs create mode 100644 crates/skate-core/src/player/offboard/ground_motion/publication.rs create mode 100644 crates/skate-core/src/player/offboard/ground_motion/support.rs create mode 100644 crates/skate-core/src/player/offboard/ground_motion/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_query.rs create mode 100644 crates/skate-core/src/player/offboard/ground_query/consume.rs create mode 100644 crates/skate-core/src/player/offboard/ground_query/geometry.rs create mode 100644 crates/skate-core/src/player/offboard/ground_query/math.rs create mode 100644 crates/skate-core/src/player/offboard/ground_query/packet.rs create mode 100644 crates/skate-core/src/player/offboard/ground_query/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_reckoning.rs create mode 100644 crates/skate-core/src/player/offboard/ground_reckoning/tests.rs create mode 100644 crates/skate-core/src/player/offboard/ground_sync.rs create mode 100644 crates/skate-core/src/player/offboard/ground_sync/math.rs create mode 100644 crates/skate-core/src/player/offboard/ground_sync/tests.rs create mode 100644 crates/skate-core/src/player/offboard/mod.rs create mode 100644 crates/skate-core/src/player/offboard/movement_intent.rs create mode 100644 crates/skate-core/src/player/offboard/movement_intent/math.rs create mode 100644 crates/skate-core/src/player/offboard/movement_intent/tests.rs create mode 100644 crates/skate-core/src/player/offboard/movement_velocity.rs create mode 100644 crates/skate-core/src/player/offboard/movement_velocity/math.rs create mode 100644 crates/skate-core/src/player/offboard/movement_velocity/tests.rs create mode 100644 crates/skate-core/src/player/offboard/pose_adjust.rs create mode 100644 crates/skate-core/src/player/offboard/pose_adjust/tests.rs create mode 100644 crates/skate-core/src/player/offboard/position_output.rs create mode 100644 crates/skate-core/src/player/offboard/position_output/tests.rs create mode 100644 crates/skate-core/src/player/offboard/reset_animation.rs create mode 100644 crates/skate-core/src/player/offboard/reset_animation/tests.rs create mode 100644 crates/skate-core/src/player/offboard/slide.rs create mode 100644 crates/skate-core/src/player/offboard/slide/tests.rs create mode 100644 crates/skate-core/src/player/offboard/surface_frame.rs create mode 100644 crates/skate-core/src/player/offboard/surface_frame/math.rs create mode 100644 crates/skate-core/src/player/offboard/surface_frame/tests.rs create mode 100644 crates/skate-core/src/player/offboard/twist_lean.rs create mode 100644 crates/skate-core/src/player/offboard/twist_lean/tests.rs create mode 100644 crates/skate-core/src/player/post_input.rs create mode 100644 crates/skate-core/src/player/post_output.rs create mode 100644 crates/skate-core/src/player/post_state.rs create mode 100644 crates/skate-core/src/player/pre_state.rs create mode 100644 crates/skate-core/src/player/selector/air.rs create mode 100644 crates/skate-core/src/player/selector/conditions.rs create mode 100644 crates/skate-core/src/player/selector/ground.rs create mode 100644 crates/skate-core/src/player/selector/input.rs create mode 100644 crates/skate-core/src/player/selector/mod.rs create mode 100644 crates/skate-core/src/player/selector/offboard.rs create mode 100644 crates/skate-core/src/player/selector/tests.rs create mode 100644 crates/skate-core/src/player/slide_state/forces.rs create mode 100644 crates/skate-core/src/player/slide_state/mod.rs create mode 100644 crates/skate-core/src/player/state.rs create mode 100644 crates/skate-core/src/player/state_phase.rs create mode 100644 crates/skate-core/src/player/teleport_state.rs create mode 100644 crates/skate-core/src/player/tests/adjust.rs create mode 100644 crates/skate-core/src/player/tests/frame.rs create mode 100644 crates/skate-core/src/player/tests/lifecycle.rs create mode 100644 crates/skate-core/src/player/tests/logic.rs create mode 100644 crates/skate-core/src/player/tests/post_input.rs create mode 100644 crates/skate-core/src/player/tests/post_output.rs create mode 100644 crates/skate-core/src/player/tests/post_state.rs create mode 100644 crates/skate-core/src/player/tests/pre_state.rs create mode 100644 crates/skate-core/src/player/tests/state_phase.rs create mode 100644 crates/skate-core/src/player/wipeout/air.rs create mode 100644 crates/skate-core/src/player/wipeout/common.rs create mode 100644 crates/skate-core/src/player/wipeout/data.rs create mode 100644 crates/skate-core/src/player/wipeout/ground.rs create mode 100644 crates/skate-core/src/player/wipeout/mod.rs create mode 100644 crates/skate-core/src/player/wipeout/requests.rs create mode 100644 crates/skate-core/src/player/wipeout/tests.rs create mode 100644 crates/skate-core/src/player/wipeout_state/body.rs create mode 100644 crates/skate-core/src/player/wipeout_state/contact_response.rs create mode 100644 crates/skate-core/src/player/wipeout_state/contact_response/material10.rs create mode 100644 crates/skate-core/src/player/wipeout_state/contact_response/material11.rs create mode 100644 crates/skate-core/src/player/wipeout_state/control_air.rs create mode 100644 crates/skate-core/src/player/wipeout_state/control_ground.rs create mode 100644 crates/skate-core/src/player/wipeout_state/control_profile_air.rs create mode 100644 crates/skate-core/src/player/wipeout_state/data.rs create mode 100644 crates/skate-core/src/player/wipeout_state/drives.rs create mode 100644 crates/skate-core/src/player/wipeout_state/math.rs create mode 100644 crates/skate-core/src/player/wipeout_state/mod.rs create mode 100644 crates/skate-core/src/player/wipeout_state/orientation.rs create mode 100644 crates/skate-core/src/player/wipeout_state/output.rs create mode 100644 crates/skate-core/src/player/wipeout_state/profiles.rs create mode 100644 crates/skate-core/src/player/wipeout_state/recovery.rs create mode 100644 crates/skate-core/src/player/wipeout_state/response.rs create mode 100644 crates/skate-core/src/player/wipeout_state/skeleton.rs create mode 100644 crates/skate-core/src/player/wipeout_state/torque.rs create mode 100644 crates/skate-core/src/player/wipeout_state/weights.rs create mode 100644 crates/skate-core/src/point_graph.rs create mode 100644 crates/skate-core/src/riding/anti_flip.rs create mode 100644 crates/skate-core/src/riding/braking.rs create mode 100644 crates/skate-core/src/riding/collision_response.rs create mode 100644 crates/skate-core/src/riding/ground_contact_response.rs create mode 100644 crates/skate-core/src/riding/ground_correction_math.rs create mode 100644 crates/skate-core/src/riding/ground_force.rs create mode 100644 crates/skate-core/src/riding/ground_orientation.rs create mode 100644 crates/skate-core/src/riding/grounded/drag.rs create mode 100644 crates/skate-core/src/riding/grounded/manual_entry.rs create mode 100644 crates/skate-core/src/riding/grounded/mod.rs create mode 100644 crates/skate-core/src/riding/grounded/mode.rs create mode 100644 crates/skate-core/src/riding/grounded/propulsion.rs create mode 100644 crates/skate-core/src/riding/grounded/state/board.rs create mode 100644 crates/skate-core/src/riding/grounded/state/board_types.rs create mode 100644 crates/skate-core/src/riding/grounded/state/contact_state.rs create mode 100644 crates/skate-core/src/riding/grounded/state/corrections.rs create mode 100644 crates/skate-core/src/riding/grounded/state/data.rs create mode 100644 crates/skate-core/src/riding/grounded/state/lifecycle.rs create mode 100644 crates/skate-core/src/riding/grounded/state/mod.rs create mode 100644 crates/skate-core/src/riding/grounded/state/motion.rs create mode 100644 crates/skate-core/src/riding/grounded/state/ordinary.rs create mode 100644 crates/skate-core/src/riding/grounded/state/output.rs create mode 100644 crates/skate-core/src/riding/grounded/state/post_physics.rs create mode 100644 crates/skate-core/src/riding/grounded/state/update.rs create mode 100644 crates/skate-core/src/riding/heading.rs create mode 100644 crates/skate-core/src/riding/mod.rs create mode 100644 crates/skate-core/src/riding/pumping.rs create mode 100644 crates/skate-core/src/riding/pumping/controller.rs create mode 100644 crates/skate-core/src/riding/pumping/geometry.rs create mode 100644 crates/skate-core/src/riding/pumping/settings.rs create mode 100644 crates/skate-core/src/riding/pumping/state.rs create mode 100644 crates/skate-core/src/riding/push.rs create mode 100644 crates/skate-core/src/riding/push_animation.rs create mode 100644 crates/skate-core/src/riding/push_behaviors.rs create mode 100644 crates/skate-core/src/riding/reckoning_frames.rs create mode 100644 crates/skate-core/src/riding/slide_friction.rs create mode 100644 crates/skate-core/src/riding/speed_and_slope.rs create mode 100644 crates/skate-core/src/riding/speed_model.rs create mode 100644 crates/skate-core/src/riding/speed_wobble.rs create mode 100644 crates/skate-core/src/riding/steering.rs create mode 100644 crates/skate-core/src/riding/straighten.rs create mode 100644 crates/skate-core/src/riding/tests/ground_orientation.rs create mode 100644 crates/skate-core/src/riding/tests/push.rs create mode 100644 crates/skate-core/src/riding/tests/push_behaviors.rs create mode 100644 crates/skate-core/src/riding/tests/speed_and_slope.rs create mode 100644 crates/skate-core/src/riding/tests/steering.rs create mode 100644 crates/skate-core/src/riding/vector.rs create mode 100644 crates/skate-core/src/trigonometry.rs create mode 100644 crates/skate-core/tests/animation_actor_composition.rs create mode 100644 crates/skate-core/tests/animation_commands.rs create mode 100644 crates/skate-core/tests/animation_output_bounds.rs create mode 100644 crates/skate-core/tests/animation_packet_merge.rs create mode 100644 crates/skate-core/tests/animation_packet_reset.rs create mode 100644 crates/skate-core/tests/animation_physics_publication.rs create mode 100644 crates/skate-core/tests/animation_skeleton_attributes.rs create mode 100644 crates/skate-core/tests/constraint_batch.rs create mode 100644 crates/skate-core/tests/contact_iteration.rs create mode 100644 crates/skate-core/tests/physics_rebuild_authored_transforms.rs create mode 100644 crates/skate-core/tests/physics_rebuild_constraints.rs create mode 100644 crates/skate-core/tests/physics_rebuild_contact_build.rs create mode 100644 crates/skate-core/tests/physics_rebuild_mass.rs create mode 100644 crates/skate-core/tests/point_graph.rs create mode 100644 crates/skate-core/tests/riding_grounded_propulsion.rs create mode 100644 crates/skate-core/tests/riding_manual.rs create mode 100644 crates/skate-core/tests/riding_manual_entry.rs create mode 100644 crates/skate-core/tests/riding_mode_binding.rs create mode 100644 crates/skate-core/tests/riding_pumping_controller.rs create mode 100644 crates/skate-core/tests/wheel_ground_solver.rs create mode 100644 crates/skate-data/Cargo.toml create mode 100644 crates/skate-data/src/abin/bank.rs create mode 100644 crates/skate-data/src/abin/mod.rs create mode 100644 crates/skate-data/src/abin/reader.rs create mode 100644 crates/skate-data/src/animation_banks.rs create mode 100644 crates/skate-data/src/animation_frames.rs create mode 100644 crates/skate-data/src/animation_frames/native.rs create mode 100644 crates/skate-data/src/animation_metadata.rs create mode 100644 crates/skate-data/src/animation_metadata/native.rs create mode 100644 crates/skate-data/src/animation_metadata/selection_space.rs create mode 100644 crates/skate-data/src/collections.rs create mode 100644 crates/skate-data/src/input_config.rs create mode 100644 crates/skate-data/src/lib.rs create mode 100644 crates/skate-data/src/manifest.rs create mode 100644 crates/skate-data/src/physics_skeleton.rs create mode 100644 crates/skate-data/src/sha256.rs create mode 100644 crates/skate-data/src/state_graph.rs create mode 100644 crates/skate-data/src/state_graph/attributes.rs create mode 100644 crates/skate-data/src/state_graph/binding.rs create mode 100644 crates/skate-data/src/tests/input_config.rs create mode 100644 crates/skate-data/tests/graph_attributes.rs create mode 100644 crates/skate-data/tests/graph_binding.rs create mode 100644 crates/skate-data/tests/graph_operations.rs create mode 100644 crates/skate-data/tests/manifest.rs create mode 100644 crates/skate-data/tests/state_graph.rs create mode 100644 crates/skate-game/Cargo.toml create mode 100644 crates/skate-game/src/animation.rs create mode 100644 crates/skate-game/src/animation_pose.rs create mode 100644 crates/skate-game/src/app.rs create mode 100644 crates/skate-game/src/assets.rs create mode 100644 crates/skate-game/src/camera.rs create mode 100644 crates/skate-game/src/camera/collision.rs create mode 100644 crates/skate-game/src/camera/graph.rs create mode 100644 crates/skate-game/src/camera/graph_conditions.rs create mode 100644 crates/skate-game/src/camera/graph_subject.rs create mode 100644 crates/skate-game/src/camera/publication.rs create mode 100644 crates/skate-game/src/camera/runtime.rs create mode 100644 crates/skate-game/src/camera/settings.rs create mode 100644 crates/skate-game/src/camera/shake_data.rs create mode 100644 crates/skate-game/src/camera/shot_data.rs create mode 100644 crates/skate-game/src/camera/stock_names.rs create mode 100644 crates/skate-game/src/camera/subject.rs create mode 100644 crates/skate-game/src/camera/tests/runtime.rs create mode 100644 crates/skate-game/src/camera/trajectory.rs create mode 100644 crates/skate-game/src/camera/world_query.rs create mode 100644 crates/skate-game/src/config.rs create mode 100644 crates/skate-game/src/graph_host/action.rs create mode 100644 crates/skate-game/src/graph_host/action_conditions.rs create mode 100644 crates/skate-game/src/graph_host/action_nodes.rs create mode 100644 crates/skate-game/src/graph_host/condition_nodes.rs create mode 100644 crates/skate-game/src/graph_host/crouching_settings.rs create mode 100644 crates/skate-game/src/graph_host/mod.rs create mode 100644 crates/skate-game/src/graph_host/motion.rs create mode 100644 crates/skate-game/src/graph_host/motion_air_execute.rs create mode 100644 crates/skate-game/src/graph_host/motion_air_leg.rs create mode 100644 crates/skate-game/src/graph_host/motion_animation.rs create mode 100644 crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs create mode 100644 crates/skate-game/src/graph_host/motion_animation/tree_builder.rs create mode 100644 crates/skate-game/src/graph_host/motion_channels.rs create mode 100644 crates/skate-game/src/graph_host/motion_character_gesture.rs create mode 100644 crates/skate-game/src/graph_host/motion_conditions.rs create mode 100644 crates/skate-game/src/graph_host/motion_execute.rs create mode 100644 crates/skate-game/src/graph_host/motion_filter_execute.rs create mode 100644 crates/skate-game/src/graph_host/motion_gameplay_conditions.rs create mode 100644 crates/skate-game/src/graph_host/motion_ground_slope.rs create mode 100644 crates/skate-game/src/graph_host/motion_hand_services.rs create mode 100644 crates/skate-game/src/graph_host/motion_hand_services/tests.rs create mode 100644 crates/skate-game/src/graph_host/motion_hooks.rs create mode 100644 crates/skate-game/src/graph_host/motion_intent_filter.rs create mode 100644 crates/skate-game/src/graph_host/motion_kickturn.rs create mode 100644 crates/skate-game/src/graph_host/motion_landing.rs create mode 100644 crates/skate-game/src/graph_host/motion_landing_execute.rs create mode 100644 crates/skate-game/src/graph_host/motion_native.rs create mode 100644 crates/skate-game/src/graph_host/motion_nodes.rs create mode 100644 crates/skate-game/src/graph_host/motion_offboard/cadence.rs create mode 100644 crates/skate-game/src/graph_host/motion_offboard/cadence/tests.rs create mode 100644 crates/skate-game/src/graph_host/motion_offboard/push_off.rs create mode 100644 crates/skate-game/src/graph_host/motion_offboard/reset.rs create mode 100644 crates/skate-game/src/graph_host/motion_offboard/twist_lean.rs create mode 100644 crates/skate-game/src/graph_host/motion_riding.rs create mode 100644 crates/skate-game/src/graph_host/motion_riding_conditions.rs create mode 100644 crates/skate-game/src/graph_host/motion_shove.rs create mode 100644 crates/skate-game/src/graph_host/motion_sliding.rs create mode 100644 crates/skate-game/src/graph_host/motion_sliding_execute.rs create mode 100644 crates/skate-game/src/graph_host/motion_spin.rs create mode 100644 crates/skate-game/src/graph_host/motion_wipeout.rs create mode 100644 crates/skate-game/src/graph_host/motion_wipeout_execute.rs create mode 100644 crates/skate-game/src/graph_host/pumping_settings.rs create mode 100644 crates/skate-game/src/graph_host/pushing.rs create mode 100644 crates/skate-game/src/graph_host/pushing_settings.rs create mode 100644 crates/skate-game/src/graph_host/tests/motion_slide.rs create mode 100644 crates/skate-game/src/graph_host/tests/motion_stock.rs create mode 100644 crates/skate-game/src/graph_host/tests/pushing.rs create mode 100644 crates/skate-game/src/graph_host/tests/pushing_metadata.rs create mode 100644 crates/skate-game/src/graph_host/turning_settings.rs create mode 100644 crates/skate-game/src/graph_runtime.rs create mode 100644 crates/skate-game/src/input.rs create mode 100644 crates/skate-game/src/input/controllers.rs create mode 100644 crates/skate-game/src/input/gesture_catalog.rs create mode 100644 crates/skate-game/src/input/platform.rs create mode 100644 crates/skate-game/src/input/tests/controllers.rs create mode 100644 crates/skate-game/src/main.rs create mode 100644 crates/skate-game/src/physics.rs create mode 100644 crates/skate-game/src/physics/air_phase.rs create mode 100644 crates/skate-game/src/physics/air_phase/board.rs create mode 100644 crates/skate-game/src/physics/air_phase/input.rs create mode 100644 crates/skate-game/src/physics/air_phase/skeleton.rs create mode 100644 crates/skate-game/src/physics/air_reckoning.rs create mode 100644 crates/skate-game/src/physics/air_reckoning/settings.rs create mode 100644 crates/skate-game/src/physics/air_trajectory/mod.rs create mode 100644 crates/skate-game/src/physics/air_trajectory/settings.rs create mode 100644 crates/skate-game/src/physics/air_trajectory/world.rs create mode 100644 crates/skate-game/src/physics/animated_skeleton.rs create mode 100644 crates/skate-game/src/physics/animation_feedback.rs create mode 100644 crates/skate-game/src/physics/animation_feedback_settings.rs create mode 100644 crates/skate-game/src/physics/animation_input.rs create mode 100644 crates/skate-game/src/physics/animation_phase.rs create mode 100644 crates/skate-game/src/physics/animation_phase_packet.rs create mode 100644 crates/skate-game/src/physics/camera_output.rs create mode 100644 crates/skate-game/src/physics/clock.rs create mode 100644 crates/skate-game/src/physics/colliders.rs create mode 100644 crates/skate-game/src/physics/controls.rs create mode 100644 crates/skate-game/src/physics/foot_ik.rs create mode 100644 crates/skate-game/src/physics/foot_ik_queries.rs create mode 100644 crates/skate-game/src/physics/foot_physical_output.rs create mode 100644 crates/skate-game/src/physics/footplant.rs create mode 100644 crates/skate-game/src/physics/footplant/math.rs create mode 100644 crates/skate-game/src/physics/footplant/pose.rs create mode 100644 crates/skate-game/src/physics/footplant/settings.rs create mode 100644 crates/skate-game/src/physics/footplant/update.rs create mode 100644 crates/skate-game/src/physics/frame.rs create mode 100644 crates/skate-game/src/physics/ground.rs create mode 100644 crates/skate-game/src/physics/ground_animation.rs create mode 100644 crates/skate-game/src/physics/ground_animation/board.rs create mode 100644 crates/skate-game/src/physics/ground_animation/settings.rs create mode 100644 crates/skate-game/src/physics/ground_animation/skeleton.rs create mode 100644 crates/skate-game/src/physics/ground_exit.rs create mode 100644 crates/skate-game/src/physics/ground_phase.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/corrections.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/entry.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/input.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/launch.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/mod.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/pumping.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/services.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/settings.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/state.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/surface.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/trajectory.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/update.rs create mode 100644 crates/skate-game/src/physics/ground_runtime/world_grab.rs create mode 100644 crates/skate-game/src/physics/input_phase.rs create mode 100644 crates/skate-game/src/physics/input_teleport.rs create mode 100644 crates/skate-game/src/physics/known_air.rs create mode 100644 crates/skate-game/src/physics/known_air/flip.rs create mode 100644 crates/skate-game/src/physics/known_air/math.rs create mode 100644 crates/skate-game/src/physics/known_air/output.rs create mode 100644 crates/skate-game/src/physics/known_air/runtime.rs create mode 100644 crates/skate-game/src/physics/known_air/settings.rs create mode 100644 crates/skate-game/src/physics/landing_quality.rs create mode 100644 crates/skate-game/src/physics/offboard/ground_query.rs create mode 100644 crates/skate-game/src/physics/offboard/ground_query/edges.rs create mode 100644 crates/skate-game/src/physics/offboard/ground_query/lines.rs create mode 100644 crates/skate-game/src/physics/offboard/ground_query/tests.rs create mode 100644 crates/skate-game/src/physics/offboard/ground_query/transform.rs create mode 100644 crates/skate-game/src/physics/offboard/ground_query/world.rs create mode 100644 crates/skate-game/src/physics/offboard/mod.rs create mode 100644 crates/skate-game/src/physics/offboard/pose_adjust.rs create mode 100644 crates/skate-game/src/physics/offboard/settings.rs create mode 100644 crates/skate-game/src/physics/offboard/settings/board.rs create mode 100644 crates/skate-game/src/physics/offboard/settings/curves.rs create mode 100644 crates/skate-game/src/physics/offboard/settings/metrics.rs create mode 100644 crates/skate-game/src/physics/offboard/settings/tests.rs create mode 100644 crates/skate-game/src/physics/offboard/skeleton_ground.rs create mode 100644 crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs create mode 100644 crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs create mode 100644 crates/skate-game/src/physics/player_input/dynamic_normal.rs create mode 100644 crates/skate-game/src/physics/player_input/grind.rs create mode 100644 crates/skate-game/src/physics/player_input/ground_position.rs create mode 100644 crates/skate-game/src/physics/player_input/initial.rs create mode 100644 crates/skate-game/src/physics/player_input/mod.rs create mode 100644 crates/skate-game/src/physics/player_input/output_reset.rs create mode 100644 crates/skate-game/src/physics/player_input/pre_input.rs create mode 100644 crates/skate-game/src/physics/player_input/reset.rs create mode 100644 crates/skate-game/src/physics/player_input/services.rs create mode 100644 crates/skate-game/src/physics/player_state.rs create mode 100644 crates/skate-game/src/physics/player_state/post_input.rs create mode 100644 crates/skate-game/src/physics/player_state/pre_state.rs create mode 100644 crates/skate-game/src/physics/player_state/publication.rs create mode 100644 crates/skate-game/src/physics/player_state/selection.rs create mode 100644 crates/skate-game/src/physics/player_state/transition.rs create mode 100644 crates/skate-game/src/physics/player_state/wipeout_output.rs create mode 100644 crates/skate-game/src/physics/render_pose.rs create mode 100644 crates/skate-game/src/physics/riding_outputs.rs create mode 100644 crates/skate-game/src/physics/riding_outputs/probes.rs create mode 100644 crates/skate-game/src/physics/settings.rs create mode 100644 crates/skate-game/src/physics/skateboard_controller.rs create mode 100644 crates/skate-game/src/physics/skater.rs create mode 100644 crates/skate-game/src/physics/skeleton_air.rs create mode 100644 crates/skate-game/src/physics/skeleton_body.rs create mode 100644 crates/skate-game/src/physics/skeleton_body_collision_settings.rs create mode 100644 crates/skate-game/src/physics/skeleton_body_drive_settings.rs create mode 100644 crates/skate-game/src/physics/skeleton_colliders.rs create mode 100644 crates/skate-game/src/physics/skeleton_controller.rs create mode 100644 crates/skate-game/src/physics/skeleton_feedback.rs create mode 100644 crates/skate-game/src/physics/skeleton_input_general.rs create mode 100644 crates/skate-game/src/physics/skeleton_input_runtime.rs create mode 100644 crates/skate-game/src/physics/skeleton_input_teleport.rs create mode 100644 crates/skate-game/src/physics/skeleton_output.rs create mode 100644 crates/skate-game/src/physics/slide_state.rs create mode 100644 crates/skate-game/src/physics/slide_state/settings.rs create mode 100644 crates/skate-game/src/physics/slide_state/update.rs create mode 100644 crates/skate-game/src/physics/solve.rs create mode 100644 crates/skate-game/src/physics/solve/assembly_contacts.rs create mode 100644 crates/skate-game/src/physics/teleport_state.rs create mode 100644 crates/skate-game/src/physics/tests/clock.rs create mode 100644 crates/skate-game/src/physics/wipeout.rs create mode 100644 crates/skate-game/src/physics/wipeout/observations.rs create mode 100644 crates/skate-game/src/physics/wipeout/settings.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/contact.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/drive_settings.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/lifecycle.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/mod.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/output.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/prediction.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/ragdoll.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/ragdoll/settings.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/settings.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/skeleton.rs create mode 100644 crates/skate-game/src/physics/wipeout_states/update.rs create mode 100644 crates/skate-game/src/skater_animation.rs create mode 100644 crates/skate-game/src/skater_animation/state.rs create mode 100644 crates/skate-game/src/tests/action_host.rs create mode 100644 crates/skate-game/src/tests/air_playback.rs create mode 100644 crates/skate-game/src/tests/animation_pose.rs create mode 100644 crates/skate-game/src/tests/graph_runtime.rs create mode 100644 crates/skate-game/src/tests/physics_startup.rs create mode 100644 crates/skate-game/src/tests/wipeout_playback.rs create mode 100644 crates/skate-game/src/verification.rs create mode 100644 crates/skate-game/src/world.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..96310228 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/bin/ +/logs/ +/assets/private/ diff --git a/BUILD.bat b/BUILD.bat new file mode 100644 index 00000000..a2280f87 --- /dev/null +++ b/BUILD.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Build.ps1" +if errorlevel 1 pause diff --git a/Build.ps1 b/Build.ps1 new file mode 100644 index 00000000..4bd1c926 --- /dev/null +++ b/Build.ps1 @@ -0,0 +1,53 @@ +param([switch]$StageOnly, [string]$TargetDirectory = (Join-Path $PSScriptRoot 'target')) +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { + if (-not $StageOnly) { + & cargo build -p skate-game --locked --target-dir $TargetDirectory + if ($LASTEXITCODE -ne 0) { throw 'Build failed; see the compiler output above.' } + } + $debugDirectory = Join-Path $TargetDirectory 'debug' + $executable = Join-Path $debugDirectory 'skate-game.exe' + if (-not (Test-Path -LiteralPath $executable)) { throw "Missing executable: $executable" } + $readobj = Join-Path $env:ProgramFiles 'LLVM/bin/llvm-readobj.exe' + if (-not (Test-Path -LiteralPath $readobj)) { throw 'LLVM llvm-readobj is required to stage exact runtime DLL dependencies.' } + $rustLibraries = (& rustc --print target-libdir).Trim() + if ($LASTEXITCODE -ne 0) { throw 'Could not locate Rust runtime libraries.' } + $binDirectory = Join-Path $PSScriptRoot 'bin' + New-Item -ItemType Directory -Path $binDirectory -Force | Out-Null + $queue = [System.Collections.Generic.Queue[string]]::new() + $queue.Enqueue($executable) + $seen = @{} + $staged = @() + while ($queue.Count -gt 0) { + $source = $queue.Dequeue() + $name = Split-Path -Leaf $source + if ($seen.ContainsKey($name)) { continue } + $seen[$name] = $true + $destination = Join-Path $binDirectory $name + Copy-Item -LiteralPath $source -Destination $destination -Force + $staged += @{name = $name; sha256 = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash} + $imports = & $readobj --coff-imports $source + if ($LASTEXITCODE -ne 0) { throw "Could not inspect DLL imports: $source" } + foreach ($line in $imports) { + if ($line -match '^\s+Name: (.+\.dll)$') { + $dependency = $Matches[1] + $found = $false + foreach ($directory in @($debugDirectory, (Join-Path $debugDirectory 'deps'), $rustLibraries)) { + $candidate = Join-Path $directory $dependency + if (Test-Path -LiteralPath $candidate) { + $queue.Enqueue($candidate) + $found = $true + break + } + } + if (-not $found -and $dependency -notmatch '^(api-ms-|ext-ms-)' -and + -not (Test-Path -LiteralPath (Join-Path $env:WINDIR "System32/$dependency"))) { + throw "Runtime dependency not found: $dependency" + } + } + } + } + $staged | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $binDirectory 'manifest.json') -Encoding UTF8 + Write-Host "Ready: $binDirectory/skate-game.exe" +} finally { Pop-Location } diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..680934d4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5937 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "simd_cesu8", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomicow" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301801c08259e328a1c7da556608c0c22687708831b22024dbd3a57ea741e6de" +dependencies = [ + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bevy" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e" +dependencies = [ + "bevy_dylib", + "bevy_internal", +] + +[[package]] +name = "bevy_a11y" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e887b25c84f384ffe3278a17cf0e4b405eaa3c8fbc3db24d05d560a11780676d" +dependencies = [ + "accesskit", + "bevy_app", + "bevy_derive", + "bevy_ecs", + "bevy_reflect", +] + +[[package]] +name = "bevy_android" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c58de772ac1148884112e8a456c4f127a94b95a0e42ab5b160b7a11895a241" +dependencies = [ + "android-activity", +] + +[[package]] +name = "bevy_animation" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5bf5b285f0d3fab983b4505e62e195e06930a29007ffc95bdabde834e163a2" +dependencies = [ + "bevy_animation_macros", + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", + "blake3", + "derive_more", + "downcast-rs 2.0.2", + "either", + "petgraph", + "ron", + "serde", + "smallvec", + "thiserror 2.0.20", + "thread_local", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_animation_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf35516d0e7ac9ec25df533be1bf8cbaa20596a8e65f36838a3f7803a267d6d" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_anti_alias" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_utils", + "tracing", +] + +[[package]] +name = "bevy_app" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def9f41aa5bf9b9dec8beda307a332798609cffb9d44f71005e0cfb45164f2f6" +dependencies = [ + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "cfg-if", + "console_error_panic_hook", + "ctrlc", + "downcast-rs 2.0.2", + "log", + "thiserror 2.0.20", + "variadics_please", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "bevy_asset" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f86fed15972b9fb1a3f7b092cf0390e67131caaedab15a2707c043e3a3c886" +dependencies = [ + "async-broadcast", + "async-channel", + "async-fs", + "async-io", + "async-lock", + "atomicow", + "bevy_android", + "bevy_app", + "bevy_asset_macros", + "bevy_diagnostic", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.1", + "blake3", + "crossbeam-channel", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "either", + "futures-io", + "futures-lite", + "futures-util", + "js-sys", + "ron", + "serde", + "stackfuture", + "thiserror 2.0.20", + "tracing", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "bevy_asset_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cb8d948365b06561b43b7d709282e62a6abb756baac5d8e295206d5e156168" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_audio" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d68da32468ce7f4bb2863b71326acfaaa88e9aef8da8306257cd487d40cede4" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_math", + "bevy_reflect", + "bevy_transform", + "coreaudio-sys", + "cpal", + "rodio", + "tracing", +] + +[[package]] +name = "bevy_camera" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ed9eed054e14341852236d06a7244597b1ace39ff9ae023fbd188ffde88619" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "downcast-rs 2.0.2", + "serde", + "smallvec", + "thiserror 2.0.20", + "wgpu-types", +] + +[[package]] +name = "bevy_color" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb41e8310a85811d14a4e75cfc2d6c07ac70661d6a4883509fc960f622970a8" +dependencies = [ + "bevy_math", + "bevy_reflect", + "bytemuck", + "derive_more", + "encase", + "serde", + "thiserror 2.0.20", + "wgpu-types", +] + +[[package]] +name = "bevy_core_pipeline" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d0810e85c2436e50c67448d48a83bf0bb1b5849899619ae2c7ea817221e9172" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.1", + "nonmax", + "radsort", + "smallvec", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "318ee0532c3da93749859d18f89a889c638fbc56aabac4d866583df7b951d103" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_dev_tools" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_state", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_window", + "tracing", +] + +[[package]] +name = "bevy_diagnostic" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8543a0f7afd56d3499ba80ffab6ef0bad12f93c2d2ca9aa7b1f1b8816c3980" +dependencies = [ + "atomic-waker", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_tasks", + "bevy_time", + "const-fnv1a-hash", + "log", + "serde", + "sysinfo", +] + +[[package]] +name = "bevy_dylib" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f142f72bbbc1a61338bf6da5dc7cf6dcf223683b9d53bcd635ebff4bfeb5308f" +dependencies = [ + "bevy_internal", +] + +[[package]] +name = "bevy_ecs" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.1", + "bumpalo", + "concurrent-queue", + "derive_more", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "slotmap", + "smallvec", + "thiserror 2.0.20", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908baf585e2ea16bd53ef0da57b69580478af0059d2dbdb4369991ac9794b618" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_encase_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fee46eeddcbc00a805ae00ffa973f224671fc5cf0fe1a796963804faeade90" +dependencies = [ + "bevy_macro_utils", + "encase_derive_impl", +] + +[[package]] +name = "bevy_feathers" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_text", + "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", + "bevy_window", + "smol_str", +] + +[[package]] +name = "bevy_gilrs" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "611827ab0ce43b88c0a695e6603901b5f34687efecaf526c861456c9d8e6fedb" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_platform", + "bevy_time", + "gilrs", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_gizmos" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aaff0dd5f405c83d290c5cd591835f1ae8009894947ab19dadcb323062bd7e7" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_gizmos_macros", + "bevy_light", + "bevy_math", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", +] + +[[package]] +name = "bevy_gizmos_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6960ea308d7e94adcac5c712553ff86614bba6b663511f3f3812f6bec028b51e" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_gizmos_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a8d18c089102de4c5e9326023ad96ba618a6961029f8102a33640b966883237" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_ecs", + "bevy_gizmos", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_render", + "bevy_shader", + "bevy_sprite_render", + "bevy_transform", + "bevy_utils", + "bytemuck", + "tracing", +] + +[[package]] +name = "bevy_gltf" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f37fb52655d0439656ca0a1db027d46926e463c81d893d4b1639668e5d7f1c1" +dependencies = [ + "async-lock", + "base64", + "bevy_animation", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_tasks", + "bevy_transform", + "fixedbitset", + "gltf", + "itertools 0.14.0", + "percent-encoding", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_image" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a71daf9b2afdd032c2b1122d1d501f99126218cb3e9983b3604ec381daa35f22" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "bitflags 2.13.1", + "bytemuck", + "futures-lite", + "guillotiere", + "half", + "image", + "ktx2", + "rectangle-pack", + "ruzstd", + "serde", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_input" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc8ffbd02df34dfc52faf420a5263985973765e228043adf542fd0d790a6b21" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "derive_more", + "log", + "smol_str", + "thiserror 2.0.20", +] + +[[package]] +name = "bevy_input_focus" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d48a5bceccb9157549a39ab3de4017f5368b65db6471605e9a3f1c19d91bbc" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_window", + "log", + "thiserror 2.0.20", +] + +[[package]] +name = "bevy_internal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754" +dependencies = [ + "bevy_a11y", + "bevy_android", + "bevy_animation", + "bevy_anti_alias", + "bevy_app", + "bevy_asset", + "bevy_audio", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_dev_tools", + "bevy_diagnostic", + "bevy_ecs", + "bevy_feathers", + "bevy_gilrs", + "bevy_gizmos", + "bevy_gizmos_render", + "bevy_gltf", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_picking", + "bevy_platform", + "bevy_post_process", + "bevy_ptr", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_state", + "bevy_tasks", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_utils", + "bevy_window", + "bevy_winit", +] + +[[package]] +name = "bevy_light" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "tracing", +] + +[[package]] +name = "bevy_log" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2aac1187f83a1ab2eae887564f7fb14b4abb3fbe8b2267a6426663463923120" +dependencies = [ + "android_log-sys", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_utils", + "tracing", + "tracing-log", + "tracing-oslog", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b147843b81a7ec548876ff97fa7bfdc646ef2567cb465566259237b39664438" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "bevy_math" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84" +dependencies = [ + "approx", + "arrayvec", + "bevy_reflect", + "derive_more", + "glam", + "itertools 0.14.0", + "libm", + "rand", + "rand_distr", + "serde", + "thiserror 2.0.20", + "variadics_please", +] + +[[package]] +name = "bevy_mesh" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288f590c8173d4cca3cae5f2ba579accd5ed1a35dd3fab338f427eb39d55f05e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mikktspace", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "hexasphere", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_mikktspace" +version = "0.17.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59" + +[[package]] +name = "bevy_pbr" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_picking" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_window", + "crossbeam-channel", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_platform" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6b36504169b644acd26a5469fd8d371aa6f1d73ee5c01b1b1181ae1cefbf9b" +dependencies = [ + "critical-section", + "foldhash 0.2.0", + "futures-channel", + "hashbrown 0.16.1", + "js-sys", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "bevy_post_process" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f77a4e894aea992e3d6938f1d5898a1cdbb87dba6eebfb95cb4038d0a2600e9" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.1", + "nonmax", + "radsort", + "smallvec", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_ptr" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a9329e8dc4e01ced480eeec4902e6d7cb56e56ec37f6fbc4323e5c937290a7" + +[[package]] +name = "bevy_reflect" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1dfeb67a9fe4f59003a84f5f99ba6302141c70e926601cbc6abfd4a1eea9ca9" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "erased-serde", + "foldhash 0.2.0", + "glam", + "indexmap", + "inventory", + "petgraph", + "serde", + "smallvec", + "smol_str", + "thiserror 2.0.20", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "475f68c93e9cd5f17e9167635c8533a4f388f12d38245a202359e4c2721d87ba" +dependencies = [ + "bevy_macro_utils", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "bevy_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243523e33fe5dfcebc4240b1eb2fc16e855c5d4c0ea6a8393910740956770f44" +dependencies = [ + "async-channel", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_encase_derive", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render_macros", + "bevy_shader", + "bevy_tasks", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "downcast-rs 2.0.2", + "encase", + "fixedbitset", + "glam", + "image", + "indexmap", + "js-sys", + "naga", + "nonmax", + "offset-allocator", + "send_wrapper", + "smallvec", + "thiserror 2.0.20", + "tracing", + "variadics_please", + "wasm-bindgen", + "web-sys", + "wgpu", +] + +[[package]] +name = "bevy_render_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b6325e9c495a71270446784611e8d7f446f927eac8506c4c099fd10cb4c3ed" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_scene" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cc1047d85ec8048261b63ef675c12f1e6b5782dc0b422fbcee0c140d026bd4" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "derive_more", + "ron", + "serde", + "thiserror 2.0.20", + "uuid", +] + +[[package]] +name = "bevy_shader" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eea95f0273c32be13d6a0b799a93bc256ad7830759ede595c404d5234302da2" +dependencies = [ + "bevy_asset", + "bevy_platform", + "bevy_reflect", + "naga", + "naga_oil", + "serde", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_sprite" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ec5bc0cbdee551b610a46f41d30374bbe42b8951ffc676253c6243ab2b9395" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_picking", + "bevy_reflect", + "bevy_text", + "bevy_transform", + "bevy_window", + "radsort", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_sprite_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82cb08905e7ddcea2694a95f757ae7f1fd01e6a7304076bad595d2158e4bfe0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "tracing", +] + +[[package]] +name = "bevy_state" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae0682968e97d29c1eccc8c6bb6283f2678d362779bc03f1bb990967059473b" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_state_macros", + "bevy_utils", + "log", + "variadics_please", +] + +[[package]] +name = "bevy_state_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73d32f90f9cfcef5a44401db7ce206770daaa1707b0fb95eb7a96a6933f54f1b" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_tasks" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384eb04d80aa38664d69988fd30cbbe03e937ecb65c66aa6abe60ce0bca826aa" +dependencies = [ + "async-channel", + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "concurrent-queue", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless", + "pin-project", +] + +[[package]] +name = "bevy_text" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc5233291dfc22e584de2535f2e37ae9766d37cb5a01652de2133ba202dcb9b" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "cosmic-text", + "serde", + "smallvec", + "sys-locale", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_time" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5ef9af4e523195e561074cf60fbfad0f4cb8d1db504855fee3c4ce8896c7244" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "crossbeam-channel", + "log", + "serde", +] + +[[package]] +name = "bevy_transform" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3bb3de7842fef699344beb03f22bdbff16599d788fe0f47fbb3b1e6fa320eb" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_log", + "bevy_math", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "derive_more", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "bevy_ui" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1691a411014085e0d35f8bb8208e5f973edd7ace061a4b1c41c83de21579dc70" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "smallvec", + "taffy", + "thiserror 2.0.20", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_ui_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2c35402d8a052f512e3fec1f36b26e83eee713fcca57f965c244ee795e1fcb0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_text", + "bevy_transform", + "bevy_ui", + "bevy_utils", + "bytemuck", + "derive_more", + "tracing", +] + +[[package]] +name = "bevy_ui_widgets" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_ui", +] + +[[package]] +name = "bevy_utils" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2111910cd7a4b1e6ce07eaaeb6f68a2c0ea0ca609ed0d0d506e3eb161101435b" +dependencies = [ + "bevy_platform", + "disqualified", + "thread_local", +] + +[[package]] +name = "bevy_window" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df06e6993a0896bae2fe7644ae6def29a1a92b45dfb1bcebbd92af782be3638" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "log", + "raw-window-handle", + "serde", +] + +[[package]] +name = "bevy_winit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2de1c13d32ab8528435b58eca7ab874a1068184c6d6f266ee11433ae99d4069" +dependencies = [ + "accesskit", + "accesskit_winit", + "approx", + "bevy_a11y", + "bevy_android", + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_window", + "bytemuck", + "cfg-if", + "js-sys", + "tracing", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winit", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "bytemuck", + "serde_core", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + +[[package]] +name = "const_panic" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3" +dependencies = [ + "typewit", +] + +[[package]] +name = "const_soft_float" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ca1caa64ef4ed453e68bb3db612e51cf1b2f5b871337f0fcab1c8f87cc3dff" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "constgebra" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aaf9b65849a68662ac6c0810c8893a765c960b907dd7cfab9c4a50bf764fbc" +dependencies = [ + "const_soft_float", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cosmic-text" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cadaea21e24c49c0c82116f2b465ae6a49d63c90e428b0f8d9ae1f638ac91f" +dependencies = [ + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.39.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "disqualified" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encase" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1acbc46b960480c4959213714511e22f9a2e0d361520693bcf5057b5924f53e" +dependencies = [ + "const_panic", + "encase_derive", + "thiserror 2.0.20", +] + +[[package]] +name = "encase_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e30d38c47336a0afbdfde297b442944041cf6dccbb1a79eceea16abfb27a92a" +dependencies = [ + "encase_derive_impl", +] + +[[package]] +name = "encase_derive_impl" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495d24abe406f090f2ba8fc362ecaa8a001e192522aa75eae7b144163c3235ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gilrs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" +dependencies = [ + "bytemuck", + "encase", + "libm", + "rand", + "serde_core", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "byteorder", + "gltf-json", + "lazy_static", + "serde_json", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0caaee032384c10dd597af4579c67dee16650d862a9ccbe1233ff1a379abc07" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.36.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hexasphere" +version = "16.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b" +dependencies = [ + "constgebra", + "glam", + "tinyvec", +] + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "ktx2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7f53bdf698e7aa7ec916411bbdc8078135da11b66db5182675b2227f6c0d07" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "pp-rs", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "naga_oil" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310c347db1b30e69581f3b84dc9a5c311ed583f67851b39b77953cb7a066c97f" +dependencies = [ + "codespan-reporting", + "data-encoding", + "indexmap", + "naga", + "regex", + "rustc-hash 1.1.0", + "thiserror 2.0.20", + "tracing", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "offset-allocator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e234d535da3521eb95106f40f0b73483d80bfb3aacf27c40d7e2b72f1a3e00a2" +dependencies = [ + "log", + "nonmax", +] + +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", + "serde_derive", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radsort" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "rectangle-pack" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d463f2884048e7153449a55166f91028d5b0ea53c79377099ce4e8cf0cf9bb" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "cpal", + "lewton", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skate-core" +version = "0.1.0" + +[[package]] +name = "skate-data" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skate-core", +] + +[[package]] +name = "skate-game" +version = "0.1.0" +dependencies = [ + "bevy", + "skate-core", + "skate-data", +] + +[[package]] +name = "skrifa" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9eb0b904a04d09bd68c65d946617b8ff733009999050f3b851c32fb3cfb60e" +dependencies = [ + "bytemuck", + "read-fonts 0.36.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stackfuture" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115beb9c69db2393ff10b75a1b8587a51716e5551d015001e55320ed279d32f9" +dependencies = [ + "const_panic", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.6.0+11769913", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "serde", + "thiserror 2.0.20", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..b60f3173 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +members = ["crates/skate-core", "crates/skate-data", "crates/skate-game"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +publish = false + +[workspace.dependencies] +bevy = { version = "=0.18.1", features = ["dynamic_linking"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +skate-core = { path = "crates/skate-core" } +skate-data = { path = "crates/skate-data" } + +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 3 diff --git a/Launch.ps1 b/Launch.ps1 new file mode 100644 index 00000000..b2fdda15 --- /dev/null +++ b/Launch.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { + $executable = Join-Path $PSScriptRoot 'bin/skate-game.exe' + if (-not (Test-Path -LiteralPath $executable)) { throw 'Game is not built. Run BUILD.bat first.' } + New-Item -ItemType Directory -Path (Join-Path $PSScriptRoot 'logs') -Force | Out-Null + $log = Join-Path $PSScriptRoot ('logs/game-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.log') + Write-Host 'Starting imported skater project (Easy). Use your XInput controller; Esc exits.' + Write-Host "Log: $log" + $errorLog = [System.IO.Path]::ChangeExtension($log, 'stderr.log') + $assetArgument = '"' + (Join-Path $PSScriptRoot 'assets') + '"' + $game = Start-Process -FilePath $executable -WorkingDirectory $PSScriptRoot ` + -ArgumentList @('--assets', $assetArgument) -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput $log -RedirectStandardError $errorLog + if ($game.ExitCode -ne 0) { + Get-Content -LiteralPath $errorLog -Tail 30 + throw "Game exited with code $($game.ExitCode). Log: $errorLog" + } +} finally { Pop-Location } diff --git a/PLAY.bat b/PLAY.bat new file mode 100644 index 00000000..371a973d --- /dev/null +++ b/PLAY.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Launch.ps1" +if errorlevel 1 pause diff --git a/README.md b/README.md new file mode 100644 index 00000000..56e9ec3f --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Imported skater project + +Double-click **PLAY.bat** to launch. Use an XInput-compatible controller; +**Esc** exits. Startup selects the supplied **Easy** profile. + +This folder is separate from the earlier clone. It contains the contents of +`crates (1).zip` and `assets.zip`, a reconstructed Cargo workspace, and launch +scripts. The supplied stock animation banks, decoded frames, graphs, skater +mesh and textures are used together. No previous-clone animation code was +needed for setup. + +The supplied scene contains a small test environment. Gameplay quality and +Skate 3 parity remain for the user to assess; compiling and starting the +project do not establish those claims. + +## Observed runtime issue + +The build launches and reports `GAME_CHARACTER_READY bones=35`. All 35 skin +bones match the supplied stock rig; the decoded frame data contains 487 clips. +However, the imported motion-graph host exits when it encounters +`AddRunoutAttribs` (behavior 86). Both startup sessions recorded that exact +unsupported-behavior error. This is an implementation gap, not a missing +animation-file error. It has not been bypassed or replaced with guessed behavior. +See `logs/game-20260906-033940.stderr.log` for the character-ready message +and subsequent failure. No automated gameplay or screenshot test was run. + +## Files + +- `crates/`: imported Rust source. The two startup profile selections were + changed from `normal` to `easy`. +- `assets/private/`: supplied assets; keep these private. +- `bin/`: staged executable and its exact Rust/Bevy runtime DLL dependencies. +- `logs/`: per-launch output and error logs. +- `Cargo.lock`: resolved dependency versions for reproducible rebuilds. +- `SETUP-PROVENANCE.json`: original ZIP hashes and setup changes. + +**BUILD.bat** rebuilds and stages the executable. It requires Cargo/Rust and +LLVM `llvm-readobj` at the installed Windows location. The initial build +reused the earlier project's compilation cache; PLAY.bat uses only this +folder's staged binary, libraries and assets. Subsequent default builds use +this folder's own `target/` directory. + +If startup fails, the launcher keeps the console open and shows the error +log path. Do not substitute animation files to silence a missing-data error; +record the exact error first. diff --git a/SETUP-PROVENANCE.json b/SETUP-PROVENANCE.json new file mode 100644 index 00000000..c4457fad --- /dev/null +++ b/SETUP-PROVENANCE.json @@ -0,0 +1,24 @@ +{ + "archives": { + "crates (1).zip": "d039af756525a3071178c366bf6d44a8b426f320e28c414cb34449240be89bd9", + "assets.zip": "5127c4608742c4c2f574edb128829c6cc22f02cf957b9ade2fae6c56e5b95f7b" + }, + "setup_changes": [ + "Added missing Cargo workspace using installed Bevy 0.18.1 and Rust 2024 edition.", + "Selected supplied Easy profile at both game startup load sites.", + "Added build, staging and launch scripts." + ], + "animation_source": "Supplied assets.zip; no assets from the previous clone required.", + "validation": { + "build": "passed (56 supplied-source warnings)", + "startup": "GAME_CHARACTER_READY bones=35", + "skin_bones_matched": 35, + "decoded_animation_clips": 487, + "runtime_issue": "Unsupported MotionGraph Behavior AddRunoutAttribs (behavior 86); no bypass applied", + "gameplay_controls": "user-driven; no injected input or automated visual test" + }, + "modified_imported_files": [ + "crates/skate-game/src/main.rs", + "crates/skate-game/src/physics.rs" + ] +} diff --git a/crates/skate-core/Cargo.toml b/crates/skate-core/Cargo.toml new file mode 100644 index 00000000..3a98493d --- /dev/null +++ b/crates/skate-core/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "skate-core" +version.workspace = true +edition.workspace = true +publish.workspace = true diff --git a/crates/skate-core/src/air/body_flip.rs b/crates/skate-core/src/air/body_flip.rs new file mode 100644 index 00000000..7ece34de --- /dev/null +++ b/crates/skate-core/src/air/body_flip.rs @@ -0,0 +1,107 @@ +//! TU3 Reckoning::UpdateBodyFlip 82D8EC70, with explicit attribute-read inputs. +//! Full scalar gates, spin matrix and conditional combined-matrix publication. +use crate::trigonometry::sin_cos; + +pub type Matrix = [[f32; 4]; 4]; + +#[derive(Clone, Debug, PartialEq)] +pub struct BodyFlipState { + /// Reckoning +1584, +1588, +1592. + pub angle: f32, + pub speed: f32, + pub requested_speed: f32, + /// Reckoning +1072 and +1008. These are distinct native output buffers. + pub spin_transform: Matrix, + pub combined_transform: Matrix, +} + +/// Actual Attrib::GetAttributePointer results (82B72420), not guessed settings. +/// Missing collection/attribute resolves to the supplied live 830D0850 value. +pub struct BodyFlipSettings { + /// Hash 26E7322CCDC3FE23. + pub smoothing: Option, + /// Hash 3D95C06DC6E3A002. + pub maximum_speed: Option, + /// Hash 89763E47712871C3. + pub spin_scale: Option, + pub missing_attribute_value: f32, +} + +pub struct BodyFlipInput { + pub requested_speed: f32, + /// Reckoning +1568, +1152 and +1232; caller-provided axes, without repair. + pub spin_angle: f32, + pub normal: [f32; 4], + pub flip_axis: [f32; 4], + /// Processed +2604; only the non-perfect branch uses this dt. + pub timestep: f32, + /// Processed +2548 -> layout +4 -> byte +28. + pub perfect_body_flips: bool, +} + +pub fn update(state: &mut BodyFlipState, settings: &BodyFlipSettings, input: &BodyFlipInput) { + let fallback = settings.missing_attribute_value; + let smoothing = settings.smoothing.unwrap_or(fallback); + let maximum = settings.maximum_speed.unwrap_or(fallback); + let spin_scale = settings.spin_scale.unwrap_or(fallback); + state.requested_speed = input.requested_speed; + let mut speed = (input.requested_speed - state.speed).mul_add(smoothing, state.speed); + if input.perfect_body_flips { + let full_turn = f32::from_bits(0x40C9_0FDB); + let remaining_rate = (full_turn - state.angle.abs()) * 12.0; + let sign = if speed > 0.0 { 1.0 } else { -1.0 }; + if !(speed.abs() < remaining_rate) { + speed = sign * remaining_rate; + } + state.angle = clamp( + speed.mul_add(f32::from_bits(0x3C88_8889), state.angle), + -full_turn, + full_turn, + ); + } else { + speed = clamp(speed, -maximum, maximum); + state.angle = input.timestep.mul_add(speed, state.angle); + } + state.speed = speed; + state.spin_transform = rotation(input.normal, input.spin_angle * spin_scale); + // Native zero-angle branch leaves the +1008 matrix untouched. + if state.angle != 0.0 { + let flip = rotation(input.flip_axis, state.angle); + let spin = state.spin_transform; + for col in 0..4 { + for lane in 0..4 { + let first = if col == 3 { + spin[col][0].mul_add(flip[0][lane], flip[3][lane]) + } else { + spin[col][0] * flip[0][lane] + }; + let second = spin[col][1].mul_add(flip[1][lane], first); + state.combined_transform[col][lane] = spin[col][2].mul_add(flip[2][lane], second); + } + } + } +} + +fn clamp(value: f32, lower: f32, upper: f32) -> f32 { + let value = if lower - value >= 0.0 { lower } else { value }; + if upper - value >= 0.0 { value } else { upper } +} + +fn rotation(axis: [f32; 4], angle: f32) -> Matrix { + let [x, y, z, _] = axis; + let (s, c) = sin_cos(angle); + let t = 1.0 - c; + let (tx, ty, tz) = (t * x, t * y, t * z); + let (sx, sy, sz) = (s * x, s * y, s * z); + let xx = tx.mul_add(x, c); + let yx = ty * x - sz; + let zx = tz.mul_add(x, sy); + // The native 822FB890 permute repeats column.x into lane w. It does not + // create a renderer-style affine matrix with zero w basis lanes. + [ + [xx, tx.mul_add(y, sz), tx * z - sy, xx], + [yx, ty.mul_add(y, c), ty.mul_add(z, sx), yx], + [zx, tz * y - sx, tz.mul_add(z, c), zx], + [0.0; 4], + ] +} diff --git a/crates/skate-core/src/air/body_spin.rs b/crates/skate-core/src/air/body_spin.rs new file mode 100644 index 00000000..c86ecc54 --- /dev/null +++ b/crates/skate-core/src/air/body_spin.rs @@ -0,0 +1,203 @@ +//! Complete TU3 BodySpin::Update (82D8BF58) and history scan (82D8C2D8). +//! Cached curves are explicit inputs, not reconstructed from Skate 2 settings. +use crate::point_graph::PointGraph; + +/// Native bytes +0..176. Preserves the unused +136 word and mode-byte padding. +/// Construction/air-entry lifecycle belongs to the recovered caller, not Default. +#[derive(Clone, Debug, PartialEq)] +pub struct BodySpinState { + words: [u32; 44], +} + +impl BodySpinState { + ///82D8DA98..DAE0 initializes every BodySpin scalar and history entry. + pub fn new() -> Self { + Self { words: [0; 44] } + } + pub fn from_words(words: [u32; 44]) -> Self { + assert!( + words[42] < 30, + "native derivative history index must be 0..30" + ); + Self { words } + } + + pub fn words(&self) -> &[u32; 44] { + &self.words + } + + /// +140, consumed by Reckoning::UpdateAirStates after BodySpin::Update. + pub fn speed(&self) -> f32 { + self.get(140) + } + + fn get(&self, offset: usize) -> f32 { + f32::from_bits(self.words[offset / 4]) + } + + fn set(&mut self, offset: usize, value: f32) { + self.words[offset / 4] = value.to_bits(); + } +} + +/// Native cached inputs at +176/+180, seven 80-byte curves, and 830BD300. +/// The vector threshold's producer must be supplied: no guessed retail default. +pub struct BodySpinSettings { + pub derivative_floor: f32, + pub acceleration_limit: f32, + /// Native +184, +264, +344, +424, +504, +584, +664; x/y at +16/+48. + pub curves: [PointGraph<8>; 7], + pub input_fade_threshold: [f32; 4], +} + +const STEP: f32 = f32::from_bits(0x3C88_8889); // 820849C8, fixed in these functions. + +/// Registers f1/f2/r6/r7, including the native mode-byte publication. +pub fn update( + state: &mut BodySpinState, + settings: &BodySpinSettings, + input: f32, + auto_spin: f32, + in_air: bool, + mode: u8, +) { + update_input(state, input, auto_spin, mode); + if !in_air { + finish_ground(state, input); + return; + } + + update_air(state, settings, input, auto_spin, mode); +} + +/// Ground caller82D8C8F0 supplies auto_spin=0, in_air=false and mode=0. +/// This branch does not read any of the airborne BodySpin tuning curves. +pub fn update_ground(state: &mut BodySpinState, input: f32) { + update_input(state, input, 0.0, 0); + finish_ground(state, input); +} + +fn update_input(state: &mut BodySpinState, input: f32, auto_spin: f32, mode: u8) { + let delta = input - state.get(120); + let derivative = delta.mul_add(1.5, state.get(124) * 0.0); + state.set(164, auto_spin); + state.set(120, input); + state.words[43] = (state.words[43] & 0x00FF_FFFF) | ((mode as u32) << 24); + state.set(124, derivative); + let filtered = if derivative * input > 0.1 { + derivative + } else { + 0.0 + }; + let filtered = native_clamp(filtered, -1.0, 1.0); + state.set(128, filtered); + let index = state.words[42] as usize; + state.set(index * 4, filtered); + state.words[42] = ((index + 1) % 30) as u32; +} + +fn finish_ground(state: &mut BodySpinState, input: f32) { + state.set(152, 0.0); + state.set(144, 0.0); + state.set(140, 0.0); + state.set(132, state.get(132).mul_add(0.8, input * 0.2)); +} + +fn update_air( + state: &mut BodySpinState, + settings: &BodySpinSettings, + input: f32, + auto_spin: f32, + mode: u8, +) { + let alternate = usize::from(mode == 0); + if state.get(152) == 0.0 { + calculate_history_derivative(state, &settings.curves[3 + alternate]); + } + let time = state.get(152); + let normal_acceleration = settings.curves[alternate].evaluate(time); + let auto_acceleration = settings.curves[2].evaluate(time); + let derivative_gain = settings.curves[3 + alternate].evaluate(time); + let candidate = derivative_gain * state.get(128); + if candidate.abs() > state.get(148).abs() { + state.set(148, candidate); + state.set(156, time); + } + let next_time = time + STEP; + state.set(152, next_time); + // vcmpgtfp128. followed by CR6 all-lanes test, not a scalar dead zone. + if settings + .input_fade_threshold + .iter() + .all(|&v| v > state.get(120).abs()) + { + let faded = state.get(132) * 0.96; + state.set(132, faded); + state.set(120, faded); + } else { + state.set(132, state.get(132).mul_add(0.8, input * 0.2)); + } + let proportional = settings.curves[5 + alternate].evaluate(next_time) * state.get(120); + state.set(144, proportional); + if proportional * state.get(148) < 0.0 { + state.set(148, 0.0); + } + let weight = state + .get(148) + .abs() + .mul_add(1.0 - settings.derivative_floor, settings.derivative_floor); + let mut target = -(weight * proportional); + let mut acceleration = normal_acceleration; + let old_speed = state.get(140); + if auto_spin.abs() > f32::from_bits(0x3780_0000) + && (old_speed * auto_spin > 0.0 || old_speed.abs() < 0.02) + { + acceleration = auto_acceleration; + target = native_clamp(auto_spin, -2.0, 2.0); + } + let limited = select( + settings.acceleration_limit - acceleration, + acceleration, + settings.acceleration_limit, + ); + let (lower, upper) = if old_speed > 0.0 { + (-limited, acceleration) + } else { + (-acceleration, limited) + }; + let change = native_clamp(target - old_speed, lower, upper); + let speed = old_speed + change; + state.set(140, speed); + // Both subtractions are present at 82D8C27C..284. + state.set(160, (target - speed) - change); +} + +/// 82D8C2D8 scans the 29 entries before the current write position. +/// The newest entry starts at negative one native step, with strict peak ties. +fn calculate_history_derivative(state: &mut BodySpinState, graph: &PointGraph<8>) { + let end = state.words[42] as usize; + let mut index = (end + 29) % 30; + let mut time = 0.0; + let mut peak: f32 = 0.0; + state.set(148, 0.0); + state.set(156, 0.0); + while index != end { + time -= STEP; + let candidate = graph.evaluate(time) * state.get(index * 4); + if candidate.abs() > peak.abs() { + peak = candidate; + state.set(156, time); + } + index = (index + 29) % 30; + } + state.set(148, peak); +} + +fn select(test: f32, nonnegative: f32, negative: f32) -> f32 { + if test >= 0.0 { nonnegative } else { negative } +} + +fn native_clamp(value: f32, lower: f32, upper: f32) -> f32 { + let value = select(lower - value, lower, value); + select(upper - value, value, upper) +} diff --git a/crates/skate-core/src/air/ground_jump/math.rs b/crates/skate-core/src/air/ground_jump/math.rs new file mode 100644 index 00000000..76f8d8da --- /dev/null +++ b/crates/skate-core/src/air/ground_jump/math.rs @@ -0,0 +1,65 @@ +//! Original arithmetic ordering and two estimate-refinement steps. +use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic}; +type Vector = [f32; 4]; +pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +pub(super) fn dot(a: Vector, b: Vector) -> f32 { + native_arithmetic::dot3(a, b) +} +pub(super) fn add(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn sub(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn scale(a: Vector, b: f32) -> Vector { + a.map(|v| v * b) +} +pub(super) fn madd(a: Vector, b: f32, c: Vector) -> Vector { + std::array::from_fn(|i| a[i].mul_add(b, c[i])) +} +pub(super) fn planar(value: Vector, normal: Vector) -> Vector { + sub(value, scale(normal, dot(value, normal))) +} +pub(super) fn maximum(a: f32, b: f32) -> f32 { + if a - b >= -0.0 { a } else { b } +} +pub(super) fn clamp(v: f32, low: f32, high: f32) -> f32 { + let lower = if low - v >= -0.0 { low } else { v }; + if high - lower >= -0.0 { lower } else { high } +} +pub(super) fn vector_clamp(v: f32, low: f32, high: f32) -> f32 { + native_arithmetic::vector_min(high, native_arithmetic::vector_max(low, v)) +} +pub(super) fn reciprocal(v: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_estimate(v); + for _ in 0..2 { + r = r.mul_add((-r).mul_add(v, 1.0), r); + } + r +} +pub(super) fn square_root(square: f32) -> f32 { + let value = square * inverse_length_squared(square, 2); + if square == 0.0 { 0.0 } else { value } +} +pub(super) fn length(value: Vector) -> f32 { + square_root(dot(value, value)) +} +pub(super) fn normalize(value: Vector) -> Vector { + let square = dot(value, value); + let inverse = inverse_length_squared(square, 2); + let length = if square == 0.0 { 0.0 } else { square * inverse }; + //82F826F8 initializes830BD350 from82181A88 (1e-6), not its IDA BSS zero. + if length > f32::from_bits(0x3586_37bd) { + scale(value, inverse) + } else { + [0.0; 4] + } +} +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0.0, + ] +} diff --git a/crates/skate-core/src/air/ground_jump/mod.rs b/crates/skate-core/src/air/ground_jump/mod.rs new file mode 100644 index 00000000..55b12e66 --- /dev/null +++ b/crates/skate-core/src/air/ground_jump/mod.rs @@ -0,0 +1,77 @@ +//! GroundAnimation's Toolkit_CalcGroundJump, original TU3 82D93618. +//! Stock graph values and live animation/physical inputs determine launch. +mod math; +mod types; +use crate::{ + air::state::{AirMath, PhysicsAirMath}, + trigonometry::acos, +}; +use math::*; +pub use types::{GroundJump, GroundJumpInput, GroundJumpMode, GroundJumpSettings}; + +pub fn calculate( + input: GroundJumpInput, + mode: GroundJumpMode, + s: &GroundJumpSettings, +) -> GroundJump { + if input.flags_2468 & 0x0040_0000 == 0 { + return GroundJump::default(); + } + let normal = input.filtered_ground_normal; + let y_scalar = s.y_scalar_vs_normal_y.evaluate(normal[1]); + let current = planar(input.current_velocity, normal); + let mut prepared = planar(input.prepared_velocity, normal); + if dot(sub(current, prepared), sub(current, prepared)) > 7.0 { + let agreement = vector_clamp(dot(normalize(current), normalize(prepared)), 0.0, 1.0); + let fraction = acos(agreement) * reciprocal(f32::from_bits(0x4049_0fdb)); + let bound = length(prepared) * s.speed_scalar_vs_angle.evaluate(fraction); + prepared = AirMath.clamp_vector_within_max_length(current, bound); + } + let mut velocity = if input.flags_2488 & 0x1000_0000 != 0 { + let mut value = madd(input.effective_forward, 2.3, prepared); + value[1] += 1.0; + value + } else { + let minimum = if input.flags_2484 & 0x800 != 0 { + mode.minimum_height_64 + } else { + mode.minimum_height_68 + }; + let speed_fraction = clamp(input.surface_speed / s.speed_response_max_speed, 0.0, 1.0); + let low = s.minimum_height_vs_speed.evaluate(speed_fraction).mul_add( + minimum - s.absolute_minimum_height, + s.absolute_minimum_height, + ); + let high = s.maximum_height_vs_speed.evaluate(speed_fraction).mul_add( + mode.maximum_height - s.absolute_minimum_height, + s.absolute_minimum_height, + ); + let height = low.mul_add(1.0 - input.jump_strength, high * input.jump_strength); + let current_height = dot( + sub( + input.animation_com_position, + input.ground_reference_position, + ), + input.reference_up, + ); + let remaining = maximum(height - current_height, 0.0); + let square = (remaining * input.gravity_y) * -2.0; + let speed = square_root(square); + let angle = acos(vector_clamp(normal[1], -1.0, 1.0)); + let fraction = clamp(angle * f32::from_bits(0x3f22_f983), 0.0, 1.0); + let response = maximum(s.vertical_response.evaluate(fraction), s.minimum_scalar); + madd(normal, speed * response, prepared) + }; + let bonus = clamp((y_scalar - 1.0) * velocity[1], 0.0, s.maximum_y_bonus); + velocity[1] += bonus; + let projected = dot(velocity, normal); + let side = normalize(cross(UP, input.forward)); + let x = -((input.jump_controls[0] * s.adjust_x_factor) * projected); + let z = (input.jump_controls[1] * s.adjust_z_factor) * projected; + let correction = madd(side, x, scale(cross(side, UP), z)); + GroundJump { + velocity: add(velocity, correction), + scalar_16: 0.0, + active: true, + } +} diff --git a/crates/skate-core/src/air/ground_jump/types.rs b/crates/skate-core/src/air/ground_jump/types.rs new file mode 100644 index 00000000..9332f049 --- /dev/null +++ b/crates/skate-core/src/air/ground_jump/types.rs @@ -0,0 +1,44 @@ +use crate::point_graph::PointGraph; +#[derive(Clone, Copy, Debug, Default)] +pub struct GroundJump { + pub velocity: [f32; 4], //JumpInfo0, metres/second + pub scalar_16: f32, //82D93618 initializes and retains zero + pub active: bool, //JumpInfo20 +} +#[derive(Clone, Copy, Debug)] +pub struct GroundJumpMode { + pub minimum_height_64: f32, //physics_mode Hash_BE3F74F978D777E5, metres + pub minimum_height_68: f32, //JumpMinHeight, metres + pub maximum_height: f32, //JumpMaxHeight72, metres +} +pub struct GroundJumpSettings { + pub vertical_response: PointGraph<16>, //physics_jump0 + pub y_scalar_vs_normal_y: PointGraph<8>, //128 + pub speed_scalar_vs_angle: PointGraph<8>, //208 + pub minimum_height_vs_speed: PointGraph<8>, //288 + pub maximum_height_vs_speed: PointGraph<8>, //352 + pub speed_response_max_speed: f32, //420, metres/second + pub minimum_scalar: f32, //424 + pub maximum_y_bonus: f32, //428, metres/second + pub adjust_z_factor: f32, //432 + pub adjust_x_factor: f32, //436 + pub absolute_minimum_height: f32, //456, metres +} +#[derive(Clone, Copy, Debug)] +pub struct GroundJumpInput { + pub flags_2468: u32, + pub flags_2484: u32, + pub flags_2488: u32, + pub effective_forward: [f32; 4], //Processed160 + pub forward: [f32; 4], //352 + pub current_velocity: [f32; 4], //400 metres/second + pub ground_reference_position: [f32; 4], //480 metres + pub reference_up: [f32; 4], //544 + pub filtered_ground_normal: [f32; 4], //576, BoardToolkit normal192 + pub animation_com_position: [f32; 4], //592 metres + pub prepared_velocity: [f32; 4], //704 metres/second + pub jump_strength: f32, //2624, animation output + pub jump_controls: [f32; 2], //2628/2632, animation output + pub gravity_y: f32, //2648 metres/second squared + pub surface_speed: f32, //2656 metres/second +} diff --git a/crates/skate-core/src/air/ground_normal.rs b/crates/skate-core/src/air/ground_normal.rs new file mode 100644 index 00000000..6f6bbf80 --- /dev/null +++ b/crates/skate-core/src/air/ground_normal.rs @@ -0,0 +1,85 @@ +//! TU3 Reckoning::UpdateGroundNormal 82D8E0F8, including filter 82D7A018. +use crate::physics::reciprocal_sqrt::estimate; + +/// Native reckoning +1280..1376: control, position, previous input, filtered +/// error, output delta and filtered input delta. All four vector lanes persist. +/// Initial values must come from the native constructor/entry path. +#[derive(Clone, Debug, PartialEq)] +pub struct GroundNormalFilter { + words: [u32; 24], +} + +impl GroundNormalFilter { + /// TU3 constructor82D8DA68 initializes current/last target to the seed, + /// and all three accumulated difference vectors to zero. + pub fn initialized(control: [f32; 4], initial: [f32; 4]) -> Self { + let mut words = [0; 24]; + words[..4].copy_from_slice(&control.map(f32::to_bits)); + words[4..8].copy_from_slice(&initial.map(f32::to_bits)); + words[8..12].copy_from_slice(&initial.map(f32::to_bits)); + Self { words } + } + + /// The up-vector filters publish the final corrected up vector only after + /// both filter updates and acceleration/damping in82D8C8F0. + pub fn publish_current(&mut self, current: [f32; 4]) { + self.words[4..8].copy_from_slice(¤t.map(f32::to_bits)); + } + + pub fn filter_raw(&mut self, control: [f32; 4], input: [f32; 4]) -> [f32; 4] { + self.words[..4].copy_from_slice(&control.map(f32::to_bits)); + self.filter(input) + } + + pub fn from_words(words: [u32; 24]) -> Self { + Self { words } + } + + pub fn words(&self) -> &[u32; 24] { + &self.words + } + + /// Copies live layout+1216 control words before each native filter call. + /// Returns the normal published at reckoning+1216 and +1296. There is no + /// invented zero-vector fallback; native degenerate arithmetic is preserved. + pub fn update(&mut self, control: [f32; 4], input: [f32; 4]) -> [f32; 4] { + self.words[..4].copy_from_slice(&control.map(f32::to_bits)); + let filtered = self.filter(input); + let squared = dot3(filtered); + let mut inverse = estimate(squared); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + let normal = filtered.map(|v| v * inverse); + self.words[4..8].copy_from_slice(&normal.map(f32::to_bits)); + normal + } + + // Full vector filter 82D7A018, with publication order before normalization. + pub(crate) fn filter(&mut self, input: [f32; 4]) -> [f32; 4] { + let previous = self.words.map(f32::from_bits); + let blend = previous[3]; + let retained = 1.0 - blend; + let mut output = [0.0; 4]; + for lane in 0..4 { + let error = input[lane] - previous[4 + lane]; + let error_blend = previous[12 + lane].mul_add(retained, error * blend); + let input_delta = input[lane] - previous[8 + lane]; + let input_blend = previous[20 + lane].mul_add(retained, input_delta * blend); + let predicted = error_blend.mul_add(previous[1], previous[4 + lane]); + let predicted = error.mul_add(previous[0], predicted); + output[lane] = (input_blend - previous[16 + lane]).mul_add(previous[2], predicted); + self.words[12 + lane] = error_blend.to_bits(); + self.words[20 + lane] = input_blend.to_bits(); + self.words[16 + lane] = (output[lane] - previous[4 + lane]).to_bits(); + self.words[4 + lane] = output[lane].to_bits(); + self.words[8 + lane] = input[lane].to_bits(); + } + output + } +} + +fn dot3(value: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(value, value) +} diff --git a/crates/skate-core/src/air/known/body.rs b/crates/skate-core/src/air/known/body.rs new file mode 100644 index 00000000..e57739a4 --- /dev/null +++ b/crates/skate-core/src/air/known/body.rs @@ -0,0 +1,155 @@ +//! Body flip and spin targets selected by TU3 KnownAir. + +use super::{ + data::{ + KnownAirFrame, KnownAirModeSettings, KnownAirReckoningFields, KnownAirSettings, + KnownAirState, Vector4, + }, + runtime::KnownAirRuntime, + trajectory::select, +}; + +const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); // 0x8206D110. +const RADIANS_TO_DEGREES: f32 = f32::from_bits(0x4265_2EE1); // 0x82084620. +const DIRECTION_EPSILON: f32 = f32::from_bits(0x3A83_126F); // 0x82063A48. +const PI: f32 = f32::from_bits(0x4049_0FDB); // 0x82060C44. +const ALIGNMENT_DELAY: f32 = f32::from_bits(0x3D03_126F); // 0x822F91E8. +const AUTO_SPIN_SCALE: f32 = f32::from_bits(0x3F99_999A); // 0x821659F4. +const FORTY_FIVE_DEGREES: f32 = f32::from_bits(0x4234_0000); // 0x82099018. +const HALF: f32 = f32::from_bits(0x3F00_0000); // 0x8209975C. +const REVERSE_SCALE: f32 = f32::from_bits(0x3BA3_D70A); // 0x82099920. +const DECELERATION_SCALE: f32 = f32::from_bits(0x3F40_0000); // 0x821814A0. + +/// `PhysState_KnownAir::CalculateBodyFlipSpeed`, TU3 `0x82D35F40`. +pub fn calculate_body_flip_speed( + state: &mut KnownAirState, + frame: &KnownAirFrame, + settings: &KnownAirSettings, + mode: &KnownAirModeSettings, + reckoning: &KnownAirReckoningFields, + runtime: &mut impl KnownAirRuntime, +) -> f32 { + if !state.body_flipping_211 { + let flags = frame.flags_2468; + let has_flip = flags & 0x0000_0020 != 0 || flags & 0x0000_0010 != 0; + let has_grab = flags & 0x0000_0080 != 0 || flags & 0x0000_0040 != 0; + if has_flip && has_grab && frame.flags_2488 & 0x0200_0000 == 0 { + let threshold = settings + .flip_start_collision_time_vs_normal_y + .evaluate(reckoning.landing_normal_1152[1]); + if state.collision_time_196 > threshold { + runtime.begin_reckoning_body_flip((flags >> 7) & 1 != 0); + state.body_flipping_211 = true; + } + } + } + + if mode.perfect_body_flips_28 { + return state.body_flip_target_speed_204; + } + let flags = frame.flags_2468; + if flags & 0x0000_0020 != 0 + || flags & 0x0000_0010 != 0 + || state.time_in_state_180 < settings.body_flip_min_grab_time_fraction_456 + { + state.body_flip_target_speed_204 + } else { + 0.0 + } +} + +/// `PhysState_KnownAir::CalculateBodySpinSpeed`, TU3 `0x82D360B0`. +pub fn calculate_body_spin_speed( + state: &KnownAirState, + frame: &KnownAirFrame, + settings: &KnownAirSettings, + mode: &KnownAirModeSettings, + reckoning: &KnownAirReckoningFields, + runtime: &mut impl KnownAirRuntime, +) -> f32 { + let scaled_input = frame.body_spin_input_2640 * settings.max_spin_speed_428; + let manual_spin = scaled_input * DEGREES_TO_RADIANS; + let mut result = if frame.flags_2472 & 0x1000_0000 != 0 { + manual_spin + } else { + 0.0 + }; + + // Native tests the unmasked manual value, not `result`. + if manual_spin == 0.0 && state.landing_heading_valid_209 && frame.flags_2484 & 0x0000_8000 == 0 + { + let mut heading_axis = reckoning.heading_axis_1200; + if ((frame.flags_2468 >> 20) & 1 != 0) != state.start_flipped_210 { + heading_axis = flip_sign_bits(heading_axis); + } + + let mut unwrapped_angle = 0.0; + if runtime.dot3(reckoning.landing_normal_1152, state.landing_normal_64) > DIRECTION_EPSILON + { + unwrapped_angle = runtime.signed_angle_about_axis( + heading_axis, + state.landing_heading_80, + reckoning.landing_normal_1152, + ); + if frame.flags_2468 & 0x0010_0000 != 0 { + unwrapped_angle += PI; + } + } + + let mut corrected_angle = runtime.wrap_angle_8258db98(unwrapped_angle); + let heading_degrees = corrected_angle * RADIANS_TO_DEGREES; + let max_adjust = settings + .max_heading_adjust_vs_up_y_160 + .evaluate(frame.skater_up_544[1]); + if heading_degrees.abs() > max_adjust { + let wrapped_pi = runtime.wrap_angle_8258db98(PI); + corrected_angle = runtime.wrap_angle_8258db98(wrapped_pi + corrected_angle); + } + + let mut current_speed = reckoning.body_spin_speed_1572; + let remaining = state.collision_time_196 - state.time_in_state_180; + let alignment_time = select(remaining - ALIGNMENT_DELAY, remaining, ALIGNMENT_DELAY); + let reciprocal_time = 1.0 / alignment_time; + let mut target = (reciprocal_time * corrected_angle) * AUTO_SPIN_SCALE; + + if current_speed.abs() < DIRECTION_EPSILON { + current_speed = 0.0; + } + if current_speed * target < 0.0 { + let alternate = runtime.wrap_angle_8258db98(unwrapped_angle + PI); + if FORTY_FIVE_DEGREES * DEGREES_TO_RADIANS > alternate.abs() + && (reciprocal_time * alternate) * current_speed > 0.0 + { + target = reciprocal_time * alternate; + } + } + + result = native_clamp( + target, + -mode.body_spin_speed_limit_56, + mode.body_spin_speed_limit_56, + ); + if result.abs() < settings.min_auto_body_speed_424 { + result *= HALF; + } + if unwrapped_angle.abs() < settings.min_auto_body_speed_424 { + result = (unwrapped_angle / frame.delta_time_2604) * HALF; + } + if result * current_speed < 0.0 { + result *= REVERSE_SCALE; + } else if current_speed.abs() > result.abs() { + result *= DECELERATION_SCALE; + } + } + + result +} + +fn flip_sign_bits(vector: Vector4) -> Vector4 { + vector.map(|lane| f32::from_bits(lane.to_bits() ^ 0x8000_0000)) +} + +fn native_clamp(value: f32, lower: f32, upper: f32) -> f32 { + let value = select(lower - value, lower, value); + select(upper - value, value, upper) +} diff --git a/crates/skate-core/src/air/known/data.rs b/crates/skate-core/src/air/known/data.rs new file mode 100644 index 00000000..494f6bbe --- /dev/null +++ b/crates/skate-core/src/air/known/data.rs @@ -0,0 +1,193 @@ +//! Persistent state and direct inputs for TU3 `PhysState_KnownAir`. + +use crate::point_graph::PointGraph; + +pub type Vector4 = [f32; 4]; + +/// The 64-byte trajectory copied by `FillPhysOut` at `0x82D36A78`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirTrajectory { + pub position: Vector4, + pub velocity: Vector4, + pub acceleration: Vector4, + pub scalar_48: f32, + pub word_52: u32, + pub word_56: u32, + pub word_60: u32, +} + +/// Winning trajectory result at selector+1680. The collision frame lies at +/// result+128, outside the copied 64-byte trajectory at result+144. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirPrediction { + pub trajectory: KnownAirTrajectory, + /// Winning PredictionResults+48, distinct from request horizon at +192. + pub collision_time_48: f32, + pub collision_frame_128: i32, +} + +/// Fields owned by the KnownAir object at native offsets +64..+216. +/// Construction remains caller-owned because Enter does not initialize every +/// field before `InitTrajectoryInfo` and the selector helpers run. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirState { + pub landing_normal_64: Vector4, + pub landing_heading_80: Vector4, + pub trajectory_apex_96: Vector4, + pub collision_position_112: Vector4, + pub selector_vector_128: Vector4, + pub trajectory_follow_offset_144: Vector4, + pub target_com_position_160: Vector4, + pub collision_normal_speed_176: f32, + pub time_in_state_180: f32, + pub start_y_184: f32, + pub max_y_188: f32, + pub com_max_y_192: f32, + pub collision_time_196: f32, + pub time_to_apex_200: f32, + pub body_flip_target_speed_204: f32, + pub reached_apex_208: bool, + pub landing_heading_valid_209: bool, + pub start_flipped_210: bool, + pub body_flipping_211: bool, + pub grind_air_adjust_activated_212: bool, + pub targeting_grind_213: bool, + pub trajectory_index_216: i32, +} + +/// Direct ProcessedPhysIn fields consumed by KnownAir. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirFrame { + pub start_flip_reference_96: Vector4, + pub alternate_head_target_112: Vector4, + pub velocity_400: Vector4, + pub ground_normal_464: Vector4, + pub start_height_484: f32, + pub skater_up_544: Vector4, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub flags_2488: u32, + pub next_physics_state_2500: i32, + pub delta_time_2604: f32, + pub forward_speed_2612: f32, + pub body_spin_input_2640: f32, + pub selector_landing_normal_2656: Vector4, +} + +/// Cached `physics_airstates/default.xml` values used by this state. Values +/// must come from the stock collection adapter; no retail defaults live here. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirSettings { + /// Settings +160; x at +176, y at +208. + pub max_heading_adjust_vs_up_y_160: PointGraph<8>, + /// Settings +240; x at +256, y at +288. + pub landing_speed_scalar_vs_ground_normal_y_240: PointGraph<8>, + /// Attribute hash `AA79C93673533C5B`; its recovered name remains unknown. + pub flip_start_collision_time_vs_normal_y: PointGraph<8>, + pub trajectory_error_blend_away_time_384: f32, + pub min_target_heading_velocity_420: f32, + pub min_auto_body_speed_424: f32, + pub max_spin_speed_428: f32, + pub frames_for_grind_air_assist_436: f32, + pub body_flip_min_grab_time_fraction_456: f32, + /// `physics_reckoning/FlipScalar`, hash `978CE73B0EFE76C8`. + pub flip_scalar: f32, +} + +/// Fields read through ProcessedPhysIn+2548 and its selected physics mode. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirModeSettings { + pub upside_down_falling_wipeout_enabled_2: bool, + pub perfect_body_flips_28: bool, + pub body_spin_speed_limit_56: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirWipeoutSettings { + pub air_falling_min_up_y_260: f32, + pub air_falling_max_angle_264: f32, +} + +/// Direct Reckoning fields read or cleared by KnownAir. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirReckoningFields { + pub landing_normal_1152: Vector4, + pub heading_axis_1200: Vector4, + pub body_spin_angle_1568: f32, + pub body_spin_speed_1572: f32, +} + +/// Direct writes to the wipeout request object at +35, +116, and +200. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirWipeoutRequest { + pub requested_35: bool, + pub scalar_116: f32, + pub counter_200: u32, +} + +/// Six skeleton bodies disabled, in this exact native order, while the grind +/// air adjustment owns the feet. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GrindFootBody { + Body3152, + Body3156, + Body3160, + Body3168, + Body3172, + Body3176, +} + +/// Trajectory packet passed to the three recovered footplant updates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirFootplantInput { + pub trajectory: KnownAirTrajectory, + pub remaining_collision_time: f32, + pub collision_position: Vector4, + pub landing_normal: Vector4, +} + +/// Native transform at Reckoning+816, interpreted only by the required math +/// adapter because Xenon lane order remains part of that contract. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AffineTransform { + pub vectors: [Vector4; 4], +} + +/// Stable inputs/outputs around the unresolved VMX geometry in `0x82D35998`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RestoreVelocityGeometry { + pub tangential_velocity: Vector4, + pub landing_speed_curve_input: f32, + /// Source clamped to [0, 1] by the two native `fsel` operations. + pub landing_speed_blend_source: f32, +} + +/// Fields written by `FillPhysOut` (`0x82D36880`). The two conditional fields +/// retain their previous value when flags+2468 bit 3 is clear. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct KnownAirOutput { + pub trajectory_apex_0: Vector4, + pub collision_position_16: Vector4, + pub landing_normal_32: Vector4, + pub selector_vector_48: Vector4, + pub trajectory_position_64: Vector4, + pub landing_heading_80: Vector4, + pub selector_com_position_96: Vector4, + pub landing_normal_copy_144: Vector4, + pub locked_trajectory_velocity_160: Vector4, + pub time_in_state_176: f32, + pub collision_time_180: f32, + pub time_until_collision_184: f32, + pub collision_normal_speed_188: f32, + pub time_to_apex_196: f32, + pub jump_height_200: f32, + pub trajectory_index_220: i32, + pub selected_trajectory_240: KnownAirTrajectory, + pub trajectory_plane_samples_336: [f32; 25], + pub reached_apex_436: bool, + pub known_air_valid_437: bool, + pub locked_trajectory_velocity_valid_452: bool, +} diff --git a/crates/skate-core/src/air/known/footplant.rs b/crates/skate-core/src/air/known/footplant.rs new file mode 100644 index 00000000..cae109f5 --- /dev/null +++ b/crates/skate-core/src/air/known/footplant.rs @@ -0,0 +1,34 @@ +//! Footplant branch embedded in TU3 KnownAir update. + +use super::{ + data::{KnownAirFootplantInput, KnownAirFrame, KnownAirState}, + runtime::KnownAirRuntime, +}; + +const MIN_FOOTPLANT_TIME: f32 = f32::from_bits(0x3DCC_CCCD); // 0x820641A8. + +/// `0x82D36728`, preserving the reset gate and the three update-call order. +pub(crate) fn update_footplant( + state: &KnownAirState, + frame: &KnownAirFrame, + runtime: &mut impl KnownAirRuntime, +) { + if frame.flags_2480 & 0x0400_0000 == 0 + || frame.flags_2476 & 0x0400_0000 != 0 + || state.time_in_state_180 <= MIN_FOOTPLANT_TIME + { + runtime.reset_footplants(); + return; + } + + let mut input = KnownAirFootplantInput { + trajectory: runtime.selector_trajectory_2704(), + remaining_collision_time: state.collision_time_196 - state.time_in_state_180, + collision_position: state.collision_position_112, + landing_normal: state.landing_normal_64, + }; + runtime.shift_footplant_trajectory_to_index(&mut input.trajectory, state.trajectory_index_216); + runtime.update_footplant_prediction(&input); + runtime.update_footplant_lock(&input); + runtime.update_footplant_pose(&input); +} diff --git a/crates/skate-core/src/air/known/lifecycle.rs b/crates/skate-core/src/air/known/lifecycle.rs new file mode 100644 index 00000000..a23eb238 --- /dev/null +++ b/crates/skate-core/src/air/known/lifecycle.rs @@ -0,0 +1,73 @@ +//! TU3 KnownAir entry and exit lifecycle. + +use super::{ + data::{ + KnownAirFrame, KnownAirModeSettings, KnownAirReckoningFields, KnownAirSettings, + KnownAirState, + }, + runtime::KnownAirRuntime, + trajectory::{init_trajectory_info, restore_velocity}, +}; + +const ZERO_VECTOR: [f32; 4] = [0.0; 4]; // 0x830BD350. + +/// `PhysState_KnownAir::Enter`, TU3 `0x82D352D0`. +pub fn enter( + state: &mut KnownAirState, + frame: &KnownAirFrame, + settings: &KnownAirSettings, + mode: &KnownAirModeSettings, + runtime: &mut impl KnownAirRuntime, +) { + runtime.enable_board_angular_drive_only(); + runtime.set_air_collision_update_enabled(true); + runtime.set_skeleton_collision_state(7); + runtime.set_skeleton_physics_truck_tilt_enabled(false); + runtime.set_skeleton_inverse_kinematics_enabled(true); + + state.body_flipping_211 = false; + state.grind_air_adjust_activated_212 = false; + state.targeting_grind_213 = false; + runtime.reset_reckoning_flipping(); + runtime.set_footplant_flag_240(false); + runtime.reset_footplants(); + + state.reached_apex_208 = false; + state.time_in_state_180 = 0.0; + state.start_y_184 = frame.start_height_484; + state.max_y_188 = runtime.board_transform_height(); + state.com_max_y_192 = runtime.skeleton_com_position()[1]; + + init_trajectory_info(state, frame, settings, mode, runtime); + + if runtime.selector_targeting_grind() { + runtime.start_grind_air_adjust_from_selector(); + state.targeting_grind_213 = true; + } + + let skeleton_com = runtime.skeleton_com_position(); + let (index, offset) = runtime.selector_closest_trajectory_point(skeleton_com, ZERO_VECTOR); + state.trajectory_index_216 = index; + state.trajectory_follow_offset_144 = offset; + + state.start_flipped_210 = 0.0 > runtime.dot3(frame.velocity_400, frame.start_flip_reference_96); +} + +/// `PhysState_KnownAir::Exit`, TU3 `0x82D35920`. +pub fn exit( + state: &mut KnownAirState, + frame: &mut KnownAirFrame, + settings: &KnownAirSettings, + reckoning: &mut KnownAirReckoningFields, + runtime: &mut impl KnownAirRuntime, +) { + runtime.reset_reckoning_flipping(); + if frame.next_physics_state_2500 == 100 { + restore_velocity(state, frame, settings, runtime); + } + runtime.reset_trajectory_selector(); + reckoning.body_spin_speed_1572 = 0.0; + reckoning.body_spin_angle_1568 = 0.0; + runtime.set_grind_air_adjust_started(false); + runtime.set_grind_air_adjust_activated(false); +} diff --git a/crates/skate-core/src/air/known/mod.rs b/crates/skate-core/src/air/known/mod.rs new file mode 100644 index 00000000..daf992d8 --- /dev/null +++ b/crates/skate-core/src/air/known/mod.rs @@ -0,0 +1,33 @@ +//! Readable TU3 `PhysState_KnownAir` (state 201). +//! +//! The routines in this module preserve the observed Skate 3 call, branch, +//! field, and constant order. Engine-owned objects and Xenon-specific vector +//! operations are mandatory adapter contracts; this module supplies no +//! guessed fallbacks. + +mod body; +mod data; +mod footplant; +mod lifecycle; +mod output; +mod post_physics; +mod runtime; +mod trajectory; +mod update; + +pub use body::{calculate_body_flip_speed, calculate_body_spin_speed}; +pub use data::{ + AffineTransform, GrindFootBody, KnownAirFootplantInput, KnownAirFrame, KnownAirModeSettings, + KnownAirOutput, KnownAirPrediction, KnownAirReckoningFields, KnownAirSettings, KnownAirState, + KnownAirTrajectory, KnownAirWipeoutRequest, KnownAirWipeoutSettings, RestoreVelocityGeometry, + Vector4, +}; +pub use lifecycle::{enter, exit}; +pub use output::fill_physics_output; +pub use post_physics::{check_upside_down_falling_wipeout, update_post_physics}; +pub use runtime::{KnownAirMath, KnownAirRuntime}; +pub use trajectory::{init_trajectory_info, restore_velocity, update_trajectory_follow}; +pub use update::update; + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/air/known/output.rs b/crates/skate-core/src/air/known/output.rs new file mode 100644 index 00000000..0526243c --- /dev/null +++ b/crates/skate-core/src/air/known/output.rs @@ -0,0 +1,62 @@ +//! TU3 KnownAir PhysOut publication. + +use super::{ + data::{KnownAirFrame, KnownAirOutput, KnownAirState}, + runtime::KnownAirRuntime, + trajectory::{add, sub, trajectory_position, trajectory_velocity}, +}; + +const FIXED_STEP: f32 = f32::from_bits(0x3C88_8889); // 0x820849C8, 1/60. +const MAX_FINITE: f32 = f32::from_bits(0x7F7F_FFFF); // 0x8206D108. + +/// `PhysState_KnownAir::FillPhysOut`, TU3 `0x82D36880`. +pub fn fill_physics_output( + state: &KnownAirState, + frame: &KnownAirFrame, + output: &mut KnownAirOutput, + runtime: &mut impl KnownAirRuntime, +) { + let prediction = runtime.selected_prediction(); + if frame.flags_2468 & 0x0000_0008 != 0 { + output.locked_trajectory_velocity_valid_452 = true; + let time = state.trajectory_index_216 as f32 * FIXED_STEP; + output.locked_trajectory_velocity_160 = trajectory_velocity(&prediction.trajectory, time); + } + + output.reached_apex_436 = state.reached_apex_208; + output.jump_height_200 = state.max_y_188 - state.start_y_184; + output.trajectory_apex_0 = state.trajectory_apex_96; + output.time_to_apex_196 = state.time_to_apex_200; + output.collision_position_16 = state.collision_position_112; + output.landing_normal_32 = state.landing_normal_64; + output.landing_normal_copy_144 = state.landing_normal_64; + output.time_until_collision_184 = if frame.flags_2468 & 0x0000_0008 != 0 { + MAX_FINITE + } else { + state.collision_time_196 - state.time_in_state_180 + }; + output.time_in_state_176 = state.time_in_state_180; + output.selector_vector_48 = state.selector_vector_128; + + let output_index = state.trajectory_index_216.max(1); + output.trajectory_position_64 = + trajectory_position(&prediction.trajectory, output_index as f32 * FIXED_STEP); + output.landing_heading_80 = state.landing_heading_80; + output.collision_normal_speed_188 = state.collision_normal_speed_176; + output.known_air_valid_437 = true; + output.selected_trajectory_240 = prediction.trajectory; + output.trajectory_index_220 = state.trajectory_index_216; + + let selector_com = runtime.selector_com_position_2784(); + output.selector_com_position_96 = selector_com; + output.collision_time_180 = state.collision_time_196; + let com_transform = runtime.reckoning_com_transform_816(); + let local_com = runtime.transform_point_82d36880(com_transform, selector_com); + let first_sample = state.trajectory_index_216.wrapping_sub(1).max(0); + for (sample_offset, destination) in output.trajectory_plane_samples_336.iter_mut().enumerate() { + let sample_index = first_sample.wrapping_add(sample_offset as i32); + let sample = trajectory_position(&prediction.trajectory, sample_index as f32 * FIXED_STEP); + let relative = sub(add(sample, local_com), state.collision_position_112); + *destination = runtime.dot3(relative, state.landing_normal_64); + } +} diff --git a/crates/skate-core/src/air/known/post_physics.rs b/crates/skate-core/src/air/known/post_physics.rs new file mode 100644 index 00000000..82fec84d --- /dev/null +++ b/crates/skate-core/src/air/known/post_physics.rs @@ -0,0 +1,55 @@ +//! TU3 KnownAir post-physics and upside-down wipeout handling. + +use super::{ + data::{ + KnownAirFrame, KnownAirModeSettings, KnownAirState, KnownAirWipeoutRequest, + KnownAirWipeoutSettings, + }, + runtime::KnownAirRuntime, +}; + +/// `PhysState_KnownAir::UpdatePostPhysics`, TU3 `0x82D36590`. +pub fn update_post_physics( + state: &mut KnownAirState, + frame: &KnownAirFrame, + mode: &KnownAirModeSettings, + settings: &KnownAirWipeoutSettings, + request: &mut KnownAirWipeoutRequest, + runtime: &mut impl KnownAirRuntime, +) { + if !state.reached_apex_208 { + let velocity = runtime.board_body_velocity(); + if velocity[1] < 0.0 { + state.reached_apex_208 = true; + } + } + runtime.check_for_air_wipeout(true); + check_upside_down_falling_wipeout(state, frame, mode, settings, request, runtime); +} + +/// `PhysState_KnownAir::CheckUpsideDownFallingWipeout`, TU3 `0x82D36650`. +pub fn check_upside_down_falling_wipeout( + state: &KnownAirState, + frame: &KnownAirFrame, + mode: &KnownAirModeSettings, + settings: &KnownAirWipeoutSettings, + request: &mut KnownAirWipeoutRequest, + runtime: &mut impl KnownAirRuntime, +) { + if !mode.upside_down_falling_wipeout_enabled_2 { + return; + } + if frame.flags_2468 & 0x0000_0020 != 0 { + return; + } + if !(frame.skater_up_544[1] < settings.air_falling_min_up_y_260) { + return; + } + if runtime.dot3(state.landing_normal_64, frame.skater_up_544) + < settings.air_falling_max_angle_264 + { + request.requested_35 = true; + request.scalar_116 = 0.0; + request.counter_200 = request.counter_200.wrapping_add(1); + } +} diff --git a/crates/skate-core/src/air/known/runtime.rs b/crates/skate-core/src/air/known/runtime.rs new file mode 100644 index 00000000..bc03c97e --- /dev/null +++ b/crates/skate-core/src/air/known/runtime.rs @@ -0,0 +1,98 @@ +//! Mandatory engine and Xenon-math boundaries for TU3 KnownAir. + +use super::data::{ + AffineTransform, GrindFootBody, KnownAirFootplantInput, KnownAirPrediction, KnownAirTrajectory, + RestoreVelocityGeometry, Vector4, +}; + +/// Xenon operations whose exact estimate/refinement, lane, or angle behavior +/// is observable in the recovered routines. There is deliberately no default +/// implementation or scalar substitute. +pub trait KnownAirMath { + /// `vmsum3fp128` including its native rounding and lane behavior. + fn dot3(&mut self, left: Vector4, right: Vector4) -> f32; + /// The `vrefp` reciprocal refinement sequence used for finite differences. + fn divide_vector_by_scalar(&mut self, vector: Vector4, scalar: f32) -> Vector4; + /// The two `vrsqrtefp128` refinements, zero selection, and returned length. + fn normalize_safe_with_length(&mut self, vector: Vector4) -> (Vector4, f32); + /// `0x8286CD88`, including its native orientation and degeneracy behavior. + fn signed_angle_about_axis(&mut self, from: Vector4, to: Vector4, axis: Vector4) -> f32; + /// `0x8258DB98` exactly; callers retain every observed invocation. + fn wrap_angle_8258db98(&mut self, angle: f32) -> f32; + /// VMX geometry at `0x82D35A0C..0x82D35B78`. The surrounding trajectory + /// evaluation, curve evaluation, blend, board write, and speed publication + /// remain explicit in readable Rust. + fn restore_velocity_geometry_82d35998( + &mut self, + trajectory_velocity: Vector4, + ground_normal: Vector4, + ) -> RestoreVelocityGeometry; + /// Affine point transform at `0x82D36AE4..0x82D36B30`. + fn transform_point_82d36880(&mut self, transform: AffineTransform, point: Vector4) -> Vector4; +} + +/// Every external call and direct engine-owned object mutation performed by +/// the recovered KnownAir routines. Implementers must bind all methods. +pub trait KnownAirRuntime: KnownAirMath { + fn enable_board_angular_drive_only(&mut self); + fn set_air_collision_update_enabled(&mut self, enabled: bool); + fn set_skeleton_collision_state(&mut self, state: u32); + fn set_skeleton_physics_truck_tilt_enabled(&mut self, enabled: bool); + fn set_skeleton_inverse_kinematics_enabled(&mut self, enabled: bool); + fn reset_reckoning_flipping(&mut self); + fn set_footplant_flag_240(&mut self, value: bool); + fn reset_footplants(&mut self); + fn board_transform_height(&mut self) -> f32; + fn skeleton_com_position(&mut self) -> Vector4; + + fn selector_targeting_grind(&self) -> bool; + fn start_grind_air_adjust_from_selector(&mut self); + fn selector_closest_trajectory_point( + &mut self, + skeleton_com: Vector4, + zero: Vector4, + ) -> (i32, Vector4); + fn selected_prediction(&self) -> KnownAirPrediction; + /// Selector+2704, distinct from the winning result trajectory at +1680. + fn selector_trajectory_2704(&self) -> KnownAirTrajectory; + fn selector_contact_position(&mut self) -> Vector4; + fn selector_vector_2848(&self) -> Vector4; + fn selector_com_position_2784(&self) -> Vector4; + fn highest_trajectory_position(&mut self, trajectory: &KnownAirTrajectory) -> (Vector4, f32); + fn selector_has_just_changed(&self) -> bool; + fn reset_trajectory_selector(&mut self); + + fn begin_reckoning_body_flip(&mut self, side: bool); + fn update_air_collision(&mut self); + fn grind_air_adjust_started(&self) -> bool; + fn set_grind_air_adjust_started(&mut self, value: bool); + fn set_grind_air_adjust_activated(&mut self, value: bool); + fn grind_air_adjust_adjusting(&self) -> bool; + fn disable_grind_foot_collision(&mut self, body: GrindFootBody); + fn update_reckoning_air_states( + &mut self, + landing_normal: Vector4, + alignment_weight: f32, + body_spin_speed: f32, + body_flip_speed: f32, + ); + fn update_known_air_skeleton(&mut self, target_com_position: Vector4); + fn set_skeleton_add_skateboard_error(&mut self, value: bool); + fn set_head_tracking_target(&mut self, target: Vector4, valid: bool); + fn update_board_steering_tilt(&mut self, tilt: f32); + + fn shift_footplant_trajectory_to_index( + &mut self, + trajectory: &mut KnownAirTrajectory, + index: i32, + ); + fn update_footplant_prediction(&mut self, input: &KnownAirFootplantInput); + fn update_footplant_lock(&mut self, input: &KnownAirFootplantInput); + fn update_footplant_pose(&mut self, input: &KnownAirFootplantInput); + + fn board_body_velocity(&mut self) -> Vector4; + fn set_board_velocity(&mut self, velocity: Vector4); + fn board_forward_axis(&mut self) -> Vector4; + fn check_for_air_wipeout(&mut self, use_com: bool); + fn reckoning_com_transform_816(&self) -> AffineTransform; +} diff --git a/crates/skate-core/src/air/known/tests/mock.rs b/crates/skate-core/src/air/known/tests/mock.rs new file mode 100644 index 00000000..4b6ae624 --- /dev/null +++ b/crates/skate-core/src/air/known/tests/mock.rs @@ -0,0 +1,376 @@ +use crate::point_graph::PointGraph; + +use super::super::*; + +pub fn flat_graph(value: f32) -> PointGraph<8> { + PointGraph { + x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], + y: [value; 8], + } +} + +pub fn trajectory() -> KnownAirTrajectory { + KnownAirTrajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + scalar_48: 1.0, + word_52: 0, + word_56: 0, + word_60: 0, + } +} + +pub fn state() -> KnownAirState { + KnownAirState { + landing_normal_64: [0.0, 1.0, 0.0, 0.0], + landing_heading_80: [1.0, 0.0, 0.0, 0.0], + trajectory_apex_96: [0.0; 4], + collision_position_112: [0.0; 4], + selector_vector_128: [0.0; 4], + trajectory_follow_offset_144: [0.0; 4], + target_com_position_160: [0.0; 4], + collision_normal_speed_176: 0.0, + time_in_state_180: 0.0, + start_y_184: 0.0, + max_y_188: 0.0, + com_max_y_192: 0.0, + collision_time_196: 1.0, + time_to_apex_200: 0.0, + body_flip_target_speed_204: 0.0, + reached_apex_208: false, + landing_heading_valid_209: false, + start_flipped_210: false, + body_flipping_211: false, + grind_air_adjust_activated_212: false, + targeting_grind_213: false, + trajectory_index_216: 0, + } +} + +pub fn frame() -> KnownAirFrame { + KnownAirFrame { + start_flip_reference_96: [0.0; 4], + alternate_head_target_112: [0.0; 4], + velocity_400: [0.0; 4], + ground_normal_464: [0.0, 1.0, 0.0, 0.0], + start_height_484: 0.0, + skater_up_544: [0.0, 1.0, 0.0, 0.0], + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + flags_2488: 0, + next_physics_state_2500: 0, + delta_time_2604: 1.0 / 60.0, + forward_speed_2612: 0.0, + body_spin_input_2640: 0.0, + selector_landing_normal_2656: [0.0, 1.0, 0.0, 0.0], + } +} + +pub fn settings() -> KnownAirSettings { + KnownAirSettings { + max_heading_adjust_vs_up_y_160: flat_graph(180.0), + landing_speed_scalar_vs_ground_normal_y_240: flat_graph(1.0), + flip_start_collision_time_vs_normal_y: flat_graph(10.0), + trajectory_error_blend_away_time_384: 1.0, + min_target_heading_velocity_420: 0.1, + min_auto_body_speed_424: 0.01, + max_spin_speed_428: 360.0, + frames_for_grind_air_assist_436: 4.0, + body_flip_min_grab_time_fraction_456: 0.25, + flip_scalar: 1.0, + } +} + +pub fn mode() -> KnownAirModeSettings { + KnownAirModeSettings { + upside_down_falling_wipeout_enabled_2: true, + perfect_body_flips_28: false, + body_spin_speed_limit_56: 20.0, + } +} + +pub fn reckoning() -> KnownAirReckoningFields { + KnownAirReckoningFields { + landing_normal_1152: [0.0, 1.0, 0.0, 0.0], + heading_axis_1200: [1.0, 0.0, 0.0, 0.0], + body_spin_angle_1568: 0.0, + body_spin_speed_1572: 0.0, + } +} + +pub fn output() -> KnownAirOutput { + KnownAirOutput { + trajectory_apex_0: [0.0; 4], + collision_position_16: [0.0; 4], + landing_normal_32: [0.0; 4], + selector_vector_48: [0.0; 4], + trajectory_position_64: [0.0; 4], + landing_heading_80: [0.0; 4], + selector_com_position_96: [0.0; 4], + landing_normal_copy_144: [0.0; 4], + locked_trajectory_velocity_160: [0.0; 4], + time_in_state_176: 0.0, + collision_time_180: 0.0, + time_until_collision_184: 0.0, + collision_normal_speed_188: 0.0, + time_to_apex_196: 0.0, + jump_height_200: 0.0, + trajectory_index_220: 0, + selected_trajectory_240: trajectory(), + trajectory_plane_samples_336: [0.0; 25], + reached_apex_436: false, + known_air_valid_437: false, + locked_trajectory_velocity_valid_452: false, + } +} + +pub struct MockRuntime { + pub calls: Vec, + pub prediction: KnownAirPrediction, + pub selector_trajectory: KnownAirTrajectory, + pub contact: Vector4, + pub selector_vector: Vector4, + pub selector_com: Vector4, + pub highest: (Vector4, f32), + pub board_y: f32, + pub com: Vector4, + pub targeting_grind: bool, + pub closest: (i32, Vector4), + pub grind_started: bool, + pub grind_adjusting: bool, + pub board_velocity: Vector4, + pub written_board_velocity: Vector4, + pub forward: Vector4, + pub signed_angle: f32, + pub restore_geometry: RestoreVelocityGeometry, +} + +impl MockRuntime { + pub fn new() -> Self { + Self { + calls: Vec::new(), + prediction: KnownAirPrediction { + trajectory: trajectory(), + collision_time_48: 1.0, + collision_frame_128: 2, + }, + selector_trajectory: trajectory(), + contact: [0.0; 4], + selector_vector: [0.0; 4], + selector_com: [0.0; 4], + highest: ([0.0; 4], 0.0), + board_y: 0.0, + com: [0.0; 4], + targeting_grind: false, + closest: (0, [0.0; 4]), + grind_started: false, + grind_adjusting: false, + board_velocity: [0.0; 4], + written_board_velocity: [0.0; 4], + forward: [1.0, 0.0, 0.0, 0.0], + signed_angle: 0.0, + restore_geometry: RestoreVelocityGeometry { + tangential_velocity: [0.0; 4], + landing_speed_curve_input: 0.0, + landing_speed_blend_source: 0.0, + }, + } + } + + fn call(&mut self, value: impl Into) { + self.calls.push(value.into()); + } +} + +impl KnownAirMath for MockRuntime { + fn dot3(&mut self, left: Vector4, right: Vector4) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + } + + fn divide_vector_by_scalar(&mut self, vector: Vector4, scalar: f32) -> Vector4 { + vector.map(|value| value / scalar) + } + + fn normalize_safe_with_length(&mut self, vector: Vector4) -> (Vector4, f32) { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + if length == 0.0 { + ([0.0; 4], 0.0) + } else { + (vector.map(|value| value / length), length) + } + } + + fn signed_angle_about_axis(&mut self, _: Vector4, _: Vector4, _: Vector4) -> f32 { + self.call("math.signed_angle"); + self.signed_angle + } + + fn wrap_angle_8258db98(&mut self, angle: f32) -> f32 { + self.call("math.wrap"); + angle + } + + fn restore_velocity_geometry_82d35998( + &mut self, + _: Vector4, + _: Vector4, + ) -> RestoreVelocityGeometry { + self.call("math.restore_geometry"); + self.restore_geometry + } + + fn transform_point_82d36880(&mut self, _: AffineTransform, point: Vector4) -> Vector4 { + point + } +} + +impl KnownAirRuntime for MockRuntime { + fn enable_board_angular_drive_only(&mut self) { + self.call("board.angular_only"); + } + fn set_air_collision_update_enabled(&mut self, value: bool) { + self.call(format!("collision.enabled:{value}")); + } + fn set_skeleton_collision_state(&mut self, value: u32) { + self.call(format!("collision.state:{value}")); + } + fn set_skeleton_physics_truck_tilt_enabled(&mut self, value: bool) { + self.call(format!("skeleton.truck_tilt:{value}")); + } + fn set_skeleton_inverse_kinematics_enabled(&mut self, value: bool) { + self.call(format!("skeleton.ik:{value}")); + } + fn reset_reckoning_flipping(&mut self) { + self.call("reckoning.reset_flip"); + } + fn set_footplant_flag_240(&mut self, value: bool) { + self.call(format!("footplant.flag:{value}")); + } + fn reset_footplants(&mut self) { + self.call("footplant.reset"); + } + fn board_transform_height(&mut self) -> f32 { + self.call("board.height"); + self.board_y + } + fn skeleton_com_position(&mut self) -> Vector4 { + self.call("skeleton.com"); + self.com + } + fn selector_targeting_grind(&self) -> bool { + self.targeting_grind + } + fn start_grind_air_adjust_from_selector(&mut self) { + self.call("grind.start"); + } + fn selector_closest_trajectory_point(&mut self, _: Vector4, _: Vector4) -> (i32, Vector4) { + self.call("selector.closest"); + self.closest + } + fn selected_prediction(&self) -> KnownAirPrediction { + self.prediction + } + fn selector_trajectory_2704(&self) -> KnownAirTrajectory { + self.selector_trajectory + } + fn selector_contact_position(&mut self) -> Vector4 { + self.call("selector.contact"); + self.contact + } + fn selector_vector_2848(&self) -> Vector4 { + self.selector_vector + } + fn selector_com_position_2784(&self) -> Vector4 { + self.selector_com + } + fn highest_trajectory_position(&mut self, _: &KnownAirTrajectory) -> (Vector4, f32) { + self.call("trajectory.highest"); + self.highest + } + fn selector_has_just_changed(&self) -> bool { + false + } + fn reset_trajectory_selector(&mut self) { + self.call("selector.reset"); + } + fn begin_reckoning_body_flip(&mut self, side: bool) { + self.call(format!("reckoning.begin_flip:{side}")); + } + fn update_air_collision(&mut self) { + self.call("collision.update"); + } + fn grind_air_adjust_started(&self) -> bool { + self.grind_started + } + fn set_grind_air_adjust_started(&mut self, value: bool) { + self.call(format!("grind.started:{value}")); + } + fn set_grind_air_adjust_activated(&mut self, value: bool) { + self.call(format!("grind.activated:{value}")); + } + fn grind_air_adjust_adjusting(&self) -> bool { + self.grind_adjusting + } + fn disable_grind_foot_collision(&mut self, body: GrindFootBody) { + let name = match body { + GrindFootBody::Body3152 => "3152", + GrindFootBody::Body3156 => "3156", + GrindFootBody::Body3160 => "3160", + GrindFootBody::Body3168 => "3168", + GrindFootBody::Body3172 => "3172", + GrindFootBody::Body3176 => "3176", + }; + self.call(format!("grind.disable:{name}")); + } + fn update_reckoning_air_states(&mut self, _: Vector4, _: f32, _: f32, _: f32) { + self.call("reckoning.update_air"); + } + fn update_known_air_skeleton(&mut self, _: Vector4) { + self.call("skeleton.update_known_air"); + } + fn set_skeleton_add_skateboard_error(&mut self, value: bool) { + self.call(format!("skeleton.board_error:{value}")); + } + fn set_head_tracking_target(&mut self, _: Vector4, valid: bool) { + self.call(format!("head.target:{valid}")); + } + fn update_board_steering_tilt(&mut self, _: f32) { + self.call("board.steering_zero"); + } + fn shift_footplant_trajectory_to_index(&mut self, _: &mut KnownAirTrajectory, _: i32) { + self.call("footplant.shift"); + } + fn update_footplant_prediction(&mut self, _: &KnownAirFootplantInput) { + self.call("footplant.prediction"); + } + fn update_footplant_lock(&mut self, _: &KnownAirFootplantInput) { + self.call("footplant.lock"); + } + fn update_footplant_pose(&mut self, _: &KnownAirFootplantInput) { + self.call("footplant.pose"); + } + fn board_body_velocity(&mut self) -> Vector4 { + self.call("board.velocity"); + self.board_velocity + } + fn set_board_velocity(&mut self, value: Vector4) { + self.call("board.set_velocity"); + self.written_board_velocity = value; + } + fn board_forward_axis(&mut self) -> Vector4 { + self.call("board.forward"); + self.forward + } + fn check_for_air_wipeout(&mut self, value: bool) { + self.call(format!("wipeout.check:{value}")); + } + fn reckoning_com_transform_816(&self) -> AffineTransform { + AffineTransform { + vectors: [[0.0; 4]; 4], + } + } +} diff --git a/crates/skate-core/src/air/known/tests/mod.rs b/crates/skate-core/src/air/known/tests/mod.rs new file mode 100644 index 00000000..9ba3b5ff --- /dev/null +++ b/crates/skate-core/src/air/known/tests/mod.rs @@ -0,0 +1,404 @@ +use super::*; + +mod mock; + +use mock::{MockRuntime, frame, mode, output, reckoning, settings, state}; + +fn assert_close(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() < 0.000_01, + "{actual} != {expected}" + ); +} + +#[test] +fn init_trajectory_info_builds_stock_landing_values() { + let mut state = state(); + let mut frame = frame(); + frame.selector_landing_normal_2656 = [0.0, 1.0, 0.0, 0.0]; + let mut settings = settings(); + settings.flip_scalar = 0.5; + settings.min_target_heading_velocity_420 = 0.5; + let mode = mode(); + let mut runtime = MockRuntime::new(); + runtime.prediction.trajectory.velocity = [3.0, 4.0, 0.0, 0.0]; + runtime.prediction.trajectory.scalar_48 = 9.0; // Independent query horizon. + runtime.prediction.collision_time_48 = 2.0; + runtime.prediction.collision_frame_128 = 3; + runtime.contact = [9.0, 8.0, 7.0, 0.0]; + runtime.selector_vector = [6.0, 5.0, 4.0, 0.0]; + runtime.highest = ([1.0, 12.0, 3.0, 0.0], 0.75); + + init_trajectory_info(&mut state, &frame, &settings, &mode, &mut runtime); + + assert_eq!(state.trajectory_apex_96, runtime.highest.0); + assert_eq!(state.time_to_apex_200, 0.75); + assert_eq!(state.collision_position_112, runtime.contact); + assert_eq!(state.selector_vector_128, runtime.selector_vector); + assert_close(state.collision_normal_speed_176, 4.0); + assert_eq!(state.landing_heading_80, [1.0, 0.0, 0.0, 0.0]); + assert!(state.landing_heading_valid_209); + assert_close( + state.body_flip_target_speed_204, + -core::f32::consts::FRAC_PI_2, + ); +} + +#[test] +fn init_negative_collision_clears_speed_and_heading_validity() { + let mut state = state(); + state.collision_normal_speed_176 = 99.0; + state.landing_heading_valid_209 = true; + let frame = frame(); + let settings = settings(); + let mode = mode(); + let mut runtime = MockRuntime::new(); + runtime.prediction.collision_time_48 = -1.0; + + init_trajectory_info(&mut state, &frame, &settings, &mode, &mut runtime); + + assert_eq!(state.collision_normal_speed_176, 0.0); + assert_eq!(state.landing_heading_80, [0.0; 4]); + assert!(!state.landing_heading_valid_209); +} + +#[test] +fn enter_preserves_recovered_effect_order_and_initializes_heights() { + let mut state = state(); + let mut frame = frame(); + frame.start_height_484 = 3.0; + frame.velocity_400 = [-1.0, 0.0, 0.0, 0.0]; + frame.start_flip_reference_96 = [1.0, 0.0, 0.0, 0.0]; + let settings = settings(); + let mode = mode(); + let mut runtime = MockRuntime::new(); + runtime.board_y = 4.0; + runtime.com = [0.0, 5.0, 0.0, 0.0]; + runtime.targeting_grind = true; + runtime.closest = (11, [0.25, 0.0, 0.0, 0.0]); + + enter(&mut state, &frame, &settings, &mode, &mut runtime); + + assert_eq!(state.start_y_184, 3.0); + assert_eq!(state.max_y_188, 4.0); + assert_eq!(state.com_max_y_192, 5.0); + assert_eq!(state.trajectory_index_216, 11); + assert_eq!(state.trajectory_follow_offset_144, runtime.closest.1); + assert!(state.targeting_grind_213); + assert!(state.start_flipped_210); + assert_eq!( + &runtime.calls[..9], + [ + "board.angular_only", + "collision.enabled:true", + "collision.state:7", + "skeleton.truck_tilt:false", + "skeleton.ik:true", + "reckoning.reset_flip", + "footplant.flag:false", + "footplant.reset", + "board.height", + ] + ); + assert!(runtime.calls.iter().any(|call| call == "grind.start")); +} + +#[test] +fn exit_restores_velocity_before_selector_and_grind_cleanup() { + let mut state = state(); + state.trajectory_index_216 = 0; + let mut frame = frame(); + frame.next_physics_state_2500 = 100; + frame.ground_normal_464 = [0.0, 1.0, 0.0, 0.0]; + let mut settings = settings(); + settings.landing_speed_scalar_vs_ground_normal_y_240 = mock::flat_graph(2.0); + let mut reckoning = reckoning(); + reckoning.body_spin_angle_1568 = 2.0; + reckoning.body_spin_speed_1572 = 3.0; + let mut runtime = MockRuntime::new(); + runtime.restore_geometry = RestoreVelocityGeometry { + tangential_velocity: [2.0, 0.0, 0.0, 0.0], + landing_speed_curve_input: 0.0, + landing_speed_blend_source: 0.5, + }; + runtime.board_velocity = [0.0, 1.0, 0.0, 0.0]; + runtime.forward = [1.0, 0.0, 0.0, 0.0]; + + exit( + &mut state, + &mut frame, + &settings, + &mut reckoning, + &mut runtime, + ); + + assert_eq!(state.trajectory_index_216, 1); + assert_eq!(runtime.written_board_velocity, [3.0, 1.0, 0.0, 0.0]); + assert_eq!(frame.forward_speed_2612, 3.0); + assert_eq!(reckoning.body_spin_speed_1572, 0.0); + assert_eq!(reckoning.body_spin_angle_1568, 0.0); + let reset = runtime + .calls + .iter() + .position(|v| v == "reckoning.reset_flip") + .unwrap(); + let write = runtime + .calls + .iter() + .position(|v| v == "board.set_velocity") + .unwrap(); + let selector = runtime + .calls + .iter() + .position(|v| v == "selector.reset") + .unwrap(); + let grind = runtime + .calls + .iter() + .position(|v| v == "grind.started:false") + .unwrap(); + assert!(reset < write && write < selector && selector < grind); +} + +#[test] +fn update_runs_footplant_grind_and_publication_in_native_order() { + let mut state = state(); + state.time_in_state_180 = 0.2; + state.collision_time_196 = 0.25; + state.reached_apex_208 = true; + state.targeting_grind_213 = true; + state.trajectory_index_216 = 4; + let mut frame = frame(); + frame.flags_2480 = 0x0400_0000; + frame.delta_time_2604 = 1.0 / 60.0; + let settings = settings(); + let mode = mode(); + let reckoning = reckoning(); + let mut runtime = MockRuntime::new(); + runtime.grind_started = true; + runtime.grind_adjusting = true; + runtime.board_y = 7.0; + runtime.com = [0.0, 8.0, 0.0, 0.0]; + + update( + &mut state, + &frame, + &settings, + &mode, + &reckoning, + &mut runtime, + ); + + assert!(state.grind_air_adjust_activated_212); + assert_eq!(state.trajectory_index_216, 5); + assert_close(state.time_in_state_180, 0.2 + frame.delta_time_2604); + assert_eq!(state.max_y_188, 7.0); + assert_eq!(state.com_max_y_192, 8.0); + let expected_bodies = ["3152", "3156", "3160", "3168", "3172", "3176"]; + let actual_bodies: Vec<_> = runtime + .calls + .iter() + .filter_map(|call| call.strip_prefix("grind.disable:")) + .collect(); + assert_eq!(actual_bodies, expected_bodies); + let foot = runtime + .calls + .iter() + .position(|v| v == "footplant.shift") + .unwrap(); + let collision = runtime + .calls + .iter() + .position(|v| v == "collision.update") + .unwrap(); + let reckon = runtime + .calls + .iter() + .position(|v| v == "reckoning.update_air") + .unwrap(); + let skeleton = runtime + .calls + .iter() + .position(|v| v == "skeleton.update_known_air") + .unwrap(); + let head = runtime + .calls + .iter() + .position(|v| v == "head.target:true") + .unwrap(); + let steering = runtime + .calls + .iter() + .position(|v| v == "board.steering_zero") + .unwrap(); + assert!( + foot < collision + && collision < reckon + && reckon < skeleton + && skeleton < head + && head < steering + ); +} + +#[test] +fn trajectory_follow_uses_selector_2704_and_honors_locked_index() { + let mut state = state(); + state.trajectory_index_216 = 0; + state.time_in_state_180 = 2.0; + let mut frame = frame(); + frame.flags_2468 = 0x0000_0008; + let settings = settings(); + let mut runtime = MockRuntime::new(); + runtime.prediction.trajectory.position = [100.0, 0.0, 0.0, 0.0]; + runtime.selector_trajectory.position = [10.0, 0.0, 0.0, 0.0]; + + update_trajectory_follow(&mut state, &frame, &settings, &runtime); + + assert_eq!(state.trajectory_index_216, 0); + assert_eq!(state.target_com_position_160, [10.0, 0.0, 0.0, 0.0]); +} + +#[test] +fn auto_spin_uses_recovered_alignment_chain_and_manual_gate() { + let mut state = state(); + state.landing_heading_valid_209 = true; + state.landing_normal_64 = [0.0, 1.0, 0.0, 0.0]; + state.landing_heading_80 = [1.0, 0.0, 0.0, 0.0]; + state.collision_time_196 = 1.0; + let mut frame = frame(); + frame.delta_time_2604 = 1.0 / 60.0; + frame.skater_up_544 = [0.0, 1.0, 0.0, 0.0]; + let mut settings = settings(); + settings.max_heading_adjust_vs_up_y_160 = mock::flat_graph(180.0); + settings.min_auto_body_speed_424 = 0.01; + let mode = mode(); + let reckoning = reckoning(); + let mut runtime = MockRuntime::new(); + runtime.signed_angle = 0.5; + + let automatic = + calculate_body_spin_speed(&state, &frame, &settings, &mode, &reckoning, &mut runtime); + assert_close(automatic, 0.6); + + frame.body_spin_input_2640 = 1.0; + frame.flags_2472 = 0; + runtime.calls.clear(); + let masked_manual = + calculate_body_spin_speed(&state, &frame, &settings, &mode, &reckoning, &mut runtime); + assert_eq!(masked_manual, 0.0); + assert!(!runtime.calls.iter().any(|v| v == "math.signed_angle")); +} + +#[test] +fn body_flip_starts_only_after_stock_flag_and_curve_gates() { + let mut state = state(); + state.collision_time_196 = 2.0; + state.body_flip_target_speed_204 = -3.0; + let mut frame = frame(); + frame.flags_2468 = 0x20 | 0x80; + let mut settings = settings(); + settings.flip_start_collision_time_vs_normal_y = mock::flat_graph(1.0); + let mode = mode(); + let reckoning = reckoning(); + let mut runtime = MockRuntime::new(); + + let speed = calculate_body_flip_speed( + &mut state, + &frame, + &settings, + &mode, + &reckoning, + &mut runtime, + ); + + assert!(state.body_flipping_211); + assert_eq!(speed, -3.0); + assert!( + runtime + .calls + .iter() + .any(|v| v == "reckoning.begin_flip:true") + ); + + state.body_flipping_211 = false; + frame.flags_2488 = 0x0200_0000; + runtime.calls.clear(); + calculate_body_flip_speed( + &mut state, + &frame, + &settings, + &mode, + &reckoning, + &mut runtime, + ); + assert!(!state.body_flipping_211); + assert!(runtime.calls.is_empty()); +} + +#[test] +fn post_physics_sets_apex_then_requests_upside_down_wipeout() { + let mut state = state(); + state.landing_normal_64 = [0.0, 1.0, 0.0, 0.0]; + let mut frame = frame(); + frame.skater_up_544 = [0.0, -1.0, 0.0, 0.0]; + let mode = mode(); + let wipeout_settings = KnownAirWipeoutSettings { + air_falling_min_up_y_260: 0.0, + air_falling_max_angle_264: 0.0, + }; + let mut request = KnownAirWipeoutRequest { + requested_35: false, + scalar_116: 9.0, + counter_200: 4, + }; + let mut runtime = MockRuntime::new(); + runtime.board_velocity = [0.0, -2.0, 0.0, 0.0]; + + update_post_physics( + &mut state, + &frame, + &mode, + &wipeout_settings, + &mut request, + &mut runtime, + ); + + assert!(state.reached_apex_208); + assert!(request.requested_35); + assert_eq!(request.scalar_116, 0.0); + assert_eq!(request.counter_200, 5); + assert_eq!(runtime.calls[0], "board.velocity"); + assert_eq!(runtime.calls[1], "wipeout.check:true"); +} + +#[test] +fn output_writes_all_samples_and_preserves_conditional_fields() { + let mut state = state(); + state.trajectory_index_216 = 2; + state.collision_position_112 = [1.0, 0.0, 0.0, 0.0]; + state.landing_normal_64 = [1.0, 0.0, 0.0, 0.0]; + state.max_y_188 = 7.0; + state.start_y_184 = 2.0; + state.collision_time_196 = 3.0; + state.time_in_state_180 = 1.0; + let frame = frame(); + let mut output = output(); + output.locked_trajectory_velocity_valid_452 = true; + output.locked_trajectory_velocity_160 = [9.0; 4]; + let mut runtime = MockRuntime::new(); + runtime.prediction.trajectory.position = [0.0; 4]; + runtime.prediction.trajectory.velocity = [60.0, 0.0, 0.0, 0.0]; + runtime.selector_trajectory.position = [999.0, 0.0, 0.0, 0.0]; + runtime.selector_com = [0.0; 4]; + + fill_physics_output(&state, &frame, &mut output, &mut runtime); + + assert_eq!(output.locked_trajectory_velocity_160, [9.0; 4]); + assert!(output.locked_trajectory_velocity_valid_452); + assert_eq!(output.jump_height_200, 5.0); + assert_eq!(output.time_until_collision_184, 2.0); + assert_eq!(output.trajectory_position_64[0], 2.0); + assert_eq!(output.trajectory_plane_samples_336[0], 0.0); + assert_close(output.trajectory_plane_samples_336[24], 24.0); + assert!(output.known_air_valid_437); +} diff --git a/crates/skate-core/src/air/known/trajectory.rs b/crates/skate-core/src/air/known/trajectory.rs new file mode 100644 index 00000000..eb5f7d60 --- /dev/null +++ b/crates/skate-core/src/air/known/trajectory.rs @@ -0,0 +1,165 @@ +//! KnownAir trajectory setup, ground restoration, and follow update. + +use super::{ + data::{ + KnownAirFrame, KnownAirModeSettings, KnownAirSettings, KnownAirState, KnownAirTrajectory, + Vector4, + }, + runtime::KnownAirRuntime, +}; + +const FIXED_STEP: f32 = f32::from_bits(0x3C88_8889); // 0x820849C8, 1/60. +const HALF: f32 = f32::from_bits(0x3F00_0000); // 0x8209975C. +const ONE: f32 = f32::from_bits(0x3F80_0000); // 0x8231A844. +const PERFECT_FLIP_NUMERATOR: f32 = f32::from_bits(0xC0F1_463B); // 0x822F9450. +const NEGATIVE_TWO_PI: f32 = f32::from_bits(0xC0C9_0FDB); // 0x822F9434. +const HEADING_LENGTH_SQUARED_MIN: f32 = f32::from_bits(0x3DCC_CCCD); // 0x820641A8. + +/// `PhysState_KnownAir::InitTrajectoryInfo`, TU3 `0x82D35508`. +pub fn init_trajectory_info( + state: &mut KnownAirState, + frame: &KnownAirFrame, + settings: &KnownAirSettings, + mode: &KnownAirModeSettings, + runtime: &mut impl KnownAirRuntime, +) { + state.landing_normal_64 = frame.selector_landing_normal_2656; + + let prediction = runtime.selected_prediction(); + let (apex, time_to_apex) = runtime.highest_trajectory_position(&prediction.trajectory); + state.time_to_apex_200 = time_to_apex; + state.trajectory_apex_96 = apex; + + state.collision_position_112 = runtime.selector_contact_position(); + //82D35580 reads r30+48; r30 is the complete winning result. + state.collision_time_196 = prediction.collision_time_48; + state.body_flip_target_speed_204 = if mode.perfect_body_flips_28 { + PERFECT_FLIP_NUMERATOR / state.collision_time_196 + } else { + (settings.flip_scalar / state.collision_time_196) * NEGATIVE_TWO_PI + }; + state.selector_vector_128 = runtime.selector_vector_2848(); + + let mut heading = [0.0; 4]; + let mut heading_valid = false; + state.collision_normal_speed_176 = 0.0; + if state.collision_time_196 >= 0.0 { + let collision_frame = prediction.collision_frame_128; + let previous_time = collision_frame.wrapping_sub(2) as f32 * FIXED_STEP; + let collision_time = collision_frame.wrapping_sub(1) as f32 * FIXED_STEP; + let previous = trajectory_position(&prediction.trajectory, previous_time); + let collision = trajectory_position(&prediction.trajectory, collision_time); + let delta = sub(collision, previous); + let velocity = runtime.divide_vector_by_scalar(delta, FIXED_STEP); + + state.collision_normal_speed_176 = runtime.dot3(state.landing_normal_64, velocity); + let normal_velocity = mul_scalar(state.landing_normal_64, state.collision_normal_speed_176); + let tangent = sub(velocity, normal_velocity); + let (normalized, length) = runtime.normalize_safe_with_length(tangent); + if !(settings.min_target_heading_velocity_420 > length) { + heading = normalized; + heading_valid = true; + } + } + + state.landing_heading_80 = heading; + state.landing_heading_valid_209 = heading_valid; + let heading_length_squared = runtime.dot3(heading, heading); + if HEADING_LENGTH_SQUARED_MIN > heading_length_squared { + state.landing_heading_valid_209 = false; + } +} + +/// Ground transition velocity restoration, TU3 `0x82D35998`. +pub fn restore_velocity( + state: &mut KnownAirState, + frame: &mut KnownAirFrame, + settings: &KnownAirSettings, + runtime: &mut impl KnownAirRuntime, +) { + let prediction = runtime.selected_prediction(); + if state.trajectory_index_216 == 0 { + state.trajectory_index_216 = 1; + } + + let sample_time = state.trajectory_index_216 as f32 * FIXED_STEP; + let trajectory_velocity = trajectory_velocity(&prediction.trajectory, sample_time); + let geometry = + runtime.restore_velocity_geometry_82d35998(trajectory_velocity, frame.ground_normal_464); + let curve_value = settings + .landing_speed_scalar_vs_ground_normal_y_240 + .evaluate(geometry.landing_speed_curve_input); + let lower_clamped = select( + -geometry.landing_speed_blend_source, + 0.0, + geometry.landing_speed_blend_source, + ); + let blend_weight = select(ONE - lower_clamped, lower_clamped, ONE); + let speed_scale = (curve_value - ONE).mul_add(blend_weight, ONE); + let scaled_tangent = mul_scalar(geometry.tangential_velocity, speed_scale); + let current_board_velocity = runtime.board_body_velocity(); + let preserved_normal_speed = runtime.dot3(current_board_velocity, frame.ground_normal_464); + let restored = core::array::from_fn(|lane| { + frame.ground_normal_464[lane].mul_add(preserved_normal_speed, scaled_tangent[lane]) + }); + runtime.set_board_velocity(restored); + + let forward = runtime.board_forward_axis(); + frame.forward_speed_2612 = runtime.dot3(restored, forward); +} + +/// `PhysState_KnownAir::UpdateTrajectoryFollow`, TU3 `0x82D36460`. +pub fn update_trajectory_follow( + state: &mut KnownAirState, + frame: &KnownAirFrame, + settings: &KnownAirSettings, + runtime: &impl KnownAirRuntime, +) { + state.trajectory_index_216 = state.trajectory_index_216.wrapping_add(1); + + let ratio = (settings.trajectory_error_blend_away_time_384 - state.time_in_state_180) + / settings.trajectory_error_blend_away_time_384; + let lower_clamped = select(-ratio, 0.0, ratio); + let blend = select(ONE - lower_clamped, lower_clamped, ONE); + let sample_time = state.trajectory_index_216 as f32 * FIXED_STEP; + let trajectory = runtime.selector_trajectory_2704(); + let trajectory_position = trajectory_position(&trajectory, sample_time); + state.target_com_position_160 = core::array::from_fn(|lane| { + state.trajectory_follow_offset_144[lane].mul_add(blend, trajectory_position[lane]) + }); + + if frame.flags_2468 & 0x0000_0008 != 0 { + state.trajectory_index_216 = state.trajectory_index_216.wrapping_sub(1); + } +} + +pub(crate) fn trajectory_position(trajectory: &KnownAirTrajectory, time: f32) -> Vector4 { + let time_squared = time * time; + core::array::from_fn(|lane| { + let linear = trajectory.velocity[lane].mul_add(time, trajectory.position[lane]); + let half_acceleration = trajectory.acceleration[lane] * HALF; + half_acceleration.mul_add(time_squared, linear) + }) +} + +pub(crate) fn trajectory_velocity(trajectory: &KnownAirTrajectory, time: f32) -> Vector4 { + core::array::from_fn(|lane| { + trajectory.acceleration[lane].mul_add(time, trajectory.velocity[lane]) + }) +} + +pub(crate) fn add(left: Vector4, right: Vector4) -> Vector4 { + core::array::from_fn(|lane| left[lane] + right[lane]) +} + +pub(crate) fn sub(left: Vector4, right: Vector4) -> Vector4 { + core::array::from_fn(|lane| left[lane] - right[lane]) +} + +pub(crate) fn mul_scalar(vector: Vector4, scalar: f32) -> Vector4 { + vector.map(|lane| lane * scalar) +} + +pub(crate) fn select(test: f32, nonnegative: f32, negative: f32) -> f32 { + if test >= 0.0 { nonnegative } else { negative } +} diff --git a/crates/skate-core/src/air/known/update.rs b/crates/skate-core/src/air/known/update.rs new file mode 100644 index 00000000..c87d3895 --- /dev/null +++ b/crates/skate-core/src/air/known/update.rs @@ -0,0 +1,106 @@ +//! Full outer TU3 KnownAir update in recovered call order. + +use super::{ + body::{calculate_body_flip_speed, calculate_body_spin_speed}, + data::{ + GrindFootBody, KnownAirFrame, KnownAirModeSettings, KnownAirReckoningFields, + KnownAirSettings, KnownAirState, + }, + footplant::update_footplant, + runtime::KnownAirRuntime, + trajectory::{init_trajectory_info, select, update_trajectory_follow}, +}; + +const ALIGNMENT_LEAD_FRAMES: f32 = f32::from_bits(0x4040_0000); // 0x82063B08, 3. +const MIN_ALIGNMENT_FRAMES: f32 = f32::from_bits(0x4000_0000); // 0x82060C50, 2. +const ONE: f32 = f32::from_bits(0x3F80_0000); // 0x8231A844. +const BOARD_ERROR_WINDOW: f32 = f32::from_bits(0x3D75_C28F); // 0x82099460, 0.06. +const ZERO: f32 = 0.0; // 0x82165A10. + +const GRIND_FOOT_BODIES: [GrindFootBody; 6] = [ + GrindFootBody::Body3152, + GrindFootBody::Body3156, + GrindFootBody::Body3160, + GrindFootBody::Body3168, + GrindFootBody::Body3172, + GrindFootBody::Body3176, +]; + +/// `PhysState_KnownAir::Update`, TU3 `0x82D35C58`. +pub fn update( + state: &mut KnownAirState, + frame: &KnownAirFrame, + settings: &KnownAirSettings, + mode: &KnownAirModeSettings, + reckoning: &KnownAirReckoningFields, + runtime: &mut impl KnownAirRuntime, +) { + update_footplant(state, frame, runtime); + if runtime.selector_has_just_changed() { + init_trajectory_info(state, frame, settings, mode, runtime); + } + runtime.update_air_collision(); + + let body_flip_speed = + calculate_body_flip_speed(state, frame, settings, mode, reckoning, runtime); + let body_spin_speed = + calculate_body_spin_speed(state, frame, settings, mode, reckoning, runtime); + + let remaining_frames = + (state.collision_time_196 - state.time_in_state_180) / frame.delta_time_2604; + let after_lead = remaining_frames - ALIGNMENT_LEAD_FRAMES; + let alignment_frames = select( + after_lead - MIN_ALIGNMENT_FRAMES, + after_lead, + MIN_ALIGNMENT_FRAMES, + ); + + if !state.grind_air_adjust_activated_212 + && (state.reached_apex_208 || alignment_frames < settings.frames_for_grind_air_assist_436) + { + let started = runtime.grind_air_adjust_started(); + runtime.set_grind_air_adjust_activated(started); + state.grind_air_adjust_activated_212 = true; + } + + if state.targeting_grind_213 + && state.grind_air_adjust_activated_212 + && runtime.grind_air_adjust_adjusting() + { + for body in GRIND_FOOT_BODIES { + runtime.disable_grind_foot_collision(body); + } + } + + runtime.update_reckoning_air_states( + state.landing_normal_64, + ONE / alignment_frames, + body_spin_speed, + body_flip_speed, + ); + update_trajectory_follow(state, frame, settings, runtime); + runtime.update_known_air_skeleton(state.target_com_position_160); + + if state.time_in_state_180 < BOARD_ERROR_WINDOW { + runtime.set_skeleton_add_skateboard_error(true); + } + + let head_target = if frame.flags_2472 & 0x0040_0000 != 0 { + frame.alternate_head_target_112 + } else { + state.collision_position_112 + }; + runtime.set_head_tracking_target(head_target, true); + runtime.update_board_steering_tilt(ZERO); + + let board_y = runtime.board_transform_height(); + if !(state.max_y_188 > board_y) { + state.max_y_188 = board_y; + } + if frame.flags_2468 & 0x0000_0008 == 0 { + state.time_in_state_180 += frame.delta_time_2604; + } + + let com_y = runtime.skeleton_com_position()[1]; + state.com_max_y_192 = select(state.com_max_y_192 - com_y, state.com_max_y_192, com_y); +} diff --git a/crates/skate-core/src/air/mod.rs b/crates/skate-core/src/air/mod.rs new file mode 100644 index 00000000..04b6ea3a --- /dev/null +++ b/crates/skate-core/src/air/mod.rs @@ -0,0 +1,9 @@ +//! Recovered airborne reckoning units. Air-state scheduling remains a caller boundary. +pub mod body_flip; +pub mod body_spin; +pub mod ground_normal; +pub mod ground_jump; +pub mod known; +pub mod reckoning; +pub mod state; +pub mod trajectory; diff --git a/crates/skate-core/src/air/reckoning/data.rs b/crates/skate-core/src/air/reckoning/data.rs new file mode 100644 index 00000000..1caefecd --- /dev/null +++ b/crates/skate-core/src/air/reckoning/data.rs @@ -0,0 +1,75 @@ +//! Additional persistent Reckoning fields and actual inputs for82D8DBD8. +use crate::{ + air::{body_flip::BodyFlipSettings, body_spin::BodySpinSettings}, + physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + point_graph::PointGraph, +}; +#[derive(Clone, Debug, PartialEq)] +pub struct AirState { + ///1568/1572, shared with Ground entry and PhysicsAir exit. + pub spin_angle: f32, + pub spin_speed: f32, + ///1580 is cleared by UpdateAirStates (1576 lives in ReckoningFrames). + pub secondary_lean_angle: f32, + pub flip_angle: f32, + pub flip_speed: f32, + pub flip_requested_speed: f32, + ///1072; final combined1008 has one owner, ReckoningFrames.body_flip. + pub spin_transform: AnimationPartTransform, + ///1232 and1596; their native trajectory/entry producers remain the caller. + pub flip_axis: [f32; 4], + pub flip_active: bool, + ///1597, BeginBodyFlip82D8EC60 and ResetBodyFlip82D8EAA4. + pub flip_side: bool, +} +impl AirState { + ///82D33178: scalar zeros, identity1072, world-X axis and inactive flip. + pub fn new() -> Self { + Self { + spin_angle: 0.0, + spin_speed: 0.0, + secondary_lean_angle: 0.0, + flip_angle: 0.0, + flip_speed: 0.0, + flip_requested_speed: 0.0, + spin_transform: IDENTITY, + flip_axis: [1.0, 0.0, 0.0, 0.0], + flip_active: false, + flip_side: false, + } + } + ///Ground entry82D37538/PhysicsAir exit82D346A0 reset exactly this pair. + pub fn reset_spin(&mut self) { + self.spin_angle = 0.0; + self.spin_speed = 0.0; + } +} +pub struct Settings { + pub ground_normal_smoothing: [f32; 4], + ///PointNegGraphData8 layout336; caller directly evaluates its X/Y352/384. + pub max_up_angle_delta: PointGraph<8>, + pub tilt_vs_rotation: PointGraph<8>, + pub tilt_vs_slope: PointGraph<8>, + pub body_spin: BodySpinSettings, + pub body_flip: BodyFlipSettings, +} +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub landing_normal: [f32; 4], + pub normal_blend: f32, + pub target_spin: f32, + pub flip_request: f32, + ///Processed752, from the actual Skeleton animation COM-to-deck producer. + pub com_to_deck: [f32; 4], + pub timestep: f32, + ///Processed2812, live PhysBodySpin animation attribute. + pub physical_body_spin: f32, + ///Processed2644, produced by Skeleton82BDD008; used only when2484bit15 is set. + pub grind_adjusted_body_spin: f32, + pub additive_spin: bool, + pub direct_spin: bool, + pub reverse_stance: bool, + ///Actual selected physics_mode layout108 and28. + pub easy_body_spins: bool, + pub perfect_body_flips: bool, +} diff --git a/crates/skate-core/src/air/reckoning/math.rs b/crates/skate-core/src/air/reckoning/math.rs new file mode 100644 index 00000000..5fbf107b --- /dev/null +++ b/crates/skate-core/src/air/reckoning/math.rs @@ -0,0 +1,105 @@ +//! Original TU3 angle limiter82BD3E78 and Air heading rotation82D8DFD4. +//! Basic arithmetic runs on the host; this is not bit-exact Xenon emulation. +use crate::{ + math::Vector3, + physics::{ + board_ground::angle_between, + board_motion_output::inverse_length_squared, + native_arithmetic::{dot3, vector_max, vector_min}, + }, + trigonometry, +}; +pub(super) type V = [f32; 4]; +const EPSILON: f32 = f32::from_bits(0x3586_37bd); +const TAU: f32 = f32::from_bits(0x40c9_0fdb); +const INV_TAU: f32 = f32::from_bits(0x3e22_f983); + +pub(super) fn angle(a: V, b: V) -> f32 { + angle_between( + Vector3::new(a[0], a[1], a[2]), + Vector3::new(b[0], b[1], b[2]), + ) +} +pub(super) fn wrap(angle: f32) -> f32 { + let turns = angle * INV_TAU; + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * TAU +} +pub(super) fn length(v: V) -> f32 { + let sq = dot3(v, v); + let inverse = inverse_length_squared(sq, 2); + if sq == 0.0 { 0.0 } else { sq * inverse } +} +pub(super) fn normalize(v: V) -> V { + let inverse = inverse_length_squared(dot3(v, v), 2); + v.map(|x| x * inverse) +} +pub(super) fn normalize_safe(v: V, fallback: V) -> V { + let sq = dot3(v, v); + let inverse = inverse_length_squared(sq, 2); + let magnitude = if sq == 0.0 { 0.0 } else { sq * inverse }; + if magnitude > EPSILON { + v.map(|x| x * inverse) + } else { + fallback + } +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} + +///82BD3E78. Retains the source's original (not normalized) endpoint operands +/// and final target magnitude. The parallel-axis branch returns target as-is. +pub fn limit_angle(target: V, from: V, maximum: f32) -> V { + let normalized_from = normalize_safe(from, [0.0; 4]); + let normalized_target = normalize_safe(target, [0.0; 4]); + let axis = cross(normalized_target, normalized_from); + let cosine = vector_min( + vector_max(dot3(normalized_target, normalized_from), -1.0), + 1.0, + ); + let angle = trigonometry::acos(cosine); + //The limiter uses floor(angle/tau + .5), unlike its caller's fraction wrap. + let turns = angle.mul_add(INV_TAU, 0.5).floor(); + let closest_angle = (-turns).mul_add(TAU, angle); + if closest_angle.abs() < maximum || dot3(axis, axis) < f32::from_bits(0x3780_0000) { + return target; + } + let axis = normalize(axis); + let (sin, cos) = trigonometry::sin_cos(-maximum * 0.5); + let q = axis.map(|v| v * sin); + let first = cross(q, from); + let intermediate = std::array::from_fn(|i| cos.mul_add(from[i], first[i])); + let second = cross(q, intermediate); + let rotated: V = std::array::from_fn(|i| second[i].mul_add(2.0, from[i])); + let magnitude = length(target); + rotated.map(|v| v * magnitude) +} + +///82D8DFD4..E0B0: Rodrigues columns followed by X/Y/Z ordered FMAs. +///The native permutation repeats each column's X into its W lane. +pub(super) fn rotate_heading(heading: V, up: V, angle: f32) -> V { + let [x, y, z, _] = up; + let (s, c) = trigonometry::sin_cos(angle); + let t = 1.0 - c; + let (tx, ty, tz) = (t * x, t * y, t * z); + let (sx, sy, sz) = (s * x, s * y, s * z); + let xx = tx.mul_add(x, c); + let yx = ty * x - sz; + let zx = tz.mul_add(x, sy); + let columns = [ + [xx, tx.mul_add(y, sz), tx * z - sy, xx], + [yx, ty.mul_add(y, c), ty.mul_add(z, sx), yx], + [zx, tz * y - sx, tz.mul_add(z, c), zx], + ]; + std::array::from_fn(|i| { + let first = columns[0][i] * heading[0]; + let second = columns[1][i].mul_add(heading[1], first); + columns[2][i].mul_add(heading[2], second) + }) +} diff --git a/crates/skate-core/src/air/reckoning/mod.rs b/crates/skate-core/src/air/reckoning/mod.rs new file mode 100644 index 00000000..e5d138fc --- /dev/null +++ b/crates/skate-core/src/air/reckoning/mod.rs @@ -0,0 +1,129 @@ +//! Complete original TU3 Reckoning::UpdateAirStates82D8DBD8. +//! Shared Ground filters, frame owners and BodySpin history persist across states. +mod data; +mod math; +use crate::{ + air::{ + body_flip::{self, BodyFlipInput, BodyFlipState}, + body_spin::{self, BodySpinState}, + }, + math::Vector3, + riding::{ground_orientation::GroundOrientation, reckoning_frames::ReckoningFrames}, +}; +pub use data::{AirState, Input, Settings}; +pub use math::limit_angle as clamp_vector_within_max_angle; +use math::{angle, length, limit_angle, normalize, normalize_safe, rotate_heading, wrap}; + +pub fn update( + orientation: &mut GroundOrientation, + frames: &mut ReckoningFrames, + body_spin: &mut BodySpinState, + state: &mut AirState, + settings: &Settings, + input: &Input, +) { + let old_up = lanes(orientation.up); + orientation.dynamic_up = orientation.up; + frames.target_lean_angle = 0.0; + state.secondary_lean_angle = 0.0; + let ground_normal = orientation + .ground_filter + .update(settings.ground_normal_smoothing, input.landing_normal); + orientation.ground_normal = vector(ground_normal); + + state.spin_speed = if input.additive_spin { + input.grind_adjusted_body_spin + input.target_spin + } else if input.direct_spin { + input.target_spin + } else { + body_spin::update( + body_spin, + &settings.body_spin, + input.physical_body_spin, + input.target_spin, + true, + u8::from(input.easy_body_spins), + ); + body_spin.speed() + }; + state.spin_angle = state + .spin_speed + .mul_add(f32::from_bits(0x3c88_8889), state.spin_angle); + if state.flip_active { + //A short call-boundary view, not a second retained owner of matrix1008. + let mut flip = BodyFlipState { + angle: state.flip_angle, + speed: state.flip_speed, + requested_speed: state.flip_requested_speed, + spin_transform: state.spin_transform, + combined_transform: frames.body_flip, + }; + body_flip::update( + &mut flip, + &settings.body_flip, + &BodyFlipInput { + requested_speed: input.flip_request, + spin_angle: state.spin_angle, + normal: old_up, + flip_axis: state.flip_axis, + timestep: input.timestep, + perfect_body_flips: input.perfect_body_flips, + }, + ); + state.flip_angle = flip.angle; + state.flip_speed = flip.speed; + state.flip_requested_speed = flip.requested_speed; + state.spin_transform = flip.spin_transform; + frames.body_flip = flip.combined_transform; + } + + let wrapped = wrap(angle(old_up, ground_normal)); + let maximum = wrapped * input.normal_blend; + let target = if wrapped.abs() >= f32::from_bits(0x40c8_f61e) { + if old_up[1].abs() < f32::from_bits(0x3f7d_70a4) { + [0.0, 1.0, 0.0, 0.0] + } else { + [1.0, 0.0, 0.0, 0.0] + } + } else { + ground_normal + }; + let candidate = normalize(limit_angle(target, old_up, maximum)); + let max_delta = settings + .max_up_angle_delta + .evaluate(length(input.com_to_deck)); + let selected = if wrap(angle(candidate, old_up)).abs() > max_delta { + limit_angle(candidate, old_up, max_delta) + } else { + candidate + }; + let up = normalize_safe(selected, old_up); + orientation.up_velocity = vector(std::array::from_fn(|i| up[i] - old_up[i])); + orientation.up = vector(up); + orientation.target = orientation.up; + + if !state.flip_active { + frames.heading = rotate_heading(frames.heading, up, input.timestep * state.spin_speed); + } + //82D8C868 advances histories using retained control words, then publishes + //the chosen up into each current-position slot; no filter reinitialization. + 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, ground_normal); + frames.calculate_tilt( + input.reverse_stance, + &settings.tilt_vs_rotation, + &settings.tilt_vs_slope, + ); +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn vector(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/air/reckoning/tests.rs b/crates/skate-core/src/air/reckoning/tests.rs new file mode 100644 index 00000000..a1de5d24 --- /dev/null +++ b/crates/skate-core/src/air/reckoning/tests.rs @@ -0,0 +1,176 @@ +use super::*; +use crate::{ + air::{body_flip::BodyFlipSettings, body_spin::BodySpinSettings}, + physics::skeleton_animation_record::IDENTITY, + point_graph::PointGraph, + riding::ground_orientation::GroundOrientationSettings, +}; +fn curve(value: f32) -> PointGraph<8> { + PointGraph { + x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], + y: [value; 8], + } +} +fn orientation() -> GroundOrientation { + GroundOrientation::new(&GroundOrientationSettings { + ground_normal_smoothing: [1.0, 0.0, 0.0, 1.0], + up_vector_smoothing_slow: [0.2, 0.1, 0.05, 0.3], + up_vector_smoothing_fast: [0.7, 0.2, 0.1, 0.8], + dynamic_up_vs_ground_y: curve(0.0), + ground_vector_blend: curve(0.0), + deck_angle_usage_vs_speed: curve(0.0), + up_vector_smoothing_vs_speed: curve(0.0), + up_vector_max_delta_vs_speed: curve(1.0), + ground_blend_max_delta: 0.0, + up_vector_max_acceleration: 1.0, + anti_wobble_damping: 0.5, + extra_side_damping: 0.0, + minimum_wheels_for_ground_blend: 1, + }) +} +fn settings() -> Settings { + Settings { + ground_normal_smoothing: [1.0, 0.0, 0.0, 1.0], + max_up_angle_delta: curve(0.5), + tilt_vs_rotation: curve(0.0), + tilt_vs_slope: curve(0.0), + body_spin: BodySpinSettings { + derivative_floor: 0.4, + acceleration_limit: 0.2, + curves: [curve(1.0); 7], + input_fade_threshold: [f32::from_bits(0x37800000); 4], + }, + body_flip: BodyFlipSettings { + smoothing: Some(0.1), + maximum_speed: Some(5.0), + spin_scale: Some(0.5), + missing_attribute_value: 0.0, + }, + } +} +fn input() -> Input { + Input { + landing_normal: [0.0, 1.0, 0.0, 0.0], + normal_blend: 0.1, + target_spin: 0.0, + flip_request: 0.0, + com_to_deck: [0.0, 1.0, 0.0, 0.0], + timestep: 1.0 / 60.0, + physical_body_spin: 0.0, + grind_adjusted_body_spin: 0.0, + additive_spin: false, + direct_spin: true, + reverse_stance: false, + easy_body_spins: true, + perfect_body_flips: false, + } +} + +#[test] +fn angle_limit_rotates_from_toward_target_and_preserves_parallel_branch() { + let target = [0.0, 0.0, 1.0, 0.0]; + let from = [1.0, 0.0, 0.0, 0.0]; + let result = math::limit_angle(target, from, std::f32::consts::FRAC_PI_4); + assert!((result[0] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.0001); + assert!((result[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.0001); + assert_eq!( + math::limit_angle([-2.0, 0.0, 0.0, 0.0], from, 0.1), + [-2.0, 0.0, 0.0, 0.0] + ); +} +#[test] +fn direct_and_additive_spin_use_fixed_angle_step_and_keep_body_spin_history() { + let mut orientation = orientation(); + let mut frames = ReckoningFrames::new(); + let mut spin = BodySpinState::new(); + let mut state = AirState::new(); + let mut input = input(); + input.target_spin = 2.0; + input.timestep = 0.1; + let old_history = *spin.words(); + update( + &mut orientation, + &mut frames, + &mut spin, + &mut state, + &settings(), + &input, + ); + assert_eq!(state.spin_angle, 2.0 * f32::from_bits(0x3c888889)); + assert_eq!(spin.words(), &old_history); + //Heading uses caller dt even though accumulated spin angle uses1/60. + assert!((frames.heading[0] - 0.2_f32.cos()).abs() < 0.0001); + assert!((frames.heading[2] + 0.2_f32.sin()).abs() < 0.0001); + input.additive_spin = true; + input.grind_adjusted_body_spin = 3.0; + update( + &mut orientation, + &mut frames, + &mut spin, + &mut state, + &settings(), + &input, + ); + assert_eq!(state.spin_speed, 5.0); + assert_eq!(spin.words(), &old_history); +} +#[test] +fn airborne_filter_update_retains_controls_and_publishes_current_normal() { + let mut orientation = orientation(); + let mut frames = ReckoningFrames::new(); + let mut spin = BodySpinState::new(); + let mut state = AirState::new(); + let mut input = input(); + let slow_control: Vec<_> = orientation.slow_filter.words()[..4].to_vec(); + let fast_control: Vec<_> = orientation.fast_filter.words()[..4].to_vec(); + input.landing_normal = [0.0, 0.8, 0.6, 0.0]; + update( + &mut orientation, + &mut frames, + &mut spin, + &mut state, + &settings(), + &input, + ); + assert!(orientation.ground_normal.z > 0.59); + assert!(orientation.up.z > 0.0 && orientation.up.z < orientation.ground_normal.z); + assert_eq!( + &orientation.slow_filter.words()[..4], + slow_control.as_slice() + ); + assert_eq!( + &orientation.fast_filter.words()[..4], + fast_control.as_slice() + ); + assert_eq!( + &orientation.slow_filter.words()[4..8], + &lanes(orientation.up).map(f32::to_bits) + ); + assert_eq!(frames.ground[1], lanes(orientation.ground_normal)); +} +#[test] +fn active_zero_flip_preserves_combined_frame_and_spin_reset_preserves_flip() { + let mut orientation = orientation(); + let mut frames = ReckoningFrames::new(); + let mut spin = BodySpinState::new(); + let mut state = AirState::new(); + let mut input = input(); + state.flip_active = true; + input.target_spin = 1.0; + update( + &mut orientation, + &mut frames, + &mut spin, + &mut state, + &settings(), + &input, + ); + assert_eq!(frames.body_flip, IDENTITY); + assert_eq!(frames.heading, [1.0, 0.0, 0.0, 0.0]); + state.flip_angle = 0.3; + state.flip_speed = 0.2; + state.reset_spin(); + assert_eq!([state.spin_angle, state.spin_speed], [0.0; 2]); + assert_eq!([state.flip_angle, state.flip_speed], [0.3, 0.2]); + assert!(state.flip_active); +} diff --git a/crates/skate-core/src/air/state/board.rs b/crates/skate-core/src/air/state/board.rs new file mode 100644 index 00000000..14ec9791 --- /dev/null +++ b/crates/skate-core/src/air/state/board.rs @@ -0,0 +1,32 @@ +//! TU3 PhysicsAir board update (`0x82D34AB0`). + +use super::{ + data::{AirBoardForce, PhysicsAirFrame, PhysicsAirReckoningFields, PhysicsAirState}, + jump_velocity::calculate_velocity_from_jump, + runtime::PhysicsAirRuntime, +}; + +const AIR_COLLISION_FORCE_TAG: u32 = 15; + +pub fn update_skateboard( + state: &PhysicsAirState, + frame: &PhysicsAirFrame, + reckoning: &PhysicsAirReckoningFields, + runtime: &mut impl PhysicsAirRuntime, +) { + runtime.update_board_steering_tilt(0.0); + + let mut force = AirBoardForce::ZERO; + let collision_force_created = runtime + .calculate_air_collision_force(reckoning.collision_reference_normal_1216, &mut force); + if collision_force_created { + runtime.enqueue_board_force(AIR_COLLISION_FORCE_TAG, force); + return; + } + + if !state.use_centre_of_mass_velocity && frame.frames_since_jump_correction_2576 < 12 { + let velocity = + calculate_velocity_from_jump(frame, frame.frames_since_jump_correction_2576, runtime); + runtime.set_board_velocity(velocity); + } +} diff --git a/crates/skate-core/src/air/state/data.rs b/crates/skate-core/src/air/state/data.rs new file mode 100644 index 00000000..0dce290c --- /dev/null +++ b/crates/skate-core/src/air/state/data.rs @@ -0,0 +1,117 @@ +//! Persistent fields and direct inputs used by TU3 `PhysState_PhysicsAir`. + +use crate::point_graph::PointGraph; + +pub type Vector4 = [f32; 4]; + +/// Native trajectory storage at PhysicsAir+80. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AirTrajectory { + pub position: Vector4, + pub velocity: Vector4, + pub acceleration: Vector4, + /// Native trajectory +48. Both recovered initializers write -1.0. + pub scalar_48: f32, +} + +/// Fields owned by the TU3 PhysicsAir object. Enter preserves prior trajectory +/// storage when COM mode is not selected; construct once and retain this owner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsAirState { + /// PhysicsAir+80. + pub centre_of_mass_trajectory: AirTrajectory, + /// PhysicsAir+144. + pub landing_normal: Vector4, + /// PhysicsAir+160. + pub time_in_state: f32, + /// PhysicsAir+164/+168. + pub start_y: f32, + pub max_y: f32, + /// PhysicsAir+172/+173/+174. + pub reached_apex: bool, + pub use_centre_of_mass_velocity: bool, + /// The producer is selector+9662. A more specific stock name is unresolved. + pub selector_latch_174: bool, + /// PhysicsAir+176, decremented as a signed wrapping integer. + pub trajectory_query_countdown: i32, +} + +impl Default for PhysicsAirState { + ///Original constructor82D341D0: zero vectors80/96/112, -1 at128, + ///up144, zero160/164/168 and172..176. Entry is a separate operation. + fn default() -> Self { + Self { + centre_of_mass_trajectory: AirTrajectory { + position: [0.0; 4], velocity: [0.0; 4], acceleration: [0.0; 4], scalar_48: -1.0, + }, + landing_normal: [0.0, 1.0, 0.0, 0.0], + time_in_state: 0.0, start_y: 0.0, max_y: 0.0, + reached_apex: false, use_centre_of_mass_velocity: false, + selector_latch_174: false, trajectory_query_countdown: 0, + } + } +} + +/// Direct ProcessedPhysIn values consumed by the recovered methods. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsAirFrame { + pub current_velocity_400: Vector4, + /// Y lane of the all-states ground position, read directly at +500. + pub ground_position_y_500: f32, + pub trajectory_position_592: Vector4, + pub trajectory_velocity_608: Vector4, + pub jump_velocity_848: Vector4, + pub flags_2468: u32, + pub previous_physics_state_2504: i32, + pub previous_physics_category_2516: i32, + pub frames_since_jump_correction_2576: i32, + pub delta_time_2604: f32, + pub body_spin_input_2640: f32, + pub gravity_y_2648: f32, + pub state_timer_2664: f32, +} + +/// Cached collection values read at PhysicsAir settings +320..+444. +/// No retail defaults are embedded; the stock vault adapter must provide them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsAirSettings { + /// PointGraph x at +320 and y at +352. + pub body_spin_over_time_320: PointGraph<8>, + pub landing_normal_blend_388: f32, + pub body_spin_scale_428: f32, + pub landing_normal_angle_limit_444: f32, +} + +/// Reckoning fields read directly by PhysicsAir, apart from the full +/// `Reckoning::UpdateAirStates` effect exposed by the runtime interface. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsAirReckoningFields { + pub current_landing_normal_1152: Vector4, + pub collision_reference_normal_1216: Vector4, + pub body_spin_angle_1568: f32, + pub body_spin_speed_1572: f32, +} + +/// Native zero-initialized point-force record passed to `0x82D944E8`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AirBoardForce { + pub force_world: Vector4, + pub point_board_local: Vector4, +} + +impl AirBoardForce { + pub const ZERO: Self = Self { + force_world: [0.0; 4], + point_board_local: [0.0; 4], + }; +} + +/// Writes performed by `FillPhysOut` (`0x82D34E90`). `scalar_184_write` is +/// `None` when native code leaves the existing PhysOut field untouched. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsAirOutput { + pub is_at_apex: bool, + pub jump_height: f32, + pub landing_normal: Vector4, + pub scalar_184_write: Option, +} diff --git a/crates/skate-core/src/air/state/jump_velocity.rs b/crates/skate-core/src/air/state/jump_velocity.rs new file mode 100644 index 00000000..455ab39c --- /dev/null +++ b/crates/skate-core/src/air/state/jump_velocity.rs @@ -0,0 +1,65 @@ +//! TU3 PhysicsAir jump-velocity correction (`0x82D34BC0`). + +use super::data::{PhysicsAirFrame, Vector4}; + +const VERTICAL_ERROR_FLOOR: f32 = f32::from_bits(0xC040_0000); // -3.0 +const HORIZONTAL_ERROR_LIMIT_SQUARED: f32 = f32::from_bits(0x4110_0000); // 9.0 + +/// Numerical operations used by the recovered formula. The PC adapter uses +/// independently implemented arithmetic and the original refinement steps; +/// agreement with every Xenon floating-point edge case is not established. +pub trait PhysicsAirMath { + /// Native `vminfp128`, used by the OffBoard air-entry velocity cap. + fn minimum_vminfp(&mut self, left: f32, right: f32) -> f32; + + /// Native `vmsum3fp128(value, value)`; only lane zero is consumed. + fn length_squared_vmsum3fp(&mut self, value: Vector4) -> f32; + + /// The native vmsum/vrsqrte/two-refinement sequence at 82D34D10..68. + fn length_vmsum3fp_vrsqrte(&mut self, value: Vector4) -> f32; + + /// `PhysicsUtility::ClampVectorWithinMaxLength`, `0x82BD3D90`. + fn clamp_vector_within_max_length(&mut self, value: Vector4, maximum_length: f32) -> Vector4; +} + +/// Reconstructs the full branch contract and arithmetic order of `0x82D34BC0`. +/// The frame count is signed in the caller and in the target's `extsw/fcfid`. +pub fn calculate_velocity_from_jump( + frame: &PhysicsAirFrame, + frames: i32, + math: &mut impl PhysicsAirMath, +) -> Vector4 { + let frame_seconds = frame.delta_time_2604 * frames as f32; + let gravity_delta = frame_seconds * frame.gravity_y_2648; + let mut predicted = frame.jump_velocity_848; + predicted[0] += 0.0; + predicted[1] += gravity_delta; + predicted[2] += 0.0; + predicted[3] += 0.0; + + let current = frame.current_velocity_400; + let mut error = core::array::from_fn(|lane| current[lane] - predicted[lane]); + let mut output = predicted; + + // `vcmpgtfp(error.y, -3.0)`: false, including equality/unordered, selects + // the current vertical velocity. + if !(error[1] > VERTICAL_ERROR_FLOOR) { + output[1] = current[1]; + } + + error[1] = 0.0; + let horizontal_error_squared = math.length_squared_vmsum3fp(error); + // Native fcmpu+blt skips correction only for an ordered value below 9.0. + if !(horizontal_error_squared < HORIZONTAL_ERROR_LIMIT_SQUARED) { + let mut predicted_horizontal = output; + predicted_horizontal[1] = 0.0; + let maximum_length = math.length_vmsum3fp_vrsqrte(predicted_horizontal); + + let mut current_horizontal = current; + current_horizontal[1] = 0.0; + let clamped = math.clamp_vector_within_max_length(current_horizontal, maximum_length); + output[0] = clamped[0]; + output[2] = clamped[2]; + } + output +} diff --git a/crates/skate-core/src/air/state/lifecycle.rs b/crates/skate-core/src/air/state/lifecycle.rs new file mode 100644 index 00000000..1c925099 --- /dev/null +++ b/crates/skate-core/src/air/state/lifecycle.rs @@ -0,0 +1,95 @@ +//! TU3 PhysicsAir Enter/Exit/output lifecycle. + +use super::{ + data::{ + AirTrajectory, PhysicsAirFrame, PhysicsAirOutput, PhysicsAirReckoningFields, + PhysicsAirState, Vector4, + }, + jump_velocity::calculate_velocity_from_jump, + runtime::PhysicsAirRuntime, +}; + +const TRAJECTORY_UNBOUNDED: f32 = f32::from_bits(0xBF80_0000); // -1.0 +const OFFBOARD_VERTICAL_SCALE: f32 = f32::from_bits(0x3F26_6666); // 0.65 +const OFFBOARD_VERTICAL_LIMIT: f32 = f32::from_bits(0x4040_0000); // 3.0 +const OUTPUT_SCALAR_184: f32 = f32::from_bits(0x4080_0000); // 4.0 + +/// Full TU3 `PhysState_PhysicsAir::Enter`, `0x82D34388`. +pub fn enter( + state: &mut PhysicsAirState, + frame: &PhysicsAirFrame, + world_acceleration: Vector4, + runtime: &mut impl PhysicsAirRuntime, +) { + runtime.set_air_collision_update_enabled(true); + runtime.set_skeleton_collision_state(7); + runtime.set_skeleton_inverse_kinematics_enabled(true); + runtime.set_skeleton_collision_state(6); + runtime.enable_board_angular_drive_only(); + runtime.set_footplant_flag_240(false); + runtime.reset_footplants(); + + state.selector_latch_174 = false; + state.reached_apex = false; + state.trajectory_query_countdown = 0; + state.landing_normal = [0.0, 1.0, 0.0, 0.0]; + state.time_in_state = 0.0; + state.max_y = runtime.board_transform_height(); + state.start_y = frame.ground_position_y_500; + + let (use_centre_of_mass_velocity, request_heading_update) = initial_centre_of_mass_mode(frame); + state.use_centre_of_mass_velocity = use_centre_of_mass_velocity; + + if use_centre_of_mass_velocity { + let mut velocity = frame.trajectory_velocity_608; + if frame.previous_physics_state_2504 == 500 { + let scaled_y = velocity[1] * OFFBOARD_VERTICAL_SCALE; + velocity[1] = runtime.minimum_vminfp(scaled_y, OFFBOARD_VERTICAL_LIMIT); + } + if frame.frames_since_jump_correction_2576 < 12 { + velocity = calculate_velocity_from_jump( + frame, + frame.frames_since_jump_correction_2576, + runtime, + ); + } + state.centre_of_mass_trajectory = AirTrajectory { + position: frame.trajectory_position_592, + velocity, + acceleration: world_acceleration, + scalar_48: TRAJECTORY_UNBOUNDED, + }; + } + + // This branch-local flag deliberately differs from the COM selection in + // the grind/category-400 path. + if request_heading_update { + runtime.request_skeleton_heading_update(); + } +} + +/// TU3 `PhysState_PhysicsAir::Exit`, `0x82D346A0`. +pub fn exit(state: &mut PhysicsAirState, reckoning: &mut PhysicsAirReckoningFields) { + state.use_centre_of_mass_velocity = false; + reckoning.body_spin_speed_1572 = 0.0; + reckoning.body_spin_angle_1568 = 0.0; +} + +/// TU3 `PhysState_PhysicsAir::FillPhysOut`, `0x82D34E90`. +pub fn fill_physics_output(state: &PhysicsAirState) -> PhysicsAirOutput { + PhysicsAirOutput { + is_at_apex: state.reached_apex, + jump_height: state.max_y - state.start_y, + landing_normal: state.landing_normal, + scalar_184_write: state.selector_latch_174.then_some(OUTPUT_SCALAR_184), + } +} + +fn initial_centre_of_mass_mode(frame: &PhysicsAirFrame) -> (bool, bool) { + if frame.previous_physics_category_2516 == 400 || frame.previous_physics_state_2504 == 701 { + (frame.flags_2468 & 0x0000_4000 == 0, false) + } else { + let selected = !matches!(frame.previous_physics_state_2504, 100 | 103 | 201); + (selected, selected) + } +} diff --git a/crates/skate-core/src/air/state/math.rs b/crates/skate-core/src/air/state/math.rs new file mode 100644 index 00000000..794618cb --- /dev/null +++ b/crates/skate-core/src/air/state/math.rs @@ -0,0 +1,46 @@ +//! Independent arithmetic for original PhysicsAir's jump-velocity helpers. +//! Retains original refinements; PC results are not certified Xenon bit-exact. +use super::PhysicsAirMath; +use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic}; + +pub struct AirMath; +impl PhysicsAirMath for AirMath { + fn minimum_vminfp(&mut self, left: f32, right: f32) -> f32 { + native_arithmetic::vector_min(left, right) + } + fn length_squared_vmsum3fp(&mut self, value: [f32; 4]) -> f32 { + native_arithmetic::dot3(value, value) + } + fn length_vmsum3fp_vrsqrte(&mut self, value: [f32; 4]) -> f32 { + let square = native_arithmetic::dot3(value, value); + let length = square * inverse_length_squared(square, 2); + if square == 0.0 { 0.0 } else { length } + } + fn clamp_vector_within_max_length(&mut self, value: [f32; 4], maximum: f32) -> [f32; 4] { + //82BD3D90..3E74, including the original short-vector bge and fsel. + let length = self.length_vmsum3fp_vrsqrte(value); + if !(length >= f32::from_bits(0x3780_0000)) { return value; } + let bound = if maximum - length >= -0.0 { length } else { maximum }; + let mut inverse = native_arithmetic::reciprocal_estimate(length); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse); + } + value.map(|v| (v * bound) * inverse) + } +} + +///8296EBB0 uses one normalization refinement and a clamped dot product. +pub fn angle_between_vectors(left: [f32; 4], right: [f32; 4]) -> f32 { + let xyz = |v: [f32; 4]| crate::math::Vector3::new(v[0], v[1], v[2]); + crate::physics::board_ground::angle_between(xyz(left), xyz(right)) +} + +///Toolkit_ClampJumpVelocity82D93518. The output uses the CURRENT velocity's +///direction; the supplied reference only bounds its magnitude. +pub fn clamp_jump_velocity(reference: [f32; 4], current: [f32; 4]) -> [f32; 4] { + let reference_speed = AirMath.length_vmsum3fp_vrsqrte(reference); + let speed = AirMath.length_vmsum3fp_vrsqrte(current); + let denominator = if speed - 1.0 >= -0.0 { speed } else { 1.0 }; + let scale = if denominator > reference_speed { reference_speed / denominator } else { 1.0 }; + current.map(|v| v * scale) +} diff --git a/crates/skate-core/src/air/state/mod.rs b/crates/skate-core/src/air/state/mod.rs new file mode 100644 index 00000000..bae10dd1 --- /dev/null +++ b/crates/skate-core/src/air/state/mod.rs @@ -0,0 +1,29 @@ +//! Readable TU3 `PhysState_PhysicsAir` lifecycle and update composition. +//! +//! The state owns the recovered branch/order semantics. Calls into the board, +//! skeleton, trajectory selector, wipeout controller, and unresolved Xenon +//! vector math remain mandatory runtime interfaces with no fallback. + +mod board; +mod data; +mod jump_velocity; +mod lifecycle; +mod math; +mod post_physics; +mod runtime; +mod update; + +pub use board::update_skateboard; +pub use data::{ + AirBoardForce, AirTrajectory, PhysicsAirFrame, PhysicsAirOutput, PhysicsAirReckoningFields, + PhysicsAirSettings, PhysicsAirState, +}; +pub use jump_velocity::{PhysicsAirMath, calculate_velocity_from_jump}; +pub use math::{AirMath, angle_between_vectors, clamp_jump_velocity}; +pub use lifecycle::{enter, exit, fill_physics_output}; +pub use post_physics::update_post_physics; +pub use runtime::{PhysicsAirLaunchInfo, PhysicsAirRuntime}; +pub use update::{integrate_trajectory_fixed_step, update, wrap_signed_angle}; + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/air/state/post_physics.rs b/crates/skate-core/src/air/state/post_physics.rs new file mode 100644 index 00000000..0c94ce5e --- /dev/null +++ b/crates/skate-core/src/air/state/post_physics.rs @@ -0,0 +1,15 @@ +//! TU3 PhysicsAir post-physics update (`0x82D34DD8`). + +use super::{data::PhysicsAirState, runtime::PhysicsAirRuntime}; + +/// Latches the first strict downward board velocity, then always runs the air +/// wipeout check. Negative zero and NaN do not mark the apex. +pub fn update_post_physics(state: &mut PhysicsAirState, runtime: &mut impl PhysicsAirRuntime) { + if !state.reached_apex { + let board_velocity = runtime.board_body_velocity(); + if board_velocity[1] < 0.0 { + state.reached_apex = true; + } + } + runtime.check_for_air_wipeout(false); +} diff --git a/crates/skate-core/src/air/state/runtime.rs b/crates/skate-core/src/air/state/runtime.rs new file mode 100644 index 00000000..2d8613d5 --- /dev/null +++ b/crates/skate-core/src/air/state/runtime.rs @@ -0,0 +1,76 @@ +//! Mandatory engine-facing effects used by TU3 PhysicsAir. + +use super::{ + data::{AirBoardForce, PhysicsAirReckoningFields, Vector4}, + jump_velocity::PhysicsAirMath, +}; + +/// Mutations of the 272-byte trajectory launch record recovered at +/// `0x82D33448`, `0x82BE33D0`, and `0x82D34570`. +pub trait PhysicsAirLaunchInfo { + fn set_start_velocity(&mut self, velocity: Vector4); + fn centre_of_mass_animation_position(&self) -> Vector4; + fn set_trajectory_start_position_override(&mut self, position: Vector4); + fn set_board_position_override(&mut self, position: Vector4); + fn cone_angles(&self) -> [f32; 2]; + fn set_cone_angles(&mut self, angles: [f32; 2]); + fn set_player_jumped(&mut self, jumped: bool); + fn set_use_trajectory_start_position_override(&mut self, enabled: bool); + fn set_trajectory_count(&mut self, count: u16); +} + +/// Every external call/read made by the recovered PhysicsAir methods. There is +/// deliberately no no-op/default implementation: an engine adapter must bind +/// each stock-owned object before this state can be scheduled. +pub trait PhysicsAirRuntime: PhysicsAirMath { + type LaunchInfo: PhysicsAirLaunchInfo; + + fn set_air_collision_update_enabled(&mut self, enabled: bool); + fn set_skeleton_collision_state(&mut self, state: u32); + fn set_skeleton_inverse_kinematics_enabled(&mut self, enabled: bool); + fn enable_board_angular_drive_only(&mut self); + fn set_footplant_flag_240(&mut self, value: bool); + fn reset_footplants(&mut self); + fn board_transform_height(&mut self) -> f32; + fn request_skeleton_heading_update(&mut self); + + fn update_air_collision(&mut self); + fn trajectory_query_just_started(&self) -> bool; + fn construct_trajectory_launch_info(&mut self) -> Self::LaunchInfo; + fn fill_skeleton_launch_info(&mut self, info: &mut Self::LaunchInfo); + fn launch_trajectory(&mut self, info: &Self::LaunchInfo); + fn update_trajectory_selector(&mut self); + fn selector_landing_normal(&self) -> Option; + fn selector_centre_of_mass_trajectory_ready(&self) -> bool; + + /// `AngleBetweenVectors` (`0x8296EBB0`), including its unresolved VMX + /// normalization behavior. The caller performs the recovered angle wrap. + fn angle_between_vectors(&mut self, left: Vector4, right: Vector4) -> f32; + ///Return the completed canonical fields: UpdateSkateboard reads the newly + ///filtered collision normal1216, after82D8DBD8, not the entry snapshot. + fn update_reckoning_air_states( + &mut self, + landing_normal: Vector4, + normal_blend: f32, + body_spin: f32, + body_flip: f32, + ) -> PhysicsAirReckoningFields; + fn update_known_air_skeleton(&mut self, trajectory_position: Vector4); + fn update_animated_skateboard_skeleton(&mut self, argument: bool); + + fn update_board_steering_tilt(&mut self, tilt: f32); + /// `Toolkit_CalcCollisionForce` (`0x82D944E8`). The adapter supplies its + /// bound settings and complete live ProcessedPhysIn; PhysicsAir only passes + /// the reference normal and receives the point-force record. + fn calculate_air_collision_force( + &mut self, + reference_normal: Vector4, + force: &mut AirBoardForce, + ) -> bool; + fn enqueue_board_force(&mut self, tag: u32, force: AirBoardForce); + fn set_board_velocity(&mut self, velocity: Vector4); + fn enable_skateboard_error_on_skeleton(&mut self); + + fn board_body_velocity(&mut self) -> Vector4; + fn check_for_air_wipeout(&mut self, argument: bool); +} diff --git a/crates/skate-core/src/air/state/tests/mod.rs b/crates/skate-core/src/air/state/tests/mod.rs new file mode 100644 index 00000000..6fe43b1e --- /dev/null +++ b/crates/skate-core/src/air/state/tests/mod.rs @@ -0,0 +1,551 @@ +use super::*; +use crate::point_graph::PointGraph; + +#[derive(Clone, Debug, Default, PartialEq)] +struct FakeLaunchInfo { + start_velocity: [f32; 4], + animation_position: [f32; 4], + trajectory_override: [f32; 4], + board_override: [f32; 4], + cone_angles: [f32; 2], + jumped_writes: Vec, + use_override: bool, + trajectory_count: u16, +} + +impl PhysicsAirLaunchInfo for FakeLaunchInfo { + fn set_start_velocity(&mut self, velocity: [f32; 4]) { + self.start_velocity = velocity; + } + + fn centre_of_mass_animation_position(&self) -> [f32; 4] { + self.animation_position + } + + fn set_trajectory_start_position_override(&mut self, position: [f32; 4]) { + self.trajectory_override = position; + } + + fn set_board_position_override(&mut self, position: [f32; 4]) { + self.board_override = position; + } + + fn cone_angles(&self) -> [f32; 2] { + self.cone_angles + } + + fn set_cone_angles(&mut self, angles: [f32; 2]) { + self.cone_angles = angles; + } + + fn set_player_jumped(&mut self, jumped: bool) { + self.jumped_writes.push(jumped); + } + + fn set_use_trajectory_start_position_override(&mut self, enabled: bool) { + self.use_override = enabled; + } + + fn set_trajectory_count(&mut self, count: u16) { + self.trajectory_count = count; + } +} + +#[derive(Debug)] +struct FakeRuntime { + events: Vec, + board_height: f32, + board_velocity: [f32; 4], + query_started: bool, + selector_normal: Option<[f32; 4]>, + selector_com_ready: bool, + angle: f32, + collision: bool, + collision_force: AirBoardForce, + enqueued_force: Option<(u32, AirBoardForce)>, + set_velocity: Option<[f32; 4]>, + launched: Option, + reckoning_call: Option<([f32; 4], f32, f32, f32)>, + reckoning_output: PhysicsAirReckoningFields, + collision_reference: Option<[f32; 4]>, + known_air_position: Option<[f32; 4]>, + forced_squared_length: Option, + forced_length: Option, + forced_clamp: Option<[f32; 4]>, +} + +impl Default for FakeRuntime { + fn default() -> Self { + Self { + events: Vec::new(), + board_height: 0.0, + board_velocity: [0.0; 4], + query_started: false, + selector_normal: None, + selector_com_ready: false, + angle: 0.0, + collision: false, + collision_force: AirBoardForce::ZERO, + enqueued_force: None, + set_velocity: None, + launched: None, + reckoning_call: None, + reckoning_output: reckoning(), + collision_reference: None, + known_air_position: None, + forced_squared_length: None, + forced_length: None, + forced_clamp: None, + } + } +} + +impl PhysicsAirMath for FakeRuntime { + fn minimum_vminfp(&mut self, left: f32, right: f32) -> f32 { + if left < right { left } else { right } + } + + fn length_squared_vmsum3fp(&mut self, value: [f32; 4]) -> f32 { + self.forced_squared_length + .unwrap_or(value[0] * value[0] + value[1] * value[1] + value[2] * value[2]) + } + + fn length_vmsum3fp_vrsqrte(&mut self, value: [f32; 4]) -> f32 { + self.forced_length + .unwrap_or((value[0] * value[0] + value[1] * value[1] + value[2] * value[2]).sqrt()) + } + + fn clamp_vector_within_max_length(&mut self, value: [f32; 4], maximum_length: f32) -> [f32; 4] { + if let Some(forced) = self.forced_clamp { + return forced; + } + let length = (value[0] * value[0] + value[1] * value[1] + value[2] * value[2]).sqrt(); + if length > maximum_length { + value.map(|lane| lane * (maximum_length / length)) + } else { + value + } + } +} + +impl PhysicsAirRuntime for FakeRuntime { + type LaunchInfo = FakeLaunchInfo; + + fn set_air_collision_update_enabled(&mut self, enabled: bool) { + self.events.push(format!("collision-enabled:{enabled}")); + } + + fn set_skeleton_collision_state(&mut self, state: u32) { + self.events.push(format!("collision-state:{state}")); + } + + fn set_skeleton_inverse_kinematics_enabled(&mut self, enabled: bool) { + self.events.push(format!("ik:{enabled}")); + } + + fn enable_board_angular_drive_only(&mut self) { + self.events.push("angular-drive".into()); + } + + fn set_footplant_flag_240(&mut self, value: bool) { + self.events.push(format!("footplant-240:{value}")); + } + + fn reset_footplants(&mut self) { + self.events.push("footplant-reset".into()); + } + + fn board_transform_height(&mut self) -> f32 { + self.events.push("board-height".into()); + self.board_height + } + + fn request_skeleton_heading_update(&mut self) { + self.events.push("heading".into()); + } + + fn update_air_collision(&mut self) { + self.events.push("air-collision".into()); + } + + fn trajectory_query_just_started(&self) -> bool { + self.query_started + } + + fn construct_trajectory_launch_info(&mut self) -> Self::LaunchInfo { + self.events.push("launch-construct".into()); + FakeLaunchInfo::default() + } + + fn fill_skeleton_launch_info(&mut self, info: &mut Self::LaunchInfo) { + self.events.push("launch-fill".into()); + info.animation_position = [10.0, 20.0, 30.0, 40.0]; + info.cone_angles = [2.0, 4.0]; + } + + fn launch_trajectory(&mut self, info: &Self::LaunchInfo) { + self.events.push("launch".into()); + self.launched = Some(info.clone()); + } + + fn update_trajectory_selector(&mut self) { + self.events.push("selector-update".into()); + } + + fn selector_landing_normal(&self) -> Option<[f32; 4]> { + self.selector_normal + } + + fn selector_centre_of_mass_trajectory_ready(&self) -> bool { + self.selector_com_ready + } + + fn angle_between_vectors(&mut self, _left: [f32; 4], _right: [f32; 4]) -> f32 { + self.events.push("angle".into()); + self.angle + } + + fn update_reckoning_air_states( + &mut self, + landing_normal: [f32; 4], + normal_blend: f32, + body_spin: f32, + body_flip: f32, + ) -> PhysicsAirReckoningFields { + self.events.push("reckoning".into()); + self.reckoning_call = Some((landing_normal, normal_blend, body_spin, body_flip)); + self.reckoning_output + } + + fn update_known_air_skeleton(&mut self, trajectory_position: [f32; 4]) { + self.events.push("known-air".into()); + self.known_air_position = Some(trajectory_position); + } + + fn update_animated_skateboard_skeleton(&mut self, argument: bool) { + self.events.push(format!("animated-board:{argument}")); + } + + fn update_board_steering_tilt(&mut self, tilt: f32) { + self.events.push(format!("steering-tilt:{tilt}")); + } + + fn calculate_air_collision_force( + &mut self, + reference_normal: [f32; 4], + force: &mut AirBoardForce, + ) -> bool { + self.events.push("collision-force".into()); + self.collision_reference = Some(reference_normal); + *force = self.collision_force; + self.collision + } + + fn enqueue_board_force(&mut self, tag: u32, force: AirBoardForce) { + self.events.push("enqueue-force".into()); + self.enqueued_force = Some((tag, force)); + } + + fn set_board_velocity(&mut self, velocity: [f32; 4]) { + self.events.push("set-board-velocity".into()); + self.set_velocity = Some(velocity); + } + + fn enable_skateboard_error_on_skeleton(&mut self) { + self.events.push("board-error".into()); + } + + fn board_body_velocity(&mut self) -> [f32; 4] { + self.events.push("board-velocity".into()); + self.board_velocity + } + + fn check_for_air_wipeout(&mut self, argument: bool) { + self.events.push(format!("wipeout:{argument}")); + } +} + +fn frame() -> PhysicsAirFrame { + PhysicsAirFrame { + current_velocity_400: [1.0, 2.0, 3.0, 0.0], + ground_position_y_500: 4.0, + trajectory_position_592: [1.0, 5.0, 2.0, 0.0], + trajectory_velocity_608: [2.0, 10.0, 4.0, 0.0], + jump_velocity_848: [2.0, 8.0, 4.0, 0.0], + flags_2468: 0, + previous_physics_state_2504: 500, + previous_physics_category_2516: 0, + frames_since_jump_correction_2576: 12, + delta_time_2604: 0.1, + body_spin_input_2640: 3.0, + gravity_y_2648: -10.0, + state_timer_2664: 1.0, + } +} + +fn state() -> PhysicsAirState { + PhysicsAirState { + centre_of_mass_trajectory: AirTrajectory { + position: [90.0; 4], + velocity: [80.0; 4], + acceleration: [70.0; 4], + scalar_48: 60.0, + }, + landing_normal: [9.0; 4], + time_in_state: 9.0, + start_y: 9.0, + max_y: 9.0, + reached_apex: true, + use_centre_of_mass_velocity: false, + selector_latch_174: true, + trajectory_query_countdown: 9, + } +} + +fn reckoning() -> PhysicsAirReckoningFields { + PhysicsAirReckoningFields { + current_landing_normal_1152: [0.0, 1.0, 0.0, 0.0], + collision_reference_normal_1216: [0.0, 0.0, 1.0, 0.0], + body_spin_angle_1568: 2.0, + body_spin_speed_1572: 3.0, + } +} + +fn settings() -> PhysicsAirSettings { + PhysicsAirSettings { + body_spin_over_time_320: PointGraph { + x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], + y: [2.0; 8], + }, + landing_normal_blend_388: 0.25, + body_spin_scale_428: 4.0, + landing_normal_angle_limit_444: 1.0, + } +} + +#[test] +fn enter_preserves_order_and_offboard_velocity_rule() { + let mut state = state(); + let frame = frame(); + let mut runtime = FakeRuntime { + board_height: 6.0, + ..Default::default() + }; + enter(&mut state, &frame, [0.0, -9.0, 0.0, 0.0], &mut runtime); + + assert_eq!( + runtime.events, + [ + "collision-enabled:true", + "collision-state:7", + "ik:true", + "collision-state:6", + "angular-drive", + "footplant-240:false", + "footplant-reset", + "board-height", + "heading", + ] + ); + assert!(state.use_centre_of_mass_velocity); + assert_eq!( + state.centre_of_mass_trajectory.velocity, + [2.0, 3.0, 4.0, 0.0] + ); + assert_eq!(state.landing_normal, [0.0, 1.0, 0.0, 0.0]); + assert_eq!((state.start_y, state.max_y), (4.0, 6.0)); + assert!(!state.reached_apex && !state.selector_latch_174); + assert_eq!(state.trajectory_query_countdown, 0); +} + +#[test] +fn category_400_can_select_com_without_requesting_heading() { + let mut state = state(); + let mut frame = frame(); + frame.previous_physics_category_2516 = 400; + frame.previous_physics_state_2504 = 100; + let mut runtime = FakeRuntime::default(); + + enter(&mut state, &frame, [0.0; 4], &mut runtime); + assert!(state.use_centre_of_mass_velocity); + assert!(!runtime.events.iter().any(|event| event == "heading")); +} + +#[test] +fn jump_velocity_uses_current_vertical_and_clamped_current_horizontal() { + let mut frame = frame(); + frame.current_velocity_400 = [10.0, 4.0, 0.0, 7.0]; + frame.jump_velocity_848 = [2.0, 10.0, 0.0, 5.0]; + let mut runtime = FakeRuntime { + forced_squared_length: Some(64.0), + forced_length: Some(2.0), + forced_clamp: Some([2.0, 0.0, 0.0, 99.0]), + ..Default::default() + }; + + let result = calculate_velocity_from_jump(&frame, 2, &mut runtime); + assert_eq!(result, [2.0, 4.0, 0.0, 5.0]); +} + +#[test] +fn update_runs_com_launch_and_fixed_step_in_native_order() { + let mut state = state(); + state.use_centre_of_mass_velocity = true; + state.selector_latch_174 = false; + state.trajectory_query_countdown = 0; + state.time_in_state = 0.25; + state.max_y = 5.0; + state.centre_of_mass_trajectory = AirTrajectory { + position: [1.0, 2.0, 3.0, 0.0], + velocity: [4.0, 5.0, 6.0, 0.0], + acceleration: [0.0, -6.0, 0.0, 0.0], + scalar_48: -1.0, + }; + let mut frame = frame(); + frame.frames_since_jump_correction_2576 = 12; + let mut runtime = FakeRuntime { + board_height: 7.0, + selector_normal: Some([0.0, 0.8, 0.2, 0.0]), + reckoning_output: PhysicsAirReckoningFields { + collision_reference_normal_1216: [1.0, 0.0, 0.0, 0.0], + ..reckoning() + }, + ..Default::default() + }; + + update( + &mut state, + &frame, + &settings(), + &mut reckoning(), + [0.0, -6.0, 0.0, 0.0], + &mut runtime, + ); + + assert_eq!( + runtime.events, + [ + "air-collision", + "launch-construct", + "launch-fill", + "launch-fill", + "launch", + "selector-update", + "angle", + "reckoning", + "known-air", + "steering-tilt:0", + "collision-force", + "board-error", + "board-height", + ] + ); + let launch = runtime.launched.unwrap(); + assert_eq!(launch.jumped_writes, [true, false]); + assert_eq!(launch.trajectory_override, [10.0, 19.35, 30.0, 40.0]); + assert_eq!(launch.board_override, [10.0, 19.2, 30.0, 40.0]); + assert_eq!(launch.cone_angles, [0.8, 1.6]); + assert!(launch.use_override); + assert_eq!(launch.trajectory_count, 5); + assert_eq!(state.trajectory_query_countdown, 7); + assert_eq!(state.max_y, 7.0); + assert_eq!(state.time_in_state, 0.35); + assert_eq!( + runtime.collision_reference, + Some([1.0, 0.0, 0.0, 0.0]), + "Board collision used the pre-update reckoning normal" + ); +} + +#[test] +fn skateboard_collision_force_wins_over_direct_velocity_correction() { + let state = state(); + let force = AirBoardForce { + force_world: [1.0, 2.0, 3.0, 4.0], + point_board_local: [5.0, 6.0, 7.0, 8.0], + }; + let mut runtime = FakeRuntime { + collision: true, + collision_force: force, + ..Default::default() + }; + let mut frame = frame(); + frame.frames_since_jump_correction_2576 = 0; + + update_skateboard(&state, &frame, &reckoning(), &mut runtime); + assert_eq!(runtime.enqueued_force, Some((15, force))); + assert_eq!(runtime.set_velocity, None); +} + +#[test] +fn skateboard_miss_applies_jump_velocity_only_before_frame_twelve() { + let mut state = state(); + state.use_centre_of_mass_velocity = false; + let mut frame = frame(); + frame.frames_since_jump_correction_2576 = 2; + frame.current_velocity_400 = [2.0, 4.0, 4.0, 0.0]; + frame.jump_velocity_848 = [2.0, 10.0, 4.0, 0.0]; + let mut runtime = FakeRuntime { + forced_squared_length: Some(0.0), + ..Default::default() + }; + + update_skateboard(&state, &frame, &reckoning(), &mut runtime); + assert_eq!(runtime.set_velocity, Some([2.0, 4.0, 4.0, 0.0])); + assert_eq!( + runtime.events, + ["steering-tilt:0", "collision-force", "set-board-velocity"] + ); +} + +#[test] +fn post_physics_latches_only_strict_downward_velocity_then_checks_wipeout() { + let mut state = state(); + state.reached_apex = false; + let mut runtime = FakeRuntime { + board_velocity: [0.0, -0.0, 0.0, 0.0], + ..Default::default() + }; + update_post_physics(&mut state, &mut runtime); + assert!(!state.reached_apex); + runtime.board_velocity[1] = -0.01; + update_post_physics(&mut state, &mut runtime); + assert!(state.reached_apex); + assert_eq!( + runtime.events, + [ + "board-velocity", + "wipeout:false", + "board-velocity", + "wipeout:false" + ] + ); +} + +#[test] +fn exit_and_output_publish_only_recovered_fields() { + let mut state = state(); + state.max_y = 9.0; + state.start_y = 4.0; + state.reached_apex = true; + state.selector_latch_174 = true; + state.landing_normal = [1.0, 2.0, 3.0, 4.0]; + let mut reckoning = reckoning(); + + let output = fill_physics_output(&state); + assert_eq!(output.jump_height, 5.0); + assert_eq!(output.scalar_184_write.map(f32::to_bits), Some(0x4080_0000)); + assert_eq!(output.landing_normal, [1.0, 2.0, 3.0, 4.0]); + + exit(&mut state, &mut reckoning); + assert!(!state.use_centre_of_mass_velocity); + assert_eq!(reckoning.body_spin_angle_1568, 0.0); + assert_eq!(reckoning.body_spin_speed_1572, 0.0); +} + +#[test] +fn angle_wrap_uses_recovered_turn_constants() { + assert_eq!(wrap_signed_angle(0.0).to_bits(), 0.0_f32.to_bits()); + assert!(wrap_signed_angle(4.0) < 0.0); + assert!(wrap_signed_angle(2.0) > 0.0); +} diff --git a/crates/skate-core/src/air/state/update.rs b/crates/skate-core/src/air/state/update.rs new file mode 100644 index 00000000..a4740c11 --- /dev/null +++ b/crates/skate-core/src/air/state/update.rs @@ -0,0 +1,193 @@ +//! TU3 PhysicsAir Update (`0x82D346C0`) and COM launch helper (`0x82D34570`). + +use crate::point_graph::PointGraph; + +use super::{ + board::update_skateboard, + data::{ + AirTrajectory, PhysicsAirFrame, PhysicsAirReckoningFields, PhysicsAirSettings, + PhysicsAirState, Vector4, + }, + jump_velocity::calculate_velocity_from_jump, + runtime::{PhysicsAirLaunchInfo, PhysicsAirRuntime}, +}; + +const FIXED_STEP: f32 = f32::from_bits(0x3C88_8889); // 1/60, 0x82098D40 +const HALF: f32 = f32::from_bits(0x3F00_0000); +const ONE: f32 = f32::from_bits(0x3F80_0000); +const TRAJECTORY_UNBOUNDED: f32 = f32::from_bits(0xBF80_0000); +const TWO: f32 = f32::from_bits(0x4000_0000); +const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); +const TWO_PI: f32 = f32::from_bits(0x40C9_0FDB); +const RECIPROCAL_TWO_PI: f32 = f32::from_bits(0x3E22_F983); +const HALF_TURN: f32 = f32::from_bits(0x3F00_0000); +const COM_START_Y_OFFSET: f32 = f32::from_bits(0xBF26_6666); // -0.65 +const BOARD_START_Y_OFFSET: f32 = f32::from_bits(0xBF4C_CCCD); // -0.8 +const CONE_SCALE: f32 = f32::from_bits(0x3ECC_CCCD); // 0.4 + +/// Full recovered outer update, retaining native effect order. +pub fn update( + state: &mut PhysicsAirState, + frame: &PhysicsAirFrame, + settings: &PhysicsAirSettings, + reckoning: &mut PhysicsAirReckoningFields, + world_acceleration: Vector4, + runtime: &mut impl PhysicsAirRuntime, +) { + runtime.update_air_collision(); + + state.trajectory_query_countdown = state.trajectory_query_countdown.wrapping_sub(1); + if state.trajectory_query_countdown <= 0 + && frame.flags_2468 & 0x0000_8000 == 0 + && frame.previous_physics_state_2504 != 0 + && !runtime.trajectory_query_just_started() + { + launch_trajectory(state, frame, world_acceleration, runtime); + state.trajectory_query_countdown = 7; + } + + runtime.update_trajectory_selector(); + + let current_normal = reckoning.current_landing_normal_1152; + let landing_normal = runtime.selector_landing_normal().unwrap_or(current_normal); + state.landing_normal = landing_normal; + + let raw_angle = runtime.angle_between_vectors(landing_normal, current_normal); + let wrapped_angle = wrap_signed_angle(raw_angle); + let mut normal_blend = if wrapped_angle <= settings.landing_normal_angle_limit_444 { + settings.landing_normal_blend_388 + } else { + 0.0 + }; + + if !state.use_centre_of_mass_velocity { + if !state.selector_latch_174 { + state.selector_latch_174 = runtime.selector_centre_of_mass_trajectory_ready(); + } + if state.selector_latch_174 { + state.use_centre_of_mass_velocity = true; + state.centre_of_mass_trajectory = AirTrajectory { + position: frame.trajectory_position_592, + velocity: frame.trajectory_velocity_608, + acceleration: world_acceleration, + scalar_48: TRAJECTORY_UNBOUNDED, + }; + } + } + + if frame.state_timer_2664 <= 0.0 { + normal_blend = 0.0; + } else if frame.flags_2468 & 0x0000_4000 != 0 { + state.use_centre_of_mass_velocity = false; + } + + let scaled_spin_input = frame.body_spin_input_2640 * settings.body_spin_scale_428; + let spin_radians = scaled_spin_input * DEGREES_TO_RADIANS; + let curve = evaluate_spin_curve(&settings.body_spin_over_time_320, state.time_in_state * TWO); + let target_body_spin = curve * spin_radians; + *reckoning = + runtime.update_reckoning_air_states(landing_normal, normal_blend, target_body_spin, 0.0); + + if state.use_centre_of_mass_velocity { + integrate_trajectory_fixed_step(&mut state.centre_of_mass_trajectory); + runtime.update_known_air_skeleton(state.centre_of_mass_trajectory.position); + } else { + runtime.update_animated_skateboard_skeleton(false); + } + + update_skateboard(state, frame, reckoning, runtime); + runtime.enable_skateboard_error_on_skeleton(); + + let board_y = runtime.board_transform_height(); + // fcmpu+bgt keeps the old value only for an ordered old > new result; + // unordered values take the board-height write. + if !(state.max_y > board_y) { + state.max_y = board_y; + } + state.time_in_state += frame.delta_time_2604; +} + +/// Scalar wrap at `0x82D347E8..0x82D34848`, after AngleBetweenVectors. +pub fn wrap_signed_angle(angle: f32) -> f32 { + let turns = angle * RECIPROCAL_TWO_PI; + let fraction = turns - turns.floor(); + let signed_fraction = if fraction > HALF_TURN { + fraction - ONE + } else { + fraction + }; + signed_fraction * TWO_PI +} + +/// Fixed 1/60 ballistic step at `0x82D349B8..0x82D34A20`. +pub fn integrate_trajectory_fixed_step(trajectory: &mut AirTrajectory) { + let step_squared = FIXED_STEP * FIXED_STEP; + let linear_position: Vector4 = core::array::from_fn(|lane| { + trajectory.velocity[lane].mul_add(FIXED_STEP, trajectory.position[lane]) + }); + let next_velocity: Vector4 = core::array::from_fn(|lane| { + trajectory.acceleration[lane].mul_add(FIXED_STEP, trajectory.velocity[lane]) + }); + let half = ONE * HALF; + let half_acceleration = trajectory.acceleration.map(|lane| lane * half); + let next_position: Vector4 = core::array::from_fn(|lane| { + half_acceleration[lane].mul_add(step_squared, linear_position[lane]) + }); + trajectory.position = next_position; + trajectory.velocity = next_velocity; +} + +fn launch_trajectory( + state: &PhysicsAirState, + frame: &PhysicsAirFrame, + world_acceleration: Vector4, + runtime: &mut impl PhysicsAirRuntime, +) { + let mut info = runtime.construct_trajectory_launch_info(); + runtime.fill_skeleton_launch_info(&mut info); + if frame.frames_since_jump_correction_2576 < 12 { + let velocity = + calculate_velocity_from_jump(frame, frame.frames_since_jump_correction_2576, runtime); + info.set_start_velocity(velocity); + } else if state.use_centre_of_mass_velocity { + adjust_centre_of_mass_launch_info(state, world_acceleration, runtime, &mut info); + } + + // The caller always clears +268, including after the COM helper sets it. + info.set_player_jumped(false); + runtime.launch_trajectory(&info); +} + +fn adjust_centre_of_mass_launch_info( + state: &PhysicsAirState, + world_acceleration: Vector4, + runtime: &mut R, + info: &mut R::LaunchInfo, +) { + runtime.fill_skeleton_launch_info(info); + + let velocity = core::array::from_fn(|lane| { + world_acceleration[lane].mul_add(FIXED_STEP, state.centre_of_mass_trajectory.velocity[lane]) + }); + info.set_start_velocity(velocity); + info.set_player_jumped(true); + + let animation_position = info.centre_of_mass_animation_position(); + let trajectory_offset = [0.0, COM_START_Y_OFFSET, 0.0, 0.0]; + let board_offset = [0.0, BOARD_START_Y_OFFSET, 0.0, 0.0]; + info.set_trajectory_start_position_override(core::array::from_fn(|lane| { + animation_position[lane] + trajectory_offset[lane] + })); + info.set_board_position_override(core::array::from_fn(|lane| { + animation_position[lane] + board_offset[lane] + })); + info.set_use_trajectory_start_position_override(true); + info.set_trajectory_count(5); + + let cone_angles = info.cone_angles(); + info.set_cone_angles([cone_angles[0] * CONE_SCALE, cone_angles[1] * CONE_SCALE]); +} + +fn evaluate_spin_curve(graph: &PointGraph<8>, input: f32) -> f32 { + graph.evaluate(input) +} diff --git a/crates/skate-core/src/air/trajectory/launch.rs b/crates/skate-core/src/air/trajectory/launch.rs new file mode 100644 index 00000000..18853938 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/launch.rs @@ -0,0 +1,170 @@ +//! Original TU3 trajectory launch calculations82D67D08/82D67320/82D682E8. +use super::{LaunchInfo, QueryRequest, SelectorInput, SelectorSettings, Trajectory, math::*}; + +pub(super) fn adjust_velocity( + info: &mut LaunchInfo, + input: SelectorInput, + s: &SelectorSettings, +) -> bool { + let n = input.ground_normal; + let v = info.start_velocity; + let mut normal = scale(n, dot(v, n)); + normal[1] = 0.0; + let combined = add([0.0, v[1], 0.0, 0.0], normal); + let residual = sub(v, combined); + let lean = (input.directional_input - 0.25).max(-1.0).min(1.0); + let aligned = normalize(sub(UP, scale(n, lean * s.vert_jump_align_max_angle))); + if n[1] >= s.vert_jump_align_max_ground_normal_y { + return false; + } + if normalize(combined)[1] > s.vert_jump_align_min_direction_y { + //The second term really uses UNIT velocity, not the original speed. + let left = scale( + add(scale(aligned, length(combined)), residual), + s.vert_jump_align_factor, + ); + let right = scale(normalize(v), 1.0 - s.vert_jump_align_factor); + info.start_velocity = scale(normalize(add(left, right)), length(v)); + true + } else { + if !info.player_jumped && input.previous_physics_state != 200 { + let normal = scale(n, dot(v, n)); + info.start_velocity = add( + scale(normal, s.natural_air_off_verts_scalar), + sub(v, normal), + ); + } + false + } +} + +pub(super) fn candidate_velocities(info: LaunchInfo, s: &SelectorSettings) -> Vec { + let count = usize::from(info.trajectory_count.min(7)); + let mut velocities = Vec::with_capacity(count); + if count == 0 { + return velocities; + } + let v = info.start_velocity; + velocities.push(v); + let right = normalize(cross(UP, v)); + let forward = normalize(cross(right, UP)); + let speed = length(v); + let cone_speed = if info.player_jumped { + length(info.com_velocity) + } else { + speed + }; + let radians = f32::from_bits(0x3c8e_fa35); + let x = info.cone_angle_x * radians; + let z = (s.cone_angle_z_vs_speed.evaluate(cone_speed * 0.05) * info.cone_angle_z) * radians; + let cone_speed = cone_speed.max(s.speed_factor_min).min(s.speed_factor_max); + let right = scale(right, crate::trigonometry::sin(x) * cone_speed); + let forward = scale(forward, crate::trigonometry::sin(z) * cone_speed); + if count > 1 { + let step = f32::from_bits(0x40c9_0fdb) / (count - 1) as f32; + let mut angle = 0.0; + for _ in 1..count { + let candidate = madd( + forward, + crate::trigonometry::cos(angle), + madd(right, crate::trigonometry::sin(angle), v), + ); + velocities.push(scale(normalize(candidate), length(candidate).min(speed))); + angle += step; + } + } + velocities +} + +#[derive(Clone, Debug)] +pub(super) struct LaunchBatch { + pub requests: Vec, + pub velocities: Vec, + pub origin: Vector, //2864 + pub board_position: Vector, //2848 + pub local_board_position: Vector, //2832 + pub local_com_position: Vector, //2784 + pub com_displacement: Vector, //2768 +} +pub(super) fn batch(info: LaunchInfo, input: SelectorInput, s: &SelectorSettings) -> LaunchBatch { + let velocities = candidate_velocities(info, s); + let ground = s + .displacement_vs_ground_normal + .evaluate(input.ground_normal[1].abs()); + let speed = s + .displacement_vs_speed + .evaluate(input.board_vertical_velocity * 0.1); + let blend = speed.max(ground); + let height = dot( + sub(info.animation_com_position, input.contact_position), + info.reckoning_transform[1], + ) - s.trajectory_radius; + let displacement = + height.min(s.trajectory_displacement) * (1.0 - blend) + s.trajectory_displacement * blend; + let mut origin = sub( + info.animation_com_position, + scale(info.reckoning_transform[1], displacement), + ); + let mut board_position = info.board_position; + if info.use_position_override { + origin = info.start_position_override; + board_position = info.board_position_override; + } + let com_displacement = sub(info.animation_com_position, origin); + let duration = max_time(info.start_velocity, s); + let requests = velocities + .iter() + .map(|&velocity| QueryRequest { + trajectory: Trajectory { + position: madd(velocity, info.timestep, origin), + velocity, + acceleration: input.gravity, + duration, + }, + radius: s.trajectory_radius, + start_error: s.trajectory_error_start, + end_error: s.trajectory_error_end, + }) + .collect(); + LaunchBatch { + requests, + velocities, + origin, + board_position, + com_displacement, + local_board_position: transform(info.reckoning_inverse, sub(info.board_position, origin)), + local_com_position: transform(info.reckoning_inverse, com_displacement), + } +} +///82D68698 deliberately uses the original -19.6 and -1/9.8 coefficients. +fn max_time(velocity: Vector, s: &SelectorSettings) -> f32 { + let square = velocity[1].mul_add( + velocity[1], + -(s.trajectory_max_drop * f32::from_bits(0xc19c_cccd)), + ); + let root = if square == 0.0 { + 0.0 + } else { + square * inverse_length(square) + }; + ((-velocity[1] - root) * f32::from_bits(0xbdd0_fac6)).min(s.trajectory_max_time) +} + +///tTrajectory::AdjustTrajectory82D609E0 changes velocity only. +pub(super) fn adjust_trajectory( + trajectory: &mut Trajectory, + frame: i32, + adjustment: Vector, + maximum: f32, +) { + if frame <= 0 { + return; + } + let correction = scale(adjustment, reciprocal(frame as f32 * STEP)); + let scalar = if dot(correction, correction) > maximum * maximum { + maximum / length(correction) + } else { + 1.0 + }; + trajectory.velocity = madd(correction, scalar, trajectory.velocity); +} diff --git a/crates/skate-core/src/air/trajectory/math.rs b/crates/skate-core/src/air/trajectory/math.rs new file mode 100644 index 00000000..7dae5af0 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/math.rs @@ -0,0 +1,80 @@ +//! Independent PC arithmetic retaining the recovered refinement sequences. +use crate::physics::native_arithmetic; +pub(super) type Vector = [f32; 4]; +pub(super) type Transform = [Vector; 4]; +pub(super) const ZERO: Vector = [0.0; 4]; +pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +pub(super) const STEP: f32 = f32::from_bits(0x3c88_8889); +pub(super) const IDENTITY: Transform = [[1.0, 0.0, 0.0, 0.0], UP, [0.0, 0.0, 1.0, 0.0], ZERO]; +pub(super) fn dot(a: Vector, b: Vector) -> f32 { + native_arithmetic::dot3(a, b) +} +pub(super) fn add(a: Vector, b: Vector) -> Vector { + core::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn sub(a: Vector, b: Vector) -> Vector { + core::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn scale(a: Vector, s: f32) -> Vector { + a.map(|v| v * s) +} +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { + core::array::from_fn(|i| a[i].mul_add(s, b[i])) +} +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0.0, + ] +} +pub(super) fn inverse_length(square: f32) -> f32 { + let mut v = native_arithmetic::reciprocal_square_root_estimate(square); + for _ in 0..2 { + v = (v * 0.5).mul_add((-square).mul_add(v * v, 1.0), v); + } + v +} +pub(super) fn reciprocal(value: f32) -> f32 { + let mut inverse = native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(value, 1.0), inverse); + } + inverse +} +///8296EBB0: squared-length gate, ONE refinement, clamped dot, native acos. +pub(super) fn angle_between(a: Vector, b: Vector) -> f32 { + let aa = dot(a, a); + let bb = dot(b, b); + if !(aa > f32::from_bits(0x38d1_b717) && bb > f32::from_bits(0x38d1_b717)) { + return 0.0; + } + let unit = |v: Vector, square: f32| { + let r = native_arithmetic::reciprocal_square_root_estimate(square); + scale(v, (r * 0.5).mul_add((-square).mul_add(r * r, 1.0), r)) + }; + crate::trigonometry::acos(dot(unit(a, aa), unit(b, bb)).max(-1.0).min(1.0)) +} +pub(super) fn length(a: Vector) -> f32 { + let square = dot(a, a); + if square == 0.0 { + 0.0 + } else { + square * inverse_length(square) + } +} +pub(super) fn normalize(a: Vector) -> Vector { + let square = dot(a, a); + let inverse = inverse_length(square); + let length = if square == 0.0 { 0.0 } else { square * inverse }; + //830BD350's initializer82F826F8 broadcasts82181A88 (1e-6). + if length > f32::from_bits(0x3586_37bd) { + scale(a, inverse) + } else { + ZERO + } +} +pub(super) fn transform(m: Transform, a: Vector) -> Vector { + madd(m[2], a[2], madd(m[1], a[1], madd(m[0], a[0], m[3]))) +} diff --git a/crates/skate-core/src/air/trajectory/mod.rs b/crates/skate-core/src/air/trajectory/mod.rs new file mode 100644 index 00000000..df8428b3 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/mod.rs @@ -0,0 +1,13 @@ +//! TU3 trajectory selection82D67848/82D682E8/82D68800. +//! Host world storage and batch ownership are independent of console layout. +mod launch; +mod math; +mod prediction; +mod query; +mod scoring; +mod selector; +mod types; +pub use prediction::{Prediction, QueryRequest, QueryResult, Trajectory}; +pub use query::{SurfaceHit, query_trajectory}; +pub use selector::{Selection, TrajectorySelector, WorldWithoutGrindEdges}; +pub use types::{LaunchInfo, SelectorInput, SelectorSettings}; diff --git a/crates/skate-core/src/air/trajectory/prediction.rs b/crates/skate-core/src/air/trajectory/prediction.rs new file mode 100644 index 00000000..690573d7 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/prediction.rs @@ -0,0 +1,90 @@ +//! tTrajectory and complete PredictionResults8276C9D8/8276DBE0. +use super::math::{IDENTITY, STEP, Transform, UP, Vector, ZERO, reciprocal}; +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Trajectory { + pub position: Vector, //0 metres + pub velocity: Vector, //16 metres/second + pub acceleration: Vector, //32 metres/second squared + pub duration: f32, //48 seconds; -1 means unbounded +} +impl Trajectory { + pub fn position_at(self, time: f32) -> Vector { + let square = time * time; + core::array::from_fn(|i| { + (self.acceleration[i] * 0.5) + .mul_add(square, self.velocity[i].mul_add(time, self.position[i])) + }) + } + pub fn velocity_at(self, time: f32) -> Vector { + core::array::from_fn(|i| self.acceleration[i].mul_add(time, self.velocity[i])) + } + pub fn highest_position(self) -> (Vector, f32) { + //82D2CCA0: downward velocity or nonnegative gravity has no apex. + if self.velocity[1] < 0.0 || self.acceleration[1] >= 0.0 { + (self.position, 0.0) + } else { + let t = self.velocity[1] * reciprocal(-self.acceleration[1]); + (self.position_at(t), t) + } + } +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct QueryRequest { + pub trajectory: Trajectory, //request144..207 + pub radius: f32, //208 metres + pub start_error: f32, //212 multiplier of radius + pub end_error: f32, //216 multiplier of radius +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct QueryResult { + pub contact_position: Vector, //0 + pub contact_normal: Vector, //16 first hit face normal + pub landing_normal: Vector, //32 velocity-qualified nearby-face average + pub contact_time: f32, //48 seconds; -1 is no contact + pub contact_transform: Transform, //64 static world identity + pub contact_frame: i32, //128 + pub surface: u32, //132 original surface data + pub geometry: u32, //136 host geometry identity +} +impl QueryResult { + pub const fn miss() -> Self { + Self { + contact_position: ZERO, + contact_normal: UP, + landing_normal: UP, + contact_time: -1.0, + contact_transform: IDENTITY, + contact_frame: -1, + surface: 0, + geometry: 0, + } + } + pub fn valid(self) -> bool { + self.contact_time >= 0.0 + } + ///PredictionResults::GetLandingNormal82D2D9E8. + pub fn suggested_normal(self) -> Vector { + if self.valid() { + self.landing_normal + } else { + UP + } + } +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Prediction { + pub result: QueryResult, + pub request: QueryRequest, +} +impl Prediction { + pub fn collision_position(self) -> Vector { + self.request + .trajectory + .position_at(self.result.contact_time) + } + pub fn collision_velocity(self) -> Vector { + self.request + .trajectory + .velocity_at(self.result.contact_frame as f32 * STEP) + } +} diff --git a/crates/skate-core/src/air/trajectory/query.rs b/crates/skate-core/src/air/trajectory/query.rs new file mode 100644 index 00000000..89377896 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/query.rs @@ -0,0 +1,152 @@ +//! Complete trajectory-segment walk82770910 and result writer8276DBE0. +//! The host supplies real nearest swept-line hits and intersecting triangles. +use super::{QueryRequest, QueryResult, math::*}; +#[derive(Clone, Copy, Debug)] +pub struct SurfaceHit { + pub position: Vector, + pub normal: Vector, + pub transform: Transform, + pub surface: u32, + pub geometry: u32, +} + +pub fn query_trajectory( + request: QueryRequest, + mut line: impl FnMut(Vector, Vector, f32) -> Result, E>, + mut nearby: impl FnMut(Vector, f32) -> Result, E>, +) -> Result { + let trajectory = request.trajectory; + let mut start_step = trajectory.duration; + let mut end_step = trajectory.duration; + if trajectory.acceleration[1] < 0.0 { + start_step = step_size( + trajectory.acceleration[1], + request.start_error * request.radius, + ); + end_step = step_size( + trajectory.acceleration[1], + request.end_error * request.radius, + ); + } + let minimum_square = + ((request.start_error * request.radius) * request.start_error) * request.radius; + let mut time = 0.0; + let mut start = trajectory.position_at(time); + let mut end = trajectory.position_at(time + start_step); + while trajectory.duration > time { + let delta = sub(end, start); + if dot(delta, delta) > minimum_square { + //82770B40 rejects a segment only when all XYZ are tiny. + if delta[..3] + .iter() + .any(|v| v.abs() > f32::from_bits(0x3780_0000)) + { + if let Some(hit) = line(start, end, request.radius)? { + let (contact_time, contact_frame) = + time_at_contact(request, hit.position, time); + let triangles = nearby(hit.position, request.radius)?; + let velocity = trajectory.velocity_at(contact_frame as f32 * STEP); + return Ok(QueryResult { + contact_position: hit.position, + contact_normal: hit.normal, + landing_normal: average_landing_normal(&triangles, velocity), + contact_time, + contact_frame, + contact_transform: hit.transform, + surface: hit.surface, + geometry: hit.geometry, + }); + } + } + start = end; + } + let fraction = reciprocal(trajectory.duration) * time; + let step = end_step.mul_add(fraction, (1.0 - fraction) * start_step); + time += step; + //The native walk evaluates beyond the horizon; it does not clamp end. + end = trajectory.position_at(time + step); + } + Ok(QueryResult::miss()) +} + +fn reciprocal(value: f32) -> f32 { + let mut inverse = crate::physics::native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(value, 1.0), inverse); + } + inverse +} +fn step_size(gravity_y: f32, error_radius: f32) -> f32 { + let square = reciprocal(gravity_y) * (-8.0 * error_radius); + if square == 0.0 { + 0.0 + } else { + square * inverse_length(square) + } +} +///8276C558 writes both collision seconds and integer frame, including its +///distinct negligible-gravity branch. A miss is handled by the caller. +fn time_at_contact(request: QueryRequest, position: Vector, mut time: f32) -> (f32, i32) { + const EPSILON: f32 = f32::from_bits(0x38d1_b717); + let trajectory = request.trajectory; + if EPSILON >= (reciprocal(2.0) * trajectory.acceleration[1]).abs() { + let distance = length(sub(position, trajectory.position)); + if !(distance.abs() > EPSILON) { + return (0.0, 0); + } + let speed = length(trajectory.velocity); + if !(speed.abs() > EPSILON) { + return (0.0, 0); + } + let frames = reciprocal(speed) * distance; + return (frames * STEP, frames.ceil() as i32); + } + let mut closest_square = f32::MAX; + let mut found = false; + while trajectory.duration >= time { + let delta = sub(position, trajectory.position_at(time)); + let square = dot(delta, delta); + if closest_square >= square { + closest_square = square; + } else if square > closest_square { + time -= STEP; + found = true; + break; + } + time += STEP; + } + if !found { + time = trajectory.duration; + } + //82F4E948 is ceiling (truncate then add1 for a positive remainder). + (time, (reciprocal(STEP) * time).ceil() as i32) +} +///82771018, using at most64 triangles retained by82772028 in world order. +fn average_landing_normal(triangles: &[[Vector; 3]], velocity: Vector) -> Vector { + let mut accepted = Vec::with_capacity(64); + let mut most_up = ZERO; + let mut highest = -1.0; + for triangle in triangles.iter().take(64) { + let normal = cross(sub(triangle[1], triangle[0]), sub(triangle[2], triangle[0])); + //This first stage uses Normalize, not NormalizeSafe. + let normal = scale(normal, inverse_length(dot(normal, normal))); + if normal[1] > 0.7 || dot(normal, velocity) <= 0.0 { + let up = dot(normal, UP); + if up > highest { + highest = up; + most_up = normal; + } + accepted.push(normal); + } + } + if accepted.is_empty() { + return UP; + } + let mut total = ZERO; + for normal in accepted { + if dot(most_up, normal) > 0.5 { + total = add(total, normal); + } + } + normalize(total) +} diff --git a/crates/skate-core/src/air/trajectory/scoring.rs b/crates/skate-core/src/air/trajectory/scoring.rs new file mode 100644 index 00000000..db97cd01 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/scoring.rs @@ -0,0 +1,193 @@ +//! TU3 scoring82D68FE8/82D68AF0/82D69790 for a world with no grind edges. +//! That topology is supplied by the authored BoardWorld, not inferred from a +//! failed collision. Every wall probe still queries real world geometry. +use super::{ + Prediction, SelectorInput, SelectorSettings, SurfaceHit, launch::adjust_trajectory, math::*, +}; + +#[derive(Clone, Copy, Debug)] +pub(super) struct Candidate { + pub prediction: Prediction, + pub start_velocity: Vector, //candidate64+0, before wall adjustment + pub normal: Vector, //+16 + pub collision_velocity: Vector, //+32, before wall adjustment + pub collision_position: Vector, //+48, before wall adjustment + pub score: f32, + pub wall_score: f32, + pub wall_ride: bool, +} +pub(super) fn score( + candidates: &mut [Candidate], + pass: u16, + adjusted_on_vert: bool, + input: SelectorInput, + s: &SelectorSettings, + mut line: impl FnMut(Vector, Vector, f32) -> Result, String>, +) -> Result { + let mut all_miss = true; //selector9662, consumed by PhysicsAir COM latch + let mut highest_wall = -1_000_000.0; + for (index, c) in candidates.iter_mut().enumerate() { + c.score = 0.0; + c.wall_score = 0.0; + c.wall_ride = false; + if !c.prediction.result.valid() { + continue; + } + all_miss = false; + if c.prediction.result.contact_frame < s.minimum_trajectory_frames { + continue; + } + let middle = index == 0 && pass == 1; + let middle_bonus = if middle && !adjusted_on_vert { + s.score_middle_bonus + } else { + 0.0 + }; + c.collision_position = c.prediction.collision_position(); + c.normal = c.prediction.result.suggested_normal(); + c.prediction.result.contact_normal = c.normal; + c.collision_velocity = c.prediction.collision_velocity(); + let force_dot = dot(c.normal, c.collision_velocity); + let sideways = length(cross(c.normal, normalize(c.collision_velocity))); + let scalar = s.landing_force_scalar.evaluate(force_dot.abs()); + let force_score = (force_dot * s.score_landing_force) * scalar; + let direction_score = (sideways * s.score_landing_direction) * scalar; + + //82D69C00 returns0 immediately for the actual empty edge query. Its + //9552 flag remains clear.82D69D68 then evaluates the stock graph at + //its native invalid-query distance1000; do not substitute a score. + let grind_score = if input.flags_2476 & 0x0200_0000 == 0 + && (input.grind_lock_distance > 0.5 || !middle) + { + s.grind_penalty_vs_distance.evaluate(1000.0) + } else { + 0.0 + }; + if grind_score <= 0.0 { + c.wall_score = wall_score( + &mut c.prediction, + grind_score < 0.0, + s, + &mut highest_wall, + &mut line, + )?; + c.wall_ride = c.wall_score > 0.0; + if c.wall_score < 0.0 { + c.prediction.result.contact_normal = UP; + } + } + let mut surface_score = 0.0; + if grind_score <= 1.0 { + match (c.prediction.result.surface >> 7) & 31 { + 6 => { + surface_score = s.surface_unrideable_score; + c.prediction.result.contact_normal = input.reference_up; + } + 7 => { + surface_score = s.surface_dont_align_score; + c.prediction.result.contact_normal = input.reference_up; + } + 8 => { + surface_score = s.surface_dont_align_score; + } + _ => {} + } + } + c.normal = c.prediction.result.contact_normal; + let mut transition = 0.0; + if adjusted_on_vert { + let sign = if input.directional_input >= 0.5 { + 1.0 + } else { + -1.0 + }; + let mut heading = input.heading_direction; + heading[1] = 0.0; + let since_apex = (c.prediction.result.contact_time - apex_time(c.prediction)).abs(); + let penalty = if since_apex >= s.minimum_time_after_apex { + 0.0 + } else { + -500.0 + }; + let coefficient = ((1.0 - input.ground_normal[1].abs()) * sign) * s.score_transition; + transition = dot(c.normal, normalize(heading)) * coefficient + penalty; + } + c.score = s + .landing_time_bonus + .evaluate(c.prediction.result.contact_time) + + (((((direction_score + 1.0) + force_score) + middle_bonus) + grind_score) + + transition); + c.score += surface_score; + } + //AddWallRideScores runs after ALL candidates, using the final highest Y. + for c in candidates { + if c.prediction.result.valid() + && c.prediction.result.contact_frame >= s.minimum_trajectory_frames + { + if c.wall_score == 0.0 && c.prediction.collision_position()[1] > highest_wall { + c.wall_score = 2000.0; + } + c.score += c.wall_score; + } + } + Ok(all_miss) +} + +fn apex_time(p: Prediction) -> f32 { + let v = p.request.trajectory.velocity[1]; + let g = p.request.trajectory.acceleration[1]; + if v < 0.0 || g >= 0.0 { + 0.0 + } else { + v * reciprocal(-g) + } +} +fn wall_score( + p: &mut Prediction, + reject_grind: bool, + s: &SelectorSettings, + highest: &mut f32, + line: &mut impl FnMut(Vector, Vector, f32) -> Result, String>, +) -> Result { + let normal = p.result.contact_normal; + if !(normal[1].abs() < 0.5) { + return Ok(0.0); + } + let start = madd(normal, s.wall_ride_test_distance, p.result.contact_position); + let end = add(start, [0.0, -10.0, 0.0, 0.0]); + let Some(hit) = line(start, end, 0.0)? else { + return Ok(0.0); + }; + if !(dot(hit.normal, normal) < s.wall_ride_normal_dot_limit) { + return Ok(0.0); + } + let height = (hit.position[1] - start[1]).abs(); + if reject_grind { + return Ok(height * s.wall_ride_height_score - 1000.0); + } + let position = p.collision_position(); + let mut delta = sub(p.request.trajectory.position, position); + let mut slope = delta[1]; + delta[1] = 0.0; + let horizontal = length(delta); + if horizontal > f32::from_bits(0x3c23_d70a) { + slope *= reciprocal(horizontal); + } + if slope > s.wall_ride_angle_allow_landing { + return Ok(height * s.wall_ride_height_score - 1000.0); + } + if height < s.wall_ride_minimum_height { + return Ok(height - 1000.0); + } + let boost = s.wall_ride_boost.evaluate(height); + if position[1] > *highest { + *highest = position[1]; + } + adjust_trajectory( + &mut p.request.trajectory, + p.result.contact_frame, + [0.0, boost, 0.0, 0.0], + s.maximum_trajectory_adjust, + ); + Ok(height * s.wall_ride_height_score) +} diff --git a/crates/skate-core/src/air/trajectory/selector.rs b/crates/skate-core/src/air/trajectory/selector.rs new file mode 100644 index 00000000..796e6e16 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/selector.rs @@ -0,0 +1,313 @@ +//! Original selector Launch82D67848 and Update82D68800 with typed batch ownership. +use super::{ + LaunchInfo, Prediction, QueryRequest, QueryResult, SelectorInput, SelectorSettings, SurfaceHit, + Trajectory, launch, + math::*, + scoring::{self, Candidate}, +}; + +///The current authored world contains collision triangles and no grind edges. +///Pass this only after querying that actual world's topology. It is not a +///replacement for a grind result when a world contains grind primitives. +#[derive(Clone, Copy, Debug)] +pub struct WorldWithoutGrindEdges; + +#[derive(Clone, Copy, Debug)] +pub struct Selection { + pub candidate_index: usize, //9636 + pub prediction: Prediction, //owning copy at1696, pointer1680 + pub start_velocity: Vector, //2640 + pub landing_normal: Vector, //2656 + pub collision_velocity: Vector, //2672 + pub collision_position: Vector, //2688 + pub com_trajectory: Trajectory, //2704 + pub surface_category: u32, //9640 + pub wall_ride: bool, //9661 +} +#[derive(Clone, Debug, Default)] +pub struct TrajectorySelector { + launch_info: Option, + batch: Option, + candidates: Vec, + selection: Option, + selected_index: Option, + grind_locked_to_middle: bool, //9653 + grind_normal: Option, //9656/2896 + pass: u16, //9632 + adjusted_on_vert: bool, //9651 + pending: bool, //9657 + valid: bool, //9658 + just_changed: bool, //9660 + all_predictions_missed: bool, //9662 + suggested_normal: Option, //9655/2880 +} +impl TrajectorySelector { + pub fn new() -> Self { + Self::default() + } + pub fn pending(&self) -> bool { + self.pending + } + pub fn valid(&self) -> bool { + self.valid + } + pub fn just_changed(&self) -> bool { + self.just_changed + } + pub fn all_predictions_missed(&self) -> bool { + self.all_predictions_missed + } + pub fn suggested_normal(&self) -> Option { + self.suggested_normal + } + pub fn selection(&self) -> Option<&Selection> { + self.selection.as_ref() + } + pub fn selected_index(&self) -> Option { + self.selected_index + } + pub fn grind_locked_to_middle(&self) -> bool { + self.grind_locked_to_middle + } + pub fn grind_normal(&self) -> Option { + self.grind_normal + } + pub fn launch_info(&self) -> Option<&LaunchInfo> { + self.launch_info.as_ref() + } + pub fn requests(&self) -> &[QueryRequest] { + self.batch.as_ref().map_or(&[], |b| b.requests.as_slice()) + } + pub fn com_displacement(&self) -> Option { + self.batch.as_ref().map(|b| b.com_displacement) + } + pub fn local_com_position(&self) -> Option { + self.batch.as_ref().map(|b| b.local_com_position) + } + pub fn local_board_position(&self) -> Option { + self.batch.as_ref().map(|b| b.local_board_position) + } + pub fn board_position(&self) -> Option { + self.batch.as_ref().map(|b| b.board_position) + } + pub fn query_origin(&self) -> Option { + self.batch.as_ref().map(|b| b.origin) + } + ///Update82D68800 always clears9660 even if no batch is ready. + pub fn update_without_completion(&mut self) -> bool { + self.just_changed = false; + self.valid + } + ///Ground's explicit cancellation writes9657 only; cached observations stay. + pub fn cancel_pending(&mut self) { + self.pending = false; + } + + ///Reset82D67228 invalidates landing observations, while retaining the + ///in-flight request9657, winning result1680 and selected index9636. + pub fn reset(&mut self) { + self.valid = false; + self.suggested_normal = None; + self.grind_normal = None; + self.just_changed = false; + self.all_predictions_missed = false; + self.pass = 0; + self.grind_locked_to_middle = false; + if let Some(selection) = &mut self.selection { + selection.com_trajectory = Trajectory { + position: ZERO, + velocity: ZERO, + acceleration: ZERO, + duration: -1.0, + }; + } + //The reset's remaining slots are not represented by this selector's + //player-owned, triangle-world observations. + } + + pub fn launch( + &mut self, + mut info: LaunchInfo, + input: SelectorInput, + s: &SelectorSettings, + ) -> Result { + if self.pending { + return Ok(false); + } + if info.trajectory_count == 0 { + return Err("GetLaunchInfo supplied zero trajectory candidates".into()); + } + self.pass = 0; + self.valid = false; + //Launch invalidates9636 but retains the cached winning result and COM. + self.selected_index = None; + self.grind_normal = None; + self.adjusted_on_vert = launch::adjust_velocity(&mut info, input, s); + self.launch_info = Some(info); + self.launch_pass(input, s); + Ok(true) + } + fn launch_pass(&mut self, input: SelectorInput, s: &SelectorSettings) { + let info = self + .launch_info + .expect("Launch owns the packet before its batch"); + self.all_predictions_missed = false; + if let Some(selection) = &mut self.selection { + selection.wall_ride = false; + } + let batch = launch::batch(info, input, s); + //Only velocities are written by InitTrajectoryInfo. Retain the other + //candidate fields across passes until scoring writes a valid result. + self.candidates + .resize_with(self.candidates.len().max(batch.requests.len()), || { + Candidate { + prediction: Prediction { + request: batch.requests[0], + result: QueryResult::miss(), + }, + start_velocity: ZERO, + normal: ZERO, + collision_velocity: ZERO, + collision_position: ZERO, + score: 0.0, + wall_score: 0.0, + wall_ride: false, + } + }); + for (index, c) in self + .candidates + .iter_mut() + .take(batch.requests.len()) + .enumerate() + { + c.start_velocity = batch.velocities[index]; + c.prediction = Prediction { + request: batch.requests[index], + result: QueryResult::miss(), + }; + } + self.batch = Some(batch); + self.pending = true; + } + + ///Complete a real world query batch in order. A second pass is exposed by + ///pending()/requests(); host scheduling preserves its next Update boundary. + pub fn complete_batch( + &mut self, + results: &[QueryResult], + input: SelectorInput, + s: &SelectorSettings, + _topology: WorldWithoutGrindEdges, + line: impl FnMut(Vector, Vector, f32) -> Result, String>, + ) -> Result { + self.just_changed = false; + if !self.pending { + return Ok(self.valid); + } + let count = self.requests().len(); + if results.len() != count { + return Err("Trajectory completion count differs from submitted batch".into()); + } + self.just_changed = self.pass == 2; + self.pending = false; + self.pass += 1; + for (c, &result) in self.candidates.iter_mut().zip(results) { + c.prediction.result = result; + } + self.all_predictions_missed = scoring::score( + &mut self.candidates[..count], + self.pass, + self.adjusted_on_vert, + input, + s, + line, + )?; + let mut index = 0; + let mut best = -100_000_000.0; + for (i, c) in self.candidates.iter().take(count).enumerate() { + if c.score > best { + best = c.score; + index = i; + } + } + let c = self.candidates[index]; + self.selected_index = Some(index); + //The copied COM trajectory is only recalculated on a valid-time result. + let previous_com = self.selection.map(|v| v.com_trajectory); + let mut selection = Selection { + candidate_index: index, + prediction: c.prediction, + start_velocity: c.start_velocity, + landing_normal: c.normal, + collision_velocity: c.collision_velocity, + collision_position: c.collision_position, + //Ctor82D66DC0 initializes2704/2720/2736=zero,2752=-1. + com_trajectory: previous_com.unwrap_or(Trajectory { + position: ZERO, + velocity: ZERO, + acceleration: ZERO, + duration: -1.0, + }), + surface_category: self.selection.map_or(0, |v| v.surface_category), + wall_ride: c.wall_ride, + }; + if c.prediction.result.contact_time < s.minimum_valid_time { + self.suggested_normal = Some(c.prediction.result.contact_normal); + self.valid = false; + self.just_changed = false; + self.selection = Some(selection); + return Ok(false); + } + selection.surface_category = (c.prediction.result.surface >> 7) & 31; + selection.com_trajectory = self.com_trajectory(selection, input, s); + //No grind edges means native9652/9653 are false. AIPath is absent from + //this player-owned runtime, so original9659 is false as well. + self.grind_locked_to_middle = false; + let second_pass = self.pass == 1 + && count >= 2 + && !(angle_between(selection.landing_normal, input.ground_normal) + < s.minimum_normal_delta_second_pass); + self.selection = Some(selection); + self.valid = true; + if second_pass { + let info = self.launch_info.as_mut().expect("completed launch packet"); + info.start_velocity = selection.start_velocity; + info.cone_angle_x = s.cone_x_second_pass; + info.cone_angle_z = s.cone_z_second_pass; + self.launch_pass(input, s); + self.pass = 2; + } + Ok(self.valid) + } + fn com_trajectory( + &self, + selection: Selection, + input: SelectorInput, + s: &SelectorSettings, + ) -> Trajectory { + let info = self.launch_info.expect("completed launch packet"); + let batch = self.batch.as_ref().expect("completed query batch"); + let mut trajectory = selection.prediction.request.trajectory; + let scalar = s + .landing_com_scalar_vs_slope + .evaluate(selection.landing_normal[1]); + let delta = sub(info.animation_com_position, trajectory.position); + let height = dot( + input.reference_up, + sub(batch.origin, info.animation_com_position), + ) + .abs() + * scalar; + let offset = scale(selection.landing_normal, height); + if selection.prediction.result.contact_frame > 0 { + trajectory.position = add(trajectory.position, delta); + launch::adjust_trajectory( + &mut trajectory, + selection.prediction.result.contact_frame, + sub(offset, delta), + s.maximum_trajectory_adjust, + ); + } + trajectory + } +} diff --git a/crates/skate-core/src/air/trajectory/types.rs b/crates/skate-core/src/air/trajectory/types.rs new file mode 100644 index 00000000..12501757 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/types.rs @@ -0,0 +1,105 @@ +//! Launch data82D33448/82BE33D0 and the source-owned caller observations. +use super::math::{IDENTITY, Transform, Vector, ZERO}; +use crate::point_graph::PointGraph; +#[derive(Clone, Copy, Debug)] +pub struct LaunchInfo { + pub reckoning_transform: Transform, //0=Reckoning816 + pub reckoning_inverse: Transform, //64=Reckoning944 + pub start_velocity: Vector, //128=Processed400 m/s + pub com_velocity: Vector, //144=Processed704 m/s + pub skeleton_vector_160: Vector, //160=Skeleton16208 + pub skeleton_vector_176: Vector, //176=Skeleton16240 + pub board_position: Vector, //192=Processed112 metres + pub animation_com_position: Vector, //208=Processed592 metres + pub start_position_override: Vector, //224 metres + pub board_position_override: Vector, //240 metres + pub cone_angle_x: f32, //256 degrees + pub cone_angle_z: f32, //260 degrees + pub timestep: f32, //264 seconds + pub player_jumped: bool, //268 + pub use_position_override: bool, //269 + pub trajectory_count: u16, //270 GetLaunchInfo selects1 or7 +} +impl Default for LaunchInfo { + ///Original ctor82D33448; Fill must supply current physical observations. + fn default() -> Self { + Self { + reckoning_transform: IDENTITY, + reckoning_inverse: IDENTITY, + start_velocity: ZERO, + com_velocity: ZERO, + skeleton_vector_160: ZERO, + skeleton_vector_176: ZERO, + board_position: ZERO, + animation_com_position: ZERO, + start_position_override: ZERO, + board_position_override: ZERO, + cone_angle_x: 0.0, + cone_angle_z: 0.0, + timestep: 0.0, + player_jumped: false, + use_position_override: false, + trajectory_count: 0, + } + } +} +#[derive(Clone, Copy, Debug)] +pub struct SelectorInput { + pub gravity: Vector, //PhysicsWorld32 m/s^2 + pub ground_normal: Vector, //Processed464 + pub contact_position: Vector, //496 metres + pub heading_direction: Vector, //512 + pub reference_up: Vector, //544 + pub board_vertical_velocity: f32, //404 m/s + pub directional_input: f32, //2636 + pub previous_physics_state: u32, //2504 + pub flags_2472: u32, + pub flags_2476: u32, + pub offboard_flags_1776: u32, + pub grind_lock_distance: f32, //active physics_mode80 metres +} +///Stock skater collection values; offsets refer to physics_trajectory unless stated. +#[derive(Clone, Debug)] +pub struct SelectorSettings { + pub cone_x: f32, //576 degrees, Skeleton GetLaunchInfo + pub cone_z: f32, //568 degrees, Skeleton GetLaunchInfo + pub trajectory_max_time: f32, //428 seconds + pub trajectory_max_drop: f32, //63673FB6DA092B7E metres + pub trajectory_error_start: f32, //432 + pub trajectory_error_end: f32, //436 + pub speed_factor_min: f32, //440 m/s + pub speed_factor_max: f32, //444 m/s + pub cone_angle_z_vs_speed: PointGraph<8>, //304 + pub cone_x_second_pass: f32, //572 degrees + pub cone_z_second_pass: f32, //564 degrees + pub landing_time_bonus: PointGraph<8>, //0 x16/y48 + pub landing_com_scalar_vs_slope: PointGraph<8>, //80 x96/y128 + pub landing_force_scalar: PointGraph<8>, //240 + pub grind_penalty_vs_distance: PointGraph<8>, //160 x176/y208 + pub score_middle_bonus: f32, //468 + pub score_landing_force: f32, //472 + pub score_landing_direction: f32, //476 + pub score_transition: f32, //456 + pub surface_unrideable_score: f32, //460 + pub surface_dont_align_score: f32, //464 + pub natural_air_off_verts_scalar: f32, //484 + pub minimum_valid_time: f32, //488 seconds + pub minimum_time_after_apex: f32, //492 seconds + pub minimum_normal_delta_second_pass: f32, //496 radians + pub maximum_trajectory_adjust: f32, //500 + pub wall_ride_test_distance: f32, //416 metres + pub wall_ride_minimum_height: f32, //420 metres + pub wall_ride_angle_allow_landing: f32, //424 + pub wall_ride_height_score: f32, //452 + pub wall_ride_boost: PointGraph<4>, //368 x384/y400 + pub wall_ride_normal_dot_limit: f32, //physics global188/layout248 + pub displacement_vs_speed: PointGraph<8>, //physics_reckoning544 + pub displacement_vs_ground_normal: PointGraph<8>, //physics_reckoning608 + pub trajectory_radius: f32, //physics_reckoning C6710942E297D587 metres + pub trajectory_displacement: f32, //physics_reckoning runtime attribute + pub minimum_trajectory_frames: i32, //physics_reckoning runtime attribute + pub vert_jump_align_factor: f32, //0B42DFBB7F7756A0 + pub vert_jump_align_max_ground_normal_y: f32, //94923D136EA2DA37 + pub vert_jump_align_min_direction_y: f32, //58BFC8D41216C7EE + pub vert_jump_align_max_angle: f32, //723FF102068622CF +} diff --git a/crates/skate-core/src/animation/air_leg_extension.rs b/crates/skate-core/src/animation/air_leg_extension.rs new file mode 100644 index 00000000..122e6e4e --- /dev/null +++ b/crates/skate-core/src/animation/air_leg_extension.rs @@ -0,0 +1,159 @@ +//! ControlAirLegExtension82BAF538 and its ascending/descending/preland helpers. +use super::output::attributes::AnimationAttribute; +use crate::{physics::native_arithmetic::dot3, point_graph::PointGraph}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Bone { + Board, + LeftToe, + RightToe, +} + +pub struct Settings { + pub going_up_speed: f32, + pub going_down_speed: f32, + pub minimum_height: f32, + pub preland_velocity: f32, + pub preland_final_height: f32, + pub offboard_multiplier: f32, + pub arm_extension: PointGraph<4>, +} + +///All vectors are preceding completed physical output in stock coordinates. +#[derive(Clone, Copy)] +pub struct Physical { + pub com_velocity: [f32; 4], + pub com_position: [f32; 4], + pub system_up: [f32; 4], + pub right_toe: [f32; 4], + pub left_toe: [f32; 4], + pub animation_height: f32, + pub offboard_316: bool, + pub remaining_air_time: f32, +} + +///CreateInstance82BAF980 zeros mode8, distance12 and descending_time16. +#[derive(Default)] +pub struct State { + mode: u32, + height: f32, + descending_time: f32, +} +impl State { + pub fn needs_initial_attribute(&self) -> bool { + self.mode == 0 + } + pub fn needs_prelanding_query(&self, p: Physical) -> bool { + self.mode == 2 || (self.mode == 1 && p.com_velocity[1] < 0.0) + } + ///The first update seeds the height but does not also advance its new mode. + pub fn update( + &mut self, + bone: Bone, + p: Physical, + settings: &Settings, + dt: f32, + initial_attribute: Option, + prepare_to_land: bool, + ) -> [f32; 2] { + let ascending = p.com_velocity[1] > 0.0; + match self.mode { + 0 => { + self.mode = if ascending { 1 } else { 2 }; + if let Some(attribute) = initial_attribute { + if attribute.kind == 0 { + //The queried union is initialized to zero before GetLastAttr. + self.height = f32::from_bits(attribute.payload.0[0].unwrap_or(0)); + } + } else { + let toe = if p.offboard_316 || bone == Bone::RightToe { + Some(p.right_toe) + } else if bone == Bone::LeftToe { + Some(p.left_toe) + } else { + None + }; + self.height = match toe { + Some(toe) => dot3( + std::array::from_fn(|i| p.com_position[i] - toe[i]), + p.system_up, + ), + None => p.animation_height, + }; + } + } + 1 if p.com_velocity[1] >= 0.0 => { + //82BAFB60 is FNMSUBS, followed by FSEL against minimum1756. + self.height = maximum( + (-dt).mul_add(settings.going_up_speed, self.height), + settings.minimum_height, + ); + } + 1 | 2 => { + self.mode = 2; + if prepare_to_land { + self.mode = 3; + self.preland(bone, settings, dt); + } else { + //82BAFC88..FD0C: both dot products precede the fused blend. + let closing = -dot3(p.com_velocity, p.system_up); + let vertical = dot3(p.system_up, [0.0, 1.0, 0.0, 0.0]).abs(); + let speed = maximum(closing, 0.0) + .mul_add(vertical, (1.0 - vertical) * settings.going_down_speed); + let speed = minimum(speed, settings.going_down_speed); + self.height = + maximum((-speed).mul_add(dt, self.height), settings.minimum_height); + } + } + 3 => self.preland(bone, settings, dt), + _ => {} + } + self.descending_time = if ascending { + 0.0 + } else { + self.descending_time + dt + }; + [ + self.height, + settings.arm_extension.evaluate(self.descending_time), + ] + } + fn preland(&mut self, bone: Bone, settings: &Settings, dt: f32) { + self.height = dt.mul_add(settings.preland_velocity, self.height); + if bone != Bone::Board { + self.height *= settings.offboard_multiplier; + } + } +} + +///82BAFDB8 checks the named FullExtension attribute after the physical query. +pub fn prepare_to_land( + physical_query: bool, + full_extension: Option, + p: Physical, + s: &Settings, +) -> bool { + if !physical_query { + return false; + } + let Some(attribute) = full_extension else { + return true; + }; + //GetLastAttr's scalar output remains initialized zero for nonfloat records. + let extension = if attribute.kind == 0 { + f32::from_bits(attribute.payload.0[0].unwrap_or(0)) + } else { + 0.0 + }; + if extension < s.preland_final_height - f32::from_bits(0x3dcccccd) { + return true; + } + let required_time = (extension - p.animation_height) / s.preland_velocity; + p.remaining_air_time - f32::from_bits(0x3d75c28f) < required_time +} +fn maximum(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { a } else { b } +} +fn minimum(a: f32, b: f32) -> f32 { + if b - a >= 0.0 { a } else { b } +} diff --git a/crates/skate-core/src/animation/body_tilt.rs b/crates/skate-core/src/animation/body_tilt.rs new file mode 100644 index 00000000..b6cd397a --- /dev/null +++ b/crates/skate-core/src/animation/body_tilt.rs @@ -0,0 +1,66 @@ +//! SettingBodyTilt Update82BA88A0 and CreateInstance82BA8B40. +use crate::point_graph::PointGraph; + +#[derive(Clone, Debug)] +pub struct Settings { + pub body_spin_factor: PointGraph<4>, + pub ground_velocity: f32, + pub ground_acceleration: f32, + pub air_velocity: f32, + pub air_acceleration: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Physical { + ///PhysOutAnimation vector+0 X, consumed with mirrored stance sign. + pub lateral_tilt: f32, + ///Expanded PhysOut bundle+8 scalar204; the curve consumes its magnitude. + pub body_spin_speed: f32, + pub filtered_category: u32, +} +#[derive(Clone, Copy, Debug, Default)] +pub struct State { + value: f32, + velocity: f32, + was_enabled: bool, +} +impl State { + pub fn disable(&mut self) {self.was_enabled=false;} + pub fn update( + &mut self, + enabled: bool, + mirrored: bool, + p: Physical, + s: &Settings, + ) -> Option { + if !self.was_enabled && enabled { + self.value = 0.0; + self.velocity = 0.0; + } + self.was_enabled = enabled; + if !enabled { + return None; + } + //82B97140 tests fullSkaterAnim15180 bit30. The nonmirrored branch + //flips the sign bit of the source X component before multiplication. + let tilt = if mirrored { + p.lateral_tilt + } else { + f32::from_bits(p.lateral_tilt.to_bits() ^ 0x80000000) + }; + let target = s.body_spin_factor.evaluate(p.body_spin_speed.abs()) * tilt; + let (velocity, acceleration) = if p.filtered_category == 2 { + (s.air_velocity, s.air_acceleration) + } else { + (s.ground_velocity, s.ground_acceleration) + }; + let desired = bound(target - self.value, -velocity, velocity); + let change = bound(desired - self.velocity, -acceleration, acceleration); + self.velocity += change; + self.value += self.velocity; + Some(self.value) + } +} +fn bound(value: f32, lo: f32, hi: f32) -> f32 { + let lower = if lo - value >= 0.0 { lo } else { value }; + if hi - lower >= 0.0 { lower } else { hi } +} diff --git a/crates/skate-core/src/animation/channel_playback.rs b/crates/skate-core/src/animation/channel_playback.rs new file mode 100644 index 00000000..a1732a56 --- /dev/null +++ b/crates/skate-core/src/animation/channel_playback.rs @@ -0,0 +1,138 @@ +//! Persistent channel timing from TU3 NewChannel82D1CC20, AddTime82D266A8, +//! RemoveExpiredChannels82D1DA00 and EndChannelPrematurely82D1D6A8. +use super::{clip_clock::AdvanceResult,output::attributes::AnimationAttribute}; + +#[derive(Clone,Copy,Debug,PartialEq)] +pub struct ChannelSettings { + pub priority:i32, + pub keep_alive:bool, + pub mirrored:bool, + pub speed:f32, + pub blend_in:f32, + pub hold_during_blend_in:bool, + pub blend_out:f32, + pub hold_during_blend_out:bool, + pub use_attributes:bool, +} + +#[derive(Clone,Debug)] +pub struct ChannelPlayback { + pub settings:ChannelSettings, + pub weight:f32, + pub influence:f32, + blend_in_elapsed:f32, + blend_out_elapsed:f32, + resurrection_elapsed:f32, + resurrection_duration:f32, + flags:u32, +} +impl ChannelPlayback { + pub fn new(settings:ChannelSettings)->Self { + let flags=(u32::from(settings.keep_alive)<<28) + |(u32::from(settings.hold_during_blend_in)<<31) + |(u32::from(settings.hold_during_blend_out)<<30) + |(u32::from(settings.blend_out>0.0)<<27) + |(u32::from(settings.use_attributes)<<25); + Self {settings,weight:0.0,influence:1.0,blend_in_elapsed:0.0,blend_out_elapsed:0.0,resurrection_elapsed:0.0,resurrection_duration:0.0,flags} + } + ///Called before advancing any trees, in the native actor AddTime order. + pub fn expired(&self)->bool { + if self.flags&0x08000000!=0 {self.blend_out_elapsed>=self.settings.blend_out} + else {self.flags&0x10000000==0&&self.flags&0x04000000!=0} + } + pub fn end(&mut self) {if self.blend_out_elapsed==0.0 {self.flags|=0x28000000;}} + pub fn end_with(&mut self,seconds:f32,from_last_frame:bool) { + if self.blend_out_elapsed==0.0 { + self.settings.blend_out=seconds;self.settings.hold_during_blend_out=from_last_frame; + self.flags=(self.flags&!0x40000000)|(u32::from(from_last_frame)<<30)|0x28000000; + } + } + pub fn can_transition(&self,resurrect:bool)->bool {!(self.blend_out_elapsed>0.0)||resurrect} + ///StartTransition82D1D398..D47C preserves fade-in time/influence and + ///priority, replaces fade-out settings, and optionally restores a fading + ///channel from its existing coefficient rather than restarting at zero. + pub fn transition(&mut self,settings:ChannelSettings,resurrect:bool) { + if self.blend_out_elapsed>0.0&&resurrect { + self.resurrection_duration=settings.blend_in; + self.resurrection_elapsed=unit(1.0-self.blend_out_elapsed/self.settings.blend_out)*settings.blend_in; + } + self.blend_out_elapsed=0.0; + self.settings.keep_alive=settings.keep_alive; + self.settings.blend_out=settings.blend_out; + self.settings.hold_during_blend_out=settings.hold_during_blend_out; + self.settings.use_attributes=settings.use_attributes; + self.flags=(self.flags&!0x7a000000)|(u32::from(settings.keep_alive)<<28) + |(u32::from(settings.hold_during_blend_out)<<30) + |(u32::from(settings.blend_out>0.0)<<27)|(u32::from(settings.use_attributes)<<25); + } + ///Returns whether the channel child should advance. The base has already + ///advanced; fade weights use PRE-update elapsed times, then increment them. + pub fn advance(&mut self,dt:f32,length:f32,time:f32)->bool { + let mut fade_in=1.0; + if self.settings.blend_in>0.0&&self.blend_in_elapsed=0.0 {remaining}else{0.0}; + let hold_end=self.flags&0x40000000!=0; + let ended=self.flags&0x04000000!=0; + let fade_automatically=if hold_end {ended}else{remaining0.0&&(self.flags&0x20000000!=0||(self.flags&0x10000000==0&&fade_automatically)) { + fade_out=unit(1.0-self.blend_out_elapsed/self.settings.blend_out); + self.blend_out_elapsed+=dt; + } + let mut resurrection=1.0; + if self.resurrection_duration>0.0&&self.resurrection_elapsed=0.0 {fade_out}else{fade_in}; + let fade=if fade-resurrection>=0.0 {resurrection}else{fade}; + self.weight=unit(fade*self.influence); + !(self.flags&0x80000000!=0&&self.blend_in_elapsed0.0) + } + pub fn did_advance(&mut self,property:AdvanceResult) { + self.flags=(self.flags&!0x04000000)|(u32::from(property.crossed_end)<<26); + } + pub fn merge_attributes(&self,base:Vec,channel:&[AnimationAttribute])->Vec { + if !self.settings.use_attributes {return base;} + //82B966D0: ordered union, channel wins equal names without scaling. + let mut output=Vec::with_capacity(base.len()+channel.len());let mut b=0;let mut c=0; + while b{output.push(base[b]);b+=1;}, + core::cmp::Ordering::Greater=>{output.push(channel[c]);c+=1;}, + core::cmp::Ordering::Equal=>{output.push(channel[c]);b+=1;c+=1;}, + } + } + output.extend_from_slice(&base[b..]);output.extend_from_slice(&channel[c..]);output + } +} +fn unit(value:f32)->f32 {let low=if -value>=0.0 {0.0}else{value};if 1.0-low>=0.0 {low}else{1.0}} + +#[cfg(test)] +mod tests { + use super::*; + fn settings()->ChannelSettings {ChannelSettings {priority:0,keep_alive:false,mirrored:false,speed:1.0,blend_in:0.5,hold_during_blend_in:true,blend_out:0.5,hold_during_blend_out:false,use_attributes:false}} + #[test] + fn preincrement_fade_and_expiry_order_preserve_native_first_frame() { + let mut channel=ChannelPlayback::new(settings()); + assert!(!channel.advance(0.25,2.0,0.0));assert_eq!(channel.weight,0.0); + assert!(channel.advance(0.25,2.0,0.0));assert_eq!(channel.weight,0.5); + channel.end(); + assert!(channel.advance(0.25,2.0,0.25));assert_eq!(channel.weight,1.0);assert!(!channel.expired()); + channel.advance(0.25,2.0,0.5);assert_eq!(channel.weight,0.5);assert!(channel.expired()); + } + #[test] + fn returning_channel_restores_from_existing_fade_without_zero_restart() { + let mut settings=settings();settings.hold_during_blend_in=false; + let mut channel=ChannelPlayback::new(settings); + channel.advance(0.5,2.0,0.0);channel.end();channel.advance(0.25,2.0,0.5); + assert!(!channel.can_transition(false));assert!(channel.can_transition(true)); + channel.transition(settings,true);channel.advance(0.1,2.0,0.0); + assert_eq!(channel.weight,0.5);assert!(!channel.expired()); + } +} diff --git a/crates/skate-core/src/animation/clip_clock.rs b/crates/skate-core/src/animation/clip_clock.rs new file mode 100644 index 00000000..790523c2 --- /dev/null +++ b/crates/skate-core/src/animation/clip_clock.rs @@ -0,0 +1,124 @@ +//! Andale Clip timing: 0x82D25A00, 0x825902C0, 0x827B8BD8, +//! 0x827B8C38, 0x827B8B90; attribute status 0x82D264C0. + +#[derive(Clone, Copy, Debug)] +pub struct ClipClock { + pub frames: f32, + pub fps: f32, + pub base_speed: f32, + pub speed: f32, + pub length: f32, + pub time: f32, + pub previous_time: f32, + /// Native +40; retained until pose evaluation commits previous time. + pub loops_since_evaluation: u32, + pub looping: bool, + /// Clip flags word bit 30 / native object byte +49. + pub phase_controlled: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct AdvanceResult { + /// Native output byte +1. Exact equality with the end does not cross it. + pub crossed_end: bool, + /// Native output +4; unchanged on updates that do not cross the end. + pub overshoot: f32, + /// Native output +8, measured before wrapping, so it may be negative. + pub remaining_before_wrap: f32, +} + +impl ClipClock { + pub fn set_time(&mut self, time: f32) { + self.time = time; + self.previous_time = time; + self.loops_since_evaluation = 0; + } + + /// Preserve the native multiply/divide order when changing playback rate. + /// In particular, simplifying this to old_speed/new_speed changes rounding. + pub fn set_speed(&mut self, speed: f32) { + let new_rate = self.base_speed * speed; + let old_rate = self.speed * self.base_speed; + let frames_per_second = self.fps * self.base_speed; + let inverse_new_rate = 1.0 / new_rate; + self.time = (self.time * old_rate) * inverse_new_rate; + self.previous_time = (self.previous_time * old_rate) * inverse_new_rate; + self.length = (self.frames - 1.0) / (frames_per_second * speed); + self.speed = speed; + } + + pub fn sample_time(&self) -> f32 { + if self.length - self.time >= 0.0 { + self.time + } else { + self.length + } + } + + pub fn advance(&mut self, delta_seconds: f32, phase: f32, result: &mut AdvanceResult) { + if self.phase_controlled { + self.previous_time = self.time; + let lower = if -phase >= 0.0 { 0.0 } else { phase }; + let phase = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + self.time = self.length * phase; + self.loops_since_evaluation = u32::from(self.time < self.previous_time); + // The native phase branch does not touch the AdvanceResult fields. + return; + } + self.time += delta_seconds; + result.remaining_before_wrap = self.length - self.time; + if self.time.partial_cmp(&self.length) != Some(core::cmp::Ordering::Greater) { + result.crossed_end = false; + } else if self.looping { + // Invalid lengths cannot make progress in the native subtraction + // loop. Fail explicitly rather than hang or invent a replacement. + assert!( + self.length > 0.0 && self.time.is_finite(), + "invalid looping clip clock" + ); + while self.time > self.length { + result.crossed_end = true; + result.overshoot = self.time - self.length; + let next = self.time - self.length; + assert!( + next < self.time, + "clip clock cannot advance at this precision" + ); + self.time = next; + self.loops_since_evaluation = self.loops_since_evaluation.wrapping_add(1); + } + } else { + result.crossed_end = true; + result.overshoot = self.time - self.length; + } + } + + /// 0x82D25C20/24, called when pose evaluation's update-history flag is set. + /// Advancing time alone does not perform this commit in the elapsed mode. + pub fn commit_evaluation(&mut self) { + self.previous_time = self.time; + self.loops_since_evaluation = 0; + } + + /// Return the native status byte used by GetAttribute/GetAttributes and + /// the physics contact consumer. Times in the bank are normalized to length. + pub fn attribute_status(&self, begin: f32, end: f32) -> u8 { + if begin == -1.0 { + return 6; + } + let begin = self.length * begin; + let end = self.length * end; + let intersects = if self.loops_since_evaluation == 0 { + end > self.previous_time && begin <= self.time + } else { + end > self.previous_time || begin <= self.time + }; + if !intersects { + 17 + } else if self.time < begin || self.time > end { + 9 + } else { + 5 + } + } +} diff --git a/crates/skate-core/src/animation/commands/batch.rs b/crates/skate-core/src/animation/commands/batch.rs new file mode 100644 index 00000000..eaa4cdda --- /dev/null +++ b/crates/skate-core/src/animation/commands/batch.rs @@ -0,0 +1,182 @@ +//! Owned host execution for the recovered SetData worker. +//! +//! 828D7E08/828D7FD8 define batch boundaries and dependency collection; preparing +//! a job is not submitting or completing it (828C88D8). This synchronous host +//! adapter accepts only SetData jobs and already-materialized input buffers. +//! It is not a replacement for the full ACS scheduler or its other engines. +use super::{ + buffers::{BufferError, PoseBuffers}, + set_data::{self, SetDataCommand}, +}; +use std::num::NonZeroU16; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct JobId { + owner: usize, + index: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QueueError { + CommandCapacityExceeded, + DependencyCapacityExceeded, + /// Other engines must finish and supply their owned buffers before this + /// SetData-only adapter is constructed; unresolved handles are rejected. + UnavailableDependency, +} + +struct Job { + bone_count: u16, + commands: Vec, + dependencies: Vec, +} +impl Job { + fn new() -> Self { + Self { + bone_count: 0, + commands: Vec::new(), + dependencies: Vec::new(), + } + } +} + +pub struct SetDataQueue { + owner: usize, + buffers: PoseBuffers, + max_work_units: NonZeroU16, + prepared: Vec, + current: Job, + command_count: usize, +} +impl SetDataQueue { + pub fn new(buffers: PoseBuffers, max_work_units: NonZeroU16) -> Self { + Self { + owner: super::buffers::new_owner(), + buffers, + max_work_units, + prepared: Vec::new(), + current: Job::new(), + command_count: 0, + } + } + + /// A returned job identity establishes neither execution nor output readiness. + /// None corresponds to a null dependency; current-job references are removed. + pub fn enqueue( + &mut self, + bone_count: u16, + command: SetDataCommand, + dependencies: &[Option], + ) -> Result { + if self.command_count == usize::from(u16::MAX) { + return Err(QueueError::CommandCapacityExceeded); + } + let full = self.current.commands.len() == usize::from(self.max_work_units.get()); + let id = JobId { + owner: self.owner, + index: self.prepared.len() + usize::from(full), + }; + let mut collected = if full { + Vec::new() + } else { + self.current.dependencies.clone() + }; + for dependency in dependencies.iter().flatten().copied() { + if dependency == id { + continue; + } + if dependency.owner != self.owner || dependency.index >= id.index { + return Err(QueueError::UnavailableDependency); + } + if !collected.contains(&dependency) { + if collected.len() == usize::from(u8::MAX) { + return Err(QueueError::DependencyCapacityExceeded); + } + collected.push(dependency); + } + } + if full { + self.flush(); + } + // The native job header is rewritten on EVERY enqueue. Its last count + // applies to external copies for all records in that job. + self.current.bone_count = bone_count; + self.current.commands.push(command); + self.current.dependencies = collected; + self.command_count += 1; + Ok(id) + } + + /// Finalize pending metadata; never executes a worker or exposes its output. + pub fn flush(&mut self) -> bool { + if self.current.commands.is_empty() { + return false; + } + self.prepared + .push(std::mem::replace(&mut self.current, Job::new())); + true + } + + pub fn submit(mut self) -> PendingSetData { + self.flush(); + PendingSetData { + owner: self.owner, + buffers: self.buffers, + jobs: self.prepared, + } + } + + /// Safe because no workers have been started by this host adapter. + pub fn cancel(self) -> PoseBuffers { + self.buffers + } +} + +pub struct PendingSetData { + owner: usize, + buffers: PoseBuffers, + jobs: Vec, +} +impl PendingSetData { + /// Execute complete workers in preparation order. All accepted dependencies + /// point to earlier jobs, so their writes precede dependent reads. No native + /// concurrency ordering beyond those dependencies is asserted. + pub fn complete(mut self) -> Result { + for (index, job) in self.jobs.iter().enumerate() { + if let Err(cause) = set_data::execute(&mut self.buffers, job.bone_count, &job.commands) + { + return Err(ExecutionFailure { + buffers: self.buffers, + job: JobId { + owner: self.owner, + index, + }, + cause, + }); + } + } + Ok(CompletedSetData { + buffers: self.buffers, + }) + } +} + +pub struct ExecutionFailure { + /// Diagnostic/recovery storage only; this is not a completed pose certificate. + pub buffers: PoseBuffers, + pub job: JobId, + pub cause: BufferError, +} + +pub struct CompletedSetData { + buffers: PoseBuffers, +} +impl CompletedSetData { + pub fn buffers(&self) -> &PoseBuffers { + &self.buffers + } + /// Reuse can begin only after completion releases ownership. + pub fn into_buffers(self) -> PoseBuffers { + self.buffers + } +} diff --git a/crates/skate-core/src/animation/commands/buffers.rs b/crates/skate-core/src/animation/commands/buffers.rs new file mode 100644 index 00000000..c86ab0d7 --- /dev/null +++ b/crates/skate-core/src/animation/commands/buffers.rs @@ -0,0 +1,138 @@ +//! Typed animation allocations. Slots identify whole allocations, never addresses. +use crate::animation::output::{NativeMatrix, Sqt}; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub(crate) fn new_owner() -> usize { + static NEXT_OWNER: AtomicUsize = AtomicUsize::new(0); + NEXT_OWNER + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .expect("animation storage identity exhausted") +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Buffer { + slot: usize, + owner: usize, + marker: PhantomData, +} +impl Buffer { + pub fn at(self, first_bone: usize) -> BoneSlice { + BoneSlice { + buffer: self, + first_bone, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BoneSlice { + pub buffer: Buffer, + pub first_bone: usize, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BufferError { + UnknownBuffer, + RangeOutsideAllocation, + UninitializedBone { + bone: usize, + }, + /// The native copy helper's partially overlapping behavior is not recovered. + UnsupportedPartialOverlap, +} +pub struct BoneBuffers { + owner: usize, + allocations: Vec>>, +} +impl Default for BoneBuffers { + fn default() -> Self { + Self { + owner: new_owner(), + allocations: Vec::new(), + } + } +} +impl BoneBuffers { + /// Allocate exactly the requested extent without inventing a bind pose. + pub fn allocate(&mut self, count: usize) -> Buffer { + self.insert(vec![None; count]) + } + pub fn initialized(&mut self, values: impl IntoIterator) -> Buffer { + self.insert(values.into_iter().map(Some).collect()) + } + fn insert(&mut self, allocation: Vec>) -> Buffer { + let slot = self.allocations.len(); + self.allocations.push(allocation); + Buffer { + slot, + owner: self.owner, + marker: PhantomData, + } + } + fn range( + &self, + source: BoneSlice, + count: usize, + ) -> Result, BufferError> { + if source.buffer.owner != self.owner { + return Err(BufferError::UnknownBuffer); + } + let allocation = self + .allocations + .get(source.buffer.slot) + .ok_or(BufferError::UnknownBuffer)?; + let end = source + .first_bone + .checked_add(count) + .ok_or(BufferError::RangeOutsideAllocation)?; + if end > allocation.len() { + return Err(BufferError::RangeOutsideAllocation); + } + Ok(source.first_bone..end) + } + pub fn read(&self, source: BoneSlice, count: usize) -> Result, BufferError> { + if count == 0 { + return Ok(Vec::new()); + } + let range = self.range(source, count)?; + range + .map(|bone| { + self.allocations[source.buffer.slot][bone] + .ok_or(BufferError::UninitializedBone { bone }) + }) + .collect() + } + pub(crate) fn copy( + &mut self, + source: BoneSlice, + destination: BoneSlice, + count: usize, + ) -> Result<(), BufferError> { + if count == 0 { + return Ok(()); + } + let from = self.range(source, count)?; + let to = self.range(destination, count)?; + if source.buffer.slot == destination.buffer.slot { + if from == to { + return Ok(()); + } + if from.start < to.end && to.start < from.end { + return Err(BufferError::UnsupportedPartialOverlap); + } + } + // Copy validity alongside the value: native SET does not initialize + // unwritten bones. Such exports remain unreadable, rather than zero-filled. + for (from, to) in from.zip(to) { + self.allocations[destination.buffer.slot][to] = + self.allocations[source.buffer.slot][from]; + } + Ok(()) + } +} +#[derive(Default)] +pub struct PoseBuffers { + pub sqt: BoneBuffers, + pub matrices: BoneBuffers, +} diff --git a/crates/skate-core/src/animation/commands/mod.rs b/crates/skate-core/src/animation/commands/mod.rs new file mode 100644 index 00000000..10341511 --- /dev/null +++ b/crates/skate-core/src/animation/commands/mod.rs @@ -0,0 +1,4 @@ +//! Typed animation-command storage and recovered workers. +pub mod batch; +pub mod buffers; +pub mod set_data; diff --git a/crates/skate-core/src/animation/commands/set_data.rs b/crates/skate-core/src/animation/commands/set_data.rs new file mode 100644 index 00000000..20d496e6 --- /dev/null +++ b/crates/skate-core/src/animation/commands/set_data.rs @@ -0,0 +1,60 @@ +//! ACSsetData_job (TU3 0x828D80B8), expressed in bones and typed allocations. +use super::buffers::{BoneBuffers, BoneSlice, BufferError, PoseBuffers}; +use crate::animation::output::{NativeMatrix, Sqt}; + +#[derive(Clone, Copy, Debug)] +pub enum InternalUpdate { + /// Native opcode 12: byte-sized partial SET count. + Set { + source: BoneSlice, + bone_count: u8, + }, + /// Every other opcode skips the SET copy, but still permits extraction. + Preserve, +} +#[derive(Clone, Copy, Debug)] +pub struct CopyCommand { + pub update: InternalUpdate, + pub internal: BoneSlice, + pub external: Option>, +} +#[derive(Clone, Copy, Debug)] +pub enum SetDataCommand { + Sqt(CopyCommand), + Matrix(CopyCommand), +} +/// 828D7E08 uses the low byte of numExtBones when the encoded byte is zero. +pub fn resolve_set_count(encoded_count: u8, external_bone_count: u32) -> u8 { + if encoded_count == 0 { + external_bone_count as u8 + } else { + encoded_count + } +} +pub(crate) fn execute( + buffers: &mut PoseBuffers, + bone_count: u16, + commands: &[SetDataCommand], +) -> Result<(), BufferError> { + for command in commands { + match command { + SetDataCommand::Sqt(command) => copy(&mut buffers.sqt, bone_count, command)?, + SetDataCommand::Matrix(command) => copy(&mut buffers.matrices, bone_count, command)?, + } + } + Ok(()) +} +fn copy( + buffers: &mut BoneBuffers, + skeleton_bones: u16, + command: &CopyCommand, +) -> Result<(), BufferError> { + if let InternalUpdate::Set { source, bone_count } = command.update { + buffers.copy(source, command.internal, usize::from(bone_count))?; + } + if let Some(external) = command.external { + // Read internal after SET, including writes through earlier aliases. + buffers.copy(command.internal, external, usize::from(skeleton_bones))?; + } + Ok(()) +} diff --git a/crates/skate-core/src/animation/crouching.rs b/crates/skate-core/src/animation/crouching.rs new file mode 100644 index 00000000..5478bd81 --- /dev/null +++ b/crates/skate-core/src/animation/crouching.rs @@ -0,0 +1,108 @@ +//! Stock riding crouch/absorption and auto-pump, TU3 82BB7DA0/82BB8818. +use crate::point_graph::PointGraph; + +#[derive(Clone,Debug)] +pub struct Settings { + pub maximum_height:PointGraph<8>,pub absorption_upforce:PointGraph<8>,pub pump_maxspeed:PointGraph<8>, + pub minimum_height:f32,pub maximum_ratio:f32,pub maximum_delta_delta:f32,pub maximum_delta:f32,pub input_blend:f32, + pub pump_vertical_speed:f32,pub maximum_crouch_from_deck:f32,pub skateboard_damping:f32,pub maximum_force:f32,pub maximum_ground_force:f32,pub absorption_factor:f32, + pub auto_pump:AutoPumpSettings, +} +#[derive(Clone,Debug)] +pub struct AutoPumpSettings { + pub maximum_crouch:PointGraph<4>,pub sufficient_crouch:f32,pub standing_threshold:f32,pub rise_speed:f32,pub pump_speed:f32, + pub potential_threshold:f32,pub potential_blend:f32,pub intent_magnitude_start:f32,pub intent_angle_region:f32,pub crouch_time:f32,pub crouch_speed:f32, +} +/// Resolved physical publication fields. Offsets stay explicit until the +/// producer's domain identity is verified; callers must publish actual values. +#[derive(Clone,Copy,Debug)] +pub struct Physical { + pub body_84:f32,pub body_164:f32,pub body_188:f32, + pub force_516:f32,pub ground_force_520:f32,pub minimum_crouch_528:f32,pub deck_angle_532:f32, + pub animation_height_72:f32, +} +#[derive(Clone,Copy,Debug)] +pub struct Intents { + pub auto_pump_angle:Option,pub auto_pump_magnitude:Option,pub crouch:Option,pub hard_turn_crouch:Option,pub manual:Option, + /// SkaterMotionGraph interface+108, used by both manual-crouch branches. + pub motion_flag_108:bool, +} +#[derive(Clone,Copy,Debug)] +pub struct State { + absorbed_velocity:f32,filtered_input:f32,pub fraction:f32,delta_fraction:f32,player_pumping:bool, + auto_fraction:f32,auto_state:u32,auto_time:f32,previous_angle:f32,previous_magnitude:f32,filtered_potential:f32,pump_idle_time:f32, +} +#[derive(Clone,Copy,Debug)] +pub struct Output {pub height:f32,pub new_auto_pump:bool,pub player_controlled_pump:bool} +impl State { + pub fn begin(physical:Physical,settings:&Settings)->Self { + let maximum=settings.maximum_height.evaluate(physical.body_164); + let fraction=1.0-unit((physical.animation_height_72-settings.minimum_height)/(maximum-settings.minimum_height)); + Self {absorbed_velocity:0.0,filtered_input:0.0,fraction,delta_fraction:0.0,player_pumping:false,auto_fraction:0.0,auto_state:0,auto_time:0.0,previous_angle:0.0,previous_magnitude:0.0,filtered_potential:0.0,pump_idle_time:0.5} + } + pub fn update(&mut self,p:Physical,intents:Intents,dt:f32,s:&Settings)->Output { + let restoring=-s.absorption_upforce.evaluate(1.0-self.fraction); + let mut board=bound(-p.force_516/s.maximum_force,-1.0,1.0); + self.absorbed_velocity+=p.body_84; + self.absorbed_velocity=s.skateboard_damping*self.absorbed_velocity; + if self.absorbed_velocity<0.0 {board=-board;} + let ground=bound(-p.ground_force_520/s.maximum_ground_force,-1.0,1.0); + let mut absorption=if board<0.0 {if ground>0.0 {ground+board} else {board}} else {maximum(board,ground)}; + if absorption<0.0 {if !(absorptiona.intent_magnitude_start; + self.previous_angle=angle;self.previous_magnitude=magnitude; + self.filtered_potential=p.body_188.mul_add(a.potential_blend,(1.0-a.potential_blend)*self.filtered_potential); + let sufficient_potential=!(self.filtered_potentiala.crouch_time {self.auto_state=2;} + let new_auto_pump=(self.auto_state==1 || self.auto_state==2) && self.fraction>a.sufficient_crouch && sufficient_potential; + if new_auto_pump {self.auto_state=3;} + if (self.auto_state==2 || self.auto_state==3) && !(self.fraction>a.standing_threshold+p.minimum_crouch_528) {self.auto_state=0;} + match self.auto_state { + 0=>self.auto_fraction=0.0, + 1=>{ + self.auto_fraction=minimum(a.maximum_crouch.evaluate(p.body_164),a.crouch_speed+self.auto_fraction); + self.auto_time=minimum(a.crouch_time+1.0,dt+self.auto_time); + }, + 2=>self.auto_fraction=nonnegative(self.auto_fraction-a.rise_speed), + 3=>self.auto_fraction=nonnegative(self.auto_fraction-a.pump_speed), + _=>{}, + } + let mut crouch=intents.crouch.unwrap_or(0.0); + if !intents.motion_flag_108 {crouch=maximum(crouch,intents.hard_turn_crouch.unwrap_or(0.0));} + if intents.motion_flag_108 && intents.manual.is_some_and(|value|value<0.0) {crouch=maximum(crouch,f32::from_bits(0x3ecccccd));} + let blend=unit(s.input_blend); + self.filtered_input=maximum(crouch,self.auto_fraction).mul_add(blend,(1.0-blend)*self.filtered_input); + if self.filtered_input.abs()0.0 || self.auto_fraction>0.0 || (self.player_pumping && self.fraction>p.minimum_crouch_528) { + self.player_pumping=true; + delta=maximum(minimum(1.0,maximum(p.minimum_crouch_528,self.filtered_input))-self.fraction,-s.maximum_delta); + self.pump_idle_time=0.0; + } else { + self.player_pumping=false; + // Native literal820849C8, deliberately not graph delta time. + self.pump_idle_time+=f32::from_bits(0x3c888889); + } + let player_controlled_pump=self.pump_idle_time<0.5; + let minimum_delta=-(s.pump_maxspeed.evaluate(1.0-self.fraction)*s.pump_vertical_speed); + let maximum_delta=s.pump_maxspeed.evaluate(self.fraction)*s.pump_vertical_speed; + let delta=bound(delta,minimum_delta,maximum_delta); + self.delta_fraction=bound(delta,self.delta_fraction-s.maximum_delta_delta,s.maximum_delta_delta+self.delta_fraction); + self.fraction=bound(self.fraction+self.delta_fraction,p.minimum_crouch_528,1.0); + let from_deck=s.maximum_crouch_from_deck*p.deck_angle_532; + if from_deck>self.fraction {self.fraction=from_deck;} + let height_ratio=unit((-s.maximum_ratio).mul_add(self.fraction,1.0)); + let maximum_height=s.maximum_height.evaluate(p.body_164); + Output {height:(maximum_height-s.minimum_height).mul_add(height_ratio,s.minimum_height),new_auto_pump,player_controlled_pump} + } +} +fn minimum(a:f32,b:f32)->f32 {if a-b>=0.0 {b} else {a}} +fn maximum(a:f32,b:f32)->f32 {if a-b>=0.0 {a} else {b}} +fn bound(v:f32,lo:f32,hi:f32)->f32 {minimum(hi,maximum(lo,v))} +fn nonnegative(v:f32)->f32 {if -v>=0.0 {0.0} else {v}} +fn unit(v:f32)->f32 {minimum(1.0,nonnegative(v))} diff --git a/crates/skate-core/src/animation/foot_ik/blend.rs b/crates/skate-core/src/animation/foot_ik/blend.rs new file mode 100644 index 00000000..194d9a40 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/blend.rs @@ -0,0 +1,71 @@ +//! SkeletonIK::BlendTransforms82BEEEB8. +use super::{ + math::{interpolate, interpolate_affine}, + status::{LimbStatus, Mode}, + transforms::{LimbBinding, LimbFrames}, +}; +use crate::physics::{ + skeleton_animation_record::{ + AnimationPartTransform as Transform, compose_affine, transform_point, + }, + skeleton_root::orthonormalize, +}; + +pub fn update( + statuses: &[LimbStatus; 4], + frames: &mut [LimbFrames; 4], + bindings: &[LimbBinding; 4], + physical_board: &Transform, + animation_to_world: &Transform, + animated_board: &Transform, +) { + // IK160 supplies only the physical board rotation. Translation comes from + // the actual animation record's board transformed through IK32. + let mut board = *physical_board; + board[3] = transform_point(animation_to_world, animated_board[3]); + for ((status, frame), binding) in statuses.iter().zip(frames).zip(bindings) { + match status.mode { + Mode::Disabled => continue, + Mode::OnDeck | Mode::External | Mode::Local => {} + } + if status.board_blend > 0.0 { + let target = compose_affine(&board, &frame.board); + frame.world = board_blend(&frame.world, &target, status.board_blend); + if binding.parent_part.is_some() { + let target = compose_affine(&board, &frame.parent_board); + frame.parent_world = board_blend(&frame.parent_world, &target, status.board_blend); + } + } + if matches!(status.mode, Mode::External | Mode::Local) { + if status.external_blend >= 1.0 { + frame.world = frame.external_world; + frame.parent_world = frame.external_parent_world; + } else { + // The native external stage also updates the retained parent + // frame for a limb without a parent, including at zero weight. + frame.world = + interpolate_affine(&frame.world, &frame.external_world, status.external_blend); + frame.parent_world = interpolate_affine( + &frame.parent_world, + &frame.external_parent_world, + status.external_blend, + ); + } + } + } +} + +fn board_blend(original: &Transform, target: &Transform, weight: f32) -> Transform { + let translation = core::array::from_fn(|lane| { + target[3][lane].mul_add(weight, original[3][lane] * (1.0 - weight)) + }); + // Each source frame is orthonormalized independently in Z/Y/X order before + // the matrix interpolation call; translation is replaced afterwards. + let mut a = orthonormalize(*original); + let mut b = orthonormalize(*target); + a[3] = [0.0; 4]; + b[3] = [0.0; 4]; + let (mut output, _) = interpolate(&a, &b, weight); + output[3] = translation; + output +} diff --git a/crates/skate-core/src/animation/foot_ik/contact.rs b/crates/skate-core/src/animation/foot_ik/contact.rs new file mode 100644 index 00000000..8323bcd5 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/contact.rs @@ -0,0 +1,159 @@ +//! Foot contact history and correction82BEFC80, helpers82BF04C8/82BF0558. +use super::{ + status::{LimbStatus, Mode}, + transforms::LimbFrames, +}; +use crate::physics::skeleton_animation_record::{ + AnimationPartTransform as Transform, transform_point, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct FootContact { + /// SkeletonIK3104:0 pending,1 previous query hit,2 previous query missed. + pub query_state: u32, + pub position: [f32; 4], + pub desired_offset: f32, + pub offset: f32, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct ContactState { + pub feet: [FootContact; 2], + ///3184 persists until Reset;3185 is cleared at each UpdateContact entry. + pub support_failed: bool, + pub support_failed_this_update: bool, +} + +pub struct ContactInput<'a> { + pub flags_2468: u32, + pub contact_bone: usize, + pub foot_bones: [usize; 2], + pub hips_world_position: [f32; 4], + pub inverse_board: &'a Transform, + pub board: &'a Transform, + /// Actual cached line tests from Processed960/1008. Current observations + /// replace the retained query only AFTER the previous query is consumed. + pub current_contacts: [Option<[f32; 4]>; 2], +} + +impl ContactState { + pub fn update( + &mut self, + statuses: &[LimbStatus; 4], + frames: &mut [LimbFrames; 4], + input: ContactInput<'_>, + ) { + self.support_failed_this_update = false; + let requires_support = input.flags_2468 & 0x4200_0000 != 0; + for foot in 0..2 { + let contact = &mut self.feet[foot]; + let frame = &mut frames[foot]; + let mut failed = false; + if statuses[foot].mode == Mode::OnDeck && input.contact_bone == input.foot_bones[foot] { + match contact.query_state { + 1 => { + let local = transform_point(input.inverse_board, contact.position); + let height = local[1] + f32::from_bits(0x3DA8_F5C3); + if height <= f32::from_bits(0x3E99_999A) { + contact.desired_offset = if input.flags_2468 & 0x0180_0000 == 0 { + limit_offset(height) + } else { + 0.0 + }; + approach(contact); + let distance = distance_to_contact( + contact, + frame, + input.board, + input.inverse_board, + ); + //82BEFFB4..FFCC: fix modest penetration immediately; + //larger separations retain the bounded transition. + if distance < 0.0 && distance > f32::from_bits(0xBE99_999A) { + contact.offset -= distance; + } + add_offset(frame, input.board, contact.offset); + failed = requires_support + && distance > f32::from_bits(0x3CF5_C28F) + && contact.offset < f32::from_bits(0xBC23_D70A); + } else { + contact.desired_offset = limit_offset(contact.desired_offset); + approach(contact); + add_offset(frame, input.board, contact.offset); + failed = requires_support + || input.hips_world_position[1] > f32::from_bits(0x3F33_3333); + } + } + 0 | 2 => { + contact.desired_offset = limit_offset(contact.desired_offset); + approach(contact); + add_offset(frame, input.board, contact.offset); + failed = contact.query_state == 2 && requires_support; + } + _ => {} + } + if let Some(position) = input.current_contacts[foot] { + contact.position = position; + contact.query_state = 1; + } else { + contact.query_state = 2; + } + } else { + contact.query_state = 0; + contact.desired_offset = 0.0; + approach(contact); + add_offset(frame, input.board, contact.offset); + } + if failed { + self.support_failed = true; + self.support_failed_this_update = true; + } + } + } +} + +///82BF0558: compare both positions in inverse-board space, retaining the +///separate point transforms and the .025 foot-surface distance adjustment. +fn distance_to_contact( + contact: &FootContact, + frame: &LimbFrames, + board: &Transform, + inverse: &Transform, +) -> f32 { + let local_contact = transform_point(inverse, contact.position); + let delta = contact_offset(board, contact.offset); + let foot = core::array::from_fn(|i| frame.world[3][i] + delta[i]); + let local_foot = transform_point(inverse, foot); + (local_foot[1] - f32::from_bits(0x3CCC_CCCD)) - local_contact[1] +} + +fn add_offset(frame: &mut LimbFrames, board: &Transform, offset: f32) { + let delta = contact_offset(board, offset); + for (i, value) in delta.into_iter().enumerate() { + frame.world[3][i] += value; + frame.parent_world[3][i] += value; + } +} +fn contact_offset(board: &Transform, offset: f32) -> [f32; 4] { + core::array::from_fn(|i| { + let x = board[0][i] * 0.0; + let y = board[1][i].mul_add(offset, x); + board[2][i].mul_add(0.0, y) + }) +} +fn select(test: f32, nonnegative: f32, negative: f32) -> f32 { + if test >= 0.0 { nonnegative } else { negative } +} +fn limit_offset(value: f32) -> f32 { + let min = f32::from_bits(0xBDCC_CCCD); + let max = f32::from_bits(0x3DCC_CCCD); + let lower = select(min - value, min, value); + select(max - lower, lower, max) +} +fn approach(contact: &mut FootContact) { + let min = f32::from_bits(0xBC75_C28F); + let max = f32::from_bits(0x3C75_C28F); + let delta = contact.desired_offset - contact.offset; + let lower = select(min - delta, min, delta); + contact.offset = select(max - lower, lower, max) + contact.offset; +} diff --git a/crates/skate-core/src/animation/foot_ik/drive.rs b/crates/skate-core/src/animation/foot_ik/drive.rs new file mode 100644 index 00000000..37715cf9 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/drive.rs @@ -0,0 +1,231 @@ +//! SkeletonIK::UpdateDriveFrames82BF0198 and its solve/mapping chain. +use super::{ + math::{Vector, cross, length, normalize}, + status::{LimbStatus, Mode}, + transforms::{LimbBinding, LimbFrames, inverse_rigid}, + two_bone::{self, AngleLimits, SolveResult}, +}; +use crate::physics::{ + native_arithmetic::dot3, + skeleton_animation_record::{ + AnimationPartTransform as Transform, compose_affine, transform_point, + }, + skeleton_root::orthonormalize, +}; + +#[derive(Clone, Debug)] +pub struct Geometry { + /// SkeletonData4704: nearest ancestor represented by a physical part. + pub parents: [Option; 24], + /// SkeletonData1536: native cofactor inverse of the authored part frames. + pub inverse_part_frames: [Transform; 24], +} +impl Geometry { + /// SkeletonData Init82BD6C40 builds these inverses once from the real + /// PhysicsParamBoneData frames. It does not assume an orthogonal basis. + pub fn new( + parents: [Option; 24], + part_frames: &[Transform; 24], + ) -> Result { + for part in 0..24 { + let mut parent = parents[part]; + let mut visited = [false; 24]; + visited[part] = true; + while let Some(index) = parent { + if index >= 24 || visited[index] { + return Err(format!( + "Invalid physical ancestor chain for IK part {part}" + )); + } + visited[index] = true; + parent = parents[index]; + } + } + Ok(Self { + parents, + inverse_part_frames: part_frames.map(|frame| super::math::inverse_affine(&frame)), + }) + } + + pub fn validate_limbs(&self, bindings: &[LimbBinding; 4]) -> Result<(), String> { + for binding in bindings { + let end = binding.parent_part.unwrap_or(binding.part); + if binding.part >= 24 + || end >= 24 + || self.parents[end].and_then(|p| self.parents[p]).is_none() + { + return Err(format!( + "IK part {end} has no two-bone physical ancestor chain" + )); + } + } + Ok(()) + } +} + +pub fn update( + statuses: &[LimbStatus; 4], + frames: &mut [LimbFrames; 4], + bindings: &[LimbBinding; 4], + geometry: &Geometry, + original_animation: &[Transform; 24], + drives: &mut [Transform; 24], + world_to_animation: &Transform, + limits: AngleLimits, +) -> [bool; 4] { + let mut updated = [false; 4]; + for limb in 0..4 { + if statuses[limb].mode == Mode::Disabled { + continue; + } + let binding = bindings[limb]; + let target = compose_affine(world_to_animation, &frames[limb].world); + let parent_target = binding + .parent_part + .map(|_| compose_affine(world_to_animation, &frames[limb].parent_world)); + let driven = binding.parent_part.unwrap_or(binding.part); + let driven_target = parent_target.as_ref().unwrap_or(&target); + let endpoint = transform_point(driven_target, geometry.inverse_part_frames[driven][3]); + let offset = subtract(endpoint, original_animation[driven][3]); + //82BF01DC..1F4: these are maximum root-to-target distances passed + //through f1 into82BD41B0, not animation blend weights. + let maximum_distance = if limb < 2 { + f32::from_bits(0x3F4C_CCCD) + } else { + 1.0 + }; + if let Some(residual) = solve_drives( + driven, + offset, + maximum_distance, + geometry, + original_animation, + drives, + limits, + ) { + drives[binding.part] = target; + add_translation(&mut drives[binding.part], residual); + // The source also adds the same returned delta to retained world + //targets. Do not insert an extra rotation absent from82BF0458. + add_translation(&mut frames[limb].world, residual); + if let Some(parent) = binding.parent_part { + drives[parent] = parent_target.unwrap(); + add_translation(&mut drives[parent], residual); + add_translation(&mut frames[limb].parent_world, residual); + } + updated[limb] = true; + } + } + updated +} + +///82BF0FA8, using original animation joint centers and mutable volume frames. +fn solve_drives( + end_part: usize, + offset: Vector, + maximum_distance: f32, + geometry: &Geometry, + original: &[Transform; 24], + drives: &mut [Transform; 24], + limits: AngleLimits, +) -> Option { + let middle_part = geometry.parents[end_part]?; + let root_part = geometry.parents[middle_part]?; + let [root, middle, end] = [ + original[root_part][3], + original[middle_part][3], + original[end_part][3], + ]; + let requested: Vector = core::array::from_fn(|i| end[i] + offset[i]); + let mut target = clamp_point(requested, root, maximum_distance); + let mut solved_middle = [0.0; 4]; + if two_bone::solve( + root, + middle, + end, + &mut solved_middle, + &mut target, + limits, + true, + 2, + ) == SolveResult::Invalid + { + return None; + } + let residual = subtract(target, requested); + let original_normal = cross(subtract(root, middle), subtract(end, middle)); + let solved_normal = cross( + subtract(root, solved_middle), + subtract(target, solved_middle), + ); + let normal = normalize_safe(core::array::from_fn(|i| { + original_normal[i] + solved_normal[i] + })); + let middle_mapping = line_mapping(middle, end, normal, solved_middle, target, normal)?; + let root_mapping = line_mapping(root, middle, normal, root, solved_middle, normal)?; + drives[root_part] = orthonormalize(compose_affine(&root_mapping, &drives[root_part])); + drives[middle_part] = orthonormalize(compose_affine(&middle_mapping, &drives[middle_part])); + Some(residual) +} + +///82BD41B0 clamps a point relative to the original chain root using scalar +///division after the two-refinement length; it is not limit_length's reciprocal. +fn clamp_point(point: Vector, origin: Vector, maximum: f32) -> Vector { + let delta = subtract(point, origin); + let distance = length(delta); + if maximum >= distance { + point + } else { + let scale = maximum / distance; + core::array::from_fn(|i| delta[i].mul_add(scale, origin[i])) + } +} + +///82BF1F20, two82BF1D28 frames and target * rigidInverse(original). +pub(super) fn line_mapping( + start: Vector, + end: Vector, + normal: Vector, + new_start: Vector, + new_end: Vector, + new_normal: Vector, +) -> Option { + let original = line_frame(start, end, normal)?; + let target = line_frame(new_start, new_end, new_normal)?; + Some(compose_affine(&target, &inverse_rigid(&original))) +} + +fn line_frame(start: Vector, end: Vector, normal: Vector) -> Option { + let line = subtract(end, start); + let across = cross(line, normal); + let third = cross(across, line); + let x = normalize_safe(across); + let z = normalize_safe(third); + let y = normalize_safe(line); + let product = (dot3(z, z) * dot3(x, x)) * dot3(y, y); + //830BD300 <-821647E0, initializer82F82690. + if product > f32::from_bits(0x3780_0000) { + Some([x, y, z, start]) + } else { + None + } +} + +pub(super) fn normalize_safe(value: Vector) -> Vector { + let magnitude = length(value); + let normalized = normalize(value); + //830BD350 <-82181A88; no arbitrary fallback axis for a degenerate line. + if magnitude > f32::from_bits(0x3586_37BD) { + normalized + } else { + [0.0; 4] + } +} +fn subtract(a: Vector, b: Vector) -> Vector { + core::array::from_fn(|i| a[i] - b[i]) +} +fn add_translation(frame: &mut Transform, offset: Vector) { + for lane in 0..4 { + frame[3][lane] += offset[lane]; + } +} diff --git a/crates/skate-core/src/animation/foot_ik/external.rs b/crates/skate-core/src/animation/foot_ik/external.rs new file mode 100644 index 00000000..61ad5b7b --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/external.rs @@ -0,0 +1,91 @@ +//! SkeletonIK::CalculateExternalIKOffset82BEE390. +use super::{ + math::{axis_rotation, cross, length, limit_length, reciprocal}, + status::{LimbStatus, Mode}, + transforms::{LimbBinding, LimbFrames, inverse_rigid}, +}; +use crate::{ + physics::skeleton_animation_record::{ + AnimationPartTransform as Transform, IDENTITY, compose_affine, transform_point, + }, + trigonometry::asin, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct ExternalTarget { + /// SkeletonIK544 /608; setters distinguish the supplied coordinate space. + pub world_position: [f32; 4], + pub animation_position: [f32; 4], + /// SkeletonIK736,800,828. The normal is measured against world up. + pub normal: [f32; 4], + pub normal_set: bool, + pub normal_blend: f32, +} + +pub fn update( + target: &mut ExternalTarget, + status: &mut LimbStatus, + frames: &mut LimbFrames, + binding: LimbBinding, + animation: &[Transform; 24], + animation_to_world: &Transform, + world_to_animation: &Transform, +) { + let animated = &animation[binding.part]; + let original = compose_affine(animation_to_world, animated); + let mut rotation = IDENTITY; + if target.normal_blend > 0.0 { + // Native v16 is the fixed Y basis82139A20, not the animated limb's up. + let axis = cross([0.0, 1.0, 0.0, 0.0], target.normal); + let magnitude = length(axis); + if magnitude > 0.05 { + let inverse = reciprocal(magnitude, 2); + let axis = axis.map(|v| inverse * v); + let angle = asin(magnitude); + let angle = if -0.71 - angle >= 0.0 { -0.71 } else { angle }; + let angle = if 0.71 - angle >= 0.0 { angle } else { 0.71 }; + rotation = axis_rotation(axis, angle * target.normal_blend); + } + if !target.normal_set { + let next = target.normal_blend - 0.2; + target.normal_blend = if -next >= 0.0 { 0.0 } else { next }; + } + } + if status.mode == Mode::Local { + let weight = status.target_blend; + let desired: [f32; 4] = core::array::from_fn(|lane| { + (target.animation_position[lane] - animated[3][lane]).mul_add( + weight, + status.external_target_local_delta[lane] * (1.0 - weight), + ) + }); + let change = limit_length( + core::array::from_fn(|lane| desired[lane] - status.external_target_local_delta[lane]), + 0.025, + ); + for lane in 0..4 { + status.external_target_local_delta[lane] += change[lane]; + // The source publishes desired before its history limit. Do not + // replace this with the separately limited retained delta. + target.animation_position[lane] = animated[3][lane] + desired[lane]; + } + target.world_position = transform_point(animation_to_world, target.animation_position); + } else { + target.animation_position = transform_point(world_to_animation, target.world_position); + } + let offset = limit_length( + core::array::from_fn(|lane| target.world_position[lane] - original[3][lane]), + 0.7, + ); + let mut from_origin = IDENTITY; + from_origin[3] = original[3].map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)); + let rotated = compose_affine(&rotation, &from_origin); + let mut to_origin = IDENTITY; + to_origin[3] = core::array::from_fn(|lane| original[3][lane] + offset[lane]); + let adjustment = compose_affine(&to_origin, &rotated); + frames.external_world = compose_affine(&adjustment, &original); + if let Some(parent) = binding.parent_part { + let relative = compose_affine(&inverse_rigid(animated), &animation[parent]); + frames.external_parent_world = compose_affine(&frames.external_world, &relative); + } +} diff --git a/crates/skate-core/src/animation/foot_ik/math.rs b/crates/skate-core/src/animation/foot_ik/math.rs new file mode 100644 index 00000000..feb1c5a8 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/math.rs @@ -0,0 +1,224 @@ +//! Matrix interpolation used by SkeletonIK, TU3 82BD3150/82BD2E60. +//! The shared Xenon estimate primitives remain host approximations pending +//! hardware comparison; the recovered branch and refinement order is retained. +use crate::{ + input::angle::atan, + physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::{dot3, reciprocal_estimate}, + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY}, + }, + trigonometry::sin_cos, +}; + +pub(super) type Vector = [f32; 4]; + +pub(super) fn reciprocal(value: f32, refinements: usize) -> f32 { + let mut inverse = reciprocal_estimate(value); + for _ in 0..refinements { + let error = (-inverse).mul_add(value, 1.0); + inverse = inverse.mul_add(error, inverse); + } + inverse +} + +pub(super) fn length(value: Vector) -> f32 { + let squared = dot3(value, value); + let result = squared * inverse_length_squared(squared, 2); + if squared == 0.0 { 0.0 } else { result } +} + +pub(super) fn normalize(value: Vector) -> Vector { + let inverse = inverse_length_squared(dot3(value, value), 2); + value.map(|v| v * inverse) +} + +pub(super) fn cross(left: Vector, right: Vector) -> Vector { + [ + (-left[2]).mul_add(right[1], left[1] * right[2]), + (-left[0]).mul_add(right[2], left[2] * right[0]), + (-left[1]).mul_add(right[0], left[0] * right[1]), + (-left[3]).mul_add(right[3], left[3] * right[3]), + ] +} + +///82BD3D90. The very short-vector branch preserves the original fourth lane. +pub(super) fn limit_length(value: Vector, limit: f32) -> Vector { + let magnitude = length(value); + if !(magnitude >= f32::from_bits(0x3780_0000)) { + return value; + } + let capped = if limit - magnitude >= 0.0 { + magnitude + } else { + limit + }; + let inverse = reciprocal(magnitude, 2); + value.map(|v| (v * capped) * inverse) +} + +///82BD2E60's axis/angle outputs. The unused screw-pivot output is omitted. +pub(super) fn rotation_axis_angle(rotation: &Transform) -> (Vector, f32) { + let skew = [ + rotation[1][2] - rotation[2][1], + rotation[2][0] - rotation[0][2], + rotation[0][1] - rotation[1][0], + 0.0, + ]; + let sine_twice = length(skew); + let cosine_twice = ((rotation[0][0] + rotation[1][1]) + rotation[2][2]) - 1.0; + let mut axis = if sine_twice > 0.0 { + let inverse = reciprocal(sine_twice, 2) * 1.0; + skew.map(|v| v * inverse) + } else { + [0.0; 4] + }; + let ratio = sine_twice.mul_add(reciprocal(cosine_twice, 1), 0.0); + let mut angle = atan(ratio); + if cosine_twice < 0.0 { + angle += f32::from_bits(0x4049_0FDB).copysign(sine_twice); + } + if cosine_twice == 0.0 { + angle = f32::from_bits(0x3FC9_0FDB).copysign(sine_twice); + } + // Literal word 820CFC28, loaded with lvlx. Do not replace this with a + // conventional numerical tolerance. + if sine_twice <= f32::from_bits(0x0020_0000) && cosine_twice <= 0.0 { + axis = half_turn_axis(rotation); + } + (axis, angle) +} + +///827B7400 selects the greatest diagonal. Its selected component is squared +/// before normalization (e.g. vmulfp at827B7488), unlike a common textbook +/// matrix-to-axis conversion. Preserve the native formula and tie ordering. +fn half_turn_axis(rotation: &Transform) -> Vector { + let selected = if rotation[0][0] > rotation[1][1] { + if rotation[0][0] > rotation[2][2] { + 0 + } else { + 2 + } + } else if rotation[1][1] > rotation[2][2] { + 1 + } else { + 2 + }; + let mut axis = [0.0; 4]; + for lane in 0..3 { + axis[lane] = if lane == selected { + let diagonal = 1.0 + rotation[selected][selected]; + diagonal * diagonal + } else { + rotation[selected][lane] + rotation[lane][selected] + }; + } + axis[3] = axis[0]; + normalize(axis) +} + +///Rodrigues construction in82BD34E0..3548. The native permutation duplicates +/// each column's X component into W; W is not a fabricated affine zero. +pub(super) fn axis_rotation(axis: Vector, angle: f32) -> Transform { + let (sine, cosine) = sin_cos(angle); + let complement = 1.0 - cosine; + let [x, y, z, _] = axis; + let sx = sine * x; + let sy = sine * y; + let sz = sine * z; + let tx = complement * x; + let ty = complement * y; + let tz = complement * z; + let xx = tx.mul_add(x, cosine); + let yy = ty.mul_add(y, cosine); + let zz = tz.mul_add(z, cosine); + let xy_positive = tx.mul_add(y, sz); + let xy_negative = ty * x - sz; + let xz_positive = tz.mul_add(x, sy); + let xz_negative = tx * z - sy; + let yz_positive = ty.mul_add(z, sx); + let yz_negative = tz * y - sx; + [ + [xx, xy_positive, xz_negative, xx], + [xy_negative, yy, yz_positive, xy_negative], + [xz_positive, yz_negative, zz, xz_positive], + [0.0; 4], + ] +} + +///82BD3150 returns the remaining rotation angle as well as the blended frame. +pub(super) fn interpolate(a: &Transform, b: &Transform, weight: f32) -> (Transform, f32) { + if weight >= 1.0 { + return (*b, 0.0); + } + let mut relative = IDENTITY; + for column in 0..3 { + relative[column] = core::array::from_fn(|lane| { + let x = a[0][column] * b[0][lane]; + let y = a[1][column].mul_add(b[1][lane], x); + a[2][column].mul_add(b[2][lane], y) + }); + } + let (axis, angle) = rotation_axis_angle(&relative); + if weight <= 0.0 { + return (*a, angle); + } + let mut output = IDENTITY; + if angle < f32::from_bits(0x3D0E_FA35) { + for column in 0..3 { + output[column] = normalize(core::array::from_fn(|lane| { + (b[column][lane] - a[column][lane]).mul_add(weight, a[column][lane]) + })); + } + } else { + let rotation = axis_rotation(axis, angle * weight); + for column in 0..3 { + output[column] = core::array::from_fn(|lane| { + let x = a[column][0] * rotation[0][lane]; + let y = a[column][1].mul_add(rotation[1][lane], x); + a[column][2].mul_add(rotation[2][lane], y) + }); + } + } + output[3] = core::array::from_fn(|lane| (b[3][lane] - a[3][lane]).mul_add(weight, a[3][lane])); + (output, angle - angle * weight) +} + +///82E0A570 overrides the interpolation helper's translation, including at +/// endpoint weights. Preserve its weighted-sum rounding instead of delta lerp. +pub(super) fn interpolate_affine(a: &Transform, b: &Transform, weight: f32) -> Transform { + let translation = + core::array::from_fn(|lane| a[3][lane].mul_add(1.0 - weight, b[3][lane] * weight)); + let (mut output, _) = interpolate(a, b, weight); + output[3] = translation; + output +} +/// General inverse expanded at8296FB50..FD14 and SkeletonData Init82BD6E9C. +/// The transpose permutation retains the second cofactor in the fourth lane. +pub fn inverse_affine(frame: &Transform) -> Transform { + let cofactors = [ + cross(frame[1], frame[2]), + cross(frame[2], frame[0]), + cross(frame[0], frame[1]), + ]; + let inverse = reciprocal(dot3(frame[0], cofactors[0]), 2); + let mut result: Transform = core::array::from_fn(|column| { + if column == 3 { + [0.0; 4] + } else { + [ + cofactors[0][column] * inverse, + cofactors[1][column] * inverse, + cofactors[2][column] * inverse, + cofactors[1][column] * inverse, + ] + } + }); + let negative = frame[3].map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)); + result[3] = core::array::from_fn(|lane| { + let x = negative[0] * result[0][lane]; + let y = negative[1].mul_add(result[1][lane], x); + negative[2].mul_add(result[2][lane], y) + }); + result +} diff --git a/crates/skate-core/src/animation/foot_ik/mod.rs b/crates/skate-core/src/animation/foot_ik/mod.rs new file mode 100644 index 00000000..ca465219 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/mod.rs @@ -0,0 +1,17 @@ +//! Native skeleton IK, TU3 GeneralUpdate82BDCA38 and its six update stages. +pub mod blend; +pub mod contact; +pub mod drive; +pub mod external; +mod physical_solve; +pub mod post_contact; +pub mod post_physics; +mod math; +pub use math::inverse_affine; +pub mod settings; +pub mod state; +pub mod status; +pub mod transforms; +pub mod two_bone; +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/animation/foot_ik/physical_solve.rs b/crates/skate-core/src/animation/foot_ik/physical_solve.rs new file mode 100644 index 00000000..59ef7de8 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/physical_solve.rs @@ -0,0 +1,72 @@ +//! SkeletonIK::SolvePhysical82BF1480. Physical joint centers, not drive targets. +use super::{ + drive::{Geometry, line_mapping, normalize_safe}, + math::cross, + two_bone::{self, AngleLimits, SolveResult}, +}; +use crate::physics::{ + skeleton_animation_record::{compose_affine, transform_point}, + skeleton_body::SkeletonBody, +}; + +pub(super) fn solve( + body: &mut SkeletonBody, + geometry: &Geometry, + end_part: usize, + offset: [f32; 4], +) -> bool { + let Some(middle_part) = geometry.parents[end_part] else { + return false; + }; + let Some(root_part) = geometry.parents[middle_part] else { + return false; + }; + let point = |part: usize| { + transform_point( + &body.record.pose[part], + geometry.inverse_part_frames[part][3], + ) + }; + let [root, middle, end] = [point(root_part), point(middle_part), point(end_part)]; + let mut target = core::array::from_fn(|i| end[i] + offset[i]); + let mut solved_middle = [0.0; 4]; + //Literal0 and180, no original-angle override and recursion budget1. + //The source requires return0; its extended/invalid paths BOTH reject. + if two_bone::solve( + root, + middle, + end, + &mut solved_middle, + &mut target, + AngleLimits { + minimum_degrees: 0.0, + maximum_degrees: 180.0, + }, + false, + 1, + ) != SolveResult::Solved + { + return false; + } + let original_normal = cross(sub(root, middle), sub(end, middle)); + let solved_normal = cross(sub(root, solved_middle), sub(target, solved_middle)); + let normal = normalize_safe(core::array::from_fn(|i| { + original_normal[i] + solved_normal[i] + })); + let Some(mid_map) = line_mapping(middle, end, normal, solved_middle, target, normal) else { + return false; + }; + let Some(root_map) = line_mapping(root, middle, normal, root, solved_middle, normal) else { + return false; + }; + let middle_frame = compose_affine(&mid_map, &body.record.pose[middle_part]); + let root_frame = compose_affine(&root_map, &body.record.pose[root_part]); + //Unlike pre-physics drive solving, no extra orthonormalization precedes + //the setter. The live body normalizes; the record keeps these frames. + body.set_part_transform(middle_part, middle_frame); + body.set_part_transform(root_part, root_frame); + true +} +fn sub(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| a[i] - b[i]) +} diff --git a/crates/skate-core/src/animation/foot_ik/post_contact.rs b/crates/skate-core/src/animation/foot_ik/post_contact.rs new file mode 100644 index 00000000..af122be0 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/post_contact.rs @@ -0,0 +1,113 @@ +//! SkeletonIK::CheckFootAgainstBoard82BF0D70, including raised deck ends. +use super::{math::reciprocal, settings::Settings, transforms::inverse_rigid}; +use crate::physics::{ + native_arithmetic::vector_min, + skeleton_animation_record::{AnimationPartTransform as Transform, transform_point}, +}; + +#[derive(Clone, Copy, Debug)] +pub struct SettingsPost { + ///physics_animation/default layout780. + pub minimum_board_up: f32, + ///Global212's authored layout412/416, selected by the wipeout argument. + pub wipeout_height: f32, + pub riding_height: f32, +} + +pub(super) fn target( + board: &Transform, + foot: [f32; 4], + adjacent: [f32; 4], + animated_board_position: [f32; 4], + wipeout: bool, + settings: &Settings, + post: &SettingsPost, +) -> Option<[f32; 4]> { + let inverse = inverse_rigid(board); + let local_foot = transform_point(&inverse, foot); + let local_adjacent = transform_point(&inverse, adjacent); + let mut midpoint: [f32; 4] = + core::array::from_fn(|i| (local_foot[i] + local_adjacent[i]) * 0.5); + //vrlimi mask4 preserves the foot's Y; X/Z test both foot and midpoint. + midpoint[1] = local_foot[1]; + let x = vector_min(local_foot[0].abs(), midpoint[0].abs()); + let z = vector_min(local_foot[2].abs(), midpoint[2].abs()); + if x >= settings.deck_half_width + settings.post_ik_padding[0] + || z >= settings.deck_total_half_length + settings.post_ik_padding[2] + { + return None; + } + let mut height = if wipeout { + post.wipeout_height + } else { + post.riding_height + }; + if z > settings.deck_half_length { + let distance = z - settings.deck_half_length; + let end = settings.deck_total_half_length - settings.deck_half_length; + let distance = if end - distance >= 0.0 { distance } else { end }; + //Constructor82BED418 caches tan(clamp(authored degrees*.01745...)). + let angle = settings.deck_front_angle_degrees * f32::from_bits(0x3C8E_FA35); + let angle = if 0.01 - angle >= 0.0 { 0.01 } else { angle }; + let maximum = f32::from_bits(0x3FC7_C82D); + let angle = if maximum - angle >= 0.0 { + angle + } else { + maximum + }; + height = distance.mul_add(tangent(angle), height); + } + if midpoint[1] >= height || midpoint[1] <= height - f32::from_bits(0x3E99_999A) { + return None; + } + let mut result = animated_board_position; + result[1] = height; + Some(transform_point(board, result)) +} + +///Scalar constructor path through original8252D980. Its argument is already +///clamped to [.01,1.5607964]; coefficients come from822FE3C0..E0. +fn tangent(angle: f32) -> f32 { + let quadrant = (angle * f32::from_bits(0x3F22_F983)).round_ties_even(); + let reduced = (-quadrant).mul_add(f32::from_bits(0x3FC9_0FDB), angle); + let reduced = (-quadrant).mul_add(f32::from_bits(0x2E85_A309), reduced); + let squared = reduced * reduced; + let denominator = squared.mul_add(f32::from_bits(0x3505_BBA8), f32::from_bits(0xB9A3_7B25)); + let denominator = squared.mul_add(denominator, f32::from_bits(0x3CD2_3CF5)); + let denominator = squared.mul_add(denominator, f32::from_bits(0xBEEE_F582)); + let denominator = squared.mul_add(denominator, 1.0); + let numerator = squared.mul_add(f32::from_bits(0xB795_D5B9), f32::from_bits(0x3B60_7415)); + let numerator = squared.mul_add(numerator, f32::from_bits(0xBE08_95AF)); + let numerator = reduced.mul_add(squared * numerator, reduced); + //vcmpbfp's two high result bits select corresponding sign/exponent bits. + //The bounded constructor argument preserves these bits in both operands; + //retain the actual bit-select rather than replacing it by a boolean branch. + let bound = f32::from_bits(0x3980_0000); + let mask = (if reduced > bound { 0x8000_0000 } else { 0 }) + | (if reduced < -bound { 0x4000_0000 } else { 0 }); + let denominator = f32::from_bits((denominator.to_bits() & !mask) | (1.0f32.to_bits() & mask)); + let numerator = f32::from_bits((numerator.to_bits() & !mask) | (reduced.to_bits() & mask)); + if quadrant.abs() as i32 & 1 == 0 { + numerator * reciprocal(denominator, 2) + } else { + denominator * reciprocal(-numerator, 2) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn original_tangent_rational_spans_both_constructor_quadrants() { + //Independent mathematical values test argument reduction and rational + //operand order, without claiming original hardware estimate parity. + for angle in [0.01f32, 0.25, 0.75, 1.0, 1.5607964] { + let actual = tangent(angle); + let reference = f64::from(angle).tan() as f32; + assert!( + (actual - reference).abs() < 3e-5 * reference.abs().max(1.0), + "{angle}: {actual} vs {reference}" + ); + } + } +} diff --git a/crates/skate-core/src/animation/foot_ik/post_physics.rs b/crates/skate-core/src/animation/foot_ik/post_physics.rs new file mode 100644 index 00000000..a41b1588 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/post_physics.rs @@ -0,0 +1,159 @@ +//! PostWipeoutCheck's feet82BF0680, hands82BF0C28 and external82BEDC10. +use super::{ + drive::Geometry, + physical_solve, + post_contact::{self, SettingsPost}, + settings::Settings, + state::{FootIkState, LIMBS}, +}; +use crate::physics::{ + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + skeleton_body::SkeletonBody, +}; + +pub struct Input<'a> { + pub state_id: u32, + pub category_id: u32, + ///Actual BoardBody868, used by original state200/201 hand/foot gates. + pub board_body_flag_868: bool, + pub wipeout: bool, + pub flags_2468: u32, + pub flags_2484: u32, + ///Processed2664 and2520, read only when2468bit18 is set. + pub value_2664: f32, + pub state_2520: u32, + pub world_to_animation: &'a Transform, + ///Actual board GetPartTransform, replaced by wobble-adjusted record when sampled. + pub board: &'a Transform, +} + +pub fn update( + state: &mut FootIkState, + body: &mut SkeletonBody, + geometry: &Geometry, + settings: &Settings, + post: &SettingsPost, + input: Input<'_>, +) -> [bool; 4] { + let mut updated = [false; 4]; + if input.state_id != 702 { + let airborne = matches!(input.state_id, 200 | 201); + if (!airborne || input.board_body_flag_868) && input.category_id != 500 { + feet(state, body, geometry, settings, post, &input, &mut updated); + } + if airborne && input.board_body_flag_868 { + for limb in 2..4 { + if state.frames[limb].within_contact_bounds { + state.frames[limb].world = + compose_affine(input.board, &state.frames[limb].board); + let part = LIMBS[limb].part; + let offset = sub(state.frames[limb].world[3], body.record.pose[part][3]); + if physical_solve::solve(body, geometry, part, offset) { + body.set_part_transform(part, state.frames[limb].world); + updated[limb] = true; + } + } + } + } + } + //Always run the external-target pass, including state702. + for limb in 0..2 { + if !(state.limbs[limb].external_blend <= 0.0) { + let binding = LIMBS[limb]; + let adjacent = binding.parent_part.unwrap(); + let offset = sub( + state.frames[limb].parent_world[3], + body.record.pose[adjacent][3], + ); + if physical_solve::solve(body, geometry, adjacent, offset) { + body.set_part_transform(binding.part, state.frames[limb].world); + body.set_part_transform(adjacent, state.frames[limb].parent_world); + updated[limb] = true; + } + } + } + for limb in 0..4 { + state.external_targets[limb].normal_set = false; + state.limbs[limb].external_target_set = false; + state.limbs[limb].local_target_set = false; + } + updated +} + +fn feet( + state: &mut FootIkState, + body: &mut SkeletonBody, + geometry: &Geometry, + settings: &Settings, + post: &SettingsPost, + input: &Input<'_>, + updated: &mut [bool; 4], +) { + let mut effective_board = *input.board; + if input.flags_2484 & (1 << 21) != 0 { + effective_board[0] = effective_board[0].map(|v| v * -1.0); + effective_board[1] = effective_board[1].map(|v| v * -1.0); + } + let up = effective_board[1]; + let up_y = up[0] * input.world_to_animation[0][1]; + let up_y = up[1].mul_add(input.world_to_animation[1][1], up_y); + let up_y = up[2].mul_add(input.world_to_animation[2][1], up_y); + let constrained = input.flags_2468 & (1 << 18) != 0; + if input.flags_2484 & (1 << 20) != 0 + || post.minimum_board_up > up_y + || constrained && input.value_2664 > f32::from_bits(0x3D23_D70A) + || constrained && input.state_2520 == 500 + { + return; + } + for limb in 0..2 { + let binding = LIMBS[limb]; + let adjacent = binding.parent_part.unwrap(); + if state.frames[limb].within_contact_bounds && !input.wipeout { + state.frames[limb].world = compose_affine(input.board, &state.frames[limb].board); + state.frames[limb].parent_world = + compose_affine(input.board, &state.frames[limb].parent_board); + let offset = sub( + state.frames[limb].parent_world[3], + body.record.pose[adjacent][3], + ); + if physical_solve::solve(body, geometry, adjacent, offset) { + body.set_part_transform(binding.part, state.frames[limb].world); + body.set_part_transform(adjacent, state.frames[limb].parent_world); + updated[limb] = true; + } + } else if !state.limbs[limb].external_target_set { + let mut foot = body.record.pose[binding.part]; + if dot3(foot[1], up) <= 0.0 { + continue; + } + let Some(target) = post_contact::target( + &effective_board, + foot[3], + body.record.pose[adjacent][3], + state.limbs[limb].part_position, + input.wipeout, + settings, + post, + ) else { + continue; + }; + let distance = dot3(up, sub(target, foot[3])); + let offset = up.map(|v| v * distance); + if physical_solve::solve(body, geometry, adjacent, offset) { + foot[3] = target; + body.set_part_transform(binding.part, foot); + let mut adjacent_frame = body.record.pose[adjacent]; + for lane in 0..4 { + adjacent_frame[3][lane] += offset[lane]; + } + body.set_part_transform(adjacent, adjacent_frame); + updated[limb] = true; + } + } + } +} +fn sub(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| a[i] - b[i]) +} diff --git a/crates/skate-core/src/animation/foot_ik/settings.rs b/crates/skate-core/src/animation/foot_ik/settings.rs new file mode 100644 index 00000000..edf42a3e --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/settings.rs @@ -0,0 +1,21 @@ +//! Authored IK parameters, resolved against the stock class layout. +use super::status::BlendSettings; +use super::two_bone::AngleLimits; + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// physics_animation/default layout876/880, consumed by82BF0FA8. + pub angle_limits: AngleLimits, + pub blend: BlendSettings, + /// physics_skeletonik layout0/48/64/80. + pub post_ik_padding: [f32; 4], + pub contact_bounds: [f32; 4], + pub foot_on_deck_padding: [f32; 4], + pub wipeout_feet_offset: f32, + ///82BED418 retrieves physicsdeck hashes EE1FD008/1DCB828B/932190E7. + pub deck_half_width: f32, + pub deck_half_length: f32, + pub deck_total_half_length: f32, + /// physicsdeck.DeckFrontEndAngle, hashF9B732CA; retained authored degrees. + pub deck_front_angle_degrees: f32, +} diff --git a/crates/skate-core/src/animation/foot_ik/state.rs b/crates/skate-core/src/animation/foot_ik/state.rs new file mode 100644 index 00000000..df94193a --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/state.rs @@ -0,0 +1,174 @@ +//! Persistent SkeletonIK state and the GeneralUpdate82BDCA38 stage order. +use super::{ + blend, + contact::{ContactInput, ContactState}, + drive::{self, Geometry}, + external::{self, ExternalTarget}, + settings::Settings, + status::{self, LimbStatus, Mode}, + transforms::{self, LimbBinding, LimbFrames}, +}; +use crate::physics::skeleton_animation_record::AnimationPartTransform as Transform; + +///82BED688 binds the toe targets to foot/toe volumes and the hand targets to +///their hand volumes. The optional second volume is distinct from chain parents. +pub const LIMBS: [LimbBinding; 4] = [ + LimbBinding { + part: 15, + parent_part: Some(16), + }, + LimbBinding { + part: 19, + parent_part: Some(20), + }, + LimbBinding { + part: 3, + parent_part: None, + }, + LimbBinding { + part: 7, + parent_part: None, + }, +]; + +#[derive(Clone, Debug)] +pub struct FootIkState { + pub limbs: [LimbStatus; 4], + pub frames: [LimbFrames; 4], + pub external_targets: [ExternalTarget; 4], + pub contacts: ContactState, + feet_enabled: bool, +} +impl Default for FootIkState { + fn default() -> Self { + //82BED780 resets the retained histories and enables feet; it is not + //the wipeout-entry solve82BF1B90, which disables feet after its update. + Self { + limbs: [LimbStatus::default(); 4], + frames: [LimbFrames::default(); 4], + external_targets: [ExternalTarget::default(); 4], + contacts: ContactState::default(), + feet_enabled: true, + } + } +} + +pub struct UpdateInput<'a> { + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2480: u32, + /// DataIn32/96; both are actual Skeleton root transforms. + pub animation_to_world: &'a Transform, + pub world_to_animation: &'a Transform, + /// DataIn160, from Skeleton12496. This is independently produced from + ///DataIn352 (Skeleton12304) used by foot contact correction. + pub physical_board: &'a Transform, + pub contact_board: &'a Transform, + pub inverse_contact_board: &'a Transform, + /// SkeletonData animation record: physical volume frames after board offset. + pub animation: &'a [Transform; 24], + /// ProcessData14160: original selected globals, before volume composition. + pub original_animation: &'a [Transform; 24], + /// Actual board-parented targets after their type5 volume composition. + pub targets: &'a [Transform; 4], + pub hips_world_position: [f32; 4], + pub contact_bone: usize, + pub foot_bones: [usize; 2], + pub current_contacts: [Option<[f32; 4]>; 2], +} + +impl FootIkState { + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Ground::Enter writes (*(Skeleton+16432))+464 directly. + pub fn enable_feet(&mut self, enabled: bool) { + self.feet_enabled = enabled; + } + pub fn feet_enabled(&self) -> bool { + self.feet_enabled + } + + /// Ground collision handling can also set the per-update3185 flag. + pub fn mark_support_failed_this_update(&mut self) { + self.contacts.support_failed_this_update = true; + } + + /// The mutable output is Skeleton12624 consumed by SkeletonDrives. The + ///original animation and collision-volume record must remain separate. + pub fn update( + &mut self, + input: UpdateInput<'_>, + geometry: &Geometry, + settings: &Settings, + drives: &mut [Transform; 24], + ) -> [bool; 4] { + for limb in 0..4 { + self.frames[limb].target = input.targets[limb]; + } + status::update_modes(&mut self.limbs, self.feet_enabled, input.flags_2472); + status::update_blends( + &mut self.limbs, + settings.deck_half_width, + settings.deck_total_half_length, + input.flags_2480, + &settings.blend, + ); + let inverse_animation_board = transforms::inverse_rigid(&input.animation[0]); + for limb in 0..4 { + self.frames[limb].within_contact_bounds = false; + if matches!(self.limbs[limb].mode, Mode::External | Mode::Local) { + external::update( + &mut self.external_targets[limb], + &mut self.limbs[limb], + &mut self.frames[limb], + LIMBS[limb], + input.animation, + input.animation_to_world, + input.world_to_animation, + ); + } + transforms::prepare_animation_target( + &mut self.limbs[limb], + &mut self.frames[limb], + LIMBS[limb], + input.animation, + input.animation_to_world, + &inverse_animation_board, + settings.contact_bounds, + ); + } + blend::update( + &self.limbs, + &mut self.frames, + &LIMBS, + input.physical_board, + input.animation_to_world, + &input.animation[0], + ); + self.contacts.update( + &self.limbs, + &mut self.frames, + ContactInput { + flags_2468: input.flags_2468, + contact_bone: input.contact_bone, + foot_bones: input.foot_bones, + hips_world_position: input.hips_world_position, + inverse_board: input.inverse_contact_board, + board: input.contact_board, + current_contacts: input.current_contacts, + }, + ); + drive::update( + &self.limbs, + &mut self.frames, + &LIMBS, + geometry, + input.original_animation, + drives, + input.world_to_animation, + settings.angle_limits, + ) + } +} diff --git a/crates/skate-core/src/animation/foot_ik/status.rs b/crates/skate-core/src/animation/foot_ik/status.rs new file mode 100644 index 00000000..9ba2fdde --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/status.rs @@ -0,0 +1,194 @@ +//! Limb mode transitions82BEDCF8 and blend weights82BEEC00. +//! Named S2 UpdateStatus/UpdateBlendValues corroborate field identities; +//! TU3 flags and cached settings determine the implementation. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Disabled, + OnDeck, + External, + Local, +} + +#[derive(Clone, Copy, Debug)] +pub struct LimbStatus { + pub mode: Mode, + /// SkeletonIK+468+4*limb. + pub board_blend: f32, + /// SkeletonIK+484+4*limb. + pub external_blend: f32, + pub target_blend: f32, + pub external_target_set: bool, + pub local_target_set: bool, + pub external_target_local_delta: [f32; 4], + /// CalculateInitialPartTransforms retains the target in animation-deck space. + pub part_position: [f32; 4], +} +impl Default for LimbStatus { + fn default() -> Self { + //82BED780 resets mode, both weights, target blend, target flags and + //the complete local delta/part-position vectors to zero. + Self { + mode: Mode::Disabled, + board_blend: 0.0, + external_blend: 0.0, + target_blend: 0.0, + external_target_set: false, + local_target_set: false, + external_target_local_delta: [0.0; 4], + part_position: [0.0; 4], + } + } +} + +/// All four limbs in native order: left foot, right foot, left hand, right hand. +/// Foot enable is SkeletonIK464; reset82BED780 initializes it to true. +pub fn update_modes(limbs: &mut [LimbStatus; 4], feet_enabled: bool, flags_2472: u32) { + let enabled = [ + feet_enabled, + feet_enabled, + flags_2472 & 0x80 != 0, + flags_2472 & 0x100 != 0, + ]; + for (limb, enabled) in limbs.iter_mut().zip(enabled) { + let fallback = if enabled { + Mode::OnDeck + } else { + Mode::Disabled + }; + match limb.mode { + Mode::Disabled => { + limb.board_blend = 0.0; + if limb.external_target_set { + limb.mode = Mode::External; + limb.external_blend = limb.target_blend; + } else if limb.local_target_set { + limb.mode = Mode::Local; + limb.external_blend = limb.target_blend; + limb.external_target_local_delta = [0.0; 4]; + } else { + limb.mode = fallback; + limb.external_blend = 0.0; + } + } + Mode::OnDeck => { + if limb.external_target_set { + limb.mode = Mode::External; + limb.external_blend = limb.target_blend; + } else if limb.local_target_set { + limb.mode = Mode::Local; + limb.external_blend = limb.target_blend; + limb.external_target_local_delta = [0.0; 4]; + } else if limb.board_blend == 0.0 { + limb.mode = fallback; + limb.external_blend = 0.0; + } + } + Mode::External => { + if !limb.external_target_set { + limb.mode = Mode::Local; + } + } + Mode::Local => { + if limb.external_target_set { + limb.mode = Mode::External; + limb.external_blend = limb.target_blend; + } + // This is a second independent test, even after switching to + //External above. Preserve the native zero-target-blend case. + if !limb.local_target_set && limb.external_blend == 0.0 { + limb.mode = fallback; + limb.external_target_local_delta = [0.0; 4]; + } + } + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct BlendSettings { + /// physics_skeletonik cached layout16/32. + pub hand_inner_padding: [f32; 4], + pub hand_outer_padding: [f32; 4], + /// Cached layout84 and88; native increments per update, without dt. + pub external_blend_step: f32, + pub board_blend_step: f32, +} + +/// Board dimensions are the actual constructor-derived half width and total +/// half length, not the board's visual bounds or cached generic dimensions. +pub fn update_blends( + limbs: &mut [LimbStatus; 4], + deck_half_width: f32, + deck_total_half_length: f32, + flags_2480: u32, + settings: &BlendSettings, +) { + let base = [deck_half_width, 0.0, deck_total_half_length, 0.0]; + let inner: [f32; 4] = core::array::from_fn(|i| base[i] + settings.hand_inner_padding[i]); + let outer: [f32; 4] = core::array::from_fn(|i| inner[i] + settings.hand_outer_padding[i]); + let step = settings.board_blend_step; + for (index, limb) in limbs.iter_mut().enumerate() { + match limb.mode { + Mode::Disabled => {} + Mode::External => { + limb.external_blend = limb.target_blend; + limb.board_blend = positive(limb.board_blend - step); + } + Mode::Local => { + limb.external_blend = if limb.local_target_set { + 1.0 + } else { + positive(limb.external_blend - settings.external_blend_step) + }; + limb.board_blend = positive(limb.board_blend - step); + } + Mode::OnDeck if index < 2 => { + let target = if flags_2480 & 0x0800_0000 != 0 { + 0.0 + } else { + 1.0 + }; + approach(&mut limb.board_blend, target, step); + } + Mode::OnDeck => { + let p = limb.part_position.map(f32::abs); + if limb.board_blend > 0.0 || p[0] < outer[0] && p[1] < outer[1] && p[2] < outer[2] { + let mut target = 1.0; + // Native order X, Z, Y; fsel preserves equality and NaNs. + for axis in [0, 2, 1] { + if p[axis] > inner[axis] { + let candidate = + 1.0 - (p[axis] - inner[axis]) / settings.hand_outer_padding[axis]; + if axis == 0 { + target = candidate; + } else if axis == 2 { + target = select(target - candidate, candidate, target); + } else { + target = select(candidate - target, target, candidate); + } + } + } + target = positive(target); + target = select(1.0 - target, target, 1.0); + approach(&mut limb.board_blend, target, step); + } + } + } + } +} +fn select(condition: f32, nonnegative: f32, negative: f32) -> f32 { + if condition >= 0.0 { + nonnegative + } else { + negative + } +} +fn positive(value: f32) -> f32 { + select(-value, 0.0, value) +} +fn approach(value: &mut f32, target: f32, step: f32) { + let delta = target - *value; + let delta = select(-step - delta, -step, delta); + *value = select(step - delta, delta, step) + *value; +} diff --git a/crates/skate-core/src/animation/foot_ik/tests.rs b/crates/skate-core/src/animation/foot_ik/tests.rs new file mode 100644 index 00000000..25904854 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/tests.rs @@ -0,0 +1,94 @@ +use super::{ + contact::{ContactInput, ContactState}, + math::{interpolate, inverse_affine}, + status::{LimbStatus, Mode}, + transforms::LimbFrames, + two_bone::{self, AngleLimits, SolveResult}, +}; +use crate::physics::skeleton_animation_record::{IDENTITY, transform_point}; + +#[test] +fn inverse_preserves_points_for_scaled_and_sheared_authored_frames() { + let frame = [ + [2.0, 0.0, 0.0, 0.0], + [0.5, 3.0, 0.0, 0.0], + [0.0, 0.25, 4.0, 0.0], + [7.0, -2.0, 5.0, 0.0], + ]; + let point = [0.3, -0.7, 1.2, 0.0]; + let recovered = transform_point(&inverse_affine(&frame), transform_point(&frame, point)); + for lane in 0..3 { + assert!((recovered[lane] - point[lane]).abs() < 2e-6); + } +} + +#[test] +fn frame_interpolation_uses_rotation_and_translation_at_the_same_weight() { + let mut target = IDENTITY; + target[0] = [0.0, 1.0, 0.0, 0.0]; + target[1] = [-1.0, 0.0, 0.0, 0.0]; + target[3] = [8.0, 2.0, -4.0, 0.0]; + let (half, remaining) = interpolate(&IDENTITY, &target, 0.5); + assert!((half[0][0] - core::f32::consts::FRAC_1_SQRT_2).abs() < 2e-6); + assert!((half[0][1] - core::f32::consts::FRAC_1_SQRT_2).abs() < 2e-6); + assert_eq!(&half[3][..3], &[4.0, 1.0, -2.0]); + assert!((remaining - core::f32::consts::FRAC_PI_4).abs() < 2e-6); + assert_eq!(interpolate(&IDENTITY, &target, 1.0), (target, 0.0)); +} + +#[test] +fn reachable_two_bone_target_keeps_both_segment_lengths() { + let root = [0.0; 4]; + let middle = [0.3, 0.4, 0.0, 0.0]; + let end = [0.6, 0.0, 0.0, 0.0]; + let mut target = [0.7, 0.0, 0.0, 0.0]; + let mut solved = [0.0; 4]; + let result = two_bone::solve( + root, + middle, + end, + &mut solved, + &mut target, + AngleLimits { + minimum_degrees: 5.0, + maximum_degrees: 175.0, + }, + true, + 2, + ); + assert_eq!(result, SolveResult::Solved); + assert_eq!(&target[..3], &[0.7, 0.0, 0.0]); + let distance = |a: [f32; 4], b: [f32; 4]| { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() + }; + assert!((distance(root, solved) - 0.5).abs() < 2e-5, "{solved:?}"); + assert!((distance(solved, target) - 0.5).abs() < 2e-5, "{solved:?}"); +} + +#[test] +fn foot_queries_affect_the_following_update_and_loss_sets_support_history() { + let mut contacts = ContactState::default(); + let mut statuses = [LimbStatus::default(); 4]; + statuses[0].mode = Mode::OnDeck; + let mut frames = [LimbFrames::default(); 4]; + let input = |hit| ContactInput { + flags_2468: 0x0200_0000, + contact_bone: 10, + foot_bones: [10, 20], + hips_world_position: [0.0; 4], + inverse_board: &IDENTITY, + board: &IDENTITY, + current_contacts: [hit, None], + }; + contacts.update(&statuses, &mut frames, input(None)); + assert!(!contacts.support_failed_this_update); + assert_eq!(contacts.feet[0].query_state, 2); + contacts.update(&statuses, &mut frames, input(Some([0.0; 4]))); + assert!(contacts.support_failed_this_update); + assert!(contacts.support_failed); + assert_eq!(contacts.feet[0].query_state, 1); + statuses[0].mode = Mode::Disabled; + contacts.update(&statuses, &mut frames, input(None)); + assert!(!contacts.support_failed_this_update); + assert!(contacts.support_failed); +} diff --git a/crates/skate-core/src/animation/foot_ik/transforms.rs b/crates/skate-core/src/animation/foot_ik/transforms.rs new file mode 100644 index 00000000..d6abdbd2 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/transforms.rs @@ -0,0 +1,97 @@ +//! Animated portion of CalculateInitialPartTransforms82BEDF08. +use super::status::{LimbStatus, Mode}; +use crate::physics::skeleton_animation_record::{ + AnimationPartTransform as Transform, IDENTITY, compose_affine, +}; + +#[derive(Clone, Copy, Debug)] +pub struct LimbBinding { + /// SkeletonIK1120: the physics volume driven by this target. + pub part: usize, + /// SkeletonIK1136: optional adjacent volume receiving the same adjustment. + pub parent_part: Option, +} + +#[derive(Clone, Copy, Debug)] +pub struct LimbFrames { + /// SkeletonIK864, from the actual reparented animation target. + pub target: Transform, + /// SkeletonIK1152; animated world frame, then blended/contact-adjusted. + pub world: Transform, + /// SkeletonIK1408: externally supplied target in world space. + pub external_world: Transform, + /// SkeletonIK1664: target relative to the animated board. + pub board: Transform, + /// SkeletonIK2240, optional adjacent volume in world space. + pub parent_world: Transform, + /// SkeletonIK2496: adjacent volume's external target. + pub external_parent_world: Transform, + /// SkeletonIK2752: adjacent volume relative to the animated board. + pub parent_board: Transform, + /// SkeletonIK3120: animated target lies within the cached contact box. + pub within_contact_bounds: bool, +} +impl Default for LimbFrames { + fn default() -> Self { + //82BED780 initializes every retained affine frame to identity. + Self { + target: IDENTITY, + world: IDENTITY, + external_world: IDENTITY, + board: IDENTITY, + parent_world: IDENTITY, + external_parent_world: IDENTITY, + parent_board: IDENTITY, + within_contact_bounds: false, + } + } +} + +/// Prepare the animated frames after this limb's CalculateExternalIKOffset +/// stage. External/local modes retain their separately calculated frames. +/// The full controller calls both stages in native limb order. +pub fn prepare_animation_target( + status: &mut LimbStatus, + frames: &mut LimbFrames, + binding: LimbBinding, + animation: &[Transform; 24], + animation_to_world: &Transform, + inverse_animation_board: &Transform, + contact_bounds: [f32; 4], +) { + if status.mode != Mode::OnDeck && !(status.board_blend > 0.0) && status.external_blend >= 1.0 { + return; + } + let animated = &animation[binding.part]; + frames.board = compose_affine(inverse_animation_board, &frames.target); + status.part_position = frames.board[3]; + frames.world = compose_affine(animation_to_world, animated); + // Native comparisons reject only a strict bound violation; NaNs do not + // become a host-side exclusion. The fourth stored lane is excluded. + frames.within_contact_bounds = (0..3).all(|i| { + !(status.part_position[i] > contact_bounds[i] + || -contact_bounds[i] > status.part_position[i]) + }); + if let Some(parent) = binding.parent_part { + let relative = compose_affine(&inverse_rigid(animated), &animation[parent]); + frames.parent_board = compose_affine(&frames.board, &relative); + frames.parent_world = compose_affine(&frames.world, &relative); + } +} + +///82BEDF5C..DFEC and E1B4..E260: rigid inverse, transpose XYZ and zero W; +/// translation uses negative position with Z multiply, Y FMA, then X FMA. +/// This deliberately does not introduce a general scale/shear inverse. +pub fn inverse_rigid(source: &Transform) -> Transform { + let mut result = IDENTITY; + for axis in 0..3 { + result[axis] = [source[0][axis], source[1][axis], source[2][axis], 0.0]; + } + let translation = source[3].map(|v| 0.0 - v); + result[3] = core::array::from_fn(|i| { + let z = translation[2] * result[2][i]; + let y = translation[1].mul_add(result[1][i], z); + translation[0].mul_add(result[0][i], y) + }); + result +} diff --git a/crates/skate-core/src/animation/foot_ik/two_bone.rs b/crates/skate-core/src/animation/foot_ik/two_bone.rs new file mode 100644 index 00000000..ea96da58 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/two_bone.rs @@ -0,0 +1,202 @@ +//! Math::_TwoBoneIKSolve8296F8C0, TU3 complete reachable/recursive/invalid path. +//! Plane construction uses the native general inverse, not a rigid transpose. +use super::math::{Vector, cross, inverse_affine, length, normalize, reciprocal}; +use crate::{ + physics::{ + native_arithmetic::{dot3, vector_max, vector_min}, + skeleton_animation_record::{AnimationPartTransform as Transform, transform_point}, + }, + trigonometry::{acos, cos, sin_cos}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SolveResult { + Solved, + Extended, + Invalid, +} + +#[derive(Clone, Copy, Debug)] +pub struct AngleLimits { + pub minimum_degrees: f32, + pub maximum_degrees: f32, +} + +const EPSILON: f32 = f32::from_bits(0x3A03_126F); //830BD4B0 <-821A0318 +const TO_DEGREES: f32 = f32::from_bits(0x4265_2EE1); //830BD390 +const TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); //830BD360 + +pub fn solve( + root: Vector, + middle: Vector, + end: Vector, + solved_middle: &mut Vector, + target: &mut Vector, + limits: AngleLimits, + override_maximum_with_original: bool, + recursion_budget: u32, +) -> SolveResult { + if recursion_budget == 0 { + *target = end; + *solved_middle = middle; + return SolveResult::Invalid; + } + let upper = subtract(middle, root); + let lower = subtract(end, middle); + let upper_length = length(upper); + let lower_length = length(lower); + if EPSILON > upper_length || EPSILON > lower_length { + *solved_middle = middle; + return SolveResult::Invalid; + } + let requested_delta = subtract(*target, root); + let forward = normalize(subtract(end, root)); + let normal = normalize(cross(upper, lower)); + let original_basis = [forward, cross(forward, normal), normal, root]; + let target_forward = normalize(requested_delta); + let target_lateral = cross(target_forward, normal); + let target_normal = cross(target_lateral, target_forward); + let target_basis = [target_forward, target_lateral, target_normal, root]; + let original_inverse = inverse_affine(&original_basis); + let target_inverse = inverse_affine(&target_basis); + // Each point is transformed separately, retaining the original origin's + // rounding; do not replace these with pre-subtracted vectors. + let local_root = transform_point(&original_inverse, root); + let local_middle = transform_point(&original_inverse, middle); + let local_end = transform_point(&original_inverse, end); + let local_target = transform_point(&target_inverse, *target); + let target_delta = subtract(local_target, local_root); + let planar_squared = + target_delta[0].mul_add(target_delta[0], target_delta[1] * target_delta[1]); + let distance = square_root(planar_squared); + let upper_squared = upper_length * upper_length; + let lower_squared = lower_length * lower_length; + let twice_upper = 2.0 * upper_length; + let twice_product = twice_upper * lower_length; + let sum_squared = upper_squared + lower_squared; + let negative_lower = lower.map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)); + let negative_length = f32::from_bits(lower_length.to_bits() ^ 0x8000_0000); + let original_angle = + acos(reciprocal(upper_length * negative_length, 2) * dot3(upper, negative_lower)) + * TO_DEGREES; + let mut maximum = limits.maximum_degrees; + if original_angle > maximum && override_maximum_with_original { + maximum = original_angle; + } + let minimum_distance = + square_root(sum_squared - twice_product * cos(limits.minimum_degrees * TO_RADIANS)); + let maximum_distance = square_root(sum_squared - twice_product * cos(maximum * TO_RADIANS)); + if minimum_distance > distance || distance > maximum_distance { + if distance > upper_length + lower_length && maximum == 180.0 { + let direction = normalize(target_delta); + let middle_local = scaled_add(direction, upper_length, local_root); + let end_local = scaled_add(direction, lower_length, middle_local); + *solved_middle = transform_point(&target_basis, middle_local); + *target = transform_point(&target_basis, end_local); + return SolveResult::Extended; + } + let distance = if distance > maximum_distance { + maximum_distance - EPSILON + } else { + minimum_distance + EPSILON + }; + *target = scaled_add(normalize(requested_delta), distance, root); + return solve( + root, + middle, + end, + solved_middle, + target, + limits, + override_maximum_with_original, + recursion_budget - 1, + ); + } + let cosine = clamp_native_cosine(reciprocal(twice_product, 2) * (sum_squared - planar_squared)); + let interior_angle = acos(cosine) * TO_DEGREES; + if limits.minimum_degrees >= interior_angle { + *target = scaled_add(normalize(requested_delta), minimum_distance + EPSILON, root); + return solve( + root, + middle, + end, + solved_middle, + target, + limits, + override_maximum_with_original, + recursion_budget - 1, + ); + } + let mut target_angle = acos(clamp_native_cosine( + reciprocal(distance, 2) * target_delta[0], + )); + if local_middle[1] < 0.0 && local_target[1] > 0.0 + || local_middle[1] > 0.0 && local_target[1] < 0.0 + { + target_angle *= -1.0; + } + // The target heading is an addend *inside* this acos in the TU3 body + // (vmaddfp82970088). Preserve it rather than fitting a textbook IK formula. + let ratio = reciprocal(twice_upper * distance, 2).mul_add( + (upper_squared + planar_squared) - lower_squared, + target_angle, + ); + let angle = acos(clamp_native_cosine(ratio)); + let original_delta = subtract(local_end, local_root); + let positive = candidate( + original_delta, + angle, + upper_length, + local_root, + &target_basis, + ); + let negative = candidate( + original_delta, + f32::from_bits(0x40C9_0FDB) - angle, + upper_length, + local_root, + &target_basis, + ); + *solved_middle = if local_end[1] > local_middle[1] { + negative + } else { + positive + }; + SolveResult::Solved +} + +///The source uses830BD310=0 and830BD430=1 for these clamps. The lower +///constant is not minus one; its initializer82F82718 loads82165A10. +fn clamp_native_cosine(value: f32) -> f32 { + vector_min(1.0, vector_max(0.0, value)) +} + +fn square_root(squared: f32) -> f32 { + let inverse = crate::physics::board_motion_output::inverse_length_squared(squared, 2); + let value = squared * inverse; + if squared == 0.0 { 0.0 } else { value } +} +fn subtract(a: Vector, b: Vector) -> Vector { + core::array::from_fn(|i| a[i] - b[i]) +} +fn scaled_add(a: Vector, scale: f32, b: Vector) -> Vector { + core::array::from_fn(|i| a[i].mul_add(scale, b[i])) +} + +fn candidate( + delta: Vector, + angle: f32, + upper_length: f32, + root: Vector, + basis: &Transform, +) -> Vector { + let (sine, cosine) = sin_cos(angle); + let rotation = [ + [cosine, sine, 0.0, cosine], + [-sine, cosine, 0.0, -sine], + [0.0, 0.0, 1.0, 0.0], + [0.0; 4], + ]; + let rotated = normalize(transform_point(&rotation, delta)); + transform_point(basis, scaled_add(rotated, upper_length, root)) +} diff --git a/crates/skate-core/src/animation/ground_acceleration.rs b/crates/skate-core/src/animation/ground_acceleration.rs new file mode 100644 index 00000000..907b86be --- /dev/null +++ b/crates/skate-core/src/animation/ground_acceleration.rs @@ -0,0 +1,121 @@ +//! Deck acceleration conditioning82DF0508 and IsBumped82BA7310, original TU3. +//! Matrices are the raw physical deck and Reckoning ground frames; the effective +//! animation deck is a different source. No render transform participates here. +use crate::physics::{native_arithmetic, skeleton_animation_record::AnimationPartTransform}; + +#[derive(Clone, Copy, Debug)] +pub struct Input { + /// PhysOutDeck0..48, actual Part6 transform (82C02AD8..2B20). + pub deck: AnimationPartTransform, + /// PhysOutGround0..48, Reckoning752..800 (82DB70E0..7100). + pub ground: AnimationPartTransform, + /// PhysOutMotion112, BoardBody528 from observed deck velocity differences. + pub world_acceleration: [f32; 4], +} +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Globals344 anim_motion/bumps layout1700, scale_x_acc. + pub scale_x_acc: f32, + /// Same collection layout1768, min_bump_mag. + pub min_bump_mag: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Output { + pub acceleration: [f32; 4], + pub bumped: bool, +} + +pub fn publish(input: Input, settings: &Settings) -> Output { + let acceleration = condition(input); + Output { + acceleration, + bumped: is_bumped(acceleration, settings), + } +} + +///82DF0508: remove deck-local Y, rotate back to world, express in the +///ground frame, then remove ground-local Y. Both original stores are required. +pub fn condition(input: Input) -> [f32; 4] { + let mut deck_local = inverse_rotate(input.deck, input.world_acceleration); + deck_local[1] = 0.0; //82DF05C4 + let world = rotate(input.deck, deck_local); + let mut ground_local = inverse_rotate(input.ground, world); + ground_local[1] = 0.0; //82DF0600 + ground_local +} + +///82BA7310 scales X only, then compares the refined length strictly > threshold. +///Independent Rust estimates are used; this is not a bit-exact Xenon claim. +pub fn is_bumped(mut acceleration: [f32; 4], settings: &Settings) -> bool { + acceleration[0] *= settings.scale_x_acc; + let squared = native_arithmetic::dot3(acceleration, acceleration); + let mut reciprocal = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + let square = reciprocal * reciprocal; + let half = reciprocal * 0.5; + let error = (-squared).mul_add(square, 1.0); + reciprocal = half.mul_add(error, reciprocal); + } + let magnitude = if squared == 0.0 { + 0.0 + } else { + squared * reciprocal + }; + magnitude > settings.min_bump_mag +} + +fn inverse_rotate(frame: AnimationPartTransform, vector: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|lane| { + frame[lane][2].mul_add( + vector[2], + frame[lane][1].mul_add(vector[1], frame[lane][0] * vector[0]), + ) + }) +} +fn rotate(frame: AnimationPartTransform, vector: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|lane| { + frame[2][lane].mul_add( + vector[2], + frame[1][lane].mul_add(vector[1], frame[0][lane] * vector[0]), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn pitched_deck_is_projected_before_the_distinct_ground_frame() { + // Deck Y points world Z. Ground X points world Z. World Z is removed + // before changing frame; removing only final Y would incorrectly keep it. + let deck = [ + [1., 0., 0., 0.], + [0., 0., 1., 0.], + [0., -1., 0., 0.], + [7., 8., 9., 0.], + ]; + let ground = [ + [0., 0., 1., 0.], + [0., 1., 0., 0.], + [-1., 0., 0., 0.], + [0.; 4], + ]; + let result = condition(Input { + deck, + ground, + world_acceleration: [2., 3., 4., 0.], + }); + assert_eq!(&result[..3], &[0., 0., -2.]); + } + #[test] + fn bump_uses_only_scaled_horizontal_magnitude_and_strict_threshold() { + let settings = Settings { + scale_x_acc: 2., + min_bump_mag: 10., + }; + assert!(!is_bumped([0.; 4], &settings)); + assert!(!is_bumped([0., 0., 10., 0.], &settings)); + assert!(is_bumped([6., 0., 0., 0.], &settings)); + assert!(!is_bumped([0., 0., 0., 999.], &settings)); + } +} diff --git a/crates/skate-core/src/animation/intent_filter.rs b/crates/skate-core/src/animation/intent_filter.rs new file mode 100644 index 00000000..2e45cbf4 --- /dev/null +++ b/crates/skate-core/src/animation/intent_filter.rs @@ -0,0 +1,109 @@ +//! FilterMotionGraphIntent TU3 82BB1730/17D0/19F0 and 82BB1B78. +//! Coefficients and limits are per graph update; only the ramp uses elapsed time. +use crate::input::graph_intents::apply_filter; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Settings { + pub starting_value: f32, + pub default_value: f32, + pub scale: f32, + pub filters: [u32; 4], + pub ramp_time: Option, + pub blend_rising: f32, + pub blend_falling: f32, + pub blend_out: Option, + pub clamp_velocity: Option, + pub clamp_acceleration: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct State { + pub elapsed: f32, + pub previous_delta: f32, + pub value: f32, +} + +impl State { + /// Begin publishes startingValue directly, without scaling or filtering. + pub fn begin(&mut self, settings: &Settings) -> f32 { + self.elapsed = 0.0; + self.previous_delta = 0.0; + self.value = settings.starting_value; + self.value + } + + /// Stance is the live animation component's fakie/mirrored getter pair. + pub fn update( + &mut self, + settings: &Settings, + input: Option, + dt: f32, + stance: (bool, bool), + ) -> f32 { + self.elapsed += dt; + let mut target = settings.scale * input.unwrap_or(settings.default_value); + for (kind, enabled) in settings.filters.into_iter().zip([true, true, stance.0, stance.1]) { + if enabled && kind <= 7 { + target = apply_filter(target, kind); + } + } + let mut blend = if target < self.value { + settings.blend_falling + } else { + settings.blend_rising + }; + if input.is_some() { + if let Some(time) = settings.ramp_time { + blend = ramp(self.elapsed, time) * blend; + } + } else if let Some(out) = settings.blend_out { + blend = out; + } + // 82BB194C..1968: separate target product, then fmadds, then subtract. + let weighted_target = blend * target; + let candidate = (1.0 - blend).mul_add(self.value, weighted_target); + let mut delta = candidate - self.value; + if let Some(limit) = settings.clamp_acceleration { + delta = limit_delta(delta, self.previous_delta - limit, self.previous_delta + limit); + } + if let Some(limit) = settings.clamp_velocity { + delta = limit_delta(delta, -limit, limit); + } + self.previous_delta = delta; + self.value = delta + self.value; + self.value + } +} + +fn limit_delta(value: f32, lower: f32, upper: f32) -> f32 { + // Preserve the original subtract/select ordering, including unordered input. + let selected = if lower - value >= 0.0 { lower } else { value }; + if upper - selected >= 0.0 { selected } else { upper } +} + +fn ramp(elapsed: f32, time: f32) -> f32 { + // 82BA8720 uses signed integer comparisons of float bits in lower_bound. + // Constructor 82BB1570..15BC supplies precisely (0,0),(rampTime,1). + let keys = [0.0f32, time]; + let mut begin = 0usize; + let mut count = 2usize; + while count != 0 { + let step = count / 2; + let middle = begin + step; + if (keys[middle].to_bits() as i32) < (elapsed.to_bits() as i32) { + begin = middle + 1; + count -= step + 1; + } else { + count = step; + } + } + match begin { + 0 => 0.0, + 2 => 1.0, + _ => ((1.0f32 - 0.0) / (time - 0.0)).mul_add(elapsed - 0.0, 0.0), + } +} + +#[cfg(test)] +#[path = "intent_filter/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/animation/intent_filter/tests.rs b/crates/skate-core/src/animation/intent_filter/tests.rs new file mode 100644 index 00000000..4ab9e097 --- /dev/null +++ b/crates/skate-core/src/animation/intent_filter/tests.rs @@ -0,0 +1,66 @@ +use super::*; + +fn settings() -> Settings { + Settings { + starting_value: 0.0, default_value: 0.0, scale: 1.0, filters: [0; 4], + ramp_time: None, blend_rising: 0.5, blend_falling: 0.25, + blend_out: Some(0.125), clamp_velocity: None, clamp_acceleration: None, + } +} + +#[test] +fn starting_value_bypasses_scale_and_filters_and_reentry_resets_history() { + let mut s = State { elapsed: 4.0, previous_delta: 9.0, value: 7.0 }; + let mut p = settings(); + p.starting_value = 2.0; + p.scale = 3.0; + p.filters = [1; 4]; + assert_eq!(s.begin(&p), 2.0); + assert_eq!(s.elapsed, 0.0); + assert_eq!(s.previous_delta, 0.0); +} + +#[test] +fn missing_input_uses_blend_out_without_ramp_and_zero_is_present() { + let mut p = settings(); + p.ramp_time = Some(8.0); + let mut s = State { value: 1.0, ..State::default() }; + assert_eq!(s.update(&p, None, 1.0, (false, false)), 0.875); + s = State { value: 1.0, ..State::default() }; + assert_eq!(s.update(&p, Some(0.0), 1.0, (false, false)), 0.96875); +} + +#[test] +fn acceleration_precedes_velocity_and_limits_are_not_scaled_by_dt() { + let mut p = settings(); + p.blend_rising = 1.0; + p.clamp_acceleration = Some(0.25); + p.clamp_velocity = Some(0.125); + let mut s = State::default(); + assert_eq!(s.update(&p, Some(4.0), 0.0, (false, false)), 0.125); + assert_eq!(s.update(&p, Some(4.0), 3.0, (false, false)), 0.25); + assert_eq!(s.update(&p, Some(-4.0), 0.0, (false, false)), 0.125); +} + +#[test] +fn scale_and_ordered_filters_use_live_stance() { + let mut p = settings(); + p.blend_rising = 1.0; + p.blend_falling = 1.0; + p.scale = 2.0; + p.filters = [1, 2, 3, 1]; + let mut s = State::default(); + assert_eq!(s.update(&p, Some(0.125), 0.0, (false, false)), 0.25); + assert_eq!(s.update(&p, Some(0.125), 0.0, (true, true)), -0.75); +} + +#[test] +fn ramp_has_native_endpoint_and_signed_bit_ordering() { + assert_eq!(ramp(0.0, 2.0), 0.0); + assert_eq!(ramp(0.5, 2.0), 0.25); + assert_eq!(ramp(2.0, 2.0), 1.0); + assert_eq!(ramp(3.0, 2.0), 1.0); + assert_eq!(ramp(-1.0, 2.0), 0.0); + assert_eq!(ramp(0.0, 0.0), 0.0); + assert_eq!(ramp(1.0, 0.0), 1.0); +} diff --git a/crates/skate-core/src/animation/kickturn.rs b/crates/skate-core/src/animation/kickturn.rs new file mode 100644 index 00000000..0283735f --- /dev/null +++ b/crates/skate-core/src/animation/kickturn.rs @@ -0,0 +1,109 @@ +//! Original Skate 3 TU3 KickTurnSteering: constructor82BAE730, +//! Begin82BAE430 and Update82BAE4A8. Curves are Globals324 anim_motion/kickturn. +use crate::point_graph::PointGraph; + +#[derive(Clone, Debug)] +pub struct Settings { + pub spin: PointGraph<8>, + pub height: PointGraph<8>, +} + +/// Authored behavior fields28/32; constructor82BC7218 defaults both to0.5. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Parameters { + pub max_height: f32, + pub spin_scale: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct State { + animation_length: f32, + elapsed: f32, + first_update: bool, + previous_height: f32, + last_nonzero_intent: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Output { + /// Published first, to the actual MotionGraph Balance attribute. + pub balance: f32, + pub spin: f32, +} + +impl State { + pub fn new() -> Self { + Self { + animation_length: 0.0, + elapsed: 0.0, + first_update: true, + previous_height: 0.0, + last_nonzero_intent: 0.0, + } + } + + pub fn begin(&mut self, settings: &Settings) { + *self = Self::new(); + //82BAE480 seeds the unscaled curve value, before applying max_height. + self.previous_height = settings.height.evaluate(0.0); + } + + pub fn needs_animation_length(&self) -> bool { + self.first_update + } + + /// The caller first applies pending animation parameters, then reads the + /// base animation tree's length. No time is added on that first update. + pub fn capture_animation_length(&mut self, length: f32) { + self.animation_length = length; + } + + pub fn update( + &mut self, + dt: f32, + intent: f32, + parameters: Parameters, + settings: &Settings, + ) -> Output { + if !self.first_update { + self.elapsed += dt; + } + let phase = bound(self.elapsed / self.animation_length, 1.0); + let spin_curve = settings.spin.evaluate(phase); + let height_curve = settings.height.evaluate(phase); + //82BAE660 fmadds, followed by separate82BAE670 fmuls. TU3 fixes + //the blend coefficient at0.5; the S2 debug alternatives are absent. + let sum = height_curve.mul_add(parameters.max_height, self.previous_height); + let height = bound(sum * 0.5, parameters.max_height); + self.previous_height = height; + if intent != 0.0 { + self.last_nonzero_intent = intent; + } + let sign = if self.last_nonzero_intent >= 0.0 { + 1.0 + } else { + -1.0 + }; + let output = Output { + balance: -height, + spin: (parameters.spin_scale * sign) * spin_curve, + }; + self.first_update = false; + output + } +} + +//Source fsel(-value,0,value), then fsel(max-lower,lower,max). +//Preserves source unordered handling: a NaN selects the upper bound. +fn bound(value: f32, maximum: f32) -> f32 { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if maximum - lower >= 0.0 { + lower + } else { + maximum + } +} + +#[cfg(test)] +#[path = "tests/kickturn.rs"] +mod tests; diff --git a/crates/skate-core/src/animation/landing_quality.rs b/crates/skate-core/src/animation/landing_quality.rs new file mode 100644 index 00000000..5a62f350 --- /dev/null +++ b/crates/skate-core/src/animation/landing_quality.rs @@ -0,0 +1,148 @@ +//! Original TU3 PhysicsConditioner::CalcLandingQuality, 0x82DE61D0. +use crate::{ + physics::{board_motion_output::inverse_length_squared, native_arithmetic}, + point_graph::PointGraph, +}; + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub twist_spin: PointGraph<4>, + pub side_speed: PointGraph<4>, +} + +#[derive(Clone, Copy, Debug)] +pub struct Input { + /// PhysicsConditioner +144 and +148, after its filtered-state update. + pub previous_filtered_state: u32, + pub filtered_state: u32, + /// PhysOut_Collision +32. Do not substitute a predicted landing normal. + pub ground_normal: [f32; 4], + /// PhysOut_SkateboardMotion +80 and +273. + pub deck_velocity: [f32; 4], + pub flipped: bool, + /// PhysOut_SkateboardReckoning +32: physical deck PART world Z from + /// 82C02AD8/82C02B1C. This is not the conditioned Reckoning ground frame. + pub reckoning_forward: [f32; 4], + /// PhysOut_Air +204. + pub air_spin: f32, +} + +/// Reset values are explicitly written by 0x82DE3F38. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Output { + pub landing_adjust_80: f32, + pub sideways_speed_84: f32, + pub forward_speed_88: f32, + pub spin_92: f32, + pub landing_type_96: u32, + pub landing_data_167: bool, +} + +impl Output { + /// Nonlanding frames preserve every field, including the valid-data latch. + pub fn update(&mut self, input: Input, settings: &Settings) { + if input.previous_filtered_state == 1 || input.filtered_state != 1 { + return; + } + let velocity = planar(input.deck_velocity, input.ground_normal); + let forward = planar(input.reckoning_forward, input.ground_normal); + let speed = length(velocity); + let forward_length = length(forward); + let mut forward_speed = dot(velocity, forward).abs(); + let mut side_speed = dot(cross(forward, input.ground_normal), input.deck_velocity).abs(); + let mut kind = 0; + let mut spin = 0.0; + if forward_length * speed < f32::from_bits(0x3727_c5ac) { + forward_speed = 0.0; + side_speed = 0.0; + } else { + let normalized_velocity = scale(velocity, reciprocal(speed)); + let normalized_forward = scale(forward, reciprocal(forward_length)); + let mut orientation = cross(normalized_forward, normalized_velocity)[1]; + if input.flipped { + orientation = -orientation; + } + let angular_speed = -input.air_spin; + let spin_input = (angular_speed.abs() - 0.1) * 0.14492753; + let spin_curve = settings.twist_spin.evaluate(upper_one(spin_input)); + let side_input = (side_speed - 0.1) * 0.1010101; + let side_curve = settings.side_speed.evaluate(upper_one(side_input)); + if speed >= 2.0 { + let heading = if input.flipped { + scale(normalized_forward, -1.0) + } else { + normalized_forward + }; + let toward = dot(heading, normalized_velocity) >= 0.0; + let magnitude; + if angular_speed.abs() <= 0.1 { + kind = 1; + magnitude = side_curve; + } else { + let product = angular_speed * orientation; + kind = if (toward && product <= 0.0) || (!toward && product > 0.0) { + 2 + } else { + 1 + }; + magnitude = if spin_curve - side_curve >= -0.0 { + spin_curve + } else { + side_curve + }; + } + spin = if angular_speed >= -0.0 { + magnitude + } else { + -magnitude + }; + if orientation.abs() < 0.2 && (kind != 2 || !(spin.abs() > 0.3)) { + kind = if orientation.abs() >= 0.05 { 3 } else { 0 }; + spin = 0.0; + } + } + } + *self = Self { + landing_adjust_80: spin, + sideways_speed_84: side_speed, + forward_speed_88: forward_speed, + spin_92: spin, + landing_type_96: kind, + landing_data_167: true, + }; + } +} + +fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + native_arithmetic::dot3(a, b) +} +fn scale(a: [f32; 4], b: f32) -> [f32; 4] { + a.map(|x| x * b) +} +fn planar(a: [f32; 4], normal: [f32; 4]) -> [f32; 4] { + let projected = scale(normal, dot(normal, a)); + std::array::from_fn(|i| a[i] - projected[i]) +} +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0.0, + ] +} +fn length(a: [f32; 4]) -> f32 { + let square = dot(a, a); + let value = square * inverse_length_squared(square, 2); + if square == 0.0 { 0.0 } else { value } +} +fn reciprocal(value: f32) -> f32 { + let mut estimate = native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + estimate = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + } + estimate +} +fn upper_one(value: f32) -> f32 { + if 1.0 - value >= -0.0 { value } else { 1.0 } +} diff --git a/crates/skate-core/src/animation/mod.rs b/crates/skate-core/src/animation/mod.rs new file mode 100644 index 00000000..00960638 --- /dev/null +++ b/crates/skate-core/src/animation/mod.rs @@ -0,0 +1,33 @@ +//! Animation timing and event semantics recovered from Skate 3 TU3. +pub mod clip_clock; +pub mod commands; +pub mod output; +pub mod skeleton_input; +pub mod playback; +pub mod playback_parameters; +pub mod playback_clip; +pub mod playback_attributes; +pub mod playback_tree; +pub mod phase_blend; +pub mod pose_blend; +pub mod playback_transition; +pub mod crouching; +pub mod pose_sample; +pub mod pose_trajectory; +pub mod pose_add; +pub mod pose_mirror; +pub mod body_tilt; +pub mod air_leg_extension; +pub mod landing_quality; +pub mod riding_fakie; +pub mod channel_playback; +pub mod pumping_channel; +pub mod physical_feedback; +pub mod ground_acceleration; +pub mod foot_ik; +pub mod vbr; +pub mod kickturn; + +pub mod intent_filter; + +pub mod posture; diff --git a/crates/skate-core/src/animation/output/actor_packet.rs b/crates/skate-core/src/animation/output/actor_packet.rs new file mode 100644 index 00000000..4028dbd9 --- /dev/null +++ b/crates/skate-core/src/animation/output/actor_packet.rs @@ -0,0 +1,128 @@ +//! Actor field publication825937EC..825939C8 and external payload copy82592810. +//! Runtime services remain explicit source interfaces; their implementation is +//! not supplied here and these fields do not manufacture a gameplay impulse. +use super::{packet_reset::AdditionalResetFields, physics_packet::PhysicsPosePacket}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExternalPhysicsInput { + /// Ten complete vectors; word representation preserves non-arithmetic bits. + pub vectors: [[u32; 4]; 10], + pub flags: u32, +} +impl ExternalPhysicsInput { + ///82592810 copies160 bytes and only the high seven flag bits. Remaining + /// destination flag bits and native padding are not overwritten. + pub fn copy_from(&mut self, source: &Self) { + self.vectors = source.vectors; + self.flags = (self.flags & 0x01ff_ffff) | (source.flags & 0xfe00_0000); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExternalReset { + pub transform: [[u32; 4]; 4], + /// Source payload+64 -> packet10768; retains the complete byte. + pub byte64: u8, +} + +pub struct ActorPacketFields { + pub external_impulse: [u32; 4], + pub external_physics: ExternalPhysicsInput, + pub external_reset: ExternalReset, + /// Actor1904 bit29 -> packet10784; not reset by82590028. + pub actor_flag_1904_bit29: bool, +} + +pub struct ActorPublicationState { + pub flags1904: u32, + pub flags1908: u32, + pub external_controller_present: bool, + pub external_impulse: [u32; 4], + pub external_physics: ExternalPhysicsInput, + pub external_reset: ExternalReset, + /// Actor1768 selects the native168-byte per-player settings record. + pub player_index: u32, +} + +/// A source-owned binding to the settings object captured before Actor+56's +/// virtual call. Each read uses the same captured object and168-byte stride. +pub trait PlayerPhysicsSettingsSource { + type Error; + /// Record+160. This read is skipped by the fixed-settings branch. + fn truck_tightness(&mut self, player_index: u32) -> Result; + /// Record+164. This read is skipped by the fixed-settings branch. + fn wheel_hardness(&mut self, player_index: u32) -> Result; + /// Record+172. Read after either branch has written both output scalars. + fn requested_physics_mode(&mut self, player_index: u32) -> Result; +} + +/// Exact unresolved external service boundaries for this field publisher. +/// A game adapter must implement these from their source-owned systems. +pub trait ActorPublicationEnvironment { + /// Implementations must report unavailable source services as an error. + /// There is deliberately no default or placeholder implementation. + type Error; + type Settings: PlayerPhysicsSettingsSource; + ///8259FC28: global830CFD94 ->220 ->328 virtual12. None means no current + /// object. Otherwise property824639E8(object20), including its native + /// default, supplies the enum. Property identifier is-616069660. + fn current_scene_mode(&mut self) -> Result, Self::Error>; + /// Read global byte830B7C30 verbatim, without bool normalization. + fn ignore_respawn_reset_button(&mut self) -> Result; + /// Actor subobject+56 virtual16, low return byte. Flags25/28 have already + /// been consumed when called. Concrete vtable binding remains unresolved. + fn actor_subobject56_slot16( + &mut self, + actor: &mut ActorPublicationState, + ) -> Result; + /// Capture global83067068 at82593954 before slot16 at8259395C. The binding + /// must stay valid across that callback and the subsequent field reads. + /// Do not snapshot record values here: native reads them after the callback. + fn capture_physics_settings(&mut self) -> Result; +} + +/// Full actor-owned suffix on distinct actor/packet payload storage. Scene +/// lookup is skipped for an external controller or force-braking bit26. +/// This stops before the physical object's borrowed-packet setter82DB3C48. +pub fn publish( + actor: &mut ActorPublicationState, + pose: &mut PhysicsPosePacket, + reset: &mut AdditionalResetFields, + fields: &mut ActorPacketFields, + environment: &mut E, +) -> Result<(), E::Error> { + reset.actor_flag_1904_bit23 = actor.flags1904 & (1 << 23) != 0; + reset.actor_flag_1908_bit2 = actor.flags1908 & (1 << 2) != 0; + reset.external_impulse_active = actor.flags1904 & (1 << 30) != 0; + fields.external_impulse = actor.external_impulse; + fields.external_physics.copy_from(&actor.external_physics); + reset.external_physics_input_active = actor.flags1904 & (1 << 27) != 0; + let scene_mode_flag = !actor.external_controller_present + && actor.flags1904 & (1 << 26) == 0 + && matches!(environment.current_scene_mode()?, Some(19 | 20)); + replace_flag(&mut pose.flags, 22, scene_mode_flag); + reset.externally_controlled = actor.external_controller_present; + fields.external_reset = actor.external_reset; + fields.actor_flag_1904_bit29 = actor.flags1904 & (1 << 29) != 0; + reset.prevent_manual_respawn = actor.flags1904 & (1 << 28) != 0; + reset.force_braking = actor.flags1904 & (1 << 26) != 0; + replace_flag(&mut pose.flags, 24, actor.flags1904 & (1 << 25) != 0); + actor.flags1904 &= !(1 << 25); + reset.ignore_respawn_reset_button = environment.ignore_respawn_reset_button()?; + actor.flags1904 &= !(1 << 28); + let mut settings = environment.capture_physics_settings()?; + let use_fixed_settings = environment.actor_subobject56_slot16(actor)? != 0; + if use_fixed_settings { + reset.truck_tightness = f32::from_bits(0x3f33_3333); + reset.wheel_hardness = f32::from_bits(0x3f33_3333); + } else { + reset.truck_tightness = settings.truck_tightness(actor.player_index)?; + reset.wheel_hardness = settings.wheel_hardness(actor.player_index)?; + } + reset.requested_physics_mode = settings.requested_physics_mode(actor.player_index)?; + Ok(()) +} + +fn replace_flag(flags: &mut u32, bit: u32, value: bool) { + *flags = (*flags & !(1 << bit)) | (u32::from(value) << bit); +} diff --git a/crates/skate-core/src/animation/output/attributes.rs b/crates/skate-core/src/animation/output/attributes.rs new file mode 100644 index 00000000..7cc2e568 --- /dev/null +++ b/crates/skate-core/src/animation/output/attributes.rs @@ -0,0 +1,120 @@ +//! Attribute copying and ordered packet merge from TU3 8258EED0/8258F210. +//! Binary names stay at this typed boundary. This module does not interpret +//! Skeleton attributes or invent a physics effect from their names. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AttributeName(pub [u32; 5]); + +/// The six union lanes retain initialization separately from value. Copying a +/// scalar must not fabricate values for the other five lanes. Words preserve +/// all floating-point payload bits across non-arithmetic publication. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AttributePayload(pub [Option; 6]); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnimationAttribute { + pub payload: AttributePayload, + pub begin_time: f32, + pub end_time: f32, + pub name: AttributeName, + pub status: u8, + /// Native+53:0/2 scalar,1 vector,3 six-word payload; other kinds copy none. + pub kind: u8, + pub sequence_id: i32, +} + +impl AnimationAttribute { + /// Complete observable field assignment8258EED0. Native padding at54/55 + /// and60..63 is not copied and has no represented domain field. + pub fn copy_from(&mut self, source: &Self) { + self.kind = source.kind; + self.name = source.name; + self.begin_time = source.begin_time; + self.end_time = source.end_time; + self.status = source.status; + self.sequence_id = source.sequence_id; + let lanes = match self.kind { + 0 | 2 => 1, + 1 => 4, + 3 => 6, + _ => 0, + }; + self.payload.0[..lanes].copy_from_slice(&source.payload.0[..lanes]); + } + + /// Copy-construction8258EE68 initializes name storage then performs the + /// assignment above. Its inactive union lanes are unspecified, not zero. + fn copy_construct(source: &Self) -> Self { + let mut result = Self { + payload: AttributePayload([None; 6]), + ..*source + }; + result.copy_from(source); + result + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MotionGraphAttribute { + /// Native24-byte MG record:20-byte name followed by scalar. + pub name: AttributeName, + pub value: f32, +} +impl MotionGraphAttribute { + ///8258F210 makes an untimed scalar attribute with status6 and sequence-1. + pub fn to_animation(self) -> AnimationAttribute { + let mut payload = AttributePayload([None; 6]); + payload.0[0] = Some(self.value.to_bits()); + AnimationAttribute { + payload, + begin_time: -1.0, + end_time: -1.0, + name: self.name, + status: 6, + kind: 0, + sequence_id: -1, + } + } +} + +/// Active entries plus retained slots model native end=begin list reset. +/// Capacity allocation policy is host-owned; active ordering and union-lane +/// preservation follow native append/copy semantics. No sorting/deduplication. +#[derive(Default)] +pub struct PacketAttributes { + slots: Vec, + active_len: usize, +} +impl PacketAttributes { + pub fn entries(&self) -> &[AnimationAttribute] { + &self.slots[..self.active_len] + } + pub fn clear(&mut self) { + self.active_len = 0; + } + pub fn append(&mut self, source: &AnimationAttribute) { + if let Some(slot) = self.slots.get_mut(self.active_len) { + slot.copy_from(source); + } else { + self.slots.push(AnimationAttribute::copy_construct(source)); + } + self.active_len += 1; + } + + /// Exact list operation order at82593750..825937E4: discard prior active + /// list, append converted MG records, then tree records in stored order. + /// Duplicate names remain distinct records for the later Skeleton dispatch. + pub fn replace_from( + &mut self, + motion_graph: &[MotionGraphAttribute], + tree: &[AnimationAttribute], + ) { + self.clear(); + for attribute in motion_graph { + self.append(&attribute.to_animation()); + } + for attribute in tree { + self.append(attribute); + } + } +} diff --git a/crates/skate-core/src/animation/output/hierarchy.rs b/crates/skate-core/src/animation/output/hierarchy.rs new file mode 100644 index 00000000..aedbaa68 --- /dev/null +++ b/crates/skate-core/src/animation/output/hierarchy.rs @@ -0,0 +1,101 @@ +use super::{NativeMatrix, PoseBufferError}; + +/// TU3 0x828D3B58: local matrices to hierarchy-composed matrices. +/// +/// `detached_parent` is native r5 (hierarchy +8 at the caller). A parent equal to +/// it, or -1, causes a verbatim local copy. No world transform is introduced. +/// The first matrix is copied even for a nonpositive count. Other output slots +/// retain caller state until visited, including when a forward parent is read. +pub fn compose_hierarchy( + bone_count: i32, + parents: &[i32], + detached_parent: i32, + local: &[NativeMatrix], + destination: &mut [NativeMatrix], +) -> Result<(), PoseBufferError> { + let count = bone_count.max(0) as usize; + validate( + count, + parents, + detached_parent, + local.len(), + destination.len(), + )?; + destination[0] = local[0]; + for bone in 0..count { + let parent = parents[bone]; + destination[bone] = if parent == -1 || parent == detached_parent { + local[bone] + } else { + concatenate(local[bone], destination[parent as usize]) + }; + } + Ok(()) +} + +/// The same native function with identical source/destination matrix pointers. +pub fn compose_hierarchy_in_place( + bone_count: i32, + parents: &[i32], + detached_parent: i32, + matrices: &mut [NativeMatrix], +) -> Result<(), PoseBufferError> { + let count = bone_count.max(0) as usize; + validate( + count, + parents, + detached_parent, + matrices.len(), + matrices.len(), + )?; + for bone in 0..count { + let parent = parents[bone]; + if parent != -1 && parent != detached_parent { + matrices[bone] = concatenate(matrices[bone], matrices[parent as usize]); + } + } + Ok(()) +} + +fn validate( + count: usize, + parents: &[i32], + detached_parent: i32, + input_len: usize, + output_len: usize, +) -> Result<(), PoseBufferError> { + if input_len < count.max(1) { + return Err(PoseBufferError::ShortInput); + } + if output_len < count.max(1) { + return Err(PoseBufferError::ShortOutput); + } + if parents.len() < count { + return Err(PoseBufferError::ShortParents); + } + for (bone, &parent) in parents[..count].iter().enumerate() { + if parent != -1 + && parent != detached_parent + && (parent < 0 || parent as usize >= output_len) + { + return Err(PoseBufferError::InvalidParent { bone, parent }); + } + } + Ok(()) +} + +fn concatenate(local: NativeMatrix, parent: NativeMatrix) -> NativeMatrix { + let mut result = local; + for row in 0..4 { + for lane in 0..4 { + let first = if row == 3 { + parent[0][lane].mul_add(local[row][0], parent[3][lane]) + } else { + parent[0][lane] * local[row][0] + }; + let second = parent[1][lane].mul_add(local[row][1], first); + result[row][lane] = parent[2][lane].mul_add(local[row][2], second); + } + } + result +} diff --git a/crates/skate-core/src/animation/output/intents.rs b/crates/skate-core/src/animation/output/intents.rs new file mode 100644 index 00000000..184f318b --- /dev/null +++ b/crates/skate-core/src/animation/output/intents.rs @@ -0,0 +1,155 @@ +//! Fixed intent-map specialization constructed by8258F638, used at full +//! MotionGraph+12 (82C0E088) and physics packet+8 (8258FEE8). +//! Names are six encoded words; no guessed text hashing or guest addresses. + +use crate::input::graph_intents::IntentMutation; + +pub const BUCKET_COUNT: usize = 61; +pub const ENTRY_CAPACITY: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IntentName(pub [u32; 6]); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Intent { + pub name: IntentName, + pub value: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IntentCapacityExceeded; + +/// Native construction takes greatest prime<=65 (61), installs64 nodes, then +/// changes maximum load to10000. Thus every valid state of this specialization +/// stays at61 buckets; generic rehash policy is unreachable before exhaustion. +/// Owned bucket vectors represent chains in traversal order. Allocator node +/// identities/free-list addresses are deliberately outside the domain model. +pub struct IntentMap { + buckets: [Vec; BUCKET_COUNT], + len: usize, +} + +impl Default for IntentMap { + fn default() -> Self { + Self { + buckets: std::array::from_fn(|_| Vec::new()), + len: 0, + } + } +} + +impl IntentMap { + pub fn len(&self) -> usize { + self.len + } + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn get(&self, name: IntentName) -> Option { + let hash = name.0.iter().fold(0u32, |sum, word| sum.wrapping_add(*word)); + self.buckets[hash as usize % BUCKET_COUNT] + .iter() + .find(|entry| entry.name == name) + .map(|entry| entry.value) + } + + ///826C20B0/825A0350: ascending buckets, head-to-tail within each chain. + pub fn entries(&self) -> impl Iterator { + self.buckets.iter().flat_map(|bucket| bucket.iter()) + } + + ///82472CD0/82472E10/824714D0: wrapping sum of all six words, exact + /// six-word equality, prepend new entries. Existing values are preserved. + /// At exhausted capacity native faults; the host rejects the new entry. + pub fn insert(&mut self, intent: Intent) -> Result { + let hash = intent + .name + .0 + .iter() + .fold(0u32, |sum, word| sum.wrapping_add(*word)); + let bucket = &mut self.buckets[hash as usize % BUCKET_COUNT]; + if bucket.iter().any(|entry| entry.name == intent.name) { + return Ok(false); + } + if self.len == ENTRY_CAPACITY { + return Err(IntentCapacityExceeded); + } + bucket.insert(0, intent); + self.len += 1; + Ok(true) + } + + /// Set the value at an existing native key, or insert a new key. The + /// native 82471818 lookup returns a value slot before the caller writes + /// it, so Set must overwrite an existing entry rather than silently + /// preserving it as `insert` does. + pub fn set(&mut self, name: IntentName, value: f32) -> Result { + let hash = name + .0 + .iter() + .fold(0u32, |sum, word| sum.wrapping_add(*word)); + let bucket = &mut self.buckets[hash as usize % BUCKET_COUNT]; + if let Some(entry) = bucket.iter_mut().find(|entry| entry.name == name) { + entry.value = value; + return Ok(false); + } + self.insert(Intent { name, value }) + } + + /// Remove one exact six-word key using the same bucket and chain order as + /// native 82BC1068. Removing an entry leaves the relative order of the + /// remaining chain untouched. + pub fn remove(&mut self, name: IntentName) -> bool { + let hash = name + .0 + .iter() + .fold(0u32, |sum, word| sum.wrapping_add(*word)); + let bucket = &mut self.buckets[hash as usize % BUCKET_COUNT]; + let Some(index) = bucket.iter().position(|entry| entry.name == name) else { + return false; + }; + bucket.remove(index); + self.len -= 1; + true + } + + /// Apply the graph host's existing mutation protocol at one decoded key. + /// `IntentName` is deliberately supplied by the graph decoder: this + /// module does not invent a string-to-six-word encoding. + pub fn apply( + &mut self, + name: IntentName, + mutation: IntentMutation, + ) -> Result { + match mutation { + IntentMutation::None => Ok(false), + IntentMutation::Remove => Ok(self.remove(name)), + IntentMutation::Set(value) => self.set(name, value), + } + } + + ///82BC1B68 clears all chains but preserves bucket configuration. + pub fn clear(&mut self) { + for bucket in &mut self.buckets { + bucket.clear(); + } + self.len = 0; + } + + ///825936FC..82593740: clear then range-insert, not a structural clone. + /// Each colliding source chain therefore reverses in the destination. + /// Canonical graph and packet are distinct owned maps. A caller binding a + /// map to itself must retain it instead, matching the native identity guard. + pub fn replace_from(&mut self, source: &Self) { + self.clear(); + for &entry in source.entries() { + // Both maps enforce the same64-entry bound and exact key uniqueness. + self.insert(entry).expect("bounded intent map assignment"); + } + } +} + +#[cfg(test)] +#[path = "tests/intents.rs"] +mod tests; diff --git a/crates/skate-core/src/animation/output/mod.rs b/crates/skate-core/src/animation/output/mod.rs new file mode 100644 index 00000000..ed1c7371 --- /dev/null +++ b/crates/skate-core/src/animation/output/mod.rs @@ -0,0 +1,27 @@ +//! Native TU3 pose-output kernels. These do not evaluate clips or choose graphs. +pub mod actor_packet; +pub mod attributes; +mod hierarchy; +pub mod intents; +pub mod motion_graph_packet; +pub mod packet_reset; +pub mod physics_packet; +pub mod setup_physics; +mod sqt; + +pub use hierarchy::{compose_hierarchy, compose_hierarchy_in_place}; +pub use sqt::{Sqt, sqt_to_local, sqt_to_matrix}; + +/// Four consecutive native vectors, including all packed fourth lanes. +/// Row/column conversion for a renderer belongs outside this module. +pub type NativeMatrix = [[f32; 4]; 4]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PoseBufferError { + CountOverflow, + ShortInput, + ShortOutput, + ShortParents, + InvalidCacheOffset, + InvalidParent { bone: usize, parent: i32 }, +} diff --git a/crates/skate-core/src/animation/output/motion_graph_packet.rs b/crates/skate-core/src/animation/output/motion_graph_packet.rs new file mode 100644 index 00000000..1862ebf8 --- /dev/null +++ b/crates/skate-core/src/animation/output/motion_graph_packet.rs @@ -0,0 +1,45 @@ +//! Concrete SkaterMotionGraph::GetPhysUpdateData82595FA8 and copy82596008. +//! Actor construction82590DC0 ->8258F488 installs base vtable823006D0; +//! slot36 resolves82595FA8. Unlike the reviewed Skate2 counterpart, this +//! Skate3 body does not reset trick, gesture or wipeout source state afterward. + +/// Native name storage at the data boundary; no addresses or host pointers. +/// Exact text/hash member interpretation is a separate string-format contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TrickIdentifier(pub [u32; 6]); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TrickData { + pub identifiers: [TrickIdentifier; 2], + /// Native trick record+48. Its producer/enum meaning is not recovered here. + pub word48: u32, + /// Native record+52/+56; copied by lfs/stfs. + pub scalars52_56: [f32; 2], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GestureData { + pub gesture: u32, + /// The native body copies the complete second word, including unused bytes. + pub flag_bytes: [u8; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MotionGraphPacket { + /// Source MG+5924 -> physics packet+10392,60 bytes. + pub trick: TrickData, + /// MG+5984/+5988 -> packet+10452/+10456. + pub gesture: GestureData, + /// MG+5992/+5996 -> packet+10460/+10464; complete words are copied. + pub wipeout_gesture: [f32; 2], +} + +/// Complete concrete publication. Timestep is unused by82595FA8; no source +/// request is consumed, and repeated publication copies the same state. +pub fn publish(source: &MotionGraphPacket, destination: &mut MotionGraphPacket) { + destination.trick = source.trick; + destination.gesture.gesture = source.gesture.gesture; + destination.gesture.flag_bytes = source.gesture.flag_bytes; + destination.wipeout_gesture[0] = source.wipeout_gesture[0]; + destination.wipeout_gesture[1] = source.wipeout_gesture[1]; +} diff --git a/crates/skate-core/src/animation/output/packet_reset.rs b/crates/skate-core/src/animation/output/packet_reset.rs new file mode 100644 index 00000000..2275e96d --- /dev/null +++ b/crates/skate-core/src/animation/output/packet_reset.rs @@ -0,0 +1,95 @@ +//! AnimOutPhysIn::Reset, TU3 82590028. +//! Reset is selective: it preserves storage, count, intents, attributes and all +//! fields not listed below. Actor::SetUpPhysics owns the later list replacement. +use super::{NativeMatrix, physics_packet::PhysicsPosePacket}; +use crate::animation::commands::buffers::BufferError; + +/// Additional fields written by Reset, kept separate from the existing pose +/// publication view. Unknown flags retain their verified producer/offset names. +pub struct AdditionalResetFields { + /// 10388; 10912/10916. + pub compression: f32, + pub foot_ik_influence: [f32; 2], + /// 10369. Position itself is preserved by Reset. + pub next_step_position_valid: bool, + /// 10375/10376; overwritten from these Actor bits during SetUpPhysics. + pub actor_flag_1904_bit23: bool, + pub actor_flag_1908_bit2: bool, + /// 10496,10688,10689: active markers only; payloads are not cleared here. + pub external_impulse_active: bool, + pub external_physics_input_active: bool, + pub externally_controlled: bool, + /// 10786/10787/10796. + pub prevent_manual_respawn: bool, + pub ignore_respawn_reset_button: u8, + pub force_braking: bool, + /// 10788/10792. Named source fields, same target scalar reset/caller roles. + pub truck_tightness: f32, + pub wheel_hardness: f32, + /// Six complete native vectors at10816,10832,10848,10864,10880,10896. + /// Individual target semantics remain unassigned; all fourth lanes reset. + pub auxiliary_vectors: [[f32; 4]; 6], + /// 10928, later overwritten by the Actor's selected physics settings. + pub requested_physics_mode: u32, +} + +/// Native packed basis, NOT a conventional homogeneous 4x4 identity. +/// Three static vectors at82139A10/20/30 plus an all-zero vector produced +/// inside Reset. All four fourth lanes, including translation W, are zero. +pub const RESET_POSE: NativeMatrix = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0; 4], +]; + +/// Complete field coverage for82590028 on valid packet allocations. Host-side +/// extent checks precede mutation; native assumes allocations match the count. +/// Existing vector lengths/capacities and bones past packet.bone_count remain. +pub fn reset( + packet: &mut PhysicsPosePacket, + fields: &mut AdditionalResetFields, +) -> Result<(), BufferError> { + // Both native loop comparisons are signed. A high-bit count skips the + // matrix loop while scalar/flag resets still run. + let count = (packet.bone_count as i32).max(0) as usize; + if packet.hierarchy.len() < count || packet.local.len() < count { + return Err(BufferError::RangeOutsideAllocation); + } + packet.timestep = f32::from_bits(0x3c88_8889); + fields.external_impulse_active = false; + fields.compression = 0.5; + fields.foot_ik_influence[0] = 0.0; + fields.external_physics_input_active = false; + fields.foot_ik_influence[1] = 0.0; + fields.externally_controlled = false; + fields.truck_tightness = 0.0; + packet.mirrored = false; + packet.riding_switch = false; + packet.riding_fakie = false; + packet.weight_forwards = false; + packet.regular_stance = false; + fields.prevent_manual_respawn = false; + fields.ignore_respawn_reset_button = 0; + fields.force_braking = false; + packet.air_dismount_revert_frames = 0; + packet.board_flipped = false; + fields.next_step_position_valid = false; + fields.actor_flag_1904_bit23 = false; + packet.flags &= 0x013f_ffff; + fields.actor_flag_1908_bit2 = false; + packet.foot_surface_ids[0] = 0; + fields.wheel_hardness = 0.0; + packet.foot_surface_ids[1] = 0; + fields.auxiliary_vectors[0] = [0.0; 4]; + fields.requested_physics_mode = 1; + for vector in &mut fields.auxiliary_vectors[1..] { + *vector = [0.0; 4]; + } + for bone in 0..count { + // Native completes local first, then hierarchy, for each bone. + packet.local[bone] = RESET_POSE; + packet.hierarchy[bone] = RESET_POSE; + } + Ok(()) +} diff --git a/crates/skate-core/src/animation/output/physics_packet.rs b/crates/skate-core/src/animation/output/physics_packet.rs new file mode 100644 index 00000000..0fb4d9aa --- /dev/null +++ b/crates/skate-core/src/animation/output/physics_packet.rs @@ -0,0 +1,149 @@ +//! Concrete SkaterAnim::GetPhysUpdateData, TU3 82B985E8. +//! This owns only fields written by that function, not the full AnimOutPhysIn +//! reset, motion-graph merge, attribute dispatch, or physical force consumers. +use super::NativeMatrix; +use crate::animation::commands::{ + batch::CompletedSetData, + buffers::{BoneSlice, BufferError}, +}; + +/// Optional object at SkaterAnim+15104. The original class name is unresolved. +pub struct AnimationSignal { + pub name_hash: u32, + pub active: u8, +} + +/// Fields read or consumed from the concrete native SkaterAnim. Other object +/// fields remain with their owners. The two unknown requests are NOT push flags. +pub struct SkaterPublicationState { + /// Native packed flags+15180 bits31,30,29,28 respectively. + pub orientation_bit31: bool, + pub mirrored: bool, + pub riding_fakie: bool, + pub weight_on_nose: bool, + /// +15196 and +15188 are distinct enums. Only equality is tested here. + pub relative_stance: i32, + pub natural_stance: i32, + /// Native flags bits16 and15, consumed at publication. + pub request_bit16: bool, + pub request_bit15: bool, + /// Native flags bit20 and persistent count+15184. + pub air_dismount_revert_requested: bool, + pub air_dismount_revert_frames: i32, + pub signal: Option, +} + +pub struct ActorPoseBuffers { + /// Actor-owned hierarchy-global matrices, full SkaterAnim+13460. + pub hierarchy: BoneSlice, + /// Actor-owned local matrices, full SkaterAnim+13468. + pub local: BoneSlice, +} + +/// Physics-packet-owned arrays are separate from the animation allocations. +/// Count is packet+10380, which native publication reads without rewriting. +/// Unlisted packet fields must be preserved by the surrounding packet owner. +pub struct PhysicsPosePacket { + pub bone_count: u32, + pub hierarchy: Vec, + pub local: Vec, + pub timestep: f32, + pub foot_surface_ids: [u32; 2], + pub flags: u32, + pub board_flipped: bool, + pub mirrored: bool, + pub riding_switch: bool, + pub riding_fakie: bool, + pub weight_forwards: bool, + pub regular_stance: bool, + pub air_dismount_revert_frames: i32, +} + +/// Publish after the source worker completes. Before modifying state, this safe +/// adapter rejects invalid/uninitialized ranges; native assumes valid storage. +/// `signal_name` is the current string selected by native global830301E4 (the +/// inspected TU3 image selects "signup"). Supply it from verified startup data. +/// Concrete getters 82B970D8/82B97100/82B97128 are pure field reads here; +/// polymorphic replacement getters are outside this concrete SkaterAnim port. +pub fn publish( + state: &mut SkaterPublicationState, + completed: &CompletedSetData, + poses: &ActorPoseBuffers, + packet: &mut PhysicsPosePacket, + timestep: f64, + signal_name: &[u8], +) -> Result { + let count = packet.bone_count as usize; + if packet.hierarchy.len() < count || packet.local.len() < count { + return Err(BufferError::RangeOutsideAllocation); + } + let hierarchy = completed.buffers().matrices.read(poses.hierarchy, count)?; + let local = completed.buffers().matrices.read(poses.local, count)?; + + publish_evaluated(state, &hierarchy, &local, packet, timestep, signal_name) +} + +/// Same publication with host-owned evaluated matrices. Completing animation +/// evaluation before this call preserves the source dependency without +/// requiring the original job/cache allocation system in the game. +pub fn publish_evaluated( + state: &mut SkaterPublicationState, + hierarchy: &[NativeMatrix], + local: &[NativeMatrix], + packet: &mut PhysicsPosePacket, + timestep: f64, + signal_name: &[u8], +) -> Result { + let count=packet.bone_count as usize; + if hierarchy.len() u32 { + let mut hash = 0_u32; + for &byte in name.iter().take_while(|&&byte| byte != 0) { + hash = hash + .wrapping_shl(4) + .wrapping_add((byte as i8 as i32) as u32); + let high = hash & 0xf000_0000; + if high != 0 { + hash ^= (high >> 23) ^ high; + } + } + hash +} diff --git a/crates/skate-core/src/animation/output/setup_physics.rs b/crates/skate-core/src/animation/output/setup_physics.rs new file mode 100644 index 00000000..12c30fb0 --- /dev/null +++ b/crates/skate-core/src/animation/output/setup_physics.rs @@ -0,0 +1,100 @@ +//! Source-bound packet composition for concrete Actor::SetUpPhysics82593640. +//! The caller supplies the runtime services and completed materialized poses. +//! This is not the complete Actor update or Skeleton attribute interpreter. +use super::{ + actor_packet::{self, ActorPacketFields, ActorPublicationEnvironment, ActorPublicationState}, + attributes::{AnimationAttribute, MotionGraphAttribute, PacketAttributes}, + intents::IntentMap, + motion_graph_packet::{self, MotionGraphPacket}, + packet_reset::{self, AdditionalResetFields}, + physics_packet::{self, ActorPoseBuffers, PhysicsPosePacket, SkaterPublicationState}, +}; +use crate::animation::commands::{batch::CompletedSetData, buffers::BufferError}; + +/// Views cover fields touched by this concrete publication chain. Packet fields +/// only used by later Skeleton consumers are not fabricated here. +pub struct PhysicsInputPacket { + pub pose: PhysicsPosePacket, + pub reset: AdditionalResetFields, + pub motion_graph: MotionGraphPacket, + pub intents: IntentMap, + pub attributes: PacketAttributes, + pub actor: ActorPacketFields, +} + +pub struct AnimationPublication<'a> { + pub state: &'a mut SkaterPublicationState, + pub completed: &'a CompletedSetData, + pub poses: &'a ActorPoseBuffers, + pub signal_name: &'a [u8], + pub tree_attributes: &'a [AnimationAttribute], +} + +pub enum IntentPublication<'a> { + MotionGraph(&'a IntentMap), + /// Exact same-container branch825936FC; no clear/reinsert occurs. + RetainPacket, +} + +pub struct MotionGraphPublication<'a> { + pub state: &'a MotionGraphPacket, + pub intents: IntentPublication<'a>, + pub attributes: &'a [MotionGraphAttribute], +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PublicationError { + Pose(BufferError), + RuntimeService(E), +} + +///82DB3C48 only stores a borrowed packet pointer at physical+1808. This safe +/// binding keeps the entire owned packet alive and prevents its next mutation +/// while a consumer reads it. It does not execute the downstream physics step. +pub struct PhysicsPacketBinding<'a> { + packet: &'a PhysicsInputPacket, +} +impl<'a> PhysicsPacketBinding<'a> { + pub fn packet(&self) -> &'a PhysicsInputPacket { + self.packet + } +} + +/// Concrete canonical virtual144 is a no-op. Remaining phases execute in the +/// native order: reset, SkaterAnim, MG, intents, MG/tree attrs, actor suffix, +/// then packet binding. Failure preserves writes already made and returns no +/// binding; the caller must not consume the partially published packet. +pub fn compose<'a, E: ActorPublicationEnvironment>( + packet: &'a mut PhysicsInputPacket, + animation: AnimationPublication<'_>, + graph: MotionGraphPublication<'_>, + actor: &mut ActorPublicationState, + environment: &mut E, +) -> Result, PublicationError> { + packet_reset::reset(&mut packet.pose, &mut packet.reset).map_err(PublicationError::Pose)?; + physics_packet::publish( + animation.state, + animation.completed, + animation.poses, + &mut packet.pose, + f64::from(f32::from_bits(0x3c88_8889)), + animation.signal_name, + ) + .map_err(PublicationError::Pose)?; + motion_graph_packet::publish(graph.state, &mut packet.motion_graph); + if let IntentPublication::MotionGraph(source) = graph.intents { + packet.intents.replace_from(source); + } + packet + .attributes + .replace_from(graph.attributes, animation.tree_attributes); + actor_packet::publish( + actor, + &mut packet.pose, + &mut packet.reset, + &mut packet.actor, + environment, + ) + .map_err(PublicationError::RuntimeService)?; + Ok(PhysicsPacketBinding { packet }) +} diff --git a/crates/skate-core/src/animation/output/sqt.rs b/crates/skate-core/src/animation/output/sqt.rs new file mode 100644 index 00000000..bad5835f --- /dev/null +++ b/crates/skate-core/src/animation/output/sqt.rs @@ -0,0 +1,103 @@ +use super::{NativeMatrix, PoseBufferError}; + +/// TU3 48-byte SQT record: scale, quaternion (x,y,z,w), translation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Sqt { + pub scale: [f32; 4], + pub rotation: [f32; 4], + pub translation: [f32; 4], +} + +/// Complete arithmetic/store behavior of TU3 0x828D3800. +/// +/// The native SIMD loop writes four records at a time, including padding beyond +/// `bone_count`. The native `dcbzl` additionally zeros the following cache line; +/// `cache_offset` specifies the destination address modulo 128 (a multiple of 16). +/// Destination must include that trailing storage. No normalization or +/// bind-pose composition occurs. Unlike native speculative loads, this safe +/// adapter does not read a further unused group beyond the padded inputs. +pub fn sqt_to_local( + bone_count: usize, + cache_offset: u8, + source: &[Sqt], + destination: &mut [NativeMatrix], +) -> Result { + if cache_offset >= 128 || cache_offset % 16 != 0 { + return Err(PoseBufferError::InvalidCacheOffset); + } + let padded = bone_count + .checked_add(3) + .ok_or(PoseBufferError::CountOverflow)? + & !3; + if source.len() < padded { + return Err(PoseBufferError::ShortInput); + } + let trailing_words = if padded == 0 { + 0 + } else { + (128 - usize::from(cache_offset)) / 4 + }; + let required = padded + .checked_add(trailing_words.div_ceil(16)) + .ok_or(PoseBufferError::CountOverflow)?; + if destination.len() < required { + return Err(PoseBufferError::ShortOutput); + } + // Earlier zeroed lines are overwritten by later groups; only the final tail + // remains visible. No typed input/output aliasing is allowed by this adapter. + for word in destination[padded..required] + .iter_mut() + .flatten() + .flatten() + .take(trailing_words) + { + *word = 0.0; + } + for (input, output) in source[..padded].iter().zip(&mut destination[..padded]) { + *output = sqt_to_matrix(*input); + } + Ok(padded) +} + +/// Per-bone numerical result828D3800. The game owns its vector allocations; +/// cache-line padding and speculative DMA loads are not gameplay behavior. +pub fn sqt_to_matrix(input: Sqt) -> NativeMatrix { + let [x, y, z, w] = input.rotation; + let [sx, sy, sz, _] = input.scale; + // Separate products and sums reflect the native VMX instruction order. + let xx = x * x; + let yy = y * y; + let zz = z * z; + let xy = x * y; + let wz = w * z; + let xz = x * z; + let wy = w * y; + let yz = y * z; + let wx = w * x; + [ + [ + (-(yy + zz)).mul_add(2.0, 1.0) * sx, + ((xy + wz) * 2.0) * sx, + ((xz - wy) * 2.0) * sx, + 0.0, + ], + [ + ((xy - wz) * 2.0) * sy, + (-(xx + zz)).mul_add(2.0, 1.0) * sy, + ((yz + wx) * 2.0) * sy, + 0.0, + ], + [ + ((xz + wy) * 2.0) * sz, + ((yz - wx) * 2.0) * sz, + (-(xx + yy)).mul_add(2.0, 1.0) * sz, + 0.0, + ], + [ + input.translation[0], + input.translation[1], + input.translation[2], + 1.0, + ], + ] +} diff --git a/crates/skate-core/src/animation/output/tests/intents.rs b/crates/skate-core/src/animation/output/tests/intents.rs new file mode 100644 index 00000000..af8b9789 --- /dev/null +++ b/crates/skate-core/src/animation/output/tests/intents.rs @@ -0,0 +1,72 @@ +use super::{BUCKET_COUNT, ENTRY_CAPACITY, Intent, IntentMap, IntentName}; +use crate::input::graph_intents::IntentMutation; + +fn key(seed: u32) -> IntentName { + IntentName([seed, 0, 0, 0, 0, 0]) +} + +#[test] +fn set_overwrites_and_remove_preserves_other_chain_entries() { + let mut map = IntentMap::default(); + let first = key(1); + let second = key(1 + BUCKET_COUNT as u32); + assert!( + map.insert(Intent { + name: first, + value: 1.0 + }) + .unwrap() + ); + assert!( + map.insert(Intent { + name: second, + value: 2.0 + }) + .unwrap() + ); + assert!(!map.set(first, 7.0).unwrap()); + assert_eq!( + map.entries() + .find(|entry| entry.name == first) + .unwrap() + .value, + 7.0 + ); + assert!(map.remove(first)); + assert!(!map.remove(first)); + assert_eq!(map.len(), 1); + assert_eq!(map.entries().next().unwrap().name, second); +} + +#[test] +fn apply_uses_existing_mutation_protocol_and_capacity() { + let mut map = IntentMap::default(); + let name = key(42); + assert!(map.apply(name, IntentMutation::Set(3.0)).unwrap()); + assert!(!map.apply(name, IntentMutation::None).unwrap()); + assert!(!map.apply(name, IntentMutation::Set(4.0)).unwrap()); + assert_eq!(map.entries().next().unwrap().value, 4.0); + assert!(map.apply(name, IntentMutation::Remove).unwrap()); + assert_eq!(map.len(), 0); +} + +#[test] +fn insert_rejects_only_after_native_fixed_capacity() { + let mut map = IntentMap::default(); + for seed in 0..ENTRY_CAPACITY as u32 { + assert!( + map.insert(Intent { + name: key(seed), + value: seed as f32 + }) + .is_ok() + ); + } + assert!( + map.insert(Intent { + name: key(ENTRY_CAPACITY as u32), + value: 0.0 + }) + .is_err() + ); +} diff --git a/crates/skate-core/src/animation/phase_blend.rs b/crates/skate-core/src/animation/phase_blend.rs new file mode 100644 index 00000000..3152e99c --- /dev/null +++ b/crates/skate-core/src/animation/phase_blend.rs @@ -0,0 +1,133 @@ +//! Andale PhaseBlend, TU3 82D24C40..82D25808; construction 82D1B67C. +use super::{clip_clock::AdvanceResult, output::attributes::{AnimationAttribute, AttributeName, MotionGraphAttribute}, playback_attributes, playback_parameters::SettableAttribute, playback_tree::{Evaluation, PlaybackTree, PoseCommand}}; + +#[derive(Clone, Debug)] +pub struct PhaseBlend { + pub parameter: AttributeName, + pub children: Vec, + order: Vec, + values: Vec, + left: usize, + right: usize, + weight: f32, + parameter_value: f32, + normalized: bool, + cull_threshold: f32, + pub length: f32, + pub time: f32, +} +impl PhaseBlend { + pub fn new(parameter: AttributeName, children: Vec) -> Result { + if children.len() < 2 { return Err("PhaseBlend requires at least two authored children".into()); } + let count = children.len(); + Ok(Self { parameter, length: children[0].length(), children, order: (0..count).collect(), values: vec![0.0;count], left: 0, right: 1, weight: 0.0, parameter_value: f32::MAX, normalized: true, cull_threshold: 0.0, time: 0.0 }) + } + pub fn set_time(&mut self, time: f32) { + let phase = time/self.length; + let left_time = self.children[self.left].length()*phase; + let right_time = self.children[self.right].length()*phase; + self.children[self.left].set_time(left_time); + self.children[self.right].set_time(right_time); + self.time = time; + } + fn phase(&self) -> f32 { self.time/if -self.length >= 0.0 {1.0} else {self.length} } + fn update_length(&mut self, phase: f32) { + self.length = self.children[self.right].length().mul_add(self.weight,self.children[self.left].length()*(1.0-self.weight)); + self.set_time(self.length*phase); + } + pub fn set_speed(&mut self, speed: f32) { + let phase = self.phase(); + for &index in &self.order { self.children[index].set_speed(speed); } + self.update_length(phase); + } + pub fn advance(&mut self, dt: f32, phase: f32, property: &mut AdvanceResult) { + let left_length = self.children[self.left].length(); + let right_length = self.children[self.right].length(); + let fraction = dt/self.length; + self.children[self.left].advance(fraction*left_length,phase,property); + let mut discarded = AdvanceResult { crossed_end:false,overshoot:-1.0,remaining_before_wrap:-1.0 }; + self.children[self.right].advance(fraction*right_length,phase,&mut discarded); + let inverse_left = 1.0/left_length; + self.time = (self.children[self.left].time()*inverse_left)*self.length; + property.remaining_before_wrap = (property.remaining_before_wrap*inverse_left)*self.length; + property.overshoot = (inverse_left*self.length)*property.overshoot; + } + pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result { + let mut changed = false; + for &index in &self.order { changed |= self.children[index].set_attributes(attributes)?; } + if let Some(attribute) = attributes.iter().find(|a| a.name == self.parameter) { + if attribute.normalized != self.normalized || !((attribute.value-self.parameter_value).abs() < f32::from_bits(0x38d1b717)) { + self.parameter_value = attribute.value; + self.normalized = attribute.normalized; + changed = true; + } + } + if !changed { return Ok(false); } + for &index in &self.order { + let mut attribute = MotionGraphAttribute {name:self.parameter,value:0.0}.to_animation(); + // Native deliberately ignores success; partial output from nested + // GetAttribute remains visible, and a complete miss retains zero. + self.children[index].query_attribute(self.parameter,15,&mut attribute)?; + self.values[index] = f32::from_bits(attribute.payload.0[0].ok_or("Uninitialized PhaseBlend parameter")?); + } + for end in (1..self.order.len()).rev() { + for i in 0..end { + if self.values[self.order[i+1]] < self.values[self.order[i]] { self.order.swap(i,i+1); } + } + } + let minimum = self.values[self.order[0]]; + let maximum = self.values[*self.order.last().unwrap()]; + let target = if self.normalized {(maximum-minimum).mul_add(self.parameter_value,minimum)} else {self.parameter_value}; + let mut low = 0; + let mut count = self.order.len(); + while count != 0 { + let step = count/2; + let middle = low+step; + if self.values[self.order[middle]] < target { low=middle+1;count-=step+1; } else {count=step;} + } + let upper = low.clamp(1,self.order.len()-1); + let left = self.order[upper-1]; + let right = self.order[upper]; + let lower_value = self.values[left]; + let upper_value = self.values[right]; + let delta = upper_value-lower_value; + let lower = if lower_value-target >= 0.0 {lower_value} else {target}; + let clamped = if upper_value-lower >= 0.0 {lower} else {upper_value}; + let weight = (clamped-lower_value)/if delta == 0.0 {1.0} else {delta}; + let lower = if -weight >= 0.0 {0.0} else {weight}; + self.weight = if 1.0-lower >= 0.0 {lower} else {1.0}; + let phase = self.phase(); + self.left=left;self.right=right; + self.update_length(phase); + Ok(true) + } + pub fn attributes(&self, mask:u32) -> Result,String> { + if self.weight < self.cull_threshold {return self.children[self.left].attributes(mask);} + if self.weight > 1.0-self.cull_threshold {return self.children[self.right].attributes(mask);} + playback_attributes::intersection(self.children[self.left].attributes(mask)?,&self.children[self.right].attributes(31)?,self.weight) + } + pub fn query_attribute(&self, name:AttributeName,mask:u32,output:&mut AnimationAttribute) -> Result { + if self.weight < self.cull_threshold {return self.children[self.left].query_attribute(name,mask,output);} + if self.weight > 1.0-self.cull_threshold {return self.children[self.right].query_attribute(name,mask,output);} + let left = self.children[self.left].query_attribute(name,mask,output)?; + let mut right = MotionGraphAttribute {name,value:0.0}.to_animation(); + let found_right = self.children[self.right].query_attribute(name,31,&mut right)?; + if left && found_right {playback_attributes::blend(output,&right,self.weight)?;Ok(true)} else {Ok(false)} + } + pub fn evaluate(&mut self, parameters:Evaluation,enabled:bool,output:&mut Vec) -> Result { + self.cull_threshold = parameters.cull_threshold; + let c = parameters.cull_threshold; + let lower = if c-self.weight >= 0.0 {c} else {self.weight}; + let upper = 1.0-c; + let clamped = if upper-lower >= 0.0 {lower} else {upper}; + let t = (clamped-c)/(-c).mul_add(2.0,1.0); + let square = t*t; + let weight = if !(t>0.5) && self.left == self.order[0] {(-t).mul_add(4.0,4.0)*square} + else if t>0.5 && self.right == *self.order.last().unwrap() {let four=t*4.0;(8.0-four).mul_add(square,-four)+1.0} + else {self.weight}; + let left=self.children[self.left].evaluate(parameters,enabled && weight<1.0,output)?; + let right=self.children[self.right].evaluate(parameters,enabled && weight>0.0,output)?; + if left && right {output.push(PoseCommand::Blend {weight});} + Ok(left || right) + } +} diff --git a/crates/skate-core/src/animation/physical_feedback.rs b/crates/skate-core/src/animation/physical_feedback.rs new file mode 100644 index 00000000..ad90fff6 --- /dev/null +++ b/crates/skate-core/src/animation/physical_feedback.rs @@ -0,0 +1,125 @@ +//! Physical publications consumed by riding animation, TU3 82DB6EC0. +//! +//! This receives the actual board, Pumping, SpeedWobble and Reckoning results. +//! It does not reconstruct the skater frame from the deck or rendered pose. +use crate::{ + animation::{crouching, ground_acceleration}, + input::{set_turning, turn_conditioner}, + math::Vector3, + physics::{board_motion_output::BoardMotionOutput, native_arithmetic}, + riding::{pumping::state::PumpingState, speed_wobble::SpeedWobbleState}, +}; + +/// The completed Reckoning owner's output required by animation publication. +/// There is intentionally no default: these are distinct physical frames. +#[derive(Clone, Copy, Debug)] +pub struct ReckoningFeedback { + /// PhysOutSystemReckoning+64. + pub system_position: Vector3, + /// Reckoning+1152 -> PhysOutSystemReckoning+96 at82DB7068. + pub system_up: Vector3, + /// PhysOutDeck+48. + pub board_position: Vector3, + /// Reckoning+1576, before the Processed2468 bit20 sign correction. + pub target_lean_angle: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ControlFeedback { + /// The complete ProcessedPhysIn+2468 flag word. + pub processed_flags: u32, + /// ProcessedPhysIn+2676 -> PhysOutGround+264. + pub turn: f32, + /// AnimOutPhysIn+10370 -> PhysOutAnimation+156. + pub animation_mirrored: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct PhysicalFeedback { + pub turning: set_turning::Physical, + pub crouching: crouching::Physical, + pub pumping_acceleration: f32, + /// PhysOutAnimation112, conditioned actual deck acceleration82DF0508. + pub ground_acceleration: [f32; 4], + /// Original IsBumped82BA7310 evaluated with anim_motion/bumps. + pub bumped: bool, + /// Complete PhysOutAnimation32..60, retained for other native consumers. + pub conditioned_turn: turn_conditioner::Output, +} + +/// Postphysics publication followed by the animation turn conditioner82DEFF38. +/// Stateful filtering happens once per conditioner update, without a dt factor. +pub fn publish( + state: &mut turn_conditioner::State, + settings: &turn_conditioner::Settings, + motion: &BoardMotionOutput, + pumping: &PumpingState, + wobble: &SpeedWobbleState, + reckoning: ReckoningFeedback, + controls: ControlFeedback, + acceleration: ground_acceleration::Output, +) -> PhysicalFeedback { + let lean = if controls.processed_flags & 0x0010_0000 != 0 { + reckoning.target_lean_angle + } else { + -reckoning.target_lean_angle + }; + let turn = turn_conditioner::update( + state, + turn_conditioner::Input { + body_160: motion.speed, + // Skateboard::FillPhysOut82C03230..3238 reads Wobble+36. + body_176: f32::from_bits(wobble.0[5]), + bundle_36_field_160: lean, + bundle_32_field_264: controls.turn, + animation_152: u8::from(controls.processed_flags & 0x8000_0000 != 0), + animation_156: u8::from(controls.animation_mirrored), + }, + settings, + ); + let system = vector(reckoning.system_position); + let board = vector(reckoning.board_position); + //82DB7A38..7A54: vsubfp followed by vmsum3fp. The isolated native + // arithmetic retains its documented host approximation boundary. + let difference = core::array::from_fn(|lane| { + flush_subnormal(flush_subnormal(system[lane]) - flush_subnormal(board[lane])) + }); + let height = native_arithmetic::dot3(difference, vector(reckoning.system_up)); + PhysicalFeedback { + turning: set_turning::Physical { + field_32: turn[0], + field_36: turn[1], + field_52: turn[5], + field_56: turn[6], + field_60: turn[7], + body_168: motion.forward_speed, + }, + crouching: crouching::Physical { + body_84: motion.linear_velocity.y, + body_164: motion.ground_speed, + //82C02CF0 copies Pumping+44, named mPumpPotential in S2 FillPhysOut. + body_188: pumping.pumping, + force_516: pumping.absorption, + ground_force_520: pumping.ground_normal_absorption, + minimum_crouch_528: pumping.minimum_crouch, + deck_angle_532: pumping.deck_angle_absorption, + animation_height_72: height, + }, + pumping_acceleration: pumping.pump_acceleration, + ground_acceleration: acceleration.acceleration, + bumped: acceleration.bumped, + conditioned_turn: turn, + } +} + +fn vector(value: Vector3) -> [f32; 4] { + [value.x, value.y, value.z, 0.0] +} + +fn flush_subnormal(value: f32) -> f32 { + if value.to_bits() & 0x7f80_0000 == 0 { + f32::from_bits(value.to_bits() & 0x8000_0000) + } else { + value + } +} diff --git a/crates/skate-core/src/animation/playback.rs b/crates/skate-core/src/animation/playback.rs new file mode 100644 index 00000000..00d00cd6 --- /dev/null +++ b/crates/skate-core/src/animation/playback.rs @@ -0,0 +1,140 @@ +//! MotionGraph PlayAnimation ctor82BB4B38, Begin82BB5188, Update82BB5670. +//! End is the verified no-op82B61BB8; leaving a graph state does not stop a tree. +use super::{ + output::attributes::AttributeName, + playback_parameters::{AttributeSink, ParameterInputs, PlaybackParameter}, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TransitionSettings { + ///1 play,2 blend,3 channelblend,4 sequence. Zero means no graph override. + pub kind: u32, + pub seconds: f32, + pub under: u32, + ///0 default,1 current frame,2 match phase,3 match frame. + pub matching: u32, + pub use_channels_from_weights: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PlayAnimation { + pub animation: String, + pub switch_animation: Option, + pub mirror_animation: Option, + pub no_board_animation: Option, + pub playback_speed: f32, + pub apply_posture: bool, + pub transition: TransitionSettings, + pub parameters: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PlaybackRequest { + pub animation: String, + pub speed: f32, + pub start_time: f32, + pub transition: TransitionSettings, +} + +/// Values published by the actual skater-animation/physical interfaces. The +/// no-board bit is PhysOut+72's byte311, not a contact-derived guess. +pub struct PlaybackContext { + pub is_switch: Option, + pub is_mirrored: Option, + pub board_available: Option, + pub pro_skater: AttributeName, + pub transition_override: Option, +} + +pub trait PlaybackService: ParameterInputs + AttributeSink { + fn set_construction_value(&mut self, name: AttributeName, value: AttributeName); + fn set_posture_enabled(&mut self, enabled: bool); + /// Return false only when the native controller's Play/Blend/Sequence + /// request fails. Unimplemented evaluation is an error, never false. + fn play(&mut self, request: PlaybackRequest) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct PlayAnimationInstance { + first_update: bool, +} +impl PlayAnimationInstance { + pub fn begin( + &mut self, + operation: &PlayAnimation, + context: &mut PlaybackContext, + service: &mut impl PlaybackService, + ) -> Result<(), String> { + self.first_update = true; + refresh_parameters(&operation.parameters, true, service)?; + service.set_construction_value( + super::skeleton_input::name::encode(b"ProSkater"), context.pro_skater, + ); + // The graph override is consumed/reset before variant choice or play. + let transition = context.transition_override.take() + .filter(|settings| settings.kind != 0) + .unwrap_or(operation.transition); + let mut animation = &operation.animation; + if let Some(switch) = &operation.switch_animation { + if context.is_switch.ok_or("PlayAnimation needs skater switch state")? { + animation = switch; + } + } + if let Some(mirror) = &operation.mirror_animation { + if context.is_mirrored.ok_or("PlayAnimation needs skater mirror state")? { + animation = mirror; + } + } + if let Some(no_board) = &operation.no_board_animation { + if !context.board_available.ok_or("PlayAnimation needs PhysOut board availability")? { + animation = no_board; + } + } + service.set_posture_enabled(operation.apply_posture); + let played = if (1..=4).contains(&transition.kind) { + service.play(PlaybackRequest { + animation: animation.clone(), speed: operation.playback_speed, + start_time: 0.0, transition, + })? + } else { false }; + if !played { + // This fallback is present in native Begin82BB5630..5660. The + // service must not use it to conceal missing tree implementations. + service.play(PlaybackRequest { + animation: "KeepDefaultAnim".into(), speed: 1.0, start_time: 0.0, + transition: TransitionSettings { kind: 1, ..operation.transition }, + })?; + } + Ok(()) + } + + pub fn update( + &mut self, operation: &PlayAnimation, service: &mut impl PlaybackService, + ) -> Result<(), String> { + if !self.first_update { + refresh_parameters(&operation.parameters, false, service)?; + } + self.first_update = false; + Ok(()) + } + pub fn end(&mut self) {} +} + +fn refresh_parameters( + parameters: &[PlaybackParameter], beginning: bool, service: &mut impl PlaybackService, +) -> Result<(), String> { + // Keep read/write effects interleaved across parameters while avoiding two + // mutable borrows of the same concrete animation service. + struct One(Option); + impl AttributeSink for One { + fn set_attribute(&mut self, value: super::playback_parameters::SettableAttribute) { + self.0 = Some(value); + } + } + for parameter in parameters { + let mut output = One(None); + parameter.update(beginning, service, &mut output)?; + if let Some(attribute) = output.0 { service.set_attribute(attribute); } + } + Ok(()) +} diff --git a/crates/skate-core/src/animation/playback_attributes.rs b/crates/skate-core/src/animation/playback_attributes.rs new file mode 100644 index 00000000..aefce9ac --- /dev/null +++ b/crates/skate-core/src/animation/playback_attributes.rs @@ -0,0 +1,64 @@ +//! PhaseBlend attribute intersection82D161B0 and Scale/AddWeighted82D16370/428. +use super::output::attributes::{AnimationAttribute, AttributeName}; + +/// Bone-name mapping supplied by the stock hierarchy. A native excluded bone +/// (mirror index -1) maps to itself; missing names remain a data error. +#[derive(Clone, Debug)] +pub struct AttributeMirror(pub Vec<(AttributeName, AttributeName)>); +impl AttributeMirror { + /// Attribute::Mirror82530F48 changes payload identity, never the event key. + pub fn apply(&self, attribute: &mut AnimationAttribute) -> Result<(), String> { + match attribute.kind { + 1 => { + let x = attribute.payload.0[0].ok_or("Uninitialized mirrored vector attribute")?; + attribute.payload.0[0] = Some(x ^ 0x8000_0000); + } + 3 => { + let mut name = [0; 5]; + for (i, word) in name.iter_mut().enumerate() { + *word = attribute.payload.0[i].ok_or("Uninitialized mirrored bone reference")?; + } + let mirrored = self.0.iter().find(|(source, _)| source.0 == name) + .map(|(_, target)| target).ok_or("Animation event bone is absent from its hierarchy")?; + for (target, source) in attribute.payload.0.iter_mut().zip(mirrored.0) { *target = Some(source); } + } + _ => {} + } + Ok(()) + } +} + +pub fn blend(left: &mut AnimationAttribute, right: &AnimationAttribute, weight: f32) -> Result<(), String> { + let left_weight = 1.0-weight; + if left.status & 2 != 0 { + left.begin_time = -1.0; + left.end_time = -1.0; + } else { + // Time fields use separate multiply/add instructions, not fused math. + left.begin_time = right.begin_time * weight + left.begin_time * left_weight; + left.end_time = right.end_time * weight + left.end_time * left_weight; + } + let lanes: &[usize] = match left.kind { 0 | 2 => &[0], 1 => &[0,1,2,3], 3 => &[5], _ => &[] }; + for &i in lanes { + let l = f32::from_bits(left.payload.0[i].ok_or("Uninitialized blended attribute payload")?); + let r = f32::from_bits(right.payload.0[i].ok_or("Uninitialized blended attribute payload")?); + left.payload.0[i] = Some(r.mul_add(weight,l*left_weight).to_bits()); + } + Ok(()) +} + +/// Native uses a forward-only intersection, retaining left order/metadata. +/// It does not append right-only attributes or replace the left reference name. +pub fn intersection(left: Vec, right: &[AnimationAttribute], weight: f32) -> Result, String> { + let mut result = Vec::new(); + let mut next = 0; + for mut attribute in left { + while next < right.len() && right[next].name.0 < attribute.name.0 { next += 1; } + let Some(other) = right.get(next) else { break; }; + if other.name == attribute.name { + blend(&mut attribute, other, weight)?; + result.push(attribute); + } + } + Ok(result) +} diff --git a/crates/skate-core/src/animation/playback_clip.rs b/crates/skate-core/src/animation/playback_clip.rs new file mode 100644 index 00000000..f9de4c9f --- /dev/null +++ b/crates/skate-core/src/animation/playback_clip.rs @@ -0,0 +1,106 @@ +//! Stateful direct Andale Clip clock and attribute evaluation. +//! Init827B8AB0, AddTime82D25A00, GetAttributes82D25E30, +//! Attribute::Init82D164F8 and scalar curve lookup82D193E0. +use super::{clip_clock::{AdvanceResult, ClipClock}, output::attributes::{AnimationAttribute, AttributeName, AttributePayload}}; + +#[derive(Clone, Debug)] +pub struct ClipAttribute { + pub name: AttributeName, + pub kind: u8, + pub begin: f32, + pub end: f32, + pub payload: Vec, +} + +#[derive(Clone, Debug)] +pub struct PlaybackClip { + pub clock: ClipClock, + pub attributes: Vec, +} +impl PlaybackClip { + pub fn new(frames: f32, fps: f32, base_speed: f32, flags: u32, attributes: Vec) -> Self { + Self { clock: ClipClock { + frames, fps, base_speed, speed: 1.0, + length: (frames - 1.0) / ((1.0 * base_speed) * fps), + time: 0.0, previous_time: 0.0, loops_since_evaluation: 0, + looping: flags & 0x1000_0000 != 0, + phase_controlled: flags & 0x4000_0000 != 0, + }, attributes } + } + /// Animatable825310F0 resets the property before every AddTime call. + pub fn advance(&mut self, dt: f32, phase: f32) -> AdvanceResult { + let mut result = AdvanceResult { + crossed_end: false, overshoot: -1.0, remaining_before_wrap: -1.0, + }; + self.clock.advance(dt, phase, &mut result); + result + } + /// Ordered GetAttributes. The untimed bulk path passes status4, unlike + /// GetAttributeStatus's untimed status6; its times are multiplied by length. + pub fn attributes(&self, mask: u32) -> Result, String> { + let mut output = Vec::new(); + for attribute in &self.attributes { + let status = if attribute.begin == -1.0 { 4 } + else { self.clock.attribute_status(attribute.begin, attribute.end) }; + if attribute.begin != -1.0 && (mask & 0x1c & u32::from(status) == 0 || mask & 3 & u32::from(status) == 0) { continue; } + output.push(self.materialize(attribute, status)?); + } + Ok(output) + } + ///82D25CA0 uses status6 for untimed records and returns first valid match. + pub fn attribute(&self, name: AttributeName, mask: u32) -> Result, String> { + for attribute in &self.attributes { + if attribute.name != name { continue; } + let status = self.clock.attribute_status(attribute.begin, attribute.end); + if mask & 0x1c & u32::from(status) == 0 || mask & 3 & u32::from(status) == 0 { continue; } + return self.materialize(attribute, status).map(Some); + } + Ok(None) + } + fn materialize(&self, attribute: &ClipAttribute, status: u8) -> Result { + let mut payload = [None; 6]; + let lanes = match attribute.kind { 0 => 1, 1 => 4, 3 => 6, _ => 0 }; + if attribute.payload.len() < lanes { return Err("Truncated clip attribute payload".into()); } + for i in 0..lanes { payload[i] = Some(attribute.payload[i]); } + if attribute.kind == 2 { + // The source deliberately multiplies time*fps*playback_speed; + // base_speed is not an additional factor in Attribute::Init. + let frame = (self.clock.time * self.clock.fps) * self.clock.speed; + payload[0] = Some(sample_curve(&attribute.payload, frame)?.to_bits()); + } + Ok(AnimationAttribute { + name: attribute.name, kind: attribute.kind, payload: AttributePayload(payload), + status, sequence_id: -1, + begin_time: if status & 2 != 0 { -1.0 } else { attribute.begin * self.clock.length }, + end_time: if status & 2 != 0 { -1.0 } else { attribute.end * self.clock.length }, + }) + } +} + +/// The curve record is an opaque word, point count, then(x,y) pairs. Native +/// binary search interpolates the final crossed bounds with a fused multiply. +pub fn sample_curve(words: &[u32], time: f32) -> Result { + let count = *words.get(1).ok_or("Missing animation curve count")? as usize; + if count == 0 || count > words.len().saturating_sub(2) / 2 { return Err("Truncated/empty animation curve".into()); } + let point = |index: usize| (f32::from_bits(words[2+2*index]), f32::from_bits(words[3+2*index])); + let mut low = 0; + let mut high = count-1; + let (mut low_x, mut low_y) = point(low); + let (mut high_x, mut high_y) = point(high); + if !(time > low_x) { return Ok(low_y); } + if !(time < high_x) { return Ok(high_y); } + while !(low_x > high_x) { + let middle = (low+high)/2; + let (x,y) = point(middle); + if time > x { + low = middle+1; + if low >= count { return Err("Malformed animation curve search bounds".into()); } + (low_x,low_y) = point(low); + } else if time < x { + high = middle.checked_sub(1).ok_or("Malformed animation curve search bounds")?; + (high_x,high_y) = point(high); + } else { return Ok(y); } + } + let slope = (low_y-high_y)/(low_x-high_x); + Ok(slope.mul_add(time-high_x,high_y)) +} diff --git a/crates/skate-core/src/animation/playback_parameters.rs b/crates/skate-core/src/animation/playback_parameters.rs new file mode 100644 index 00000000..fff8f2ae --- /dev/null +++ b/crates/skate-core/src/animation/playback_parameters.rs @@ -0,0 +1,121 @@ +//! PlayAnimation's parameter refresh82BB4760 and settable queue82D19100. +use super::output::attributes::{AnimationAttribute, AttributeName}; + +#[derive(Clone, Debug, PartialEq)] +pub enum ParameterSource { + MotionIntent(String), + FilteredIntent(String), + LastAnimation(AttributeName), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PlaybackParameter { + pub source: ParameterSource, + pub rename: Option, + pub default_value: Option, + pub normalized: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SettableAttribute { + pub name: AttributeName, + pub value: f32, + pub normalized: bool, + pub sequence_id: i32, +} + +/// Gameplay providers are queried when a node executes, preserving visibility +/// of earlier same-tick graph operations and the last animation tree output. +pub trait ParameterInputs { + fn motion_intent(&self, name: &str) -> Option; + fn filtered_intent(&self, name: &str) -> Option; + fn last_attribute(&mut self, name: AttributeName) -> Result, String>; +} + +pub trait AttributeSink { + fn set_attribute(&mut self, attribute: SettableAttribute); +} + +#[derive(Clone, Debug, Default)] +pub struct SettableAttributes { + entries: Vec, +} +impl SettableAttributes { + pub fn entries(&self) -> &[SettableAttribute] { + &self.entries + } + pub fn clear(&mut self) { + self.entries.clear(); + } +} +impl AttributeSink for SettableAttributes { + fn set_attribute(&mut self, attribute: SettableAttribute) { + if let Some(entry) = self.entries.iter_mut().find(|entry| { + entry.name == attribute.name && entry.sequence_id == attribute.sequence_id + }) { + *entry = attribute; + } else { + self.entries.push(attribute); + } + } +} + +impl PlaybackParameter { + /// Last-animation parameters execute only on Begin. Existing non-scalar + /// attributes suppress the default too: native does not reinterpret them. + pub fn update( + &self, + beginning: bool, + inputs: &mut impl ParameterInputs, + output: &mut impl AttributeSink, + ) -> Result<(), String> { + use super::skeleton_input::name::encode; + let (name, value, normalized) = match &self.source { + ParameterSource::MotionIntent(source) => ( + self.rename.unwrap_or_else(|| encode(source.as_bytes())), + inputs.motion_intent(source).or(self.default_value), + self.normalized, + ), + ParameterSource::FilteredIntent(source) => { + let found = inputs.filtered_intent(source); + ( + self.rename.unwrap_or_else(|| encode(source.as_bytes())), + found.or(self.default_value), + //82BB4908..491C goes through4AF8 and explicitly passes0. + found.is_some() && self.normalized, + ) + } + ParameterSource::LastAnimation(source) => { + if !beginning { + return Ok(()); + } + let value = match inputs.last_attribute(*source)? { + Some(attribute) if matches!(attribute.kind, 0 | 2) => Some( + f32::from_bits(attribute.payload.0[0] + .ok_or("Last animation scalar payload is uninitialized")?), + ), + Some(_) => return Ok(()), + None => self.default_value, + }; + (self.rename.unwrap_or(*source), value, false) + } + }; + if let Some(value) = value { + output.set_attribute(SettableAttribute { + name, value, normalized, sequence_id: -1, + }); + } + Ok(()) + } +} + +/// Six-word intent/animation identifiers use the same native six-character +/// chunks as the five-word attribute encoder, but retain up to36 input bytes. +pub fn intent_key(text: &str) -> [u32; 6] { + let bytes = text.as_bytes(); + let first = super::skeleton_input::name::encode(bytes).0; + let tail = if bytes.len() > 30 && !bytes[..30].contains(&0) { + super::skeleton_input::name::encode(&bytes[30..]).0[0] + } else { 0 }; + [first[0], first[1], first[2], first[3], first[4], tail] +} diff --git a/crates/skate-core/src/animation/playback_transition.rs b/crates/skate-core/src/animation/playback_transition.rs new file mode 100644 index 00000000..d57ecd96 --- /dev/null +++ b/crates/skate-core/src/animation/playback_transition.rs @@ -0,0 +1,66 @@ +//! TU3 Sequence82B95C18 and Blend82B96058/82B961F8/82B963D8. +use super::{clip_clock::AdvanceResult, output::attributes::{AnimationAttribute,AttributeName}, playback::TransitionSettings, playback_parameters::SettableAttribute, playback_tree::{Evaluation,PlaybackTree,PoseCommand}}; + +#[derive(Clone,Debug)] +pub struct PlaybackTransition { + pub from:Box, + pub to:Box, + pub settings:TransitionSettings, + elapsed:f32, + sequence_complete:bool, +} +impl PlaybackTransition { + pub fn new(from:PlaybackTree,to:PlaybackTree,settings:TransitionSettings)->Self { + Self {from:Box::new(from),to:Box::new(to),settings,elapsed:0.0,sequence_complete:false} + } + pub fn complete(&self)->bool { + if self.settings.kind==4 {self.sequence_complete} else {self.elapsed>self.settings.seconds} + } + pub fn advance(&mut self,dt:f32,phase:f32,property:&mut AdvanceResult) { + let mut discarded=AdvanceResult {crossed_end:false,overshoot:-1.0,remaining_before_wrap:-1.0}; + if self.settings.kind==4 { + if self.sequence_complete {self.to.advance(dt,phase,property);} + else { + self.from.advance(dt,phase,&mut discarded); + if discarded.crossed_end {self.sequence_complete=true;self.to.advance(discarded.overshoot,phase,property);} + else {property.crossed_end=false;property.remaining_before_wrap=self.to.length()+discarded.remaining_before_wrap;} + } + return; + } + if self.settings.matching!=1 {self.from.advance(dt,phase,&mut discarded);} + if self.settings.matching==3 { + let length=self.to.length();let time=self.from.time(); + self.to.set_time(if time-length>=0.0 {length} else {time}); + } else if self.settings.matching==2 && self.from.length()>0.0 { + self.to.set_time(self.to.length()*(self.from.time()/self.from.length())); + } else {self.to.advance(dt,phase,property);} + self.elapsed+=dt; + } + pub fn set_attributes(&mut self,attributes:&[SettableAttribute])->Result { + let result=self.to.set_attributes(attributes)?; + if self.settings.kind==4 && !self.sequence_complete {self.from.set_attributes(attributes)} else {Ok(result)} + } + fn attributes_child(&self)->&PlaybackTree { + if self.settings.kind==4 && !self.sequence_complete {&self.from} else {&self.to} + } + pub fn attributes(&self,mask:u32)->Result,String> {self.attributes_child().attributes(mask)} + pub fn query_attribute(&self,name:AttributeName,mask:u32,output:&mut AnimationAttribute)->Result {self.attributes_child().query_attribute(name,mask,output)} + pub fn weight(&self)->f32 { + let value=self.elapsed/self.settings.seconds; + let lower=if -value>=0.0 {0.0} else {value}; + let weight=if 1.0-lower>=0.0 {lower} else {1.0}; + if self.settings.seconds1.0 || weight<0.0 {return weight;} + let square=weight*weight;let cube=square*weight; + square.mul_add(3.0,-(cube*2.0)) + } + pub fn evaluate(&mut self,parameters:Evaluation,enabled:bool,output:&mut Vec)->Result { + if self.settings.kind==4 {return if self.sequence_complete {self.to.evaluate(parameters,enabled,output)} else {self.from.evaluate(parameters,enabled,output)};} + let weight=self.weight(); + let from=self.from.evaluate(parameters,enabled,output)?; + let to=self.to.evaluate(parameters,enabled,output)?; + if enabled && from && to { + output.push(if self.settings.kind==3 {PoseCommand::ChannelBlend {weight,use_channels_from_weights:self.settings.use_channels_from_weights}} else {PoseCommand::Blend {weight}}); + } + Ok(from||to) + } +} diff --git a/crates/skate-core/src/animation/playback_tree.rs b/crates/skate-core/src/animation/playback_tree.rs new file mode 100644 index 00000000..44f4e1a3 --- /dev/null +++ b/crates/skate-core/src/animation/playback_tree.rs @@ -0,0 +1,278 @@ +//! Owned animation tree evaluation; stock data constructs this topology. +pub mod selection_space; +use super::{ + clip_clock::AdvanceResult, + output::attributes::{AnimationAttribute, AttributeName}, + phase_blend::PhaseBlend, + playback_clip::PlaybackClip, + playback_parameters::SettableAttribute, +}; + +#[derive(Clone, Debug)] +pub enum PlaybackTree { + Clip { + name: String, + clip: PlaybackClip, + }, + PhaseBlend(PhaseBlend), + SelectionSpace(selection_space::SelectionSpace), + Transition(super::playback_transition::PlaybackTransition), + ///SkaterAnim::AddBindPose82B98118, latched when a named tree is created. + BindPose { + motion: Box, + posture: Option, + board_backwards: bool, + mirror_modes: Vec, + attribute_mirror: std::sync::Arc, + }, +} +#[derive(Clone, Debug, PartialEq)] +pub enum PoseCommand { + ///82D25B00 supplies both bounded source times and the unconsumed wrap count. + Clip { + name: String, + previous_time: f32, + time: f32, + loops: u32, + }, + ///Blend the two most recent subtree poses, preserving authored nesting. + Blend { + weight: f32, + }, + ChannelBlend { + weight: f32, + use_channels_from_weights: bool, + }, + Pose { + name: String, + }, + Add { + motion_is_a: bool, + }, + Mirror { + trajectory_mode: u32, + }, +} +#[derive(Clone, Copy, Debug)] +pub struct Evaluation { + pub cull_threshold: f32, + pub update_history: bool, +} +impl PlaybackTree { + pub fn length(&self) -> f32 { + match self { + Self::Clip { clip, .. } => clip.clock.length, + Self::PhaseBlend(tree) => tree.length, + Self::SelectionSpace(tree) => tree.current().map_or(0.0, Self::length), + Self::Transition(tree) => tree.to.length(), + Self::BindPose { motion, .. } => motion.length(), + } + } + pub fn time(&self) -> f32 { + match self { + Self::Clip { clip, .. } => clip.clock.sample_time(), + Self::PhaseBlend(tree) => tree.time, + Self::SelectionSpace(tree) => tree.current().map_or(0.0, Self::time), + Self::Transition(tree) => tree.to.time(), + Self::BindPose { motion, .. } => motion.time(), + } + } + pub fn set_time(&mut self, time: f32) { + match self { + Self::Clip { clip, .. } => clip.clock.set_time(time), + Self::PhaseBlend(tree) => tree.set_time(time), + Self::SelectionSpace(tree) => tree.set_time(time), + Self::Transition(tree) => tree.to.set_time(time), + Self::BindPose { motion, .. } => motion.set_time(time), + } + } + pub fn set_speed(&mut self, speed: f32) { + match self { + Self::Clip { clip, .. } => clip.clock.set_speed(speed), + Self::PhaseBlend(tree) => tree.set_speed(speed), + Self::SelectionSpace(tree) => tree.set_speed(speed), + Self::Transition(tree) => tree.to.set_speed(speed), + Self::BindPose { motion, .. } => motion.set_speed(speed), + } + } + pub fn advance(&mut self, dt: f32, phase: f32, property: &mut AdvanceResult) { + match self { + Self::Clip { clip, .. } => clip.clock.advance(dt, phase, property), + Self::PhaseBlend(tree) => tree.advance(dt, phase, property), + Self::SelectionSpace(tree) => { + if let Some(child) = tree.current_mut() { + child.advance(dt, phase, property); + } + } + Self::Transition(tree) => tree.advance(dt, phase, property), + Self::BindPose { motion, .. } => motion.advance(dt, phase, property), + } + } + pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result { + match self { + Self::Clip { .. } => Ok(false), + Self::PhaseBlend(tree) => tree.set_attributes(attributes), + Self::SelectionSpace(tree) => tree.set_attributes(attributes), + Self::Transition(tree) => tree.set_attributes(attributes), + Self::BindPose { motion, .. } => motion.set_attributes(attributes), + } + } + pub fn attributes(&self, mask: u32) -> Result, String> { + match self { + Self::Clip { clip, .. } => clip.attributes(mask), + Self::PhaseBlend(tree) => tree.attributes(mask), + Self::SelectionSpace(tree) => tree + .current() + .ok_or("SelectionSpace attributes requested before native selection")? + .attributes(mask), + Self::Transition(tree) => tree.attributes(mask), + Self::BindPose { + motion, + mirror_modes, + attribute_mirror, + .. + } => { + let mut attributes = motion.attributes(mask)?; + for _ in mirror_modes { + for attribute in &mut attributes { + attribute_mirror.apply(attribute)?; + } + } + Ok(attributes) + } + } + } + pub fn attribute( + &self, + name: AttributeName, + mask: u32, + ) -> Result, String> { + let mut output = + super::output::attributes::MotionGraphAttribute { name, value: 0.0 }.to_animation(); + Ok(self + .query_attribute(name, mask, &mut output)? + .then_some(output)) + } + pub fn query_attribute( + &self, + name: AttributeName, + mask: u32, + output: &mut AnimationAttribute, + ) -> Result { + match self { + Self::PhaseBlend(tree) => tree.query_attribute(name, mask, output), + Self::SelectionSpace(tree) => tree + .current() + .ok_or("SelectionSpace attribute requested before native selection")? + .query_attribute(name, mask, output), + Self::Transition(tree) => tree.query_attribute(name, mask, output), + Self::BindPose { + motion, + mirror_modes, + attribute_mirror, + .. + } => { + //825476D0 leaves a child's partial output untouched on false. + let found = motion.query_attribute(name, mask, output)?; + if found { + for _ in mirror_modes { + attribute_mirror.apply(output)?; + } + } + Ok(found) + } + Self::Clip { clip, .. } => match clip.attribute(name, mask)? { + Some(attribute) => { + output.copy_from(&attribute); + Ok(true) + } + None => Ok(false), + }, + } + } + pub fn evaluate( + &mut self, + parameters: Evaluation, + enabled: bool, + output: &mut Vec, + ) -> Result { + match self { + Self::PhaseBlend(tree) => tree.evaluate(parameters, enabled, output), + Self::SelectionSpace(tree) => { + if !enabled { + Ok(false) + } else { + tree.current_mut() + .ok_or("SelectionSpace evaluated before native selection")? + .evaluate(parameters, true, output) + } + } + Self::Transition(tree) => tree.evaluate(parameters, enabled, output), + Self::BindPose { + motion, + posture, + board_backwards, + mirror_modes, + .. + } => { + let produced = motion.evaluate(parameters, enabled, output)?; + if produced { + if let Some(pose) = posture { + output.push(PoseCommand::Pose { + name: pose.name().into(), + }); + output.push(PoseCommand::Add { motion_is_a: true }); + } + output.push(PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }); + output.push(PoseCommand::Add { motion_is_a: true }); + if *board_backwards { + output.push(PoseCommand::Pose { + name: "BOARD_BACKWARDS".into(), + }); + output.push(PoseCommand::Add { motion_is_a: false }); + output.push(PoseCommand::Pose { + name: "BOARD_BACKWARDS_IK".into(), + }); + output.push(PoseCommand::Add { motion_is_a: true }); + } + for &trajectory_mode in mirror_modes.iter() { + output.push(PoseCommand::Mirror { trajectory_mode }); + } + } + Ok(produced) + } + Self::Clip { name, clip } => { + let clock = &mut clip.clock; + let rate = clock.base_speed * clock.speed; + let limit = (clock.frames - 1.0) / clock.fps; + let bound = |time: f32| { + let lower = if -time >= 0.0 { 0.0 } else { time }; + if limit - lower >= 0.0 { lower } else { limit } + }; + let mut previous_time = bound(clock.previous_time * rate); + let time = bound(clock.sample_time() * rate); + if clock.loops_since_evaluation == 0 && previous_time > time { + previous_time = time; + } + if enabled { + output.push(PoseCommand::Clip { + name: name.clone(), + previous_time, + time, + loops: clock.loops_since_evaluation, + }); + } + if parameters.update_history { + clock.commit_evaluation(); + } + Ok(enabled) + } + } + } +} + +#[cfg(test)] +#[path = "posture/tree_tests.rs"] +mod posture_tests; diff --git a/crates/skate-core/src/animation/playback_tree/selection_space.rs b/crates/skate-core/src/animation/playback_tree/selection_space.rs new file mode 100644 index 00000000..af36818c --- /dev/null +++ b/crates/skate-core/src/animation/playback_tree/selection_space.rs @@ -0,0 +1,159 @@ +//! Type11's authored metric and first-update selection: 82D26CC0/82D26FF8. +//! Native construction clears transition/reselect bits. No exposed operation +//! on this tree enables them; ordinary playback therefore latches one child. +use super::super::{output::attributes::AttributeName, playback_parameters::SettableAttribute}; +use super::PlaybackTree; + +#[derive(Clone, Debug)] +pub struct Parameter { + pub name: AttributeName, + pub mode: u32, + pub weight: f32, + pub minimum: f32, + pub maximum: f32, +} +#[derive(Clone, Debug)] +pub struct Candidate { + pub name: String, + pub values: Vec, + pub tree: PlaybackTree, +} +#[derive(Clone, Debug)] +pub struct SelectionSpace { + pub parameters: Vec, + pub candidates: Vec, + pub selected: Option, + /// Host GetAnimTree callback has supplied the selected child's wrappers. + pub child_constructed: bool, + values: Vec>, + speed: f32, + requested_time: f32, +} +impl SelectionSpace { + pub fn new(parameters: Vec, candidates: Vec) -> Result { + if parameters.len() > 10 + || candidates.is_empty() + || candidates + .iter() + .any(|c| c.values.len() != parameters.len()) + { + return Err("Invalid native selection space dimensions".into()); + } + let values = vec![None; parameters.len()]; + Ok(Self { + parameters, + candidates, + selected: None, + child_constructed: false, + values, + speed: 1.0, + requested_time: 0.0, + }) + } + pub fn current(&self) -> Option<&PlaybackTree> { + self.selected.map(|i| &self.candidates[i].tree) + } + pub fn current_mut(&mut self) -> Option<&mut PlaybackTree> { + self.selected.map(|i| &mut self.candidates[i].tree) + } + pub fn set_time(&mut self, time: f32) { + self.requested_time = time; + if let Some(child) = self.current_mut() { + child.set_time(time); + } + } + pub fn set_speed(&mut self, speed: f32) { + self.speed = speed; + if let Some(child) = self.current_mut() { + child.set_speed(speed); + } + } + pub fn select(&mut self, attributes: &[SettableAttribute]) -> Result<(), String> { + for attribute in attributes { + for (parameter, value) in self.parameters.iter().zip(&mut self.values) { + if parameter.name == attribute.name { + *value = Some(attribute.value); + } + } + } + if self.selected.is_none() { + let values = self + .values + .iter() + .enumerate() + .map(|(i, value)| { + value.ok_or_else(|| { + format!("SelectionSpace parameter {} has no source producer", i) + }) + }) + .collect::, _>>()?; + let mut best = f32::MAX; + let mut selected = None; + for (i, candidate) in self.candidates.iter().enumerate() { + let distance = distance(&self.parameters, &values, &candidate.values); + if distance < best { + best = distance; + selected = Some(i); + } + } + let chosen = selected + .ok_or_else(|| "SelectionSpace has no finite native minimum".to_string())?; + //82D26EE4..F88 resolves the first child record with the chosen name. + self.selected = self + .candidates + .iter() + .position(|c| c.name == self.candidates[chosen].name); + } + Ok(()) + } + pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result { + self.select(attributes)?; + let speed = self.speed; + if let Some(child) = self.current_mut() { + child.set_attributes(attributes)?; + child.set_speed(speed); + } + Ok(true) + } +} + +/// Original 82D26FF8 uses two accumulators and FMA for pairs, then a separate +/// multiply/multiply/add for an odd tail. Do not reorder into a generic sum. +pub fn distance(parameters: &[Parameter], values: &[f32], candidate: &[f32]) -> f32 { + let mut even = 0.0f32; + let mut odd = 0.0f32; + let pairs = parameters.len() / 2 * 2; + for i in (0..pairs).step_by(2) { + let d = delta(¶meters[i], values[i], candidate[i]); + even = (parameters[i].weight * d).mul_add(d, even); + let d = delta(¶meters[i + 1], values[i + 1], candidate[i + 1]); + odd = (parameters[i + 1].weight * d).mul_add(d, odd); + } + let sum = odd + even; + if pairs < parameters.len() { + let d = delta(¶meters[pairs], values[pairs], candidate[pairs]); + sum + (parameters[pairs].weight * d) * d + } else { + sum + 0.0 + } +} +fn delta(parameter: &Parameter, mut value: f32, mut candidate: f32) -> f32 { + let range = parameter.maximum - parameter.minimum; + if range > f32::from_bits(0x3780_0000) { + candidate /= range; + value /= range; + } + match parameter.mode { + 1 if value > candidate => (1.0 - value) + candidate, + 2 => { + let difference = (value - candidate).abs(); + let wrapped = 1.0 - difference; + if difference - wrapped >= 0.0 { + wrapped + } else { + difference + } + } + _ => candidate - value, + } +} diff --git a/crates/skate-core/src/animation/pose_add.rs b/crates/skate-core/src/animation/pose_add.rs new file mode 100644 index 00000000..2282311c --- /dev/null +++ b/crates/skate-core/src/animation/pose_add.rs @@ -0,0 +1,18 @@ +//! ACS immediate AddSQT828CCC08, used by SkaterAnim::AddBindPose82B98118. +use super::{output::Sqt, pose_trajectory::{multiply, rotate}}; + +/// Native operand B supplies the reference frame. The designated motion +/// operand supplies the channel weight, independently of operand ordering. +pub fn add(a: Sqt, b: Sqt, motion_is_a: bool) -> Sqt { + let rotated = rotate(b.rotation, [a.translation[0], a.translation[1], a.translation[2]]); + Sqt { + scale: core::array::from_fn(|i| b.scale[i] * a.scale[i]), + rotation: multiply(b.rotation, a.rotation), + translation: [ + rotated[0] + b.translation[0], + rotated[1] + b.translation[1], + rotated[2] + b.translation[2], + if motion_is_a { a.translation[3] } else { b.translation[3] }, + ], + } +} diff --git a/crates/skate-core/src/animation/pose_blend.rs b/crates/skate-core/src/animation/pose_blend.rs new file mode 100644 index 00000000..bf178f29 --- /dev/null +++ b/crates/skate-core/src/animation/pose_blend.rs @@ -0,0 +1,62 @@ +//! Standard SQT blend in TU3 ACS828CB418..4A4 and worker828D6BA8..6C2C. +//! The caller owns tree order and coefficients; interpolation does not flatten +//! nested blends or use a renderer's quaternion blend implementation. +use super::output::{PoseBufferError, Sqt}; +use crate::physics::native_arithmetic; + +pub fn blend( + first: &[Sqt], + second: &[Sqt], + weight: f32, + output: &mut [Sqt], +) -> Result<(), PoseBufferError> { + if first.len() != second.len() { + return Err(PoseBufferError::ShortInput); + } + if output.len() < first.len() { + return Err(PoseBufferError::ShortOutput); + } + for ((&a, &b), destination) in first.iter().zip(second).zip(output) { + *destination = blend_sample(a, b, weight); + } + Ok(()) +} + +pub fn blend_sample(first: Sqt, second: Sqt, weight: f32) -> Sqt { + let positive = native_arithmetic::dot4(first.rotation, second.rotation) > 0.0; + let rotation = core::array::from_fn(|lane| { + if positive { + (second.rotation[lane] - first.rotation[lane]).mul_add(weight, first.rotation[lane]) + } else { + (-(second.rotation[lane] + first.rotation[lane])).mul_add(weight, first.rotation[lane]) + } + }); + let squared = native_arithmetic::dot4(rotation, rotation); + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + Sqt { + scale: interpolate(first.scale, second.scale, weight), + rotation: rotation.map(|lane| lane * inverse), + translation: interpolate(first.translation, second.translation, weight), + } +} + +/// ACSChannelBlend828CC91C..CA64 selects the first subtree's translation.W +/// when requested, otherwise the second's, and clamps the weighted coefficient. +pub fn channel_blend_sample(first: Sqt, second: Sqt, weight: f32, use_first_weights: bool) -> Sqt { + let channel = if use_first_weights { first.translation[3] } else { second.translation[3] }; + let coefficient = weight * channel; + let coefficient = if coefficient > 1.0 { 1.0 } else { coefficient }; + let coefficient = if 0.0 > coefficient { 0.0 } else { coefficient }; + blend_sample(first, second, coefficient) +} + +fn interpolate(first: [f32; 4], second: [f32; 4], weight: f32) -> [f32; 4] { + core::array::from_fn(|lane| (second[lane] - first[lane]).mul_add(weight, first[lane])) +} + +#[cfg(test)] +#[path = "tests/pose_blend.rs"] +mod tests; diff --git a/crates/skate-core/src/animation/pose_mirror.rs b/crates/skate-core/src/animation/pose_mirror.rs new file mode 100644 index 00000000..9b506c5c --- /dev/null +++ b/crates/skate-core/src/animation/pose_mirror.rs @@ -0,0 +1,37 @@ +//! ACS immediate Mirror828CDAF8. Pairing comes from the stock hierarchy. +use super::{output::Sqt, pose_trajectory::multiply}; + +pub fn mirror(pose: &mut [Sqt], parents: &[i32], partners: &[i32], trajectory_mode: u32) -> Result<(), String> { + if pose.len() != parents.len() || pose.len() != partners.len() { + return Err("Mirror pose and hierarchy dimensions differ".into()); + } + for (i, &partner) in partners.iter().enumerate() { + if partner < -1 || partner >= pose.len() as i32 { + return Err(format!("Invalid mirror partner for bone{i}")); + } + // -1 excludes a bone; the smaller index processes both sides of a pair. + if partner < i as i32 { continue; } + if trajectory_mode == 1 && (i == 0 || parents[i] == 0) { + if i != 0 { + // Literal quaternion8232F740, before the alternate reflection. + pose[i].rotation = multiply(pose[i].rotation, [0.0, 1.0, 0.0, 0.0]); + } + pose[i].rotation[1] *= -1.0; + pose[i].rotation[2] *= -1.0; + pose[i].translation[0] *= -1.0; + } else { + reflect(&mut pose[i]); + } + if partner > i as i32 { + pose.swap(i, partner as usize); + reflect(&mut pose[i]); + } + } + Ok(()) +} + +fn reflect(pose: &mut Sqt) { + pose.rotation[0] *= -1.0; + pose.rotation[1] *= -1.0; + pose.translation[2] *= -1.0; +} diff --git a/crates/skate-core/src/animation/pose_sample.rs b/crates/skate-core/src/animation/pose_sample.rs new file mode 100644 index 00000000..607f2d9b --- /dev/null +++ b/crates/skate-core/src/animation/pose_sample.rs @@ -0,0 +1,76 @@ +//! TU3 timed frame selection82D20788 and decoded VBR interpolation82D1DE08. +//! The data adapter supplies complete decoded keys; no clip substitution occurs. +use super::{output::Sqt, pose_blend::blend_sample}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameSelection { + pub first: usize, + pub second: usize, + pub coefficient: f32, +} + +/// Clip evaluation supplies bounded nonnegative seconds. Frame blending uses +/// zero truncation offset; disabled blending uses the FetchSys setting at+8. +pub fn select_frames( + time: f32, + fps: f32, + frames: usize, + blend_frames: bool, + truncation_offset: f32, +) -> Result { + if frames == 0 || !time.is_finite() || time < 0.0 || !fps.is_finite() || fps <= 0.0 { + return Err("Invalid stock animation sampling input"); + } + let last = (frames - 1) as f32; + let position = time * fps; + let position = if position - last >= 0.0 { + last + } else { + position + }; + let offset = if blend_frames { 0.0 } else { truncation_offset }; + let first = (position + offset) as usize; + if first >= frames { + return Err("Stock frame selection exceeds decoded samples"); + } + let second = if blend_frames { + (first + 1).min(frames - 1) + } else { + first + }; + //82D20C08 truncates to16bits,82D1E090 expands using literal822F88B8. + let fraction = ((position - first as f32) * 65535.0) as u16; + Ok(FrameSelection { + first, + second, + coefficient: f32::from(fraction) * f32::from_bits(0x3780_0080), + }) +} + +pub fn sample_key(first: Sqt, second: Sqt, selection: FrameSelection) -> Sqt { + // Same-frame extraction does not normalize its decoded quaternion. + if selection.first == selection.second { + first + } else { + blend_sample(first, second, selection.coefficient) + } +} + +/// TU3 VBR's two-frame extraction selects the second compressed block at an +/// 8-frame boundary, and uses its second frame for both decoded keys. +/// Original raw82D1E0C0..E0 selects the block;82E89BD0..BF4 replaces the first +/// intra-block index. Keep the ORIGINAL selection for interpolation: two-frame +/// extraction still normalizes the quaternion, even when both samples coincide. +pub fn sample_key_vbr(first: Sqt, second: Sqt, selection: FrameSelection) -> Sqt { + let crosses_block = selection.first / 8 != selection.second / 8; + let copies_second = crosses_block && (selection.first % 8 == 7 || selection.second % 8 == 0); + sample_key( + if copies_second { second } else { first }, + second, + selection, + ) +} + +#[cfg(test)] +#[path = "tests/pose_sample_vbr.rs"] +mod vbr_tests; diff --git a/crates/skate-core/src/animation/pose_trajectory.rs b/crates/skate-core/src/animation/pose_trajectory.rs new file mode 100644 index 00000000..9584dd24 --- /dev/null +++ b/crates/skate-core/src/animation/pose_trajectory.rs @@ -0,0 +1,61 @@ +//! TU3 FetchSys trajectory delta82D178D8. This modifies only the trajectory SQT. +use super::output::Sqt; +use crate::physics::native_arithmetic::dot3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LoopTransform { + pub rotation: [f32; 4], + pub translation: [f32; 3], +} + +/// BatchFetch82D20A40 selects the authored transform when the loop argument is +/// nonzero, otherwise identity. It does not exponentiate by the loop count. +pub fn delta(current: Sqt, previous: Sqt, loop_transform: Option) -> Sqt { + let loop_transform = loop_transform.unwrap_or(LoopTransform { + rotation: [0.0, 0.0, 0.0, 1.0], + translation: [0.0; 3], + }); + let inverse_previous = [ + -previous.rotation[0], -previous.rotation[1], -previous.rotation[2], + previous.rotation[3], + ]; + let loop_displacement = rotate(current.rotation, loop_transform.translation); + let displacement = core::array::from_fn(|i| { + (loop_displacement[i] + current.translation[i]) - previous.translation[i] + }); + let translation = rotate(inverse_previous, displacement); + let rotation = multiply(multiply(loop_transform.rotation, current.rotation), inverse_previous); + Sqt { + scale: current.scale, + rotation, + // The extractor restores the channel weight separately for channel clips. + translation: [translation[0], translation[1], translation[2], current.translation[3]], + } +} + +/// Fused cross arithmetic from82D17908..17A24, without quaternion normalization. +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 3] { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + ] +} + +pub(super) fn rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] { + let vector = [v[0], v[1], v[2], 0.0]; + let first = cross(q, vector); + let intermediate = core::array::from_fn(|i| if i < 3 { q[3].mul_add(v[i], first[i]) } else { 0.0 }); + let second = cross(q, intermediate); + core::array::from_fn(|i| 2.0f32.mul_add(second[i], v[i])) +} + +pub(super) fn multiply(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + let vector = cross(a, b); + [ + a[0].mul_add(b[3], b[0].mul_add(a[3], vector[0])), + a[1].mul_add(b[3], b[1].mul_add(a[3], vector[1])), + a[2].mul_add(b[3], b[2].mul_add(a[3], vector[2])), + a[3] * b[3] - dot3(a, b), + ] +} diff --git a/crates/skate-core/src/animation/posture.rs b/crates/skate-core/src/animation/posture.rs new file mode 100644 index 00000000..0c144ff6 --- /dev/null +++ b/crates/skate-core/src/animation/posture.rs @@ -0,0 +1,86 @@ +//! TU3 posture profile selection and the pending GetAnimTree construction latch. +//! 82590B50 maps profile values; 82678990 sets the latch; 82B984C8 consumes it. +//! Channel construction bypasses this service entirely (82D1CC20/82D1D0F0). + +/// Registration order at 82858810, independent of asset directory ordering. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PosturePose { + Stiff, + Slouch, + Buff, +} + +impl PosturePose { + pub const fn from_profile(value: u32) -> Option { + match value { + 1 => Some(Self::Stiff), + 2 => Some(Self::Slouch), + 3 => Some(Self::Buff), + _ => None, + } + } + + pub const fn name(self) -> &'static str { + match self { + Self::Stiff => "POSTURE_STIFF_POSE", + Self::Slouch => "POSTURE_SLOUCH_POSE", + Self::Buff => "POSTURE_BUFF_POSE", + } + } +} + +/// Actual profile selection and a request for the next eligible main tree. +/// Native reset 824FA9A0 supplies profile 0; constructor 82B973C8 clears pending. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PendingPosture { + profile: u32, + pending: bool, +} + +impl PendingPosture { + /// Profile refresh changes selection without inventing a playback request. + pub fn set_profile(&mut self, profile: u32) { + self.profile = profile; + } + + pub fn profile(&self) -> u32 { + self.profile + } + + /// Every Play Begin overwrites this latch, including an authored false. + pub fn set_requested(&mut self, requested: bool) { + self.pending = requested; + } + + pub fn is_pending(&self) -> bool { + self.pending + } + + pub fn selected_pose(&self) -> Option { + if self.pending { + PosturePose::from_profile(self.profile) + } else { + None + } + } + + /// Wrap a successfully built main motion tree before its bind-pose wrapper. + /// The caller must first establish a valid registered static-pose bank. + /// Missing motion/bank must skip this call and leave the latch untouched. + /// Clear only after posture construction succeeds, before outer bind work. + pub fn apply( + &mut self, + motion: T, + wrap: impl FnOnce(T, PosturePose) -> Result, + ) -> Result { + let Some(pose) = self.selected_pose() else { + return Ok(motion); + }; + let wrapped = wrap(motion, pose)?; + self.pending = false; + Ok(wrapped) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/animation/posture/tests.rs b/crates/skate-core/src/animation/posture/tests.rs new file mode 100644 index 00000000..2e3beba2 --- /dev/null +++ b/crates/skate-core/src/animation/posture/tests.rs @@ -0,0 +1,70 @@ +use super::{PendingPosture, PosturePose}; + +#[test] +fn native_default_stays_disabled_even_after_request() { + let mut state = PendingPosture::default(); + assert_eq!(state.profile(), 0); + assert!(!state.is_pending()); + state.set_requested(true); + let tree = state.apply::<_, ()>(7, |_, _| panic!("disabled profile wrapped")); + assert_eq!(tree, Ok(7)); + assert!(state.is_pending()); +} + +#[test] +fn profile_values_follow_registration_not_alphabetical_order() { + for (value, name) in [ + (1, "POSTURE_STIFF_POSE"), + (2, "POSTURE_SLOUCH_POSE"), + (3, "POSTURE_BUFF_POSE"), + ] { + assert_eq!(PosturePose::from_profile(value).unwrap().name(), name); + } + for value in [0, 4, u32::MAX] { + assert_eq!(PosturePose::from_profile(value), None); + } +} + +#[test] +fn failed_construction_keeps_request_for_fallback() { + let mut state = PendingPosture::default(); + state.set_profile(2); + state.set_requested(true); + assert_eq!(state.apply((), |_, _| Err("missing pose")), Err("missing pose")); + assert!(state.is_pending()); + let tree = state.apply::<_, ()>(Vec::new(), |mut tree, pose| { + tree.push(pose); + Ok(tree) + }).unwrap(); + assert_eq!(tree, [PosturePose::Slouch]); + assert!(!state.is_pending()); +} + +#[test] +fn synchronous_begins_each_wrap_without_changing_previous_tree() { + let mut state = PendingPosture::default(); + state.set_profile(1); + let mut trees = Vec::new(); + for requested in [true, true, false] { + state.set_requested(requested); + trees.push(state.apply::<_, ()>(None, |_, pose| Ok(Some(pose))).unwrap()); + } + assert_eq!(trees, [Some(PosturePose::Stiff), Some(PosturePose::Stiff), None]); + assert!(!state.is_pending()); +} + +#[test] +fn false_overwrites_unconsumed_request_and_refresh_does_not_request() { + let mut state = PendingPosture::default(); + state.set_requested(true); + state.set_requested(false); + state.set_profile(3); + assert_eq!(state.selected_pose(), None); + state.set_requested(true); + assert_eq!(state.selected_pose(), Some(PosturePose::Buff)); + state.set_profile(99); + assert_eq!(state.selected_pose(), None); + assert!(state.is_pending()); + state.set_profile(1); + assert_eq!(state.selected_pose(), Some(PosturePose::Stiff)); +} diff --git a/crates/skate-core/src/animation/posture/tree_tests.rs b/crates/skate-core/src/animation/posture/tree_tests.rs new file mode 100644 index 00000000..77ad9076 --- /dev/null +++ b/crates/skate-core/src/animation/posture/tree_tests.rs @@ -0,0 +1,69 @@ +use crate::animation::{ + playback_attributes::AttributeMirror, + playback_clip::PlaybackClip, + playback_tree::{Evaluation, PlaybackTree, PoseCommand}, + posture::PosturePose, +}; +use std::sync::Arc; + +#[test] +fn posture_commands_stay_inside_bind_and_mirror_layers() { + for posture in [ + None, + Some(PosturePose::Stiff), + Some(PosturePose::Slouch), + Some(PosturePose::Buff), + ] { + let mut tree = PlaybackTree::BindPose { + motion: Box::new(PlaybackTree::Clip { + name: "motion".into(), + clip: PlaybackClip::new(31.0, 30.0, 1.0, 0, Vec::new()), + }), + posture, + board_backwards: true, + mirror_modes: vec![2, 1], + attribute_mirror: Arc::new(AttributeMirror(Vec::new())), + }; + tree.set_time(0.25); + assert_eq!(tree.time(), 0.25); + assert_eq!(tree.length(), 1.0); + let parameters = Evaluation { + cull_threshold: 0.0, + update_history: false, + }; + let mut commands = Vec::new(); + assert!(!tree.evaluate(parameters, false, &mut commands).unwrap()); + assert!(commands.is_empty()); + //Cloning preserves the inner posture selection through tree ownership changes. + assert!( + tree.clone() + .evaluate(parameters, true, &mut commands) + .unwrap() + ); + assert!(matches!(commands.remove(0), PoseCommand::Clip { .. })); + let mut expected = Vec::new(); + if let Some(pose) = posture { + expected.push(PoseCommand::Pose { + name: pose.name().into(), + }); + expected.push(PoseCommand::Add { motion_is_a: true }); + } + expected.extend([ + PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS".into(), + }, + PoseCommand::Add { motion_is_a: false }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS_IK".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Mirror { trajectory_mode: 2 }, + PoseCommand::Mirror { trajectory_mode: 1 }, + ]); + assert_eq!(commands, expected); + } +} diff --git a/crates/skate-core/src/animation/pumping_channel.rs b/crates/skate-core/src/animation/pumping_channel.rs new file mode 100644 index 00000000..7f89c36f --- /dev/null +++ b/crates/skate-core/src/animation/pumping_channel.rs @@ -0,0 +1,30 @@ +//! Pumping behavior82BAC1B0 and instance seeds82BAC528. +use crate::point_graph::PointGraph; +#[derive(Clone,Debug)] +pub struct Settings { + pub amplify:PointGraph<8>,pub input_blend:f32,pub maximum_physics_pump:f32, + pub new_pump_threshold:f32,pub blend_in:f32,pub blend_out:f32, +} +#[derive(Clone,Copy,Debug)] +pub struct State {value:f32,filtered:f32,channel:Option,peak:f32} +impl Default for State {fn default()->Self {Self {value:0.0,filtered:0.0,channel:None,peak:0.0}}} +pub struct Update {pub start:Option,pub influence:Option<(usize,f32)>} +impl State { + pub fn update(&mut self,physics_pump:f32,occupied:[bool;5],s:&Settings)->Update { + let previous=self.value; + let input=physics_pump/s.maximum_physics_pump; + let lower=if -input>=0.0 {0.0}else{input}; + let input=if 1.0-lower>=0.0 {lower}else{1.0}; + //82BAC2F8 is fmadds; preserve the single final rounding. + self.filtered=input.mul_add(s.input_blend,(1.0-s.input_blend)*self.filtered); + self.value=s.amplify.evaluate(self.filtered); + let start=if !(previous>s.new_pump_threshold)&&self.value>s.new_pump_threshold { + occupied.iter().position(|&exists|!exists) + } else {None}; + if let Some(index)=start {self.channel=Some(index);self.peak=self.value;} + let influence=if self.value>=previous&&self.value>=self.peak { + self.channel.map(|index|{self.peak=self.value;(index,self.value)}) + } else {None}; + Update {start,influence} + } +} diff --git a/crates/skate-core/src/animation/riding_fakie.rs b/crates/skate-core/src/animation/riding_fakie.rs new file mode 100644 index 00000000..b83927f0 --- /dev/null +++ b/crates/skate-core/src/animation/riding_fakie.rs @@ -0,0 +1,66 @@ +//! UpdateRidingFakie82BB2330. The two persistent clocks belong to the graph +//! instance, so re-entering another leaf does not restart them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Settings { + pub high_speed: f32, + pub low_speed: f32, + pub slowly_backwards_seconds: f32, + pub after_teleport_seconds: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub category: u32, + pub grind_state: u32, + pub doing_trick: bool, + /// PhysOutSkeleton+0, as produced by Skeleton::GetData. + pub board_axis: [f32; 4], + pub deck_velocity: [f32; 4], + /// PhysOut bundle+36, used outside filtered Ground category. + pub external_velocity: [f32; 4], + pub ground_projected_speed: f32, +} +#[derive(Clone, Copy, Debug, Default)] +pub struct State { + slowly_backwards: f32, + after_teleport: f32, +} +impl State { + /// None preserves the existing fakie flag during grounded/air trick execution. + pub fn update(&mut self, physical: Physical, dt: f32, settings: Settings) -> Option { + let eligible = if physical.category == 5 { + self.after_teleport = 0.0; + false + } else { + let eligible = self.after_teleport > settings.after_teleport_seconds; + self.after_teleport += dt; + eligible + }; + if !eligible { + self.slowly_backwards = 0.0; + return Some(false); + } + let allowed = (matches!(physical.category, 1 | 2) + || physical.category == 6 && physical.grind_state == 503) + && !physical.doing_trick; + if !allowed { + self.slowly_backwards = 0.0; + return (!matches!(physical.category, 1 | 2)).then_some(false); + } + let velocity = if physical.category == 1 { + physical.deck_velocity + } else { + physical.external_velocity + }; + let projection = crate::physics::native_arithmetic::dot3(velocity, physical.board_axis); + if physical.ground_projected_speed > settings.high_speed && projection < -0.5 { + self.slowly_backwards = 0.0; + Some(true) + } else if physical.ground_projected_speed > settings.low_speed && projection < -0.5 { + self.slowly_backwards = dt + self.slowly_backwards; + Some(self.slowly_backwards > settings.slowly_backwards_seconds) + } else { + self.slowly_backwards = 0.0; + Some(false) + } + } +} diff --git a/crates/skate-core/src/animation/skeleton_input/attribute_finalization.rs b/crates/skate-core/src/animation/skeleton_input/attribute_finalization.rs new file mode 100644 index 00000000..9eeeb1e7 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/attribute_finalization.rs @@ -0,0 +1,105 @@ +//! Special scalar handlers and unconditional suffix of82BDA0D0. +use super::{extended_attributes::ExtendedAttributes, scalar_attributes::ScalarAttributeInputs}; +use crate::input::controller::ActionMap; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct JumpAttributeState { + /// Skeleton16488/16492, cached by PrepareJump. + pub prepared_controls: [f32; 2], + /// Skeleton16496/16511; this cache survives only the native suffix gate. + pub height_override: f32, + pub height_override_active: bool, +} + +impl JumpAttributeState { + /// Skeleton constructor82BD7A54..82BD7A98, once per skeleton. + pub fn new() -> Self { + Self {prepared_controls:[0.0;2],height_override:0.0,height_override_active:false} + } +} + +#[derive(Clone, Copy, Debug)] +pub struct FinalizationInput { + /// Selected global292 layout1872/1876/1880. + pub select_jump_extremes: bool, + pub low_jump_threshold: f32, + pub high_jump_threshold: f32, + /// Selected state variant layout76. + pub allow_height_override: bool, + /// Selected global208 layout452. + pub use_prepared_controls: bool, + /// Actual AnimOutPhysIn10496 and10932. + pub external_impulse_active: bool, + pub animation_flags: u32, +} + +/// Function locals are reset on every ProcessAnimAttributes invocation. +pub struct Accumulator { + revert: bool, + revert_direction: f32, + minimum_jump: f32, + trick_height: f32, + has_trick_height: bool, +} +impl Accumulator { + pub fn new() -> Self { + Self {revert:false,revert_direction:1.0,minimum_jump:0.0,trick_height:0.0,has_trick_height:false} + } + pub fn dispatch(&mut self, name:&str, scalar:impl Fn()->Result, + fields:&mut ScalarAttributeInputs, cached:&mut JumpAttributeState, + map:&mut Option<&mut dyn ActionMap>) -> Result { + match name { + "Revert" => self.revert=true, + "RevertDir" => {self.revert_direction=scalar()?;fields.flags2472|=0x200000;} + "MinJump" => self.minimum_jump=scalar()?, + "TrickHeight" => {self.trick_height=scalar()?;self.has_trick_height=true;} + "PrepareJump" => { + fields.flags2468|=0x200000; + if let Some(map)=map { + //82590358/825903C8 call stock cInputMap64 and65. + cached.prepared_controls=[clamp_control(map.value(64)),clamp_control(map.value(65))]; + } + } + "JumpHeightOverride" => {cached.height_override=scalar()?;cached.height_override_active=true;} + _=>return Ok(false), + } + Ok(true) + } + pub fn finish(self, fields:&mut ScalarAttributeInputs, extra:&mut ExtendedAttributes, + cached:&mut JumpAttributeState, input:FinalizationInput, map:Option<&mut dyn ActionMap>) { + if self.revert {extra.revert_direction=self.revert_direction;} + if fields.flags2468&0x400000!=0 { + extra.jump_strength=if input.select_jump_extremes && self.trick_height<=input.low_jump_threshold { + self.minimum_jump + } else if input.select_jump_extremes && self.trick_height>=input.high_jump_threshold { + 1.0 + } else {self.trick_height}; + } + if self.has_trick_height && input.allow_height_override { + if cached.height_override_active { + let difference=cached.height_override-extra.jump_strength; + if difference>=0.0 {extra.jump_strength=cached.height_override;} + } + } else {cached.height_override=0.0;cached.height_override_active=false;} + if extra.flags2480&0x1000!=0 {extra.jump_strength=self.trick_height;} + if input.use_prepared_controls {extra.jump_controls=cached.prepared_controls;} + else if let Some(map)=map { + //The suffix deliberately selects68 through8255E010, not65 used + //by PrepareJump. Keep the recovered source's distinct routing. + extra.jump_controls=[clamp_control(map.value(64)),clamp_control(map.value(68))]; + } + if input.external_impulse_active {fields.flags2468|=0x40000;} + //82BDC650 reads a BYTE at10932, the high byte of this big-endian + //word. The later publications read the complete word separately. + extra.flags2480=(extra.flags2480&!8)|((input.animation_flags>>24)&8); + fields.flags2484=(fields.flags2484&!2)|((input.animation_flags>>22)&2); + fields.flags2484=(fields.flags2484&0x7fffffff)|((input.animation_flags<<6)&0x80000000); + fields.flags2484=(fields.flags2484&!0x01000000)|((input.animation_flags>>4)&0x01000000); + if fields.flags2476&0x600!=0 {fields.flags2476&=!0x400000;} + } +} + +fn clamp_control(value:f32)->f32 { + let lower=if -1.0-value>=0.0 {-1.0} else {value}; + if 1.0-lower>=0.0 {lower} else {1.0} +} diff --git a/crates/skate-core/src/animation/skeleton_input/catalog.rs b/crates/skate-core/src/animation/skeleton_input/catalog.rs new file mode 100644 index 00000000..b5f219a7 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/catalog.rs @@ -0,0 +1,191 @@ +//! All151 scalar-name comparison sites in82BDA0D0, in native search order. +//! Raw disassembly corrects the old130-entry catalog: direct-name checks were +//! omitted, Spin was misidentified, and left/right grab calls were conflated. +use super::name::encode; +use crate::animation::output::attributes::AttributeName; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScalarAttribute { + pub name: &'static str, + pub encoded_name: AttributeName, + /// Traceability only, never an executable/data address in the host. + pub comparison_site: u32, +} +const fn entry(name: &'static str, comparison_site: u32) -> ScalarAttribute { + ScalarAttribute { + name, + encoded_name: encode(name.as_bytes()), + comparison_site, + } +} +pub const SCALAR_ATTRIBUTES: &[ScalarAttribute] = &[ + entry("SlideBoard", 0x82bdacf8), + entry("SlideNose", 0x82bdad10), + entry("SlideTail", 0x82bdad28), + entry("Grind5050", 0x82bdad40), + entry("Grind5_O", 0x82bdad58), + entry("GrindNose", 0x82bdad70), + entry("GrabWorld", 0x82bdad88), + entry("BoardAdjustLeft", 0x82bdadb0), + entry("BoardAdjustRight", 0x82bdadd4), + entry("BoardAdjustUp", 0x82bdadf8), + entry("BoardAdjustDown", 0x82bdae1c), + entry("GrindFacingForwards", 0x82bdae40), + entry("GrindFacingBackwards", 0x82bdae6c), + entry("NoInput", 0x82bdae98), + entry("ExitGrind", 0x82bdaed0), + entry("NewAutoPump", 0x82bdaef8), + entry("EnteringCoffin", 0x82bdaf24), + entry("InCoffin", 0x82bdaf58), + entry("LeavingCoffin", 0x82bdaf8c), + entry("PlayingLanding", 0x82bdafb4), + entry("Balance", 0x82bdafdc), + entry("Spin", 0x82bdb004), + entry("KickTurn", 0x82bdb02c), + entry("Anticipating", 0x82bdb058), + entry("BodySpin", 0x82bdb084), + entry("ManualBrake", 0x82bdb0ac), + entry("Brake", 0x82bdb0d4), + entry("Turn", 0x82bdb0f8), + entry("IsStumbling", 0x82bdb120), + entry("IsWipeoutPushOffImpulse", 0x82bdb148), + entry("IsWipeoutPushOff", 0x82bdb170), + entry("IsMuteRFoot", 0x82bdb198), + entry("IsMuteLFoot", 0x82bdb1c0), + entry("IsForceDownRFoot", 0x82bdb1e8), + entry("IsForceDownLFoot", 0x82bdb210), + entry("TurnScale", 0x82bdb238), + entry("MagScale", 0x82bdb25c), + entry("AnimEndCOMX", 0x82bdb280), + entry("AnimEndCOMY", 0x82bdb2c0), + entry("AnimEndCOMZ", 0x82bdb300), + entry("AnimTransX", 0x82bdb340), + entry("AnimTransY", 0x82bdb380), + entry("AnimTransZ", 0x82bdb3c0), + entry("AnimTime", 0x82bdb400), + entry("AnimPhysBlendSec", 0x82bdb424), + entry("IsAnimInterpToEdge", 0x82bdb448), + entry("CadenceEndPercent", 0x82bdb470), + entry("OB_Traj", 0x82bdb494), + entry("OB_Air", 0x82bdb4bc), + entry("Carving", 0x82bdb4e4), + entry("RawTurn", 0x82bdb50c), + entry("HardTurn", 0x82bdb534), + entry("Slide", 0x82bdb55c), + entry("FrontFlip", 0x82bdb584), + entry("BackFlip", 0x82bdb5ac), + entry("Grabbing", 0x82bdb5d4), + entry("WantsLeftAirGrab", 0x82bdb5fc), + entry("WantsRightAirGrab", 0x82bdb614), + entry("FlipTrick", 0x82bdb62c), + entry("WipeOutRecover", 0x82bdb654), + entry("ControlledWipeout", 0x82bdb67c), + entry("Revert", 0x82bdb6a4), + entry("RevertDir", 0x82bdb6c8), + entry("Wipeout", 0x82bdb6f4), + entry("MinJump", 0x82bdb71c), + entry("TrickHeight", 0x82bdb73c), + entry("PrepareJump", 0x82bdb764), + entry("jump", 0x82bdb7e8), + entry("DangerZone", 0x82bdb810), + entry("LateTrick", 0x82bdb838), + entry("NoDangerZone", 0x82bdb860), + entry("ChristAir", 0x82bdb888), + entry("PhysGrindTranslation", 0x82bdb8e0), + entry("PhysGrindStabilityNudge", 0x82bdb904), + entry("PhysGrindUpDown", 0x82bdb928), + entry("PhysGrindGrabMinHeight", 0x82bdb94c), + entry("PhysBodySpin", 0x82bdb970), + entry("world_grab_y", 0x82bdb994), + entry("world_grab_z", 0x82bdb9b8), + entry("PushContact", 0x82bdb9dc), + entry("NewHandPlant", 0x82bdba04), + entry("AnimCommittedHandPlant", 0x82bdba28), + entry("DisableFeetTargetIK", 0x82bdba4c), + entry("OB_Jump", 0x82bdba70), + entry("OB_StandingJump", 0x82bdba98), + entry("OB_Sprint", 0x82bdbac0), + entry("OB_Turn", 0x82bdbae8), + entry("OB_Mag", 0x82bdbb10), + entry("OB_BipedWorldX", 0x82bdbb34), + entry("OB_BipedWorldZ", 0x82bdbb58), + entry("OB_LookAtX", 0x82bdbb7c), + entry("OB_LookAtY", 0x82bdbba0), + entry("OB_ObjectMvZ", 0x82bdbbc4), + entry("OB_ObjectMvX", 0x82bdbbe8), + entry("OB_ObjectMvRot", 0x82bdbc0c), + entry("PlayerControlledPump", 0x82bdbc30), + entry("OneFootAir", 0x82bdbc58), + entry("WipeoutControlX", 0x82bdbc7c), + entry("WipeoutControlY", 0x82bdbca0), + entry("OB_Mounting", 0x82bdbcc4), + entry("OB_Unmounting", 0x82bdbce8), + entry("OB_Dismount", 0x82bdbd0c), + entry("OB_HoldBoard", 0x82bdbd30), + entry("OB_ReleaseBoard", 0x82bdbd58), + entry("OB_ThrowBoard", 0x82bdbd84), + entry("OB_RetrieveBoard", 0x82bdbdac), + entry("OB_RetrievingBoard", 0x82bdbdd4), + entry("OB_DroppingBoard", 0x82bdbdfc), + entry("BipedBoardOnGround", 0x82bdbe24), + entry("IsGroundMount", 0x82bdbe4c), + entry("FootJump", 0x82bdbe74), + entry("AnimSkateboard", 0x82bdbe98), + entry("HippyJumping", 0x82bdbebc), + entry("PushTricking", 0x82bdbee0), + entry("KickoutDismount", 0x82bdbf04), + entry("RunoutDismount", 0x82bdbf28), + entry("FixLeftFoot", 0x82bdbf4c), + entry("FixRightFoot", 0x82bdbf70), + entry("DoLandOnBoard", 0x82bdbf94), + entry("TransitioningOnOffBoard", 0x82bdbfb8), + entry("JumpAboutToTakeOff", 0x82bdbfdc), + entry("JumpInProgress", 0x82bdc004), + entry("OB_JumpX", 0x82bdc02c), + entry("OB_JumpZ", 0x82bdc054), + entry("SkaterOffBoard", 0x82bdc078), + entry("JumpHeightOverride", 0x82bdc0a0), + entry("IsDark", 0x82bdc0d0), + entry("GrindTrick", 0x82bdc0f8), + entry("Shoving", 0x82bdc120), + entry("ShoveDirection", 0x82bdc148), + entry("BipedStartAngle", 0x82bdc170), + entry("BipedSpinAngle", 0x82bdc1a0), + entry("BipedAnimTime", 0x82bdc1c4), + entry("InManualZone", 0x82bdc1e8), + entry("OB_Toggle", 0x82bdc210), + entry("TrickFromManual", 0x82bdc238), + entry("WipeoutGestureStickX", 0x82bdc260), + entry("WipeoutGestureStickY", 0x82bdc284), + entry("StrongWipeoutArms", 0x82bdc2a8), + entry("StrongWipeoutLegs", 0x82bdc2d0), + entry("UnderflipBoardContact", 0x82bdc2f8), + entry("AudibleFootStepStrength", 0x82bdc324), + entry("IsTakeDownByBoard", 0x82bdc34c), + entry("DropInLock", 0x82bdc378), + entry("DropInRelease", 0x82bdc3a4), + entry("OB_DropIn", 0x82bdc3d0), + entry("BodyAdjustZ", 0x82bdc408), + entry("BodyAdjustX", 0x82bdc42c), + entry("OneFootIntent", 0x82bdc450), + entry("PushSpeed", 0x82bdc478), + entry("FootPlanting", 0x82bdc4a0), +]; + +/// First exact five-word match,824714D0. Unknown names fall through the native +/// chain without a handler; known but unimplemented handlers must not do so. +pub fn lookup(name: AttributeName) -> Option<&'static ScalarAttribute> { + SCALAR_ATTRIBUTES + .iter() + .find(|entry| entry.encoded_name == name) +} + +/// kind3/status&12 event name comparisons preceding the scalar chain. The +/// fifth comparison is of the event payload, not the attribute name. +pub const EVENT_COMPARISONS: &[ScalarAttribute] = &[ + entry("push_contact", 0x82bdab08), + entry("brake_contact", 0x82bdac40), + entry("right_hand_grab", 0x82bdac98), + entry("left_hand_grab", 0x82bdacb0), + entry("righthand", 0x82bdacc8), +]; diff --git a/crates/skate-core/src/animation/skeleton_input/contact_events.rs b/crates/skate-core/src/animation/skeleton_input/contact_events.rs new file mode 100644 index 00000000..da6d1eff --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/contact_events.rs @@ -0,0 +1,67 @@ +//! Complete kind3/status&12 dispatch82BDAAA0..82BDACE4. +//! Bone identity is resolved even for an unknown event name. Push speed comes +//! from the evaluated trajectory displacement, not a button-driven force. +use super::{name::encode, scalar_attributes::ScalarAttributeInputs}; +use crate::{animation::output::{NativeMatrix, attributes::{AnimationAttribute, AttributeName}}, + physics::native_arithmetic::{dot3, reciprocal_square_root_estimate}}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactEventState { + /// ProcessedPhysIn2560, including native GetBone's -1 result. + pub bone: i32, + /// ProcessedPhysIn2608, consumed by the native push-force calculation. + pub push_speed: f32, +} + +pub struct ContactEventPose<'a> { + pub bone_names: &'a [AttributeName], + pub hierarchy: &'a [NativeMatrix], + /// Skeleton11716, assigned by SkeletonData initialization. + pub trajectory_bone: usize, + /// Skeleton4748: animation index of physical part19 (RIGHTTOEBASE). + pub right_toe_bone: i32, + /// Skeleton11704: current animation/physics input timestep. + pub timestep: f32, +} + +pub fn dispatch(attribute: &AnimationAttribute, pose: &ContactEventPose<'_>, + fields: &mut ScalarAttributeInputs, state: &mut ContactEventState) -> Result<(), String> { + if attribute.kind != 3 || attribute.status & 12 == 0 { return Ok(()); } + let mut words = [0; 6]; + for (destination, source) in words.iter_mut().zip(attribute.payload.0) { + *destination = source.ok_or("Active animation bone event has incomplete payload")?; + } + let name = AttributeName(words[..5].try_into().unwrap()); + let strength = f32::from_bits(words[5]); + state.bone = pose.bone_names.iter().position(|&bone| bone == name).map_or(-1, |i| i as i32); + if attribute.name == encode(b"push_contact") { + fields.flags2468 |= 1 << 27; + replace(&mut fields.flags2468, 26, state.bone == pose.right_toe_bone); + replace(&mut fields.flags2468, 25, strength == 1.0); + replace(&mut fields.flags2468, 24, strength == -1.0); + let translation = pose.hierarchy.get(pose.trajectory_bone) + .ok_or("Push contact requires the actual animation trajectory bone")?[3]; + let squared = dot3(translation, translation); + let mut inverse = reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + // Native clears a zero-length result before multiplying by scalar + // fdivs(1,dt). Estimate arithmetic remains hardware-unverified. + let length = if squared == 0.0 { 0.0 } else { squared * inverse }; + let speed = length * (1.0 / pose.timestep); + state.push_speed = if strength == 1.0 { speed } else { 0.0 }; + } else if attribute.name == encode(b"brake_contact") { + fields.flags2468 |= 1 << 28; + replace(&mut fields.flags2468, 30, strength == 1.0); + replace(&mut fields.flags2468, 23, strength == -1.0); + } else if attribute.name == encode(b"right_hand_grab") || attribute.name == encode(b"left_hand_grab") { + // The payload bone name, not the event's left/right label, selects it. + fields.flags2472 |= if name == encode(b"righthand") { 0x100 } else { 0x80 }; + } + Ok(()) +} + +fn replace(flags: &mut u32, bit: u32, set: bool) { + *flags = (*flags & !(1 << bit)) | (u32::from(set) << bit); +} diff --git a/crates/skate-core/src/animation/skeleton_input/extended_attributes.rs b/crates/skate-core/src/animation/skeleton_input/extended_attributes.rs new file mode 100644 index 00000000..a2306cb1 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/extended_attributes.rs @@ -0,0 +1,150 @@ +//! Remaining scalar fields and flags82BDB6A4..82BDC4B4. Values retain their +//! preceding ProcessData/reset state until the corresponding attribute writes. +use super::scalar_attributes::{AnimationControlOutput, ScalarAttributeInputs}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ExtendedAttributes { + pub flags2480: u32, + /// Processed2796..2820. + pub grind_translation: f32, + pub grind_stability_nudge: f32, + pub grind_up_down: f32, + pub grind_grab_min_height: f32, + pub physical_body_spin: f32, + pub world_grab_y: f32, + pub world_grab_z: f32, + /// Processed2680..2692,2704..2708,2784..2792. + pub offboard_turn: f32, + pub offboard_magnitude: f32, + pub biped_world_x: f32, + pub biped_world_z: f32, + pub look_x: f32, + pub look_y: f32, + pub object_move_z: f32, + pub object_move_x: f32, + pub object_move_rotation: f32, + /// Processed2824/2828,2840/2844,2916/2920,2928/2932. + pub wipeout_control: [f32; 2], + pub offboard_jump: [f32; 2], + pub wipeout_gesture: [f32; 2], + pub body_adjust: [f32; 2], + /// Processed2936/2940/2944; AnimOut24. + pub biped_start_angle: f32, + pub biped_spin_angle: f32, + pub biped_animation_time: f32, + pub footstep_strength: f32, + /// Processed2624/2628/2632/2744. + pub jump_strength: f32, + pub jump_controls: [f32; 2], + pub revert_direction: f32, +} + +impl ExtendedAttributes { + /// Reset82BF9EF0 zeros every represented ProcessedPhysIn field here. + /// AnimOut24 belongs to another owner and survives this reset. + pub fn reset(footstep_strength:f32) -> Self { + Self { + flags2480:0,grind_translation:0.0,grind_stability_nudge:0.0, + grind_up_down:0.0,grind_grab_min_height:0.0,physical_body_spin:0.0, + world_grab_y:0.0,world_grab_z:0.0,offboard_turn:0.0, + offboard_magnitude:0.0,biped_world_x:0.0,biped_world_z:0.0, + look_x:0.0,look_y:0.0,object_move_z:0.0,object_move_x:0.0, + object_move_rotation:0.0,wipeout_control:[0.0;2],offboard_jump:[0.0;2], + wipeout_gesture:[0.0;2],body_adjust:[0.0;2],biped_start_angle:0.0, + biped_spin_angle:0.0,biped_animation_time:0.0,footstep_strength, + jump_strength:0.0,jump_controls:[0.0;2],revert_direction:0.0, + } + } +} + +/// Special jump/revert handlers and finalization are in attribute_finalization. +/// Return false only for a name outside this contiguous scalar region. +pub fn dispatch(name: &str, scalar: impl Fn() -> Result, + input: &mut ScalarAttributeInputs, extra: &mut ExtendedAttributes, + output: &mut AnimationControlOutput) -> Result { + match name { + "Wipeout" => input.flags2468 |= 0x40000, + "jump" => input.flags2468 |= 0x400000, + "DangerZone" => input.flags2472 |= 0x8000, + "LateTrick" => input.flags2472 |= 0x20, + "NoDangerZone" => input.flags2472 |= 0x10, + "ChristAir" => input.flags2472 |= 0x40, + "PhysGrindTranslation" => extra.grind_translation = scalar()?, + "PhysGrindStabilityNudge" => extra.grind_stability_nudge = scalar()?, + "PhysGrindUpDown" => extra.grind_up_down = scalar()?, + "PhysGrindGrabMinHeight" => extra.grind_grab_min_height = scalar()?, + "PhysBodySpin" => extra.physical_body_spin = scalar()?, + "world_grab_y" => extra.world_grab_y = scalar()?, + "world_grab_z" => extra.world_grab_z = scalar()?, + "NewHandPlant" => extra.flags2480 |= 0x20000000, + "AnimCommittedHandPlant" => extra.flags2480 |= 0x10000000, + "DisableFeetTargetIK" => extra.flags2480 |= 0x08000000, + "OB_Jump" => input.flags2476 |= 0x80000, + "OB_StandingJump" => input.flags2476 |= 0x40000, + "OB_Sprint" => input.flags2484 |= 0x20000, + "OB_Turn" => extra.offboard_turn = -scalar()?, + "OB_Mag" => extra.offboard_magnitude = scalar()?, + "OB_BipedWorldX" => extra.biped_world_x = scalar()?, + "OB_BipedWorldZ" => extra.biped_world_z = scalar()?, + "OB_LookAtX" => extra.look_x = scalar()?, + "OB_LookAtY" => extra.look_y = scalar()?, + "OB_ObjectMvZ" => extra.object_move_z = scalar()?, + "OB_ObjectMvX" => extra.object_move_x = scalar()?, + "OB_ObjectMvRot" => extra.object_move_rotation = scalar()?, + "OneFootAir" => extra.flags2480 |= 0x04000000, + "WipeoutControlX" => extra.wipeout_control[0] = scalar()?, + "WipeoutControlY" => extra.wipeout_control[1] = scalar()?, + "OB_Mounting" => extra.flags2480 |= 0x80000, + "OB_Unmounting" => extra.flags2480 |= 0x40000, + "OB_Dismount" => extra.flags2480 |= 0x20000, + "OB_HoldBoard" => input.flags2476 |= 0x4000, + "OB_ReleaseBoard" => input.flags2476 = (input.flags2476 & !0x3000) | 0x2000, + "OB_ThrowBoard" => input.flags2476 |= 0x3000, + "OB_RetrieveBoard" => input.flags2476 |= 0x800, + "OB_RetrievingBoard" => input.flags2476 |= 0x400, + "OB_DroppingBoard" => input.flags2476 |= 0x200, + "BipedBoardOnGround" => input.flags2484 |= 1, + "IsGroundMount" => input.flags2488 |= 0x80000000, + "FootJump" => extra.flags2480 |= 0x2000, + "AnimSkateboard" => extra.flags2480 |= 0x4000, + "HippyJumping" => extra.flags2480 |= 0x1000, + "PushTricking" => extra.flags2480 |= 0x800, + "KickoutDismount" => extra.flags2480 |= 0x100, + "RunoutDismount" => extra.flags2480 |= 0x80, + "FixLeftFoot" => extra.flags2480 |= 0x40, + "FixRightFoot" => extra.flags2480 |= 0x20, + "DoLandOnBoard" => extra.flags2480 |= 0x10, + "TransitioningOnOffBoard" => extra.flags2480 |= 4, + "JumpAboutToTakeOff" => input.flags2484 |= 0x40000000, + "JumpInProgress" => input.flags2484 |= 0x20000000, + "OB_JumpX" => extra.offboard_jump[0] = -scalar()?, + "OB_JumpZ" => extra.offboard_jump[1] = scalar()?, + "SkaterOffBoard" => input.flags2484 |= 0x08000000, + "IsDark" => input.flags2484 |= 0x200000, + "GrindTrick" => input.flags2484 |= 0x100000, + "Shoving" => input.flags2484 |= 0x80000, + "ShoveDirection" => input.flags2484 |= 0x40000, + "BipedStartAngle" => { input.flags2484 |= 0x4000; extra.biped_start_angle = scalar()?; } + "BipedSpinAngle" => extra.biped_spin_angle = scalar()?, + "BipedAnimTime" => extra.biped_animation_time = scalar()?, + "InManualZone" => input.flags2484 |= 0x2000, + "OB_Toggle" => input.flags2484 |= 0x1000, + "TrickFromManual" => input.flags2484 |= 0x800, + "WipeoutGestureStickX" => extra.wipeout_gesture[0] = scalar()?, + "WipeoutGestureStickY" => extra.wipeout_gesture[1] = scalar()?, + "StrongWipeoutArms" => input.flags2484 |= 0x20, + "StrongWipeoutLegs" => input.flags2484 |= 0x10, + "UnderflipBoardContact" => output.flags |= 0x04000000, + "AudibleFootStepStrength" => extra.footstep_strength = scalar()?, + "IsTakeDownByBoard" => output.flags |= 0x02000000, + "DropInLock" => output.flags |= 0x01000000, + "DropInRelease" => output.flags |= 0x00800000, + "OB_DropIn" => { output.flags |= 0x00400000; input.flags2488 |= 0x10000000; } + "BodyAdjustZ" => extra.body_adjust[1] = scalar()?, + "BodyAdjustX" => extra.body_adjust[0] = scalar()?, + "OneFootIntent" => input.flags2488 |= 0x02000000, + "FootPlanting" => extra.flags2480 |= 0x00800000, + _ => return Ok(false), + } + Ok(true) +} diff --git a/crates/skate-core/src/animation/skeleton_input/mod.rs b/crates/skate-core/src/animation/skeleton_input/mod.rs new file mode 100644 index 00000000..b21cd3dd --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/mod.rs @@ -0,0 +1,12 @@ +//! Concrete Skeleton animation-attribute consumers. These do not replace the +//! remaining ProcessInput and ProcessData pose/IK stages. +pub mod catalog; +pub mod name; +pub mod scalar_attributes; +pub mod contact_events; +pub mod extended_attributes; +pub mod attribute_finalization; +pub mod process_attributes; + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/animation/skeleton_input/name.rs b/crates/skate-core/src/animation/skeleton_input/name.rs new file mode 100644 index 00000000..69fea661 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/name.rs @@ -0,0 +1,33 @@ +//! Native five-word text encoding823C3C78 and six-character chunk82382B50. +//! This is an encoded string, not a vault/hash-table key or pointer identity. +use crate::animation::output::attributes::AttributeName; + +/// Encode up to30 bytes, stopping at NUL. Native accepts signed bytes and does +/// not validate its alphabet. Preserve its case folding, punctuation behavior, +/// wrapping word arithmetic and truncation; do not substitute UTF-8 casefold. +pub const fn encode(text: &[u8]) -> AttributeName { + let mut words = [0u32; 5]; + let mut position = 0; + while position < 30 && position < text.len() && text[position] != 0 { + let mut weight = 79_235_168u32; // 38^5, exactly the native first weight. + let word = position / 6; + let mut count = 0; + while count < 6 && position < text.len() && text[position] != 0 { + let byte = text[position] as i8 as i32; + let digit = if byte >= 97 { + byte - 86 + } else if byte > 90 { + byte - 58 + } else if byte > 57 { + byte - 54 + } else { + byte - 47 + }; + words[word] = words[word].wrapping_add((digit as u32).wrapping_mul(weight)); + weight /= 38; + count += 1; + position += 1; + } + } + AttributeName(words) +} diff --git a/crates/skate-core/src/animation/skeleton_input/process_attributes.rs b/crates/skate-core/src/animation/skeleton_input/process_attributes.rs new file mode 100644 index 00000000..3b2c76b9 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/process_attributes.rs @@ -0,0 +1,35 @@ +//! Full ordered Skeleton::ProcessAnimAttributes dispatch82BDA0D0. The game +//! supplies actual stock settings, pose and input map, and retains owned state. +use super::{attribute_finalization::{Accumulator,FinalizationInput,JumpAttributeState}, + catalog,contact_events::{self,ContactEventPose,ContactEventState}, + extended_attributes::{self,ExtendedAttributes}, + scalar_attributes::{self,AnimationControlOutput,DispatchError,ScalarAttributeInputs}}; +use crate::{animation::output::attributes::AnimationAttribute,input::controller::ActionMap}; + +pub fn process(attributes:&[AnimationAttribute], pose:&ContactEventPose<'_>, + fields:&mut ScalarAttributeInputs, extra:&mut ExtendedAttributes, + contacts:&mut ContactEventState, cached_jump:&mut JumpAttributeState, + output:&mut AnimationControlOutput, finalization:FinalizationInput, + mut input_map:Option<&mut dyn ActionMap>) -> Result<(),String> { + let mut accumulator=Accumulator::new(); + for (index,attribute) in attributes.iter().enumerate() { + let result=(|| { + if attribute.kind==3 { + return contact_events::dispatch(attribute,pose,fields,contacts); + } + match scalar_attributes::dispatch_attribute(attribute,fields,output) { + Ok(())=>return Ok(()), + Err(DispatchError::KnownScalarUnavailable {..})=>{}, + Err(error)=>return Err(format!("Invalid scalar payload: {error:?}")), + } + let name=catalog::lookup(attribute.name).unwrap().name; + let scalar=||attribute.payload.0[0].map(f32::from_bits).ok_or_else(||format!("{name}: scalar payload absent")); + if accumulator.dispatch(name,scalar,fields,cached_jump,&mut input_map)? + || extended_attributes::dispatch(name,scalar,fields,extra,output)? {Ok(())} + else {Err(format!("Unimplemented known Skeleton attribute {name}"))} + })(); + result.map_err(|error|format!("Skeleton attribute{index}: {error}"))?; + } + accumulator.finish(fields,extra,cached_jump,finalization,input_map); + Ok(()) +} diff --git a/crates/skate-core/src/animation/skeleton_input/scalar_attributes.rs b/crates/skate-core/src/animation/skeleton_input/scalar_attributes.rs new file mode 100644 index 00000000..29cc5698 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/scalar_attributes.rs @@ -0,0 +1,201 @@ +//! Complete contiguous scalar handlers82BDACF0..82BDB694, plus the two +//! independently verified scalar PushContact/PushSpeed flag handlers. +//! Input order is the packet's MG-then-tree order; no timing/value threshold +//! filters are added. process_attributes::process supplies the complete ordered +//! dispatch, kind3 events, remaining scalar handlers and finalization. +use super::catalog::{self, ScalarAttribute}; +use crate::animation::output::{ + attributes::{AnimationAttribute, AttributeName}, + setup_physics::PhysicsPacketBinding, +}; + +/// These flag words are shared with other ProcessedPhysIn stages. They must +/// be seeded by the real preceding reset/consumer stages, not zeroed here. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScalarAttributeInputs { + pub flags2468: u32, + pub flags2472: u32, + pub flags2476: u32, + pub flags2484: u32, + pub flags2488: u32, + /// Native+2492: left1,right2,up3,down4. Unknown prior enum values survive. + pub board_adjust: u32, + /// Native2720,2672,2640,2728,2676 respectively. + pub balance: f32, + pub spin: f32, + pub body_spin: f32, + pub brake: f32, + pub turn: f32, + /// Native2908/2912. + pub turn_scale: f32, + pub magnitude_scale: f32, + /// Native2880/2864; scalar handlers replace XYZ independently, preserving W. + pub animation_end_com: [f32; 4], + pub animation_translation: [f32; 4], + /// Native2896/2904/2900. + pub animation_time: f32, + pub animation_physics_blend_seconds: f32, + pub cadence_end_percent: f32, + /// Native2712/2716/2732. + pub raw_turn: f32, + pub hard_turn: f32, + pub slide: f32, +} + +impl ScalarAttributeInputs { + /// Represented ProcessedPhysIn fields written by Reset82BF9EF0. + /// This is the reset stage, not an implicit reset in attribute dispatch. + pub fn reset(previous_flags2468:u32, previous_flags2488:u32) -> Self { + Self { + flags2468:(previous_flags2468&8)|0x2000,flags2472:0,flags2476:0, + flags2484:0,flags2488:previous_flags2488&0x1fffff,board_adjust:0, + balance:0.0,spin:0.0,body_spin:0.0,brake:0.0,turn:0.0, + turn_scale:1.0,magnitude_scale:1.0,animation_end_com:[0.0;4], + animation_translation:[0.0;4],animation_time:0.0, + animation_physics_blend_seconds:0.0,cadence_end_percent:-1.0, + raw_turn:0.0,hard_turn:0.0,slide:0.0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AnimationControlOutput { + /// AnimOut+0..16: selected Slide/Grind name, copied as five exact words. + pub grind_name: AttributeName, + pub flags: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DispatchError { + /// Active kind3 requires node lookup82530D80 even for unknown event names. + EventConsumerUnavailable { + name: AttributeName, + }, + KnownScalarUnavailable { + attribute: ScalarAttribute, + }, + UninitializedScalar { + attribute: ScalarAttribute, + }, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PacketDispatchError { + pub attribute_index: usize, + pub cause: DispatchError, +} + +/// Dispatch actual packet attributes into their physical consumer fields. +/// On failure earlier attribute effects remain; the failed handler performs no +/// writes here. Success means these supported handlers ran, not that native +/// attribute finalization or Skeleton::ProcessData has completed. +pub fn dispatch_supported_packet( + binding: &PhysicsPacketBinding<'_>, + input: &mut ScalarAttributeInputs, + output: &mut AnimationControlOutput, +) -> Result<(), PacketDispatchError> { + for (attribute_index, attribute) in binding.packet().attributes.entries().iter().enumerate() { + dispatch_attribute(attribute, input, output).map_err(|cause| PacketDispatchError { + attribute_index, + cause, + })?; + } + Ok(()) +} + +pub fn dispatch_attribute( + attribute: &AnimationAttribute, + input: &mut ScalarAttributeInputs, + output: &mut AnimationControlOutput, +) -> Result<(), DispatchError> { + if attribute.kind != 0 { + if attribute.kind == 3 && attribute.status & 12 != 0 { + return Err(DispatchError::EventConsumerUnavailable { + name: attribute.name, + }); + } + return Ok(()); + } + let Some(entry) = catalog::lookup(attribute.name) else { + return Ok(()); + }; + let scalar = || { + attribute.payload.0[0] + .map(f32::from_bits) + .ok_or(DispatchError::UninitializedScalar { attribute: *entry }) + }; + match entry.name { + "SlideBoard" | "SlideNose" | "SlideTail" | "Grind5050" | "Grind5_O" | "GrindNose" => { + output.grind_name = attribute.name + } + "GrabWorld" => input.flags2476 |= 1 << 22, + "BoardAdjustLeft" => input.board_adjust = 1, + "BoardAdjustRight" => input.board_adjust = 2, + "BoardAdjustUp" => input.board_adjust = 3, + "BoardAdjustDown" => input.board_adjust = 4, + "GrindFacingForwards" => output.flags |= 1 << 31, + "GrindFacingBackwards" => output.flags &= !(1 << 31), + "NoInput" => { + output.flags |= 1 << 30; + input.flags2472 |= 1 << 23; + } + "ExitGrind" => input.flags2476 |= 1 << 29, + "NewAutoPump" => output.flags |= 1 << 29, + //82BDBC30..48: scalar value is not tested. + "PlayerControlledPump" => input.flags2476 |= 2, + "EnteringCoffin" => { + input.flags2472 |= 2; + input.flags2476 |= 1 << 30; + } + "InCoffin" => { + input.flags2472 |= 1; + input.flags2476 |= 1 << 30; + } + "LeavingCoffin" => input.flags2476 |= 0xc000_0000, + "PlayingLanding" => input.flags2476 |= 1 << 28, + "Balance" => input.balance = -scalar()?, + "Spin" => input.spin = -scalar()?, + "KickTurn" => output.flags |= 1 << 28, + "Anticipating" => output.flags |= 1 << 27, + "BodySpin" => input.body_spin = -scalar()?, + "ManualBrake" => input.flags2468 |= 1 << 29, + "Brake" => input.brake = scalar()?, + "Turn" => input.turn = -scalar()?, + "IsStumbling" => input.flags2484 |= 1 << 10, + "IsWipeoutPushOffImpulse" => input.flags2484 |= 1 << 8, + "IsWipeoutPushOff" => input.flags2484 |= 1 << 9, + "IsMuteRFoot" => input.flags2484 |= 1 << 7, + "IsMuteLFoot" => input.flags2484 |= 1 << 6, + "IsForceDownRFoot" => input.flags2484 |= 1 << 3, + "IsForceDownLFoot" => input.flags2484 |= 1 << 2, + "TurnScale" => input.turn_scale = scalar()?, + "MagScale" => input.magnitude_scale = scalar()?, + "AnimEndCOMX" => input.animation_end_com[0] = scalar()?, + "AnimEndCOMY" => input.animation_end_com[1] = scalar()?, + "AnimEndCOMZ" => input.animation_end_com[2] = scalar()?, + "AnimTransX" => input.animation_translation[0] = scalar()?, + "AnimTransY" => input.animation_translation[1] = scalar()?, + "AnimTransZ" => input.animation_translation[2] = scalar()?, + "AnimTime" => input.animation_time = scalar()?, + "AnimPhysBlendSec" => input.animation_physics_blend_seconds = scalar()?, + "IsAnimInterpToEdge" => input.flags2488 |= 1 << 27, + "CadenceEndPercent" => input.cadence_end_percent = scalar()?, + "OB_Traj" => input.flags2476 |= 1 << 15, + "OB_Air" => input.flags2476 |= 1 << 7, + "Carving" => input.flags2468 |= 1 << 31, + "RawTurn" => input.raw_turn = -scalar()?, + "HardTurn" => input.hard_turn = -scalar()?, + "Slide" => input.slide = -scalar()?, + "FrontFlip" => input.flags2468 |= 1 << 7, + "BackFlip" => input.flags2468 |= 1 << 6, + "Grabbing" => input.flags2468 |= 1 << 5, + "WantsLeftAirGrab" | "WantsRightAirGrab" => input.flags2468 |= 1 << 4, + "FlipTrick" => input.flags2472 |= 1 << 22, + "WipeOutRecover" => input.flags2472 |= 1 << 20, + "ControlledWipeout" => input.flags2472 |= 1 << 19, + // Independent scalar markers. Neither reads strength or queues force. + "PushContact" => input.flags2488 |= 1 << 29, + "PushSpeed" => input.flags2488 |= 1 << 23, + _ => return Err(DispatchError::KnownScalarUnavailable { attribute: *entry }), + } + Ok(()) +} diff --git a/crates/skate-core/src/animation/skeleton_input/tests.rs b/crates/skate-core/src/animation/skeleton_input/tests.rs new file mode 100644 index 00000000..a08a6952 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/tests.rs @@ -0,0 +1,48 @@ +use super::{attribute_finalization::*,catalog::SCALAR_ATTRIBUTES,contact_events::*, + extended_attributes::ExtendedAttributes,name::encode,process_attributes::process, + scalar_attributes::{AnimationControlOutput,ScalarAttributeInputs}}; +use crate::{animation::output::{attributes::*,packet_reset::RESET_POSE},input::controller::ActionMap}; + +struct Map; +impl ActionMap for Map { + fn value(&mut self,key:u32)->f32 {match key {64=>0.25,65=>-0.75,68=>0.5,_=>panic!("Unexpected source action{key}")}} + fn state(&mut self,_:u32)->u8 {panic!("Attribute finalization reads analog values only")} +} + +#[test] +fn complete_attribute_packet_keeps_contact_timing_reset_and_finalization() { + let mut fields=ScalarAttributeInputs::reset(8,u32::MAX); + let mut extra=ExtendedAttributes::reset(0.0); + let mut contacts=ContactEventState {bone:0,push_speed:0.0}; + let mut cached=JumpAttributeState::new(); + let mut output=AnimationControlOutput {grind_name:encode(b""),flags:0}; + let names=[encode(b"Trajectory"),encode(b"RightToeBase")]; + let mut hierarchy=[RESET_POSE;2]; + hierarchy[0][3]=[0.0,0.0,0.1,0.0]; + // Deliberately unrelated foot position: native speed is trajectory/dt. + hierarchy[1][3]=[20.0,10.0,-40.0,0.0]; + let pose=ContactEventPose {bone_names:&names,hierarchy:&hierarchy,trajectory_bone:0,right_toe_bone:1,timestep:1.0/60.0}; + let mut map=Map; + let input=FinalizationInput {select_jump_extremes:true,low_jump_threshold:0.25,high_jump_threshold:0.75, + allow_height_override:true,use_prepared_controls:false,external_impulse_active:false,animation_flags:0x08000000}; + let mut event=MotionGraphAttribute {name:encode(b"push_contact"),value:0.0}.to_animation(); + event.kind=3;event.status=4; + event.payload=AttributePayload(std::array::from_fn(|i|Some(if i<5 {names[1].0[i]} else {1.0f32.to_bits()}))); + process(&[event],&pose,&mut fields,&mut extra,&mut contacts,&mut cached,&mut output,input,Some(&mut map)).unwrap(); + assert_eq!(contacts.bone,1); + assert!((contacts.push_speed-6.0).abs()<0.00001); + assert_eq!(fields.flags2468&0x0f000000,0x0e000000); + assert_eq!(extra.flags2480&8,8); //byte at start of a big-endian word + assert_eq!(extra.jump_controls,[0.25,0.5]); + event.status=2;contacts.push_speed=7.0; + process(&[event],&pose,&mut fields,&mut extra,&mut contacts,&mut cached,&mut output,input,Some(&mut map)).unwrap(); + assert_eq!(contacts.push_speed,7.0); //inactive event performs no writes + let attributes:Vec<_>=SCALAR_ATTRIBUTES.iter().map(|a|MotionGraphAttribute {name:a.encoded_name,value:0.5}.to_animation()).collect(); + process(&attributes,&pose,&mut fields,&mut extra,&mut contacts,&mut cached,&mut output,input,Some(&mut map)).unwrap(); + assert_eq!(cached.prepared_controls,[0.25,-0.75]); + assert_eq!(fields.flags2476&2,2); //PlayerControlledPump + assert_eq!(fields.flags2476&0x400000,0); //retrieving/dropping clears GrabWorld + let reset=ScalarAttributeInputs::reset(fields.flags2468,fields.flags2488); + assert_eq!(reset.flags2468,0x2008); + assert_eq!((reset.turn_scale,reset.magnitude_scale,reset.cadence_end_percent),(1.0,1.0,-1.0)); +} diff --git a/crates/skate-core/src/animation/tests/kickturn.rs b/crates/skate-core/src/animation/tests/kickturn.rs new file mode 100644 index 00000000..58ea39ae --- /dev/null +++ b/crates/skate-core/src/animation/tests/kickturn.rs @@ -0,0 +1,56 @@ +use super::*; + +fn graph(y: [f32; 8]) -> PointGraph<8> { + PointGraph { + x: [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0], + y, + } +} +fn settings() -> Settings { + Settings { + spin: graph([0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0]), + height: graph([2.0; 8]), + } +} + +#[test] +fn first_update_uses_unscaled_height_seed_without_advancing_time() { + let settings = settings(); + let mut state = State::new(); + state.begin(&settings); + state.capture_animation_length(1.0); + let p = Parameters { + max_height: 0.3, + spin_scale: 0.5, + }; + let first = state.update(0.25, -0.8, p, &settings); + assert_eq!( + first, + Output { + balance: -0.3, + spin: -0.0 + } + ); + assert!(!state.needs_animation_length()); + //Absent/zero input keeps the original negative intent direction. + assert_eq!(state.update(0.25, 0.0, p, &settings).spin, -0.125); + assert_eq!(state.update(0.25, -0.0, p, &settings).spin, -0.25); + state.begin(&settings); + state.capture_animation_length(1.0); + assert_eq!(state.update(0.25, 0.0, p, &settings).spin.to_bits(), 0); +} + +#[test] +fn phase_clamp_preserves_zero_length_and_end_behavior() { + let settings = settings(); + let p = Parameters { + max_height: 0.3, + spin_scale: 0.5, + }; + let mut state = State::new(); + state.begin(&settings); + state.capture_animation_length(0.0); + //0/0 follows the original fsel path to phase1, rather than phase0. + assert_eq!(state.update(0.25, 1.0, p, &settings).spin, 0.5); + assert_eq!(state.update(10.0, -1.0, p, &settings).spin, -0.5); +} diff --git a/crates/skate-core/src/animation/tests/pose_blend.rs b/crates/skate-core/src/animation/tests/pose_blend.rs new file mode 100644 index 00000000..ec820dfb --- /dev/null +++ b/crates/skate-core/src/animation/tests/pose_blend.rs @@ -0,0 +1,39 @@ +use super::*; + +fn pose(rotation: [f32; 4]) -> Sqt { + Sqt { + scale: [1.0; 4], + rotation, + translation: [0.0; 4], + } +} + +#[test] +fn blend_retains_native_antipodal_and_zero_dot_decisions() { + let identity = pose([0.0, 0.0, 0.0, 1.0]); + let antipode = pose([0.0, 0.0, 0.0, -1.0]); + let same = blend_sample(identity, antipode, 0.5); + assert!((same.rotation[3] - 1.0).abs() < 1e-6); + let half_turn = pose([1.0, 0.0, 0.0, 0.0]); + let quarter = blend_sample(identity, half_turn, 0.5); + assert!(quarter.rotation[0] < 0.0); //Native sign gate is >0, not >=0. + assert!((quarter.rotation[0].abs() - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6); + assert!((quarter.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6); +} + +#[test] +fn nested_blends_preserve_tree_order_and_per_blend_normalization() { + let a = pose([0.0, 0.0, 0.0, 1.0]); + let b = pose([0.6, 0.0, 0.0, 0.8]); + let mut c = pose([0.0, 0.6, 0.0, 0.8]); + c.translation = [4.0, 8.0, 12.0, 0.0]; + let left = blend_sample(blend_sample(a, b, 0.5), c, 0.25); + let right = blend_sample(a, blend_sample(b, c, 0.25), 0.5); + assert_eq!(left.translation, [1.0, 2.0, 3.0, 0.0]); + assert!( + left.rotation + .iter() + .zip(right.rotation) + .any(|(a, b)| (a - b).abs() > 0.01) + ); +} diff --git a/crates/skate-core/src/animation/tests/pose_sample_vbr.rs b/crates/skate-core/src/animation/tests/pose_sample_vbr.rs new file mode 100644 index 00000000..7fd2bcb8 --- /dev/null +++ b/crates/skate-core/src/animation/tests/pose_sample_vbr.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn boundary_uses_second_key_but_retains_two_frame_normalization() { + let first = Sqt { + scale: [1.0; 4], rotation: [0.0, 0.0, 2.0, 0.0], + translation: [7.0, 0.0, 0.0, 1.0], + }; + let second = Sqt { + rotation: [0.0, 0.0, 0.0, 2.0], translation: [8.0, 0.0, 0.0, 1.0], ..first + }; + let at_boundary = FrameSelection { first: 7, second: 8, coefficient: 0.25 }; + let sampled = sample_key_vbr(first, second, at_boundary); + assert_eq!(sampled.translation, second.translation); + assert_eq!(sampled.rotation, [0.0, 0.0, 0.0, 1.0]); + let exact = FrameSelection { first: 8, second: 8, coefficient: 0.0 }; + assert_eq!(sample_key_vbr(second, second, exact).rotation, second.rotation); + let within = FrameSelection { first: 6, second: 7, coefficient: 0.25 }; + assert_eq!(sample_key_vbr(first, second, within), sample_key(first, second, within)); +} diff --git a/crates/skate-core/src/animation/vbr/bits.rs b/crates/skate-core/src/animation/vbr/bits.rs new file mode 100644 index 00000000..daa813a0 --- /dev/null +++ b/crates/skate-core/src/animation/vbr/bits.rs @@ -0,0 +1,64 @@ +//! TU3 Vector_UnPackFrameBlock82E8A928, expressed as scalar bit readers. +//! The native reverse-byte permutation830CEAD0 (initializer82F96DF8) makes +//! both streams little endian. Width nibbles come from big-endian header words. + +struct Bits<'a> { + bytes: &'a [u8], + position: usize, +} +impl Bits<'_> { + fn read(&mut self, count: u32) -> Result { + let mut value = 0; + for bit in 0..count { + let byte = self + .bytes + .get(self.position / 8) + .ok_or("VBR coefficient bit stream truncated")?; + value |= (((byte >> (self.position % 8)) & 1) as u32) << bit; + self.position += 1; + } + Ok(value) + } +} + +pub(super) fn unpack(block: &[u8], widths: &[u32]) -> Result, String> { + if widths.is_empty() { + return Ok(Vec::new()); + } + let prefix = *block.first().ok_or("VBR block has no selector length")? as usize; + let selectors = block.get(1..).ok_or("VBR selector stream missing")?; + let magnitudes = block + .get(1 + prefix.div_ceil(8)..) + .ok_or("VBR selector length exceeds block")?; + let mut selected = Bits { + bytes: selectors, + position: 0, + }; + let mut packed = Bits { + bytes: magnitudes, + position: 0, + }; + let mut output = Vec::with_capacity(widths.len()); + for &descriptor in widths { + let mut coefficients = [0.0; 8]; + for (i, coefficient) in coefficients.iter_mut().enumerate() { + let width = (descriptor >> (4 * i)) & 15; + // A zero width consumes neither a selector nor packed data. + if width != 0 && selected.read(1)? != 0 { + let positive = packed.read(1)? != 0; + let magnitude = packed.read(width)? as f32; + // The original complements the sign bit, then shifts it to + // bit31 and ORs it into the converted unsigned magnitude. + // Preserve negative zero; this is not two's complement. + *coefficient = + f32::from_bits(magnitude.to_bits() | if positive { 0 } else { 0x8000_0000 }); + } + } + output.push(coefficients); + } + Ok(output) +} + +#[cfg(test)] +#[path = "tests/bits.rs"] +mod tests; diff --git a/crates/skate-core/src/animation/vbr/header.rs b/crates/skate-core/src/animation/vbr/header.rs new file mode 100644 index 00000000..09b62d6a --- /dev/null +++ b/crates/skate-core/src/animation/vbr/header.rs @@ -0,0 +1,175 @@ +//! Original TU3 InitPerAnim82E88FA8 and UnPackHeaderBits82E8A780. +use std::ops::Range; + +#[derive(Clone, Debug)] +pub(super) struct Channel { + pub dynamic: usize, + pub constant: usize, + pub components: usize, + pub min: f32, + pub range: f32, +} + +pub(super) struct Header { + pub frames: usize, + pub bones: usize, + pub dct_scale: f32, + pub channels: Vec, + pub constants: Vec, + pub constant_flags: Vec, + pub widths: Vec, + pub blocks: Vec>, +} + +impl Header { + pub fn read( + bytes: &[u8], + part: usize, + header_relative: usize, + data_relative: usize, + ) -> Result { + let header_word = u32_at(bytes, part)?; + let bones = ((header_word >> 6) & 63) as usize + 1; + let header = offset(part, header_relative)?; + let header_end = offset(header, (header_word >> 12) as usize)?; + let data = offset(part, data_relative)?; + let data_end = offset(data, u32_at(bytes, offset(part, 4)?)? as usize)?; + take( + bytes, + header, + header_end.checked_sub(header).ok_or("VBR header range")?, + )?; + take( + bytes, + data, + data_end.checked_sub(data).ok_or("VBR data range")?, + )?; + let frames = u16_at(bytes, header + 2)? as usize; + let count = take(bytes, header + 12, 2)?; + if count[0] != 3 || frames == 0 { + return Err("VBR SQT requires three channel types and a nonempty frame range".into()); + } + let dictionary = header + 16; + let dictionary_count = count[1] as usize; + let size_table = offset(dictionary, dictionary_count * 4)?; + let block_count = frames.div_ceil(8); + let types = offset(offset(size_table, block_count * 2)?, 15)? & !15; + if offset(types, 48)? > header_end { + return Err("VBR channel descriptions exceed compression header".into()); + } + let mut channels = Vec::with_capacity(3); + for (i, expected_components) in [3, 4, 3].into_iter().enumerate() { + let at = types + 16 * i; + let dynamic = u16_at(bytes, at)? as usize; + let constant = u16_at(bytes, at + 2)? as usize; + let components = take(bytes, at + 12, 1)?[0] as usize; + let min = f32::from_bits(u32_at(bytes, at + 4)?); + let max = f32::from_bits(u32_at(bytes, at + 8)?); + if components != expected_components || dynamic + constant != bones { + return Err(format!( + "VBR channel {i} does not match its SQT output descriptor" + )); + } + channels.push(Channel { + dynamic, + constant, + components, + min, + range: max - min, + }); + } + let mut cursor = data; + let mut constants = Vec::new(); + for channel in &channels { + for _ in 0..channel.constant * channel.components { + let index = take(bytes, cursor, 1)?[0] as usize; + cursor += 1; + if index >= dictionary_count { + return Err("VBR constant dictionary index out of bounds".into()); + } + let value = f32::from_bits(u32_at(bytes, dictionary + index * 4)?); + // Original scalar fmadds82E8A804..878, including constants. + constants.push(value.mul_add(channel.range, channel.min)); + } + } + let runs = u16_at(bytes, header)? as usize; + let mut constant_flags = Vec::with_capacity(3 * bones); + for run in 0..runs { + let length = take(bytes, cursor, 1)?[0] as usize; + cursor += 1; + if constant_flags.len() + length > 3 * bones { + return Err("VBR constant-channel run exceeds channel count".into()); + } + constant_flags.extend(std::iter::repeat_n(run % 2 == 1, length)); + } + if runs == 0 && constants.is_empty() { + constant_flags.resize(3 * bones, false); + } + if constant_flags.len() != 3 * bones { + return Err("VBR constant-channel runs do not cover all channels".into()); + } + for (i, channel) in channels.iter().enumerate() { + if constant_flags[i * bones..(i + 1) * bones] + .iter() + .filter(|v| **v) + .count() + != channel.constant + { + return Err(format!( + "VBR channel {i} constant count differs from its runs" + )); + } + } + let dynamic_count = channels + .iter() + .map(|c| c.dynamic * c.components) + .sum::(); + let mut widths = Vec::with_capacity(dynamic_count); + for _ in 0..dynamic_count { + widths.push(u32_at(bytes, cursor)?); + cursor += 4; + } + let block_start = cursor; + let mut blocks = Vec::with_capacity(block_count); + // Vector_ExtractPackedNVBR82D1E0E4..118 accumulates u16 block sizes. + let mut displacement = 0u16; + for block in 0..block_count { + let size = u16_at(bytes, size_table + 2 * block)?; + let start = offset(block_start, displacement as usize)?; + let end = offset(start, size as usize)?; + if end > data_end || end < start || size < 3 { + return Err(format!("VBR block {block} exceeds compressed data")); + } + blocks.push(start..end); + displacement = displacement.wrapping_add(size); + } + let low = f32::from_bits(u32_at(bytes, header + 4)?).abs(); + let high = f32::from_bits(u32_at(bytes, header + 8)?).abs(); + Ok(Self { + frames, + bones, + dct_scale: low.max(high), + channels, + constants, + constant_flags, + widths, + blocks, + }) + } +} + +pub(super) fn offset(base: usize, relative: usize) -> Result { + base.checked_add(relative) + .ok_or_else(|| "VBR offset overflow".into()) +} +pub(super) fn take(bytes: &[u8], at: usize, count: usize) -> Result<&[u8], String> { + bytes + .get(at..offset(at, count)?) + .ok_or_else(|| format!("VBR read {at:#x}+{count} out of bounds")) +} +pub(super) fn u16_at(bytes: &[u8], at: usize) -> Result { + Ok(u16::from_be_bytes(take(bytes, at, 2)?.try_into().unwrap())) +} +pub(super) fn u32_at(bytes: &[u8], at: usize) -> Result { + Ok(u32::from_be_bytes(take(bytes, at, 4)?.try_into().unwrap())) +} diff --git a/crates/skate-core/src/animation/vbr/mod.rs b/crates/skate-core/src/animation/vbr/mod.rs new file mode 100644 index 00000000..b43edaf8 --- /dev/null +++ b/crates/skate-core/src/animation/vbr/mod.rs @@ -0,0 +1,85 @@ +//! Direct stock VBR SQT decoding from original Skate 3 TU3. +//! Source:82E88FA8/82E8A780(header),82E8A928(bits),82E89770(transform), +//! 82E89A60/82E88BA0(output), and82D20298(SQT channel descriptors). +//! Host allocation/cache ownership is independent. PC floating-point arithmetic +//! is not claimed to emulate the Xenon's numerical instructions bit for bit. +mod bits; +mod header; +mod transform; + +pub struct VbrDecoder<'a> { + bytes: &'a [u8], + header: header::Header, + cached_block: Option<(usize, Vec<[f32; 8]>)>, +} +impl<'a> VbrDecoder<'a> { + pub fn new( + bytes: &'a [u8], + part_offset: usize, + compression_header_relative: usize, + compressed_data_relative: usize, + ) -> Result { + Ok(Self { + bytes, + header: header::Header::read( + bytes, + part_offset, + compression_header_relative, + compressed_data_relative, + )?, + cached_block: None, + }) + } + pub fn frame_count(&self) -> usize { + self.header.frames + } + pub fn channel_count(&self) -> usize { + self.header.bones + } + + ///Return each authored bone's scaleXYZ, quaternionXYZW, translationXYZ bits. + ///No quaternion normalization, retargeting, hierarchy changes or weight edits. + pub fn decode_frame(&mut self, frame: usize) -> Result, String> { + if frame >= self.header.frames { + return Err(format!("VBR frame {frame} out of range")); + } + let block_index = frame / 8; + let block = &self.bytes[self.header.blocks[block_index].clone()]; + if self.cached_block.as_ref().map(|v| v.0) != Some(block_index) { + self.cached_block = + Some((block_index, bits::unpack(&block[3..], &self.header.widths)?)); + } + let coefficients = &self.cached_block.as_ref().unwrap().1; + let mut output = vec![[0; 10]; self.header.bones]; + let mut constant = 0; + let mut dynamic = 0; + let mut component_offset = 0; + for (channel_index, channel) in self.header.channels.iter().enumerate() { + for (bone, sqt) in output.iter_mut().enumerate() { + let is_constant = + self.header.constant_flags[channel_index * self.header.bones + bone]; + for component in 0..channel.components { + let value = if is_constant { + let value = self.header.constants[constant]; + constant += 1; + value + } else { + let value = transform::decode( + frame % 8, + block[channel_index], + self.header.dct_scale, + coefficients[dynamic], + channel.min, + channel.range, + ); + dynamic += 1; + value + }; + sqt[component_offset + component] = value.to_bits(); + } + } + component_offset += channel.components; + } + Ok(output) + } +} diff --git a/crates/skate-core/src/animation/vbr/tests/bits.rs b/crates/skate-core/src/animation/vbr/tests/bits.rs new file mode 100644 index 00000000..989c92b6 --- /dev/null +++ b/crates/skate-core/src/animation/vbr/tests/bits.rs @@ -0,0 +1,24 @@ +use super::unpack; + +#[test] +fn native_sign_bit_and_zero_width_do_not_consume_extra_selectors() { + // Widths3,0,2: selector bits11, packed negative5=1010 and positive2=101. + let values = unpack(&[2, 3, 0x5a], &[0x0203]).unwrap(); + assert_eq!(&values[0][..3], &[-5.0, 0.0, 2.0]); + let negative_zero = unpack(&[1, 1, 0], &[1]).unwrap(); + assert_eq!(negative_zero[0][0].to_bits(), 0x8000_0000); +} + +#[test] +fn packed_words_continue_across_the_native_vector_boundary() { + // Nine width15 coefficients consume nine sign+magnitude16-bit words. + let mut bytes = vec![9, 0xff, 1]; + bytes.extend_from_slice(&[ + 0xff, 0xff, 2, 0, 5, 0, 6, 0, 9, 0, 10, 0, 13, 0, 14, 0, 17, 0, + ]); + let values = unpack(&bytes, &[0xffff_ffff, 15]).unwrap(); + assert_eq!(values[0], [32767.0, -1.0, 2.0, -3.0, 4.0, -5.0, 6.0, -7.0]); + assert_eq!(values[1][0], 8.0); + bytes.pop(); + assert!(unpack(&bytes, &[0xffff_ffff, 15]).is_err()); +} diff --git a/crates/skate-core/src/animation/vbr/transform.rs b/crates/skate-core/src/animation/vbr/transform.rs new file mode 100644 index 00000000..a9619a5a --- /dev/null +++ b/crates/skate-core/src/animation/vbr/transform.rs @@ -0,0 +1,106 @@ +//! TU3 Init82E891D8, IDCT table82E88C58, DecompressFrameBlock82E89770. +//! All coefficient words below were read from the original TU3 mapped image. +use std::sync::OnceLock; + +struct Tables { + quantization: [[f32; 8]; 256], + inverse_dct: [[f32; 8]; 8], +} +fn tables() -> &'static Tables { + static TABLES: OnceLock = OnceLock::new(); + TABLES.get_or_init(|| { + let base: [f32; 8] = std::array::from_fn(|i| { + logarithm_for_frequency(i as u32 + 2) * f32::from_bits(0x3800_0000) + }); + let mut multiplier = 1.0f32; + let quantization = std::array::from_fn(|_| { + let row = base.map(|v| v * multiplier); + multiplier += f32::from_bits(0x3e4c_cccd); // 82099280; accumulated fadds + row + }); + // 822F8B48..58 and individual .785398/.1.570796 literal loads. + let angles = [ + 0, + 0x3ec9_0fdb, + 0x3f49_0fdb, + 0x3f96_cbe4, + 0x3fc9_0fdb, + 0x3ffb_53d2, + 0x4016_cbe4, + 0x402f_ede0, + ]; + let inverse_dct = std::array::from_fn(|frame| { + let phase = frame as f32 + 0.5; + let mut row = + angles.map(|a| crate::trigonometry::cos(phase * f32::from_bits(a)) * 0.25); + row[0] *= 0.5; + row + }); + Tables { + quantization, + inverse_dct, + } + }) +} + +/// Original logarithm polynomial82E89598..5DC, evaluated only at integers2..9. +/// For this bounded positive domain, exponent extraction supplies exactly the +/// integer requested by the original floor(log2-estimate) instruction pair. +fn logarithm_for_frequency(integer: u32) -> f32 { + let input = integer as f32; + let exponent = ((input.to_bits() >> 23) & 255) as i32 - 127; + let mantissa = f32::from_bits((input.to_bits() & 0x007f_ffff) | 0x3f80_0000); + let a = mantissa - 1.0; + let a2 = a * a; + let a3 = a * a2; + let a4 = a2 * a2; + // Original vectors8230EA70 and8230EA80. + let c = [ + 0x3fb8_aa0e, + 0xbf38_9e52, + 0x3ef5_162d, + 0xbeb1_d204, + 0x3e77_adbd, + 0xbe0c_d4fb, + 0x3d55_41c6, + 0xbc18_8b0b, + ] + .map(f32::from_bits); + let low = a.mul_add(c[1], c[0]); + let high = a.mul_add(c[5], c[4]); + let low = a2.mul_add(c[2], low); + let high = a2.mul_add(c[6], high); + let low = a3.mul_add(c[3], low); + let high = a3.mul_add(c[7], high); + let polynomial = a4.mul_add(high, low); + a.mul_add(polynomial, exponent as f32) * f32::from_bits(0x3f31_7218) //82139A70 +} + +pub(super) fn decode( + frame: usize, + quantization_index: u8, + scale: f32, + coefficients: [f32; 8], + channel_min: f32, + channel_range: f32, +) -> f32 { + let tables = tables(); + let squared = scale * scale; + // Literal820612E0 is 2^-15. This equation was independently recovered + // from original vmulfp/vmaddcfp instructions82E89930..4C. + let bias = scale * f32::from_bits(0x3800_0000); + let weights: [f32; 8] = std::array::from_fn(|i| { + tables.quantization[quantization_index as usize][i].mul_add(squared, bias) + * tables.inverse_dct[frame][i] + }); + let first = crate::physics::native_arithmetic::dot4( + coefficients[..4].try_into().unwrap(), + weights[..4].try_into().unwrap(), + ); + let last = crate::physics::native_arithmetic::dot4( + coefficients[4..].try_into().unwrap(), + weights[4..].try_into().unwrap(), + ); + // BSS830CEAF0 initializer82F96D58 converts integer1 with scale1 -> .5. + ((first + last) + 0.5).mul_add(channel_range, channel_min) +} diff --git a/crates/skate-core/src/camera/anchors.rs b/crates/skate-core/src/camera/anchors.rs new file mode 100644 index 00000000..c5948929 --- /dev/null +++ b/crates/skate-core/src/camera/anchors.rs @@ -0,0 +1,100 @@ +//! Seven normal-camera anchors created by TU382DF2DB8 and updated82DF69C0. +//! Names come from that constructor; notably HipsAnchor reads reckoned COM. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorInputs { + /// Subject388: physical output entry0+144. + pub board_position: [f32; 4], + /// Subject392/396: board output entry1+80/+64 respectively. + pub board_velocity: [f32; 4], + pub board_acceleration: [f32; 4], + /// Subject456/460/464: reckoning direction, COM and filtered COM. + pub board_offset_direction: [f32; 4], + pub center_of_mass: [f32; 4], + pub damped_center_of_mass: [f32; 4], + /// Subject384 matrix+16 and436. + pub skeleton_root_up: [f32; 4], + pub grind_point: [f32; 4], + /// Subject596/544; reset is state output byte62. + pub grinding: bool, + pub reset: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorState { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub acceleration: [f32; 4], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Anchors { + /// Skateboard, Hips, Air, Grind, World, DampedCOM, POI in native order. + pub entries: [AnchorState; 7], + grind_position: [f32; 4], +} + +impl Anchors { + pub fn new() -> Self { + Self { + entries: [AnchorState { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + }; 7], + grind_position: [0.0; 4], + } + } + + pub fn update(&mut self, input: AnchorInputs) { + // Publisher82DF7790 always passes this fixed timestep to every anchor, + // independently of CameraMan's render update timestep. + let dt = f32::from_bits(0x3c888889); + let inverse = super::vector_tracker::refined_reciprocal(dt); + let board = core::array::from_fn(|i| { + input.board_offset_direction[i] + .mul_add(f32::from_bits(0x3e99999a), input.board_position[i]) + }); + let air = core::array::from_fn(|i| { + input.center_of_mass[i] - input.skeleton_root_up[i] * f32::from_bits(0x3ea8f5c3) + }); + // GrindAnchor82DF2C78 retains and advects its point after leaving a rail. + if input.reset || input.grinding { + let point = if input.reset { + input.board_position + } else { + input.grind_point + }; + self.grind_position = + core::array::from_fn(|i| input.board_offset_direction[i].mul_add(0.5, point[i])); + } else { + self.grind_position = core::array::from_fn(|i| { + input.board_velocity[i].mul_add(dt, self.grind_position[i]) + }); + } + let positions = [ + board, + input.center_of_mass, + air, + self.grind_position, + [0.0; 4], + input.damped_center_of_mass, + [0.0; 4], + ]; + for (index, (state, position)) in self.entries.iter_mut().zip(positions).enumerate() { + // Common82DF2950: first publish position, then derive velocity; + // acceleration comes from a separate getter and is not differenced. + let previous = state.position; + state.position = position; + state.velocity = if input.reset { + [0.0; 4] + } else { + core::array::from_fn(|i| inverse * (position[i] - previous[i])) + }; + state.acceleration = if matches!(index, 0 | 2 | 3) { + input.board_acceleration + } else { + [0.0; 4] + }; + } + } +} diff --git a/crates/skate-core/src/camera/angle_tracker.rs b/crates/skate-core/src/camera/angle_tracker.rs new file mode 100644 index 00000000..fd8c3fe9 --- /dev/null +++ b/crates/skate-core/src/camera/angle_tracker.rs @@ -0,0 +1,88 @@ +//! TU3 angular-tracking candidates; exceptional wrap conversion is unavailable. +use super::{ScalarTracker, ScalarTrackerParameters}; + +/// Angular state has the same six-word layout as the scalar tracker. Angles +/// and angular rates use radians; effective settings are explicitly supplied. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AngleTracker(pub ScalarTracker); + +impl AngleTracker { + pub fn update(&mut self, dt: f32, target: f32, parameters: ScalarTrackerParameters) { + let state = &mut self.0; + let position = state.position; + state.target = target; + let delta = normalize_angle(target - position); + if parameters.overshoot_zeroes_velocity && state.velocity * delta < 0.0 { + state.velocity *= 0.0; + } + let velocity = state.velocity; + let inverse_dt = 1.0 / dt; + state.acceleration = inverse_dt.mul_add(delta, -velocity) * inverse_dt; + let magnitude = delta.abs(); + let fraction = if magnitude < parameters.delta_umbra { + 1.0 + } else if magnitude < parameters.delta_penumbra { + 1.0 - (magnitude - parameters.delta_umbra) + / (parameters.delta_penumbra - parameters.delta_umbra) + } else { + 0.0 + }; + if state.acceleration * delta > 0.0 { + state.acceleration_clamp = super::tracker::interpolate( + parameters.acceleration_clamp_min, + parameters.acceleration_clamp_max, + fraction, + ); + let magnitude = state.acceleration.abs(); + if magnitude > state.acceleration_clamp { + state.acceleration = (state.acceleration / magnitude) * state.acceleration_clamp; + } + } + state.velocity = state.acceleration.mul_add(dt, velocity); + let magnitude = state.velocity.abs(); + if magnitude > parameters.speed_clamp { + state.velocity = (state.velocity / magnitude) * parameters.speed_clamp; + } + // Native normalizes each intermediate separately. Combining these into + // a single wrap, or fusing the position addition, changes boundary cases. + let next = normalize_angle(normalize_angle(state.velocity * dt) + position); + state.smoothing = super::tracker::interpolate( + parameters.smoothing_min, + parameters.smoothing_max, + fraction, + ); + let displacement = normalize_angle(next - position); + let smoothed = normalize_angle(displacement * (1.0 - state.smoothing)); + state.position = normalize_angle(smoothed + position); + } +} + +/// Candidate 8258DB98 wrapping sequence. Its fast interval is [-pi, pi), and +/// its reduction uses truncation followed by one correction, not rem_euclid. +pub fn normalize_angle(angle: f32) -> f32 { + const PI: f32 = f32::from_bits(0x40490fdb); + const NEG_PI: f32 = f32::from_bits(0xc0490fdb); + const INV_TAU: f32 = f32::from_bits(0x3e22f983); + const TAU: f32 = f32::from_bits(0x40c90fdb); + if angle >= NEG_PI && angle < PI { + return angle; + } + let turns = angle * INV_TAU; + // Ordinary finite truncation follows the native fctiwz operation. Its + // exceptional/overflow results are unresolved; do not borrow host rules. + if !turns.is_finite() || turns < i32::MIN as f32 || turns as f64 > i32::MAX as f64 { + panic!( + "Skate 3 exceptional angle integer conversion is unavailable: independent implementation required" + ); + } + let count = turns as i32; + let count = count as f32; + let reduced = -((count as f64).mul_add(TAU as f64, -(angle as f64))) as f32; + if reduced >= PI { + reduced - TAU + } else if reduced < NEG_PI { + reduced + TAU + } else { + reduced + } +} diff --git a/crates/skate-core/src/camera/avoidance.rs b/crates/skate-core/src/camera/avoidance.rs new file mode 100644 index 00000000..f13793a7 --- /dev/null +++ b/crates/skate-core/src/camera/avoidance.rs @@ -0,0 +1,262 @@ +//! Complete TU3 normal-camera avoidance82E04C20. Three native PathEvaluators +//! retain separate asynchronous requests. World/subject adapters are external. + +use super::vector_tracker::{dot, length, refined_reciprocal}; +use super::{ + MovingObstacleProvider, PathEvaluator, Positioner, PredictionPath, TrajectoryCollisionRequest, + direction_to_angles, position_from_angles, +}; + +pub trait AvoidanceSubject { + fn transform_376(&mut self) -> [[f32; 4]; 4]; + fn vector_452(&mut self) -> [f32; 4]; + fn vector_444(&mut self) -> [f32; 4]; + fn context_532(&mut self) -> u32; + fn flag_608(&mut self) -> u8; + fn flag_548(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AvoidancePath { + pub evaluator: PathEvaluator, + /// Native path byte56, cleared by each avoidance submission. + pub prediction_flag: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AvoidanceSettings { + /// Parameter offsets628,632,636,640,920. + pub horizon: f32, + pub heading_smoothing: f32, + pub elevation_smoothing: f32, + pub ease_out_time: f32, + pub radius_padding: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigAvoidance { + pub anchor: [f32; 4], + pub velocity: [f32; 4], + pub tracked_anchor: [f32; 4], + pub heading_target: f32, + pub heading_reference: f32, + pub elevation_target: f32, + pub avoidance_heading: f32, + pub avoidance_elevation: f32, + pub reset_time: f32, + pub disable_time: f32, + pub height_transition_start: f32, + pub height_transition_end: f32, + pub height_transition_duration: f32, + pub height_transition_time: f32, + pub reference_height: f32, + pub prediction_distance: f32, + pub heading_weight: f32, + pub elevation_weight: f32, + pub avoidance_mode: u32, + pub height_mode: u32, + pub flags_516: u8, + pub paths: [AvoidancePath; 3], +} + +impl RigAvoidance { + /// Complete82E04C20. The last breadcrumb is supplied from the native ring + /// selector (capacity+index-1)%capacity; no sampled/fitted trail is generated. + pub fn update( + &mut self, + dt: f32, + settings: AvoidanceSettings, + positioner: &Positioner, + last_breadcrumb: [f32; 4], + subject: &mut impl AvoidanceSubject, + requests: &mut [impl TrajectoryCollisionRequest; 3], + moving: &mut impl MovingObstacleProvider, + ) { + if self.avoidance_mode == 1 && self.subject_speed(subject) < 0.5 { + return; + } + if self.flags_516 & 2 != 0 || self.disable_time > 0.0 || self.avoidance_mode == 0 { + self.heading_weight = 0.0; + self.elevation_weight = 0.0; + return; + } + match self.avoidance_mode { + 1 => { + let mut anchor = self.tracked_anchor; + anchor[1] = self.reference_height; + let delta = core::array::from_fn(|i| last_breadcrumb[i] - anchor[i]); + let magnitude = length(delta); + if f32::from_bits(0x38d1b717) > magnitude { + return; + } + let inverse = refined_reciprocal(magnitude); + let angles = direction_to_angles(delta.map(|v| inverse * v)); + self.avoidance_heading = -angles[1]; + self.avoidance_elevation = angles[0]; + } + 2 => { + let angles = direction_to_angles(subject.vector_452()); + self.avoidance_heading = -angles[1]; + self.avoidance_elevation = angles[0]; + } + 3 => { + self.avoidance_heading = self.heading_reference; + self.avoidance_elevation = f32::from_bits(0x3fbde44e); + } + 4 => { + self.avoidance_heading = + super::rig_tracking::wrap_vmx(self.heading_target + f32::from_bits(0x40490fdb)); + let low = f32::from_bits(0x3e32b8c2); + self.avoidance_elevation = if low - self.elevation_target >= 0.0 { + low + } else { + self.elevation_target + }; + } + _ => {} + } + if self.avoidance_elevation.is_nan() { + return; + } + let prediction = position_from_angles( + self.anchor, + [0.0; 4], + self.avoidance_elevation, + self.avoidance_heading, + self.prediction_distance, + positioner.config.clamp_height, + positioner.radius + positioner.config.floor_height, + ); + let transitioning = self.height_transition_time < self.height_transition_duration; + let mut velocity = self.velocity; + if transitioning { + velocity[1] = (self.height_transition_end - self.height_transition_start) + / self.height_transition_duration; + } + let acceleration_flag = u8::from(self.height_mode == 1 && !transitioning); + self.paths[0].set( + PredictionPath { + // v127 preserves the current positioner position across the + // prediction helper; its returned point is used by path1. + position: positioner.position, + velocity, + radius: settings.radius_padding + positioner.radius, + horizon: settings.horizon, + }, + acceleration_flag, + ); + let context = subject.context_532(); + self.paths[0] + .evaluator + .update(context, &mut requests[0], moving); + let camera_time = self.paths[0].evaluator.last_valid_time; + let second_start = if self.height_mode == 1 { + prediction + } else { + [ + self.anchor[0], + positioner.position[1], + self.anchor[2], + self.anchor[0], + ] + }; + self.paths[1].set( + PredictionPath { + position: second_start, + velocity, + radius: f32::from_bits(0x3ca3d70a), + horizon: settings.horizon, + }, + acceleration_flag, + ); + let context = subject.context_532(); + self.paths[1] + .evaluator + .update(context, &mut requests[1], moving); + let third_start = subject.vector_444(); + self.paths[2].set( + PredictionPath { + position: third_start, + velocity, + radius: f32::from_bits(0x3ca3d70a), + horizon: settings.horizon, + }, + acceleration_flag, + ); + let context = subject.context_532(); + self.paths[2] + .evaluator + .update(context, &mut requests[2], moving); + let second_time = self.paths[1].evaluator.last_valid_time; + let third_time = self.paths[2].evaluator.last_valid_time; + let any_acceleration = self.paths[1].evaluator.result_acceleration_flag != 0 + || self.paths[2].evaluator.result_acceleration_flag != 0; + let other_time = if second_time - third_time >= 0.0 { + second_time + } else { + third_time + }; + if third_time >= second_time { + self.flags_516 |= 1; + } + let subject_flag = subject.flag_608(); + if subject_flag == 0 && positioner.flags & 0x20 != 0 { + self.heading_weight = dt * 0.5 + self.heading_weight; + self.elevation_weight = dt * 0.5 + self.elevation_weight; + } else if subject_flag == 0 + && self.height_mode != 1 + && (if any_acceleration { + other_time * 0.5 > camera_time + } else { + other_time > camera_time + }) + { + let target = clamp(1.0 - camera_time / settings.horizon); + let heading_target = if self.heading_weight - target >= 0.0 { + self.heading_weight + } else { + target + }; + let elevation_target = if self.elevation_weight - target >= 0.0 { + self.elevation_weight + } else { + target + }; + self.heading_weight = (1.0 - settings.heading_smoothing) + .mul_add(heading_target - self.heading_weight, self.heading_weight); + self.elevation_weight = (1.0 - settings.elevation_smoothing).mul_add( + elevation_target - self.elevation_weight, + self.elevation_weight, + ); + } else if (self.subject_speed(subject) > f32::from_bits(0x3e19999a) + && self.reset_time <= 0.0) + || subject.flag_548() != 0 + { + self.heading_weight = floor_zero(self.heading_weight - dt / settings.ease_out_time); + self.elevation_weight = floor_zero(self.elevation_weight - dt / settings.ease_out_time); + self.flags_516 &= !1; + } + self.heading_weight = clamp(self.heading_weight); + self.elevation_weight = clamp(self.elevation_weight); + } + + /// Complete82E03060; subject transform+32 is its forward vector. + fn subject_speed(&self, subject: &mut impl AvoidanceSubject) -> f32 { + dot(self.velocity, subject.transform_376()[2]) + } +} + +impl AvoidancePath { + fn set(&mut self, path: PredictionPath, acceleration_flag: u8) { + self.evaluator.path = path; + self.prediction_flag = 0; + self.evaluator.acceleration_flag = acceleration_flag; + } +} +fn floor_zero(v: f32) -> f32 { + if v >= 0.0 { v } else { 0.0 } +} +fn clamp(v: f32) -> f32 { + let v = if -v >= 0.0 { 0.0 } else { v }; + if 1.0 - v >= 0.0 { v } else { 1.0 } +} diff --git a/crates/skate-core/src/camera/compass.rs b/crates/skate-core/src/camera/compass.rs new file mode 100644 index 00000000..85a100c2 --- /dev/null +++ b/crates/skate-core/src/camera/compass.rs @@ -0,0 +1,168 @@ +//! Nine stock gameplay compass headings, TU382DF3770/82DF39B0. These are +//! subject directions consumed by authored shots, not replay camera controls. +use super::{ManagerSubject, direction_to_angles}; +use super::manager_state::clamp; +use super::vector_tracker::length; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompassSettings { + pub heading_response: f32, //parameter604 + pub time_before_lineup: f32, //880 + pub lineup_speed: f32, //884 + pub minimum_deadzone_speed: f32, //888 + pub maximum_deadzone_speed: f32, //892 + pub maximum_deadzone_size: f32, //896 + pub deadzone_smoothing: f32, //900 +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompassInputs { + pub ground_normal: [f32; 4], //0: subject452 + pub landing_normal: [f32; 4], //16: subject412 or448 + /// Current physical entry0+64, publisher cache80; not the lagged subject376. + pub transform: [[f32; 4]; 4], //32 + pub trajectory_direction: [f32; 4], //96: subject420 or normalized392 + pub launch_position: [f32; 4], //112 + pub landing_position: [f32; 4], //128 + pub grind_direction: [f32; 4], //144: subject424 + pub skeleton_direction: [f32; 4], //160: subject440=Skeleton output0 + pub camera_position: [f32; 4], //176 + pub look_target: [f32; 4], //192: subject468=output6+64 + pub look: [f32; 2], //208 + pub trajectory_fraction: f32, //224 + pub selected_compass: u32, //228 + pub no_trajectory: bool, //232bit80 + pub state_103: bool, //bit40: subject564 + pub wiping_out: bool, //bit20 + pub grinding: bool, //bit10 + pub air_flag_452: bool, //bit8 +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Compass { + pub headings: [f32; 9], //240..272 and their working copies280..312 + pub movement_heading: f32, //316 + pub orbit_delta: f32, //320 + pub velocity: [f32; 4], //336 + pub damped_velocity: [f32; 4], //352 + pub previous_position: [f32; 4], //368 + pub orbit_position: [f32; 4], //384 + pub previous_reset: bool, //400 + pub stopped_time: f32, //404 + pub deadzone_size: f32, //408 + pub deadzone_position: [f32; 2], //416 + pub deadzone_velocity: [f32; 2], //432 +} + +impl Compass { + pub fn new() -> Self { + Self { headings: [0.0; 9], movement_heading: 0.0, orbit_delta: 0.0, + velocity: [0.0; 4], damped_velocity: [0.0; 4], previous_position: [0.0; 4], + orbit_position: [0.0; 4], previous_reset: true, stopped_time: 0.0, + deadzone_size: 0.0, deadzone_position: [0.0; 2], deadzone_velocity: [0.0; 2] } + } + + ///82DF7B90 supplies0 on subject reset and exactly1/60 otherwise. + pub fn update(&mut self, dt: f32, input: CompassInputs, settings: CompassSettings) -> [f32; 9] { + if self.previous_reset { + self.previous_position = input.transform[3]; + self.stopped_time = 0.0; + self.deadzone_size = 0.0; + } + self.update_movement(dt, input, settings); + let mut launch = heading(input.ground_normal); + let mut landing = heading(input.landing_normal); + if (launch - landing).abs() > PI { + if launch >= landing { landing += TAU; } else { launch += TAU; } + } + self.headings[1] = wrap((landing - launch).mul_add(input.trajectory_fraction, launch)); + self.headings[0] = self.movement_heading; + let difference = horizontal(sub(input.landing_position, input.launch_position)); + if input.trajectory_fraction > 0.0 && length(difference) > 1.0 { + self.headings[4] = if dt == 0.0 { direction_to_angles(input.skeleton_direction)[1] } + else { -direction_to_angles(input.trajectory_direction)[1] }; + self.headings[0] = wrap(blend(self.headings[0], self.headings[4], input.trajectory_fraction)); + } + self.headings[3] = heading(input.transform[2]); + self.update_orbit(dt, input); + self.update_follow(input); + let direction = horizontal(sub(input.transform[3], input.camera_position)); + let forward = heading(input.transform[2]); + let camera_heading = heading(direction); + let delta = wrap_floor(camera_heading - forward); + let minimum = f32::from_bits(0x3f9c61aa); + self.headings[8] = if delta.abs() <= minimum { + if delta >= 0.0 { forward + minimum } else { forward - minimum } + } else { camera_heading }; + self.previous_reset = dt == 0.0; + self.headings + } + + fn update_follow(&mut self, input: CompassInputs) { + let relative = sub(input.transform[3], input.camera_position); + if input.selected_compass != 7 { + self.headings[7] = heading(horizontal(relative)); + } else if input.air_flag_452 { + self.headings[7] = heading(horizontal(sub(input.look_target, input.transform[3]))); + } else { + let velocity = horizontal(self.velocity); + let speed = length(velocity); + if speed > 0.7 { + let maximum = clamp((speed - 0.7) * f32::from_bits(0x3ede9bd3), 0.0, 1.0) * 0.6; + let target = heading(velocity); + let previous = heading(relative); + self.headings[7] = clamp(wrap_floor(target - previous) * 0.25, + -maximum, maximum) + previous; + } + } + } +} + +pub(super) const PI: f32 = f32::from_bits(0x40490fdb); +pub(super) const TAU: f32 = f32::from_bits(0x40c90fdb); +pub(super) fn horizontal(v: [f32; 4]) -> [f32; 4] { [v[0], 0.0, v[2], v[3]] } +pub(super) fn sub(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { core::array::from_fn(|i| a[i] - b[i]) } +pub(super) fn wrap(v: f32) -> f32 { super::rig_tracking::wrap_vmx(v) } +pub(super) fn blend(from: f32, to: f32, weight: f32) -> f32 { wrap(wrap(to - from).mul_add(weight, from)) } +pub(super) fn wrap_floor(v: f32) -> f32 { + let turns = v.mul_add(f32::from_bits(0x3e22f983), 0.5).floor(); + -turns.mul_add(TAU, -v) +} +///82DF3888 intentionally tests positive Y only and does not normalize XZ. +pub(super) fn heading(v: [f32; 4]) -> f32 { + if v[1] > f32::from_bits(0x3f7ff2e5) { 0.0 } + else { super::manager_incline::atan_ratio(-v[0], v[2]) } +} + +/// Physical fields not already present in ManagerSubject, used by82DF7B90. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompassPoseInputs { + pub current_physical_transform: [[f32; 4]; 4], + pub skeleton_direction: [f32; 4], + pub look_target: [f32; 4], + pub board_velocity: [f32; 4], + pub trajectory_direction: [f32; 4], + pub state_103: bool, +} +impl CompassPoseInputs { + pub fn bind(self, subject: &ManagerSubject, camera_position: [f32; 4], + selected_compass: u32) -> CompassInputs { + let trajectory = subject.rig.trajectory_valid != 0; + let trajectory_direction = if subject.rig.grinding != 0 { subject.direction_424 } + else if trajectory { self.trajectory_direction } + else { super::orientation_math::normalize(self.board_velocity) }; + CompassInputs { + ground_normal: subject.rig.last_valid_ground_up, + landing_normal: if trajectory { subject.landing_normal } else { subject.ground_normal }, + transform: self.current_physical_transform, trajectory_direction, + launch_position: if trajectory { subject.launch_position } else { [0.0; 4] }, + landing_position: if trajectory { subject.landing_position } else { [0.0; 4] }, + grind_direction: subject.direction_424, skeleton_direction: self.skeleton_direction, + camera_position, look_target: self.look_target, look: subject.look, + trajectory_fraction: if trajectory { clamp(subject.trajectory_time / subject.trajectory_duration, 0.0, 1.0) } else { 0.0 }, + selected_compass, no_trajectory: !trajectory, state_103: self.state_103, + wiping_out: subject.rig.wiping_out != 0, grinding: subject.rig.grinding != 0, + air_flag_452: subject.rig.air_flag_452 != 0, + } + } +} diff --git a/crates/skate-core/src/camera/compass_movement.rs b/crates/skate-core/src/camera/compass_movement.rs new file mode 100644 index 00000000..42606cfd --- /dev/null +++ b/crates/skate-core/src/camera/compass_movement.rs @@ -0,0 +1,63 @@ +//! Movement compass82DF3AC0 and its two-dimensional dead-zone82DF9AD0. +use super::{Compass, CompassInputs, CompassSettings, direction_to_angles}; +use super::compass::{blend, horizontal, sub}; +use super::manager_state::clamp; +use super::vector_tracker::{length, refined_reciprocal}; + +impl Compass { + pub(super) fn update_movement(&mut self, dt: f32, input: CompassInputs, s: CompassSettings) { + if !input.no_trajectory { return; } + let reset = dt == 0.0 || self.previous_reset; + if reset { + self.velocity = [0.0; 4]; + self.damped_velocity = [0.0; 4]; + } else { + let inverse = refined_reciprocal(dt); + self.velocity = sub(input.transform[3], self.previous_position).map(|v| v * inverse); + let speed = length(horizontal(self.velocity)); + let fraction = clamp((speed - s.maximum_deadzone_speed) + / (s.minimum_deadzone_speed - s.maximum_deadzone_speed), 0.0, 1.0); + self.deadzone_size = fraction.mul_add(s.maximum_deadzone_size, -self.deadzone_size) + .mul_add(1.0 - s.deadzone_smoothing, self.deadzone_size); + self.update_deadzone(dt, [input.transform[3][0], input.transform[3][2]]); + self.damped_velocity = [self.deadzone_velocity[0], 0.0, + self.deadzone_velocity[1], self.deadzone_velocity[0]]; + self.previous_position = input.transform[3]; + } + let speed = length(horizontal(self.velocity)); + if reset { + self.movement_heading = -direction_to_angles(input.skeleton_direction)[1]; + } else if input.grinding { + self.movement_heading = -direction_to_angles(input.grind_direction)[1]; + } else { + let mut target = self.movement_heading; + let mut weight = 1.0; + self.stopped_time = if input.wiping_out || speed > 0.5 { 0.0 } + else { self.stopped_time + dt }; + if speed > 0.5 { + weight = clamp(s.heading_response, 0.0, 1.0); + target = -direction_to_angles(super::orientation_math::normalize(self.damped_velocity))[1]; + } else if !input.state_103 && self.stopped_time >= s.time_before_lineup { + weight = s.lineup_speed; + target = -direction_to_angles(input.transform[2])[1]; + } + self.movement_heading = blend(self.movement_heading, target, weight); + } + } + + fn update_deadzone(&mut self, dt: f32, target: [f32; 2]) { + let old = self.deadzone_position; + let difference: [f32; 2] = core::array::from_fn(|i| target[i] - old[i]); + // The source explicitly sums two squared lanes, not vmsum3fp. + let square = difference[0] * difference[0] + difference[1] * difference[1]; + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); } + let distance = if square == 0.0 { 0.0 } else { square * inverse }; + if distance > self.deadzone_size { + self.deadzone_position = core::array::from_fn(|i| + (difference[i] * inverse).mul_add(distance - self.deadzone_size, old[i])); + } + self.deadzone_velocity = if dt == 0.0 { [0.0; 2] } + else { core::array::from_fn(|i| (self.deadzone_position[i] - old[i]) * (1.0 / dt)) }; + } +} diff --git a/crates/skate-core/src/camera/compass_orbit.rs b/crates/skate-core/src/camera/compass_orbit.rs new file mode 100644 index 00000000..c23a3c88 --- /dev/null +++ b/crates/skate-core/src/camera/compass_orbit.rs @@ -0,0 +1,66 @@ +//! Free-look compass82DF4100. Retains native orbit position, angular dead-zone, +//! thirty-degree limit relative to the last camera, and two-unit arm length. +use super::{Compass, CompassInputs}; +use super::compass::{heading, horizontal, sub, wrap}; +use super::vector_tracker::{dot, length, refined_reciprocal}; + +impl Compass { + pub(super) fn update_orbit(&mut self, dt: f32, input: CompassInputs) { + let position = input.transform[3]; + if input.selected_compass != 6 || dt == 0.0 { + self.orbit_position = input.camera_position; + self.headings[6] = heading(horizontal(sub(position, self.orbit_position))); + self.orbit_delta = 0.0; + return; + } + let stick = -input.look[0]; + let mut step = 0.0; + let mut retained: f32 = 0.95; + if stick.abs() > 0.3 { + retained = 0.9; + step = ((stick - if stick >= 0.0 { 0.3 } else { -0.3 }) * dt) + * f32::from_bits(0x408f9d9c); + } + self.orbit_delta = wrap((1.0 - retained).mul_add(step, self.orbit_delta * retained)); + let half = self.orbit_delta * 0.5; + let axis = [0.0, crate::trigonometry::sin(half), 0.0, crate::trigonometry::cos(half)]; + self.orbit_position = add(position, rotate(axis, sub(self.orbit_position, position))); + let from = horizontal(sub(self.orbit_position, position)); + let previous = horizontal(sub(input.camera_position, position)); + let from_direction = normalize_safe(from); + let previous_direction = normalize_safe(previous); + let normal = cross(from_direction, previous_direction); + if dot(from_direction, previous_direction) < f32::from_bits(0x3f5db22d) { + let magnitude = length(normal); + if magnitude >= 0.001 { + let inverse = refined_reciprocal(magnitude); + let half = -f32::from_bits(0x3f060a92) * 0.5; + let sine = crate::trigonometry::sin(half); + let axis = [normal[0] * inverse * sine, normal[1] * inverse * sine, + normal[2] * inverse * sine, crate::trigonometry::cos(half)]; + self.orbit_position = add(position, rotate(axis, previous)); + } else { self.orbit_position = input.camera_position; } + } + let relative = sub(position, self.orbit_position); + let distance = length(relative); + if distance > 0.001 { + self.orbit_position = sub(position, relative.map(|v| v * (2.0 / distance))); + } + self.headings[6] = heading(horizontal(sub(position, self.orbit_position))); + } +} +fn normalize_safe(v: [f32; 4]) -> [f32; 4] { + if length(v) > f32::from_bits(0x358637bd) { super::orientation_math::normalize(v) } + else { [0.0; 4] } +} +fn add(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { core::array::from_fn(|i| a[i] + b[i]) } +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + [(-a[2]).mul_add(b[1], a[1] * b[2]), (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), 0.0] +} +fn rotate(q: [f32; 4], v: [f32; 4]) -> [f32; 4] { + let first = cross(q, v); + let intermediate = core::array::from_fn(|i| q[3].mul_add(v[i], first[i])); + let second = cross(q, intermediate); + core::array::from_fn(|i| 2.0_f32.mul_add(second[i], v[i])) +} diff --git a/crates/skate-core/src/camera/direction.rs b/crates/skate-core/src/camera/direction.rs new file mode 100644 index 00000000..f3503591 --- /dev/null +++ b/crates/skate-core/src/camera/direction.rs @@ -0,0 +1,60 @@ +//! Complete TU3 8258DC10 direction-to-angular-vector operation, shared by +//! normal rig framing and avoidance. Input Y is consumed directly, not normalized. + +use super::{ + normalize_angle, + vector_tracker::{length, refined_reciprocal}, +}; + +pub fn direction_to_angles(direction: [f32; 4]) -> [f32; 4] { + let horizontal = [direction[0], 0.0, direction[2], 0.0]; + let magnitude = length(horizontal); + if !(magnitude > f32::from_bits(0x38d1b717)) { + return [0.0; 4]; + } + let inverse = refined_reciprocal(magnitude); + let x = inverse * horizontal[0]; + let z = inverse * horizontal[2]; + let (component, base) = if x >= 0.0 { + if z >= 0.0 { + (x, 0.0) + } else { + (-z, f32::from_bits(0x3fc90fdb)) + } + } else if z <= 0.0 { + (-x, f32::from_bits(0x40490fdb)) + } else { + (z, f32::from_bits(0x4096cbe4)) + }; + let heading = normalize_angle(asin_lane(clamp(component)) + base); + [asin_lane(clamp(direction[1])), heading, 0.0, 0.0] +} + +fn clamp(value: f32) -> f32 { + let lower = if -1.0 > value { -1.0 } else { value }; + if 1.0 < lower { 1.0 } else { lower } +} + +// Native inline asin polynomial: 822F9820/30/40, radicand822FB840. +// Keep it before the acos subtraction used by the separate82453298 operation. +fn asin_lane(value: f32) -> f32 { + let a = value.abs(); + let cube = (value * value) * a; + let c = f32::from_bits; + let p0 = c(0x400b1889).mul_add(a, c(0xc0d1360e)); + let p1 = c(0x3e663246).mul_add(a, c(0xbf983f2f)); + let p2 = c(0xbed65553).mul_add(a, c(0x408980bd)); + let p3 = c(0xbd6dd42d).mul_add(a, c(0x3f1dd7b6)); + let p0 = p0.mul_add(a, c(0x40af6ad8)); + let p1 = p1.mul_add(a, c(0x3fb58485)); + let p2 = p2.mul_add(a, c(0xc08f6ad9)); + let p3 = p3.mul_add(a, c(0xbfaf4418)); + let left = p1.mul_add(cube, p0); + let right = p3.mul_add(cube, p2); + let radicand = c(0x3f800001) - a; + let mut r = crate::physics::reciprocal_sqrt::estimate(radicand); + let correction = (-(radicand * 0.5)).mul_add(r * r, 0.5); + r = r.mul_add(correction, r); + let residual = (-a).mul_add(value, value) * right; + residual.mul_add(r, value * left) +} diff --git a/crates/skate-core/src/camera/drop_predictor.rs b/crates/skate-core/src/camera/drop_predictor.rs new file mode 100644 index 00000000..a7b06f88 --- /dev/null +++ b/crates/skate-core/src/camera/drop_predictor.rs @@ -0,0 +1,258 @@ +//! Native terrain drop prediction82DF84F8/89B0/8BC0/8740. +//! Retains one update of query history: a new batch is submitted only after the +//! previous batch has been consumed. Host execution may be synchronous. +use super::vector_tracker::length; +use super::{FatLine, FatLineResult}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DropSettings { + /// camera_droppredictor/default fields bound8252EF84..8252EFA4. + pub minimum_test_distance: f32, + pub total_test_time: f32, + pub maximum_test_distance: f32, + pub maximum_drop_distance: f32, +} + +pub trait DropCollisionProvider { + fn query(&mut self, lines: [FatLine; 10], context: u32) -> Result<[FatLineResult; 10], String>; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Probe { + start: [f32; 4], + end: [f32; 4], + normal: [f32; 4], + depth: f32, + valid: bool, + hit: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DropPredictor { + pub horizontal_velocity: [f32; 4], + pub steepest_normal: [f32; 4], + pub has_normal: bool, + pub elevation: f32, + pub distance_offset: f32, + pub target_elevation: f32, + spacing: f32, + selected: usize, + probes: [Probe; 5], + pending: Option<([FatLine; 10], [FatLineResult; 10])>, +} + +impl DropPredictor { + pub fn new() -> Self { + Self { + horizontal_velocity: [0.0; 4], + steepest_normal: [0.0; 4], + has_normal: false, + elevation: 0.0, + distance_offset: 0.0, + target_elevation: 0.0, + spacing: 0.0, + selected: 0, + probes: [Probe { + start: [0.0; 4], + end: [0.0; 4], + normal: [0.0; 4], + depth: 0.0, + valid: false, + hit: false, + }; 5], + pending: None, + } + } + + /// Reset82DF8440 preserves the five probe records and cancels their pending + /// query. The following disabled/reset update clears probe valid/depth. + pub fn reset(&mut self) { + self.horizontal_velocity = [0.0; 4]; + self.steepest_normal = [0.0; 4]; + self.has_normal = false; + self.elevation = 0.0; + self.distance_offset = 0.0; + self.target_elevation = 0.0; + self.spacing = 0.0; + self.selected = 0; + self.pending = None; + } + + /// Complete82E00B60 velocity selection, consuming the previous rig velocity. + pub fn prediction_velocity( + reset: bool, + transform: [[f32; 4]; 4], + rig_velocity: [f32; 4], + settings: DropSettings, + ) -> [f32; 4] { + if reset || settings.minimum_test_distance > length(rig_velocity) { + transform[2].map(|v| v * settings.minimum_test_distance) + } else { + rig_velocity + } + } + + pub fn update( + &mut self, + position: [f32; 4], + velocity: [f32; 4], + enabled: bool, + offboard: bool, + context: u32, + settings: DropSettings, + query: &mut impl DropCollisionProvider, + ) -> Result<(), String> { + self.horizontal_velocity = [velocity[0], 0.0, velocity[2], 0.0]; + if enabled { + let span = length(self.horizontal_velocity) * settings.total_test_time; + let span = if -span >= 0.0 { 0.0 } else { span }; + let span = if settings.maximum_test_distance - span >= 0.0 { + span + } else { + settings.maximum_test_distance + }; + self.spacing = span / 5.0; + if let Some((lines, results)) = self.pending.take() { + self.consume(lines, results, settings.maximum_drop_distance); + } + let lines = self.lines(position, settings.maximum_drop_distance); + let results = query.query(lines, context)?; + self.pending = Some((lines, results)); + let mut greatest_depth = 0.0; + self.selected = 0; + for (index, probe) in self.probes.iter().enumerate() { + if !probe.valid { + break; + } + if probe.depth > greatest_depth { + self.selected = index; + greatest_depth = probe.depth; + } + } + } else { + self.selected = 0; + for probe in &mut self.probes { + probe.valid = false; + probe.depth = 0.0; + } + } + self.update_elevation(enabled, offboard); + Ok(()) + } + + /// Native getter82DF8980. + pub fn drop_depth(&self) -> f32 { + let probe = self.probes[self.selected]; + if probe.valid { probe.depth } else { 0.0 } + } + + fn lines(&self, position: [f32; 4], depth: f32) -> [FatLine; 10] { + let direction = super::orientation_math::normalize(self.horizontal_velocity); + core::array::from_fn(|index| { + let point = core::array::from_fn(|i| { + (direction[i] * self.spacing).mul_add((index / 2 + 1) as f32, position[i]) + }); + if index % 2 == 0 { + FatLine { + start: position, + end: point, + radius: f32::from_bits(0x3c23d70a), + } + } else { + FatLine { + start: point, + end: core::array::from_fn(|i| point[i] - [0.0, 1.0, 0.0, 0.0][i] * depth), + radius: f32::from_bits(0x3dcccccd), + } + } + }) + } + + fn consume(&mut self, lines: [FatLine; 10], results: [FatLineResult; 10], maximum_depth: f32) { + self.has_normal = false; + for index in 0..5 { + if results[2 * index].hit != 0 { + for probe in &mut self.probes[index..] { + probe.valid = false; + probe.depth = 0.0; + } + break; + } + let line = lines[2 * index + 1]; + let result = results[2 * index + 1]; + let probe = &mut self.probes[index]; + *probe = Probe { + start: line.start, + end: line.end, + normal: [0.0; 4], + depth: maximum_depth, + valid: true, + hit: result.hit != 0, + }; + if probe.hit { + probe.depth = length(core::array::from_fn(|i| result.position[i] - line.start[i])); + probe.end = result.position; + probe.normal = result.normal; + if !self.has_normal || self.steepest_normal[1].abs() > result.normal[1].abs() { + self.steepest_normal = result.normal; + self.has_normal = true; + } + } + } + } + + fn update_elevation(&mut self, enabled: bool, offboard: bool) { + let probe = self.probes[self.selected]; + let mut angle = 0.0; + if probe.valid { + let distance = (self.selected + 1) as f32 * self.spacing; + // Native angle wrapper applies one reciprocal refinement here; the + // called82473B98 polynomial has its own independent reductions. + let initial = crate::physics::native_arithmetic::reciprocal_estimate(distance); + let inverse = initial.mul_add((-initial).mul_add(distance, 1.0), initial); + angle = crate::input::angle::atan(probe.depth.mul_add(inverse, 0.0)); + let sign = probe.depth.to_bits() & 0x80000000; + if 0.0 > distance { + angle = f32::from_bits(0x40490fdb | sign) + angle; + } + if distance == 0.0 { + angle = f32::from_bits(0x3fc90fdb | sign); + } + } + let half_pi = f32::from_bits(0x3fc90fdb); + if angle - half_pi >= 0.0 { + angle = half_pi; + } + let limit = (if offboard { 40.0 } else { 70.0 }) * f32::from_bits(0x3c8efa35); + if angle - limit >= 0.0 { + angle = limit; + } + self.target_elevation = if enabled && angle >= f32::from_bits(0x3e860a92) { + angle + } else { + 0.0 + }; + let delta = (self.target_elevation - self.elevation).abs(); + let degrees = delta * f32::from_bits(0x42652ee1); + let step = (degrees - 7.0).mul_add(f32::from_bits(0x3d3a2e8c), f32::from_bits(0x3dcccccd)); + let step = if f32::from_bits(0x3dcccccd) - step >= 0.0 { + f32::from_bits(0x3dcccccd) + } else { + step + }; + let step = if f32::from_bits(0x3fcccccd) - step >= 0.0 { + step + } else { + f32::from_bits(0x3fcccccd) + }; + if degrees > step { + self.elevation = if self.elevation >= self.target_elevation { + -step.mul_add(f32::from_bits(0x3c8efa35), -self.elevation) + } else { + step.mul_add(f32::from_bits(0x3c8efa35), self.elevation) + }; + } else { + self.elevation = self.target_elevation; + } + } +} diff --git a/crates/skate-core/src/camera/frame_composer.rs b/crates/skate-core/src/camera/frame_composer.rs new file mode 100644 index 00000000..295373ea --- /dev/null +++ b/crates/skate-core/src/camera/frame_composer.rs @@ -0,0 +1,169 @@ +//! Complete single/transition framing82E01260/82E01740. Camera positioning, +//! reference producers and stock shot selection remain the caller's inputs. +use super::{ + Shot, + orientation_math::{basis_from_angles, look_basis}, + shot_orientation::slerp, +}; +use crate::math::Basis3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameSettings { + pub maximum_pitch_degrees: f32, + pub roll_response: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameSubject { + pub positions: [[f32; 4]; 10], + /// Subject getter456 (the board-offset direction). + pub board_offset_direction: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameComposer { + pub roll: f32, + pub reference: [f32; 4], + pub yaw: f32, + pub pitch: f32, +} + +impl FrameComposer { + pub fn new() -> Self { + Self { + roll: 0.0, + reference: [0.0; 4], + yaw: 0.0, + pitch: 0.0, + } + } + + /// The native transition evaluates `to` before `from`: the latter's + /// reference/yaw/pitch are therefore the retained composer fields. + pub fn compose( + &mut self, + dt: f32, + fraction: f32, + mirror: f32, + from: Shot, + to: Shot, + fallback_reference: [f32; 4], + camera_position: [f32; 4], + subject: FrameSubject, + settings: FrameSettings, + ) -> [f32; 4] { + let to_orientation = self.single( + to, + fallback_reference, + camera_position, + mirror, + subject, + settings, + ); + let from_orientation = self.single( + from, + fallback_reference, + camera_position, + mirror, + subject, + settings, + ); + let orientation = slerp(from_orientation, to_orientation, fraction); + self.roll = if dt > 0.0 { + ((to.framing[0] - self.roll) * settings.roll_response).mul_add(dt, self.roll) + } else { + to.framing[0] + }; + orientation + } + + pub fn single( + &mut self, + shot: Shot, + fallback_reference: [f32; 4], + camera_position: [f32; 4], + mirror: f32, + subject: FrameSubject, + settings: FrameSettings, + ) -> [f32; 4] { + self.reference = shot.reference_point( + &subject.positions, + subject.board_offset_direction, + fallback_reference, + ); + self.yaw = shot.framing[1] * mirror; + self.pitch = -shot.framing[2]; + let framing = basis_from_angles(self.pitch, self.yaw, 0.0); + let mut direction: [f32; 3] = + core::array::from_fn(|i| self.reference[i] - camera_position[i]); + // Native copies X into W for the comparison, so only XYZ can cause + // the nonzero path. The retained fourth position lane is not tested. + if !direction + .iter() + .any(|v| v.abs() > f32::from_bits(0x34000000)) + { + direction = [0.0, 0.0, 1.0]; + } else { + direction = super::orientation_math::normalize(direction); + let ceiling = crate::trigonometry::sin( + settings.maximum_pitch_degrees * f32::from_bits(0x3c8efa35), + ); + if direction[1] > ceiling { + direction[1] = ceiling; + direction = super::orientation_math::normalize(direction); + } + } + let look = look_basis(direction); + let result = Basis3 { + columns: framing.columns.map(|column| { + core::array::from_fn(|i| { + look.columns[2][i].mul_add( + column[2], + look.columns[1][i].mul_add(column[1], look.columns[0][i] * column[0]), + ) + }) + }), + }; + quaternion_from_basis(result) + } +} + +/// The signed diagonal candidates and strict component selection are the +/// inline matrix conversion82E01608..82E01730; signs are XOR negations. +fn quaternion_from_basis(basis: Basis3) -> [f32; 4] { + let [right, up, at] = basis.columns; + let [x, y, z] = [right[0], up[1], at[2]]; + let trace = (x + y) + z; + let (sum, companion, dominant) = if trace > 0.0 { + ( + (x + y) + (z + 1.0), + [up[2] - at[1], at[0] - right[2], right[1] - up[0], 0.5], + 3, + ) + } else if x > y && x > z { + ( + (x + (-y)) + ((-z) + 1.0), + [0.5, up[0] + right[1], at[0] + right[2], up[2] - at[1]], + 0, + ) + } else if y > z { + ( + ((-x) + y) + ((-z) + 1.0), + [up[0] + right[1], 0.5, at[1] + up[2], at[0] - right[2]], + 1, + ) + } else { + ( + ((-x) + (-y)) + (z + 1.0), + [at[0] + right[2], at[1] + up[2], 0.5, right[1] - up[0]], + 2, + ) + }; + let mut inverse = crate::physics::reciprocal_sqrt::estimate(sum); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-sum).mul_add(inverse * inverse, 1.0), inverse); + } + let root = sum * inverse; + let half_inverse = 0.5 * inverse; + core::array::from_fn(|i| companion[i] * if i == dominant { root } else { half_inverse }) +} diff --git a/crates/skate-core/src/camera/look_input.rs b/crates/skate-core/src/camera/look_input.rs new file mode 100644 index 00000000..39cb8240 --- /dev/null +++ b/crates/skate-core/src/camera/look_input.rs @@ -0,0 +1,32 @@ +//! Stock gameplay FreeCamManager82E084A0, used by a shot's look-stick option. +//! This is the normal camera's offset filter, unrelated to the replay editor. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LookSettings { + pub heading_offset_degrees: f32, + pub heading_speed: f32, + pub elevation_offset_degrees: f32, + pub elevation_speed: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LookInput { + pub elevation: f32, + pub heading: f32, +} + +impl LookInput { + pub fn new() -> Self { Self { elevation: 0.0, heading: 0.0 } } + + pub fn update(&mut self, input: [f32; 2], settings: LookSettings) { + let radians = f32::from_bits(0x3c8efa35); + let limit = f32::from_bits(0x3fc90fdb) - 5.0 * radians; + let clamp = |value: f32| { + let lower = if -limit - value >= 0.0 { -limit } else { value }; + if limit - lower >= 0.0 { lower } else { limit } + }; + let heading = clamp((input[0] * settings.heading_offset_degrees) * radians); + self.heading = (heading - self.heading).mul_add(settings.heading_speed * f32::from_bits(0x3c888889), self.heading); + let elevation = clamp((input[1] * settings.elevation_offset_degrees) * radians); + self.elevation = (elevation - self.elevation).mul_add(settings.elevation_speed * f32::from_bits(0x3c888889), self.elevation); + } +} diff --git a/crates/skate-core/src/camera/manager.rs b/crates/skate-core/src/camera/manager.rs new file mode 100644 index 00000000..4e7a4ef0 --- /dev/null +++ b/crates/skate-core/src/camera/manager.rs @@ -0,0 +1,172 @@ +//! Normal CameraMan82DFEAA8/82DFEC90/82DFEE80. The host publishes the real +//! physics/animation subject, evaluates the stock graph between prepare and +//! update, and supplies the three native kinds of collision query. +use super::*; +use super::manager_blends::BlendEnvironment; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManagerSettings { + pub rig: RigSettings, + pub orientation: OrientationSettings, + pub framing: FrameSettings, + pub drop: DropSettings, + pub look: LookSettings, + pub shake: ShakeSettings, + pub steering_threshold: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CameraMan { + pub state: ManagerState, + pub rig: Rig, + pub shots: ShotManager, + pub orientation: RigOrientation, + pub composer: FrameComposer, + pub drop: DropPredictor, + pub look: LookInput, + pub shake: [ShakeEffect; 2], + pub frame: CameraFrame, + // Rig transform0..32 is the previous subject orientation; the camera's + // tracked orientation lives separately in RigOrientation (native64..96). + subject_forward: [f32; 4], + heading_multiplier: f32, +} + +impl CameraMan { + pub fn new() -> Self { + Self { + state: ManagerState::new(), rig: Rig::new(1), shots: ShotManager::new(), + orientation: RigOrientation::new(), composer: FrameComposer::new(), + drop: DropPredictor::new(), look: LookInput::new(), + shake: [ShakeEffect::new(); 2], frame: CameraFrame::new(), + subject_forward: [0.0, 0.0, 1.0, 0.0], heading_multiplier: 1.0, + } + } + + /// Once per published subject, before the camera graph reads its conditions. + /// Landing response consumes the previous velocity/normal before incline + /// publication replaces them; swapping these operations changes impacts. + pub fn prepare(&mut self, subject: &ManagerSubject, settings: ManagerSettings) { + self.state.update_timing(subject, settings.steering_threshold); + if let Some((normal, velocity)) = self.state.update_air(subject, &mut self.rig) { + self.shake[0].landing_impulse(normal, velocity, settings.shake); + } + self.state.update_incline(subject); + } + + pub fn set_shot(&mut self, name: &str, force: bool, subject: &ManagerSubject, + database: &impl ShotDatabase) -> Result { + let mut environment = BlendEnvironment { + state: &self.state, subject, rig: &self.rig, + rig_forward: self.subject_forward, angular_velocity: [0.0; 4], + heading_multiplier: self.heading_multiplier, + previous_distance: self.shots.previous.distance, + }; + self.shots.set_shot(name, force, database, &mut environment, ShotPlacement { + anchor: self.rig.fields.anchor, + camera_position: self.rig.positioner.position, + clamp_height: self.rig.positioner.config.clamp_height, + floor_height: self.rig.positioner.config.floor_height, + }) + } + + pub fn update(&mut self, dt: f32, subject: &mut ManagerSubject, + settings: ManagerSettings, samples: [&ShakeSamples; 2], + requests: &mut [impl TrajectoryCollisionRequest; 3], + moving: &mut impl MovingObstacleProvider, + collision: &mut (impl PositionerCollisionProvider + DropCollisionProvider), + ) -> Result { + if self.shots.current().name.is_empty() { + return Err("The stock camera graph has not selected a shot".into()); + } + self.state.frames = self.state.frames.wrapping_add(1); + if self.state.frames > 30 { self.state.flags |= 1; } + self.state.update_mirrors(dt, subject, &self.rig, &self.shots); + let reset = self.state.reset_requested(); + if self.state.options & 0x40 != 0 { + if reset { self.drop.reset(); } + let velocity = DropPredictor::prediction_velocity(reset, subject.rig.transform, + self.rig.fields.velocity, settings.drop); + self.drop.update(subject.rig.transform[3], velocity, + self.shots.current().shot.use_drop_predictor != 0 && !reset, + subject.rig.off_board != 0, subject.rig.context, settings.drop, collision)?; + } + let look_enabled = if reset { self.shots.first_leaf().use_free_camera_stick } + else { self.shots.current().shot.use_free_camera_stick } != 0; + self.rig.fields.flags_517 = (self.rig.fields.flags_517 & !0x10) + | (u8::from(!look_enabled) << 4); + self.look.update(if look_enabled { subject.look } else { [0.0; 2] }, settings.look); + + if reset { + self.shots.make_instant(); + self.rig.fields.velocity = [0.0; 4]; + self.rig.fields.acceleration = [0.0; 4]; + self.state.slowmo_frames = 0; + self.shake = [ShakeEffect::new(); 2]; + self.heading_multiplier = self.state.heading_mirror.position; + self.orientation.framing_mirror = self.state.framing_mirror.position; + } else { + self.state.slowmo_frames = if subject.rig.broken_bone_slowmo != 0 { + self.state.slowmo_frames.wrapping_add(1) + } else { 0 }; + if self.state.slowmo_frames == 1 { + let impulse = settings.shake.impulse_magnitude * 20.0; + if impulse > self.shake[0].impulse { self.shake[0].impulse = impulse; } + } + } + self.shots.update(dt, &mut BlendEnvironment { + state: &self.state, subject, rig: &self.rig, + rig_forward: self.subject_forward, angular_velocity: [0.0; 4], + heading_multiplier: self.heading_multiplier, + previous_distance: self.shots.previous.distance, + }); + self.bind_anchor(subject, reset); + self.heading_multiplier = self.state.bind_shot(self.shots.interpolated, subject, + &mut self.rig, &mut self.orientation, &self.drop, self.look, + self.composer.roll, self.shots.is_transitioning()); + if reset { + self.rig.teleport(); + self.frame.shake_translation = [0.0; 4]; + } + self.rig.update(dt, settings.rig, &mut subject.rig, requests, moving, collision); + self.state.opacity = self.shots.interpolated.subject_opacity; + self.state.field_of_view = super::manager_frame::lens_field_of_view( + self.shots.interpolated.lens_length, self.state.aspect_ratio); + self.orientation.target = self.composer.compose(dt, self.shots.framing_fraction(), + self.orientation.framing_mirror, self.shots.previous, self.shots.current().shot, + self.rig.anchor.position, self.rig.positioner.position, FrameSubject { + positions: subject.rig.reference_positions, + board_offset_direction: subject.board_offset_direction, + }, settings.framing); + self.orientation.update(dt, settings.orientation, self.rig.fields.reset_time, + &mut self.rig.fields.flags_516, self.rig.fields.flags_517); + self.finish_frame(dt, subject, settings.shake, samples, reset); + self.shots.instant_changes = reset || self.state.instant_frames > 0; + if reset { + self.state.options &= !0x80; + self.state.flags |= 2; + } else if subject.is_ground_camera(self.rig.fields.height_mode) { + self.state.flags &= !2; + } + if self.state.instant_frames > 0 { self.state.instant_frames -= 1; } + Ok(self.frame) + } + + fn bind_anchor(&mut self, subject: &ManagerSubject, reset: bool) { + let anchor = if reset { self.shots.interpolated.anchor } + else { self.shots.current().shot.anchor }; + if !reset && anchor != self.state.selected_anchor { self.rig.fields.flags_517 |= 0x40; } + self.state.selected_anchor = anchor; + let selected = subject.anchors[anchor as usize]; + self.state.anchor = selected.position; + self.state.anchor_velocity = if reset { [0.0; 4] } else { selected.velocity }; + // Native setter subtracts velocities without division by the timestep. + self.rig.fields.acceleration = core::array::from_fn(|i| + self.state.anchor_velocity[i] - self.rig.fields.velocity[i]); + self.rig.fields.velocity = self.state.anchor_velocity; + self.rig.fields.anchor = self.state.anchor; + self.subject_forward = subject.rig.transform[2]; + self.rig.fields.flags_516 |= 0x10; + self.composer.reference = if reset { self.state.anchor } else { self.rig.anchor.position }; + } +} diff --git a/crates/skate-core/src/camera/manager_air.rs b/crates/skate-core/src/camera/manager_air.rs new file mode 100644 index 00000000..f05d64f4 --- /dev/null +++ b/crates/skate-core/src/camera/manager_air.rs @@ -0,0 +1,85 @@ +//! Camera trajectory transitions82DFE0E0 and slope relation82DFE718. +use super::{ManagerState,ManagerSubject,Rig,direction_to_angles,normalize_angle}; +use super::manager_state::clamp; +use super::vector_tracker::{dot,length}; + +impl ManagerState { + /// Returns the exact normal/velocity pair passed to ShakeEffect82E055D8 + /// when the native ground-camera transition triggers a landing impulse. + pub(super) fn update_air(&mut self,subject:&ManagerSubject,rig:&mut Rig) + ->Option<([f32;4],[f32;4])> { + let previous_delta=self.landing_height_delta; + self.landing_height_delta=subject.landing_position[1]-subject.launch_position[1]; + self.apex_height=subject.apex_position[1]-subject.launch_position[1]; + self.apex_time=subject.apex_time; + self.launch_position=subject.launch_position; + self.apex_height_ratio=if self.trajectory_camera_distance!=0.0 { + self.apex_height/self.trajectory_camera_distance + } else {0.0}; + self.landing_normal=subject.landing_normal; + self.launch_incline=crate::trigonometry::acos(clamp(subject.launch_normal[1],-1.0,1.0)); + self.flags &= !0x10; + let ground=subject.is_ground_camera(rig.fields.height_mode); + if ground || subject.rig.trajectory_valid==0 { + self.flags &= !4; + } else { + if self.flags&4!=0 { + if (self.landing_height_delta-previous_delta).abs()>f32::from_bits(0x3a83126f) { + rig.fields.flags_517 |=8; + } + } else { + let selected=subject.anchors[self.selected_anchor as usize].position; + self.trajectory_camera_distance=length(core::array::from_fn(|i| + rig.positioner.position[i]-selected[i])); + rig.fields.flags_517 &= !8; + } + self.flags |=4; + self.landing_incline=crate::trigonometry::acos(clamp(self.landing_normal[1],-1.0,1.0)); + self.signed_landing_incline=self.landing_incline; + if dot(subject.rig.transform[2],self.landing_normal)>0.0 { + self.signed_landing_incline = -self.landing_incline; + } + if self.launch_incline>f32::from_bits(0x3f9c61aa) + && self.landing_incline>f32::from_bits(0x3f060a92) { + self.flags|=0x10; + } + self.update_slope_relation(subject); + } + rig.fields.height_transition_weight=if !ground && subject.rig.trajectory_valid!=0 { + clamp(subject.trajectory_time/subject.trajectory_duration,0.0,1.0) + } else {0.0}; + let mut impulse=None; + if ground { + if self.flags&0x40==0 && rig.positioner.flags&0x80==0 { + if dot(self.previous_velocity,self.ground_normal)< -0.0 { + impulse=Some((self.ground_normal,self.previous_velocity)); + } + self.landing_height_delta=0.0; + } + } else if rig.fields.height_transition_time>=rig.fields.height_transition_duration + && subject.rig.trajectory_valid!=0 && self.landing_height_delta > -3.0 + && subject.flag_684==0 { + if self.flags&2!=0 || subject.rig.air_flag_452!=0 { + rig.fields.flags_516|=0x40; + } else if rig.fields.previous_height!=f32::MAX && rig.fields.reference_height!=f32::MAX { + rig.fields.height_transition_start=rig.fields.reference_height; + rig.fields.height_transition_end=self.landing_height_delta+rig.fields.reference_height; + rig.fields.height_transition_duration=subject.trajectory_duration-subject.trajectory_time; + rig.fields.height_transition_time=0.0; + } + } + self.flags=(self.flags&!0x40)|(u8::from(ground)<<6); + self.previous_velocity=rig.fields.velocity; + impulse + } + + fn update_slope_relation(&mut self,subject:&ManagerSubject) { + self.launch_landing_heading_delta=f32::from_bits(0x40490fdb); + if subject.launch_normal[1] { + pub state:&'a ManagerState, + pub subject:&'a ManagerSubject, + pub rig:&'a Rig, + pub rig_forward:[f32;4], + pub angular_velocity:[f32;4], + /// Rig424, including the compass2 override from the previous binding. + pub heading_multiplier:f32, + /// ShotManager992+60: previous shot distance. + pub previous_distance:f32, +} +impl ShotEnvironment for BlendEnvironment<'_> { + fn heading_mirror(&self)->f32 { self.heading_multiplier } + fn compass_north(&self,entry:u32)->f32 { self.subject.compass[entry as usize] } + fn special_camera_flag(&self)->bool { self.state.flags & 0x10 != 0 } + fn raw_blend_value(&mut self,kind:u32,authored:f32,dt:f32)->f32 { + let s=self.subject;let m=self.state; + match kind { + 0=>authored, + 1=>dot(self.rig.fields.velocity,s.rig.transform[2]).abs(), + 2=>dot(self.rig.fields.acceleration,self.rig_forward), + 3=>self.heading_multiplier*(-self.angular_velocity[1]), + 4=>m.apex_height, + 5=>self.air_progress(), + 6=>m.velocity_incline*DEGREES, + 7=>self.previous_distance, + 8=>self.apex_progress(), + 9=>m.absolute_velocity_incline*DEGREES, + 10=>m.landing_incline*DEGREES, + 11=>if m.mirrored() {-s.steering[1]} else {s.steering[1]}, + 12=>s.steering_for_blend(m.mirrored()), + 13=>if dt==0.0 {0.0} else {s.steering_for_turn(m.mirrored())}, + 14=>if dt==0.0 {0.0} else {f32::MAX}, + 15=>m.frontside_angle, + 16=>s.valid_trajectory_duration(), + 17=>s.look[0],18=>s.look[1],19=>s.look_heading(), + 20=>m.direction_incline*DEGREES, + 21=>s.value_512,22=>-s.value_516,23=>m.effect_weight, + _=>0.5, + } + } +} +impl BlendEnvironment<'_> { + fn air_progress(&self)->f32 { + let s=self.subject; + if s.is_ground_camera(self.rig.fields.height_mode) {return 1.0;} + if self.state.apex_height<=0.0 {return 0.0;} + let remaining=s.trajectory_duration-s.trajectory_time; + clamp(if s.trajectory_duration<=0.0 {1.0} else { + (s.trajectory_duration-remaining)/s.trajectory_duration + },0.0,1.0) + } + fn apex_progress(&self)->f32 { + let s=self.subject;let m=self.state; + if s.is_ground_camera(self.rig.fields.height_mode) { + return if m.flags&0x40!=0 {0.0} else {1.0}; + } + if m.apex_height<=0.0 || s.valid_trajectory_duration()twice { + clamp((s.trajectory_time-twice)/remaining+1.0,1.0,2.0) + } else {s.trajectory_time/twice} + } +} diff --git a/crates/skate-core/src/camera/manager_controls.rs b/crates/skate-core/src/camera/manager_controls.rs new file mode 100644 index 00000000..04b98ada --- /dev/null +++ b/crates/skate-core/src/camera/manager_controls.rs @@ -0,0 +1,41 @@ +//! Normal camera state timing82DFEAA8 and stance tracking82E00108. +use super::{ManagerState,ManagerSubject,Rig,ShotManager}; +use super::manager_state::{STEP,mirror_parameters}; + +impl ManagerState { + pub(super) fn update_timing(&mut self,subject:&ManagerSubject,steering_threshold:f32) { + self.time_without_trajectory=if subject.rig.trajectory_valid!=0 {0.0} + else {self.time_without_trajectory+STEP}; + if subject.reset!=0 || self.reset_requested() { + self.options|=0x80; + self.flags &= !4; + self.trajectory_camera_distance=0.0; + self.centred_time=0.0; + self.steering_time=0.0; + self.collision_elevation=0.0; + self.instant_frames=3; + self.anchor_velocity=[0.0;4]; + } + self.steering_time=if subject.steering[0].abs()<=steering_threshold {0.0} + else {self.steering_time+STEP}; + self.centred_time=if subject.steering_for_turn(self.mirrored()).abs()>=steering_threshold {0.0} + else {self.centred_time+STEP}; + self.flags=(self.flags&!8)|(u8::from(subject.stance_592!=subject.stance_560)<<3); + } + + pub(super) fn update_mirrors(&mut self,dt:f32,subject:&ManagerSubject,rig:&Rig,shots:&ShotManager) { + if !self.reset_requested() && !subject.is_ground_camera(rig.fields.height_mode) {return;} + let option=if self.reset_requested() {shots.first_leaf().mirror_for_stance} + else {shots.current().shot.mirror_for_stance}; + let target=if option!=0 && self.mirrored() {-1.0} else {1.0}; + if self.reset_requested() { + for tracker in [&mut self.heading_mirror,&mut self.framing_mirror] { + tracker.target=target;tracker.position=target; + tracker.velocity=0.0;tracker.acceleration=0.0; + } + } else { + self.heading_mirror.update(dt,target,mirror_parameters(100.0,500.0)); + self.framing_mirror.update(dt,target,mirror_parameters(15.0,50.0)); + } + } +} diff --git a/crates/skate-core/src/camera/manager_frame.rs b/crates/skate-core/src/camera/manager_frame.rs new file mode 100644 index 00000000..d4922d00 --- /dev/null +++ b/crates/skate-core/src/camera/manager_frame.rs @@ -0,0 +1,54 @@ +//! Final CameraMan frame arithmetic82DFF88C..82DFFF28, Euler82DFCD00. +use crate::math::Basis3; + +#[derive(Clone,Copy,Debug,PartialEq)] +pub struct CameraFrame { + pub basis:Basis3, + pub position:[f32;4], + pub previous_basis:Basis3, + pub previous_position:[f32;4], + pub linear_velocity:[f32;4], + pub angular_velocity:[f32;4], + pub shake_translation:[f32;4], + pub discontinuity:bool, + pub field_of_view_degrees:f32, + pub opacity:f32, + pub blur:f32, +} +impl CameraFrame { + pub(super) fn new()->Self { + let basis=Basis3 {columns:[[1.0,0.0,0.0],[0.0,1.0,0.0],[0.0,0.0,1.0]]}; + Self {basis,position:[0.0;4],previous_basis:basis,previous_position:[0.0;4], + linear_velocity:[0.0;4],angular_velocity:[0.0;4],shake_translation:[0.0;4], + discontinuity:false,field_of_view_degrees:0.0,opacity:1.0,blur:1.0} + } + pub(super) fn motion(&mut self,dt:f32,basis:Basis3,position:[f32;4]) { + let inverse=if dt!=0.0 {1.0/dt} else {1.0}; + self.linear_velocity=core::array::from_fn(|i|(position[i]-self.position[i])*inverse); + let old=angles(self.basis);let new=angles(basis); + self.angular_velocity=core::array::from_fn(|i| + if i==3 {0.0} else {super::normalize_angle(new[i]-old[i])*inverse}); + } +} + +pub(super) fn lens_field_of_view(lens:f32,aspect:f32)->f32 { + let angle=crate::input::angle::atan(35.0/(lens*2.0)); + let degrees=angle*f32::from_bits(0x42e52ee1); + square_root((degrees*degrees)/(aspect+1.0)) +} + +fn angles(basis:Basis3)->[f32;3] { + let [right,up,at]=basis.columns; + let horizontal=square_root(right[0].mul_add(right[0],right[1]*right[1])); + let yaw=super::manager_incline::atan_ratio(-right[2],horizontal); + if horizontal>f32::from_bits(0x3a83126f) { + [super::manager_incline::atan_ratio(up[2],at[2]),yaw, + super::manager_incline::atan_ratio(right[1],right[0])] + } else {[super::manager_incline::atan_ratio(-at[1],up[1]),yaw,0.0]} +} + +fn square_root(value:f32)->f32 { + let mut inverse=crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..2 {inverse=(inverse*0.5).mul_add((-value).mul_add(inverse*inverse,1.0),inverse);} + if value==0.0 {0.0} else {value*inverse} +} diff --git a/crates/skate-core/src/camera/manager_incline.rs b/crates/skate-core/src/camera/manager_incline.rs new file mode 100644 index 00000000..82eae761 --- /dev/null +++ b/crates/skate-core/src/camera/manager_incline.rs @@ -0,0 +1,63 @@ +//! Ground incline and frontside state, complete TU3 82E002A8. +use super::{ManagerState,ManagerSubject,direction_to_angles}; +use super::manager_state::{HALF_PI,UP}; +use super::vector_tracker::{dot,length}; + +impl ManagerState { + pub(super) fn update_incline(&mut self, subject:&ManagerSubject) { + self.ground_normal=subject.ground_normal; + let normal=subject.rig.last_valid_ground_up; + if normal[1]>f32::from_bits(0x3f7fff58) && subject.rig.grinding==0 { + self.velocity_incline=0.0; + self.absolute_velocity_incline=0.0; + self.frontside_angle=0.0; + self.ground_heading=0.0; + self.direction_incline=0.0; + self.flags &= !0x20; + return; + } + let direction=if length(self.anchor_velocity)>f32::from_bits(0x358637bd) { + super::orientation_math::normalize(self.anchor_velocity) + } else {subject.rig.transform[2]}; + self.velocity_incline=HALF_PI-angle_between(UP,direction); + self.absolute_velocity_incline=if self.velocity_incline<0.0 { + -self.velocity_incline + } else {self.velocity_incline}; + self.ground_heading=direction_to_angles(normal)[1]; + // Projection and subtraction are separate VMX operations, followed + // by the two-refinement normalization used inline at82E00554. + let projected=normal.map(|v|v*dot(UP,normal)); + let tangent=super::orientation_math::normalize(core::array::from_fn::<_,4,_>(|i|UP[i]-projected[i])); + let cross=[ + (-normal[2]).mul_add(tangent[1],normal[1]*tangent[2]), + (-normal[0]).mul_add(tangent[2],normal[2]*tangent[0]), + (-normal[1]).mul_add(tangent[0],normal[0]*tangent[1]),0.0, + ]; + let denominator=dot(direction,tangent); + let numerator=dot(direction,cross); + self.frontside_angle=atan_ratio(numerator,denominator); + self.flags=(self.flags&!0x20)|(u8::from(self.frontside_angle>0.0)<<5); + if self.mirrored() {self.frontside_angle *= -1.0;} + // vrlimi mask1 replaces W with X; this condition tests XYZ only. + if self.anchor_velocity[..3].iter().any(|v|v.abs()>f32::from_bits(0x34000000)) { + self.direction_incline=super::normalize_angle(HALF_PI-angle_between(subject.direction_424,UP)); + } + } +} + +pub(super) fn angle_between(a:[f32;4],b:[f32;4])->f32 { + let to_vector=|v:[f32;4]|crate::math::Vector3::new(v[0],v[1],v[2]); + crate::physics::board_ground::angle_between(to_vector(a),to_vector(b)) +} + +/// Inline atan wrapper82E005A4..82E00628: one reciprocal Newton step, +/// multiply-add with positive zero, then signed quadrant selection. +pub(super) fn atan_ratio(numerator:f32,denominator:f32)->f32 { + let initial=crate::physics::native_arithmetic::reciprocal_estimate(denominator); + let inverse=initial.mul_add((-initial).mul_add(denominator,1.0),initial); + let mut angle=crate::input::angle::atan(numerator.mul_add(inverse,0.0)); + let sign=numerator.to_bits()&0x80000000; + if 0.0>denominator {angle=f32::from_bits(0x40490fdb|sign)+angle;} + if denominator==0.0 {angle=f32::from_bits(0x3fc90fdb|sign);} + angle +} diff --git a/crates/skate-core/src/camera/manager_output.rs b/crates/skate-core/src/camera/manager_output.rs new file mode 100644 index 00000000..a7e41aeb --- /dev/null +++ b/crates/skate-core/src/camera/manager_output.rs @@ -0,0 +1,43 @@ +//! Final output/shake ordering from82DFFAA4..82DFFF28. +use super::{CameraMan, ManagerSubject, ShakeSamples, ShakeSettings}; +use super::vector_tracker::length; + +impl CameraMan { + pub(super) fn finish_frame(&mut self, dt: f32, subject: &ManagerSubject, + settings: ShakeSettings, samples: [&ShakeSamples; 2], reset: bool) { + let old_basis = self.frame.basis; + let old_position = self.frame.position; + let basis = self.orientation.basis; + let position = self.rig.positioner.position; + self.frame.motion(dt, basis, position); + let enabled = subject.rig.wiping_out == 0 || self.rig.positioner.flags & 0x80 != 0 + || subject.rig.broken_bone_slowmo != 0; + let velocity = self.rig.fields.velocity; + let speed = length([velocity[0], 0.0, velocity[2], 0.0]) + * if subject.flag_652 != 0 { f32::from_bits(0x3f6147ae) } else { 1.0 }; + let distance = length(core::array::from_fn(|i| self.rig.fields.anchor[i] - position[i])); + let first = self.shake[0].update(dt, speed, distance, basis, enabled, samples[0], settings); + let second = self.shake[1].update(dt, + if subject.shake_variant != 0 { speed * 50.0 } else { speed }, + distance, basis, enabled, samples[1], settings); + let selected = usize::from(subject.shake_variant != 0); + self.frame.basis = [first, second][selected]; + self.frame.position = position; + self.frame.shake_translation = self.shake[selected].translation; + //82DFFEC8..82DFFECC publishes ShakeEffect+16 into Rig+320. The + // positioner consumes this world-space offset on the following tick; + // it is not an extra translation added by the renderer to this frame. + self.rig.fields.offset = self.frame.shake_translation; + self.frame.discontinuity = self.rig.positioner.flags & 0x40 != 0 || reset; + if self.frame.discontinuity { + self.frame.previous_basis = self.frame.basis; + self.frame.previous_position = position; + } else { + self.frame.previous_basis = old_basis; + self.frame.previous_position = old_position; + } + self.frame.field_of_view_degrees = self.state.field_of_view; + self.frame.opacity = self.state.opacity; + self.frame.blur = self.state.blur; + } +} diff --git a/crates/skate-core/src/camera/manager_rig.rs b/crates/skate-core/src/camera/manager_rig.rs new file mode 100644 index 00000000..71958632 --- /dev/null +++ b/crates/skate-core/src/camera/manager_rig.rs @@ -0,0 +1,82 @@ +//! CameraMan shot-to-rig binding82DFF23C..82DFF864. +use super::{DropPredictor,LookInput,ManagerState,ManagerSubject,Rig,RigOrientation,Shot}; +use super::manager_state::clamp; +use super::vector_tracker::length; + +impl ManagerState { + pub(super) fn bind_shot(&mut self,shot:Shot,subject:&ManagerSubject,rig:&mut Rig, + orientation:&mut RigOrientation,drop:&DropPredictor,look:LookInput,previous_roll:f32, + transitioning:bool)->f32 { + let reset=self.reset_requested(); + rig.fields.avoidance_mode=u32::from(self.options&0x20!=0); + rig.fields.flags_516=(rig.fields.flags_516&!0x62) + |(u8::from(transitioning)<<5)|((shot.follow_subject_in_air<<6)&0x40) + |((shot.avoidance_override<<1)&2); + rig.fields.flags_517=(rig.fields.flags_517&0x7f)|(subject.stance_560<<7); + let [x,y,z,w]=shot.arm_orientation; + let basis=crate::physics::rigid_body::basis_from_quaternion( + crate::physics::rigid_body::RetailQuaternion {x,y,z,w}); + let at=basis.columns[2]; + let angles=super::direction_to_angles([at[0],at[1],at[2],0.0]); + let mut elevation=-angles[0];let mut heading=angles[1];let mut distance=shot.distance; + if subject.special_effect!=0 { + elevation += f32::from_bits(0x3eb2b8c2); + let time=(self.frames as f32)*f32::from_bits(0x3dd67750); + let distance_wave=crate::trigonometry::sin(time*f32::from_bits(0x3d321643)); + let heading_wave=crate::trigonometry::sin(time*f32::from_bits(0x3c23d70a)); + heading=heading_wave.mul_add(f32::from_bits(0x40278d36),heading); + distance=(distance_wave+distance)+1.0; + } + let mut drop_elevation=0.0; + if self.options&0x40!=0 { + let speed=length(self.anchor_velocity); + let (minimum,maximum)=if self.options&0x10!=0 {(0.7,0.8)} else {(0.9,1.0)}; + let scale=clamp(-(speed-4.0).mul_add(f32::from_bits(0x3cccccd0),-maximum),minimum,maximum); + drop_elevation=drop.elevation*scale; + distance=drop.distance_offset+distance; + } + let collision_elevation=if reset {0.0} else { + let maximum=if distance-2.5>=0.0 {2.5} else {distance}; + let fraction=clamp((maximum-rig.positioner.available_distance)/maximum,0.0,1.0); + let angle=(if subject.rig.off_board!=0 {60.0} else {50.0})*f32::from_bits(0x3c8efa35); + let target=angle.mul_add(fraction,-drop_elevation); + let mut target=if target>=0.0 {target} else {0.0}; + if subject.is_ground_camera(rig.fields.height_mode) && subject.ground_normal[1]< -0.2 { + target *= -1.0; + } + let outward=(target>0.0 && self.collision_elevationtarget); + let step=angle*(if outward {f32::from_bits(0x3d888889)} else {f32::from_bits(0x3c23d70a)}); + self.collision_elevation=if (target-self.collision_elevation).abs()>step { + if target>=self.collision_elevation {self.collision_elevation+step} + else {self.collision_elevation-step} + } else {target}; + clamp(self.collision_elevation,-f32::from_bits(0x3fb2b8c2),f32::from_bits(0x3fb2b8c2)) + }; + rig.fields.distance=distance; + let turns=heading.mul_add(f32::from_bits(0x3e22f983),0.5).floor(); + let reference=-turns.mul_add(f32::from_bits(0x40c90fdb),-heading); + rig.fields.heading_reference=reference; + rig.fields.heading_target=if reset {reference} else { + let from=rig.fields.avoidance_heading; + let delta=super::rig_tracking::wrap_vmx(reference-from); + super::rig_tracking::wrap_vmx(delta.mul_add(1.0-rig.fields.heading_weight,from)) + }; + rig.set_elevation(elevation,!reset); + rig.fields.elevation_offset=drop_elevation+look.elevation; + rig.fields.additional_elevation=collision_elevation; + rig.fields.flags_517=(rig.fields.flags_517&!0x20)|(u8::from(shot.compass_north!=5)<<5); + self.blur=if reset {0.0} else if transitioning {shot.transition_blur} else {shot.blur}; + let heading_mirror=if shot.compass_north==2 {1.0} else {self.heading_mirror.position}; + orientation.framing_mirror=if shot.compass_north==2 {1.0} else {self.framing_mirror.position}; + rig.fields.collision_mode=shot.collision_hint; + let h=1.0-shot.smoothing[0];let e=1.0-shot.smoothing[1]; + let pan=1.0-shot.smoothing[2];let tilt=1.0-shot.smoothing[3]; + rig.fields.heading_smoothing=1.0-(-h.mul_add(h,-1.0)); + rig.fields.elevation_smoothing=1.0-(-e.mul_add(e,-1.0)); + orientation.pan_smoothing=-pan.mul_add(pan,-1.0); + orientation.tilt_smoothing=-tilt.mul_add(tilt,-1.0); + orientation.roll=previous_roll; + heading_mirror + } +} diff --git a/crates/skate-core/src/camera/manager_state.rs b/crates/skate-core/src/camera/manager_state.rs new file mode 100644 index 00000000..e0aaa0ef --- /dev/null +++ b/crates/skate-core/src/camera/manager_state.rs @@ -0,0 +1,88 @@ +//! Normal CameraMan persistent state, constructor/reset82DFDA60/82DFDFA0. +use super::{ScalarTracker, ScalarTrackerParameters}; + +pub(super) const STEP: f32 = f32::from_bits(0x3c888889); +pub(super) const HALF_PI: f32 = f32::from_bits(0x3fc90fdb); +pub(super) const DEGREES: f32 = f32::from_bits(0x42652ee1); +pub(super) const UP: [f32;4] = [0.0,1.0,0.0,0.0]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManagerState { + pub anchor: [f32;4], //624 + pub anchor_velocity: [f32;4], //656 + pub landing_normal: [f32;4], //720 + pub launch_position: [f32;4], //736 + pub ground_normal: [f32;4], //752 + pub previous_velocity: [f32;4], //768 + pub incline_normal: [f32;4], //784 + pub selected_anchor: u32, //800 + pub frontside_angle: f32, //808 + pub ground_heading: f32, //812 + pub velocity_incline: f32, //816 + pub absolute_velocity_incline: f32, //820 + pub launch_incline: f32, //824 + pub signed_landing_incline: f32, //828 + pub landing_incline: f32, //832 + pub direction_incline: f32, //836 + pub field_of_view: f32, //844 + pub apex_height: f32, //848 + pub apex_time: f32, //852 + pub time_without_trajectory: f32, //856 + pub landing_height_delta: f32, //860 + pub apex_height_ratio: f32, //864 + pub launch_landing_heading_delta: f32, //868 + pub trajectory_camera_distance: f32, //872 + pub steering_time: f32, //876 + pub centred_time: f32, //880 + pub opacity: f32, //884 + pub aspect_ratio: f32, //888 + pub blur: f32, //896 + pub effect_weight: f32, //900 + pub collision_elevation: f32, //904 + pub frames: u32, //908 + pub slowmo_frames: u32, //912 + pub instant_frames: u32, //916 + pub flags: u8, //920 + pub options: u8, //921 + pub heading_mirror: ScalarTracker, //464 + pub framing_mirror: ScalarTracker, //524 +} + +impl ManagerState { + pub fn new() -> Self { + Self { + anchor:[0.0;4], anchor_velocity:[0.0;4], landing_normal:UP, + launch_position:[0.0;4], ground_normal:UP, previous_velocity:[0.0;4], + incline_normal:[0.0;4], selected_anchor:0, frontside_angle:0.0, + ground_heading:0.0, velocity_incline:0.0, absolute_velocity_incline:0.0, + launch_incline:0.0,signed_landing_incline:0.0,landing_incline:0.0, + direction_incline:0.0,field_of_view:0.0,apex_height:0.0,apex_time:0.0, + time_without_trajectory:f32::MAX,landing_height_delta:0.0, + apex_height_ratio:0.0,launch_landing_heading_delta:0.0,trajectory_camera_distance:0.0,steering_time:0.0, + centred_time:0.0,opacity:1.0,aspect_ratio:f32::from_bits(0x3fe38e39), + blur:1.0,effect_weight:1.0,collision_elevation:0.0,frames:0, + slowmo_frames:0,instant_frames:3,flags:2,options:0x60, + heading_mirror:mirror_tracker(),framing_mirror:mirror_tracker(), + } + } + + pub fn reset_requested(&self) -> bool { self.options & 0x80 != 0 } + pub fn request_reset(&mut self) { self.options |= 0x80; } + pub fn mirrored(&self) -> bool { self.flags & 8 != 0 } +} + +pub(super) fn clamp(value:f32, low:f32, high:f32)->f32 { + let value=if low-value>=0.0 {low} else {value}; + if high-value>=0.0 {value} else {high} +} + +fn mirror_tracker()->ScalarTracker { + ScalarTracker {target:1.0,position:1.0,velocity:0.0,acceleration:0.0, + acceleration_clamp:0.0,smoothing:0.0} +} + +pub(super) fn mirror_parameters(speed:f32,acceleration:f32)->ScalarTrackerParameters { + ScalarTrackerParameters {delta_umbra:0.0,delta_penumbra:0.0,speed_clamp:speed, + acceleration_clamp_min:acceleration,acceleration_clamp_max:acceleration, + smoothing_min:0.85,smoothing_max:0.85,overshoot_zeroes_velocity:true} +} diff --git a/crates/skate-core/src/camera/manager_subject.rs b/crates/skate-core/src/camera/manager_subject.rs new file mode 100644 index 00000000..50df3d9a --- /dev/null +++ b/crates/skate-core/src/camera/manager_subject.rs @@ -0,0 +1,82 @@ +//! CameraMan inputs published by the normal subject, TU3 82DF69C0. +//! The physics host supplies these independently produced values; camera code +//! does not replace trajectory, stance or animation outputs with pose guesses. +use super::{AnchorState, Subject}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManagerSubject { + pub rig: Subject, + pub anchors: [AnchorState; 7], + /// Subject compass getter364, filled by82DF7B90. + pub compass: [f32; 9], + pub board_offset_direction: [f32; 4], //456 + pub ground_normal: [f32; 4], //448 + pub launch_position: [f32; 4], //400 + pub launch_normal: [f32; 4], //404 + pub landing_position: [f32; 4], //408 + pub landing_normal: [f32; 4], //412 + pub apex_position: [f32; 4], //416 + pub direction_424: [f32; 4], + pub look: [f32; 2], //472 + /// Getters484,488,492,496 are distinct source producers. + pub steering: [f32; 4], + pub trajectory_time: f32, //500 + pub trajectory_duration: f32, //504 + pub apex_time: f32, //508 + pub value_512: f32, + pub value_516: f32, + pub reset: u8, //544 + pub flag_556: u8, + pub stance_560: u8, + pub stance_592: u8, + pub flag_652: u8, + pub shake_variant: u8, //672 + pub special_effect: u8, //676 + pub flag_684: u8, +} + +impl ManagerSubject { + ///82E00040: this camera predicate is distinct from grounded physics. + pub fn is_ground_camera(&self, height_mode: u32) -> bool { + height_mode == 2 || (self.flag_556 == 0 && self.rig.trajectory_valid == 0 + && self.trajectory_duration <= f32::from_bits(0x3ecccccd)) + } + + ///82E00980. Sign disagreement retains the primary steering channel; + /// agreement selects the greater magnitude. Equality preserves492. + pub fn steering_for_turn(&self, mirrored: bool) -> f32 { + let [primary, _, first, second] = self.steering; + let mut selected = if second.abs() > first.abs() { second } else { first }; + if sign(selected) != sign(primary) || selected.abs() < primary.abs() { + selected = primary; + } + if u8::from(mirrored) == self.stance_560 { selected } else { -selected } + } + + ///82E00790. This variant starts with channel488 and keeps the secondary + /// channel on sign disagreement; it is not identical to82E00980. + pub fn steering_for_blend(&self, mirrored: bool) -> f32 { + let [_, primary, first, second] = self.steering; + let secondary = if second.abs() > first.abs() { second } else { first }; + let selected = if sign(primary) != sign(secondary) || primary.abs() < secondary.abs() { + secondary + } else { primary }; + if u8::from(mirrored) == self.stance_560 { selected } else { -selected } + } + + ///82E00EB0. + pub fn valid_trajectory_duration(&self) -> f32 { + if self.rig.trajectory_valid != 0 { self.trajectory_duration } else { 0.0 } + } + + ///82E00F78; normalize before the native direction-to-angles helper. + pub fn look_heading(&self) -> f32 { + let direction = super::orientation_math::normalize([self.look[0], 0.0, self.look[1], 0.0]); + -super::direction_to_angles(direction)[1] + } +} + +// Vector comparisons distinguish zero from positive, unlike signum(). +fn sign(value: f32) -> i8 { + if value >= 0.0 { if value > 0.0 { 1 } else { 0 } } else { -1 } +} diff --git a/crates/skate-core/src/camera/mod.rs b/crates/skate-core/src/camera/mod.rs new file mode 100644 index 00000000..1280f414 --- /dev/null +++ b/crates/skate-core/src/camera/mod.rs @@ -0,0 +1,84 @@ +//! Readable camera implementation candidates. Prior generated-code validation +//! is withdrawn; native correctness and full gameplay integration are unverified. + +mod angle_tracker; +mod anchors; +mod avoidance; +mod direction; +mod drop_predictor; +mod look_input; +mod manager_subject; +mod manager; +mod manager_output; +mod compass; +mod compass_movement; +mod compass_orbit; +mod manager_state; +mod manager_blends; +mod manager_controls; +mod manager_incline; +mod manager_air; +mod manager_rig; +mod manager_frame; +mod shake; +mod shake_curve; +mod shake_update; +mod frame_composer; +mod orientation_math; +mod path_evaluator; +mod path_prediction; +mod trajectory_query; +mod slow_motion; +mod positioner; +mod rig; +mod rig_initialization; +mod rig_orientation; +mod rig_framing; +mod rig_positioning; +mod rig_tracking; +mod subject; +mod subject_pose; +mod shot; +mod shot_manager; +mod shot_orientation; +mod tracker; +mod vector_tracker; + +pub use angle_tracker::{AngleTracker, normalize_angle}; +pub use anchors::{AnchorInputs, AnchorState, Anchors}; +pub use avoidance::{AvoidancePath, AvoidanceSettings, AvoidanceSubject, RigAvoidance}; +pub use direction::direction_to_angles; +pub use drop_predictor::{DropCollisionProvider, DropPredictor, DropSettings}; +pub use look_input::{LookInput, LookSettings}; +pub use manager_subject::ManagerSubject; +pub use manager::{CameraMan, ManagerSettings}; +pub use compass::{Compass, CompassInputs, CompassPoseInputs, CompassSettings}; +pub use manager_state::ManagerState; +pub use manager_frame::CameraFrame; +pub use shake::{ShakeEffect,ShakeSamples,ShakeSettings}; +pub use frame_composer::{FrameComposer, FrameSettings, FrameSubject}; +pub use path_evaluator::{MovingObstacleProvider, PathEvaluator, TrajectoryCollisionRequest}; +pub use path_prediction::{PathObstacle, PredictionPath, candidate_collision_time}; +pub use trajectory_query::TrajectoryQuery; +pub use slow_motion::{SimulationRateRequest, SlowMotionController, SlowMotionSettings}; +pub use positioner::{ + FatLine, FatLineResult, Positioner, PositionerCollisionProvider, PositionerConfig, + position_from_angles, project_hit, +}; +pub use rig::{Breadcrumbs, Rig, RigFields, RigSettings}; +pub use rig_orientation::{OrientationSettings, OrientationTrackerSettings, RigOrientation}; +pub use rig_framing::{RigFraming, RigMode, RigModeSubject}; +pub use rig_positioning::{ + DistanceTrackingSettings, RigPositioning, RigPositioningSettings, RigPositioningSubject, +}; +pub use rig_tracking::{ + AnchorRigTracking, AnchorTrackingSettings, AnchorTrackingSubject, AngleTrackingSettings, + AngularRigTracking, +}; +pub use rig_tracking::{ReferenceHeightSubject, ReferenceHeightTracking}; +pub use tracker::{ScalarTracker, ScalarTrackerParameters}; +pub use subject::{ReferencePointInputs, Subject}; +pub use subject_pose::{PublishedSubjectPose, SubjectPoseInputs, SubjectPosePublisher}; +pub use shot::{Shot, blend_interval, filter_blend_value, interpolate_float}; +pub use shot_manager::{ShotDatabase, ShotDefinition, ShotEnvironment, ShotManager, ShotPlacement}; +pub use vector_tracker::{VectorTracker, clamp_magnitude}; diff --git a/crates/skate-core/src/camera/orientation_math.rs b/crates/skate-core/src/camera/orientation_math.rs new file mode 100644 index 00000000..f1fc27ed --- /dev/null +++ b/crates/skate-core/src/camera/orientation_math.rs @@ -0,0 +1,96 @@ +//! Native camera Euler conversions. These preserve the products and fused sums +//! from TU3 82E06CB8 and 82E045E0 rather than passing through renderer math. +use crate::{math::Basis3, trigonometry::sin_cos}; + +pub(super) fn quaternion_from_angles(pitch: f32, yaw: f32, roll: f32) -> [f32; 4] { + let (sp, cp) = sin_cos(pitch * 0.5); + let (sy, cy) = sin_cos(yaw * 0.5); + let (sr, cr) = sin_cos(roll * 0.5); + let cp_cr = cp * cr; + let sp_cr = sp * cr; + let sp_sr = sp * sr; + let cp_sr = cp * sr; + [ + cy * sp_cr - sy * cp_sr, + cy.mul_add(sp_sr, sy * cp_cr), + cy * cp_sr - sy * sp_cr, + cy.mul_add(cp_cr, sy * sp_sr), + ] +} + +/// TU3 82E04AD4..82E04BC4. The native matrix's three spatial columns are +/// retained; its unused fourth lanes are not part of the host Basis3 type. +pub(super) fn basis_from_angles(pitch: f32, yaw: f32, roll: f32) -> Basis3 { + let (sp, cp) = sin_cos(pitch); + let (sy, cy) = sin_cos(yaw); + let (sr, cr) = sin_cos(roll); + let cp_sr = cp * sr; + let cp_cr = cp * cr; + let sp_sr = sp * sr; + let sp_cr = sp * cr; + Basis3 { + columns: [ + [cy * cr, cy * sr, -sy], + [sy * sp_cr - cp_sr, sy.mul_add(sp_sr, cp_cr), cy * sp], + [sy.mul_add(cp_cr, sp_sr), sy * cp_sr - sp_cr, cy * cp], + ], + } +} + +/// Complete axis rotation82BD35B8. Standalone sine/cosine and the matrix +/// product's X,Y,Z accumulation order are observable in the camera output. +pub(super) fn rotate_about_axis(basis: Basis3, axis: [f32; 3], angle: f32) -> Basis3 { + let cosine = crate::trigonometry::cos(angle); + let sine = crate::trigonometry::sin(angle); + let [x, y, z] = axis; + let sx = sine * x; + let sy = sine * y; + let sz = sine * z; + let one_minus_cosine = 1.0 - cosine; + let tx = one_minus_cosine * x; + let ty = one_minus_cosine * y; + let tz = one_minus_cosine * z; + let rotation = [ + [x.mul_add(tx, cosine), tx.mul_add(y, sz), tx * z - sy], + [ty * x - sz, ty.mul_add(y, cosine), ty.mul_add(z, sx)], + [tz.mul_add(x, sy), tz * y - sx, z.mul_add(tz, cosine)], + ]; + Basis3 { + columns: basis.columns.map(|column| { + core::array::from_fn(|i| { + rotation[2][i].mul_add( + column[2], + rotation[1][i].mul_add(column[1], rotation[0][i] * column[0]), + ) + }) + }), + } +} + +/// Look basis8296EF20: normalize world-up cross At, then normalize At cross +/// Right. The caller has already handled a zero direction and its pitch cap. +pub(super) fn look_basis(at: [f32; 3]) -> Basis3 { + let right = normalize(cross([0.0, 1.0, 0.0], at)); + let up = normalize(cross(at, right)); + Basis3 { + columns: [right, up, at], + } +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + ] +} + +pub(super) fn normalize(v: [f32; N]) -> [f32; N] { + let square = + crate::physics::native_arithmetic::dot3([v[0], v[1], v[2], 0.0], [v[0], v[1], v[2], 0.0]); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + v.map(|lane| lane * inverse) +} diff --git a/crates/skate-core/src/camera/path_evaluator.rs b/crates/skate-core/src/camera/path_evaluator.rs new file mode 100644 index 00000000..8a27fcf4 --- /dev/null +++ b/crates/skate-core/src/camera/path_evaluator.rs @@ -0,0 +1,122 @@ +//! Complete TU3 82E023D0/82E02490 orchestration. Backend adapters are external; +//! the trajectory request and moving-sphere provider are distinct native objects. + +use super::{PathObstacle, PredictionPath, candidate_collision_time}; + +/// Native request vtable slots +4/+8/+12 (82DF9270/82DF9338/82DF9340). +/// A nonzero flag selects the manager acceleration vector in the stock adapter. +pub trait TrajectoryCollisionRequest { + fn submit(&mut self, path: PredictionPath, context: u32, acceleration_flag: u8); + fn is_ready(&mut self) -> bool; + fn collision_time(&mut self) -> f32; +} + +/// Native 82DF9400 provider. Implementations preserve native actor filtering and +/// sphere generation; the returned count must be at most the 50-record capacity. +pub trait MovingObstacleProvider { + fn collect( + &mut self, + position: [f32; 4], + velocity: [f32; 4], + radius: f32, + output: &mut [PathObstacle; 50], + ) -> usize; +} + +/// Fields touched by the two evaluator routines. The complete native constructor +/// and path setter own initialization; omitted native padding/fields are untouched. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PathEvaluator { + /// Native word +8: only 0 and 1 have update behavior. + pub request_state: u32, + pub path: PredictionPath, + /// Native byte +57, saved when submitting a request. + pub acceleration_flag: u8, + pub collision_time: f32, + pub last_valid_time: f32, + /// Native byte +72. Readiness counts even for a negative/no-hit result. + pub found: u8, + /// Native byte +73; only a ready asynchronous result replaces it. + pub result_acceleration_flag: u8, + /// Native byte +76. + pub submitted_acceleration_flag: u8, + /// Native word +80, compared as signed after wrapping increment. + pub pending_polls: i32, +} + +impl PathEvaluator { + /// Complete 82E02490. Does not clear current-frame result fields on entry. + pub fn update_request(&mut self, context: u32, request: &mut impl TrajectoryCollisionRequest) { + match self.request_state { + 0 => { + request.submit(self.path, context, self.acceleration_flag); + self.submitted_acceleration_flag = self.acceleration_flag; + self.request_state = 1; + self.pending_polls = 0; + } + 1 => { + if request.is_ready() { + let result = request.collision_time(); + let result = if result < 0.0 { f32::MAX } else { result }; + self.found = 1; + self.request_state = 0; + self.result_acceleration_flag = self.submitted_acceleration_flag; + self.collision_time = select_time(self.collision_time, result); + } else { + self.pending_polls = self.pending_polls.wrapping_add(1); + if self.pending_polls > 3 { + self.request_state = 0; + self.last_valid_time = f32::MAX; + } + } + } + _ => {} + } + } + + /// Complete 82E023D0, including the native asynchronous-before-moving order. + pub fn update( + &mut self, + context: u32, + request: &mut impl TrajectoryCollisionRequest, + moving: &mut impl MovingObstacleProvider, + ) { + self.found = 0; + self.collision_time = f32::MAX; + self.update_request(context, request); + + // Host scratch storage; only the provider's returned records are consumed. + let mut obstacles = [PathObstacle { + position: [0.0; 4], + velocity: [0.0; 4], + radius: 0.0, + }; 50]; + let count = moving.collect( + self.path.position, + self.path.velocity, + self.path.radius, + &mut obstacles, + ); + assert!( + count <= obstacles.len(), + "native moving-sphere capacity exceeded" + ); + let time = candidate_collision_time(self.path, &obstacles[..count]); + if time < self.path.horizon { + self.found = 1; + self.collision_time = select_time(self.collision_time, time); + } + if self.found != 0 { + self.last_valid_time = self.collision_time; + } + } +} + +// Preserve the native fsubs/fsel ordering (including unordered operands and ties). +fn select_time(current: f32, candidate: f32) -> f32 { + if current - candidate >= 0.0 { + candidate + } else { + current + } +} diff --git a/crates/skate-core/src/camera/path_prediction.rs b/crates/skate-core/src/camera/path_prediction.rs new file mode 100644 index 00000000..bd6252f2 --- /dev/null +++ b/crates/skate-core/src/camera/path_prediction.rs @@ -0,0 +1,49 @@ +//! Complete candidate-list evaluator TU3 82E02608, used by avoidance82E023D0. +//! Query enumeration and its separate asynchronous world provider are upstream. + +/// Fields consumed from each native 48-byte candidate; remaining words are not +/// interpreted by this evaluator. Velocity is in the same world frame as path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PathObstacle { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub radius: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PredictionPath { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub radius: f32, + pub horizon: f32, +} + +/// Returns the earliest accepted closest-approach time, or native FLT_MAX. +/// Native does not solve first surface entry time; tangent cases are rejected. +pub fn candidate_collision_time(path: PredictionPath, obstacles: &[PathObstacle]) -> f32 { + let mut result = f32::MAX; + for obstacle in obstacles { + let separation = core::array::from_fn(|i| obstacle.position[i] - path.position[i]); + let relative_velocity = core::array::from_fn(|i| obstacle.velocity[i] - path.velocity[i]); + let radius = obstacle.radius + path.radius; + let clearance = super::vector_tracker::dot(separation, separation) - radius * radius; + let time = if clearance < 0.0 { + 0.0 + } else { + let approach = super::vector_tracker::dot(separation, relative_velocity); + if !(approach < 0.0) { + continue; + } + let speed_squared = super::vector_tracker::dot(relative_velocity, relative_velocity); + let time = (-1.0 / speed_squared) * approach; + if !(approach.mul_add(time, clearance) < 0.0) { + continue; + } + time + }; + if time < path.horizon && time < result { + result = time; + } + } + result +} diff --git a/crates/skate-core/src/camera/positioner.rs b/crates/skate-core/src/camera/positioner.rs new file mode 100644 index 00000000..e498bc7e --- /dev/null +++ b/crates/skate-core/src/camera/positioner.rs @@ -0,0 +1,289 @@ +//! TU3 normal-camera positioner 82E01948 and its complete collision/geometry +//! callees. The caller supplies recovered state/configuration and the native +//! six-fat-line backend; this module does not choose camera settings. + +use super::vector_tracker::{length, refined_reciprocal}; +use super::{ScalarTracker, ScalarTrackerParameters}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FatLine { + pub start: [f32; 4], + pub end: [f32; 4], + pub radius: f32, +} + +/// Native 48-byte result: vectors0/16, fraction32, hit byte36, surface word40. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FatLineResult { + pub position: [f32; 4], + pub normal: [f32; 4], + pub fraction: f32, + pub hit: u8, + pub surface: u32, +} + +/// Camera server slots+8/+12, implemented natively by82DF31C0/82DF3290. +/// Results preserve segment order; native submission always contains six lines. +pub trait PositionerCollisionProvider { + fn submit(&mut self, lines: [FatLine; 6], context: u32); + fn results(&mut self) -> [FatLineResult; 6]; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PositionerConfig { + pub anchor: [f32; 4], + pub offset: [f32; 4], + pub reference_heading: f32, + pub reference_elevation: f32, + pub heading: f32, + pub elevation: f32, + pub distance: f32, + pub minimum_distance: f32, + pub collision_enabled: u8, + pub bypass_distance_tracker: u8, + pub clamp_height: u8, + pub floor_height: f32, + pub speed_clamp: f32, + pub acceleration_clamp: f32, + pub smoothing: f32, + pub query_starts: [[f32; 4]; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Positioner { + pub distance_tracker: ScalarTracker, + pub tracker_parameters: ScalarTrackerParameters, + pub offset: [f32; 4], + pub previous_valid_position: [f32; 4], + pub reference_position: [f32; 4], + pub collision_clear_time: f32, + pub config: PositionerConfig, + pub position: [f32; 4], + pub velocity: [f32; 4], + pub distance: f32, + pub heading: f32, + pub elevation: f32, + pub radius: f32, + pub available_distance: f32, + pub flags: u8, +} + +impl Positioner { + /// Complete82E01948. Negative/zero dt follows native tracker/velocity gates. + pub fn update( + &mut self, + dt: f32, + context: u32, + config: PositionerConfig, + collision: &mut impl PositionerCollisionProvider, + ) -> u32 { + self.config = config; + self.offset = config.offset; + if self.collision_clear_time < 5.0 { + let scale = self.collision_clear_time * f32::from_bits(0x3e4ccccd); + self.offset = self.offset.map(|v| v * scale); + } + if config.bypass_distance_tracker != 0 { + self.distance_tracker.target = config.distance; + self.distance_tracker.position = config.distance; + } else { + let acceleration = if self.distance_tracker.position < f32::from_bits(0x3f866666) { + 100.0 + } else { + config.acceleration_clamp + }; + self.tracker_parameters.acceleration_clamp_min = acceleration; + self.tracker_parameters.acceleration_clamp_max = acceleration; + self.tracker_parameters.speed_clamp = config.speed_clamp; + self.tracker_parameters.smoothing_min = config.smoothing; + self.tracker_parameters.smoothing_max = config.smoothing; + self.distance_tracker + .update(dt, config.distance, self.tracker_parameters); + } + self.radius = f32::from_bits(0x3e051eb8); + let previous = self.position; + let result = self.resolve(context, collision); + if dt > 0.0 { + let inverse = refined_reciprocal(dt); + self.velocity = core::array::from_fn(|i| inverse * (self.position[i] - previous[i])); + } + if self.flags & 0xc0 != 0 { + self.collision_clear_time = 0.0; + } else { + self.collision_clear_time += dt; + } + result + } + + /// Complete82E01BA0. Query status 3 preserves previous-valid state; the + /// current complete82E01EA8 body produces only statuses0,1,2. + pub fn resolve( + &mut self, + context: u32, + collision: &mut impl PositionerCollisionProvider, + ) -> u32 { + self.available_distance = self.config.distance; + self.flags = (self.flags & 0xc1) | 0x0c; + self.heading = self.config.heading; + self.elevation = self.config.elevation; + self.distance = self.distance_tracker.position; + self.position = self.position_at(self.elevation, self.heading, self.distance); + let result = self.resolve_collision(context, collision); + if result == 1 || result == 2 { + let distance = length(core::array::from_fn(|i| { + self.position[i] - self.config.anchor[i] + })); + self.distance_tracker.target = distance; + self.distance_tracker.position = distance; + self.distance_tracker.velocity = 0.0; + self.distance_tracker.acceleration = 0.0; + self.distance = distance; + } + if result == 3 { + self.position = self.previous_valid_position; + self.flags &= !8; + } else { + self.previous_valid_position = self.position; + self.flags = (self.flags & !0x20) | if result == 2 { 0x20 } else { 0 }; + } + self.flags = (self.flags & 0x3f) | if result != 0 { 0x80 } else { 0 }; + result + } + + /// Complete82E01D30. Submit happens even when collision_enabled is zero. + pub fn submit_collision( + &mut self, + context: u32, + collision: &mut impl PositionerCollisionProvider, + ) { + self.reference_position = self.position_at( + self.config.reference_elevation, + self.config.reference_heading, + self.config.distance, + ); + let lines = core::array::from_fn(|i| FatLine { + start: self.config.query_starts[i % 3], + end: if i < 3 { + self.position + } else { + self.reference_position + }, + radius: self.radius, + }); + collision.submit(lines, context); + } + + /// Complete82E01EA8, preserving first-three/last-three result roles and order. + pub fn resolve_collision( + &mut self, + context: u32, + collision: &mut impl PositionerCollisionProvider, + ) -> u32 { + self.submit_collision(context, collision); + let results = collision.results(); + if self.config.collision_enabled != 0 { + self.flags |= 2; + self.available_distance = 0.0; + for result in &results[3..6] { + if result.hit != 0 { + let point = project_hit( + self.reference_position, + self.config.query_starts[0], + *result, + ); + let distance = + length(core::array::from_fn(|i| point[i] - self.config.anchor[i])); + if !(self.available_distance - distance >= 0.0) { + self.available_distance = distance; + } + } else { + self.available_distance = self.config.distance; + self.flags &= !2; + } + } + if results[..3].iter().all(|r| r.hit != 0) { + self.position = project_hit(self.position, self.config.query_starts[0], results[0]); + let distance = length(core::array::from_fn(|i| { + self.position[i] - self.config.query_starts[0][i] + })); + return if distance < self.radius + self.config.minimum_distance { + 2 + } else { + 1 + }; + } + } + 0 + } + + fn position_at(&self, elevation: f32, heading: f32, distance: f32) -> [f32; 4] { + position_from_angles( + self.config.anchor, + self.offset, + elevation, + heading, + distance, + self.config.clamp_height, + self.radius + self.config.floor_height, + ) + } +} + +/// Complete82E020F0. Invalid hit positions or a zero hit byte preserve the end. +pub fn project_hit(end: [f32; 4], first_query_start: [f32; 4], result: FatLineResult) -> [f32; 4] { + if result.position[..3].iter().all(|v| !v.is_nan()) && result.hit != 0 { + let lower = if -result.fraction >= 0.0 { + 0.0 + } else { + result.fraction + }; + let fraction = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + core::array::from_fn(|i| { + (end[i] - first_query_start[i]).mul_add(fraction, first_query_start[i]) + }) + } else { + end + } +} + +/// Complete82E02190, including native four-lane basis construction and ordering. +pub fn position_from_angles( + anchor: [f32; 4], + offset: [f32; 4], + elevation: f32, + heading: f32, + distance: f32, + clamp_height: u8, + minimum_height: f32, +) -> [f32; 4] { + let (s_e, c_e) = crate::trigonometry::sin_cos(-elevation); + let (s_h, c_h) = crate::trigonometry::sin_cos(-heading); + // 822FB890 permutation and vrlimi lane insertion, in guest memory order. + let first = [ + [1.0, 0.0, 0.0, 1.0], + [0.0, c_e, s_e, 0.0], + [0.0, -s_e, c_e, 0.0], + ]; + let second = [ + [c_h, 0.0, -s_h, c_h], + [0.0, 1.0, 0.0, 0.0], + [s_h, 0.0, c_h, s_h], + ]; + let product: [[f32; 4]; 3] = core::array::from_fn(|column| { + core::array::from_fn(|i| { + let value = second[0][i] * first[column][0]; + let value = second[1][i].mul_add(first[column][1], value); + second[2][i].mul_add(first[column][2], value) + }) + }); + let mut point = core::array::from_fn(|i| { + let value = product[0][i] * 0.0; + let value = product[1][i].mul_add(0.0, value); + let value = product[2][i].mul_add(distance, value); + (value + offset[i]) + anchor[i] + }); + if clamp_height != 0 && minimum_height > point[1] { + point[1] = minimum_height; + } + point +} diff --git a/crates/skate-core/src/camera/rig.rs b/crates/skate-core/src/camera/rig.rs new file mode 100644 index 00000000..85fcf0ef --- /dev/null +++ b/crates/skate-core/src/camera/rig.rs @@ -0,0 +1,312 @@ +//! Native normal-camera rig update82E03300. The rig owns one copy of each +//! shared field; operation adapters below cannot retain inconsistent snapshots. +use super::*; + +#[derive(Clone, Debug, PartialEq)] +pub struct Breadcrumbs { + pub positions: Vec<[f32; 4]>, + pub index: usize, + pub distance_squared_threshold: f32, +} +impl Breadcrumbs { + fn last(&self) -> [f32; 4] { + self.positions[(self.positions.len() + self.index - 1) % self.positions.len()] + } + fn update(&mut self, position: [f32; 4]) { + let delta = core::array::from_fn(|i| position[i] - self.positions[self.index][i]); + if super::vector_tracker::dot(delta, delta) > self.distance_squared_threshold { + self.index += 1; + if self.index >= self.positions.len() { + self.index = 0; + } + self.positions[self.index] = position; + } + } +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigSettings { + pub heading: AngleTrackingSettings, + pub elevation: AngleTrackingSettings, + pub anchor: AnchorTrackingSettings, + pub avoidance: AvoidanceSettings, + pub positioning: RigPositioningSettings, + /// Parameter620 and native global830BD350 respectively. + pub collision_hold_duration: f32, + pub normalization_threshold: [f32; 4], +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigFields { + pub anchor: [f32; 4], + pub velocity: [f32; 4], + pub acceleration: [f32; 4], + pub offset: [f32; 4], + pub heading_target: f32, + pub heading_reference: f32, + pub elevation_reference: f32, + pub elevation_target: f32, + pub elevation_offset: f32, + pub additional_elevation: f32, + pub heading_offset: f32, + pub avoidance_heading: f32, + pub avoidance_elevation: f32, + pub smoothed_acceleration: f32, + pub previous_acceleration: f32, + pub collision_hold_time: f32, + pub reset_time: f32, + pub disable_avoidance_time: f32, + pub height_transition_start: f32, + pub height_transition_end: f32, + pub height_transition_weight: f32, + pub height_transition_duration: f32, + pub height_transition_time: f32, + pub reference_height: f32, + pub previous_height: f32, + pub height_offset: f32, + pub latch_time: f32, + pub input_time: f32, + pub distance: f32, + pub heading_smoothing: f32, + pub elevation_smoothing: f32, + pub heading_weight: f32, + pub elevation_weight: f32, + pub mode_time: f32, + pub frame_counter: u32, + pub collision_mode: u32, + pub avoidance_mode: u32, + pub height_mode: u32, + pub previous_height_mode: u32, + pub flags_516: u8, + pub flags_517: u8, +} +#[derive(Clone, Debug, PartialEq)] +pub struct Rig { + pub fields: RigFields, + pub heading: AngleTracker, + pub heading_parameters: ScalarTrackerParameters, + pub elevation: AngleTracker, + pub elevation_parameters: ScalarTrackerParameters, + pub anchor: VectorTracker, + pub anchor_parameters: ScalarTrackerParameters, + pub positioner: Positioner, + pub paths: [AvoidancePath; 3], + pub breadcrumbs: Breadcrumbs, +} +impl Rig { + pub fn update( + &mut self, + dt: f32, + settings: RigSettings, + subject: &mut S, + requests: &mut [impl TrajectoryCollisionRequest; 3], + moving: &mut impl MovingObstacleProvider, + collision: &mut impl PositionerCollisionProvider, + ) where + S: RigModeSubject + + AvoidanceSubject + + ReferenceHeightSubject + + AnchorTrackingSubject + + RigPositioningSubject, + { + self.fields.reset_time = floor_zero(self.fields.reset_time - dt); + self.fields.disable_avoidance_time = floor_zero(self.fields.disable_avoidance_time - dt); + self.fields.frame_counter = self.fields.frame_counter.wrapping_add(1); + let mut mode = RigMode { + current: self.fields.height_mode, + previous: self.fields.previous_height_mode, + elapsed: self.fields.mode_time, + }; + mode.update(subject); + self.fields.height_mode = mode.current; + self.fields.previous_height_mode = mode.previous; + self.fields.mode_time = mode.elapsed; + let f = self.fields; + let mut avoidance = RigAvoidance { + anchor: f.anchor, + velocity: f.velocity, + tracked_anchor: self.anchor.position, + heading_target: f.heading_target, + heading_reference: f.heading_reference, + elevation_target: f.elevation_target, + avoidance_heading: f.avoidance_heading, + avoidance_elevation: f.avoidance_elevation, + reset_time: f.reset_time, + disable_time: f.disable_avoidance_time, + height_transition_start: f.height_transition_start, + height_transition_end: f.height_transition_end, + height_transition_duration: f.height_transition_duration, + height_transition_time: f.height_transition_time, + reference_height: f.reference_height, + prediction_distance: f.distance, + heading_weight: f.heading_weight, + elevation_weight: f.elevation_weight, + avoidance_mode: f.avoidance_mode, + height_mode: f.height_mode, + flags_516: f.flags_516, + paths: self.paths, + }; + avoidance.update( + dt, + settings.avoidance, + &self.positioner, + self.breadcrumbs.last(), + subject, + requests, + moving, + ); + self.paths = avoidance.paths; + self.fields.avoidance_heading = avoidance.avoidance_heading; + self.fields.avoidance_elevation = avoidance.avoidance_elevation; + self.fields.heading_weight = avoidance.heading_weight; + self.fields.elevation_weight = avoidance.elevation_weight; + self.fields.flags_516 = avoidance.flags_516; + let f = self.fields; + let mut height = ReferenceHeightTracking { + anchor_height: f.anchor[1], + transition_start: f.height_transition_start, + transition_end: f.height_transition_end, + transition_weight: f.height_transition_weight, + transition_duration: f.height_transition_duration, + transition_time: f.height_transition_time, + height: f.reference_height, + previous_height: f.previous_height, + height_offset: f.height_offset, + mode: f.height_mode, + flags_516: f.flags_516, + flags_517: f.flags_517, + }; + height.update(dt, subject); + self.fields.height_transition_duration = height.transition_duration; + self.fields.height_transition_time = height.transition_time; + self.fields.reference_height = height.height; + self.fields.previous_height = height.previous_height; + self.fields.height_offset = height.height_offset; + let mut angular = self.angular(); + angular.update_heading(dt, settings.heading); + angular.update_elevation(dt, settings.elevation); + self.store_angular(angular); + let f = self.fields; + let square = super::vector_tracker::dot(f.velocity, f.velocity); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + let magnitude = if square == 0.0 { 0.0 } else { square * inverse }; + // Mapped TU3 read-only constant82139A30. + let fallback = [0.0, 0.0, 1.0, 0.0]; + let direction = core::array::from_fn(|i| { + if magnitude > settings.normalization_threshold[i] { + f.velocity[i] * inverse + } else { + fallback[i] + } + }); + let acceleration = super::vector_tracker::dot(f.acceleration, direction); + self.fields.smoothed_acceleration = (f.previous_acceleration + acceleration) * 0.5; + self.fields.previous_acceleration = acceleration; + let mut anchor = AnchorRigTracking { + tracker: self.anchor, + parameters: self.anchor_parameters, + anchor: f.anchor, + velocity: f.velocity, + reference_height: f.reference_height, + latch_time: f.latch_time, + input_time: f.input_time, + flags_516: f.flags_516, + flags_517: f.flags_517, + }; + anchor.update(dt, settings.anchor, subject); + self.anchor = anchor.tracker; + self.anchor_parameters = anchor.parameters; + self.fields.latch_time = anchor.latch_time; + self.fields.input_time = anchor.input_time; + self.fields.flags_516 = anchor.flags_516; + self.fields.collision_hold_time = if self.positioner.flags & 0x80 != 0 { + settings.collision_hold_duration + } else { + floor_zero(self.fields.collision_hold_time - dt) + }; + let mut breadcrumb = self.anchor.position; + breadcrumb[1] = self.fields.reference_height; + self.breadcrumbs.update(breadcrumb); + let mut framing = self.framing(); + let mut angular = self.angular(); + framing.update( + dt, + &mut angular, + self.anchor.position, + &self.positioner, + settings.normalization_threshold, + ); + self.fields.anchor = framing.anchor; + self.fields.distance = framing.distance; + let f = self.fields; + let mut positioning = RigPositioning { + heading_reference: f.heading_reference, + elevation_reference: f.elevation_reference, + avoidance_heading: f.avoidance_heading, + avoidance_elevation: f.avoidance_elevation, + elevation_weight: f.elevation_weight, + collision_mode: f.collision_mode, + flags_516: f.flags_516, + }; + positioning.update( + dt, + settings.positioning, + &mut angular, + &framing, + self.anchor.position, + &mut self.positioner, + &mut self.breadcrumbs.positions, + subject, + collision, + ); + self.store_angular(angular); + self.fields.heading_reference = positioning.heading_reference; + self.fields.elevation_reference = positioning.elevation_reference; + self.fields.avoidance_heading = positioning.avoidance_heading; + self.fields.avoidance_elevation = positioning.avoidance_elevation; + self.fields.elevation_weight = positioning.elevation_weight; + self.fields.flags_516 = positioning.flags_516; + } + pub(super) fn angular(&self) -> AngularRigTracking { + let f = self.fields; + AngularRigTracking { + heading: self.heading, + heading_parameters: self.heading_parameters, + elevation: self.elevation, + elevation_parameters: self.elevation_parameters, + heading_target: f.heading_target, + elevation_target: f.elevation_target, + heading_offset: f.heading_offset, + reset_time: f.reset_time, + heading_smoothing: f.heading_smoothing, + elevation_smoothing: f.elevation_smoothing, + heading_acceleration_state: f.heading_weight, + flags_517: f.flags_517, + } + } + pub(super) fn store_angular(&mut self, a: AngularRigTracking) { + self.heading = a.heading; + self.heading_parameters = a.heading_parameters; + self.elevation = a.elevation; + self.elevation_parameters = a.elevation_parameters; + self.fields.heading_target = a.heading_target; + self.fields.elevation_target = a.elevation_target; + self.fields.heading_weight = a.heading_acceleration_state; + self.fields.flags_517 = a.flags_517; + } + fn framing(&self) -> RigFraming { + let f = self.fields; + RigFraming { + anchor: f.anchor, + offset: f.offset, + elevation_offset: f.elevation_offset, + additional_elevation: f.additional_elevation, + reference_height: f.reference_height, + distance: f.distance, + } + } +} +fn floor_zero(value: f32) -> f32 { + if -value >= 0.0 { 0.0 } else { value } +} diff --git a/crates/skate-core/src/camera/rig_framing.rs b/crates/skate-core/src/camera/rig_framing.rs new file mode 100644 index 00000000..75700f2c --- /dev/null +++ b/crates/skate-core/src/camera/rig_framing.rs @@ -0,0 +1,90 @@ +//! Complete TU3 framing82E03560 and subject-driven mode selection82E053E0. +use super::{AngularRigTracking, Positioner, direction_to_angles}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigFraming { + pub anchor: [f32; 4], + pub offset: [f32; 4], + pub elevation_offset: f32, + pub additional_elevation: f32, + pub reference_height: f32, + pub distance: f32, +} + +impl RigFraming { + /// The threshold is the native global vector830BD350. Its runtime producer + /// must be bound by the caller; mapped-image zeros are not a tuning default. + pub fn update( + &mut self, + dt: f32, + angular: &mut AngularRigTracking, + tracked_anchor: [f32; 4], + positioner: &Positioner, + normalization_threshold: [f32; 4], + ) { + if angular.flags_517 & 0x40 == 0 { + return; + } + let mut camera = positioner.position; + if positioner.flags & 0x80 == 0 { + camera = core::array::from_fn(|i| positioner.velocity[i].mul_add(dt, camera[i])); + } + let mut anchor = tracked_anchor; + anchor[1] = self.reference_height; + let delta = core::array::from_fn(|i| (camera[i] - anchor[i]) - self.offset[i]); + let square = super::vector_tracker::dot(delta, delta); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + let magnitude = if square == 0.0 { 0.0 } else { square * inverse }; + let direction = core::array::from_fn(|i| { + if magnitude > normalization_threshold[i] { + delta[i] * inverse + } else { + 0.0 + } + }); + let angles = direction_to_angles(direction); + angular.heading_target = -angles[1] - angular.heading_offset; + angular.elevation_target = (angles[0] - self.elevation_offset) - self.additional_elevation; + self.distance = magnitude; + self.anchor[1] = self.reference_height; + angular.reset_heading(false); + angular.reset_elevation(false); + } +} + +pub trait RigModeSubject { + fn flag_568(&mut self) -> u8; + fn flag_596(&mut self) -> u8; + fn flag_680(&mut self) -> u8; + fn flag_540(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigMode { + pub current: u32, + pub previous: u32, + pub elapsed: f32, +} +impl RigMode { + /// Complete82E053E0. No graph topology or skater-state mapping is inferred + /// from the native getter slots. Preserve their conditional call order. + pub fn update(&mut self, subject: &mut impl RigModeSubject) { + let next = if subject.flag_568() != 0 { + 0 + } else if subject.flag_596() != 0 || subject.flag_680() != 0 { + 2 + } else if subject.flag_540() != 0 { + 1 + } else { + return; + }; + if next != self.current { + self.previous = self.current; + self.current = next; + self.elapsed = 0.0; + } + } +} diff --git a/crates/skate-core/src/camera/rig_initialization.rs b/crates/skate-core/src/camera/rig_initialization.rs new file mode 100644 index 00000000..5fe1f018 --- /dev/null +++ b/crates/skate-core/src/camera/rig_initialization.rs @@ -0,0 +1,219 @@ +//! State construction82E02738, reset82E02E58 and teleport82E031D8. +use super::*; + +impl Rig { + pub fn new(avoidance_mode: u32) -> Self { + let parameters = tracker_parameters(); + let mut rig = Self { + fields: fields(avoidance_mode), + heading: AngleTracker(scalar(0.0)), + heading_parameters: parameters, + elevation: AngleTracker(scalar(0.0)), + elevation_parameters: parameters, + anchor: VectorTracker { + target: [0.0; 4], + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + acceleration_clamp: 0.0, + smoothing: 0.0, + }, + anchor_parameters: parameters, + positioner: Positioner { + distance_tracker: scalar(1.0), + tracker_parameters: parameters, + offset: [0.0; 4], + previous_valid_position: [0.0; 4], + reference_position: [0.0; 4], + collision_clear_time: f32::MAX, + // Host storage for parameters fully supplied by the first rig update. + config: PositionerConfig { + anchor: [0.0; 4], + offset: [0.0; 4], + reference_heading: 0.0, + reference_elevation: 0.0, + heading: 0.0, + elevation: 0.0, + distance: 0.0, + minimum_distance: 0.0, + collision_enabled: 0, + bypass_distance_tracker: 0, + clamp_height: 0, + floor_height: 0.0, + speed_clamp: 0.0, + acceleration_clamp: 0.0, + smoothing: 0.0, + query_starts: [[0.0; 4]; 3], + }, + position: [0.0; 4], + velocity: [0.0; 4], + distance: 0.0, + heading: 0.0, + elevation: 0.0, + radius: f32::from_bits(0x3e051eb8), + available_distance: 0.0, + flags: 0, + }, + paths: [AvoidancePath { + prediction_flag: 0, + evaluator: PathEvaluator { + request_state: 0, + path: PredictionPath { + position: [0.0; 4], + velocity: [0.0; 4], + radius: 0.0, + horizon: 0.0, + }, + acceleration_flag: 0, + collision_time: f32::MAX, + last_valid_time: f32::MAX, + found: 0, + result_acceleration_flag: 0, + submitted_acceleration_flag: 0, + pending_polls: 0, + }, + }; 3], + breadcrumbs: Breadcrumbs { + positions: vec![[0.0; 4]; 2], + index: 0, + distance_squared_threshold: 1.0, + }, + }; + rig.reset_angles(); + rig + } + + /// Reset preserves native subobject history, avoidance mode and the four + /// low flag bits in517. Orientation is reset separately by its owner. + pub fn reset(&mut self) { + let previous = self.fields; + self.fields = fields(previous.avoidance_mode); + self.fields.height_transition_weight = previous.height_transition_weight; + self.fields.flags_517 = (previous.flags_517 & 0x0f) | 0x30; + self.breadcrumbs.distance_squared_threshold = 1.0; + self.reset_angles(); + } + + /// Teleport to this frame's native anchor and shot distance. + pub fn teleport(&mut self) { + self.fields.avoidance_heading = 0.0; + self.fields.avoidance_elevation = 0.0; + self.fields.heading_weight = 0.0; + self.fields.elevation_weight = 0.0; + self.fields.flags_516 &= !1; + self.reset_angles(); + self.anchor.target = self.fields.anchor; + self.anchor.position = self.fields.anchor; + self.anchor.velocity = [0.0; 4]; + self.anchor.acceleration = [0.0; 4]; + self.fields.reference_height = self.fields.anchor[1]; + self.fields.height_offset = 0.0; + //82E03288 clears the preceding frame's world-space shake offset. + self.fields.offset = [0.0; 4]; + self.positioner.distance_tracker.target = self.fields.distance; + self.positioner.distance_tracker.position = self.fields.distance; + self.positioner.distance_tracker.velocity = 0.0; + self.positioner.distance_tracker.acceleration = 0.0; + self.fields.reset_time = f32::from_bits(0x3dcccccd); + self.fields.disable_avoidance_time = 2.0; + self.fields.collision_hold_time = 5.0; + self.fields.height_transition_duration = 0.0; + self.fields.flags_517 &= !0x40; + self.fields.previous_acceleration = 0.0; + self.fields.flags_516 = (self.fields.flags_516 & 0xb3) | 8; + self.fields.input_time = 0.0; + self.fields.latch_time = 0.0; + } + + /// SetElevation82E03150: obstruction is latched until the native reset. + pub fn set_elevation(&mut self, elevation: f32, use_avoidance: bool) { + self.fields.elevation_target = elevation; + self.fields.elevation_reference = elevation; + if use_avoidance { + self.fields.flags_516 |= u8::from(self.positioner.flags & 0x10 != 0); + if self.fields.flags_516 & 1 != 0 { + let delta = + super::rig_tracking::wrap_vmx(self.fields.avoidance_elevation - elevation); + self.fields.elevation_target = + super::rig_tracking::wrap_vmx(super::rig_tracking::wrap_vmx( + delta.mul_add(self.fields.elevation_weight, elevation), + )); + } + } + } + + fn reset_angles(&mut self) { + let mut angular = self.angular(); + angular.reset_heading(true); + angular.reset_elevation(true); + self.store_angular(angular); + } +} + +fn scalar(position: f32) -> ScalarTracker { + ScalarTracker { + target: position, + position, + velocity: 0.0, + acceleration: 0.0, + acceleration_clamp: 0.0, + smoothing: 0.0, + } +} +fn tracker_parameters() -> ScalarTrackerParameters { + ScalarTrackerParameters { + delta_umbra: 0.0, + delta_penumbra: 0.0, + speed_clamp: 0.0, + acceleration_clamp_min: 0.0, + acceleration_clamp_max: 0.0, + smoothing_min: 0.0, + smoothing_max: 0.0, + overshoot_zeroes_velocity: true, + } +} +fn fields(avoidance_mode: u32) -> RigFields { + RigFields { + anchor: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + offset: [0.0; 4], + heading_target: f32::from_bits(0x40490fdb), + heading_reference: f32::from_bits(0x40490fdb), + elevation_reference: 0.0, + elevation_target: 0.0, + elevation_offset: 0.0, + additional_elevation: 0.0, + heading_offset: 0.0, + avoidance_heading: 0.0, + avoidance_elevation: 0.0, + smoothed_acceleration: 0.0, + previous_acceleration: 0.0, + collision_hold_time: 5.0, + reset_time: 2.0, + disable_avoidance_time: 0.0, + height_transition_start: 0.0, + height_transition_end: 0.0, + height_transition_weight: 0.0, + height_transition_duration: 0.0, + height_transition_time: 0.0, + reference_height: 1000.0, + previous_height: f32::MAX, + height_offset: 0.0, + latch_time: 0.0, + input_time: 0.0, + distance: 1.0, + heading_smoothing: 1.0, + elevation_smoothing: 1.0, + heading_weight: 0.0, + elevation_weight: 0.0, + mode_time: 0.0, + frame_counter: 0, + collision_mode: 0, + avoidance_mode, + height_mode: 0, + previous_height_mode: 0, + flags_516: 0x88, + flags_517: 0x30, + } +} diff --git a/crates/skate-core/src/camera/rig_orientation.rs b/crates/skate-core/src/camera/rig_orientation.rs new file mode 100644 index 00000000..aa1ac553 --- /dev/null +++ b/crates/skate-core/src/camera/rig_orientation.rs @@ -0,0 +1,203 @@ +//! Normal gameplay rig pitch/yaw tracking and roll, TU3 82E045E0. +use super::{ + AngleTracker, ScalarTracker, ScalarTrackerParameters, direction_to_angles, normalize_angle, +}; +use crate::{ + math::Basis3, + physics::rigid_body::{RetailQuaternion, basis_from_quaternion}, + point_graph::PointGraph, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OrientationTrackerSettings { + pub acceleration_min_degrees: f32, + pub acceleration_max_degrees: f32, + pub smoothing_min: f32, + pub delta_umbra_degrees: f32, + pub delta_penumbra_degrees: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OrientationSettings { + pub pan_umbra_degrees: f32, + pub pan_penumbra_degrees: f32, + pub pan_smoothing_curve: PointGraph<8>, + pub pan_minimum_smoothing: f32, + pub pan: OrientationTrackerSettings, + pub tilt: OrientationTrackerSettings, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigOrientation { + pub target: [f32; 4], + pub basis: Basis3, + pub pitch: AngleTracker, + pub yaw: AngleTracker, + pub pitch_parameters: ScalarTrackerParameters, + pub yaw_parameters: ScalarTrackerParameters, + pub pan_smoothing: f32, + pub tilt_smoothing: f32, + pub roll: f32, + pub framing_mirror: f32, +} + +impl RigOrientation { + /// Construction82E02738 and reset82E02E58. The angle parameters are host + /// storage: every consumed numerical field is rebound before tracking. + pub fn new() -> Self { + let tracker = AngleTracker(ScalarTracker { + target: 0.0, + position: 0.0, + velocity: 0.0, + acceleration: 0.0, + acceleration_clamp: 0.0, + smoothing: 0.0, + }); + let parameters = ScalarTrackerParameters { + delta_umbra: 0.0, + delta_penumbra: 0.0, + speed_clamp: 0.0, + acceleration_clamp_min: 0.0, + acceleration_clamp_max: 0.0, + smoothing_min: 0.0, + smoothing_max: 0.0, + overshoot_zeroes_velocity: true, + }; + Self { + target: [0.0; 4], + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + pitch: tracker, + yaw: tracker, + pitch_parameters: parameters, + yaw_parameters: parameters, + pan_smoothing: 0.0, + tilt_smoothing: 0.0, + roll: 0.0, + framing_mirror: 1.0, + } + } + + pub fn update( + &mut self, + dt: f32, + settings: OrientationSettings, + reset_time: f32, + flags_516: &mut u8, + flags_517: u8, + ) { + // The same sqrt(2)-scaled quaternion expansion occurs in the rigid + // body's basis helper and82E0466C..82E046AC. + let [x, y, z, w] = self.target; + let target = basis_from_quaternion(RetailQuaternion { x, y, z, w }); + let at = target.columns[2]; + let angles = direction_to_angles([at[0], at[1], at[2], 0.0]); + let pitch = -angles[0]; + let yaw = angles[1]; + let error = normalize_angle(normalize_angle(yaw) - self.yaw.0.position); + let error = if error < normalize_angle(0.0) { + normalize_angle(normalize_angle(-error)) + } else { + error + }; + let radians = f32::from_bits(0x3c8efa35); + let umbra = settings.pan_umbra_degrees * radians; + let penumbra = settings.pan_penumbra_degrees * radians; + let fraction = if error < umbra { + 1.0 + } else if error < penumbra { + 1.0 - super::rig_tracking::wrap_vmx(error - umbra) + / super::rig_tracking::wrap_vmx(penumbra - umbra) + } else { + 0.0 + }; + let response = settings.pan_smoothing_curve.evaluate(fraction); + let minimum = 1.0 - settings.pan_minimum_smoothing; + let minimum = -(minimum * minimum - 1.0); + let pan = if self.pan_smoothing >= minimum { + (self.pan_smoothing - minimum) * response + minimum + } else { + self.pan_smoothing + }; + let pan = cap_smoothing(pan); + self.tilt_smoothing = cap_smoothing(self.tilt_smoothing); + if *flags_516 & 8 != 0 || reset_time > 0.0 { + reset(&mut self.pitch, pitch); + reset(&mut self.yaw, yaw); + *flags_516 &= !8; + } + if *flags_516 & 0x40 != 0 { + bind( + &mut self.pitch_parameters, + settings.tilt, + self.tilt_smoothing, + flags_517 & 0x10 != 0, + ); + self.pitch + .update(dt, normalize_angle(pitch), self.pitch_parameters); + } + bind(&mut self.yaw_parameters, settings.pan, pan, true); + self.yaw + .update(dt, normalize_angle(yaw), self.yaw_parameters); + let basis = super::orientation_math::basis_from_angles( + self.pitch.0.position, + self.yaw.0.position, + 0.0, + ); + self.basis = super::orientation_math::rotate_about_axis( + basis, + basis.columns[2], + self.roll * self.framing_mirror, + ); + } +} + +fn reset(tracker: &mut AngleTracker, target: f32) { + tracker.0.target = normalize_angle(target); + tracker.0.position = normalize_angle(target); + tracker.0.velocity = 0.0; + tracker.0.acceleration = 0.0; +} + +fn cap_smoothing(value: f32) -> f32 { + if value >= 1.0 { + 1.0 + } else if f32::from_bits(0x3f666666) - value >= 0.0 { + value + } else { + f32::from_bits(0x3f666666) + } +} + +fn bind( + p: &mut ScalarTrackerParameters, + s: OrientationTrackerSettings, + smoothing: f32, + clamp_acceleration: bool, +) { + let radians = f32::from_bits(0x3c8efa35); + p.speed_clamp = f32::MAX; + p.acceleration_clamp_min = if clamp_acceleration { + s.acceleration_min_degrees * radians + } else { + f32::MAX + }; + p.acceleration_clamp_max = if clamp_acceleration { + s.acceleration_max_degrees * radians + } else { + f32::MAX + }; + p.smoothing_min = if smoothing < 1.0 { + if s.smoothing_min - smoothing >= 0.0 { + smoothing + } else { + s.smoothing_min + } + } else { + 1.0 + }; + p.smoothing_max = if smoothing < 1.0 { smoothing } else { 1.0 }; + p.delta_umbra = s.delta_umbra_degrees * radians; + p.delta_penumbra = s.delta_penumbra_degrees * radians; +} diff --git a/crates/skate-core/src/camera/rig_positioning.rs b/crates/skate-core/src/camera/rig_positioning.rs new file mode 100644 index 00000000..7c48a701 --- /dev/null +++ b/crates/skate-core/src/camera/rig_positioning.rs @@ -0,0 +1,176 @@ +//! TU3 rig-to-positioner operation82E037F0. Authored settings and the subject +//! adapter are inputs; the world provider retains the positioner's six-line ABI. +use super::{ + AngularRigTracking, Positioner, PositionerCollisionProvider, PositionerConfig, RigFraming, +}; + +pub trait RigPositioningSubject { + fn flag_668(&mut self) -> u8; + fn flag_548(&mut self) -> u8; + fn flag_660(&mut self) -> u8; + fn value_524(&mut self) -> f32; + fn transform_384(&mut self) -> [[f32; 4]; 4]; + fn position_360(&mut self, index: u32) -> [f32; 4]; + fn context_532(&mut self) -> u32; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DistanceTrackingSettings { + pub speed_clamp: f32, + pub smoothing: f32, + pub acceleration_clamp: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigPositioningSettings { + /// Parameter fields536/540/544 and548/552/556, selected by subject668. + pub normal: DistanceTrackingSettings, + pub alternate: DistanceTrackingSettings, + /// Parameter fields908 and912, selected by subject548. + pub minimum_distance: f32, + pub alternate_minimum_distance: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigPositioning { + pub heading_reference: f32, + pub elevation_reference: f32, + pub avoidance_heading: f32, + pub avoidance_elevation: f32, + pub elevation_weight: f32, + pub collision_mode: u32, + pub flags_516: u8, +} +impl RigPositioning { + pub fn update( + &mut self, + dt: f32, + settings: RigPositioningSettings, + angular: &mut AngularRigTracking, + framing: &RigFraming, + tracked_anchor: [f32; 4], + positioner: &mut Positioner, + breadcrumbs: &mut [[f32; 4]], + subject: &mut impl RigPositioningSubject, + collision: &mut impl PositionerCollisionProvider, + ) { + let alternate = subject.flag_668() != 0; + let mut anchor = tracked_anchor; + anchor[1] = framing.reference_height; + let reference_heading = angular.current_heading(); + let reference_elevation = + clamp_elevation(framing.elevation_offset + angular.elevation.0.position); + let heading = angular.current_heading(); + let elevation = clamp_elevation( + (framing.additional_elevation + framing.elevation_offset) + + angular.elevation.0.position, + ); + let distance = if framing.distance.is_nan() { + 1.0 + } else { + framing.distance + }; + let minimum_distance = if subject.flag_548() != 0 { + settings.alternate_minimum_distance + } else { + settings.minimum_distance + }; + let clamp_height = subject.flag_660(); + let floor_height = subject.value_524(); + let tracking = if alternate { + settings.alternate + } else { + settings.normal + }; + let up = subject.transform_384()[1]; + let first = subject.position_360(3); + let second = subject.position_360(0); + let third = subject.position_360(7); + let third = core::array::from_fn(|i| up[i].mul_add(f32::from_bits(0x3e19999a), third[i])); + let config = PositionerConfig { + anchor, + offset: framing.offset, + reference_heading, + reference_elevation, + heading, + elevation, + distance, + minimum_distance, + collision_enabled: u8::from(self.collision_mode != 2), + bypass_distance_tracker: u8::from(angular.flags_517 & 0x40 != 0), + clamp_height, + floor_height, + speed_clamp: tracking.speed_clamp, + acceleration_clamp: tracking.acceleration_clamp, + smoothing: tracking.smoothing, + query_starts: [first, second, third], + }; + // Native BLE skips reset; unordered reset_time proceeds into the reset. + if !(angular.reset_time <= 0.0) { + positioner.distance_tracker.target = distance; + positioner.distance_tracker.position = distance; + positioner.distance_tracker.velocity = 0.0; + positioner.distance_tracker.acceleration = 0.0; + } + angular.flags_517 &= !0x40; + let context = subject.context_532(); + positioner.update(dt, context, config, collision); + if positioner.flags & 4 == 0 { + angular.heading_acceleration_state = 1.0; + self.elevation_weight = 1.0; + self.avoidance_heading = positioner.heading; + self.avoidance_elevation = positioner.elevation; + breadcrumbs.fill(positioner.position); + self.heading_reference = positioner.heading; + angular.heading_target = blend_angle( + self.avoidance_heading, + self.heading_reference, + 1.0 - angular.heading_acceleration_state, + ); + self.set_elevation(positioner.elevation, true, angular, positioner.flags); + angular.reset_heading(true); + angular.reset_elevation(true); + self.flags_516 |= 8; + } + } + + /// Complete82E03150, including the optional avoidance-angle blend. + pub fn set_elevation( + &mut self, + elevation: f32, + avoidance: bool, + angular: &mut AngularRigTracking, + positioner_flags: u8, + ) { + angular.elevation_target = elevation; + self.elevation_reference = elevation; + if avoidance { + self.flags_516 |= (positioner_flags >> 4) & 1; + if self.flags_516 & 1 != 0 { + angular.elevation_target = blend_angle( + angular.elevation_target, + self.avoidance_elevation, + self.elevation_weight, + ); + } + } + angular.elevation_target = super::rig_tracking::wrap_vmx(angular.elevation_target); + } +} +/// Complete82DF3410: wrap delta, fused interpolation, then wrap result. +fn blend_angle(current: f32, target: f32, weight: f32) -> f32 { + super::rig_tracking::wrap_vmx( + weight.mul_add(super::rig_tracking::wrap_vmx(target - current), current), + ) +} +fn clamp_elevation(value: f32) -> f32 { + let maximum = f32::from_bits(0x3fb2b8c2); + let value = if -maximum - value >= 0.0 { + -maximum + } else { + value + }; + if maximum - value >= 0.0 { + value + } else { + maximum + } +} diff --git a/crates/skate-core/src/camera/rig_tracking.rs b/crates/skate-core/src/camera/rig_tracking.rs new file mode 100644 index 00000000..0f223dc6 --- /dev/null +++ b/crates/skate-core/src/camera/rig_tracking.rs @@ -0,0 +1,317 @@ +//! Complete normal-rig tracking stages. Runtime parameters are explicit native +//! bindings; this does not initialize a shot or infer stock camera settings. + +use super::vector_tracker::{length, refined_reciprocal}; +use super::{AngleTracker, ScalarTrackerParameters, VectorTracker, normalize_angle}; +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AngleTrackingSettings { + pub speed_clamp_degrees: f32, + pub acceleration_min_degrees: f32, + pub acceleration_max_degrees: f32, + pub delta_umbra_degrees: f32, + pub delta_penumbra_degrees: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AngularRigTracking { + pub heading: AngleTracker, + pub heading_parameters: ScalarTrackerParameters, + pub elevation: AngleTracker, + pub elevation_parameters: ScalarTrackerParameters, + pub heading_target: f32, + pub elevation_target: f32, + pub heading_offset: f32, + pub reset_time: f32, + pub heading_smoothing: f32, + pub elevation_smoothing: f32, + /// Native rig484 controls both the heading gate and acceleration doubling. + pub heading_acceleration_state: f32, + /// Native rig517; bit0x20 enables heading update independently of rig484. + pub flags_517: u8, +} + +impl AngularRigTracking { + /// Complete82E030C0. This VMX wrap differs from scalar8258DB98. + pub fn current_heading(&self) -> f32 { + wrap_vmx(self.heading.0.position + self.heading_offset) + } + + /// Complete82E054D8, including the preserve-velocity reset branch. + pub fn reset_heading(&mut self, clear_motion: bool) { + self.heading.0.target = normalize_angle(self.heading_target); + self.heading.0.position = normalize_angle(self.heading_target); + if clear_motion { + self.heading.0.velocity = 0.0; + self.heading.0.acceleration = 0.0; + } + } + + /// Complete82E05558. + pub fn reset_elevation(&mut self, clear_motion: bool) { + self.elevation.0.target = normalize_angle(self.elevation_target); + self.elevation.0.position = normalize_angle(self.elevation_target); + if clear_motion { + self.elevation.0.velocity = 0.0; + self.elevation.0.acceleration = 0.0; + } + } + + /// Complete82E03C18 with parameter-block fields516..532. + pub fn update_heading(&mut self, dt: f32, settings: AngleTrackingSettings) { + if self.flags_517 & 0x20 == 0 && self.heading_acceleration_state == 0.0 { + return; + } + let error = wrap_vmx(self.heading_target - self.current_heading()).abs(); + let smoothing = 1.0 - self.heading_smoothing; + let capped = if f32::from_bits(0x3f4ccccd) - smoothing >= 0.0 { + smoothing + } else { + f32::from_bits(0x3f4ccccd) + }; + let smoothing = if error < f32::from_bits(0x3f490fdb) { + (capped - smoothing).mul_add(error * f32::from_bits(0x3fa2f984), smoothing) + } else { + capped + }; + if self.reset_time > 0.0 { + self.reset_heading(true); + } + bind_angle_settings(&mut self.heading_parameters, settings, smoothing); + if self.heading_acceleration_state > 0.0 { + self.heading_parameters.acceleration_clamp_min *= 2.0; + self.heading_parameters.acceleration_clamp_max *= 2.0; + } + self.heading_parameters.overshoot_zeroes_velocity = false; + self.heading.update( + dt, + normalize_angle(self.heading_target), + self.heading_parameters, + ); + } + + /// Complete82E03E28 with parameter-block fields560..576. + pub fn update_elevation(&mut self, dt: f32, settings: AngleTrackingSettings) { + if self.reset_time > 0.0 { + self.reset_elevation(true); + } + bind_angle_settings( + &mut self.elevation_parameters, + settings, + 1.0 - self.elevation_smoothing, + ); + self.elevation.update( + dt, + normalize_angle(self.elevation_target), + self.elevation_parameters, + ); + } +} + +fn bind_angle_settings(p: &mut ScalarTrackerParameters, s: AngleTrackingSettings, smoothing: f32) { + let radians = f32::from_bits(0x3c8efa35); + p.speed_clamp = s.speed_clamp_degrees * radians; + p.acceleration_clamp_min = s.acceleration_min_degrees * radians; + p.acceleration_clamp_max = s.acceleration_max_degrees * radians; + p.delta_umbra = s.delta_umbra_degrees * radians; + p.delta_penumbra = s.delta_penumbra_degrees * radians; + p.smoothing_min = smoothing; + p.smoothing_max = smoothing; +} + +pub(super) fn wrap_vmx(angle: f32) -> f32 { + let tau = f32::from_bits(0x40c90fdb); + let pi = f32::from_bits(0x40490fdb); + let turns = (refined_reciprocal(tau) * angle).trunc(); + let reduced = (-turns).mul_add(tau, angle); + let lower = if reduced + pi >= 0.0 { + reduced + } else { + reduced + tau + }; + if reduced - pi >= 0.0 { + reduced - tau + } else { + lower + } +} + +/// Native subject getter slots used by82E04360/82E04478/82E041B8. +/// Slot numbers remain explicit until the coordinator binds the subject producers. +pub trait AnchorTrackingSubject { + fn flag_584(&mut self) -> u8; + fn flag_588(&mut self) -> u8; + fn value_476(&mut self) -> f32; + fn flag_548(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorTrackingSettings { + /// Native X320/Y352, eight points. + pub latch_curve: PointGraph<8>, + /// Native X448/Y480, eight points. + pub input_curve: PointGraph<8>, + pub speed_clamp: f32, + pub acceleration_clamp: f32, + /// Native fields600,624,808. + pub latch_scale: f32, + pub input_threshold: f32, + pub input_scale: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorRigTracking { + pub tracker: VectorTracker, + pub parameters: ScalarTrackerParameters, + pub anchor: [f32; 4], + pub velocity: [f32; 4], + pub reference_height: f32, + pub latch_time: f32, + pub input_time: f32, + pub flags_516: u8, + pub flags_517: u8, +} + +impl AnchorRigTracking { + /// Complete82E04360, including the conditional second subject getter. + pub fn latch_smoothing( + &mut self, + dt: f32, + s: AnchorTrackingSettings, + subject: &mut impl AnchorTrackingSubject, + ) -> f32 { + self.latch_time = floor_zero(self.latch_time - dt); + if subject.flag_584() == 0 { + self.flags_516 &= !4; + } else if subject.flag_588() != 0 { + self.flags_516 |= 4; + } + if self.flags_516 & 4 != 0 { + self.latch_time = 0.5; + } + clamp_fraction(s.latch_curve.evaluate((-self.latch_time).mul_add(2.0, 1.0))) * s.latch_scale + } + + /// Complete82E04478. + pub fn input_smoothing( + &mut self, + dt: f32, + s: AnchorTrackingSettings, + subject: &mut impl AnchorTrackingSubject, + ) -> f32 { + self.input_time = floor_zero(self.input_time - dt); + if subject.value_476() > s.input_threshold { + self.input_time = 0.5; + } + clamp_fraction(s.input_curve.evaluate((-self.input_time).mul_add(2.0, 1.0))) * s.input_scale + } + + /// Complete82E041B8. Both smoothing histories update before the disable gate. + pub fn update( + &mut self, + dt: f32, + s: AnchorTrackingSettings, + subject: &mut impl AnchorTrackingSubject, + ) { + let latch = self.latch_smoothing(dt, s, subject); + let input = self.input_smoothing(dt, s, subject); + let smoothing = if subject.flag_548() != 0 || self.flags_517 & 0x40 != 0 { + 0.0 + } else { + let maximum = if latch - input >= 0.0 { latch } else { input }; + maximum * clamp_fraction((length(self.velocity) - 8.0) * -0.25) + }; + self.parameters.speed_clamp = s.speed_clamp; + self.parameters.acceleration_clamp_min = s.acceleration_clamp; + self.parameters.acceleration_clamp_max = s.acceleration_clamp; + self.parameters.smoothing_min = smoothing; + self.parameters.smoothing_max = smoothing; + let mut target = self.anchor; + target[1] = self.reference_height; + self.tracker.update(dt, target, self.parameters); + } +} + +fn floor_zero(value: f32) -> f32 { + if -value >= 0.0 { 0.0 } else { value } +} +fn clamp_fraction(value: f32) -> f32 { + let v = floor_zero(value); + if 1.0 - v >= 0.0 { v } else { 1.0 } +} + +/// Subject getter slots consumed by the complete reference-height stage82E03F20. +pub trait ReferenceHeightSubject { + fn flag_656(&mut self) -> u8; + fn flag_608(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ReferenceHeightTracking { + pub anchor_height: f32, + pub transition_start: f32, + pub transition_end: f32, + pub transition_weight: f32, + pub transition_duration: f32, + pub transition_time: f32, + pub height: f32, + pub previous_height: f32, + pub height_offset: f32, + pub mode: u32, + pub flags_516: u8, + pub flags_517: u8, +} + +impl ReferenceHeightTracking { + /// Complete82E03F20, including zero/initial height, transitions and the + /// conditional subject-query order. No mode is invented or selected here. + pub fn update(&mut self, dt: f32, subject: &mut impl ReferenceHeightSubject) { + let immediate = self.flags_516 & 0x40 != 0; + if immediate { + self.transition_duration = 0.0; + } + let old_height = self.height; + let mut target = old_height; + let blending = !immediate && self.transition_time < self.transition_duration; + if blending { + let fraction = clamp_fraction(self.transition_time / self.transition_duration); + target = fraction.mul_add( + self.transition_end - self.transition_start, + self.transition_start, + ); + if self.flags_517 & 8 != 0 { + let weight = self.transition_weight * self.transition_weight; + target = (1.0 - weight).mul_add(target, self.anchor_height * weight); + } + } else if self.mode == 0 || self.mode == 2 { + self.previous_height = old_height; + target = self.anchor_height; + } else if immediate || old_height > self.anchor_height { + target = self.anchor_height; + } + let mut smoothing = 1.0; + if old_height != f32::MAX && !blending && self.mode != 2 && subject.flag_656() != 0 { + smoothing = f32::from_bits(0x3c23d70a); + } + self.height = (target - self.height).mul_add(smoothing, self.height); + if subject.flag_608() != 0 { + let delta = self.height - self.anchor_height; + if delta.abs() > f32::from_bits(0x38d1b717) { + self.height_offset = delta; + if delta > 0.0 { + self.height = self.anchor_height; + self.height_offset = 0.0; + } + } else if self.height_offset.abs() > f32::from_bits(0x3ba3d70a) { + self.height_offset *= f32::from_bits(0x3f666666); + self.height += self.height_offset; + } else { + self.height_offset = 0.0; + } + } else { + self.height_offset = self.height - self.anchor_height; + } + self.transition_time += dt; + } +} diff --git a/crates/skate-core/src/camera/shake.rs b/crates/skate-core/src/camera/shake.rs new file mode 100644 index 00000000..893ee7c1 --- /dev/null +++ b/crates/skate-core/src/camera/shake.rs @@ -0,0 +1,85 @@ +//! Stock normal-camera shake data and impact response82E05DC0/82E055D8. +use super::manager_state::clamp; +use super::vector_tracker::{dot,length,refined_reciprocal}; + +#[derive(Clone,Debug,PartialEq)] +pub struct ShakeSamples { + pub(super) rotations:Vec<[f32;4]>, + pub(super) translations:Vec<[f32;4]>, +} +impl ShakeSamples { + /// Eight floats per .shk record. Native consumes XYZ of both records and + /// discards each fourth token; it subtracts each complete sequence's mean. + pub fn from_rows(rows:&[[f32;8]])->Result { + if rows.is_empty() {return Err("Stock camera shake has no samples".into());} + if rows.iter().flatten().any(|v|!v.is_finite()) { + return Err("Stock camera shake contains a non-finite sample".into()); + } + let mut rotations=Vec::with_capacity(rows.len()); + let mut translations=Vec::with_capacity(rows.len()); + let mut rotation_sum=[0.0_f32;4];let mut translation_sum=[0.0_f32;4]; + for row in rows { + let rotation=[row[0],row[1],row[2],0.0]; + let translation=[row[4],row[5],row[6],0.0]; + rotation_sum=core::array::from_fn(|i|rotation_sum[i]+rotation[i]); + translation_sum=core::array::from_fn(|i|translation_sum[i]+translation[i]); + rotations.push(rotation);translations.push(translation); + } + let inverse=1.0/(rows.len() as f32); + let rotation_mean=rotation_sum.map(|v|v*inverse); + let translation_mean=translation_sum.map(|v|v*inverse); + for (rotation,translation) in rotations.iter_mut().zip(&mut translations) { + *rotation=core::array::from_fn(|i|rotation[i]-rotation_mean[i]); + *translation=core::array::from_fn(|i|translation[i]-translation_mean[i]); + } + Ok(Self {rotations,translations}) + } +} + +#[derive(Clone,Copy,Debug,PartialEq)] +pub struct ShakeSettings { + pub amplitude_curve:[[f32;4];4], //parameter0 + pub frequency_curve:[[f32;4];4], //64 + pub impulse_magnitude:f32, //744 + pub impulse_minimum_velocity:f32, //748 + pub impulse_maximum_velocity:f32, //752 + pub impulse_frequency:f32, //756 + pub impulse_decay:f32, //760 + pub amplitude_minimum:f32, //764 + pub amplitude_maximum:f32, //768 + pub amplitude_top_speed:f32, //772 + pub frequency_minimum:f32, //776 + pub frequency_maximum:f32, //780 + pub frequency_top_speed:f32, //784 + pub data_frames_per_second:f32, //788 + pub translation_multiplier:f32, //792 + pub rotation_multiplier:f32, //796 + pub dutch_multiplier:f32, //800 +} + +#[derive(Clone,Copy,Debug,PartialEq)] +pub struct ShakeEffect { + pub translation:[f32;4], + pub time:f32, + pub impulse:f32, + pub weight:f32, +} +impl ShakeEffect { + pub fn new()->Self {Self {translation:[0.0;4],time:0.0,impulse:0.0,weight:0.0}} + + ///82E055D8, including grazing-angle attenuation before velocity shaping. + pub fn landing_impulse(&mut self,normal:[f32;4],velocity:[f32;4],settings:ShakeSettings)->bool { + let speed=length(velocity); + if f32::from_bits(0x3727c5ac)>speed {return false;} + let inverse=refined_reciprocal(speed); + let direction=velocity.map(|v|inverse*v); + let cosine=clamp(dot(normal.map(|v|-v),direction),-1.0,1.0); + let angle=crate::trigonometry::acos(cosine)*f32::from_bits(0x3f22f983); + let weighted_speed=speed*(1.0-clamp(angle,0.0,1.0)); + if weighted_speed<=0.0 {return false;} + let fraction=clamp((weighted_speed-settings.impulse_minimum_velocity) + /(settings.impulse_maximum_velocity-settings.impulse_minimum_velocity),0.0,1.0); + let impulse=(fraction*fraction)*settings.impulse_magnitude; + if impulse>self.impulse {self.impulse=impulse;true} else {false} + } +} diff --git a/crates/skate-core/src/camera/shake_curve.rs b/crates/skate-core/src/camera/shake_curve.rs new file mode 100644 index 00000000..725db872 --- /dev/null +++ b/crates/skate-core/src/camera/shake_curve.rs @@ -0,0 +1,23 @@ +//! Camera shake Bezier inversion8296F530 and evaluation8296F388. +//! Basis initialized82F82738 from8232FB00/FAF0/FAE0/F660. +pub(super) fn sample(points:[[f32;4];4],input:f32)->f32 { + let basis=[[-1.0,3.0,-3.0,1.0],[3.0,-6.0,3.0,0.0],[-3.0,3.0,0.0,0.0],[1.0,0.0,0.0,0.0]]; + let coefficients:[[f32;4];4]=basis.map(|row|core::array::from_fn(|lane| + points[3][lane].mul_add(row[3],points[2][lane].mul_add(row[2], + points[1][lane].mul_add(row[1],points[0][lane]*row[0]))))); + let mut t=0.5_f32; + for iteration in 0..10 { + let square=t*t; + let evaluate=|powers:[f32;4],lane:usize|coefficients[3][lane].mul_add(powers[3], + coefficients[2][lane].mul_add(powers[2],coefficients[1][lane].mul_add(powers[1], + coefficients[0][lane]*powers[0]))); + let value=evaluate([square*t,square,t,1.0],0); + let result=evaluate([square*t,square,t,1.0],1); + let derivative=evaluate([square*3.0,t*2.0,1.0,0.0],0); + let residual=input-value; + if iteration==9 || !(residual.abs()>f32::from_bits(0x3c23d70a) + && derivative.abs()>f32::from_bits(0x34000000)) {return result;} + t=residual/derivative+t; + } + unreachable!() +} diff --git a/crates/skate-core/src/camera/shake_update.rs b/crates/skate-core/src/camera/shake_update.rs new file mode 100644 index 00000000..0fa1eb60 --- /dev/null +++ b/crates/skate-core/src/camera/shake_update.rs @@ -0,0 +1,56 @@ +//! Complete normal-camera ShakeEffect update82E05800. +use super::{ShakeEffect,ShakeSamples,ShakeSettings}; +use super::manager_state::clamp; +use crate::math::Basis3; + +impl ShakeEffect { + pub fn update(&mut self,dt:f32,speed:f32,distance:f32,basis:Basis3,enabled:bool, + samples:&ShakeSamples,settings:ShakeSettings)->Basis3 { + self.weight=clamp(if enabled {dt.mul_add(5.0,self.weight)} + else {-dt.mul_add(5.0,-self.weight)},0.0,1.0); + let amplitude=if settings.amplitude_top_speed!=0.0 { + super::shake_curve::sample(settings.amplitude_curve, + clamp(speed/settings.amplitude_top_speed,0.0,1.0)) + .mul_add(settings.amplitude_maximum-settings.amplitude_minimum,settings.amplitude_minimum) + } else {settings.amplitude_minimum}; + let amplitude=(self.impulse+amplitude)*self.weight; + let mut frequency=if settings.frequency_top_speed!=0.0 { + super::shake_curve::sample(settings.frequency_curve, + clamp(speed/settings.frequency_top_speed,0.0,1.0)) + .mul_add(settings.frequency_maximum-settings.frequency_minimum,settings.frequency_minimum) + } else {settings.frequency_minimum}; + if self.impulse>0.0 {frequency=settings.impulse_frequency;} + self.time=(settings.data_frames_per_second*frequency).mul_add(dt,self.time); + let inverse=super::vector_tracker::refined_reciprocal(1.0); + let whole=(inverse*self.time).trunc(); + let fraction=(-whole).mul_add(1.0,self.time); + let left=(self.time-fraction) as u32 as usize % samples.rotations.len(); + let right=(left+1)%samples.rotations.len(); + let distance_weight=clamp((f32::from_bits(0x4059999a)-distance) + *f32::from_bits(0x3fd55552),0.0,1.0); + let inverse_fraction=1.0-fraction; + let mut angles:[f32;3]=core::array::from_fn(|i| { + let interpolated=samples.rotations[left][i].mul_add(inverse_fraction, + samples.rotations[right][i]*fraction); + ((interpolated*amplitude)*distance_weight)*settings.rotation_multiplier + }); + // vrlimi mask2 selects Z (Dutch/roll), not Y. + angles[2] *=settings.dutch_multiplier; + let rotation=super::orientation_math::basis_from_angles(angles[0],angles[1],angles[2]); + let local_translation:[f32;3]=core::array::from_fn(|i| { + let interpolated=samples.translations[left][i].mul_add(inverse_fraction, + samples.translations[right][i]*fraction); + ((interpolated*settings.translation_multiplier)*amplitude)*distance_weight + }); + self.translation=core::array::from_fn(|i| if i==3 {0.0} else { + basis.columns[2][i].mul_add(local_translation[2],basis.columns[1][i].mul_add(local_translation[1], + basis.columns[0][i].mul_add(local_translation[0],0.0))) + }); + let result=Basis3 {columns:rotation.columns.map(|column|core::array::from_fn(|i| + column[2].mul_add(basis.columns[2][i],column[1].mul_add(basis.columns[1][i], + column[0]*basis.columns[0][i]))))}; + self.impulse=(1.0-settings.impulse_decay)*self.impulse; + if self.impulse Self { + Self { + distance: 2.0, + lens_length: 12.0, + smoothing: [f32::from_bits(0x3f4ccccd); 4], + reference_weights: [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], + board_offset: 0.0, + position_heading: f32::from_bits(0x40490fdb), + position_elevation: 0.0, + framing: [0.0; 3], + follow_subject_in_air: 1, + mirror_for_stance: 1, + snap_to_reference_point: 0, + use_previous_shot: 0, + use_drop_predictor: 0, + use_free_camera_stick: 0, + avoidance_override: 0, + blur: 1.0, + transition_blur: 1.0, + subject_opacity: 1.0, + collision_hint: 0, + anchor: 0, + compass_north: 0, + world_heading: 0.0, + arm_orientation: [0.0, 0.0, 0.0, 1.0], + camera_orientation: [0.0, 0.0, 0.0, 1.0], + } + } + + /// Normal-shot evaluation82E06CB8/82E00D70. `north` is the subject's + /// selected compass entry (the manager applies the special 6->5 case). + /// UsePreviousShot preserves the already copied arm orientation. + pub fn update_normal(&mut self, north: f32, heading_mirror: f32) { + if self.use_previous_shot != 0 { + return; + } + let from = self.position_heading; + let delta = super::rig_tracking::wrap_vmx(-self.position_heading - from); + let heading = + super::rig_tracking::wrap_vmx(delta.mul_add((1.0 - heading_mirror) * 0.5, from)); + let heading = super::rig_tracking::wrap_vmx(heading + north); + self.arm_orientation = + super::orientation_math::quaternion_from_angles(self.position_elevation, heading, 0.0); + } + + /// Complete weighted reference-point calculation82E01070. The board + /// offset follows subject getter456 and is applied to every weighted + /// reference point before summation, including points with zero weight. + pub fn reference_point( + &self, + positions: &[[f32; 4]; 10], + board_offset_direction: [f32; 4], + fallback: [f32; 4], + ) -> [f32; 4] { + let offset = board_offset_direction.map(|v| v * self.board_offset); + let mut sum = [0.0_f32; 4]; + let mut weight_sum = 0.0_f32; + for (position, weight) in positions.iter().zip(self.reference_weights) { + weight_sum = weight + weight_sum; + sum = core::array::from_fn(|i| (position[i] + offset[i]).mul_add(weight, sum[i])); + } + if f32::from_bits(0x38d1b717) > weight_sum.abs() { + fallback + } else { + let inverse = super::vector_tracker::refined_reciprocal(weight_sum); + sum.map(|v| inverse * v) + } + } + + /// Complete field publication82E06980, also used by82E06E38 after it + /// applies its first sine-shaped blend. Discrete options come from `to` + /// even at fraction zero. Heading and shot metadata are not written. + pub fn interpolate_from(&mut self, from: Self, to: Self, fraction: f32) { + let blend = |a, b| interpolate_float(a, b, 0, fraction); + self.distance = blend(from.distance, to.distance); + self.lens_length = blend(from.lens_length, to.lens_length); + self.smoothing = core::array::from_fn(|i| blend(from.smoothing[i], to.smoothing[i])); + self.reference_weights = + core::array::from_fn(|i| blend(from.reference_weights[i], to.reference_weights[i])); + self.board_offset = blend(from.board_offset, to.board_offset); + self.position_elevation = blend(from.position_elevation, to.position_elevation); + self.framing = core::array::from_fn(|i| blend(from.framing[i], to.framing[i])); + self.follow_subject_in_air = to.follow_subject_in_air; + self.mirror_for_stance = to.mirror_for_stance; + self.snap_to_reference_point = to.snap_to_reference_point; + self.use_previous_shot = to.use_previous_shot; + self.use_drop_predictor = to.use_drop_predictor; + self.use_free_camera_stick = to.use_free_camera_stick; + self.avoidance_override = to.avoidance_override; + self.blur = blend(from.blur, to.blur); + self.transition_blur = to.transition_blur; + self.subject_opacity = blend(from.subject_opacity, to.subject_opacity); + self.collision_hint = to.collision_hint; + self.anchor = to.anchor; + self.compass_north = to.compass_north; + self.world_heading = blend(from.world_heading, to.world_heading); + self.arm_orientation = + interpolate_orientation(from.arm_orientation, to.arm_orientation, fraction); + self.camera_orientation = + interpolate_orientation(from.camera_orientation, to.camera_orientation, fraction); + } +} + +/// TU3 82E074A8. Style0 eases with native sine; style1 takes the target; +/// other styles preserve the source. The caller owns fraction clamping. +pub fn interpolate_float(from: f32, to: f32, style: u32, fraction: f32) -> f32 { + match style { + 0 => (to - from).mul_add(sine_blend(fraction), from), + 1 => to, + _ => from, + } +} + +pub(super) fn sine_blend(fraction: f32) -> f32 { + let angle = fraction.mul_add(f32::from_bits(0x40490fdb), -f32::from_bits(0x3fc90fdb)); + crate::trigonometry::sin(angle).mul_add(0.5, 0.5) +} + +/// Complete blend-value filtering82E07378 after82E07708 produces the raw +/// subject-dependent value. This filter intentionally does not multiply by dt. +pub fn filter_blend_value( + previous: f32, + raw: f32, + smoothing: f32, + minimum: f32, + maximum: f32, +) -> f32 { + let response = 1.0 - smoothing; + let next = (response * response).mul_add(raw - previous, previous); + let lower = if minimum - next >= 0.0 { minimum } else { next }; + if maximum - lower >= 0.0 { + lower + } else { + maximum + } +} + +/// Exact82E07240 interval choice. Entries stop at the first absent child; +/// the returned indices may match at the ends. Zero entries publishes nothing. +pub fn blend_interval( + points: [f32; 3], + present: [bool; 3], + value: f32, +) -> Option<(usize, usize, f32)> { + let count = present.iter().take_while(|present| **present).count(); + if count == 0 { + return None; + } + for i in 0..count { + if points[i] > value { + return Some(if i == 0 { + (0, 0, 1.0) + } else { + ( + i - 1, + i, + (value - points[i - 1]) / (points[i] - points[i - 1]), + ) + }); + } + } + Some((count.saturating_sub(2), count - 1, 1.0)) +} diff --git a/crates/skate-core/src/camera/shot_manager.rs b/crates/skate-core/src/camera/shot_manager.rs new file mode 100644 index 00000000..8e290602 --- /dev/null +++ b/crates/skate-core/src/camera/shot_manager.rs @@ -0,0 +1,307 @@ +//! Normal camera ShotManager82E06370/82E064B0/82E068B8/82E06E38. +//! The host owns immutable stock definitions and tree allocation. The authored +//! child order, filter history and transition calculations retain TU3 behavior. +use super::{Shot, blend_interval, filter_blend_value, position_from_angles}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ShotDefinition { + pub name: String, + pub shot_type: u32, + pub shot: Shot, + pub transition_time: f32, + pub transition_units: u32, + pub children: [Option; 3], + pub blend_points: [f32; 3], + pub blend_value: f32, + pub blend_type: u32, + pub blend_smoothing: f32, +} + +impl ShotDefinition { + fn initial() -> Self { + Self { + name: String::new(), + shot_type: 0, + shot: Shot::new(), + transition_time: 0.5, + transition_units: 0, + children: [None, None, None], + blend_points: [0.0; 3], + blend_value: 0.0, + blend_type: 0, + blend_smoothing: 0.0, + } + } +} + +pub trait ShotDatabase { + fn load(&self, name: &str) -> Result; +} + +/// Runtime values are produced by CameraMan82E07708 and the subject compass. +/// The 6->5 compass override is applied here only when its subject flag is set. +pub trait ShotEnvironment { + fn heading_mirror(&self) -> f32; + fn compass_north(&self, entry: u32) -> f32; + fn special_camera_flag(&self) -> bool; + fn raw_blend_value(&mut self, kind: u32, authored: f32, dt: f32) -> f32; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShotPlacement { + /// Native rig transform+48, not the anchor tracker's smoothed position. + pub anchor: [f32; 4], + pub camera_position: [f32; 4], + pub clamp_height: u8, + pub floor_height: f32, +} + +#[derive(Clone, Debug, PartialEq)] +struct ShotNode { + definition: ShotDefinition, + children: Vec, +} + +impl ShotNode { + fn new(definition: ShotDefinition) -> Self { + Self { + definition, + children: Vec::new(), + } + } + + fn setup( + &mut self, + database: &impl ShotDatabase, + env: &mut impl ShotEnvironment, + ancestry: &mut Vec, + count: &mut usize, + ) -> Result<(), String> { + if self.definition.shot_type != 1 { + return Ok(()); + } + ancestry.push(self.definition.name.clone()); + for name in self + .definition + .children + .iter() + .take_while(|name| name.is_some()) + .flatten() + { + if ancestry + .iter() + .any(|parent| parent.eq_ignore_ascii_case(name)) + { + return Err(format!("Cyclic stock camera shot {name}")); + } + *count += 1; + if *count > 64 { + return Err("Stock camera shot exceeds its 64-child capacity".into()); + } + let mut child = Self::new(database.load(name)?); + child.setup(database, env, ancestry, count)?; + self.children.push(child); + } + ancestry.pop(); + self.definition.blend_value = + env.raw_blend_value(self.definition.blend_type, self.definition.blend_value, 0.0); + Ok(()) + } + + fn update(&mut self, dt: f32, env: &mut impl ShotEnvironment) { + if self.definition.shot_type == 0 { + let entry = self.definition.shot.compass_north; + let entry = if entry == 6 && env.special_camera_flag() { + 5 + } else { + entry + }; + self.definition + .shot + .update_normal(env.compass_north(entry), env.heading_mirror()); + return; + } + let definition = &mut self.definition; + // Empty blended shots never reach the table indexing below in authored + // stock data. Preserve their native no-publication result. + if self.children.is_empty() { + return; + } + let raw = env.raw_blend_value(definition.blend_type, definition.blend_value, dt); + definition.blend_value = filter_blend_value( + definition.blend_value, + raw, + definition.blend_smoothing, + definition.blend_points[0], + definition.blend_points[self.children.len() - 1], + ); + let Some((left, right, fraction)) = blend_interval( + definition.blend_points, + core::array::from_fn(|i| i < self.children.len()), + definition.blend_value, + ) else { + return; + }; + // 82E06E98 and82E06EC4 are separate multiply/subtract/add operations. + let fraction = crate::trigonometry::sin( + fraction * f32::from_bits(0x40490fdb) - f32::from_bits(0x3fc90fdb), + ) * 0.5 + + 0.5; + self.children[left].update(dt, env); + // Native updates the same child twice when both selected pointers match. + self.children[right].update(dt, env); + definition.shot.interpolate_from( + self.children[left].definition.shot, + self.children[right].definition.shot, + fraction, + ); + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ShotManager { + current: ShotNode, + pub previous: Shot, + pub interpolated: Shot, + pub elapsed: f32, + pub transition_distance: f32, + pub transition_duration: f32, + pub transition_override: f32, + pub instant_changes: bool, +} + +impl ShotManager { + pub fn new() -> Self { + Self { + current: ShotNode::new(ShotDefinition::initial()), + previous: Shot::new(), + interpolated: Shot::new(), + elapsed: 0.0, + transition_distance: 0.0, + transition_duration: 0.0, + transition_override: -1.0, + instant_changes: false, + } + } + pub fn current(&self) -> &ShotDefinition { + &self.current.definition + } + + /// CameraChooseShot::Begin82DF5C60, after SetShot. The previous behavior's + /// outgoing override is used only when this one has no incoming override. + pub fn apply_graph_transition(&mut self, incoming: f32, outgoing: f32) { + if incoming >= 0.0 { + self.current.definition.transition_time = incoming; + } else if self.transition_override >= 0.0 { + self.current.definition.transition_time = self.transition_override; + } + self.transition_override = if outgoing >= 0.0 { outgoing } else { -1.0 }; + } + + /// CameraMan reset follows child0 pointers to the authored leaf. + pub fn first_leaf(&self) -> Shot { + let mut node=&self.current; + while let Some(child)=node.children.first() {node=child;} + node.definition.shot + } + + pub fn set_shot( + &mut self, + name: &str, + force: bool, + database: &impl ShotDatabase, + env: &mut impl ShotEnvironment, + placement: ShotPlacement, + ) -> Result { + // 82AE8B40 lowercases ASCII A..Z before the native comparison. + if !force && self.current.definition.name.eq_ignore_ascii_case(name) { + return Ok(false); + } + let mut current = ShotNode::new(database.load(name)?); + self.previous = self.interpolated; + if current.definition.shot.use_previous_shot != 0 { + let shot = &mut current.definition.shot; + shot.distance = self.interpolated.distance; + shot.lens_length = self.interpolated.lens_length; + shot.position_heading = self.interpolated.position_heading; + shot.position_elevation = self.interpolated.position_elevation; + shot.framing = self.interpolated.framing; + shot.arm_orientation = self.interpolated.arm_orientation; + } + self.current = current; + // Native calculates instant time before publishing the new distance. + if self.instant_changes { + self.make_instant(); + } else { + self.elapsed = 0.0; + } + self.current.setup(database, env, &mut Vec::new(), &mut 0)?; + let shot = self.current.definition.shot; + let target = position_from_angles( + placement.anchor, + [0.0; 4], + shot.position_elevation, + shot.position_heading, + shot.distance, + placement.clamp_height, + placement.floor_height, + ); + self.transition_distance = super::vector_tracker::length(core::array::from_fn(|i| { + target[i] - placement.camera_position[i] + })); + Ok(true) + } + + pub fn update(&mut self, dt: f32, env: &mut impl ShotEnvironment) -> f32 { + self.elapsed = dt + self.elapsed; + self.current.update(dt, env); + self.transition_duration = self.duration(false); + let fraction = if self.transition_duration <= 0.0 { + 1.0 + } else { + self.elapsed / self.transition_duration + }; + let fraction = if -fraction >= 0.0 { 0.0 } else { fraction }; + let fraction = if 1.0 - fraction >= 0.0 { fraction } else { 1.0 }; + self.interpolated + .interpolate_from(self.previous, self.current.definition.shot, fraction); + fraction + } + + pub fn make_instant(&mut self) { + self.transition_override = -1.0; + self.elapsed = self.duration(false); + } + + /// CameraMan82DFF174 clamps a distance-transition speed <=0 to0.0001; + /// ShotManager82E06418 and MakeInstant82E07450 do not apply that clamp. + pub fn is_transitioning(&self) -> bool { + self.elapsed < self.duration(true) + } + + /// CameraMan82DFF964..82DFFA08 uses a separate sine-eased framing blend. + pub fn framing_fraction(&self) -> f32 { + let duration = self.duration(true); + if duration <= 0.0 { return 1.0; } + let fraction = super::manager_state::clamp(self.elapsed / duration, 0.0, 1.0); + crate::trigonometry::sin(fraction.mul_add( + f32::from_bits(0x40490fdb), -f32::from_bits(0x3fc90fdb))) + .mul_add(0.5, 0.5) + } + + fn duration(&self, camera_man: bool) -> f32 { + let definition = &self.current.definition; + match definition.transition_units { + 1 => definition.transition_time * f32::from_bits(0x3c888889), + 2 => { + let speed = if camera_man && definition.transition_time <= 0.0 { + f32::from_bits(0x38d1b717) + } else { + definition.transition_time + }; + self.transition_distance / speed + } + _ => definition.transition_time, + } + } +} diff --git a/crates/skate-core/src/camera/shot_orientation.rs b/crates/skate-core/src/camera/shot_orientation.rs new file mode 100644 index 00000000..65042550 --- /dev/null +++ b/crates/skate-core/src/camera/shot_orientation.rs @@ -0,0 +1,38 @@ +//! Quaternion interpolation82E07540, with the native sign choice and near-angle +//! branch. Shared Xenon arithmetic remains approximation-derived; no generated +//! implementation is linked or imported. +use super::{shot::sine_blend, vector_tracker::refined_reciprocal}; + +pub(super) fn interpolate_orientation(from: [f32; 4], to: [f32; 4], fraction: f32) -> [f32; 4] { + slerp(from, to, sine_blend(fraction)) +} + +/// FrameComposer82E01740 uses the same interpolation without sine-easing its +/// input. Native negates the first quaternion when the dot product is negative. +pub(super) fn slerp(from: [f32; 4], to: [f32; 4], weight: f32) -> [f32; 4] { + let dot = crate::physics::native_arithmetic::dot4(from, to); + let signed_from = if 0.0 > dot { from.map(|v| -v) } else { from }; + let absolute_dot = if 0.0 > dot { -dot } else { dot }; + if absolute_dot > f32::from_bits(0x3f7f069e) { + let same_direction = crate::physics::native_arithmetic::dot4(signed_from, to) > 0.0; + let value = core::array::from_fn(|i| { + if same_direction { + (to[i] - signed_from[i]).mul_add(weight, signed_from[i]) + } else { + (-(to[i] + signed_from[i])).mul_add(weight, signed_from[i]) + } + }); + let square = crate::physics::native_arithmetic::dot4(value, value); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + value.map(|v| v * inverse) + } else { + let angle = crate::trigonometry::acos(absolute_dot); + let inverse_sine = refined_reciprocal(crate::trigonometry::sin(angle)); + let first = crate::trigonometry::sin((1.0 - weight) * angle) * inverse_sine; + let second = crate::trigonometry::sin(weight * angle) * inverse_sine; + core::array::from_fn(|i| to[i].mul_add(second, signed_from[i] * first)) + } +} diff --git a/crates/skate-core/src/camera/slow_motion.rs b/crates/skate-core/src/camera/slow_motion.rs new file mode 100644 index 00000000..cc71dc07 --- /dev/null +++ b/crates/skate-core/src/camera/slow_motion.rs @@ -0,0 +1,71 @@ +//! SlowMotionController TU382BB1DD8/82BB1EE0/82BB20A8. The host owns +//! message delivery; this retains the curve, latching and timestep calculation. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct SlowMotionSettings { + pub timescale: PointGraph<16>, + pub fps_at_scale_one: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct SimulationRateRequest { + pub timestep: f32, + pub ticks: u32, +} +#[derive(Clone, Copy, Debug)] +pub struct SlowMotionController { + elapsed: f32, + air_duration: f32, + time_before_prediction: f32, + latched_prediction: bool, +} +impl SlowMotionController { + pub fn begin(settings: SlowMotionSettings) -> (Self, SimulationRateRequest) { + ( + Self { + elapsed: 0.0, + air_duration: 0.0, + time_before_prediction: 0.0, + latched_prediction: false, + }, + settings.request(0.0), + ) + } + pub fn update( + &mut self, + dt: f32, + air_duration: f32, + settings: SlowMotionSettings, + ) -> SimulationRateRequest { + self.elapsed += dt; + //82BB1FC8 uses ble (not GT), which also takes the unordered case. + let progress = if !(air_duration > 0.0) { + if !self.latched_prediction { + self.time_before_prediction += dt; + } + f32::from_bits(0x3dcccccd) + } else { + if !self.latched_prediction { + self.air_duration = air_duration; + self.latched_prediction = true; + } + self.elapsed / (self.time_before_prediction + self.air_duration) + }; + settings.request(progress) + } + pub fn end() -> SimulationRateRequest { + SimulationRateRequest { + timestep: f32::from_bits(0x3c888889), + ticks: 0, + } + } +} +impl SlowMotionSettings { + fn request(self, progress: f32) -> SimulationRateRequest { + let curve = self.timescale.evaluate(progress); + SimulationRateRequest { + timestep: 1.0 / curve.mul_add(self.fps_at_scale_one - 60.0, 60.0), + ticks: 1, + } + } +} diff --git a/crates/skate-core/src/camera/subject.rs b/crates/skate-core/src/camera/subject.rs new file mode 100644 index 00000000..1f2d27d1 --- /dev/null +++ b/crates/skate-core/src/camera/subject.rs @@ -0,0 +1,161 @@ +//! Normal camera subject accessors and reference points, TU3 82DF69C0/82DF78A8. +//! Physical and animation producers supply this snapshot together, after their +//! tick. No grounded state, bone position, or camera anchor is inferred here. +use super::{ + AnchorTrackingSubject, AvoidanceSubject, ReferenceHeightSubject, RigModeSubject, + RigPositioningSubject, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Subject { + /// 82DF80D8 -> setter24. This is the native camera subject basis. + pub transform: [[f32; 4]; 4], + /// Physical output bundle5+432 -> setter32. + pub skeleton_root: [[f32; 4]; 4], + /// Physical output bundle5+64 -> setter92, getter444. + pub hips_position: [f32; 4], + /// Physical output bundle8+96 -> setter100, getter452. + pub last_valid_ground_up: [f32; 4], + pub reference_positions: [[f32; 4]; 10], + pub context: u32, + /// Physical output bundle8+268 -> setter124, getter476. + pub pumping_acceleration: f32, + /// Physical output bundle7+32 -> setter172, getter524. + pub state_height_32: f32, + pub in_ground_physics: u8, + pub grinding: u8, + pub trajectory_valid: u8, + pub wiping_out: u8, + pub physically_pushing: u8, + pub at_pushable_speed: u8, + pub off_board: u8, + /// Bundle2 byte452 -> setter304, getter656. Its producer owns the meaning. + pub air_flag_452: u8, + /// Bundle7 byte81 -> setter308, getter660. + pub state_flag_81: u8, + /// Bundle15 float200 > 0 and flags204 bit2 -> setter316, getter668. + pub broken_bone_slowmo: u8, + /// Setter328 is cleared by82DF69C0; later subject writers remain explicit. + pub subject_flag_328: u8, +} + +/// Exact inputs used by82DF78A8; names identify their role in the camera's +/// reference-point table, while comments retain the physical output binding. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ReferencePointInputs { + pub head: [f32; 4], // skeleton+32 + pub hips: [f32; 4], // skeleton+64 + pub left_foot: [f32; 4], // skeleton+144 + pub right_foot: [f32; 4], // skeleton+160 + pub board: [f32; 4], // skeleton+480 + pub centre_of_mass: [f32; 4], // bundle9+64 + pub damped_centre_of_mass: [f32; 4], // subject464 (bundle9+80) + pub grind_position: [f32; 4], // bundle4+96 + pub tracked_anchor: [f32; 4], // CameraMan604 -> Rig184 -> tracker16 + pub incline_normal: [f32; 4], // CameraMan784 + pub grinding: u8, +} + +impl ReferencePointInputs { + /// The stock list has ten entries. The collision positioner requests + /// entries3,0,7, so substituting one board point for this list changes it. + pub fn positions(self) -> [[f32; 4]; 10] { + let feet = core::array::from_fn(|i| (self.left_foot[i] + self.right_foot[i]) * 0.5); + [ + self.head, + self.hips, + if self.grinding != 0 { + self.grind_position + } else { + self.board + }, + self.centre_of_mass, + self.board, + self.centre_of_mass, + self.tracked_anchor, + feet, + self.damped_centre_of_mass, + self.incline_normal, + ] + } +} + +impl RigModeSubject for Subject { + fn flag_568(&mut self) -> u8 { + self.in_ground_physics + } + fn flag_596(&mut self) -> u8 { + self.grinding + } + fn flag_680(&mut self) -> u8 { + self.subject_flag_328 + } + fn flag_540(&mut self) -> u8 { + self.trajectory_valid + } +} +impl AvoidanceSubject for Subject { + fn transform_376(&mut self) -> [[f32; 4]; 4] { + self.transform + } + fn vector_452(&mut self) -> [f32; 4] { + self.last_valid_ground_up + } + fn vector_444(&mut self) -> [f32; 4] { + self.hips_position + } + fn context_532(&mut self) -> u32 { + self.context + } + fn flag_608(&mut self) -> u8 { + self.off_board + } + fn flag_548(&mut self) -> u8 { + self.wiping_out + } +} +impl AnchorTrackingSubject for Subject { + fn flag_584(&mut self) -> u8 { + self.physically_pushing + } + fn flag_588(&mut self) -> u8 { + self.at_pushable_speed + } + fn value_476(&mut self) -> f32 { + self.pumping_acceleration + } + fn flag_548(&mut self) -> u8 { + self.wiping_out + } +} +impl ReferenceHeightSubject for Subject { + fn flag_656(&mut self) -> u8 { + self.air_flag_452 + } + fn flag_608(&mut self) -> u8 { + self.off_board + } +} +impl RigPositioningSubject for Subject { + fn flag_668(&mut self) -> u8 { + self.broken_bone_slowmo + } + fn flag_548(&mut self) -> u8 { + self.wiping_out + } + fn flag_660(&mut self) -> u8 { + self.state_flag_81 + } + fn value_524(&mut self) -> f32 { + self.state_height_32 + } + fn transform_384(&mut self) -> [[f32; 4]; 4] { + self.skeleton_root + } + fn position_360(&mut self, index: u32) -> [f32; 4] { + self.reference_positions[index as usize] + } + fn context_532(&mut self) -> u32 { + self.context + } +} diff --git a/crates/skate-core/src/camera/subject_pose.rs b/crates/skate-core/src/camera/subject_pose.rs new file mode 100644 index 00000000..37d2460b --- /dev/null +++ b/crates/skate-core/src/camera/subject_pose.rs @@ -0,0 +1,51 @@ +//! Pose publication82DF80D8 and82DF7558..82DF76B4. Physical outputs remain +//! authoritative; this owns only the camera's proven one-frame pose history. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SubjectPoseInputs { + pub physical_transform: [[f32; 4]; 4], + pub skeleton_root: [[f32; 4]; 4], + pub center_of_mass: [f32; 4], + pub reckoned_center_of_mass: [f32; 4], + /// State output7 bytes59 and75 select the skeleton root. + pub wiping_out: bool, + pub state_flag_75: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PublishedSubjectPose { + pub transform: [[f32; 4]; 4], + pub damped_center_of_mass: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SubjectPosePublisher { + previous_physical_transform: [[f32; 4]; 4], +} + +impl SubjectPosePublisher { + pub fn new() -> Self { + // Subject constructor82E07C08 initializes its three transform matrices + // from the native unit-axis constants, with a zero translation vector. + Self { previous_physical_transform: [ + [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], [0.0; 4], + ] } + } + + pub fn publish(&mut self, input: SubjectPoseInputs) -> PublishedSubjectPose { + let transform = if input.wiping_out || input.state_flag_75 { + input.skeleton_root + } else { self.previous_physical_transform }; + // 82DF80D8 executes before publisher setter28, preserving that delay. + self.previous_physical_transform = input.physical_transform; + let mut damped = input.reckoned_center_of_mass; + let delta = core::array::from_fn(|i| damped[i] - input.center_of_mass[i]); + let distance = super::vector_tracker::length(delta); + let maximum = f32::from_bits(0x3e147ae1); + if distance > maximum { + let inverse = super::vector_tracker::refined_reciprocal(distance); + damped = core::array::from_fn(|i| (inverse * delta[i]).mul_add(maximum, input.center_of_mass[i])); + } + PublishedSubjectPose { transform, damped_center_of_mass: damped } + } +} diff --git a/crates/skate-core/src/camera/tracker.rs b/crates/skate-core/src/camera/tracker.rs new file mode 100644 index 00000000..0fc014ae --- /dev/null +++ b/crates/skate-core/src/camera/tracker.rs @@ -0,0 +1,90 @@ +//! Complete scalar tracker update at TU3 0x82E08640 (0x190 bytes). +//! Callers supply native state, parameters and timestep; no camera tuning or +//! initialization policy is inferred here. See docs/agents/camera/CAM-01.md. + +/// The six scalar words written/read at tracker offsets +0 through +20. +/// Acceleration clamp retains its prior value when acceleration opposes error. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScalarTracker { + pub target: f32, + pub position: f32, + pub velocity: f32, + pub acceleration: f32, + pub acceleration_clamp: f32, + pub smoothing: f32, +} + +/// Effective tracker fields +28 through +56, after the native caller binds +/// stock settings and applies its dynamic overrides. These are not defaults. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScalarTrackerParameters { + pub delta_umbra: f32, + pub delta_penumbra: f32, + pub speed_clamp: f32, + pub acceleration_clamp_min: f32, + pub acceleration_clamp_max: f32, + pub smoothing_min: f32, + pub smoothing_max: f32, + pub overshoot_zeroes_velocity: bool, +} + +impl ScalarTracker { + /// Advance the complete native update. TU3 does not clamp dt, sanitize + /// parameters or special-case dt == 0 in this function. + pub fn update(&mut self, dt: f32, target: f32, parameters: ScalarTrackerParameters) { + let position = self.position; + let delta = target - position; + self.target = target; + if parameters.overshoot_zeroes_velocity && self.velocity * delta < 0.0 { + // Multiplication preserves negative zero; assignment to +0 does not. + self.velocity *= 0.0; + } + let velocity = self.velocity; + let reciprocal_dt = 1.0 / dt; + self.acceleration = reciprocal_dt.mul_add(delta, -velocity) * reciprocal_dt; + + let absolute_delta = delta.abs(); + let fraction = if absolute_delta < parameters.delta_umbra { + 1.0 + } else if absolute_delta < parameters.delta_penumbra { + 1.0 - (absolute_delta - parameters.delta_umbra) + / (parameters.delta_penumbra - parameters.delta_umbra) + } else { + 0.0 + }; + + if self.acceleration * delta > 0.0 { + let limit = interpolate( + parameters.acceleration_clamp_min, + parameters.acceleration_clamp_max, + fraction, + ); + self.acceleration_clamp = limit; + let absolute_acceleration = self.acceleration.abs(); + if absolute_acceleration > limit { + self.acceleration = (self.acceleration / absolute_acceleration) * limit; + } + } + + self.velocity = self.acceleration.mul_add(dt, velocity); + let absolute_velocity = self.velocity.abs(); + if absolute_velocity > parameters.speed_clamp { + self.velocity = (self.velocity / absolute_velocity) * parameters.speed_clamp; + } + + let next_position = self.velocity.mul_add(dt, position); + self.smoothing = interpolate(parameters.smoothing_min, parameters.smoothing_max, fraction); + // Smoothing changes position only. Do not derive velocity back from it. + self.position = (next_position - position).mul_add(1.0 - self.smoothing, position); + } +} + +pub(super) fn interpolate(minimum: f32, maximum: f32, fraction: f32) -> f32 { + // The equal-parameter branch avoids a multiply/add, including for NaNs in + // the unused fraction. Native otherwise uses a VMX fused multiply-add. + if minimum == maximum { + maximum + } else { + (maximum - minimum).mul_add(fraction, minimum) + } +} diff --git a/crates/skate-core/src/camera/trajectory_query.rs b/crates/skate-core/src/camera/trajectory_query.rs new file mode 100644 index 00000000..53561dbd --- /dev/null +++ b/crates/skate-core/src/camera/trajectory_query.rs @@ -0,0 +1,107 @@ +//! Camera-observable part of the TU3 trajectory batch. The variable-step walk +//! is 82770910, step sizing 8276C940 and collision time 8276C558. World storage +//! and scheduling are host-owned; the native segment sequence is retained. +use super::vector_tracker::{dot, length, refined_reciprocal}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TrajectoryQuery { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub gravity: [f32; 4], + pub duration: f32, + pub radius: f32, + pub start_error: f32, + pub end_error: f32, +} + +impl TrajectoryQuery { + /// Return the first trajectory-segment collision's reconstructed native + /// time, or -1. The callback returns the closest swept-line contact point. + /// The extra normal/mesh/frame fields of the native batch are not consumed + /// by the gameplay camera request's GetCollisionTime accessor82DF9340. + pub fn collision_time( + self, + mut line: impl FnMut([f32; 4], [f32; 4], f32) -> Result, E>, + ) -> Result { + let mut start_step = self.duration; + let mut end_step = self.duration; + if self.gravity[1] < 0.0 { + start_step = self.step_size(self.start_error * self.radius); + end_step = self.step_size(self.end_error * self.radius); + } + let minimum_square = + ((self.start_error * self.radius) * self.start_error) * self.radius; + let mut time = 0.0; + let mut start = self.evaluate(time); + let mut end = self.evaluate(time + start_step); + while self.duration > time { + let delta = core::array::from_fn(|i| end[i] - start[i]); + if dot(delta, delta) > minimum_square { + // 82770B40 rejects only segments with every XYZ component at + // or below this native threshold, before world enumeration. + let visible = delta[..3] + .iter() + .any(|v| v.abs() > f32::from_bits(0x3780_0000)); + if visible { + if let Some(position) = line(start, end, self.radius)? { + return Ok(self.time_at_contact(position, time)); + } + } + start = end; + } + let fraction = refined_reciprocal(self.duration) * time; + let step = end_step.mul_add(fraction, (1.0 - fraction) * start_step); + time += step; + // The stock walk evaluates one step ahead and deliberately does + // not clamp that point to duration. Do not replace with a uniform + // sample count or an end-clamped parabola. + end = self.evaluate(time + step); + } + Ok(-1.0) + } + + fn evaluate(self, time: f32) -> [f32; 4] { + let square = time * time; + core::array::from_fn(|i| { + (self.gravity[i] * 0.5) + .mul_add(square, self.velocity[i].mul_add(time, self.position[i])) + }) + } + + fn step_size(self, error_radius: f32) -> f32 { + let square = refined_reciprocal(self.gravity[1]) * (-8.0 * error_radius); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5) + .mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + if square == 0.0 { 0.0 } else { square * inverse } + } + + fn time_at_contact(self, position: [f32; 4], mut time: f32) -> f32 { + const FRAME: f32 = f32::from_bits(0x3c88_8889); + const EPSILON: f32 = f32::from_bits(0x38d1_b717); + if EPSILON >= (refined_reciprocal(2.0) * self.gravity[1]).abs() { + let distance = length(core::array::from_fn(|i| position[i] - self.position[i])); + if !(distance.abs() > EPSILON) { return 0.0; } + let speed = length(self.velocity); + if !(speed.abs() > EPSILON) { return 0.0; } + // Preserve8276C794/7B4, including its frame factor. This branch + // does not use the segment's fractional intersection parameter. + return (refined_reciprocal(speed) * distance) * FRAME; + } + let mut closest_square = f32::MAX; + while self.duration >= time { + let point = self.evaluate(time); + let delta = core::array::from_fn(|i| position[i] - point[i]); + let square = dot(delta, delta); + if closest_square >= square { + closest_square = square; + } else if square > closest_square { + return time - FRAME; + } + time += FRAME; + } + self.duration + } +} diff --git a/crates/skate-core/src/camera/vector_tracker.rs b/crates/skate-core/src/camera/vector_tracker.rs new file mode 100644 index 00000000..252a4204 --- /dev/null +++ b/crates/skate-core/src/camera/vector_tracker.rs @@ -0,0 +1,92 @@ +//! TU3 vector-tracker candidates. +use super::{ScalarTrackerParameters, tracker::interpolate}; + +/// Four lanes are retained because native vector stores update all four. +/// Magnitudes and direction tests use xyz only. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VectorTracker { + pub target: [f32; 4], + pub position: [f32; 4], + pub velocity: [f32; 4], + pub acceleration: [f32; 4], + pub acceleration_clamp: f32, + pub smoothing: f32, +} + +impl VectorTracker { + pub fn update(&mut self, dt: f32, target: [f32; 4], p: ScalarTrackerParameters) { + self.target = target; + let old = self.position; + let delta = core::array::from_fn(|i| target[i] - old[i]); + if p.overshoot_zeroes_velocity && dot(delta, self.velocity) < 0.0 { + self.velocity = self.velocity.map(|v| v * 0.0); + } + let inverse_dt = refined_reciprocal(dt); + self.acceleration = core::array::from_fn(|i| { + // Separate VMX multiply/subtract/multiply, unlike scalar FMSUBS. + (inverse_dt * delta[i] - self.velocity[i]) * inverse_dt + }); + let magnitude = length(delta); + let fraction = if magnitude < p.delta_umbra { + 1.0 + } else if magnitude < p.delta_penumbra { + 1.0 - (magnitude - p.delta_umbra) / (p.delta_penumbra - p.delta_umbra) + } else { + 0.0 + }; + if dot(self.acceleration, delta) > 0.0 { + self.acceleration_clamp = + interpolate(p.acceleration_clamp_min, p.acceleration_clamp_max, fraction); + self.acceleration = clamp_magnitude(self.acceleration, self.acceleration_clamp); + } + self.velocity = + core::array::from_fn(|i| self.acceleration[i].mul_add(dt, self.velocity[i])); + self.velocity = clamp_magnitude(self.velocity, p.speed_clamp); + let next: [f32; 4] = core::array::from_fn(|i| self.velocity[i].mul_add(dt, old[i])); + self.smoothing = interpolate(p.smoothing_min, p.smoothing_max, fraction); + self.position = + core::array::from_fn(|i| (next[i] - old[i]).mul_add(1.0 - self.smoothing, old[i])); + } +} + +/// TU3 82E08D18 preserves the fourth lane unless the xyz magnitude is capped; +/// then every lane receives the same multiplier. +pub fn clamp_magnitude(value: [f32; 4], limit: f32) -> [f32; 4] { + let magnitude = length(value); + if magnitude > limit { + let scale = limit / magnitude; + value.map(|v| v * scale) + } else { + value + } +} + +pub(super) fn refined_reciprocal(value: f32) -> f32 { + let mut estimate = reciprocal_estimate(value); + for _ in 0..2 { + estimate = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + } + estimate +} + +fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} + +pub(super) fn length(value: [f32; 4]) -> f32 { + let square = dot(value, value); + let mut estimate = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + let residual = (-square).mul_add(estimate * estimate, 1.0); + estimate = (estimate * 0.5).mul_add(residual, estimate); + } + if square == 0.0 { + 0.0 + } else { + square * estimate + } +} + +pub(super) fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} diff --git a/crates/skate-core/src/graph/activation.rs b/crates/skate-core/src/graph/activation.rs new file mode 100644 index 00000000..bc7c62c8 --- /dev/null +++ b/crates/skate-core/src/graph/activation.rs @@ -0,0 +1,213 @@ +//! Data-driven activation for the stock state graph. +//! +//! Node::GetActivation 82C14C28 delegates to its expression with the caller's +//! mask. Condition::GetActivationMasked 82C12D48 excludes disabled conditions +//! and conditions whose authored mask does not intersect that mask. Expression +//! combination and its excluded-child rules are implemented in `expression`. + +use super::{controller::Frame, expression::evaluate_operator, selection::NodeId}; + +pub type ConditionId = usize; +pub type ExpressionId = usize; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Child { + Expression(ExpressionId), + Condition(ConditionId), +} + +#[derive(Clone, Debug)] +pub struct Expression { + pub operator: u32, + /// Native child vector order. Evaluation is lazy, so this order is visible. + pub children: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Condition { + pub enabled: bool, + /// Parsed CondMask bits from the stock condition's `mask` attribute. + pub mask: u32, +} + +#[derive(Clone, Debug)] +pub struct ActivationProgram { + pub state_expressions: Vec>, + pub transition_expressions: Vec>, + pub expressions: Vec, + pub conditions: Vec, +} + +/// Concrete condition implementations are registered by the game. The graph +/// runtime decides whether they run; the host only evaluates an included leaf. +pub trait ConditionHost { + fn condition_activation(&mut self, condition: ConditionId, frame: &Frame) -> u32; +} + +impl ActivationProgram { + /// Complete Node::GetActivation boundary (82C14C28). + pub fn node_activation( + &self, + node: NodeId, + mask: u32, + frame: &Frame, + host: &mut impl ConditionHost, + ) -> bool { + let expression = match node { + NodeId::State(id) => self.state_expressions[id], + NodeId::Transition(id) => self.transition_expressions[id], + }; + let Some(expression) = expression else { + return true; + }; + let mut excluded = 1; + let result = self.expression_activation(expression, mask, frame, &mut excluded, host); + excluded != 0 || result as u8 != 0 + } + + fn expression_activation( + &self, + id: ExpressionId, + mask: u32, + frame: &Frame, + excluded: &mut u8, + host: &mut impl ConditionHost, + ) -> u32 { + let expression = &self.expressions[id]; + evaluate_operator( + expression.operator, + expression.children.len(), + excluded, + |index, child_excluded| match expression.children[index] { + Child::Expression(child) => { + self.expression_activation(child, mask, frame, child_excluded, host) + } + Child::Condition(child) => { + self.condition_activation(child, mask, frame, child_excluded, host) + } + }, + ) + } + + /// Complete Condition::GetActivationMasked gate (82C12D48). + fn condition_activation( + &self, + id: ConditionId, + mask: u32, + frame: &Frame, + excluded: &mut u8, + host: &mut impl ConditionHost, + ) -> u32 { + let condition = self.conditions[id]; + if !condition.enabled || condition.mask & mask == 0 { + *excluded = 1; + 1 + } else { + *excluded = 0; + host.condition_activation(id, frame) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct Host { + values: Vec, + calls: Vec, + } + + impl ConditionHost for Host { + fn condition_activation(&mut self, condition: ConditionId, _: &Frame) -> u32 { + self.calls.push(condition); + self.values[condition] + } + } + + fn frame() -> Frame { + Frame { + dt: 0.0, + current: None, + last: None, + state_times: Vec::new(), + } + } + + fn program(operator: u32, conditions: Vec) -> ActivationProgram { + ActivationProgram { + state_expressions: vec![Some(0)], + transition_expressions: Vec::new(), + expressions: vec![Expression { + operator, + children: (0..conditions.len()).map(Child::Condition).collect(), + }], + conditions, + } + } + + #[test] + fn excluded_conditions_do_not_participate_and_all_excluded_is_true() { + let graph = program( + 1, + vec![ + Condition { + enabled: false, + mask: 1, + }, + Condition { + enabled: true, + mask: 2, + }, + ], + ); + let mut host = Host { + values: vec![0, 0], + ..Default::default() + }; + assert!(graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert!(host.calls.is_empty()); + } + + #[test] + fn included_false_condition_controls_result_and_or_short_circuits() { + let graph = program( + 2, + vec![ + Condition { + enabled: true, + mask: 1, + }, + Condition { + enabled: true, + mask: 1, + }, + ], + ); + let mut host = Host { + values: vec![1, 0], + ..Default::default() + }; + assert!(graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert_eq!(host.calls, [0]); + + host.values[0] = 0; + host.calls.clear(); + assert!(!graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert_eq!(host.calls, [0, 1]); + } + + #[test] + fn nodes_without_expressions_are_active() { + let graph = ActivationProgram { + state_expressions: vec![None], + transition_expressions: vec![None], + expressions: Vec::new(), + conditions: Vec::new(), + }; + let mut host = Host::default(); + assert!(graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert!(graph.node_activation(NodeId::Transition(0), 2, &frame(), &mut host)); + } +} diff --git a/crates/skate-core/src/graph/conditions.rs b/crates/skate-core/src/graph/conditions.rs new file mode 100644 index 00000000..9e7805b0 --- /dev/null +++ b/crates/skate-core/src/graph/conditions.rs @@ -0,0 +1,206 @@ +//! ActionGraph condition leaves recovered from Skate 3 TU3. +//! Physical values are published by their real producers. An absent producer +//! is an integration error, never an implicit ground state or neutral speed. +use super::intents::IntentMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Comparison { + None, + Equal, + NotEqual, + Greater, + Less, + GreaterEqual, + LessEqual, + GreaterAbsolute, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NumericCondition { + pub comparison: Comparison, + pub threshold: f32, + pub absolute: bool, +} +impl NumericCondition { + /// NumericCondition::Compare82C12AB8. The bge/ble branches test the + /// absence of LT/GT, including unordered inputs; do not change to >=/<=. + pub fn matches(self, value: f32) -> bool { + let value = if self.absolute { value.abs() } else { value }; + match self.comparison { + Comparison::None => false, + Comparison::Equal => value == self.threshold, + Comparison::NotEqual => value != self.threshold, + Comparison::Greater => value > self.threshold, + Comparison::Less => value < self.threshold, + Comparison::GreaterEqual => !(value < self.threshold), + Comparison::LessEqual => !(value > self.threshold), + Comparison::GreaterAbsolute => value.abs() > self.threshold, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpeedInputs { + /// PhysOut skateboard+164/+168/+192, respectively. + pub speed: f32, + pub forward_speed: f32, + pub speed_and_slope: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicalStateInputs { + /// PhysOut filtered-state+0/+80/+8, respectively. + pub category: u32, + pub grinding: bool, + pub grind_name: String, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushBrakeInputs { + /// Y of ground-output+80, used by82BA5150. Its producer must be supplied; + /// this is not interchangeable with deck up or a selected contact normal. + pub ground_axis_y: f32, + /// PhysOut skeleton+598. + pub skeleton_disables_push_brake: bool, + /// Selected global collection316, layout+1844. + pub maximum_ground_angle_degrees: f32, +} +impl PushBrakeInputs { + /// Complete scalar result of DisablePushBrake82BA5150. The inverse-sine + /// estimate retains the explicitly documented numerical validation boundary. + pub fn disabled(self) -> bool { + let degrees = crate::trigonometry::asin(self.ground_axis_y) * f32::from_bits(0x4265_2EE1); + 90.0 - degrees > self.maximum_ground_angle_degrees || self.skeleton_disables_push_brake + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ConditionInputs { + pub speeds: Option, + pub physical_state: Option, + /// PhysOut miscellaneous+128, read by82BA15F8. + pub time_since_last_input: Option, + /// Actor stance accessors82BA4C30/82BA4C78, through component+1804. + pub mirrored: Option, + pub riding_fakie: Option, + pub push_brake: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ActionCondition { + HasIntent { + name: String, + numeric: NumericCondition, + }, + TimeSinceLastInput(NumericCondition), + Speed { + along_skate_z: bool, + numeric: NumericCondition, + }, + SpeedAndSlope(NumericCondition), + FilteredState(u32), + Grinding { + name: Option, + }, + Mirrored, + RidingFakie, + DisablePushBrake, + /// Name resolves relative to the containing state, using82C16C10. The + /// host binds target once because the loaded hierarchy is immutable. + CurrentState { + name: String, + target: Option, + }, +} +impl ActionCondition { + pub fn evaluate( + &self, + input: &ConditionInputs, + action_intents: &IntentMap, + current: Option, + parents: &[Option], + ) -> Result { + Ok(match self { + Self::HasIntent { name, numeric } => action_intents.get(name).is_some_and(|&value| { + // HasAGIntent82BA0EA8 distinguishes presence-only from numeric. + numeric.comparison == Comparison::None || numeric.matches(value) + }), + Self::TimeSinceLastInput(numeric) => numeric.matches( + input + .time_since_last_input + .ok_or("TimeSinceLastInput needs PhysOut miscellaneous+128")?, + ), + Self::Speed { + along_skate_z, + numeric, + } => { + let speeds = input + .speeds + .ok_or("PhysicsSpeedCompare needs skateboard output")?; + numeric.matches(if *along_skate_z { + speeds.forward_speed + } else { + speeds.speed + }) + } + Self::SpeedAndSlope(numeric) => numeric.matches( + input + .speeds + .ok_or("PhysicsSpeedAndSlopeCompare needs skateboard output+192")? + .speed_and_slope, + ), + Self::FilteredState(expected) => { + input + .physical_state + .as_ref() + .ok_or("PhysFilteredState needs filtered physical state")? + .category + == *expected + } + Self::Grinding { name } => { + let state = input + .physical_state + .as_ref() + .ok_or("IsGrinding needs filtered physical state")?; + //824714D0 compares the five encoded words, including the + //case/truncation semantics already used by skeleton attributes. + state.grinding + && name.as_ref().is_none_or(|name| { + crate::animation::skeleton_input::name::encode(name.as_bytes()) + == crate::animation::skeleton_input::name::encode( + state.grind_name.as_bytes(), + ) + }) + } + Self::Mirrored => input + .mirrored + .ok_or("IsMirrored needs the motion-graph stance owner")?, + Self::RidingFakie => input + .riding_fakie + .ok_or("IsRidingFakie needs the motion-graph stance owner")?, + Self::DisablePushBrake => input + .push_brake + .ok_or("DisablePushBrake needs ground/skeleton outputs and stock angle")? + .disabled(), + Self::CurrentState { target, .. } => { + //82C13820 includes the current state itself, then its ancestors. + let mut cursor = current; + let mut matches = false; + if let Some(target) = target { + while let Some(state) = cursor { + if state == *target { + matches = true; + break; + } + cursor = parents[state]; + } + } + matches + } + }) + } +} + +#[cfg(test)] +#[path = "tests/conditions.rs"] +mod tests; diff --git a/crates/skate-core/src/graph/controller.rs b/crates/skate-core/src/graph/controller.rs new file mode 100644 index 00000000..05dab4b9 --- /dev/null +++ b/crates/skate-core/src/graph/controller.rs @@ -0,0 +1,268 @@ +//! Dynamic controller 82C13260, list preparation 82C13A00, Enter 82C13B20 and +//! Exit 82C13E58. The host owns concrete behavior/condition implementations and +//! instance storage. Graph topology is the processed stock asset. + +use super::{ + activation::{ActivationProgram, ConditionHost}, + selection::{Selection, StateId, Topology, TransitionId}, +}; + +pub type BehaviorId = usize; +pub type HookId = usize; + +#[derive(Clone, Debug)] +pub struct Behavior { + pub owner: StateId, + pub enabled: bool, +} + +#[derive(Clone, Debug)] +pub struct Program { + pub topology: Topology, + pub root: StateId, + pub activation: ActivationProgram, + pub behaviors: Vec, + /// State+64 vector, in authored order. + pub state_behaviors: Vec>, + /// Transition+44 vector, in authored order. + pub transition_hooks: Vec>, +} + +#[derive(Clone, Debug)] +pub struct Frame { + pub dt: f32, + pub current: Option, + pub last: Option, + /// Missing entries correspond to absent native state-time map entries. + pub state_times: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ActiveBehavior { + pub behavior: BehaviorId, + /// Opaque host instance handle; zero is the native null instance. + pub instance: u32, +} + +/// Concrete nodes run immediately. Effects of End, hooks and Begin are visible +/// to subsequent calls in this tick. A context is the native six-word value; +/// its pointer words are opaque handles at this platform-independent boundary. +/// The host resolves them and owns instance lifetime (including external refs). +pub trait Host: ConditionHost { + fn context(&self) -> [u32; 6]; + fn allocate(&mut self, behavior: BehaviorId, frame: &Frame) -> u32; + fn begin(&mut self, behavior: BehaviorId, context: [u32; 6], frame: &Frame); + fn update(&mut self, behavior: BehaviorId, context: [u32; 6], frame: &Frame); + fn end(&mut self, behavior: BehaviorId, context: [u32; 6], frame: &Frame); + fn hook(&mut self, hook: HookId, frame: &Frame); + /// Release the controller's instance ownership after its End callback. + fn release(&mut self, instance: u32); +} + +#[derive(Clone, Debug)] +pub struct Controller { + pub frame: Frame, + pub active: Vec, +} + +impl Controller { + pub fn new(state_count: usize) -> Self { + Self { + frame: Frame { + dt: 0.0, + current: None, + last: None, + state_times: vec![None; state_count], + }, + active: Vec::new(), + } + } + + pub fn update(&mut self, program: &Program, dt: f32, host: &mut impl Host) { + self.frame.dt = dt; + let mut next = None; + let transition = Selection { + graph: &program.topology, + root: program.root, + current: self.frame.current, + } + .next(&mut next, |node, mask| { + program + .activation + .node_activation(node, mask, &self.frame, host) + }); + let (exiting, entering) = self.prepare_lists(program, transition, next); + + for time in self.frame.state_times.iter_mut().flatten() { + *time += dt; + } + self.exit(program, &exiting, host); + if let Some(id) = transition { + for &hook in &program.transition_hooks[id] { + host.hook(hook, &self.frame); + } + } + self.enter(program, &entering, host); + for active in &self.active { + let mut context = host.context(); + context[2] = active.instance; + host.update(active.behavior, context, &self.frame); + } + } + + /// DynamicHierarchicalController::EndAllBehaviours (82C13498). Native + /// shutdown visits active behaviours in reverse order and does not alter + /// the current state or state-time map; graph reset remains a separate act. + pub fn end_all_behaviors(&mut self, host: &mut impl Host) { + while let Some(&active) = self.active.last() { + let mut context = host.context(); + context[2] = active.instance; + host.end(active.behavior, context, &self.frame); + self.active.pop(); + host.release(active.instance); + } + } + + /// Complete 82C13A00 / 82C122E8 / 82C12260. Explicit transitions to a + /// current ancestor re-enter that ancestor, including self transitions. + fn prepare_lists( + &mut self, + program: &Program, + transition: Option, + next: Option, + ) -> (Vec, Vec) { + let old_path = path(&program.topology, self.frame.current); + let boundary = match transition { + None => next, + Some(id) => { + let target = program.topology.transitions[id].target; + if old_path.contains(&target) { + program.topology.states[target].parent + } else { + Some(target) + } + } + }; + let boundary_path = path(&program.topology, boundary); + let common = old_path + .iter() + .zip(&boundary_path) + .take_while(|(a, b)| a == b) + .count(); + let exiting = old_path[common..].to_vec(); + let entering = path(&program.topology, next)[common..].to_vec(); + self.frame.last = self.frame.current; + self.frame.current = next; + (exiting, entering) + } + + fn exit(&mut self, program: &Program, exiting: &[StateId], host: &mut impl Host) { + for index in (0..self.active.len()).rev() { + let active = self.active[index]; + if exiting.contains(&program.behaviors[active.behavior].owner) { + let mut context = host.context(); + context[2] = active.instance; + host.end(active.behavior, context, &self.frame); + self.active.remove(index); + host.release(active.instance); + } + } + // End observes the old state's final incremented time. Erasure follows + // every End callback, before transition hooks and new state timers. + for &state in exiting { + self.frame.state_times[state] = None; + } + } + + fn enter(&mut self, program: &Program, entering: &[StateId], host: &mut impl Host) { + // All new state timers exist before the first allocation or Begin. + for &state in entering { + self.frame.state_times[state] = Some(0.0); + } + for &state in entering { + for &behavior in &program.state_behaviors[state] { + if program.behaviors[behavior].enabled { + let instance = host.allocate(behavior, &self.frame); + self.active.push(ActiveBehavior { behavior, instance }); + let mut context = host.context(); + context[2] = instance; + host.begin(behavior, context, &self.frame); + } + } + } + } +} + +fn path(graph: &Topology, mut state: Option) -> Vec { + let mut result = Vec::new(); + while let Some(id) = state { + result.push(id); + state = graph.states[id].parent; + } + result.reverse(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::activation::ConditionId; + + #[derive(Default)] + struct HostLog(Vec); + + impl ConditionHost for HostLog { + fn condition_activation(&mut self, _: ConditionId, _: &Frame) -> u32 { + unreachable!() + } + } + + impl Host for HostLog { + fn context(&self) -> [u32; 6] { + [1, 2, 0, 4, 5, 6] + } + + fn allocate(&mut self, _: BehaviorId, _: &Frame) -> u32 { + unreachable!() + } + + fn begin(&mut self, _: BehaviorId, _: [u32; 6], _: &Frame) { + unreachable!() + } + + fn update(&mut self, _: BehaviorId, _: [u32; 6], _: &Frame) { + unreachable!() + } + + fn end(&mut self, behavior: BehaviorId, context: [u32; 6], _: &Frame) { + self.0.push(format!("end:{behavior}:{}", context[2])); + } + + fn hook(&mut self, _: HookId, _: &Frame) { + unreachable!() + } + + fn release(&mut self, instance: u32) { + self.0.push(format!("release:{instance}")); + } + } + + #[test] + fn end_all_behaviors_uses_reverse_order_and_instance_context() { + let mut controller = Controller::new(0); + controller.active = vec![ + ActiveBehavior { + behavior: 4, + instance: 40, + }, + ActiveBehavior { + behavior: 7, + instance: 70, + }, + ]; + let mut host = HostLog::default(); + controller.end_all_behaviors(&mut host); + assert_eq!(host.0, ["end:7:70", "release:70", "end:4:40", "release:40"]); + assert!(controller.active.is_empty()); + } +} diff --git a/crates/skate-core/src/graph/expression.rs b/crates/skate-core/src/graph/expression.rs new file mode 100644 index 00000000..e760579e --- /dev/null +++ b/crates/skate-core/src/graph/expression.rs @@ -0,0 +1,60 @@ +//! TU3 ExpressionOperator::GetActivationMasked (82C12508). +//! Excluded children do not participate in AND/OR/NOT. Preserve lazy calls: +//! condition operations may read or modify their own instance state. + +/// ParseOperator 82C12438: case-sensitive strings, zero for missing/unknown. +pub fn parse_operator(name: Option<&str>) -> u32 { + match name { + Some("and") => 1, + Some("or") => 2, + Some("not") => 3, + _ => 0, + } +} + +/// Execute the complete operator with the native virtual child-call boundary. +/// The callback receives its ordered child index and the initially true +/// excluded byte; it returns the native result word. Context/mask are supplied +/// by the caller's closure, without an invented default condition result. +/// NOT requires a first child, as in the native caller contract. +pub fn evaluate_operator( + operator: u32, + child_count: usize, + excluded: &mut u8, + mut child: impl FnMut(usize, &mut u8) -> u32, +) -> u32 { + if operator > 3 { + return 1; // Native default leaves the caller's excluded byte untouched. + } + if operator == 3 { + assert!(child_count > 0, "stock NOT expression requires a child"); + let mut child_excluded = 1; + let value = child(0, &mut child_excluded) as u8; + *excluded = child_excluded; + return if child_excluded != 0 { + 1 + } else { + u32::from(value == 0) + }; + } + let is_or = operator == 2; + let mut value = if is_or { 0_u32 } else { 1_u32 }; + let mut all_excluded = 1; + for index in 0..child_count { + if (is_or && value as u8 != 0) || (!is_or && value as u8 == 0) { + break; + } + let mut child_excluded = 1; + let result = child(index, &mut child_excluded); + if child_excluded == 0 { + all_excluded = 0; + value = if is_or { + result + } else { + u32::from((result as u8) & (value as u8)) + }; + } + } + *excluded = all_excluded; + if all_excluded != 0 { 1 } else { value } +} diff --git a/crates/skate-core/src/graph/intent_handlers.rs b/crates/skate-core/src/graph/intent_handlers.rs new file mode 100644 index 00000000..91b395bf --- /dev/null +++ b/crates/skate-core/src/graph/intent_handlers.rs @@ -0,0 +1,86 @@ +//! Source-backed constant and clock-derived MotionGraph intent behaviours. +//! +//! These are the small stateful operation boundaries used by the stock graph +//! host. They deliberately return the existing `IntentMutation` protocol so +//! the host remains the sole owner of the intent map. + +use crate::input::graph_intents::IntentMutation; + +/// TU3 `CreateConstMGIntent` (factory 0x82BC26D8, ctor 0x82BA1768, +/// Begin 0x82BA18A0, Update 0x82BA1938, End 0x82BA1988/0x82BA1990). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ConstMgIntent { + pub value: f32, + pub on_update: bool, + created: bool, +} + +impl ConstMgIntent { + pub const fn new(value: f32, on_update: bool) -> Self { + Self { + value, + on_update, + created: false, + } + } + + /// Begin publishes the authored constant and marks the instance alive. + pub fn begin(&mut self) -> IntentMutation { + self.created = true; + IntentMutation::Set(self.value) + } + + /// TU3 does not republish the constant when `onUpdate` is true. It keeps + /// the existing map value. A false `onUpdate` instance removes its value + /// only after the begin frame has elapsed. + pub fn update(&mut self) -> IntentMutation { + let mutation = if self.on_update || self.created { + IntentMutation::None + } else { + IntentMutation::Remove + }; + self.created = false; + mutation + } + + /// End removes the owned intent unconditionally. Native End only emits + /// the remove operation; instance storage is released by the host. + pub const fn end(&self) -> IntentMutation { + IntentMutation::Remove + } +} + +/// TU3 `CreateMGTimeIntentFromAGIntent` (factory 0x82BC2778, ctor 0x82BA3040, +/// Update 0x82BA3158, End 0x82BA28B8). The graph-clock value is supplied by +/// the host; this operation only applies the verified dt accumulation rule. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TimeMgIntent { + pub elapsed: f32, +} + +impl TimeMgIntent { + /// The AG lookup is an existence check only. Any present value advances + /// the instance clock by the caller's dt; its amplitude is not consumed. + /// An absent AG key resets the clock and removes the MG entry. + pub fn update(&mut self, action_value: Option, dt: f32) -> IntentMutation { + match action_value { + Some(_) => { + self.elapsed += dt; + IntentMutation::Set(self.elapsed) + } + None => { + self.elapsed = 0.0; + IntentMutation::Remove + } + } + } + + /// Native End removes the map entry and does not reset elapsed state. + pub const fn end(&self) -> IntentMutation { + IntentMutation::Remove + } +} + +#[cfg(test)] +#[path = "tests/intent_handlers.rs"] +mod tests; diff --git a/crates/skate-core/src/graph/intents.rs b/crates/skate-core/src/graph/intents.rs new file mode 100644 index 00000000..8bd4ca94 --- /dev/null +++ b/crates/skate-core/src/graph/intents.rs @@ -0,0 +1,47 @@ +//! Canonical ActionGraph/MotionGraph intent identity. +//! TU3 FastString constructor823C3BF8 uses six words of82382B50 encoding. +//! Storage is ours; aliases share one value and the most recent write wins. +use crate::animation::playback_parameters::intent_key; +use std::collections::BTreeMap; + +/// Decoded names cannot bypass native case folding, NUL termination or the +/// 36-byte limit on insert, read or removal. Kept separate from five-word +/// animation attribute names and graph time tags. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct IntentMap { + values: BTreeMap<[u32; 6], f32>, +} + +impl IntentMap { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, name: &str, value: f32) -> Option { + self.values.insert(intent_key(name), value) + } + + pub fn get(&self, name: &str) -> Option<&f32> { + self.values.get(&intent_key(name)) + } + + pub fn remove(&mut self, name: &str) -> Option { + self.values.remove(&intent_key(name)) + } + + pub fn contains_key(&self, name: &str) -> bool { + self.values.contains_key(&intent_key(name)) + } + + pub fn clear(&mut self) { + self.values.clear(); + } + + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } +} diff --git a/crates/skate-core/src/graph/mod.rs b/crates/skate-core/src/graph/mod.rs new file mode 100644 index 00000000..08898f2f --- /dev/null +++ b/crates/skate-core/src/graph/mod.rs @@ -0,0 +1,8 @@ +//! Recovered generic graph operations. Stock topology is supplied as data. +pub mod activation; +pub mod conditions; +pub mod controller; +pub mod expression; +pub mod intent_handlers; +pub mod intents; +pub mod selection; diff --git a/crates/skate-core/src/graph/selection.rs b/crates/skate-core/src/graph/selection.rs new file mode 100644 index 00000000..955f46b5 --- /dev/null +++ b/crates/skate-core/src/graph/selection.rs @@ -0,0 +1,204 @@ +//! Native dynamic-controller selection: 82C140D8 and its complete state-search +//! helpers. Topology and flags come from loaded graph objects, never a riding +//! state machine. The activation callback is Node::GetActivation's boundary. + +pub type StateId = usize; +pub type TransitionId = usize; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeId { + State(StateId), + Transition(TransitionId), +} + +#[derive(Clone, Debug)] +pub struct State { + pub parent: Option, + pub enabled: bool, + pub active: bool, + /// Native state+36: 0=false, 1=true, 2=resolved ancestor name. + pub interruptibility: u32, + pub interrupt_ancestor: Option, + pub children: Vec, + pub transitions: Vec, +} + +#[derive(Clone, Debug)] +pub struct Transition { + pub enabled: bool, + pub target: StateId, + pub priority: u32, +} + +#[derive(Clone, Debug)] +pub struct Topology { + pub states: Vec, + pub transitions: Vec, +} + +pub struct Selection<'a> { + pub graph: &'a Topology, + pub root: StateId, + pub current: Option, +} + +impl Selection<'_> { + /// Complete GetNextState 82C140D8. The output slot is explicit because an + /// unrecognized interruptibility ordinal leaves it untouched in TU3. + pub fn next( + &self, + target: &mut Option, + mut activate: impl FnMut(NodeId, u32) -> bool, + ) -> Option { + let Some(current) = self.current else { + *target = self.descend(self.root, 1, &mut activate); + return None; + }; + for priority in (0..=3).rev() { + if let Some((transition, state)) = + self.search_transitions(current, priority, &mut activate) + { + *target = Some(state); + return Some(transition); + } + } + // Complete retained/reselected-state path 82C14248. + match self.graph.states[current].interruptibility { + 0 if self.ancestors_active(current, 2, &mut activate) => *target = Some(current), + 0 | 1 => *target = self.descend(self.root, 2, &mut activate), + 2 => { + let ancestor = self.graph.states[current] + .interrupt_ancestor + .expect("native processed interrupt ancestor must resolve"); + if self.ancestors_active(ancestor, 2, &mut activate) { + *target = self.descend(ancestor, 2, &mut activate); + if target.is_some() { + return None; + } + } + *target = self.descend(self.root, 2, &mut activate); + } + _ => {} + } + None + } + + /// Complete 82C14710 (82C14708 is its thunk). + fn descend( + &self, + state: StateId, + mask: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> Option { + let effective_mask = if mask == 2 && self.is_ancestor(state, self.current) { + 2 + } else { + 1 + }; + if !activate(NodeId::State(state), effective_mask) { + return None; + } + self.first_child_or_leaf(state, mask, activate) + } + + fn first_child_or_leaf( + &self, + state: StateId, + mask: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> Option { + let children = &self.graph.states[state].children; + if children.is_empty() { + return Some(state); + } + for &child in children { + let node = &self.graph.states[child]; + if node.enabled && node.active { + if let Some(leaf) = self.descend(child, mask, activate) { + return Some(leaf); + } + } + } + None + } + + /// Complete priority-specific SearchTransitions 82C14810, leaf to root. + fn search_transitions( + &self, + mut state: StateId, + priority: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> Option<(TransitionId, StateId)> { + loop { + for &id in &self.graph.states[state].transitions { + let transition = &self.graph.transitions[id]; + let target = transition.target; + if transition.priority != priority + || !transition.enabled + || !self.graph.states[target].enabled + { + continue; + } + if !activate(NodeId::Transition(id), 1) + || !self.valid_target(self.current, target, activate) + || !activate(NodeId::State(target), 1) + { + continue; + } + if let Some(leaf) = self.first_child_or_leaf(target, 1, activate) { + return Some((id, leaf)); + } + } + state = self.graph.states[state].parent?; + } + } + + /// Complete ValidTarget 82C14978. Target and new ancestors use preconditions; + /// a shared ancestor (other than target) and its parents use mask 2. + fn valid_target( + &self, + current: Option, + target: StateId, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> bool { + let mut next = Some(target); + while let Some(state) = next { + if state != target && self.is_ancestor(state, current) { + return self.ancestors_active(state, 2, activate); + } + if !activate(NodeId::State(state), 1) { + return false; + } + next = self.graph.states[state].parent; + } + true + } + + /// Complete state-parent activation chain 82C16B88 for State objects. + fn ancestors_active( + &self, + mut state: StateId, + mask: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> bool { + loop { + if !activate(NodeId::State(state), mask) { + return false; + } + let Some(parent) = self.graph.states[state].parent else { + return true; + }; + state = parent; + } + } + + fn is_ancestor(&self, ancestor: StateId, mut state: Option) -> bool { + while let Some(id) = state { + if id == ancestor { + return true; + } + state = self.graph.states[id].parent; + } + false + } +} diff --git a/crates/skate-core/src/graph/tests/conditions.rs b/crates/skate-core/src/graph/tests/conditions.rs new file mode 100644 index 00000000..8a20647f --- /dev/null +++ b/crates/skate-core/src/graph/tests/conditions.rs @@ -0,0 +1,102 @@ +use super::*; + +fn numeric(comparison: Comparison, threshold: f32) -> NumericCondition { + NumericCondition { + comparison, + threshold, + absolute: false, + } +} + +#[test] +fn comparison_preserves_presence_and_unordered_branch_semantics() { + let none = numeric(Comparison::None, 0.0); + let condition = ActionCondition::HasIntent { + name: "Pushing".into(), + numeric: none, + }; + let mut intents = IntentMap::new(); + let input = ConditionInputs::default(); + assert!(!condition.evaluate(&input, &intents, None, &[]).unwrap()); + intents.insert("Pushing", 0.0); + assert!(condition.evaluate(&input, &intents, None, &[]).unwrap()); + assert!(!none.matches(0.0)); + assert!(numeric(Comparison::GreaterAbsolute, 0.5).matches(-0.75)); + assert!(numeric(Comparison::GreaterEqual, 1.0).matches(f32::NAN)); + assert!(numeric(Comparison::LessEqual, 1.0).matches(f32::NAN)); + assert!(!numeric(Comparison::Greater, 1.0).matches(f32::NAN)); + assert!(!numeric(Comparison::Less, 1.0).matches(f32::NAN)); +} + +#[test] +fn speed_and_state_conditions_require_their_real_distinct_outputs() { + let mut input = ConditionInputs::default(); + let speed = ActionCondition::Speed { + along_skate_z: true, + numeric: numeric(Comparison::Less, 0.0), + }; + assert!( + speed + .evaluate(&input, &IntentMap::new(), None, &[]) + .is_err() + ); + input.speeds = Some(SpeedInputs { + speed: 4.0, + forward_speed: -3.0, + speed_and_slope: 2.0, + }); + assert!( + speed + .evaluate(&input, &IntentMap::new(), None, &[]) + .unwrap() + ); + let state = ActionCondition::CurrentState { + name: "Riding".into(), + target: Some(1), + }; + let parents = [None, Some(0), Some(1), Some(0)]; + assert!( + state + .evaluate(&input, &IntentMap::new(), Some(1), &parents) + .unwrap() + ); + assert!( + state + .evaluate(&input, &IntentMap::new(), Some(2), &parents) + .unwrap() + ); + assert!( + !state + .evaluate(&input, &IntentMap::new(), Some(3), &parents) + .unwrap() + ); + assert!( + !state + .evaluate(&input, &IntentMap::new(), None, &parents) + .unwrap() + ); +} + +#[test] +fn push_brake_uses_ground_angle_and_skeleton_override() { + let ground = PushBrakeInputs { + ground_axis_y: 1.0, + skeleton_disables_push_brake: false, + maximum_ground_angle_degrees: 45.0, + }; + assert!(!ground.disabled()); + assert!( + PushBrakeInputs { + ground_axis_y: 0.0, + ..ground + } + .disabled() + ); + assert!( + PushBrakeInputs { + skeleton_disables_push_brake: true, + ..ground + } + .disabled() + ); +} diff --git a/crates/skate-core/src/graph/tests/intent_handlers.rs b/crates/skate-core/src/graph/tests/intent_handlers.rs new file mode 100644 index 00000000..83a9fa88 --- /dev/null +++ b/crates/skate-core/src/graph/tests/intent_handlers.rs @@ -0,0 +1,44 @@ +use super::{ConstMgIntent, TimeMgIntent}; +use crate::input::graph_intents::IntentMutation; + +#[test] +fn constant_keeps_begin_value_for_one_frame_then_removes() { + let mut intent = ConstMgIntent::new(2.5, false); + assert_eq!(intent.begin(), IntentMutation::Set(2.5)); + assert_eq!(intent.update(), IntentMutation::None); + assert_eq!(intent.update(), IntentMutation::Remove); +} + +#[test] +fn constant_on_update_does_not_republish() { + let mut intent = ConstMgIntent::new(-1.0, true); + assert_eq!(intent.begin(), IntentMutation::Set(-1.0)); + assert_eq!(intent.update(), IntentMutation::None); + assert_eq!(intent.update(), IntentMutation::None); +} + +#[test] +fn constant_end_removes_without_changing_instance_state() { + let mut intent = ConstMgIntent::new(2.5, false); + intent.begin(); + let previous = intent; + assert_eq!(intent.end(), IntentMutation::Remove); + assert_eq!(intent, previous); +} + +#[test] +fn time_uses_presence_and_accumulates_instance_clock() { + let mut intent = TimeMgIntent::default(); + assert_eq!(intent.update(Some(0.0), 0.25), IntentMutation::Set(0.25)); + assert_eq!(intent.update(Some(99.0), 0.5), IntentMutation::Set(0.75)); + assert_eq!(intent.update(None, 0.5), IntentMutation::Remove); + assert_eq!(intent.elapsed, 0.0); +} + +#[test] +fn time_end_removes_without_resetting_instance_clock() { + let mut intent = TimeMgIntent::default(); + assert_eq!(intent.update(Some(0.0), 0.25), IntentMutation::Set(0.25)); + assert_eq!(intent.end(), IntentMutation::Remove); + assert_eq!(intent.elapsed, 0.25); +} diff --git a/crates/skate-core/src/input/angle.rs b/crates/skate-core/src/input/angle.rs new file mode 100644 index 00000000..b02d80a5 --- /dev/null +++ b/crates/skate-core/src/input/angle.rs @@ -0,0 +1,78 @@ +//! Scalar lane of native SIMD atan 82473B98 and intention left-stick angle. +pub(super) fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} + +fn refined_reciprocal(value: f32) -> f32 { + let initial = reciprocal_estimate(value); + let first = initial.mul_add((-value).mul_add(initial, 1.0), initial); + let second = first.mul_add((-value).mul_add(first, 1.0), first); + if first.is_nan() { initial } else { second } +} +pub fn atan(value: f32) -> f32 { + let absolute = value.abs(); + let invert = absolute > 1.0; + let reduced = if invert { + refined_reciprocal(absolute) + } else { + absolute + }; + let transform = reduced > f32::from_bits(0x3e8930a3); + let offset = if transform { + if invert { + f32::from_bits(0x3f860a92) + } else { + f32::from_bits(0x3f060a92) + } + } else if invert { + f32::from_bits(0x3fc90fdb) + } else { + 0.0 + }; + let transformed = (reduced.mul_add(f32::from_bits(0x3f3b67af), reduced) - 1.0) + * refined_reciprocal(reduced + f32::from_bits(0x3fddb3d7)); + let t = if transform { transformed } else { reduced }; + let square = t * t; + let numerator = square.mul_add(f32::from_bits(0xbf566bd7), f32::from_bits(0xc107e9fb)); + let numerator = square.mul_add(numerator, f32::from_bits(0xc1a40bfe)); + let numerator = square.mul_add(numerator, f32::from_bits(0xc15b0533)); + let denominator = square.mul_add( + square + f32::from_bits(0x4170624f), + f32::from_bits(0x426e5052), + ); + let denominator = square.mul_add(denominator, f32::from_bits(0x42ac5090)); + let denominator = square.mul_add(denominator, f32::from_bits(0x422443e6)); + let polynomial = t.mul_add((numerator * square) * refined_reciprocal(denominator), t); + let result = if f32::from_bits(0x39800000) > t.abs() { + t + } else { + polynomial + }; + let result = (if invert { -result } else { result }) + offset; + let result = if value < 0.0 { -result } else { result }; + let result = if value > f32::from_bits(0x7e800000) { + f32::from_bits(0x3fc90fdb) + } else { + result + }; + if -f32::from_bits(0x7e800000) > value { + -f32::from_bits(0x3fc90fdb) + } else { + result + } +} + +pub(super) fn left_stick_angle(x: f32, y: f32) -> f32 { + if x == 0.0 && y == 0.0 { + return 0.0; + } + let vertical = -y; + let reciprocal = reciprocal_estimate(vertical); + let refined = reciprocal.mul_add((-reciprocal).mul_add(vertical, 1.0), reciprocal); + let basic = atan(x.mul_add(refined, 0.0)); + let sign = x.to_bits() & 0x80000000; + let pi = f32::from_bits(0x40490fdb | sign); + let half_pi = f32::from_bits(0x3fc90fdb | sign); + let angle = if 0.0 > vertical { pi + basic } else { basic }; + if vertical == 0.0 { half_pi } else { angle } +} diff --git a/crates/skate-core/src/input/animation_packet.rs b/crates/skate-core/src/input/animation_packet.rs new file mode 100644 index 00000000..2593384a --- /dev/null +++ b/crates/skate-core/src/input/animation_packet.rs @@ -0,0 +1,58 @@ +//! Complete TU3 82DB5BE0: AnimOutPhysIn -> ProcessedPhysIn publication. +//! Called by physical input stage 82DB4048 before board/attribute processing. +//! Inputs are already produced by animation; this is not a gamepad mapping. + +/// Source packet fields. Byte flags use their low bit, including noncanonical +/// byte values. Matrix/vector words are copied without numerical conversion. +pub struct AnimationPacketFields { + /// Packet +10368 -> processed +2468 bit 20. + pub stance_byte: u8, + /// Packet +10384 / +10388. + pub timestep: f32, + pub scalar_10388: f32, + /// Packet +10375, +10496, +10784 -> processed +2468 bits 3, 2, 1. + pub flags_10375_10496_10784: [u8; 3], + pub vector_10480: [u32; 4], + pub matrix_10704: [[u32; 4]; 4], + pub byte_10768: u8, + /// Packet +10788 / +10792. + pub truck_tightness: f32, + pub scalar_10792: f32, + /// Packet +10371 -> processed +2476 bit 20. + pub flag_10371: u8, +} + +/// All fields written by 82DB5BE0. Seed flag words from the preceding native +/// reset/publication stages: this helper preserves their unrelated bits. +pub struct ProcessedPacketFields { + pub flags_2468: u32, + pub flags_2476: u32, + pub timestep: f32, + pub scalar_2668: f32, + pub vector_1520: [u32; 4], + pub matrix_1536: [[u32; 4]; 4], + pub byte_1600: u8, + pub truck_tightness: f32, + pub scalar_2764: f32, +} + +/// Publish the complete function's writes into distinct source/destination +/// records. The source packet must remain valid through physical processing. +pub fn publish(source: &AnimationPacketFields, output: &mut ProcessedPacketFields) { + replace_bit(&mut output.flags_2468, 20, source.stance_byte); + output.timestep = source.timestep; + output.scalar_2668 = source.scalar_10388; + replace_bit(&mut output.flags_2468, 3, source.flags_10375_10496_10784[0]); + replace_bit(&mut output.flags_2468, 2, source.flags_10375_10496_10784[1]); + output.vector_1520 = source.vector_10480; + replace_bit(&mut output.flags_2468, 1, source.flags_10375_10496_10784[2]); + output.matrix_1536 = source.matrix_10704; + output.byte_1600 = source.byte_10768; + output.truck_tightness = source.truck_tightness; + output.scalar_2764 = source.scalar_10792; + replace_bit(&mut output.flags_2476, 20, source.flag_10371); +} + +fn replace_bit(word: &mut u32, bit: u32, byte: u8) { + *word = (*word & !(1 << bit)) | (u32::from(byte & 1) << bit); +} diff --git a/crates/skate-core/src/input/body_flip_signal.rs b/crates/skate-core/src/input/body_flip_signal.rs new file mode 100644 index 00000000..da4b5b21 --- /dev/null +++ b/crates/skate-core/src/input/body_flip_signal.rs @@ -0,0 +1,44 @@ +//! BodyFlippingSignal82BA3380/33B8/35E0/3790. Both windows update on +//! every call before deciding whether to create or remove the motion intent. + +#[derive(Clone,Copy,Debug)] +pub struct Settings { + /// anim_motion.extend_bodyflip_gesture and extend_takeoff_point. + pub gesture_window:f32, + pub takeoff_window:f32, +} + +#[derive(Clone,Copy,Debug,Default)] +pub struct State { + gesture_time:f32, + takeoff_time:f32, + /// Ordered native keys830C01B4 then830BE3A8. None is the constructor's + /// null name. The host binds their recovered text to its intent storage. + selected:Option, +} + +impl State { + pub fn begin(&mut self,settings:Settings) { + self.gesture_time=settings.gesture_window; + self.takeoff_time=settings.takeoff_window; + } + + /// Some(index) sets only that intent to1. None removes BOTH keys. + /// Incoming intentions are tested for presence, not their scalar value. + pub fn update(&mut self,present:[bool;2],filtered_category:u32,dt:f32,settings:Settings)->Option { + if let Some(index)=present.iter().position(|&value|value) { + self.gesture_time=0.0; + self.selected=Some(index); + } else { + self.gesture_time=cap(self.gesture_time+dt,settings.gesture_window); + } + let gesture=self.gesture_timef32 {if value-limit>=0.0 {limit} else {value}} diff --git a/crates/skate-core/src/input/controller.rs b/crates/skate-core/src/input/controller.rs new file mode 100644 index 00000000..27e1b8ab --- /dev/null +++ b/crates/skate-core/src/input/controller.rs @@ -0,0 +1,166 @@ +//! TU3 RawControllerInput 82598FE0 and DerivedControllerInput 825992D8. +//! Native action-map evaluation is an explicit boundary, not a platform mapping. +use crate::physics::reciprocal_sqrt::estimate; + +/// Evaluated native cInputMap actions. IDs are TU3 map indices, not XInput +/// buttons. Implementations must preserve the call order; the stock expression +/// evaluator 8296C350 and loaded mapping still determine deadzones and scaling. +pub trait ActionMap { + fn value(&mut self, action: u32) -> f32; + fn state(&mut self, action: u32) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RawControllerInput { + /// Six binary32 axes/triggers followed by native packed button bits. + words: [u32; 7], +} + +impl RawControllerInput { + pub fn from_words(words: [u32; 7]) -> Self { + Self { words } + } + pub fn words(&self) -> &[u32; 7] { + &self.words + } + + /// Complete 82598FE0, including simultaneous bumper arbitration and + /// retention of the low twenty bits of the destination flags word. + pub fn update( + &mut self, + previous: &Self, + map: &mut impl ActionMap, + state_502: bool, + state_104: bool, + ) { + for (slot, action) in [64, 65, 67, 68, 70, 71].into_iter().enumerate() { + self.words[slot] = map.value(action).to_bits(); + } + for (action, bit) in [(66, 31), (69, 30), (72, 29), (73, 28)] { + self.set_bit(bit, map.state(action)); + } + let both = (1 << 29) | (1 << 28); + if self.words[6] & both == both { + let prior = previous.words[6] & both; + if (prior == 1 << 29 || prior == 1 << 28) && (state_502 || state_104) { + self.words[6] = (self.words[6] & !both) | prior; + } else { + self.words[6] &= !(1 << 28); + } + } + for action in 74..=81 { + self.set_bit(101 - action, map.state(action)); + } + } + + fn set_bit(&mut self, bit: u32, value: u8) { + self.words[6] = (self.words[6] & !(1 << bit)) | (u32::from(value & 1) << bit); + } +} + +/// Actual inputlistener attribute result for key 1F3C1C1C68AF4C23. +/// Missing collection/attribute uses the live 830D0850 fallback. +pub struct MagnitudeHeldSettings { + pub attribute: Option, + pub missing_attribute_value: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DerivedControllerInput { + words: [u32; 26], +} + +impl DerivedControllerInput { + /// Requires native initialized state. No reset or inferred default supplied. + pub fn from_words(words: [u32; 26]) -> Self { + Self { words } + } + pub fn words(&self) -> &[u32; 26] { + &self.words + } + + /// Prefix construction recovered from TU3 `82599718`. The native + /// constructor clears the controller axes and timers, preserves the low + /// twenty flag bits in the two packed raw words, and seeds the four + /// edge-timer fields from `flt_822F8B34` (`0x7effffff`). This is only the + /// controller prefix; listener registration/reset belongs to its owner. + pub fn initialize(&mut self) { + let flags_last = self.words[6] & 0x000f_ffff; + let flags_current = self.words[13] & 0x000f_ffff; + self.words = [0; 26]; + self.words[6] = flags_last; + self.words[13] = flags_current; + self.words[16..20].fill(0x7eff_ffff); + } + + /// Complete 825992D8: raw history, trigger/button edge timers and stick + /// magnitude-held timers. Timing uses caller dt without an imposed rate. + pub fn update( + &mut self, + map: &mut impl ActionMap, + timestep: f32, + state_502: bool, + state_104: bool, + settings: &MagnitudeHeldSettings, + ) { + let previous = RawControllerInput::from_words(self.words[7..14].try_into().unwrap()); + self.words[..7].copy_from_slice(previous.words()); + let mut current = previous; + current.update(&previous, map, state_502, state_104); + self.words[7..14].copy_from_slice(current.words()); + for (slot, axis) in [(14, 4), (15, 5)] { + self.advance_or_reset(slot, f32::from_bits(current.words[axis]) == 1.0, timestep); + } + let before = previous.words[6]; + let now = current.words[6]; + for (bit, rise, fall) in [(31, 16, 17), (30, 18, 19)] { + let was_down = before & (1 << bit) != 0; + let is_down = now & (1 << bit) != 0; + self.advance_or_reset(rise, !(!was_down && is_down), timestep); + self.advance_or_reset(fall, !(was_down && !is_down), timestep); + } + for (slot, action) in [(20, 80), (21, 81), (22, 78), (23, 79)] { + self.advance_or_reset(slot, map.state(action) != 0, timestep); + } + // Native evaluates these actions again after the button timers. + let lx = map.value(64); + let ly = map.value(65); + let rx = map.value(67); + let ry = map.value(68); + let right = magnitude(ry.mul_add(ry, rx * rx)); + let left = magnitude(ly.mul_add(ly, lx * lx)); + let threshold = settings + .attribute + .unwrap_or(settings.missing_attribute_value); + // PPC ble treats unordered as false; retain that branch direction. + self.advance_or_reset(24, !(left <= threshold), timestep); + self.advance_or_reset(25, !(right <= threshold), timestep); + } + + fn advance_or_reset(&mut self, slot: usize, advance: bool, timestep: f32) { + self.words[slot] = if advance { + (f32::from_bits(self.words[slot]) + timestep).to_bits() + } else { + 0 + }; + } +} + +#[cfg(test)] +#[path = "controller_startup.rs"] +mod startup_tests; + +/// Independent reciprocal-square-root estimate followed by the original two +/// refinement steps. Host arithmetic is not claimed to be bit-exact Xenon math. +pub(super) fn magnitude(squared: f32) -> f32 { + let mut inverse = estimate(squared); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + if squared == 0.0 { + 0.0 + } else { + squared * inverse + } +} diff --git a/crates/skate-core/src/input/controller_startup.rs b/crates/skate-core/src/input/controller_startup.rs new file mode 100644 index 00000000..45c899a7 --- /dev/null +++ b/crates/skate-core/src/input/controller_startup.rs @@ -0,0 +1,20 @@ +use super::DerivedControllerInput; + +#[test] +fn initialize_preserves_only_native_low_twenty_flag_bits() { + let mut words = [0u32; 26]; + words[6] = 0xfff0_1234; + words[13] = 0xabcd_5678; + words[0] = 1; + words[24] = 2; + let mut input = DerivedControllerInput::from_words(words); + input.initialize(); + assert_eq!(input.words()[6], 0x0000_1234); + assert_eq!(input.words()[13], 0x000d_5678); + for (index, &word) in input.words().iter().enumerate() { + if !(index == 6 || index == 13 || (16..20).contains(&index)) { + assert_eq!(word, 0, "word {index}"); + } + } + assert_eq!(&input.words()[16..20], &[0x7eff_ffff; 4]); +} diff --git a/crates/skate-core/src/input/gameplay_map.rs b/crates/skate-core/src/input/gameplay_map.rs new file mode 100644 index 00000000..84ecfb38 --- /dev/null +++ b/crates/skate-core/src/input/gameplay_map.rs @@ -0,0 +1,52 @@ +//! Specialization of the 18 stock gameplay expressions registered by82697740. +//! Config identity and VM operand semantics are recorded in STEERING_INPUT.md. +use super::{controller::ActionMap, pad::Pad}; + +pub struct GameplayActions { + values: [f32; 18], +} +impl GameplayActions { + /// The native pad retains storage even while count is zero. With a nonzero + /// count, stock Button operands read their fixed index, without a per-index + /// count check; the caller must provide the native 24-button storage. + pub fn from_pad(pad: &Pad) -> Self { + let value = |slot: usize| { + if pad.count() == 0 { + 0.0 + } else { + f32::from_bits(pad.records()[slot][0]) + } + }; + Self { + values: [ + value(16) - value(17), + value(18) - value(19), + value(6), + value(20) - value(21), + value(22) - value(23), + value(7), + value(10), + value(11), + value(8), + value(9), + value(0), + value(1), + value(2), + value(3), + value(14), + value(15), + value(12), + value(13), + ], + } + } +} +impl ActionMap for GameplayActions { + fn value(&mut self, action: u32) -> f32 { + self.values + [usize::try_from(action.checked_sub(64).expect("gameplay action below 64")).unwrap()] + } + fn state(&mut self, action: u32) -> u8 { + u8::from(self.value(action) != 0.0) + } +} diff --git a/crates/skate-core/src/input/graph_intents.rs b/crates/skate-core/src/input/graph_intents.rs new file mode 100644 index 00000000..d277158b --- /dev/null +++ b/crates/skate-core/src/input/graph_intents.rs @@ -0,0 +1,134 @@ +//! Native CreateMGIntentFromAGIntent and AttachIntent behavior boundaries. + +/// Complete82BA0C20. Ordinals come from82BA0A48 and stock filter names. +pub fn apply_filter(value: f32, kind: u32) -> f32 { + let pi = f32::from_bits(0x40490fdb); + let half_pi = f32::from_bits(0x3fc90fdb); + match kind { + 0 => value, + 1 => value * -1.0, + 2 => value.abs(), + 3 => { + if value >= 0.0 { + 1.0 - value + } else { + -1.0 - value + } + } + 4 => { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } + } + 5 => { + if value >= 0.0 { + pi - value + } else { + -pi - value + } + } + 6 => { + let rotated = value - half_pi; + if rotated < -pi { + rotated + f32::from_bits(0x40c90fdb) + } else { + rotated + } + } + _ => { + let rotated = value + half_pi; + if rotated > pi { + rotated - f32::from_bits(0x40c90fdb) + } else { + rotated + } + } + } +} + +/// Four source ordinals: filter1, filter2, fakieFilter, mirrorFilter. +/// Missing stance component bypasses every filter, matching82BA2930. +pub fn filter_chain(mut value: f32, filters: [u32; 4], stance: Option<(bool, bool)>) -> f32 { + if let Some((fakie, mirror)) = stance { + for (kind, enabled) in filters.into_iter().zip([true, true, fakie, mirror]) { + if enabled && kind <= 7 { + value = apply_filter(value, kind); + } + } + } + value +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum IntentMutation { + None, + Remove, + Set(f32), +} + +pub struct CreateMgIntent { + pub on_update: bool, + pub default_value: Option, + pub scale: f32, + pub filters: [u32; 4], +} + +impl CreateMgIntent { + /// Complete82BA2790 with container lookup/removal/insertion represented by + /// an explicit mutation. Keys are the caller's decoded MGIntent/AGIntent. + pub fn emit(&self, action_value: Option, stance: Option<(bool, bool)>) -> IntentMutation { + match action_value.or(self.default_value) { + None => IntentMutation::Remove, + Some(value) => { + IntentMutation::Set(filter_chain(self.scale * value, self.filters, stance)) + } + } + } + /// Complete82BA26F0 lifecycle write, including absent-input removal. + pub fn enter( + &self, + created_this_frame: &mut bool, + action_value: Option, + stance: Option<(bool, bool)>, + ) -> IntentMutation { + let result = self.emit(action_value, stance); + *created_this_frame = true; + result + } + /// Complete82BA2728: onUpdate=false removes on subsequent updates. + pub fn update( + &self, + created_this_frame: &mut bool, + action_value: Option, + stance: Option<(bool, bool)>, + ) -> IntentMutation { + let result = if self.on_update { + self.emit(action_value, stance) + } else if !*created_this_frame { + IntentMutation::Remove + } else { + IntentMutation::None + }; + *created_this_frame = false; + result + } + ///82BA2788 ->82BA28B8 unconditionally removes the owned MG intent. + pub fn exit(&self) -> IntentMutation { + IntentMutation::Remove + } +} + +/// Complete82BB5AE0 at resolved lookup/sink boundaries. The packet sink is +/// invoked first; optional skeleton sink receives the same value afterwards. +pub fn attach_intent( + value: Option, + set_skeleton: bool, + mut packet: impl FnMut(f32), + mut skeleton: impl FnMut(f32), +) { + if let Some(value) = value { + packet(value); + if set_skeleton { + skeleton(value); + } + } +} diff --git a/crates/skate-core/src/input/history.rs b/crates/skate-core/src/input/history.rs new file mode 100644 index 00000000..9c396813 --- /dev/null +++ b/crates/skate-core/src/input/history.rs @@ -0,0 +1,98 @@ +//! Native controller history owner (`8296D0D0`, `8296D1F0`, `82699230`). +//! +//! The cache is thirty frame batches. Each batch contains four 100-byte +//! records, one per registered device. A record contains a value count and +//! up to 24 native float values. Startup is read=0 and write=1. + +use super::pad::Pad; + +pub const HISTORY_CAPACITY: usize = 30; +pub const DEVICE_SLOTS: usize = 4; +pub const MAX_VALUES: usize = 24; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HistoryRecord { + values: [f32; MAX_VALUES], + count: usize, +} + +impl HistoryRecord { + pub fn new(values: &[f32]) -> Self { + assert!(values.len() <= MAX_VALUES); + let mut record = Self { + values: [0.0; MAX_VALUES], + count: values.len(), + }; + record.values[..values.len()].copy_from_slice(values); + record + } + pub fn count(&self) -> usize { + self.count + } + pub fn values(&self) -> &[f32] { + &self.values[..self.count] + } + /// Failed device polls clear publication count while retaining value words. + pub fn clear_count(&mut self) { + self.count = 0; + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PadHistory { + batches: [[HistoryRecord; DEVICE_SLOTS]; HISTORY_CAPACITY], + read: usize, + write: usize, +} + +impl PadHistory { + /// Matches `826986A8`: zeroed storage, read index 0, write index 1. + pub fn new() -> Self { + let empty = HistoryRecord::new(&[]); + Self { + batches: [[empty; DEVICE_SLOTS]; HISTORY_CAPACITY], + read: 0, + write: 1, + } + } + pub fn read_index(&self) -> usize { + self.read + } + pub fn write_index(&self) -> usize { + self.write + } + + /// `8296D0D0` copies the registered-device prefix into one frame batch, + /// then advances the producer once. Uncopied slots retain their contents; + /// an empty poll still advances the producer. + pub fn publish(&mut self, records: &[HistoryRecord]) { + assert!(records.len() <= DEVICE_SLOTS); + self.batches[self.write][..records.len()].copy_from_slice(records); + self.write = (self.write + 1) % HISTORY_CAPACITY; + } + + /// `82699230` drains to the newest available batch and updates all Pads + /// exactly once with that batch. + pub fn drain_to_latest(&mut self, pads: &mut [Pad; DEVICE_SLOTS]) -> bool { + if self.is_empty() { + return false; + } + let mut latest = self.read; + while (self.read + 1) % HISTORY_CAPACITY != self.write { + self.read = (self.read + 1) % HISTORY_CAPACITY; + latest = self.read; + } + for (pad, record) in pads.iter_mut().zip(self.batches[latest].iter()) { + pad.update(record.values()); + } + true + } + + fn is_empty(&self) -> bool { + (self.read + 1) % HISTORY_CAPACITY == self.write + } +} + +#[cfg(test)] +#[path = "history_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/input/history_tests.rs b/crates/skate-core/src/input/history_tests.rs new file mode 100644 index 00000000..5e56c02f --- /dev/null +++ b/crates/skate-core/src/input/history_tests.rs @@ -0,0 +1,79 @@ +use super::{DEVICE_SLOTS, HISTORY_CAPACITY, HistoryRecord, PadHistory}; +use crate::input::pad::Pad; + +fn pads() -> [Pad; DEVICE_SLOTS] { + [Pad::new(), Pad::new(), Pad::new(), Pad::new()] +} +fn batch(seed: f32) -> [HistoryRecord; DEVICE_SLOTS] { + [ + HistoryRecord::new(&[seed]), + HistoryRecord::new(&[seed + 1.0]), + HistoryRecord::new(&[seed + 2.0]), + HistoryRecord::new(&[seed + 3.0]), + ] +} + +#[test] +fn drains_newest_batch_once_for_all_devices() { + let mut history = PadHistory::new(); + history.publish(&batch(10.0)); + history.publish(&batch(20.0)); + let mut output = pads(); + assert!(history.drain_to_latest(&mut output)); + for (pad, expected) in output.iter().zip([20.0_f32, 21.0, 22.0, 23.0]) { + assert_eq!(pad.count(), 1); + assert_eq!(pad.records()[0][0], expected.to_bits()); + assert_eq!(pad.records()[0][3], 1, "only the final batch advances Pad"); + } + assert!(!history.drain_to_latest(&mut output)); +} + +#[test] +fn producer_wraps_at_full_lap_without_overflow_policy() { + let mut history = PadHistory::new(); + for i in 0..HISTORY_CAPACITY - 1 { + history.publish(&batch(i as f32)); + } + assert_eq!(history.write_index(), 0); + let mut output = pads(); + assert!(history.drain_to_latest(&mut output)); + history.publish(&batch(99.0)); + assert_eq!(history.write_index(), 1); + assert!(history.drain_to_latest(&mut output)); + let mut full = PadHistory::new(); + for i in 0..HISTORY_CAPACITY { + full.publish(&batch(i as f32)); + } + assert_eq!(full.write_index(), 1); + assert!(!full.drain_to_latest(&mut output)); +} + +#[test] +fn empty_publish_advances_once() { + let mut history = PadHistory::new(); + history.publish(&batch(1.0)); + history.publish(&[]); + assert_eq!(history.write_index(), 3); +} + +#[test] +fn reused_batch_retains_unregistered_slots() { + let mut history = PadHistory::new(); + history.batches[1] = batch(40.0); + let mut output = pads(); + history.publish(&[HistoryRecord::new(&[99.0])]); + assert!(history.drain_to_latest(&mut output)); + assert_eq!(output[0].records()[0][0], 99.0_f32.to_bits()); + assert_eq!(output[1].records()[0][0], 41.0_f32.to_bits()); + assert_eq!(output[2].records()[0][0], 42.0_f32.to_bits()); + assert_eq!(output[3].records()[0][0], 43.0_f32.to_bits()); +} + +#[test] +fn clear_count_preserves_visibility_contract() { + let mut record = HistoryRecord::new(&[3.5, 4.5]); + record.clear_count(); + assert_eq!(record.count(), 0); + assert_eq!(record.values(), &[]); + assert_eq!(&record.values[..2], &[3.5, 4.5]); +} diff --git a/crates/skate-core/src/input/mod.rs b/crates/skate-core/src/input/mod.rs new file mode 100644 index 00000000..536a093b --- /dev/null +++ b/crates/skate-core/src/input/mod.rs @@ -0,0 +1,19 @@ +//! Native input publication. Controller/graph producers remain separate stages. +pub mod angle; +pub mod animation_packet; +pub mod controller; +pub mod gameplay_map; +pub mod graph_intents; +pub mod history; +pub mod pad; +pub mod power_sliding; +pub mod riding_intentions; +pub mod set_turning; +pub mod steering_intentions; +pub mod turn_conditioner; +pub mod turn_remap; +pub mod xbox; +pub mod body_flip_signal; + +pub mod wipeout_intentions; +pub mod offboard_intentions; diff --git a/crates/skate-core/src/input/offboard_intentions.rs b/crates/skate-core/src/input/offboard_intentions.rs new file mode 100644 index 00000000..04e6de31 --- /dev/null +++ b/crates/skate-core/src/input/offboard_intentions.rs @@ -0,0 +1,138 @@ +//! Off-board inputs from original TU3 Fill825999F0 and helper8259C260. +//! The stock ActionGraph owns activation; these inputs do not change state. +use super::controller::DerivedControllerInput; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OffboardIntent { + pub name: &'static str, + pub value: f32, +} + +/// Read the already-updated controller prefix exactly once per input tick. +/// `air_reckoning_active` is actual PhysOut bundle+8 byte452, not a state test. +/// Analog movement has a separate physical-output-dependent producer. +pub fn produce_discrete( + controller: &DerivedControllerInput, + actor_flags: u32, + air_reckoning_active: bool, +) -> Vec { + let words = controller.words(); + let previous = words[6]; + let current = words[13]; + let axis = |slot: usize| f32::from_bits(words[slot]); + let held = |bit: u32| current & (1u32 << bit) != 0; + let rising = |bit: u32| held(bit) && previous & (1u32 << bit) == 0; + let mut output = Vec::with_capacity(12); + let mut emit = |name, value| output.push(OffboardIntent { name, value }); + + //8259AA8C invokes8259C260 before the later toggle/sprint publications. + if rising(23) { + emit("OB_Jump", 1.0); + } + let right_x = axis(9); + let right_y = axis(10); + if right_x != 0.0 { + emit("OB_LookAtX", right_x); + } + if right_y != 0.0 { + emit("OB_LookAtY", right_y); + } + let drop_board = axis(4) != 1.0 && axis(11) == 1.0; + let throw_board = axis(5) != 1.0 && axis(12) == 1.0; + if drop_board { + emit("OB_DropBoard", 1.0); + } + if throw_board { + emit("OB_ThrowBoard", 1.0); + } + if drop_board || throw_board { + emit("OB_RetrieveBoard", 1.0); + } + if held(28) || held(30) { + emit("OB_DoAirBodyTweak", 1.0); + } + emit("OB_AirBodyTweakX", right_x); + emit("OB_AirBodyTweakY", right_y); + + //8259A604..A6C0 computes both;8259AF80 gates only NewToggle by actor bit10. + if !air_reckoning_active && !held(29) && held(22) { + //Native bge does not take the branch for unordered timer values. + if (rising(22) || !(axis(23) >= f32::from_bits(0x3cf5_c28f))) + && actor_flags & (1 << 10) == 0 + { + emit("NewToggleOffBoardState", 1.0); + } + emit("ToggleOffBoardState", 1.0); + } + if !(axis(21) > 0.0) && axis(20) > 0.0 { + emit("OB_Sprint", 1.0); + } + output +} + +/// Completed physical publication read by Fill825999F0. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnalogObservation { + /// PhysOutSkeleton+0: effective animation root Z, including processed2476bit2. + pub effective_skeleton_z: [f32; 4], + /// Some(OffBoard288) iff actual OffBoard333 is nonzero. This vector is not + /// normalized here. A missing Biped producer must not be converted to None. + pub biped_correction: Option<[f32; 4]>, +} + +///8259A7A8..A9BC calculation and8259AFFC..B088 ordered movement publications. +pub fn produce_analog( + controller: &DerivedControllerInput, + observation: AnalogObservation, +) -> [OffboardIntent; 4] { + let words = controller.words(); + let stick = [f32::from_bits(words[7]), 0.0, f32::from_bits(words[8]), 0.0]; + let mut horizontal = observation.effective_skeleton_z; + horizontal[1] = 0.0; + let forward = safe_unit(horizontal); + let magnitude = if let Some(normal) = observation.biped_correction { + let negative = normal.map(|value| f32::from_bits(value.to_bits() ^ 0x8000_0000)); + let projection = dot(stick, negative); + let lower = if -projection >= 0.0 { 0.0 } else { projection }; + let coefficient = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + //8259A994 ble takes only ordered <=. Unordered follows the zero branch. + if !(dot(safe_unit(stick), negative) <= f32::from_bits(0x3f66_6666)) { + 0.0 + } else { + let corrected = std::array::from_fn(|i| normal[i].mul_add(coefficient, stick[i])); + dot(forward, corrected) + } + } else { + dot(forward, stick) + }; + [ + OffboardIntent { name: "OB_Mag", value: magnitude }, + OffboardIntent { name: "OB_BipedWorldZ", value: stick[2] }, + OffboardIntent { name: "OB_BipedWorldX", value: stick[0] }, + OffboardIntent { name: "OB_BipedStickMag", value: super::controller::magnitude(dot(stick, stick)) }, + ] +} + +fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn safe_unit(vector: [f32; 4]) -> [f32; 4] { + let squared = dot(vector, vector); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(squared); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + let length = if squared == 0.0 { 0.0 } else { squared * inverse }; + //Initializer82F826F8 supplies830BD350 from82181A88: LENGTH threshold1e-6. + if length > f32::from_bits(0x3586_37bd) { + vector.map(|value| value * inverse) + } else { + [0.0; 4] + } +} +#[cfg(test)] +#[path = "offboard_intentions/tests.rs"] +mod tests; + diff --git a/crates/skate-core/src/input/offboard_intentions/tests.rs b/crates/skate-core/src/input/offboard_intentions/tests.rs new file mode 100644 index 00000000..d95d1a20 --- /dev/null +++ b/crates/skate-core/src/input/offboard_intentions/tests.rs @@ -0,0 +1,138 @@ +use super::*; + +fn values(words: [u32; 26], flags: u32, blocked: bool) -> Vec { + produce_discrete(&DerivedControllerInput::from_words(words), flags, blocked) +} +fn has(output: &[OffboardIntent], name: &str) -> bool { + output.iter().any(|intent| intent.name == name) +} +fn toggle_words(previous: bool, timer: f32) -> [u32; 26] { + let mut words = [0; 26]; + words[6] = if previous { 1 << 22 } else { 0 }; + words[13] = 1 << 22; + words[23] = timer.to_bits(); + words +} + +#[test] +fn toggle_rise_and_short_hold_are_distinct_from_continuous_hold() { + let new = "NewToggleOffBoardState"; + let held = "ToggleOffBoardState"; + assert!(has(&values(toggle_words(false, 9.0), 0, false), new)); + assert!(has(&values(toggle_words(true, 0.02), 0, false), new)); + let boundary = values(toggle_words(true, f32::from_bits(0x3cf5_c28f)), 0, false); + assert!(!has(&boundary, new)); + assert!(has(&boundary, held)); + let nan_timer = values(toggle_words(true, f32::NAN), 0, false); + assert!(has(&nan_timer, new)); + let mut released = toggle_words(true, 0.0); + released[13] = 0; + let released = values(released, 0, false); + assert!(!has(&released, new)); + assert!(!has(&released, held)); +} + +#[test] +fn physical_and_modifier_gates_block_both_but_actor_gate_only_blocks_new() { + let words = toggle_words(false, 0.0); + let disabled = values(words, 1 << 10, false); + assert!(!has(&disabled, "NewToggleOffBoardState")); + assert!(has(&disabled, "ToggleOffBoardState")); + let mut modifier = words; + modifier[13] |= 1 << 29; + for output in [values(words, 0, true), values(modifier, 0, false)] { + assert!(!has(&output, "NewToggleOffBoardState")); + assert!(!has(&output, "ToggleOffBoardState")); + } +} + +#[test] +fn jump_requires_new_button_and_does_not_inherit_toggle_gates() { + let mut words = [0; 26]; + words[13] = (1 << 23) | (1 << 29); + words[22] = 0.01f32.to_bits(); + assert!(has(&values(words, u32::MAX, true), "OB_Jump")); + words[6] = 1 << 23; + assert!(!has(&values(words, 0, false), "OB_Jump")); +} + +#[test] +fn full_trigger_edges_publish_board_actions_together_and_do_not_repeat() { + let mut words = [0; 26]; + words[11] = 0.99f32.to_bits(); + assert!(!has(&values(words, 0, false), "OB_RetrieveBoard")); + words[11] = 1.0f32.to_bits(); + words[12] = 1.0f32.to_bits(); + let output = values(words, u32::MAX, true); + let board: Vec<_> = output.iter().filter(|i| i.name.ends_with("Board")).map(|i| i.name).collect(); + assert_eq!(board, ["OB_DropBoard", "OB_ThrowBoard", "OB_RetrieveBoard"]); + words[4] = words[11]; + words[5] = words[12]; + assert!(!has(&values(words, 0, false), "OB_RetrieveBoard")); + words[5] = 0; + let right_only = values(words, 0, false); + assert!(!has(&right_only, "OB_DropBoard")); + assert!(has(&right_only, "OB_ThrowBoard")); + assert!(has(&right_only, "OB_RetrieveBoard")); +} + +#[test] +fn sprint_uses_competing_held_timers_and_axes_preserve_zero_presence() { + let mut words = [0; 26]; + words[20] = 0.01f32.to_bits(); + let output = values(words, u32::MAX, true); + assert!(has(&output, "OB_Sprint")); + assert!(!has(&output, "OB_LookAtX")); + assert!(!has(&output, "OB_LookAtY")); + assert!(has(&output, "OB_AirBodyTweakX")); + assert!(has(&output, "OB_AirBodyTweakY")); + words[21] = 0.01f32.to_bits(); + assert!(!has(&values(words, 0, false), "OB_Sprint")); + words[9] = (-0.5f32).to_bits(); + words[13] = 1 << 30; + let output = values(words, 0, false); + assert!(has(&output, "OB_DoAirBodyTweak")); + assert_eq!(output.iter().find(|i| i.name == "OB_LookAtX").unwrap().value, -0.5); + assert_eq!(output.iter().find(|i| i.name == "OB_AirBodyTweakX").unwrap().value, -0.5); +} + +fn analog(x: f32, z: f32, forward: [f32; 4], correction: Option<[f32; 4]>) -> [OffboardIntent; 4] { + let mut words = [0; 26]; + words[7] = x.to_bits(); + words[8] = z.to_bits(); + produce_analog(&DerivedControllerInput::from_words(words), AnalogObservation { + effective_skeleton_z: forward, biped_correction: correction, + }) +} + +#[test] +fn movement_preserves_signed_projection_and_raw_axes() { + let output = analog(0.75, -0.5, [0.0, 8.0, 3.0, 0.0], None); + assert!((output[0].value + 0.5).abs() < 1e-6); + assert_eq!(output[1].value, -0.5); + assert_eq!(output[2].value, 0.75); + assert!((output[3].value - (0.75f32 * 0.75 + 0.25).sqrt()).abs() < 1e-6); + let mirrored = analog(0.75, -0.5, [0.0, 0.0, -3.0, 0.0], None); + assert!((mirrored[0].value - 0.5).abs() < 1e-6); +} + +#[test] +fn obstacle_correction_stops_direct_approach_but_preserves_tangent_motion() { + let forward = [0.0, 0.0, 1.0, 0.0]; + let wall = Some([0.0, 0.0, -1.0, 0.0]); + assert_eq!(analog(0.0, 1.0, forward, wall)[0].value, 0.0); + let tangent = analog(0.6, 0.8, [1.0, 0.0, 0.0, 0.0], wall); + assert!((tangent[0].value - 0.6).abs() < 1e-6); + assert!((analog(0.0, -0.5, forward, wall)[0].value + 0.5).abs() < 1e-6); + //The source intentionally does not normalize the supplied correction vector. + assert!((analog(0.0, 1.0, forward, Some([0.0, 0.0, -0.5, 0.0]))[0].value - 0.75).abs() < 1e-6); +} + +#[test] +fn zero_and_tiny_headings_use_original_length_threshold() { + assert_eq!(analog(0.0, 1.0, [0.0; 4], None)[0].value, 0.0); + assert_eq!(analog(0.0, 1.0, [0.0, 0.0, 0.5e-6, 0.0], None)[0].value, 0.0); + assert!((analog(0.0, 1.0, [0.0, 0.0, 2.0e-6, 0.0], None)[0].value - 1.0).abs() < 1e-6); + let zero = analog(0.0, 0.0, [0.0, 0.0, 1.0, 0.0], None); + assert!(zero.iter().all(|intent| intent.value == 0.0)); +} diff --git a/crates/skate-core/src/input/pad.rs b/crates/skate-core/src/input/pad.rs new file mode 100644 index 00000000..bedde4a7 --- /dev/null +++ b/crates/skate-core/src/input/pad.rs @@ -0,0 +1,79 @@ +//! Native pad publication 82966F30; retained records use their big-endian words. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pad { + records: Vec<[u32; 4]>, + count: usize, +} +impl Pad { + /// An empty native cInputPad. 8296D288/826986A8 initialize the published + /// count to zero; records become initialized only when a larger count is + /// published by 82966F30. + pub fn new() -> Self { + Self { + records: Vec::new(), + count: 0, + } + } + + /// Existing initialized native records. The native count is their length. + pub fn from_records(records: Vec<[u32; 4]>) -> Self { + Self { + count: records.len(), + records, + } + } + pub fn from_storage(records: Vec<[u32; 4]>, count: usize) -> Self { + assert!(count <= records.len()); + Self { records, count } + } + pub fn count(&self) -> usize { + self.count + } + pub fn records(&self) -> &[[u32; 4]] { + &self.records + } + + /// Complete update, including growth reset, updated count on shorter + /// polls, three-call edge suppression and 24/12-call repeat intervals. + pub fn update(&mut self, values: &[f32]) { + if values.len() > self.count { + if values.len() > self.records.len() { + self.records.resize(values.len(), [0; 4]); + } + self.records[self.count..values.len()].fill([0; 4]); + } + self.count = values.len(); + for (record, &value) in self.records.iter_mut().zip(values) { + record[0] = value.to_bits(); + let down = u32::from(value > 0.5); + let held = (record[1] >> 8) & 255; + if (record[3] as i32) < 3 { + record[3] = record[3].wrapping_add(1); + record[1] &= 0xffff; + } else if down == held { + record[1] &= 0xffff; + } else { + record[1] = + (record[1] & 255) | (down << 8) | if down != 0 { 1 << 24 } else { 1 << 16 }; + record[3] = 0; + } + if record[1] & 0xff00 != 0 { + if record[2] == 0 { + record[1] = (record[1] & !255) | 1; + record[2] = 24; + } else { + record[2] = record[2].wrapping_sub(1); + if record[2] == 0 { + record[1] = (record[1] & !255) | 1; + record[2] = 12; + } else { + record[1] &= !255; + } + } + } else { + record[2] = 0; + record[1] &= !255; + } + } + } +} diff --git a/crates/skate-core/src/input/power_sliding.rs b/crates/skate-core/src/input/power_sliding.rs new file mode 100644 index 00000000..1bac78be --- /dev/null +++ b/crates/skate-core/src/input/power_sliding.rs @@ -0,0 +1,119 @@ +//! Complete PowerSliding update82BB28E8 at resolved graph/component boundaries. +//! Registration82F89300; factory82BC9060; vtable82320328. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct State { + pub elapsed: f32, + pub previous_right: f32, + pub previous_left: f32, + pub flags: u32, +} +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Global slot332 layout1684 and1632. + pub minimum_speed: f32, + pub minimum_slide_time: f32, + /// Graph4 layouts x1408/y1424, x1360/y1376, x1456/y1472. + pub stop_time: PointGraph<4>, + pub speed_response: PointGraph<4>, + pub angle_response: PointGraph<4>, +} +#[derive(Clone, Copy, Debug)] +pub struct Input { + /// PhysOut64->0 and PhysOut4->164. + pub category: u32, + pub speed: f32, + pub right_slide: Option, + pub left_slide: Option, + /// Results of82454FD8 for keys830BE528 and830C0818. + pub right_query: bool, + pub left_query: bool, + /// MotionGraph interface virtual224,82595930. + pub graph_scalar: f32, +} +fn bit(word: &mut u32, mask: u32, value: bool) { + *word = (*word & !mask) | if value { mask } else { 0 }; +} + +/// 82595930 arithmetic. +/// Inputs are PhysOut4->vector80, +/// PhysOut32->basis row32 and PhysOut4->byte273. None means absent Physical. +pub fn alignment(input: Option<([f32; 4], [f32; 4], u8)>) -> f32 { + let Some((velocity, basis, flipped)) = input else { + return 1.0; + }; + fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) + } + let squared = dot(velocity, velocity); + if !(squared > f32::from_bits(0x3a83_126f)) { + return 1.0; + } + let length = super::controller::magnitude(squared); + let mut reciprocal = super::angle::reciprocal_estimate(length); + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-reciprocal).mul_add(length, 1.0), reciprocal); + } + let velocity = velocity.map(|v| reciprocal * v); + let basis = if flipped != 0 { + basis.map(|v| -v) + } else { + basis + }; + dot(velocity, basis) +} + +/// Clock calls preserve the conditional first query and two subsequent timer +/// queries. The native context obtains each through its clock virtual40. +pub fn update( + state: &mut State, + latch: &mut [u32; 5], + input: Input, + settings: &Settings, + mut clock: impl FnMut() -> f32, +) { + let right = input.right_slide.unwrap_or(0.0); + let left = input.left_slide.unwrap_or(0.0); + let active = input.category == 1; + if active && state.flags & 0x2000_0000 == 0 { + state.elapsed = 0.0; + state.previous_right = right; + state.previous_left = left; + state.flags &= 0x3fff_ffff; + } + bit(&mut state.flags, 0x2000_0000, active); + if !active { + return; + } + let fast = input.speed > settings.minimum_speed; + let start_right = + fast && input.right_query && state.flags & 0x8000_0000 == 0 && state.previous_right < right; + bit(&mut latch[3], 0x8000_0000, start_right); + state.previous_right = right; + bit(&mut state.flags, 0x8000_0000, input.right_query); + let start_left = + fast && input.left_query && state.flags & 0x4000_0000 == 0 && state.previous_left > left; + bit(&mut latch[1], 0x8000_0000, start_left); + state.previous_left = left; + bit(&mut state.flags, 0x4000_0000, input.left_query); + let stop_time = settings.stop_time.evaluate(input.graph_scalar); + let response = settings.speed_response.evaluate(input.speed) * (1.0 - input.graph_scalar.abs()); + let threshold = settings.angle_response.evaluate(input.graph_scalar); + state.elapsed = if response < threshold { + state.elapsed + clock() + } else { + 0.0 + }; + let stop = state.elapsed > stop_time; + let right_time = clock() + f32::from_bits(latch[2]); + latch[2] = right_time.to_bits(); + let left_time = clock() + f32::from_bits(latch[0]); + latch[0] = left_time.to_bits(); + let exit_right = + right_time > settings.minimum_slide_time && (!input.right_slide.is_some() || !fast || stop); + let exit_left = + left_time > settings.minimum_slide_time && (!input.left_slide.is_some() || !fast || stop); + bit(&mut latch[3], 0x4000_0000, exit_right); + bit(&mut latch[1], 0x4000_0000, exit_left); +} diff --git a/crates/skate-core/src/input/riding_intentions.rs b/crates/skate-core/src/input/riding_intentions.rs new file mode 100644 index 00000000..33458465 --- /dev/null +++ b/crates/skate-core/src/input/riding_intentions.rs @@ -0,0 +1,139 @@ +//! Push, brake, body-spin, steering, kick-turn and crouch from TU3 Fill825999F0. +//! Trick/gesture, slide, manual and offboard branches remain separate work. +use super::{ + angle::left_stick_angle, + controller::{DerivedControllerInput, magnitude}, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct PushPreferences { + /// Profile component+52, byte58. The host owns profile storage/selection. + pub automatic_push_enabled: bool, + /// Profile component+56, byte156 selects the automatic push foot. + pub automatic_push_right: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RidingIntent { + pub name: &'static str, + pub value: f32, +} + +pub fn produce( + controller: &DerivedControllerInput, + actor_flags: u32, + preferences: PushPreferences, +) -> Vec { + let words = controller.words(); + let previous = words[6]; + let current = words[13]; + let left = [f32::from_bits(words[7]), f32::from_bits(words[8])]; + let allowed = actor_flags & (1 << 11) == 0; + let held = |bit: u32| current & (1u32 << bit) != 0 && allowed; + let new = |bit: u32, timer: usize| { + held(bit) && (previous & (1u32 << bit) == 0 || f32::from_bits(words[timer]) < 0.3) + }; + let mut right = held(21); + let mut left_push = held(23); + let mut new_right = new(21, 20); + let mut new_left = new(23, 22); + let angle = left_stick_angle(left[0], left[1]); + let from_forward = if angle >= 0.0 { + f32::from_bits(0x40490fdb) - angle + } else { + -f32::from_bits(0x40490fdb) - angle + }; + let length = magnitude(left[1].mul_add(left[1], left[0] * left[0])); + if preferences.automatic_push_enabled + && allowed + && !(right || left_push || new_right || new_left) + && from_forward.abs() < 0.6 + && length > 0.8 + { + if preferences.automatic_push_right { + right = true; + new_right = true; + } else { + left_push = true; + new_left = true; + } + } + if actor_flags & (1 << 6) != 0 && (right || new_right) && (left_push || new_left) { + right = false; + left_push = false; + new_right = false; + new_left = false; + } + let mut output = Vec::with_capacity(13); + let mut emit = |name, value| output.push(RidingIntent { name, value }); + if current & (1 << 20) != 0 && actor_flags & (1 << 7) == 0 { + emit("Brake", 1.0); + } + if new_right || new_left { + emit("NewPush", 1.0); + } + if right { + emit("RightPush", 1.0); + emit("Pushing", 1.0); + } + if left_push { + emit("LeftPush", 1.0); + emit("Pushing", 1.0); + } + //8259AC1C..AC54 emits this ordered pair from current left X. Actor1908 + //bit1 gates both; steering's bit0 and the physical state do not gate it. + //BodySpin's later animation-attribute handler owns its sign conversion. + if left[0] != 0.0 && actor_flags & (1 << 1) == 0 { + emit("BodySpin", left[0]); + emit("PhysBodySpin", left[0]); + } + //8259ACDC..ACE8 selects KickTurn from current left X, not shaped Turn. + if left[0] != 0.0 && actor_flags & 1 == 0 { + emit("KickTurn", left[0]); + } + let steering = super::steering_intentions::produce(left, actor_flags); + if let Some(value) = steering.hard_turn_crouch { + emit("HardTurnCrouch", value); + } + if let Some(value) = steering.hard_turn { + emit("HardTurn", value); + } + //8259A398..A424, emission key8259AE30..AE3C: the stick-based crouch ramp is + //multiplied by literal zero (f24). The two fsel instructions select the + //MAXIMUM of that result and the two trigger values, not a subtraction. + //An active gate can therefore intentionally emit Crouch with value zero. + let absolute = angle.abs(); + let start = f32::from_bits(0x3ff5_c28f); //1.92 + let end = f32::from_bits(0x4016_6666); //2.35 + let stick_crouch = if absolute <= start { + 0.0 + } else if absolute < end { + ((absolute - start) * f32::from_bits(0x4014_d655)) * length + } else { + length + } * 0.0; + let [left_trigger, right_trigger] = [words[11], words[12]].map(f32::from_bits); + if absolute > start || left_trigger > 0.0 || right_trigger != 0.0 { + let trigger = if left_trigger - right_trigger >= -0.0 { + left_trigger + } else { + right_trigger + }; + emit( + "Crouch", + if stick_crouch - trigger >= -0.0 { + stick_crouch + } else { + trigger + }, + ); + } + if let Some(value) = steering.turn { + emit("Turn", value); + } + output +} + +#[cfg(test)] +#[path = "riding_intentions_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/input/riding_intentions_tests.rs b/crates/skate-core/src/input/riding_intentions_tests.rs new file mode 100644 index 00000000..170c93d8 --- /dev/null +++ b/crates/skate-core/src/input/riding_intentions_tests.rs @@ -0,0 +1,88 @@ +use super::*; +#[test] +fn crouch_preserves_zero_presence_and_uses_maximum_trigger() { + let mut words = [0u32; 26]; + words[8] = 1.0f32.to_bits(); + let values = produce( + &DerivedControllerInput::from_words(words), + 1, + PushPreferences::default(), + ); + assert_eq!( + values.iter().find(|i| i.name == "Crouch").unwrap().value, + 0.0 + ); + words[11] = 0.4f32.to_bits(); + words[12] = 0.7f32.to_bits(); + words[7] = 0.5f32.to_bits(); + let values = produce( + &DerivedControllerInput::from_words(words), + 0, + PushPreferences::default(), + ); + assert_eq!( + values.iter().find(|i| i.name == "Crouch").unwrap().value, + 0.7 + ); + assert_eq!( + values.iter().find(|i| i.name == "KickTurn").unwrap().value, + 0.5 + ); + assert_ne!(values.iter().find(|i| i.name == "Turn").unwrap().value, 0.5); +} +fn snapshot(previous: u32, current: u32, held: f32) -> DerivedControllerInput { + let mut words = [0; 26]; + words[6] = previous; + words[13] = current; + words[20] = held.to_bits(); + words[22] = held.to_bits(); + DerivedControllerInput::from_words(words) +} +#[test] +fn held_push_continues_after_new_push_allowance_expires() { + let button = 1 << 21; + let first = produce(&snapshot(0, button, 0.5), 0, PushPreferences::default()); + assert!(first.iter().any(|i| i.name == "NewPush")); + let held = produce( + &snapshot(button, button, 0.5), + 0, + PushPreferences::default(), + ); + assert_eq!( + held.iter().map(|i| i.name).collect::>(), + ["RightPush", "Pushing"] + ); + assert!( + produce( + &snapshot(0, button, 0.0), + 1 << 11, + PushPreferences::default() + ) + .is_empty() + ); +} +#[test] +fn dual_push_gate_requires_both_feet_and_brake_uses_actor_mode() { + assert!( + !produce( + &snapshot(0, 1 << 21, 0.0), + 1 << 6, + PushPreferences::default() + ) + .is_empty() + ); + assert!( + produce( + &snapshot(0, (1 << 21) | (1 << 23), 0.0), + 1 << 6, + PushPreferences::default() + ) + .is_empty() + ); + let brake = snapshot(0, 1 << 20, 0.0); + assert_eq!( + produce(&brake, 0, PushPreferences::default())[0].name, + "Brake" + ); + assert!(produce(&brake, 1 << 7, PushPreferences::default()).is_empty()); +} diff --git a/crates/skate-core/src/input/set_turning.rs b/crates/skate-core/src/input/set_turning.rs new file mode 100644 index 00000000..961378de --- /dev/null +++ b/crates/skate-core/src/input/set_turning.rs @@ -0,0 +1,233 @@ +//! SetTurning enter 82BB3810 and update 82BB3830 at resolved component boundaries. +//! This is a reusable node operation; the loaded graph owns its activation. +use super::turn_remap::TurnRemap; +use crate::point_graph::PointGraph; + +/// One retained native slide record (SpecificMotionGraph3204..3220). +/// Both SetTurning and authored slide handlers read and mutate this owner. +/// Reserved bits stay intact; reset82595450..78 only clears the defined bits. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SlideLatch { + words: [u32; 5], +} +impl SlideLatch { + pub fn captured_fakie(&self) -> bool { + self.words[4] & 0x8000_0000 != 0 + } + pub fn candidate_enabled(&self) -> bool { + self.words[4] & 0x4000_0000 != 0 + } + pub fn set_candidate_enabled(&mut self, enabled: bool) { + self.words[4] = (self.words[4] & !0x4000_0000) | ((enabled as u32) << 30); + } + pub fn elapsed(&self, right: bool) -> f32 { + f32::from_bits(self.words[if right { 2 } else { 0 }]) + } + pub fn start(&self, right: bool) -> bool { + self.words[if right { 3 } else { 1 }] & 0x8000_0000 != 0 + } + pub fn end(&self, right: bool) -> bool { + self.words[if right { 3 } else { 1 }] & 0x4000_0000 != 0 + } + pub fn set_start(&mut self, right: bool, value: bool) { + let word = &mut self.words[if right { 3 } else { 1 }]; + *word = (*word & !0x8000_0000) | ((value as u32) << 31); + } + pub fn set_end(&mut self, right: bool, value: bool) { + let word = &mut self.words[if right { 3 } else { 1 }]; + *word = (*word & !0x4000_0000) | ((value as u32) << 30); + } + pub fn advance_elapsed(&mut self, right: bool, dt: f32) { + let word = &mut self.words[if right { 2 } else { 0 }]; + *word = (dt + f32::from_bits(*word)).to_bits(); + } + /// CreateSlide Begin82BB2E88; do not clear start/end/grab/candidate flags. + pub fn begin_slide(&mut self, fakie: bool) { + self.words[0] = 0; + self.words[2] = 0; + self.words[4] = (self.words[4] & !0x8000_0000) | ((fakie as u32) << 31); + } + /// GrabSlide82BBB848 maps authored side through the captured stance. + pub fn grab(&mut self, authored_right: bool) { + let right = authored_right ^ self.captured_fakie(); + self.words[if right { 3 } else { 1 }] |= 0x2000_0000; + } + /// ShouldLeaveSlide82BA7130 uses captured, not current, stance. + pub fn should_leave(&self, authored_right: bool) -> bool { + self.end(authored_right ^ self.captured_fakie()) + } + pub fn reset(&mut self) { + self.words[0] = 0; + self.words[2] = 0; + self.words[1] &= 0x1fff_ffff; + self.words[3] &= 0x1fff_ffff; + self.words[4] &= 0x3fff_ffff; + } +} + +#[derive(Clone, Copy, Debug)] +pub struct State { + pub elapsed: f32, + pub smoothed: f32, + pub mode: u32, +} +impl State { + /// Native enter and allocator 82BB4138 use the same three initial values. + pub fn enter(&mut self) { + self.elapsed = 0.0; + self.smoothed = 0.0; + self.mode = 2; + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Native helper mode 0 and mode 1, in that order. + pub remaps: [TurnRemap; 2], + pub speed_tuck: PointGraph<8>, + pub blend: PointGraph<8>, + pub speed_threshold: f32, + pub maximum_delta: f32, + /// Global collection slot 332, layout 1680. + pub override_turn: f32, +} + +/// Resolved physical output bundle+56 and bundle+4 inputs. Offset names avoid +/// assigning unverified meanings to upstream physical producer fields. +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub field_32: f32, + pub field_36: f32, + pub field_52: f32, + pub field_56: f32, + pub field_60: f32, + pub body_168: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct Intents { + pub fakie_turn: Option, + /// Native keys 830C067C and 830C05F4, respectively. + pub mode_0_slide: Option, + pub mode_1_slide: Option, +} + +/// Skeleton selectors bind to the decoded node parameter keys. Packet Turn and +/// Slide are native fixed attribute keys. Emissions occur in native call order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Attribute { + Angle, + Direction, + Quickness, + Speed, + Holding, + Turn, + Slide, +} + +/// `latch` preserves all five words returned by motion graph virtual+64. +/// `stance` is the component's virtual+12 and virtual+28 result pair. +/// `dt` is the graph context clock virtual+40 result, not a guessed fixed tick. +pub fn update( + state: &mut State, + latch: &mut SlideLatch, + physical: Physical, + stance: (bool, bool), + dt: f32, + settings: &Settings, + intents: Intents, + mut emit: impl FnMut(Attribute, f32), +) { + let latch = &mut latch.words; + let a = settings.remaps[0].apply([physical.field_32, physical.field_56]); + let _ = settings.remaps[0].apply([physical.field_36, physical.field_56]); + let b = settings.remaps[1].apply([physical.field_32, physical.field_56]); + let _ = settings.remaps[1].apply([physical.field_36, physical.field_56]); + let complement = 1.0 - physical.field_52; + let mut target = -a[0].mul_add(physical.field_52, b[0] * complement); + let direction = -a[1].mul_add(physical.field_52, b[1] * complement); + let old_0 = latch[1]; + let old_1 = latch[3]; + latch[1] &= !0x2000_0000; + latch[3] &= !0x2000_0000; + let prior_0 = old_0 & 0x2000_0000 != 0; + let prior_1 = old_1 & 0x2000_0000 != 0; + let enabled = latch[4] & 0x4000_0000 != 0; + let mut mode = state.mode; + if mode == 2 { + let first = enabled && (latch[3] & 0x8000_0000 != 0 || prior_1); + let second = enabled && (latch[1] & 0x8000_0000 != 0 || prior_0); + if first || second { + if !prior_0 && !prior_1 { + latch[0] = 0; + latch[2] = 0; + latch[4] = (latch[4] & 0x7fff_ffff) | ((stance.0 as u32) << 31); + } + mode = if first { 1 } else { 0 }; + } + } else if (mode == 1 && (!enabled || latch[3] & 0x4000_0000 != 0)) + || (mode == 0 && (!enabled || latch[1] & 0x4000_0000 != 0)) + { + mode = 2; + } + if mode <= 1 { + target = if (mode == 1) ^ stance.1 { 1.0 } else { -1.0 }; + if latch[4] & 0x8000_0000 != 0 { + target = -target; + } + } + if mode != state.mode { + state.elapsed = 0.0; + } + state.mode = mode; + let blend = settings.blend.evaluate(state.elapsed); + let lower = state.smoothed - settings.maximum_delta; + let upper = state.smoothed + settings.maximum_delta; + let value = (1.0 - blend).mul_add(state.smoothed, blend * target); + let value = if lower - value >= 0.0 { lower } else { value }; + state.elapsed += dt; + state.smoothed = if upper - value >= 0.0 { value } else { upper }; + let speed = physical.body_168.abs(); + let tuck = if speed > settings.speed_threshold { + settings + .speed_tuck + .evaluate(speed - settings.speed_threshold) + } else { + 0.0 + }; + emit(Attribute::Angle, state.smoothed); + emit(Attribute::Direction, direction); + emit(Attribute::Quickness, physical.field_52); + emit(Attribute::Speed, tuck); + emit(Attribute::Holding, physical.field_60); + if mode <= 1 { + let flip = latch[4] & 0x8000_0000 != 0; + let turn = if (mode == 1) ^ flip { + settings.override_turn + } else { + -settings.override_turn + }; + emit(Attribute::Turn, turn); + let slide = if mode == 0 { + intents.mode_0_slide + } else { + intents.mode_1_slide + } + .unwrap_or(0.0); + let slide = if flip { + let negative = -slide; + let absolute = negative.abs(); + let value = if absolute > 0.5 { + 1.0 - absolute + } else { + absolute + }; + if negative >= 0.0 { value } else { -value } + } else { + slide + }; + emit(Attribute::Slide, slide); + } else if let Some(value) = intents.fakie_turn { + emit(Attribute::Turn, value); + } +} diff --git a/crates/skate-core/src/input/steering_intentions.rs b/crates/skate-core/src/input/steering_intentions.rs new file mode 100644 index 00000000..ac8f3cb4 --- /dev/null +++ b/crates/skate-core/src/input/steering_intentions.rs @@ -0,0 +1,49 @@ +//! Complete local Turn/HardTurn/HardTurnCrouch emission dataflow in 825999F0. +//! Other intention outputs and the subsequent action/motion graphs are separate. +use super::{angle::left_stick_angle, controller::magnitude}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SteeringIntentions { + /// Absence is retained: native does not emit a zero Turn intention. + pub turn: Option, + pub hard_turn: Option, + pub hard_turn_crouch: Option, +} + +/// Consumes the current record after 825992D8 and actor+1908 captured at +/// 82599A38. No history field affects these three local emission sites. +pub fn produce(left: [f32; 2], actor_flags_1908: u32) -> SteeringIntentions { + let [x, y] = left; + let angle = left_stick_angle(x, y); + let absolute = angle.abs(); + let length = if x == 0.0 && y == 0.0 { + 0.0 + } else { + magnitude(y.mul_add(y, x * x)) + }; + let signed_length = if x >= 0.0 { length } else { -length }; + let turn = if y > 0.0 { + if absolute > f32::from_bits(0x40433333) { + 0.0 + } else if absolute > f32::from_bits(0x40166666) { + ((f32::from_bits(0x40433333) - absolute) * f32::from_bits(0x3fb6db6d)) * signed_length + } else { + signed_length + } + } else { + x + }; + let active = length > f32::from_bits(0x3f666666) + && !(absolute > f32::from_bits(0x3fb1eb85)) + && !(absolute < f32::from_bits(0x3f68f5c3)); + let hard = (absolute - f32::from_bits(0x3fb1eb85)) * f32::from_bits(0xc0055556); + let minimum = f32::from_bits(0x3a83126f); + let hard = if hard - minimum >= 0.0 { hard } else { minimum }; + let hard = if angle >= 0.0 { hard } else { -hard }; + let allowed = actor_flags_1908 & 1 == 0; + SteeringIntentions { + turn: (turn != 0.0 && allowed).then_some(turn), + hard_turn: (active && allowed).then_some(hard), + hard_turn_crouch: (active && allowed).then_some((hard * f32::from_bits(0x3f59999a)).abs()), + } +} diff --git a/crates/skate-core/src/input/turn_conditioner.rs b/crates/skate-core/src/input/turn_conditioner.rs new file mode 100644 index 00000000..e2989baf --- /dev/null +++ b/crates/skate-core/src/input/turn_conditioner.rs @@ -0,0 +1,144 @@ +//! Complete steering conditioner 82DEFF38, called by82DEFB30. +//! The settings decoder supplies global830CFDA4 slot204's verified layout. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Layout688/704/720, corresponding to conditioner216/180/144. + pub filter_coefficients: [[f32; 4]; 3], + /// Layout x304/y336, x240/y272, x32/y64, x384/y400. + pub input_curve: PointGraph<8>, + pub quickness_curve: PointGraph<8>, + pub speed_curve: PointGraph<8>, + pub smoothing_curve: PointGraph<4>, + /// Layout768,868,872,896,900,904,908,912,924,928,932,936,940. + pub parameters: [f32; 13], +} + +#[derive(Clone, Copy, Debug)] +pub struct State { + /// Conditioner112..140, in native order. + pub history: [f32; 8], + /// Complete9-word filters at216,180,144; first4 words refresh every call. + pub filters: [[f32; 9]; 3], +} + +impl State { + /// Steering-history portion of native reset82DEFAA0. Cached coefficients + /// survive reset; the update refreshes them from the settings layout. + pub fn reset_history(&mut self) { + self.history.fill(0.0); + for filter in &mut self.filters { + filter[4..].fill(0.0); + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub body_160: f32, + pub body_176: f32, + pub bundle_36_field_160: f32, + pub bundle_32_field_264: f32, + pub animation_152: u8, + pub animation_156: u8, +} + +/// PhysOut_Animation32/36/40/44/48/52/56/60, in native memory order. +pub type Output = [f32; 8]; + +fn clamp(value: f32, low: f32, high: f32) -> f32 { + let value = if low - value >= 0.0 { low } else { value }; + if high - value >= 0.0 { value } else { high } +} + +fn filter(state: &mut [f32; 9], coefficients: [f32; 4], input: f32) -> f32 { + state[..4].copy_from_slice(&coefficients); + let difference = input - state[4]; + let input_difference = input - state[5]; + let complement = 1.0 - state[3]; + state[5] = input; + state[8] = input_difference.mul_add(state[3], state[8] * complement); + state[6] = difference.mul_add(state[3], complement * state[6]); + let term = (state[8] - state[7]).mul_add(state[2], state[1] * state[6]); + let term = difference.mul_add(state[0], term); + let result = term + state[4]; + state[7] = result - state[4]; + state[4] = result; + result +} + +pub fn update(state: &mut State, input: Input, settings: &Settings) -> Output { + let p = settings.parameters; + let sign = if input.animation_156 != 0 { -1.0 } else { 1.0 }; + let scaled = input.bundle_32_field_264 * sign; + let speed_fraction = clamp(input.body_160 / p[11], 0.0, 1.0); + let other_fraction = clamp((input.bundle_36_field_160 / p[10]) * sign, -1.0, 1.0); + let base = settings.input_curve.evaluate(speed_fraction) * scaled; + let attenuated = (-(1.0 - p[8])).mul_add(input.body_176, 1.0) * other_fraction; + let target = attenuated.mul_add(p[9], (1.0 - p[9]) * base); + let first = filter( + &mut state.filters[0], + settings.filter_coefficients[0], + target, + ); + let epsilon = f32::from_bits(0x3c23_d70a); + let first = if first.abs() > epsilon { first } else { 0.0 }; + let second = filter( + &mut state.filters[1], + settings.filter_coefficients[1], + target, + ); + let second = if second.abs() > epsilon { second } else { 0.0 }; + let third = filter( + &mut state.filters[2], + settings.filter_coefficients[2], + (target - second) / p[12], + ); + let movement = if input.animation_152 != 0 { + scaled + } else { + 0.0 + }; + let difference = movement - state.history[0]; + state.history[0] = movement; + state.history[2] = difference + .abs() + .mul_add(p[2], (1.0 - p[2]) * state.history[2]); + let ratio = state.history[2] / p[1]; + let squared = ratio * ratio; + let bounded = if squared - 1.0 >= 0.0 { 1.0 } else { squared }; + state.history[3] = clamp(bounded, state.history[3] - p[0], state.history[3] + p[0]); + let quick = settings.quickness_curve.evaluate(state.history[3]); + let quick = clamp(quick, 0.0, 1.0); + let quick = settings.speed_curve.evaluate(input.body_160) * quick; + let movement = if input.animation_152 != 0 { + second + } else { + 0.0 + }; + let difference = movement - state.history[1]; + state.history[1] = movement; + state.history[5] = difference + .abs() + .mul_add(p[5], (1.0 - p[5]) * state.history[5]); + let hold: f32 = if state.history[5] < p[3] { 1.0 } else { 0.0 }; + let alpha = (1.0 - hold).mul_add(p[7], p[6] * hold); + let next = (1.0 - alpha).mul_add(state.history[4], alpha * hold); + let difference = next - state.history[4]; + state.history[6] = clamp(difference, state.history[6] - p[4], state.history[6] + p[4]); + state.history[4] = clamp(state.history[4] + state.history[6], 0.0, 1.0); + let alpha = settings.smoothing_curve.evaluate(input.body_176); + state.history[7] = (1.0 - alpha).mul_add(state.history[7], alpha * first); + let turn = clamp(state.history[7], -1.0, 1.0); + [ + turn, + clamp(target, -1.0, 1.0), + base, + turn * sign, + base * sign, + quick, + clamp(third, -1.0, 1.0), + clamp(state.history[4], 0.0, 1.0), + ] +} diff --git a/crates/skate-core/src/input/turn_remap.rs b/crates/skate-core/src/input/turn_remap.rs new file mode 100644 index 00000000..2ba56d4a --- /dev/null +++ b/crates/skate-core/src/input/turn_remap.rs @@ -0,0 +1,104 @@ +//! Complete TU3 SetTurning vector remap, 82BB4248. +//! Curves and offsets are supplied from the native animation_turning layout. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct TurnRemap { + pub magnitude: PointGraph<16>, + pub angle: PointGraph<16>, + pub angle_offset: f32, +} + +fn clamp(value: f32) -> f32 { + let lower = if -1.0 - value >= 0.0 { -1.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} + +fn angle(x: f32, y: f32, magnitude: f32) -> f32 { + if !(magnitude > f32::from_bits(0x3780_0000)) { + return 0.0; + } + let reciprocal = super::angle::reciprocal_estimate(x); + let reciprocal = reciprocal.mul_add((-reciprocal).mul_add(x, 1.0), reciprocal); + let basic = super::angle::atan(y.mul_add(reciprocal, 0.0)); + let sign = y.to_bits() & 0x8000_0000; + let result = if x < 0.0 { + f32::from_bits(0x4049_0fdb | sign) + basic + } else { + basic + }; + if x == 0.0 { + f32::from_bits(0x3fc9_0fdb | sign) + } else { + result + } +} + +// Scalar lane of standalone cosine 82473930; its power tree differs from SinCos. +fn cosine(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3e22_f983)).round_ties_even(); + let x = (-f32::from_bits(0x40c9_0fdb)).mul_add(turns, angle); + let x2 = x * x; + let x4 = x2 * x2; + let x6 = x4 * x2; + let x8 = x4 * x4; + let x10 = x6 * x4; + let x12 = x6 * x6; + let x14 = x8 * x6; + let x16 = x8 * x8; + let x18 = x10 * x8; + let x22 = x12 * x10; + let x20 = x10 * x10; + let mut value = (-0.5_f32).mul_add(x2, 1.0); + for (power, coefficient) in [ + (x4, 0x3d2a_aaab), + (x6, 0xbab6_0b61), + (x8, 0x37d0_0d01), + (x10, 0xb493_f27e), + (x12, 0x310f_76c8), + (x14, 0xad49_cba5), + (x16, 0x2957_3f9f), + (x18, 0xa534_13c3), + (x20, 0x20f2_a15d), + (x22, 0x9c86_71cb), + ] { + value = f32::from_bits(coefficient).mul_add(power, value); + } + value +} + +impl TurnRemap { + pub fn apply(&self, input: [f32; 2]) -> [f32; 4] { + let pi = f32::from_bits(0x4049_0fdb); + let magnitude = super::controller::magnitude(input[0] * input[0] + input[1] * input[1]); + let first_angle = angle(input[0], input[1], magnitude); + let graph_input = if first_angle >= 0.0 { + first_angle + } else { + first_angle + pi + }; + let scaled = magnitude * self.magnitude.evaluate(graph_input); + let x = clamp(cosine(first_angle) * scaled); + let y = clamp(crate::trigonometry::sin(first_angle) * scaled); + let magnitude = super::controller::magnitude(x * x + y * y); + let second_angle = angle(x, y, magnitude); + let graph_input = if second_angle >= 0.0 { + second_angle + } else { + second_angle + pi + }; + let mapped = self.angle.evaluate(graph_input); + let mapped = if second_angle >= 0.0 { + mapped + } else { + mapped + pi + }; + let result_angle = self.angle_offset + mapped; + [ + clamp(cosine(result_angle) * magnitude), + clamp(crate::trigonometry::sin(result_angle) * magnitude), + 0.0, + 0.0, + ] + } +} diff --git a/crates/skate-core/src/input/wipeout_intentions.rs b/crates/skate-core/src/input/wipeout_intentions.rs new file mode 100644 index 00000000..01627e1c --- /dev/null +++ b/crates/skate-core/src/input/wipeout_intentions.rs @@ -0,0 +1,51 @@ +//! Wipeout intention helper8259BC28, called by Fill825999F0 at8259AA6C. +//! Uses the existing evaluated stock action map and completed output flags. +use super::{controller::DerivedControllerInput, riding_intentions::RidingIntent}; + +pub fn produce( + controller: &DerivedControllerInput, + actor_flags_1908: u32, + physical_flags_204: u32, +) -> Vec { + let words = controller.words(); + let old_buttons = words[6]; + let buttons = words[13]; + let axis = |slot| f32::from_bits(words[slot]); + let rising = |bit: u32| old_buttons & (1u32 << bit) == 0u32 && buttons & (1u32 << bit) != 0u32; + let trigger_edge = |previous, current| axis(previous) != 1.0 && axis(current) == 1.0; + let mut output = Vec::with_capacity(7); + let mut emit = |name, value| output.push(RidingIntent { name, value }); + + //8259BC7C..BCEC: both names always exist, including disabled/centered zeros. + let gesture = physical_flags_204 & 0x80 != 0; + emit("WipeoutGestureX", if gesture { axis(9) } else { 0.0 }); + emit("WipeoutGestureY", if gesture { axis(10) } else { 0.0 }); + let control = physical_flags_204 & 0x100 != 0; + let x = if control { axis(7) } else { 0.0 }; + let y = if control { axis(8) } else { 0.0 }; + if x != 0.0 { + emit("WipeoutControlX", x); + } + if y != 0.0 { + emit("WipeoutControlY", y); + } + //8259BD28..BDBC: raw rising edges, not push repeat timers or held buttons. + if rising(21) || rising(23) { + emit("WipeOutRecover", 1.0); + } + //8259BDCC..BEF0: exact full trigger endpoints, both stick buttons, and + //a fresh edge from any of the four; actor disablebit6 gates this request. + if actor_flags_1908 & 0x40 == 0 + && axis(11) == 1.0 + && axis(12) == 1.0 + && buttons & 0xC000_0000 == 0xC000_0000 + && (trigger_edge(4, 11) || trigger_edge(5, 12) || rising(31) || rising(30)) + { + emit("WipeOutRequest", 1.0); + } + //8259BEFC..BF3C: only the second trigger's new full endpoint. + if physical_flags_204 & 0x40 != 0 && trigger_edge(5, 12) { + emit("WipeOutPushOff", 1.0); + } + output +} diff --git a/crates/skate-core/src/input/xbox.rs b/crates/skate-core/src/input/xbox.rs new file mode 100644 index 00000000..1e6c946f --- /dev/null +++ b/crates/skate-core/src/input/xbox.rs @@ -0,0 +1,70 @@ +//! Complete TU3 Xbox pad conversion 8296D5F8, after successful platform polling. +use super::controller::magnitude; + +/// Fields of the native Xbox state passed by value to 8296D5F8. +pub struct XboxState { + pub buttons: u16, + pub triggers: [u8; 2], + pub left: [i16; 2], + pub right: [i16; 2], +} + +/// Returns all 24 native button values. `device_byte_13` is explicit because +/// its producer/meaning is not inferred from the conversion's four clear stores. +pub fn convert(state: &XboxState, device_byte_13: u8) -> [f32; 24] { + let mut result = [0.0; 24]; + for (bit, output) in result[..10].iter_mut().enumerate() { + *output = if state.buttons & (1 << bit) != 0 { + 1.0 + } else { + 0.0 + }; + } + result[10] = f32::from(state.triggers[0]) * f32::from_bits(0x3b808081); + result[11] = f32::from(state.triggers[1]) * f32::from_bits(0x3b808081); + for (slot, bit) in [(12, 12), (13, 13), (14, 14), (15, 15)] { + result[slot] = if state.buttons & (1 << bit) != 0 { + 1.0 + } else { + 0.0 + }; + } + let left = condition(state.left); + let right = condition(state.right); + result[16..20].copy_from_slice(&left); + result[20..24].copy_from_slice(&right); + if device_byte_13 != 0 { + for slot in [10, 11, 21, 22] { + result[slot] = 0.0; + } + } + result +} + +fn condition(stick: [i16; 2]) -> [f32; 4] { + let x = f32::from(stick[0]) * f32::from_bits(0x38000000); + let y = f32::from(stick[1]) * f32::from_bits(0x38000000); + let squared = y.mul_add(y, x * x); + let length = magnitude(squared); + let factor = if length < f32::from_bits(0x3a83126f) { + 0.0 + } else { + let scaled = (length - 0.25) * f32::from_bits(0x3fb6db6e); + let bounded = if scaled < 0.0 { + 0.0 + } else if scaled > 1.0 { + 1.0 + } else { + scaled + }; + bounded / length + }; + let x = factor * x; + let y = factor * y; + [positive(x), positive(-x), positive(y), positive(-y)] +} + +fn positive(value: f32) -> f32 { + // fsel(-value, +0, value), including the native signed-zero selection. + if -value >= 0.0 { 0.0 } else { value } +} diff --git a/crates/skate-core/src/lib.rs b/crates/skate-core/src/lib.rs new file mode 100644 index 00000000..d87cc88d --- /dev/null +++ b/crates/skate-core/src/lib.rs @@ -0,0 +1,17 @@ +//! Engine-independent recovered calculations. No rendering, file I/O, or ECS. +//! The game schedules these systems; full gameplay parity remains under review. +//! Arithmetic uses independent Rust implementations. Original-hardware numerical +//! agreement is unverified. See docs/correction for current source coverage, +//! confirmed integration gaps and the evidence still required. +#![forbid(unsafe_code)] +pub mod air; +pub mod animation; +pub mod camera; +pub mod graph; +pub mod input; +pub mod math; +pub mod physics; +pub mod player; +pub mod point_graph; +pub mod riding; +pub mod trigonometry; diff --git a/crates/skate-core/src/math.rs b/crates/skate-core/src/math.rs new file mode 100644 index 00000000..c00f5838 --- /dev/null +++ b/crates/skate-core/src/math.rs @@ -0,0 +1,26 @@ +//! Shared scalar types. Rendering conversions stay outside the core. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Vector3 { + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl Vector3 { + pub const ZERO: Self = Self::new(0.0, 0.0, 0.0); + + pub const fn new(x: f32, y: f32, z: f32) -> Self { + Self { x, y, z } + } +} + +impl Default for Vector3 { + fn default() -> Self { + Self::ZERO + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Basis3 { + pub columns: [[f32; 3]; 3], +} diff --git a/crates/skate-core/src/physics/assembly.rs b/crates/skate-core/src/physics/assembly.rs new file mode 100644 index 00000000..c6a13443 --- /dev/null +++ b/crates/skate-core/src/physics/assembly.rs @@ -0,0 +1,204 @@ +//! Board constraint construction from physical body snapshots. +//! +//! The default TU3 assembly has six joints, two truck drives and a hook drive. Wheel drives +//! are an alternate configuration, disabled in the recovered stock assembly. +//! World collision and gameplay force production do not belong in this module. +use super::{ + board::{BODY_COUNT, BodyId}, + constraint_batch::compile_active, + drive_frames::{RetailAffineTransform, RetailDriveFrames}, + drive_parameters::RetailDriveDynamics, + drive_solver::{RetailDriveBodyState, RetailDriveRows, build_drive_rows}, + joint_builder::{RetailJointBodyInput, RetailJointBuildInput, build_retail_joint_jacobian}, + joint_records::default_joint_records, + rigid_body::{RetailBodyRates, RetailInertiaDynamics, pack_world_inverse_inertia}, + solver::JointConstraint, + truck_frames::steering_drive_frames, +}; + +#[derive(Clone, Copy, Debug)] +pub struct BodySnapshot { + pub state_flags: u32, + pub rates: RetailBodyRates, + pub inertia: RetailInertiaDynamics, +} + +/// Separate animated target body registered by CreateHookDrive 82C0D330. +/// It is not one of the seven physical board parts and must not be substituted +/// for the deck. Startup supplies its native body state explicitly. +#[derive(Clone, Debug)] +pub struct BoardHook { + pub body: BodySnapshot, + pub drive: super::hook_drive::HookDriveState, +} + +pub const HOOK_REACTION: usize = BODY_COUNT; + +/// The caller owns the snapshots, force accumulators and tick timing. No pose +/// is adjusted here to force the board onto a plane or into an animation. +pub struct BoardConstraints { + /// Eligible rows only; retained in native registration order. + pub joints: Vec, + /// Front truck, back truck, then hook; native creation/insertion order. + pub drives: Vec, +} + +impl BoardConstraints { + pub fn build( + bodies: &[BodySnapshot; BODY_COUNT], + hook: &BoardHook, + frames: [RetailDriveFrames; 3], + truck_dynamics: RetailDriveDynamics, + delta_seconds: f32, + ) -> Self { + let joints = compile_active( + default_joint_records(), + |record| { + [ + bodies[record.live_body_a().index()].state_flags, + bodies[record.live_body_b().index()].state_flags, + ] + }, + |record| { + let a = record.live_body_a(); + let b = record.live_body_b(); + JointConstraint { + jacobian: build_retail_joint_jacobian(RetailJointBuildInput { + parameters: record.parameters, + frames: record.frames, + body_a: joint_body(bodies[a.index()]), + body_b: joint_body(bodies[b.index()]), + time_step: delta_seconds, + // Guest pointer metadata is not dereferenced by the host solver. + joint_guest_address: 0, + }), + reaction_a: a.index(), + reaction_b: b.index(), + } + }, + ); + let mut drives = compile_active( + 0..2, + |&index| { + let truck = [BodyId::FrontTruck, BodyId::BackTruck][index]; + [ + bodies[truck.index()].state_flags, + bodies[BodyId::Deck.index()].state_flags, + ] + }, + |index| { + let truck = [BodyId::FrontTruck, BodyId::BackTruck][index]; + build_drive_rows( + drive_body(bodies[truck.index()], truck.index()), + drive_body(bodies[BodyId::Deck.index()], BodyId::Deck.index()), + frames[index], + truck_dynamics, + delta_seconds, + ) + }, + ); + drives.extend(compile_active( + [()], + |_| { + [ + hook.body.state_flags, + bodies[BodyId::Deck.index()].state_flags, + ] + }, + |_| { + build_drive_rows( + drive_body(hook.body, HOOK_REACTION), + drive_body(bodies[BodyId::Deck.index()], BodyId::Deck.index()), + frames[2], + hook.drive.solver_dynamics(), + delta_seconds, + ) + }, + )); + Self { joints, drives } + } +} + +/// SetTruckDriveFrames 82C0B9C0 followed by the active-drive quaternion +/// preparation in Island::Step_Solver2 82763B08. Hook normalization is a live +/// state write; it is not deferred to the animation frame setters. +pub fn prepare_drive_frames( + base: [RetailAffineTransform; 2], + targets: [f32; 2], + hook: &mut BoardHook, +) -> [RetailDriveFrames; 3] { + use super::drive_preparation::normalize_drive_frames; + let trucks = steering_drive_frames(base, targets).map(|frames| { + let mut raw = [0u32; 16]; + for (i, frame) in [frames.body_a, frames.body_b].into_iter().enumerate() { + let q = frame.orientation; + let t = frame.translation; + raw[i * 8..i * 8 + 8] + .copy_from_slice(&[q.x, q.y, q.z, q.w, t.x, t.y, t.z, 0.0].map(f32::to_bits)); + } + normalize_drive_frames(&mut raw); + hook_frames(&raw) + }); + normalize_drive_frames(&mut hook.drive.frames); + [trucks[0], trucks[1], hook_frames(&hook.drive.frames)] +} + +fn hook_frames(words: &[u32; 16]) -> super::drive_frames::RetailDriveFrames { + use super::{ + drive_frames::{RetailDriveFrame, RetailDriveFrames}, + rigid_body::RetailQuaternion, + }; + use crate::math::Vector3; + let f = |i| f32::from_bits(words[i]); + let frame = |offset: usize| RetailDriveFrame { + orientation: RetailQuaternion { + x: f(offset), + y: f(offset + 1), + z: f(offset + 2), + w: f(offset + 3), + }, + translation: Vector3::new(f(offset + 4), f(offset + 5), f(offset + 6)), + }; + RetailDriveFrames { + body_a: frame(0), + body_b: frame(8), + } +} + +fn joint_body(body: BodySnapshot) -> RetailJointBodyInput { + let rates = body.rates; + RetailJointBodyInput { + reaction_guest_address: 0, + state: body.state_flags, + orientation: rates.orientation, + center_of_mass: rates.position, + basis: rates.basis, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} + +fn drive_body(body: BodySnapshot, reaction_index: usize) -> RetailDriveBodyState { + let rates = body.rates; + RetailDriveBodyState { + reaction_index, + state: body.state_flags, + orientation: rates.orientation, + basis: rates.basis, + center_of_mass: rates.position, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} + +#[cfg(test)] +#[path = "tests/assembly.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board.rs b/crates/skate-core/src/physics/board.rs new file mode 100644 index 00000000..dee0f7b6 --- /dev/null +++ b/crates/skate-core/src/physics/board.rs @@ -0,0 +1,35 @@ +//! Physical body indices and stock geometry used by the TU3 board assembly. +pub const BODY_COUNT: usize = 7; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum BodyId { + RightFrontWheel = 0, + LeftFrontWheel = 1, + RightBackWheel = 2, + LeftBackWheel = 3, + FrontTruck = 4, + BackTruck = 5, + Deck = 6, +} +impl BodyId { + pub const ORDER: [Self; BODY_COUNT] = [ + Self::RightFrontWheel, + Self::LeftFrontWheel, + Self::RightBackWheel, + Self::LeftBackWheel, + Self::FrontTruck, + Self::BackTruck, + Self::Deck, + ]; + + pub const fn index(self) -> usize { + self as usize + } +} + +pub const RETAIL_WHEEL_RADIUS: f32 = f32::from_bits(0x3CFD_F3B6); +pub const RETAIL_TRUCK_Y_POSITION: f32 = f32::from_bits(0xBD67_6C8B); +pub const RETAIL_TRUCK_Z_POSITION_BACK: f32 = f32::from_bits(0xBD54_FDF4); +pub const RETAIL_TRUCK_Z_POSITION_FRONT: f32 = f32::from_bits(0xBD54_FDF4); +pub const RETAIL_TRUCK_ROTATION_AXIS_ANGLE_DEGREES: f32 = f32::from_bits(0x41F8_0000); +pub const RETAIL_DECK_MID_LENGTH: f32 = f32::from_bits(0x3F17_0A3D); diff --git a/crates/skate-core/src/physics/board_animation.rs b/crates/skate-core/src/physics/board_animation.rs new file mode 100644 index 00000000..6e17df81 --- /dev/null +++ b/crates/skate-core/src/physics/board_animation.rs @@ -0,0 +1,123 @@ +//! Original TU3 board error capture82C04368 and animation blend82C01878. +//! PC arithmetic and the recovered refinements are used; this is not a claim +//! of instruction-identical Xenon estimates or exceptional-lane execution. +use super::{ + constraint_frames::{basis, reciprocal, reciprocal_sqrt}, + drive_frames::retail_quaternion_from_basis, + native_arithmetic::dot4, + rigid_body::RetailQuaternion, + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY, compose_affine}, + skeleton_root::{inverse_rigid, orthonormalize}, +}; +use crate::{math::Basis3, point_graph::PointGraph, trigonometry}; + +pub struct BoardAnimationSettings { + pub slow: PointGraph<8>, + pub fast: PointGraph<8>, +} + +///Board240 and288. This state must be shared between physical Ground error +///capture and subsequent animated-board updates; it is not a per-state cache. +#[derive(Clone, Debug)] +pub struct BoardAnimation { + pub rotation_error: [f32; 4], + pub blending: bool, +} +impl Default for BoardAnimation { + fn default() -> Self { + //Original constructor82C01100/1104. + Self { + rotation_error: [0.0, 0.0, 0.0, 1.0], + blending: false, + } + } +} +impl BoardAnimation { + ///82C0606C resets the quaternion. The source does not clear byte288. + pub fn reset(&mut self) { + self.rotation_error = [0.0, 0.0, 0.0, 1.0]; + } + + ///82C04368: target inverse * actual DECK PART frame, reverse-axis + ///orthonormalization, quaternion conversion, and unconditional activation. + ///The scalar candidate conversion preserves the recovered comparisons and + ///refinements; exact VMX gather-register encoding remains separately audited. + pub fn capture_physics_error(&mut self, target: &Transform, deck_part: &Transform) { + let relative = orthonormalize(compose_affine(&inverse_rigid(target), deck_part)); + let q = retail_quaternion_from_basis(Basis3 { + columns: std::array::from_fn(|i| relative[i][..3].try_into().unwrap()), + }); + self.rotation_error = [q.x, q.y, q.z, q.w]; + self.blending = true; + } + + ///82C041C0 returns the target unchanged while inactive. Otherwise the + ///slow/fast graph is evaluated once per invocation, without a dt factor. + pub fn apply( + &mut self, + target: &Transform, + fast: bool, + settings: &BoardAnimationSettings, + ) -> Transform { + if !self.blending { + return *target; + } + let angle = trigonometry::acos(self.rotation_error[3]) * 2.0; + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let folded = fraction - if fraction > 0.5 { 1.0 } else { 0.0 }; + let distance = (folded * f32::from_bits(0x40c9_0fdb)).abs(); + let fraction = if fast { settings.fast } else { settings.slow }.evaluate(distance); + //Original fcmpu/blt: unordered graph output also completes the blend. + if !(fraction < f32::from_bits(0x3f73_3333)) { + self.blending = false; + return *target; + } + let identity = [0.0, 0.0, 0.0, 1.0]; + let dot = dot4(self.rotation_error, identity); + let reverse = 0.0 > dot; + let magnitude = if reverse { -dot } else { dot }; + let q = self.rotation_error.map(|v| if reverse { -v } else { v }); + let q = if magnitude > f32::from_bits(0x3f7f_069e) { + let same_sign = dot4(q, identity) > 0.0; + let mixed: [f32; 4] = std::array::from_fn(|lane| { + if same_sign { + fraction.mul_add(identity[lane] - q[lane], q[lane]) + } else { + (-(identity[lane] + q[lane])).mul_add(fraction, q[lane]) + } + }); + let inverse = reciprocal_sqrt(dot4(mixed, mixed)); + mixed.map(|v| v * inverse) + } else { + let theta = trigonometry::acos(magnitude); + let a = trigonometry::sin((1.0 - fraction) * theta); + let b = trigonometry::sin(fraction * theta); + let inverse = reciprocal(trigonometry::sin(theta)); + let weight_a = a * inverse; + let weight_b = b * inverse; + std::array::from_fn(|lane| identity[lane].mul_add(weight_b, q[lane] * weight_a)) + }; + self.rotation_error = q; + //82C01B04..BE0 uses the same sqrt(2) / paired residual basis kernel + //independently present in82AE1CC4. No quaternion normalization here. + let rotation = basis(RetailQuaternion { + x: q[0], + y: q[1], + z: q[2], + w: q[3], + }); + let mut correction = IDENTITY; + for axis in 0..3 { + correction[axis][..3].copy_from_slice(&rotation.columns[axis]); + } + compose_affine(target, &correction) + } +} + +///82C04270: target is compared with actual deck BODY COM, then all seven +///board bodies receive this XYZ velocity, leaving their angular rates alone. +pub fn target_velocity(target: [f32; 4], deck_body_position: [f32; 4], dt: f32) -> [f32; 4] { + let inverse = 1.0 / dt; + std::array::from_fn(|lane| (target[lane] - deck_body_position[lane]) * inverse) +} diff --git a/crates/skate-core/src/physics/board_dynamic_normal.rs b/crates/skate-core/src/physics/board_dynamic_normal.rs new file mode 100644 index 00000000..a7ced8b9 --- /dev/null +++ b/crates/skate-core/src/physics/board_dynamic_normal.rs @@ -0,0 +1,120 @@ +//! Dynamic wheel-response normal, original TU3 82C02388, with acceleration +//! history from82C082AC..8348. This is Ground64, distinct from wheel normals. +use super::{ + board::{BODY_COUNT, BodyId}, + board_ground::BoardGroundState, + board_motion_output::{add, dot, inverse_length_squared, scale, subtract}, + board_runtime::BoardRuntime, +}; +use crate::{math::Vector3, point_graph::PointGraph}; +const UP: Vector3 = Vector3::new(0., 1., 0.); +const ZERO: Vector3 = Vector3::ZERO; +#[derive(Clone, Debug)] +pub struct DynamicNormalSettings { + pub speed_damping: f32, + pub up_vector_damping: f32, + pub maximum_delta: f32, + pub speed_scale: f32, + pub maximum_delta_vs_speed: PointGraph<8>, +} +#[derive(Clone, Debug)] +pub struct BoardDynamicNormal { + /// Skateboard112, published to Ground64. + pub normal: Vector3, + /// Skateboard128. The constructor really initializes this to UP. + pub delta: Vector3, + /// Skateboard144 and160, retained independently by the source. + pub acceleration: Vector3, + pub last_contact_normal: Vector3, + previous_velocities: [Vector3; BODY_COUNT], + pub part_accelerations: [Vector3; BODY_COUNT], +} +impl BoardDynamicNormal { + ///82C00ED0 initializes112/128 to UP and144/160 to zero. The full board + ///reset82C0D680 ->82C00D68 clears all seven previous velocities. + pub fn new() -> Self { + Self { + normal: UP, + delta: UP, + acceleration: ZERO, + last_contact_normal: ZERO, + previous_velocities: [ZERO; BODY_COUNT], + part_accelerations: [ZERO; BODY_COUNT], + } + } + /// Board reset clears acceleration history but preserves wrapper112/128. + pub fn reset_body_history(&mut self) { + self.previous_velocities.fill(ZERO); + self.part_accelerations.fill(ZERO); + } + pub fn update( + &mut self, + board: &BoardRuntime, + contacts: &BoardGroundState, + gravity: Vector3, + dt: f32, + previous_ground_speed: f32, + settings: &DynamicNormalSettings, + ) { + let frequency = 1.0 / dt; + for part in BodyId::ORDER { + let i = part.index(); + let velocity = board.bodies()[i].rates.linear_velocity; + self.part_accelerations[i] = + scale(subtract(velocity, self.previous_velocities[i]), frequency); + self.previous_velocities[i] = velocity; + } + if contacts.wheel_contact_count == 0 { + self.acceleration = ZERO; + return; + } + let mut sum = ZERO; + //The original body tests780,781,782, then normalizes. There is no + //fourth-wheel accumulation in this function; retain that distinction. + for i in 0..3 { + if contacts.parts[i].in_contact { + sum = subtract(add(sum, self.part_accelerations[i]), gravity); + } + } + let target = normalize(sum); + self.delta = scale(self.delta, settings.speed_damping); + let error = scale(subtract(target, self.normal), settings.up_vector_damping); + let (direction, magnitude) = normalize_length(error); + let maximum = settings + .maximum_delta_vs_speed + .evaluate(previous_ground_speed.abs() / settings.speed_scale) + * settings.maximum_delta; + let nonnegative = if -magnitude >= -0.0 { 0.0 } else { magnitude }; + let amount = if maximum - nonnegative >= -0.0 { + nonnegative + } else { + maximum + }; + self.delta = Vector3::new( + direction.x.mul_add(amount, self.delta.x), + direction.y.mul_add(amount, self.delta.y), + direction.z.mul_add(amount, self.delta.z), + ); + self.normal = normalize(add(self.normal, self.delta)); + self.last_contact_normal = contacts.overall_normal; + } +} +fn normalize(v: Vector3) -> Vector3 { + normalize_length(v).0 +} +fn normalize_length(v: Vector3) -> (Vector3, f32) { + let squared = dot(v, v); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + //830BD350's verified initializer is float1e-6. Source compares length. + let normal = if length > f32::from_bits(0x3586_37bd) { + scale(v, inverse) + } else { + ZERO + }; + (normal, length) +} diff --git a/crates/skate-core/src/physics/board_ground.rs b/crates/skate-core/src/physics/board_ground.rs new file mode 100644 index 00000000..ccdee6b4 --- /dev/null +++ b/crates/skate-core/src/physics/board_ground.rs @@ -0,0 +1,312 @@ +//! Board contact normals and wheel-line state used by riding output. +//! +//! TU3 CollisionInfo constructor82C00D68/reset82C00CA0, wheel result +//! publication82C079E0, and world-contact normal selection82C07D20. The +//! postphysics stage consumes actual solver reports, never speculative query +//! contacts. Dynamic-object classification is outside the host's static world. +use crate::{math::Vector3, trigonometry}; +use super::{ + board::{BodyId, BODY_COUNT}, + board_motion_output::{add, dot, inverse_length_squared, length, scale, subtract}, + board_runtime::BoardRuntime, + board_step::CollisionBody, + contact_feedback::BoardContactReport, + native_arithmetic, +}; + +const UP: Vector3 = Vector3::new(0.0, 1.0, 0.0); +pub const WHEEL_LINE_LENGTH: f32 = f32::from_bits(0x3E4C_CCCD); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelLine { + pub start: Vector3, + pub end: Vector3, +} + +/// The four zero-radius queries in82C07788. Up is Reckoning+1152, supplied +/// by PhysicalPlayerHiLOD::StartSkateboardLineTests82DB6310. +pub fn wheel_lines(board: &BoardRuntime, reckoning_up: Vector3) -> [WheelLine; 4] { + let poses = board.part_transforms(); + let delta = scale(reckoning_up, WHEEL_LINE_LENGTH); + core::array::from_fn(|i| WheelLine { + start: poses[i].translation, + end: subtract(poses[i].translation, delta), + }) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelLineHit { + /// Query result+96, before82C079E0 multiplies by the authored line length. + pub fraction: f32, + pub normal: Vector3, + pub surface_tag: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelLineState { + pub normals: [Vector3; 4], + pub distances: [f32; 4], + pub physics_surfaces: [u32; 4], + pub minimum_distance: f32, +} +impl Default for WheelLineState { + fn default() -> Self { + //82C00D68 initializes all four normal/distance/surface records. + Self { + normals: [UP; 4], + distances: [0.0; 4], + physics_surfaces: [0; 4], + minimum_distance: 0.0, + } + } +} +impl WheelLineState { + /// Complete four-wheel branch82C079F8..82C07A9C. A miss clears the + /// surface, while the previous distance and normal deliberately survive. + pub fn publish(&mut self, hits: [Option; 4]) { + self.minimum_distance = WHEEL_LINE_LENGTH; + for (i, hit) in hits.into_iter().enumerate() { + self.physics_surfaces[i] = 0; + if let Some(hit) = hit { + let distance = hit.fraction * WHEEL_LINE_LENGTH; + self.minimum_distance = if distance - self.minimum_distance >= -0.0 { + self.minimum_distance + } else { + distance + }; + self.normals[i] = hit.normal; + self.distances[i] = distance; + self.physics_surfaces[i] = (hit.surface_tag >> 7) & 31; + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PartGroundContact { + pub in_contact: bool, + pub normal: Vector3, + pub point: Vector3, + pub relative_velocity: Vector3, +} +const EMPTY_PART: PartGroundContact = PartGroundContact { + in_contact: false, normal: UP, point: Vector3::ZERO, relative_velocity: Vector3::ZERO, +}; + +#[derive(Clone, Debug, PartialEq)] +pub struct BoardGroundState { + pub parts: [PartGroundContact; BODY_COUNT], + /// BoardBody320..416, retained between postphysics updates. + pub previous_velocities: [Vector3; BODY_COUNT], + /// BoardBody432..528, observed accelerations in actual part order. + pub accelerations: [Vector3; BODY_COUNT], + ///Body656/852: strongest closing velocity against this frame's contacts, + ///computed from retained part velocities before acceleration sampling. + pub closing_velocity: Vector3, + pub maximum_closing_speed: f32, + ///Body860: range of DECK contact normal projections onto Reckoning1152. + pub opposing_contact: f32, + ///Body864/872: static-world contact classifications and surface12 height. + ///Other-assembly group flags require actual dynamic-object reports. + pub surface_twelve_height: f32, + pub collision_flags: u32, + /// CollisionInfo+0. Can use truck/deck normals when the wheel sum fails. + pub overall_normal: Vector3, + /// CollisionInfo+16. Retained until a valid contacting-wheel sum replaces it. + /// FillPhysOut publishes this to Ground+80 and Ground+96. + pub wheel_normal: Vector3, + pub valid_wheel_normals: [bool; 4], + pub part_contact_count: u8, + pub wheel_contact_count: u8, + ///Body7692, accumulated only while all four physical wheels lack contact. + pub time_without_wheel_contact: f32, + /// Native wheel inertia+36 writes made after normal selection. + pub wheel_linear_drag: [f32; 4], +} +impl Default for BoardGroundState { + fn default() -> Self { + Self { + parts: [EMPTY_PART; BODY_COUNT], overall_normal: UP, wheel_normal: UP, + // CollisionInfo ctor82C00DC8/CC zeros both arrays. Board reset + //82C0D758 calls that constructor again; per-frame reset retains velocities. + previous_velocities: [Vector3::ZERO; BODY_COUNT], + accelerations: [Vector3::ZERO; BODY_COUNT], + closing_velocity: Vector3::ZERO, + maximum_closing_speed: 0.0, + opposing_contact: 0.0, + surface_twelve_height: 0.0, + collision_flags: 0, + valid_wheel_normals: [false; 4], part_contact_count: 0, + wheel_contact_count: 0, time_without_wheel_contact: 0.0, wheel_linear_drag: [0.0; 4], + } + } +} +impl BoardGroundState { + /// Original PostPhysics82C082B8..8444. The scalar reciprocal timestep is + /// shared by all seven parts, then each previous velocity is replaced. + pub fn sample_accelerations(&mut self, velocities: [Vector3; BODY_COUNT], time_step: f32) { + let inverse_dt = 1.0 / time_step; + for (i, current) in velocities.into_iter().enumerate() { + self.accelerations[i] = scale(subtract(current, self.previous_velocities[i]), inverse_dt); + self.previous_velocities[i] = current; + } + } + + ///82C087D8..87F4 after contact publication. The incoming f1 is the + ///actual physics timestep; reset82C0D680 deliberately preserves this field. + pub fn advance_contact_time(&mut self, time_step: f32) { + self.time_without_wheel_contact = if self.wheel_contact_count == 0 { + self.time_without_wheel_contact + time_step + } else { 0.0 }; + } + /// Complete contact-normal/count/drag subpipeline of82C07D20 for the + /// static world. Reports must already satisfy82AE1608/827682B0 eligibility. + /// `maximum_ground_angle_degrees` is physics_reckoning. + /// MaxAllowedGroundNormalFromUp (full64 0E508708CC7F5249). + pub fn update( + &mut self, + reports: &[BoardContactReport], + lines: &WheelLineState, + reckoning_up: Vector3, + maximum_ground_angle_degrees: f32, + board_wiping_out: bool, + ) { + self.parts.fill(EMPTY_PART); + //CollisionInfo Reset82C00CA0 clears per-frame impacts and high flags. + self.closing_velocity = Vector3::ZERO; + self.maximum_closing_speed = 0.0; + self.surface_twelve_height = 0.0; + self.collision_flags &= 0x01ff_ffff; + self.overall_normal = UP; + self.valid_wheel_normals.fill(true); + let mut highest_y = -2.0; + let mut highest_part = None; + //82C07E24..30 seeds min/max; only deck reports enter82C07EAC..CC. + let mut minimum_projection = 1.0; + let mut maximum_projection = -1.0; + let mut surfaces = [0; BODY_COUNT]; + surfaces[..4].copy_from_slice(&lines.physics_surfaces); + for report in reports { + assert_eq!(report.other, CollisionBody::StaticWorld, + "dynamic object contact classification needs its recovered owner"); + let i = report.part.index(); + if report.part == BodyId::Deck { + let projection = dot(report.normal, reckoning_up); + minimum_projection = if minimum_projection - projection >= 0.0 { + projection + } else { minimum_projection }; + maximum_projection = if maximum_projection - projection >= 0.0 { + maximum_projection + } else { projection }; + } + //82C07ED0..7F2C: this is the OLD part velocity, not the report's + //post-solve relative velocity or the finite-difference acceleration. + let closing = -dot(report.normal, self.previous_velocities[i]); + if !(closing <= self.maximum_closing_speed) { + self.maximum_closing_speed = closing; + self.closing_velocity = scale(report.normal, -closing); + } + let surface = (u32::from(report.other_surface) >> 7) & 31; + if surface == 12 { + self.collision_flags |= 1 << 25; //82C080D4..80F0. + self.surface_twelve_height = report.position.y; + } + if i >= 4 { + surfaces[i] = surface; //82C081B4..81E0, last report wins. + } + let contact = &mut self.parts[i]; + if !contact.in_contact || report.normal.y > contact.normal.y { + contact.normal = report.normal; + contact.point = report.position; + contact.relative_velocity = report.relative_linear_velocity; + } + // Selection occurs after the per-part maximum, in report order. + if contact.normal.y > highest_y { + highest_y = contact.normal.y; + highest_part = Some(i); + } + contact.in_contact = true; + } + //82C082AC/E8/F4/F8; no deck reports produce max(-2,0)=0. + let range = maximum_projection - minimum_projection; + self.opposing_contact = if -range >= 0.0 { 0.0 } else { range }; + //82C0834C..836C combines physical contact with the wheel query's + //surface (wheels), or the last actual contact tag (trucks/deck). + for (part, surface) in self.parts.iter().zip(surfaces) { + if part.in_contact && surface == 8 { + self.collision_flags |= 1 << 31; + } + } + // The source's -1 best-part index addresses the retained wheel normal. + let reference_normal = highest_part.map_or(self.wheel_normal, |i| self.parts[i].normal); + for i in 0..4 { + if !self.parts[i].in_contact { + if lines.distances[i] < f32::from_bits(0x3D8F_5C29) { + self.parts[i].normal = lines.normals[i]; + } else { + self.valid_wheel_normals[i] = false; + } + } + } + self.part_contact_count = self.parts.iter().filter(|part| part.in_contact).count() as u8; + self.wheel_contact_count = self.parts[..4].iter().filter(|part| part.in_contact).count() as u8; + let angle_radians = maximum_ground_angle_degrees * f32::from_bits(0x3C8E_FA35); + let minimum_up_dot = trigonometry::cos(angle_radians); + let mut sum = Vector3::ZERO; + for i in 0..4 { + let contact = self.parts[i]; + if self.valid_wheel_normals[i] + && dot(contact.normal, reckoning_up) > minimum_up_dot + && angle_between(reference_normal, contact.normal) < f32::from_bits(0x3F49_0FDB) + { + sum = add(sum, contact.normal); + } + let drag = if contact.in_contact { + if board_wiping_out { f32::from_bits(0x3D23_D70A) } else { 0.0 } + } else { + f32::from_bits(0x3BC4_9BA6) + }; + self.wheel_linear_drag[i] = drag * f32::from_bits(0x426F_FFFF); + } + let squared = dot(sum, sum); + if self.wheel_contact_count > 0 && squared > f32::from_bits(0x3780_0000) { + self.overall_normal = scale(sum, inverse_length_squared(squared, 2)); + self.wheel_normal = self.overall_normal; + } else { + let mut support = Vector3::ZERO; + // Native sum order: deck, front truck, back truck. + for i in [BodyId::Deck.index(), BodyId::FrontTruck.index(), BodyId::BackTruck.index()] { + if self.parts[i].in_contact { support = add(support, self.parts[i].normal); } + } + let magnitude = length(support); + if magnitude > f32::from_bits(0x3C23_D70A) { + let mut inverse = native_arithmetic::reciprocal_estimate(magnitude); + for _ in 0..2 { + let error = (-inverse).mul_add(magnitude, 1.0); + inverse = inverse.mul_add(error, inverse); + } + self.overall_normal = scale(support, inverse); + } + } + } +} + +/// 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 { + let a_squared = dot(a, a); + let b_squared = dot(b, b); + let epsilon = f32::from_bits(0x38D1_B717); + if !(a_squared > epsilon && b_squared > epsilon) { return 0.0; } + let a = scale(a, inverse_length_squared(a_squared, 1)); + let b = scale(b, inverse_length_squared(b_squared, 1)); + let cosine = native_arithmetic::vector_min( + native_arithmetic::vector_max(dot(a, b), -1.0), 1.0, + ); + trigonometry::acos(cosine) +} + + +#[cfg(test)] +#[path = "tests/board_ground.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_input_output.rs b/crates/skate-core/src/physics/board_input_output.rs new file mode 100644 index 00000000..c65cf22c --- /dev/null +++ b/crates/skate-core/src/physics/board_input_output.rs @@ -0,0 +1,111 @@ +//! Board-owned fields consumed by PhysicalPlayerHiLOD::Input, from original +//! TU3 Skateboard::FillPhysOut82C02A80. Other physical components publish into +//! the same packet after its native per-frame reset; their fields are retained. +use super::{ + board::BodyId, + board_ground::BoardGroundState, + board_motion_output::{BoardMotionOutput, dot, scale, subtract}, +}; +use crate::{ + math::Vector3, player::input_phase::PhysicalPlayerInput, + riding::collision_response::signed_angle, +}; + +/// Processed96 was captured by PrepareBoardToolkit before simulation. It is +/// deliberately separate from the current solved board transform. Ground64 is +/// the retained normal calculated by82C02388, not the wheel-contact normal. +pub struct BoardInputFrame { + pub processed_forward: Vector3, + pub reckoning_normal_1216: Vector3, + pub reckoning_ground_up: Vector3, + pub retained_ground_normal_112: Vector3, + pub processed_flags_2476: u32, +} + +pub fn publish_board_input( + out: &mut PhysicalPlayerInput, + motion: &BoardMotionOutput, + contacts: &BoardGroundState, + frame: BoardInputFrame, +) { + let side = motion.effective_basis.columns[0]; + let sign = if frame.processed_flags_2476 & 4 != 0 { + -1.0 + } else { + 1.0 + }; + out.board_reckoning_side_176 = + [side[0] * sign, side[1] * sign, side[2] * sign, 0.0].map(f32::to_bits); + out.skateboard.vector_64 = bits(motion.angular_velocity); + out.skateboard.vector_80 = bits(motion.linear_velocity); + out.skateboard.vector_96 = bits(motion.ground_velocity); + out.skateboard.scalar_160 = motion.speed; + out.skateboard.scalar_164 = motion.ground_speed; + out.skateboard.scalar_168 = motion.forward_speed; + out.skateboard.scalar_172 = travel_angle( + frame.processed_forward, + motion.ground_velocity, + frame.reckoning_normal_1216, + frame.reckoning_ground_up, + ); + //82C02A80 never writes Motion128/144. Preserve the values from the + //output reset and any other physical component, rather than inventing them. + out.ground.vector_64 = bits(frame.retained_ground_normal_112); + out.ground.vector_96 = bits(contacts.wheel_normal); + out.collision.wheel_contact_3296_3299 = + core::array::from_fn(|i| u8::from(contacts.parts[i].in_contact)); + out.collision.wheel_count_0 = u32::from(contacts.wheel_contact_count); + out.collision.flag_3472 = u8::from( + contacts.parts[BodyId::FrontTruck.index()].in_contact + || contacts.parts[BodyId::BackTruck.index()].in_contact, + ); + out.collision.flag_3475 = u8::from(contacts.parts[BodyId::Deck.index()].in_contact); + out.collision.flag_3477 = u8::from( + out.collision.wheel_count_0 > 0 + || out.collision.flag_3472 != 0 + || out.collision.flag_3475 != 0, + ); +} + +///82C030B8..322C, including8286CD88's product gate and plane rejection. +fn travel_angle(forward: Vector3, velocity: Vector3, normal: Vector3, ground_up: Vector3) -> f32 { + let mut projected_forward = reject(forward, normal); + if !(dot(projected_forward, velocity) > 0.0) { + projected_forward = scale(projected_forward, -1.0); + } + let angle = if dot(projected_forward, projected_forward) * dot(velocity, velocity) + > f32::from_bits(0x3780_0000) + { + signed_angle( + reject(projected_forward, ground_up), + reject(velocity, ground_up), + ground_up, + ) + } else { + 0.0 + }; + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb) +} +fn reject(vector: Vector3, normal: Vector3) -> Vector3 { + subtract(vector, scale(normal, dot(vector, normal))) +} +fn bits(vector: Vector3) -> [u32; 4] { + [vector.x, vector.y, vector.z, 0.0].map(f32::to_bits) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn forward_and_reverse_motion_use_the_same_travel_axis() { + let forward = Vector3::new(0., 0., 1.); + let normal = Vector3::new(0., 1., 0.); + let a = travel_angle(forward, Vector3::new(1., 0., 4.), normal, normal); + let b = travel_angle(forward, Vector3::new(-1., 0., -4.), normal, normal); + assert!((a - b).abs() < 0.0001); + assert!(a > 0. && a < 0.5); + assert_eq!(travel_angle(forward, Vector3::ZERO, normal, normal), 0.); + } +} diff --git a/crates/skate-core/src/physics/board_motion_output.rs b/crates/skate-core/src/physics/board_motion_output.rs new file mode 100644 index 00000000..1d05494d --- /dev/null +++ b/crates/skate-core/src/physics/board_motion_output.rs @@ -0,0 +1,78 @@ +//! Observed deck motion from TU3 Skateboard::FillPhysOut82C02A80. +//! These calculations consume the live solver body and the actual Reckoning +//! normal. They do not infer contact state from height or vertical velocity. +use crate::math::{Basis3, Vector3}; +use super::{board::BodyId, board_runtime::BoardRuntime, native_arithmetic}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardMotionOutput { + pub angular_velocity: Vector3, + pub linear_velocity: Vector3, + pub ground_velocity: Vector3, + /// SkateboardMotion+160: magnitude of the unprojected deck velocity. + pub speed: f32, + /// SkateboardMotion+164: magnitude after rejecting the Reckoning normal. + pub ground_speed: f32, + /// SkateboardMotion+168: raw deck velocity projected onto effective Z. + pub forward_speed: f32, + pub effective_basis: Basis3, +} + +impl BoardMotionOutput { + pub fn from_board( + board: &BoardRuntime, + reckoning_ground_normal: Vector3, + processed_flags_2468: u32, + ) -> Self { + let deck = board.bodies()[BodyId::Deck.index()].rates; + let mut effective_basis = board.part_transforms()[BodyId::Deck.index()].basis; + // Complete GetEffectiveTransform82C01BF8 axis adjustment; translation + // and Y remain the physical part's values. + if processed_flags_2468 & 0x0010_0000 != 0 { + for axis in [0, 2] { + effective_basis.columns[axis] = effective_basis.columns[axis].map(|v| -v); + } + } + let normal_speed = dot(deck.linear_velocity, reckoning_ground_normal); + let ground_velocity = subtract( + deck.linear_velocity, + scale(reckoning_ground_normal, normal_speed), + ); + let z = effective_basis.columns[2]; + Self { + angular_velocity: deck.angular_velocity, + linear_velocity: deck.linear_velocity, + ground_velocity, + speed: length(deck.linear_velocity), + ground_speed: length(ground_velocity), + forward_speed: dot(deck.linear_velocity, Vector3::new(z[0], z[1], z[2])), + effective_basis, + } + } +} + +pub fn dot(a: Vector3, b: Vector3) -> f32 { + native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} +pub(crate) fn scale(v: Vector3, s: f32) -> Vector3 { + Vector3::new(v.x * s, v.y * s, v.z * s) +} +pub(crate) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +pub(crate) fn subtract(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(crate) fn inverse_length_squared(squared: f32, refinements: usize) -> f32 { + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..refinements { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + inverse +} +pub fn length(v: Vector3) -> f32 { + let squared = dot(v, v); + let value = squared * inverse_length_squared(squared, 2); + if squared == 0.0 { 0.0 } else { value } +} diff --git a/crates/skate-core/src/physics/board_pose.rs b/crates/skate-core/src/physics/board_pose.rs new file mode 100644 index 00000000..7405cfd7 --- /dev/null +++ b/crates/skate-core/src/physics/board_pose.rs @@ -0,0 +1,25 @@ +//! Complete TU3 board SetTransform 82C0B2C8 and its part/pose dependencies. +//! This changes poses only; it does not infer startup, settle joints or reset rates. +mod arithmetic; +mod normalization; +mod part; +use arithmetic::*; +pub use normalization::{orthonormalize_part_basis, orthonormalize_rotation}; +pub use part::{PartPose, part_transform, set_part_transform}; + +/// Guest-order Ri/Up/At/translation vectors, including all packed fourth lanes. +pub type PoseMatrix = [u32; 16]; + +/// Set deck part 6 to the orthonormalized request, then move parts 0..5 by its +/// common rigid delta. The separate hook receives the ORIGINAL requested pose. +pub fn set_board_transform(parts: &mut [PartPose; 7], hook: &mut PartPose, requested: PoseMatrix) { + let old_deck = matrix(part_transform(&parts[6])); + let normalized = orthonormalize_rotation(requested); + let delta = compose(matrix(normalized), inverse_rigid(old_deck)); + set_part_transform(&mut parts[6], normalized); + for part in &mut parts[..6] { + let moved = compose(delta, matrix(part_transform(part))); + set_part_transform(part, orthonormalize_rotation(words(moved))); + } + set_part_transform(hook, requested); +} diff --git a/crates/skate-core/src/physics/board_pose/arithmetic.rs b/crates/skate-core/src/physics/board_pose/arithmetic.rs new file mode 100644 index 00000000..c8c35bc0 --- /dev/null +++ b/crates/skate-core/src/physics/board_pose/arithmetic.rs @@ -0,0 +1,79 @@ +//! Arithmetic order from the retained TU3 pose functions; guest lane order. +use super::PoseMatrix; +pub(super) type V = [f32; 4]; +pub(super) type M = [V; 4]; +pub(super) fn matrix(w: PoseMatrix) -> M { + core::array::from_fn(|i| core::array::from_fn(|j| f32::from_bits(w[i * 4 + j]))) +} +pub(super) fn words(m: M) -> PoseMatrix { + core::array::from_fn(|i| m[i / 4][i % 4].to_bits()) +} +pub(super) fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn mul(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] * b[i]) +} +pub(super) fn madd(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| a[i].mul_add(b[i], c[i])) +} +pub(super) fn nmsub(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| (-a[i]).mul_add(b[i], c[i])) +} +pub(super) fn perm(a: V, p: [usize; 4]) -> V { + p.map(|i| a[i]) +} +pub(super) fn dot(a: V, b: V, four: bool) -> f32 { + if four { + crate::physics::native_arithmetic::dot4(a, b) + } else { + crate::physics::native_arithmetic::dot3(a, b) + } +} +pub(super) fn rsqrt(v: f32) -> f32 { + let mut e = super::super::reciprocal_sqrt::estimate(v); + for _ in 0..2 { + let s = e * e; + let h = e * 0.5; + e = h.mul_add((-v).mul_add(s, 1.0), e); + } + e +} +pub(super) fn normalize(v: V, four: bool) -> V { + mul(v, [rsqrt(dot(v, v, four)); 4]) +} +pub(super) fn cross(a: V, b: V) -> V { + perm( + nmsub(perm(a, [1, 2, 0, 3]), b, mul(a, perm(b, [1, 2, 0, 3]))), + [1, 2, 0, 3], + ) +} +pub(super) fn compose(a: M, b: M) -> M { + core::array::from_fn(|i| { + let first = if i == 3 { + madd([b[i][0]; 4], a[0], a[3]) + } else { + mul([b[i][0]; 4], a[0]) + }; + madd([b[i][2]; 4], a[2], madd([b[i][1]; 4], a[1], first)) + }) +} +pub(super) fn inverse_rigid(m: M) -> M { + let mut out: [[f32; 4]; 4] = core::array::from_fn(|i| { + if i < 3 { + [m[0][i], m[1][i], m[2][i], 0.0] + } else { + [0.0; 4] + } + }); + let neg = sub([0.0; 4], m[3]); + out[3] = madd( + [neg[0]; 4], + out[0], + madd([neg[1]; 4], out[1], mul([neg[2]; 4], out[2])), + ); + out +} diff --git a/crates/skate-core/src/physics/board_pose/normalization.rs b/crates/skate-core/src/physics/board_pose/normalization.rs new file mode 100644 index 00000000..414c4a36 --- /dev/null +++ b/crates/skate-core/src/physics/board_pose/normalization.rs @@ -0,0 +1,54 @@ +use super::{PoseMatrix, arithmetic::*}; + +/// 825C5710: z/y/x projection against ORIGINAL input axes, then normalization. +/// Translation is copied verbatim. Degenerate inputs receive no invented repair. +pub fn orthonormalize_rotation(input: PoseMatrix) -> PoseMatrix { + let original = matrix(input); + let mut out = original; + for axis in (0..3).rev() { + let mut residual = original[axis]; + for other in (axis + 1..3).rev() { + residual = sub( + residual, + mul(out[other], [dot(out[other], original[axis], false); 4]), + ); + } + out[axis] = normalize(residual, false); + } + words(out) +} + +/// 82AE0A40: native magnitude gates and least-parallel pair selection followed +/// by two normalized cross products. This differs from 825C5710 above. +pub fn orthonormalize_part_basis(input: PoseMatrix) -> PoseMatrix { + let mut m = matrix(input); + let squares: [f32; 3] = core::array::from_fn(|i| dot(m[i], m[i], false)); + let magnitudes = squares.map(|v| { + let root = v * rsqrt(v); + if v == 0.0 { 0.0 } else { root } + }); + for axis in 0..3 { + m[axis] = mul(m[axis], [rsqrt(squares[axis]); 4]); + } + let (u, v, w) = if !(magnitudes[0] > 0.0) { + (1, 2, 0) + } else if !(magnitudes[1] > 0.0) { + (2, 0, 1) + } else if !(magnitudes[2] > 0.0) { + (0, 1, 2) + } else { + let ca = dot(m[2], m[0], false).abs(); + let bc = dot(m[1], m[2], false).abs(); + let ab = dot(m[0], m[1], false).abs(); + if ca > bc { + if ab > bc { (1, 2, 0) } else { (0, 1, 2) } + } else if ab > ca { + (2, 0, 1) + } else { + (0, 1, 2) + } + }; + m[w] = normalize(cross(m[u], m[v]), false); + m[v] = normalize(cross(m[w], m[u]), false); + words(m) +} diff --git a/crates/skate-core/src/physics/board_pose/part.rs b/crates/skate-core/src/physics/board_pose/part.rs new file mode 100644 index 00000000..de65272d --- /dev/null +++ b/crates/skate-core/src/physics/board_pose/part.rs @@ -0,0 +1,107 @@ +use super::{PoseMatrix, arithmetic::*, orthonormalize_part_basis}; + +/// Explicit referenced records for a native 96-byte Part. Optional fields model +/// the actual null-pointer branches; callers own loading and activation policy. +#[derive(Clone, Debug)] +pub struct PartPose { + pub transform: PoseMatrix, + /// Record addressed by Part+68 (the local mass-frame transform). + pub local_mass_frame: Option, + pub body: Option<[u32; 44]>, + pub inertia: Option<[u32; 10]>, +} + +/// 82585CB0: return the Part transform from its body, or its cached part matrix +/// if no body exists. This is not the whole game Body::GetPosition method. +pub fn part_transform(part: &PartPose) -> PoseMatrix { + let Some(body) = part.body.as_ref() else { + return part.transform; + }; + let m = [ + load(body, 16), + load(body, 20), + load(body, 24), + load(body, 4), + ]; + words(if let Some(local) = part.local_mass_frame { + compose(m, matrix(local)) + } else { + m + }) +} + +/// 82BD4318: update a live body's pose/caches, then always copy the original +/// requested matrix to the part. Velocity, forces and other packed lanes survive. +pub fn set_part_transform(part: &mut PartPose, requested: PoseMatrix) { + if let Some(body) = part.body.as_mut() { + let m = if let Some(local) = part.local_mass_frame { + words(compose(matrix(requested), inverse_rigid(matrix(local)))) + } else { + requested + }; + let m = matrix(orthonormalize_part_basis(m)); + for (off, v) in [(16, m[0]), (20, m[1]), (24, m[2]), (4, m[3])] { + store_xyz(body, off, v); + } + body[..4].copy_from_slice(&quaternion(m).map(f32::to_bits)); + if let Some(inertia) = part.inertia { + let [ri, up, at, _] = m; + let r = mul(ri, [f32::from_bits(inertia[0]); 4]); + let u = mul(up, [f32::from_bits(inertia[1]); 4]); + let a = mul(at, [f32::from_bits(inertia[2]); 4]); + let full = madd(a, [at[0]; 4], madd(r, [ri[0]; 4], mul(u, [up[0]; 4]))); + let p = |v| perm(v, [2, 1, 1, 3]); + let s = |v| perm(v, [2, 1, 2, 3]); + let split = madd(p(at), s(a), madd(p(ri), s(r), mul(p(up), s(u)))); + store_xyz(body, 28, full); + store_xyz(body, 32, split); + body[31] = inertia[4]; + } + } + part.transform = requested; +} + +fn quaternion(m: M) -> V { + let [ri, up, at, _] = m; + // Sign masks are initialized by TU3 82F83360/90/C0, not image defaults. + let rx = [ri[0], ri[0], -ri[0], -ri[0]]; + let uy = [up[1], -up[1], up[1], -up[1]]; + let az = [at[2], -at[2], -at[2], at[2]]; + let first = add(rx, uy); + let d = add(first, add(az, [1.0; 4])); + let inv = d.map(rsqrt); + let roots = mul(d, inv); + let halves = mul([0.5; 4], inv); + let a = [up[2], at[0], ri[1], 0.5]; + let b = [at[1], ri[2], up[0], 0.0]; + let minus = sub(a, b); + let plus = add(a, b); + let y = mul( + [plus[2], minus[3], plus[0], minus[1]], + [halves[2], roots[2], halves[2], halves[2]], + ); + let z = mul( + [plus[1], plus[0], minus[3], minus[2]], + [halves[3], halves[3], roots[3], halves[3]], + ); + let x = mul( + [minus[3], plus[2], plus[1], minus[0]], + [roots[1], halves[1], halves[1], halves[1]], + ); + let w = mul(minus, [halves[0], halves[0], halves[0], roots[0]]); + let yz = if up[1] > at[2] { y } else { z }; + let xyz = if ri[0] > up[1] && ri[0] > at[2] { + x + } else { + yz + }; + normalize(if first[0] + at[2] > 0.0 { w } else { xyz }, true) +} +fn load(w: &[u32], off: usize) -> V { + core::array::from_fn(|i| f32::from_bits(w[off + i])) +} +fn store_xyz(w: &mut [u32], off: usize, v: V) { + for i in 0..3 { + w[off + i] = v[i].to_bits(); + } +} diff --git a/crates/skate-core/src/physics/board_probes.rs b/crates/skate-core/src/physics/board_probes.rs new file mode 100644 index 00000000..0e9730d0 --- /dev/null +++ b/crates/skate-core/src/physics/board_probes.rs @@ -0,0 +1,154 @@ +//! Deck and wall probes in TU3 82C07788/82C079E0, with the actual optional +//! wall-line producer82C01F10. Wheel probes remain in board_ground. +use super::{ + board_ground::WheelLine, + board_motion_output::{dot, inverse_length_squared}, +}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardProbeHit { + pub point: Vector3, + pub normal: Vector3, + pub surface_tag: u32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardProbeState { + pub start: Vector3, + pub point: Vector3, + pub normal: Vector3, + pub surface_tag: u32, + pub hit: bool, +} +impl Default for BoardProbeState { + fn default() -> Self { + // SkateboardBody ctor82C06614..82C0675C clears both complete records. + Self { + start: Vector3::ZERO, + point: Vector3::ZERO, + normal: Vector3::ZERO, + surface_tag: 0, + hit: false, + } + } +} +impl BoardProbeState { + pub fn start(&mut self, position: Vector3) { + self.start = position; + self.hit = false; + } + /// A miss only clears the hit byte. Previously published point, normal and + /// surface deliberately remain, unlike a disabled optional query. + pub fn publish(&mut self, hit: Option) { + self.hit = hit.is_some(); + if let Some(hit) = hit { + self.point = hit.point; + self.normal = hit.normal; + self.surface_tag = hit.surface_tag; + } + } + pub fn disable(&mut self) { + *self = Self::default(); + } +} + +pub const DECK_PROBE_RADIUS: f32 = f32::from_bits(0x3e19_999a); +/// Fifth query always runs from the physical deck origin100m down, radius0.15. +/// Native start is copied to body7968, and result to7984/8000/8016/8020. +pub fn deck_probe(position: Vector3) -> WheelLine { + WheelLine { + start: position, + end: Vector3::new( + position.x, + position.y + f32::from_bits(0xc2c8_0000), + position.z, + ), + } +} + +#[derive(Clone, Copy, Debug)] +pub struct WallLineInput { + /// Processed2508: current physical state (100 or101 admitted). + pub state: u32, + /// Processed464,544,112,2752. + pub contact_normal: Vector3, + pub skater_up: Vector3, + pub deck_position: Vector3, + pub time: f32, +} +/// Source82C01F10 clears endpoints208/224 on every call, then enables byte291 +/// only for the three physical gates below. No guessed grounded condition. +pub fn wall_probe(input: WallLineInput) -> Option { + if !matches!(input.state, 100 | 101) + || !(input.contact_normal.y.abs() < 0.5) + || !(dot(input.skater_up, input.contact_normal) > f32::from_bits(0x3f35_c28f)) + || !(input.time > f32::from_bits(0x3c23_d70a)) + { + return None; + } + let normal = input.contact_normal; + let start = Vector3::new( + normal.x.mul_add(0.05, input.deck_position.x), + normal.y.mul_add(0.05, input.deck_position.y), + normal.z.mul_add(0.05, input.deck_position.z), + ); + let first = cross(Vector3::new(0., 1., 0.), normal); + let down = cross(first, normal); + let squared = dot(down, down); + let inverse = inverse_length_squared(squared, 2); + let magnitude = if squared == 0. { 0. } else { squared * inverse }; + let down = if magnitude > f32::from_bits(0x3586_37bd) { + Vector3::new(down.x * inverse, down.y * inverse, down.z * inverse) + } else { + Vector3::ZERO + }; + Some(WheelLine { + start, + end: Vector3::new( + down.x.mul_add(4., start.x), + down.y.mul_add(4., start.y), + down.z.mul_add(4., start.z), + ), + }) +} +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn wall_probe_gates_and_miss_retention_follow_source() { + let mut input = WallLineInput { + state: 100, + contact_normal: Vector3::new(1., 0., 0.), + skater_up: Vector3::new(1., 0., 0.), + deck_position: Vector3::new(2., 3., 4.), + time: 0.02, + }; + let line = wall_probe(input).unwrap(); + assert_eq!(line.start, Vector3::new(2.05, 3., 4.)); + assert_eq!(line.end, Vector3::new(2.05, -1., 4.)); + input.state = 200; + assert!(wall_probe(input).is_none()); + let mut output = BoardProbeState::default(); + output.start(line.start); + output.publish(Some(BoardProbeHit { + point: line.end, + normal: Vector3::new(0., 1., 0.), + surface_tag: 7, + })); + output.start(line.start); + output.publish(None); + assert!(!output.hit); + assert_eq!(output.point, line.end); + assert_eq!(output.surface_tag, 7); + output.disable(); + assert_eq!(output, BoardProbeState::default()); + } +} diff --git a/crates/skate-core/src/physics/board_runtime.rs b/crates/skate-core/src/physics/board_runtime.rs new file mode 100644 index 00000000..92a3ee66 --- /dev/null +++ b/crates/skate-core/src/physics/board_runtime.rs @@ -0,0 +1,403 @@ +//! Persistent board physics for the host game. +//! +//! Ownership and spawning are host policy. Physical initialization follows TU3 +//! AddRigidBody 82AE5928 and Reset 82776140: dynamic bodies start at rest with +//! gravity accumulated, while the separate hook has zero inverse mass/tensor. +//! Part pose conversion reuses 82BD4318/82C0B2C8; no packed body is retained +//! beside the state consumed and updated by BoardStep. +use crate::math::{Basis3, Vector3}; + +use super::{ + assembly::{BoardHook, BodySnapshot}, + board::{BODY_COUNT, BodyId}, + board_pose::{PartPose, PoseMatrix, part_transform, set_board_transform, set_part_transform}, + board_step::{AttachedStep, BoardCollision, BoardStep, BoardStepSettings}, + drive_frames::RetailAffineTransform, + force_queue::BoardForceQueue, + hook_drive::HookDriveState, + rigid_body::{ + RetailBodyMassProperties, RetailBodyRates, RetailInertiaDynamics, RetailLocalMassFrame, + RetailQuaternion, RetailSimulationStep, world_inverse_inertia, + }, +}; + +/// The host explicitly chooses the board's initial physical mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BoardMotion { + Active, + Frozen, + Static, +} + +impl BoardMotion { + fn flags(self) -> u32 { + match self { + Self::Active => 4, + Self::Frozen => 2, + Self::Static => 1, + } + } +} + +pub struct BoardRuntime { + bodies: [BodySnapshot; BODY_COUNT], + mass_frames: [RetailLocalMassFrame; BODY_COUNT], + hook: BoardHook, + step: BoardStep, + forces: BoardForceQueue, + ///Original assembly/part group: standard82C090C0=4, wipeout82C09160=7. + collision_group: u32, +} + +impl BoardRuntime { + /// `authored` contains part poses in board space. `spawn` places the deck + /// part in world space, and moves the other parts by the same rigid delta. + /// All mass properties come from the caller's physical data producer. + pub fn new( + masses: [RetailBodyMassProperties; BODY_COUNT], + authored: [RetailAffineTransform; BODY_COUNT], + spawn: RetailAffineTransform, + simulation: RetailSimulationStep, + mode: BoardMotion, + ) -> Self { + let mut parts = core::array::from_fn(|i| { + let mut part = PartPose { + transform: pose_words(authored[i]), + local_mass_frame: Some(mass_frame_words(masses[i].local_mass_frame)), + body: Some([0; 44]), + inertia: (mode != BoardMotion::Static).then(|| inertia_words(masses[i].dynamics)), + }; + set_part_transform(&mut part, pose_words(authored[i])); + part + }); + let mut hook_part = PartPose { + transform: pose_words(RetailAffineTransform::IDENTITY), + local_mass_frame: None, + body: Some([0; 44]), + inertia: None, + }; + set_board_transform(&mut parts, &mut hook_part, pose_words(spawn)); + let bodies = core::array::from_fn(|i| { + initialized_body(&parts[i], masses[i].dynamics, simulation, mode) + }); + // CreateHookDrive 82C0D330 creates its own STATIC_BODY target. It does + // not alias the deck or a global world body. Its mass calculation is + // irrelevant to physical response because this body has no inertia. + let hook = BoardHook { + body: initialized_body(&hook_part, ZERO_INERTIA, simulation, BoardMotion::Static), + drive: HookDriveState::initial(), + }; + Self { + bodies, + mass_frames: masses.map(|mass| mass.local_mass_frame), + hook, + step: BoardStep::default(), + forces: BoardForceQueue::default(), + collision_group: 4, + } + } + + pub fn collision_group(&self) -> u32 { self.collision_group } + ///All seven native board parts share the current assembly group. + pub fn set_collision_group(&mut self, group: u32) { self.collision_group = group } + + pub fn bodies(&self) -> &[BodySnapshot; BODY_COUNT] { + &self.bodies + } + + /// Physical portion of82C05F50/82C0D680. Restore the authored part poses, + /// apply the requested stance and board transform, then clear rates. State + /// flags, cooldown, inertias and target-body rates retain their own owners. + pub fn reset_physical( + &mut self, + authored: [RetailAffineTransform; BODY_COUNT], + mut target: RetailAffineTransform, + processed_flags: u32, + gravity: Vector3, + ) { + let mut parts = core::array::from_fn(|i| PartPose { + transform: pose_words(authored[i]), + local_mass_frame: Some(mass_frame_words(self.mass_frames[i])), + body: Some(body_pose_words(self.bodies[i].rates)), + inertia: Some(inertia_words(self.bodies[i].inertia)), + }); + for i in [6, 0, 1, 2, 3, 4, 5] { + set_part_transform(&mut parts[i], pose_words(authored[i])); + } + if processed_flags & 0x0010_0000 != 0 { + for i in [0, 2] { + target.basis.columns[i] = target.basis.columns[i].map(|v| -v); + } + } + let mut hook = PartPose { + transform: pose_words(self.hook_transform()), + local_mass_frame: None, + body: Some(body_pose_words(self.hook.body.rates)), + inertia: None, + }; + set_board_transform(&mut parts, &mut hook, pose_words(target)); + for (body, part) in self.bodies.iter_mut().zip(&parts) { + copy_pose(part, &mut body.rates); + body.rates.linear_velocity = Vector3::ZERO; + body.rates.angular_velocity = Vector3::ZERO; + body.rates.force_acceleration = gravity; + body.rates.torque_acceleration = Vector3::ZERO; + body.rates.world_inverse_inertia = world_inverse_inertia( + body.rates.basis, body.inertia.inverse_tensor, + ); + } + copy_pose(&hook, &mut self.hook.body.rates); + self.forces.clear(); + // Completed contacts belong to the old pose; host storage can be + // discarded without rebuilding physical bodies or their constraints. + self.step = BoardStep::default(); + } + + /// Gameplay may publish actual velocities/accumulators or physical mode + /// changes here. Rendering should use the immutable view or part poses. + pub fn bodies_mut(&mut self) -> &mut [BodySnapshot; BODY_COUNT] { + &mut self.bodies + } + + pub fn hook(&self) -> &BoardHook { + &self.hook + } + + /// Animation/state services own the hook target and live drive parameters. + pub fn hook_mut(&mut self) -> &mut BoardHook { + &mut self.hook + } + + pub fn forces(&self) -> &BoardForceQueue { + &self.forces + } + + pub fn contact_reports(&self) -> &[super::contact_feedback::BoardContactReport] { + self.step.contact_reports() + } + + /// Original post-solver observers consume these completed shared rows. + pub fn solved_contacts(&self) -> &[super::contact_solver::RetailContactJacobian] { + self.step.solved_contacts() + } + + pub fn forces_mut(&mut self) -> &mut BoardForceQueue { + &mut self.forces + } + + /// Queue clearing is a separate gameplay phase, as in GeneralUpdate + /// 82C02360. Advancing physics does not silently consume its ownership. + pub fn clear_forces(&mut self) { + self.forces.clear(); + } + + pub fn advance( + &mut self, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + ) { + self.step.advance( + &mut self.bodies, + &mut self.hook, + &self.forces, + collisions, + truck_targets, + settings, + ); + } + + ///The skater, board and hook constraints exchange reactions in every + ///iteration; this never advances the skeleton in a separate solver. + pub fn advance_attached( + &mut self, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + attached: AttachedStep<'_>, + ) { + self.step.advance_attached( + &mut self.bodies, + &mut self.hook, + &self.forces, + collisions, + truck_targets, + settings, + attached, + ); + } + + /// Live center-of-mass pose for collision and other physical consumers. + pub fn body_transform(&self, id: BodyId) -> RetailAffineTransform { + rates_transform(self.bodies[id.index()].rates) + } + + /// Live visual/collision part poses, including each mass-frame offset. + /// This derives poses from the integrated bodies rather than stale spawn + /// matrices, and never writes physics from presentation. + pub fn part_transforms(&self) -> [RetailAffineTransform; BODY_COUNT] { + core::array::from_fn(|i| { + let part = PartPose { + transform: pose_words(rates_transform(self.bodies[i].rates)), + local_mass_frame: Some(mass_frame_words(self.mass_frames[i])), + body: Some(body_pose_words(self.bodies[i].rates)), + inertia: None, + }; + transform_from_words(part_transform(&part)) + }) + } + + pub fn hook_transform(&self) -> RetailAffineTransform { + rates_transform(self.hook.body.rates) + } + + /// Publish an animation target while retaining the hook's zero physical + /// response and its separate drive state. + pub fn set_hook_transform(&mut self, requested: RetailAffineTransform) { + let mut part = PartPose { + transform: pose_words(self.hook_transform()), + local_mass_frame: None, + body: Some(body_pose_words(self.hook.body.rates)), + inertia: None, + }; + set_part_transform(&mut part, pose_words(requested)); + copy_pose(&part, &mut self.hook.body.rates); + } +} + +const ZERO_INERTIA: RetailInertiaDynamics = RetailInertiaDynamics { + inverse_tensor: Vector3::ZERO, + inverse_mass: 0.0, + spherical: 0.0, + maximum_linear_velocity: 0.0, + maximum_angular_velocity: 0.0, + linear_drag: 0.0, + angular_drag: 0.0, +}; + +pub(crate) fn initialized_body( + part: &PartPose, + inertia: RetailInertiaDynamics, + simulation: RetailSimulationStep, + mode: BoardMotion, +) -> BodySnapshot { + let is_static = mode == BoardMotion::Static; + let inertia = if is_static { ZERO_INERTIA } else { inertia }; + let mut rates = RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: RetailAffineTransform::IDENTITY.basis, + world_inverse_inertia: Basis3 { + columns: [[0.0; 3]; 3], + }, + position: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: if is_static { + Vector3::ZERO + } else { + simulation.gravity_acceleration + }, + torque_acceleration: Vector3::ZERO, + kinetic_energy: if is_static { 0.0 } else { f32::MAX }, + cool_down: if mode == BoardMotion::Active { + 0 + } else { + simulation.cool_down + }, + }; + copy_pose(part, &mut rates); + rates.world_inverse_inertia = world_inverse_inertia(rates.basis, inertia.inverse_tensor); + BodySnapshot { + state_flags: mode.flags(), + rates, + inertia, + } +} + +fn copy_pose(part: &PartPose, rates: &mut RetailBodyRates) { + let words = part + .body + .as_ref() + .expect("transient pose adapter has a body"); + let f = |i| f32::from_bits(words[i]); + rates.orientation = RetailQuaternion { + x: f(0), + y: f(1), + z: f(2), + w: f(3), + }; + rates.position = Vector3::new(f(4), f(5), f(6)); + rates.basis = Basis3 { + columns: [ + [f(16), f(17), f(18)], + [f(20), f(21), f(22)], + [f(24), f(25), f(26)], + ], + }; +} + +fn rates_transform(rates: RetailBodyRates) -> RetailAffineTransform { + RetailAffineTransform { + basis: rates.basis, + translation: rates.position, + } +} + +fn body_pose_words(rates: RetailBodyRates) -> [u32; 44] { + let mut words = [0; 44]; + let q = rates.orientation; + words[..4].copy_from_slice(&[q.x, q.y, q.z, q.w].map(f32::to_bits)); + words[4..7] + .copy_from_slice(&[rates.position.x, rates.position.y, rates.position.z].map(f32::to_bits)); + for (axis, column) in rates.basis.columns.into_iter().enumerate() { + words[16 + axis * 4..19 + axis * 4].copy_from_slice(&column.map(f32::to_bits)); + } + words +} + +fn inertia_words(inertia: RetailInertiaDynamics) -> [u32; 10] { + [ + inertia.inverse_tensor.x, + inertia.inverse_tensor.y, + inertia.inverse_tensor.z, + 0.0, + inertia.inverse_mass, + inertia.spherical, + inertia.maximum_linear_velocity, + inertia.maximum_angular_velocity, + inertia.linear_drag, + inertia.angular_drag, + ] + .map(f32::to_bits) +} + +fn mass_frame_words(frame: RetailLocalMassFrame) -> PoseMatrix { + pose_words(RetailAffineTransform { + basis: frame.basis, + translation: frame.translation, + }) +} + +fn pose_words(transform: RetailAffineTransform) -> PoseMatrix { + let mut words = [0; 16]; + for (axis, column) in transform.basis.columns.into_iter().enumerate() { + words[axis * 4..axis * 4 + 3].copy_from_slice(&column.map(f32::to_bits)); + } + let t = transform.translation; + words[12..15].copy_from_slice(&[t.x, t.y, t.z].map(f32::to_bits)); + words +} + +fn transform_from_words(words: PoseMatrix) -> RetailAffineTransform { + let f = |i| f32::from_bits(words[i]); + RetailAffineTransform { + basis: Basis3 { + columns: [[f(0), f(1), f(2)], [f(4), f(5), f(6)], [f(8), f(9), f(10)]], + }, + translation: Vector3::new(f(12), f(13), f(14)), + } +} + +#[cfg(test)] +#[path = "tests/board_runtime.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_step.rs b/crates/skate-core/src/physics/board_step.rs new file mode 100644 index 00000000..f61023da --- /dev/null +++ b/crates/skate-core/src/physics/board_step.rs @@ -0,0 +1,320 @@ +//! The connected-board path through TU3's simulation frame. +//! +//! Sources: simulation_job_setup_resolution.json (82DC35E0), +//! drive_solver_handoff_resolution.json (82DC3098 / 82AE27D0 / 82AE6590). +//! This owns compilation, shared reactions and integration. Collision queries +//! and ground/action-state force production have separate owners. +use crate::math::Vector3; + +use super::{ + assembly::{BoardConstraints, BoardHook, BodySnapshot, prepare_drive_frames}, + board::{BODY_COUNT, BodyId}, + contact::{RetailContactBodyState, RetailContactInput, generate_contact}, + contact_feedback::{BoardContactReport, board_reports}, + contact_solver::{RetailContactJacobian, build_contact_jacobian}, + drive_frames::RetailAffineTransform, + drive_parameters::RetailDriveDynamics, + drive_solver::RetailDriveRows, + force_queue::BoardForceQueue, + point_force::RetailForceAccumulator, + rigid_body::{ + RetailReactionCorrections, RetailSimulationStep, integrate_body_rates, + pack_world_inverse_inertia, + }, + solver::{JointConstraint, solve_constraints}, +}; + +#[cfg(test)] +const WORLD_REACTION: usize = BODY_COUNT + 1; +#[cfg(test)] +const REACTION_COUNT: usize = BODY_COUNT + 2; +///Board parts0..6 and their separate hook7 precede the physical skater. +pub const ATTACHED_REACTION_BASE: usize = BODY_COUNT + 1; + +///Other assemblies participating in this SAME solve. References point to the +///actual bodies owned by the physical skeleton and its hooks. The caller builds +///their rows after publishing this tick's physical drive targets and forces. +pub struct AttachedStep<'a> { + pub bodies: Vec<&'a mut BodySnapshot>, + pub contacts: &'a mut [RetailContactJacobian], + pub joints: &'a mut [JointConstraint], + pub drives: &'a mut [RetailDriveRows], +} +impl AttachedStep<'_> { + pub fn world_reaction(&self) -> usize { + ATTACHED_REACTION_BASE + self.bodies.len() + } +} + +/// Narrow-phase output, in the collision provider's original A/B order. +/// Materials must already be combined by `combine_contact_materials`. +#[derive(Clone, Copy, Debug)] +pub struct BoardCollision { + pub body_a: CollisionBody, + pub body_b: CollisionBody, + pub contact: RetailContactInput, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CollisionBody { + Board(BodyId), + /// Index in AttachedStep::bodies, referencing the actual physical body. + Attached(usize), + StaticWorld, +} + +impl CollisionBody { + pub fn contact_id(self) -> u32 { + match self { + Self::Board(id) => id.index() as u32, + Self::Attached(index) => u32::try_from(ATTACHED_REACTION_BASE + index) + .expect("attached contact body index exceeds host storage"), + Self::StaticWorld => u32::MAX, + } + } + + pub fn from_contact_id(id: u32) -> Self { + if id == u32::MAX { + Self::StaticWorld + } else if id < BODY_COUNT as u32 { + Self::Board(BodyId::ORDER[id as usize]) + } else { + assert!(id >= ATTACHED_REACTION_BASE as u32, "board target has no collision volume"); + Self::Attached(id as usize - ATTACHED_REACTION_BASE) + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct BoardStepSettings { + pub simulation: RetailSimulationStep, + /// Loaded from Simulation+176 by 82DC3098. Not a fixed solver constant. + pub iterations: u32, + pub base_truck_transforms: [RetailAffineTransform; 2], + pub truck_dynamics: RetailDriveDynamics, + pub force_point_y_offset: f32, +} + +/// Reuses the contact workspace between ticks. Seven board bodies and the +/// separate hook share solver reactions. This does not assign islands or implement the +/// native activation partition for multiple disconnected assemblies. +#[derive(Default)] +pub struct BoardStep { + contacts: Vec, + reports: Vec, + reactions: Vec, +} + +impl BoardStep { + pub fn contact_reports(&self) -> &[BoardContactReport] { + &self.reports + } + pub fn solved_contacts(&self) -> &[RetailContactJacobian] { + &self.contacts + } + /// The caller supplies current poses, processed steering targets, queued + /// forces and narrow-phase contacts. It owns the separate force-queue reset + /// in GeneralUpdate (82C02360); consuming forces does not clear the queue. + pub fn advance( + &mut self, + bodies: &mut [BodySnapshot; BODY_COUNT], + hook: &mut BoardHook, + forces: &BoardForceQueue, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + ) { + self.advance_attached( + bodies, + hook, + forces, + collisions, + truck_targets, + settings, + AttachedStep { + bodies: Vec::new(), + contacts: &mut [], + joints: &mut [], + drives: &mut [], + }, + ); + } + + pub fn advance_attached( + &mut self, + bodies: &mut [BodySnapshot; BODY_COUNT], + hook: &mut BoardHook, + forces: &BoardForceQueue, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + mut attached: AttachedStep<'_>, + ) { + // StartFrame writes both timing fields (82DC2FB8). Use one dt for row + // construction and integration, and derive the reciprocal from it. + let mut simulation = settings.simulation; + assert!(simulation.time_step.is_finite() && simulation.time_step > 0.0); + simulation.frequency = 1.0 / simulation.time_step; + for collision in collisions { + assert_ne!(collision.body_a, collision.body_b); + } + + apply_deck_forces(bodies, forces, settings.force_point_y_offset); + let frames = prepare_drive_frames(settings.base_truck_transforms, truck_targets, hook); + let world_reaction = attached.world_reaction(); + self.reactions + .resize(world_reaction + 1, RetailReactionCorrections::default()); + self.reactions.fill(RetailReactionCorrections::default()); + self.contacts.clear(); + self.reports.clear(); + if bodies + .iter() + .chain(core::iter::once(&hook.body)) + .chain(attached.bodies.iter().map(|body| &**body)) + .all(|body| body.state_flags & 4 == 0) + { + return; + } + for collision in collisions { + // Copy force/torque workspaces AFTER force accumulation. Otherwise + // ContactBatchBuild cannot oppose this frame's applied forces. + let contact = generate_contact( + collision.contact, + contact_body(collision.body_a, bodies, &attached.bodies, world_reaction), + contact_body(collision.body_b, bodies, &attached.bodies, world_reaction), + ); + self.contacts + .push(build_contact_jacobian(contact, simulation.time_step)); + } + let mut constraints = BoardConstraints::build( + bodies, + hook, + frames, + settings.truck_dynamics, + simulation.time_step, + ); + let board_contacts = self.contacts.len(); + let board_joints = constraints.joints.len(); + let board_drives = constraints.drives.len(); + self.contacts.extend_from_slice(attached.contacts); + constraints.joints.extend_from_slice(attached.joints); + constraints.drives.extend_from_slice(attached.drives); + solve_constraints( + &mut self.contacts, + &mut constraints.joints, + &mut constraints.drives, + &mut self.reactions, + settings.iterations, + ); + // Every solver family finishes before ANY body integrates. The native + // job tree places BatchIntegrator after all island solver jobs. + for (body, reaction) in bodies + .iter_mut() + .chain(core::iter::once(&mut hook.body)) + .chain(attached.bodies.iter_mut().map(|body| &mut **body)) + .zip(&mut self.reactions) + { + if body.state_flags & 4 != 0 { + body.rates = + integrate_body_rates(body.rates, body.inertia, simulation, *reaction).state; + } + // DynamicUpdate clears all four working vectors, not just the + // velocity-producing pair. Accumulated rows are rebuilt next tick. + *reaction = RetailReactionCorrections::default(); + } + // ContactSpiesJob follows integration (82DC35E0). Step_Solver3 then + // publishes reports before the physical Adjust/Output phases. + board_reports::collect( + &mut self.reports, + &self.contacts, + bodies, + simulation.frequency, + ); + //Physical output/spy consumers observe the rows from the shared solve. + attached + .contacts + .clone_from_slice(&self.contacts[board_contacts..]); + attached + .joints + .clone_from_slice(&constraints.joints[board_joints..]); + attached + .drives + .clone_from_slice(&constraints.drives[board_drives..]); + } +} + +fn apply_deck_forces( + bodies: &mut [BodySnapshot; BODY_COUNT], + forces: &BoardForceQueue, + y_offset: f32, +) { + let deck = &mut bodies[BodyId::Deck.index()]; + let result = forces.apply_to_deck( + RetailForceAccumulator { + force_acceleration: deck.rates.force_acceleration, + torque_acceleration: deck.rates.torque_acceleration, + cool_down: deck.rates.cool_down, + }, + deck.rates.basis, + deck.inertia.inverse_mass, + deck.rates.world_inverse_inertia, + y_offset, + ); + deck.rates.force_acceleration = result.force_acceleration; + deck.rates.torque_acceleration = result.torque_acceleration; + deck.rates.cool_down = result.cool_down; +} + +fn contact_body( + body: CollisionBody, + bodies: &[BodySnapshot; BODY_COUNT], + attached: &[&mut BodySnapshot], + world_reaction: usize, +) -> RetailContactBodyState { + if body == CollisionBody::StaticWorld { + return RetailContactBodyState { + contact_body_id: u32::MAX, + reaction_id: world_reaction as u32, + center_of_mass: Vector3::ZERO, + inverse_inertia_full: Vector3::ZERO, + inverse_inertia_split: Vector3::ZERO, + inverse_mass: 0.0, + state: 0, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }; + }; + let contact_id = body.contact_id(); + let body = match body { + CollisionBody::Board(id) => &bodies[id.index()], + CollisionBody::Attached(index) => &*attached[index], + CollisionBody::StaticWorld => unreachable!(), + }; + let rates = body.rates; + let inertia = pack_world_inverse_inertia(rates.world_inverse_inertia); + RetailContactBodyState { + contact_body_id: contact_id, + reaction_id: contact_id, + center_of_mass: rates.position, + inverse_inertia_full: inertia.full, + inverse_inertia_split: inertia.split, + inverse_mass: body.inertia.inverse_mass, + // The physical assemblies subscribe to post-solver feedback. Bit 8 + // requests contact observations; it does not alter dynamic eligibility. + state: body.state_flags | 8, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + kinetic_energy: rates.kinetic_energy, + cool_down: rates.cool_down, + } +} + +#[cfg(test)] +#[path = "tests/board_step.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_toolkit.rs b/crates/skate-core/src/physics/board_toolkit.rs new file mode 100644 index 00000000..f387f4eb --- /dev/null +++ b/crates/skate-core/src/physics/board_toolkit.rs @@ -0,0 +1,144 @@ +//! Skateboard::PrepareBoardToolkit82C013F0, TU3. Derived physical axes and +//! filtered normal are calculated from the same live deck used by the solver. +use super::{ + board::BodyId, board_motion_output::inverse_length_squared, board_runtime::BoardRuntime, + force_queue::total_body_mass, native_arithmetic::dot3, +}; + +#[derive(Clone, Copy, Debug)] +pub struct BoardToolkit { + pub deck: [[f32; 4]; 4], + pub effective: [[f32; 4]; 4], + pub inverse_effective: [[f32; 4]; 4], + pub side: [f32; 4], + pub up: [f32; 4], + pub forward: [f32; 4], + pub horizontal_forward: [f32; 4], + pub transverse_up: [f32; 4], + pub forward_velocity: [f32; 4], + pub travel_direction: [f32; 4], + pub filtered_normal: [f32; 4], + pub absolute_speed: f32, + pub control_sign: f32, + pub total_mass: f32, +} +impl BoardToolkit { + /// Board192 is retained by the owner and replaced by filtered_normal after + /// this call. Reset must supply the recovered retained normal, not derive + /// grounded state from board height. + pub fn from_board( + board: &BoardRuntime, + flags_2468: u32, + speed_2612: f32, + ground_normal_464: [f32; 4], + retained_normal: [f32; 4], + ) -> Self { + let part = board.part_transforms()[BodyId::Deck.index()]; + let axis = |i: usize| { + let c = part.basis.columns[i]; + [c[0], c[1], c[2], 0.0] + }; + let deck = [ + axis(0), + axis(1), + axis(2), + [ + part.translation.x, + part.translation.y, + part.translation.z, + 1.0, + ], + ]; + let masses = board.bodies().map(|body| body.inertia.inverse_mass); + Self::calculate( + deck, + &masses, + flags_2468, + speed_2612, + ground_normal_464, + retained_normal, + ) + } + pub fn calculate( + deck: [[f32; 4]; 4], + inverse_masses: &[f32], + flags: u32, + speed: f32, + normal: [f32; 4], + retained_normal: [f32; 4], + ) -> Self { + let control_sign = if flags & 0x0010_0000 != 0 { -1.0 } else { 1.0 }; + let mut effective = deck; + for axis in [0, 2] { + effective[axis] = deck[axis].map(|v| v * control_sign); + } + //82C0149C..150C transposes XYZ, clears all W lanes, and composes the + //negative translation in Z,Y,X fused order. Native inverse W is zero. + let mut inverse_effective = [[0.0; 4]; 4]; + for axis in 0..3 { + for lane in 0..3 { + inverse_effective[axis][lane] = effective[lane][axis]; + } + } + for lane in 0..4 { + let z = (0.0 - effective[3][2]) * inverse_effective[2][lane]; + let yz = (0.0 - effective[3][1]).mul_add(inverse_effective[1][lane], z); + inverse_effective[3][lane] = + (0.0 - effective[3][0]).mul_add(inverse_effective[0][lane], yz); + } + let side = effective[0]; + let up = deck[1]; + let forward = effective[2]; + let mut horizontal_forward = forward; + horizontal_forward[1] = 0.0; + let horizontal_squared = dot3(horizontal_forward, horizontal_forward); + if horizontal_squared > f32::from_bits(0x3727_c5ac) { + horizontal_forward = + horizontal_forward.map(|v| v * inverse_length_squared(horizontal_squared, 2)); + } + let parallel = dot3(up, horizontal_forward); + let rejected = std::array::from_fn(|i| up[i] - horizontal_forward[i] * parallel); + let inverse = inverse_length_squared(dot3(rejected, rejected), 2); + let transverse_up = rejected.map(|v| v * inverse); + //The bgt takes only ordered positive values; zero and NaN use -1. + let travel_sign = if speed > 0.0 { 1.0 } else { -1.0 }; + let filtered_normal = if !(dot3(retained_normal, normal) <= f32::from_bits(0x3f6f_5c29)) { + normal + } else { + let blended = std::array::from_fn(|i| { + retained_normal[i].mul_add( + f32::from_bits(0x3f66_6666), + normal[i] * f32::from_bits(0x3dcc_cccd), + ) + }); + let squared = dot3(blended, blended); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + if length > f32::from_bits(0x3586_37bd) { + blended.map(|v| v * inverse) + } else { + [0.0; 4] + } + }; + Self { + deck, + effective, + inverse_effective, + side, + up, + forward, + horizontal_forward, + transverse_up, + forward_velocity: forward.map(|v| v * speed), + travel_direction: forward.map(|v| v * travel_sign), + filtered_normal, + absolute_speed: speed * travel_sign, + control_sign, + total_mass: total_body_mass(inverse_masses), + } + } +} diff --git a/crates/skate-core/src/physics/board_world.rs b/crates/skate-core/src/physics/board_world.rs new file mode 100644 index 00000000..7aba963b --- /dev/null +++ b/crates/skate-core/src/physics/board_world.rs @@ -0,0 +1,336 @@ +//! Contacts for every board primitive against the host triangle world. +//! Rust owns geometry storage and traversal. Recovered query8277B720, +//! triangle fixup82AD3130, material combine82763078 and contact retention +//! determine the physical result, in world-triangle then moving-volume order. +pub mod query_metadata; +use crate::math::Vector3; +use query_metadata::QueryMetadata; + +use super::{ + board::BodyId, + board_runtime::BoardRuntime, + board_step::{BoardCollision, CollisionBody}, + collision::{Sphere, Triangle, WorldContactSettings}, + contact::{RetailContactInput, RetailContactMaterial, combine_contact_materials}, + triangle_query::{TriangleLineHit, triangle_segment}, + world_contact::{ + ContactBuffer, ContactPrimitive, ContactRecord, primitive_triangle_world_contacts, + triangle_from_volume, + }, +}; + +#[derive(Clone, Copy, Debug)] +pub struct WorldTriangle { + pub triangle: Triangle, + pub material: RetailContactMaterial, + pub tag: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WorldLineHit { + pub geometry: TriangleLineHit, + pub tag: u32, +} + +impl WorldTriangle { + /// Host geometry preparation, with explicit adjacency and sidedness. + /// Uses the native Volume constructor and its authored winding. Invalid + /// host geometry is rejected without substituting a repaired triangle. + pub fn from_vertices( + vertices: [Vector3; 3], + material: RetailContactMaterial, + tag: u32, + flags: u32, + edge_cosines: [f32; 3], + fatness: f32, + ) -> Option { + if vertices + .iter() + .any(|v| !v.x.is_finite() || !v.y.is_finite() || !v.z.is_finite()) + { + return None; + } + let triangle = triangle_from_volume(vertices, fatness, edge_cosines, flags); + let normal = triangle.feature.normal; + if triangle + .edge_lengths + .iter() + .any(|&v| !v.is_finite() || v <= 0.0) + || !normal.x.is_finite() + || !normal.y.is_finite() + || !normal.z.is_finite() + || normal == Vector3::ZERO + { + return None; + } + Some(Self { + triangle, + material, + tag, + }) + } +} + +/// Caller-resolved TempContactBuffer settings. Retention changes solver rows, +/// so its thresholds/capacity remain explicit instead of using tuned defaults. +#[derive(Clone, Copy, Debug)] +pub struct ContactRetentionSettings { + pub capacity: u32, + pub duplicate_distance_squared: f32, + pub deferred_reduction: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct WheelWorldSettings { + /// Sphere shared by wheel definitions in82C0AA78. + pub radius: f32, + pub material: RetailContactMaterial, + pub query: WorldContactSettings, + pub retention: ContactRetentionSettings, +} + +/// One enabled moving primitive, with its owning solver body and material. +#[derive(Clone, Copy, Debug)] +pub struct BoardWorldVolume { + pub body: CollisionBody, + pub primitive: ContactPrimitive, + pub linear_velocity: Vector3, + pub material: RetailContactMaterial, +} + +/// World geometry remains in supplied order. Each query reuses output storage; +/// the returned contacts are valid until the next mutable call. +pub struct BoardWorld { + triangles: Vec, + query_metadata: Option, + contacts: Vec, + buffer: ContactBuffer, +} + +impl BoardWorld { + pub fn new(triangles: Vec) -> Self { + Self { + triangles, + query_metadata: None, + contacts: Vec::new(), + buffer: ContactBuffer { + count: 0, + flushed: 0, + capacity: 0, + dropped: 0, + distance_squared_threshold: 0.0, + allow_flush: 1, + deferred_reduction: 0, + full: 0, + records: [[0; 64]; 50], + }, + } + } + + pub fn with_query_metadata( + triangles: Vec, + metadata: QueryMetadata, + ) -> Result { + metadata.validate(&triangles)?; + let mut world = Self::new(triangles); + world.query_metadata = Some(metadata); + Ok(world) + } + + pub fn query_metadata(&self) -> Result<&QueryMetadata, &'static str> { + self.query_metadata + .as_ref() + .ok_or("Canonical world has no authored query metadata") + } + + pub fn triangles(&self) -> &[WorldTriangle] { + &self.triangles + } + + /// Board wheel segments use zero query radius; rounded world triangles + /// still select the native swept branch through their own fatness. + pub fn query_thin_line( + &self, + start: Vector3, + end: Vector3, + ) -> Result, &'static str> { + self.query_swept_line(start, end, 0.0) + } + + /// Ordered nearest-hit traversal for wheel and camera queries. The host + /// owns traversal; each candidate uses the complete TU3 triangle dispatcher. + pub fn query_swept_line( + &self, + start: Vector3, + end: Vector3, + radius: f32, + ) -> Result, &'static str> { + if !radius.is_finite() || radius < 0.0 { + return Err("line query radius must be finite and nonnegative"); + } + let direction = Vector3::new(end.x - start.x, end.y - start.y, end.z - start.z); + let mut nearest: Option = None; + for entry in &self.triangles { + let mut geometry = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + if triangle_segment( + &mut geometry, + start, + direction, + entry.triangle.vertices, + radius, + entry.triangle.fatness, + ) { + if nearest + .as_ref() + .is_none_or(|hit| geometry.fraction < hit.geometry.fraction) + { + nearest = Some(WorldLineHit { + geometry, + tag: entry.tag, + }); + } + } + } + Ok(nearest) + } + + /// Sphere-only convenience for callers querying wheels. The game uses + /// query_primitives with all enabled deck/truck/wheel children. + pub fn query( + &mut self, + board: &BoardRuntime, + settings: WheelWorldSettings, + ) -> &[BoardCollision] { + let poses = board.part_transforms(); + let volumes: Vec<_> = BodyId::ORDER[..4] + .iter() + .filter_map(|&id| { + let body = &board.bodies()[id.index()]; + (body.state_flags != 1).then_some(BoardWorldVolume { + body: CollisionBody::Board(id), + primitive: ContactPrimitive::Sphere(Sphere { + center: poses[id.index()].translation, + radius: settings.radius, + }), + linear_velocity: body.rates.linear_velocity, + material: settings.material, + }) + }) + .collect(); + self.query_primitives(&volumes, settings.query, settings.retention) + } + + /// World-space shapes come from the live part poses and authored children. + /// The host's small level visits every pair, preserving source query order + /// without a broad-phase bound that could drop valid predictive contacts. + pub fn query_primitives( + &mut self, + volumes: &[BoardWorldVolume], + query: WorldContactSettings, + retention: ContactRetentionSettings, + ) -> &[BoardCollision] { + self.contacts.clear(); + self.buffer.count = 0; + self.buffer.flushed = 0; + self.buffer.dropped = 0; + self.buffer.full = 0; + self.buffer.capacity = retention.capacity; + self.buffer.distance_squared_threshold = retention.duplicate_distance_squared; + self.buffer.deferred_reduction = u8::from(retention.deferred_reduction); + let output = &mut self.contacts; + let mut publish = |records: &[ContactRecord]| { + output.extend(records.iter().map(collision_from_record)); + }; + for entry in &self.triangles { + for volume in volumes { + let Some(manifold) = primitive_triangle_world_contacts( + volume.primitive, + entry.triangle, + volume.linear_velocity, + query, + ) else { + continue; + }; + let material = combine_contact_materials(volume.material, entry.material); + for pair in &manifold.points[..manifold.count] { + let contact = RetailContactInput { + position_on_a: pair.a, + position_on_b: pair.b, + normal: manifold.normal, + restitution: material.restitution, + static_friction: material.static_friction, + dynamic_friction: material.dynamic_friction, + tag: entry.tag, + }; + let Some(slot) = self.buffer.allocate(&mut publish) else { + self.buffer.flush(&mut publish); + return &self.contacts; + }; + self.buffer.records[slot] = retention_record(volume.body, contact); + //8277C23C removes the most recent record on rejection. + if self.buffer.last_is_duplicate() { + self.buffer.count -= 1; + } + } + } + } + self.buffer.flush(&mut publish); + &self.contacts + } + + pub fn contacts(&self) -> &[BoardCollision] { + &self.contacts + } + + pub fn dropped_contacts(&self) -> u32 { + self.buffer.dropped + } +} + +/// Retention reads only contact header geometry, body IDs and material/tag. +/// BoardStep builds the actual body workspaces after this frame's force queue +/// has been applied, preventing stale acceleration copies in solver contacts. +fn retention_record(id: CollisionBody, contact: RetailContactInput) -> ContactRecord { + let mut record = [0; 64]; + for (offset, v) in [ + (0, contact.position_on_a), + (4, contact.position_on_b), + (8, contact.normal), + ] { + record[offset..offset + 3].copy_from_slice(&[v.x, v.y, v.z].map(f32::to_bits)); + } + record[3] = id.contact_id(); + record[7] = u32::MAX; + record[11] = contact.restitution.to_bits(); + record[15] = contact.static_friction.to_bits(); + record[19] = contact.dynamic_friction.to_bits(); + record[23] = contact.tag; + record +} + +fn collision_from_record(record: &ContactRecord) -> BoardCollision { + let f = |i| f32::from_bits(record[i]); + let vector = |i| Vector3::new(f(i), f(i + 1), f(i + 2)); + BoardCollision { + body_a: CollisionBody::from_contact_id(record[3]), + body_b: CollisionBody::StaticWorld, + contact: RetailContactInput { + position_on_a: vector(0), + position_on_b: vector(4), + normal: vector(8), + restitution: f(11), + static_friction: f(15), + dynamic_friction: f(19), + tag: record[23], + }, + } +} + +#[cfg(test)] +#[path = "tests/board_world.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_world/query_metadata.rs b/crates/skate-core/src/physics/board_world/query_metadata.rs new file mode 100644 index 00000000..e307f432 --- /dev/null +++ b/crates/skate-core/src/physics/board_world/query_metadata.rs @@ -0,0 +1,139 @@ +//! Canonical authored scene metadata; independent of rendering tags and actors. +use super::WorldTriangle; +use crate::{math::Vector3, physics::drive_frames::RetailAffineTransform}; +use std::ops::Range; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QueryPool { + Ground, + Island, + Conditional, +} + +#[derive(Clone, Copy, Debug)] +pub struct Bounds { + pub min: Vector3, + pub max: Vector3, +} +impl Bounds { + pub fn from_points(points: impl IntoIterator) -> Option { + let mut points = points.into_iter(); + let first = points.next()?; + if !finite(first) { + return None; + } + let mut bounds = Self { + min: first, + max: first, + }; + for p in points { + if !finite(p) { + return None; + } + bounds.min = Vector3::new( + bounds.min.x.min(p.x), + bounds.min.y.min(p.y), + bounds.min.z.min(p.z), + ); + bounds.max = Vector3::new( + bounds.max.x.max(p.x), + bounds.max.y.max(p.y), + bounds.max.z.max(p.z), + ); + } + Some(bounds) + } + fn contains(self, p: Vector3) -> bool { + finite(p) + && p.x >= self.min.x + && p.x <= self.max.x + && p.y >= self.min.y + && p.y <= self.max.y + && p.z >= self.min.z + && p.z <= self.max.z + } + fn valid(self) -> bool { + finite(self.min) + && finite(self.max) + && self.min.x <= self.max.x + && self.min.y <= self.max.y + && self.min.z <= self.max.z + } +} + +#[derive(Clone, Copy, Debug)] +pub struct EdgeSegment { + pub start: Vector3, + pub end: Vector3, + pub local_bounds: Bounds, +} + +#[derive(Clone, Debug)] +pub struct QueryMesh { + pub triangle_range: Range, + pub local_to_world: RetailAffineTransform, + pub world_to_local: RetailAffineTransform, + pub local_bounds: Bounds, + pub matching_group: i32, + pub pool: QueryPool, +} + +#[derive(Clone, Debug)] +pub struct QueryMetadata { + /// Exact authored uint16 codes, in canonical triangle order. Low7 material, + ///next5 category, upper4 flags; never synthesized from WorldTriangle::tag. + pub packed_surfaces: Vec, + pub meshes: Vec, + /// Explicit level query edges; triangulation diagonals are not query edges. + pub static_edges: Vec, + pub island_flags: u32, +} +impl QueryMetadata { + pub(super) fn validate(&self, triangles: &[WorldTriangle]) -> Result<(), &'static str> { + if self.packed_surfaces.len() != triangles.len() { + return Err("Query surface count must equal canonical triangle count"); + } + let mut next = 0; + for mesh in &self.meshes { + if mesh.triangle_range.start != next + || mesh.triangle_range.end <= next + || mesh.triangle_range.end > triangles.len() + { + return Err("Query meshes must partition canonical triangles in supplied order"); + } + //BoardWorld collision currently owns world-space static triangles. + //Reject an unsupported scene transform rather than move query-only geometry. + if mesh.local_to_world != RetailAffineTransform::IDENTITY + || mesh.world_to_local != RetailAffineTransform::IDENTITY + { + return Err("BoardWorld static query meshes require identity transforms"); + } + if !mesh.local_bounds.valid() { + return Err("Invalid query mesh bounds"); + } + if triangles[mesh.triangle_range.clone()] + .iter() + .flat_map(|entry| entry.triangle.vertices) + .any(|p| !mesh.local_bounds.contains(p)) + { + return Err("Query mesh bounds exclude canonical triangle geometry"); + } + next = mesh.triangle_range.end; + } + if next != triangles.len() { + return Err("Query metadata leaves canonical triangles unassigned"); + } + for edge in &self.static_edges { + if !edge.local_bounds.valid() + || !edge.local_bounds.contains(edge.start) + || !edge.local_bounds.contains(edge.end) + { + return Err("Invalid authored query edge"); + } + } + Ok(()) + } +} +fn finite(p: Vector3) -> bool { + p.x.is_finite() && p.y.is_finite() && p.z.is_finite() +} diff --git a/crates/skate-core/src/physics/centre_of_mass_filter.rs b/crates/skate-core/src/physics/centre_of_mass_filter.rs new file mode 100644 index 00000000..01878704 --- /dev/null +++ b/crates/skate-core/src/physics/centre_of_mass_filter.rs @@ -0,0 +1,83 @@ +//! Physical-output COM conditioning: original TU3 82DE56B0/82DE5978. +//! Advances once after physical Skeleton publishes position64 and velocity16. +use super::native_arithmetic::{dot3, reciprocal_estimate}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CentreOfMassOutput { + /// PhysOutSystemReckoning0, used by animation and camera consumers. + pub velocity: [f32; 4], + /// PhysOutSystemReckoning32. + pub acceleration: [f32; 4], + /// PhysOutSystemReckoning80. + pub position: [f32; 4], +} + +#[derive(Debug)] +pub struct CentreOfMassFilter { + velocity: [f32; 4], + position: [f32; 4], + position_velocity: [f32; 4], + accumulated_error: [f32; 4], + position_valid: bool, +} + +impl Default for CentreOfMassFilter { + fn default() -> Self { + // Original Reset82DE5588 clears vectors16/96/112/128 and byte191. + Self { + velocity: [0.0; 4], + position: [0.0; 4], + position_velocity: [0.0; 4], + accumulated_error: [0.0; 4], + position_valid: false, + } + } +} + +impl CentreOfMassFilter { + pub fn reset(&mut self) { + *self = Self::default(); + } + + pub fn update(&mut self, position: [f32; 4], velocity: [f32; 4]) -> CentreOfMassOutput { + let dt = f32::from_bits(0x3c888889); + let old_velocity = self.velocity; + self.velocity = std::array::from_fn(|i| old_velocity[i].mul_add(0.75, velocity[i] * 0.25)); + let mut inverse_dt = reciprocal_estimate(dt); + inverse_dt = inverse_dt.mul_add((-dt).mul_add(inverse_dt, 1.0), inverse_dt); + inverse_dt = inverse_dt.mul_add((-dt).mul_add(inverse_dt, 1.0), inverse_dt); + let acceleration = + std::array::from_fn(|i| (self.velocity[i] - old_velocity[i]) * inverse_dt); + if !self.position_valid { + self.position = position; + self.position_velocity = [0.0; 4]; + self.position_valid = true; + } else { + let coefficient: [f32; 4] = [0.05, 0.03, 0.05, 0.0]; + let unity: [f32; 4] = [1.0, 1.0, 1.0, 0.0]; + self.position_velocity = std::array::from_fn(|i| { + (unity[i] - coefficient[i]) + .mul_add(self.position_velocity[i], coefficient[i] * velocity[i]) + }); + let predicted: [f32; 4] = + std::array::from_fn(|i| self.position_velocity[i].mul_add(dt, self.position[i])); + self.position = std::array::from_fn(|i| { + (unity[i] - coefficient[i]).mul_add(predicted[i], coefficient[i] * position[i]) + }); + let error = std::array::from_fn(|i| position[i] - self.position[i]); + if dot3(error, self.accumulated_error) > 0.0 { + self.position = std::array::from_fn(|i| { + self.accumulated_error[i].mul_add(0.08, self.position[i]) + }); + } + self.accumulated_error = std::array::from_fn(|i| { + self.accumulated_error[i].mul_add(0.95, (position[i] - self.position[i]) * 0.05) + }); + } + CentreOfMassOutput { + velocity: self.velocity, + acceleration, + position: self.position, + } + } +} diff --git a/crates/skate-core/src/physics/collision/arithmetic.rs b/crates/skate-core/src/physics/collision/arithmetic.rs new file mode 100644 index 00000000..ad28763c --- /dev/null +++ b/crates/skate-core/src/physics/collision/arithmetic.rs @@ -0,0 +1,39 @@ +use crate::math::Vector3 as V; +use crate::physics::reciprocal_sqrt::estimate; + +pub(super) fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} +pub(super) fn cross(a: V, b: V) -> V { + V::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +pub(super) fn scale(a: V, b: f32) -> V { + V::new(a.x * b, a.y * b, a.z * b) +} +pub(super) fn neg(a: V) -> V { + V::new(-a.x, -a.y, -a.z) +} +pub(super) fn sub(a: V, b: V) -> V { + V::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn madd(a: V, b: f32, c: V) -> V { + V::new( + a.x.mul_add(b, c.x), + a.y.mul_add(b, c.y), + a.z.mul_add(b, c.z), + ) +} +pub(super) fn inverse_length_squared(value: f32, refinements: u32) -> f32 { + let mut r = estimate(value); + for _ in 0..refinements { + r = (r * 0.5).mul_add((-value).mul_add(r * r, 1.0), r); + } + r +} +pub(super) fn normalize(a: V, refinements: u32) -> V { + scale(a, inverse_length_squared(dot(a, a), refinements)) +} diff --git a/crates/skate-core/src/physics/collision/edge.rs b/crates/skate-core/src/physics/collision/edge.rs new file mode 100644 index 00000000..79f6e12b --- /dev/null +++ b/crates/skate-core/src/physics/collision/edge.rs @@ -0,0 +1,21 @@ +//! TU3 EdgeCosTest / EdgeCosTestFull, 82AD2860 / 82AD2928. +use super::arithmetic::*; +use crate::math::Vector3; + +pub(super) fn edge_cos_test( + edge: Vector3, + face: Vector3, + normal: Vector3, + cosine: f32, + convex: bool, + two_sided: bool, +) -> bool { + if dot(normal, cross(edge, face)) < 0.0 { + return true; + } + if !convex && !two_sided { + return false; + } + let projected = normalize(sub(normal, scale(edge, dot(normal, edge))), 2); + dot(projected, if convex { face } else { neg(face) }) >= cosine +} diff --git a/crates/skate-core/src/physics/collision/fixup.rs b/crates/skate-core/src/physics/collision/fixup.rs new file mode 100644 index 00000000..8c8eac97 --- /dev/null +++ b/crates/skate-core/src/physics/collision/fixup.rs @@ -0,0 +1,238 @@ +//! Complete TU3 FixUpTriangleResult (82AD3130), including 82AD2CB0, +//! 82AD2E00 and contact-point reprojection (82AD2BF0). +use super::{arithmetic::*, edge::edge_cos_test}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TriangleRegion { + Face, + Edge0, + Edge1, + Vertex1, + Edge2, + Vertex0, + Vertex2, +} + +/// Native GPTriangle fields +16, +64..96, +148 and +152..160. +/// Direction order is native +64, +80, +96. Adjacency cosine indices +/// refer to the reversed direction order; these arrays are not interchangeable. +#[derive(Clone, Copy, Debug)] +pub struct TriangleFeature { + pub normal: Vector3, + pub edges: [Vector3; 3], + pub flags: u32, + pub edge_cosines: [f32; 3], +} +impl TriangleFeature { + pub const ONE_SIDED: u32 = 0x10; + pub const USE_EDGE_COSINES: u32 = 0x100; + pub const fn edge_convex(self, edge: usize) -> bool { + self.flags & (0x20 << edge) != 0 + } + pub const fn vertex_disabled(self, vertex: usize) -> bool { + self.flags & (0x200 << vertex) != 0 + } + + /// ComputeTriangleFeatureTypeFromNormal (82AD2A50), taking the normalized + /// in-plane direction produced by FixUpTriangleResult. + pub fn classify(self, direction: Vector3) -> TriangleRegion { + use TriangleRegion::*; + let [a, b, c] = self.edges.map(|e| dot(direction, e)); + let t = f32::from_bits(0x3D4C_CCCD); + if a > t && b < -t { + Vertex2 + } else if b > t && c < -t { + Vertex1 + } else if c > t && a < -t { + Vertex0 + } else if a > -c && -b >= a { + Edge2 + } else if b > -a && -c >= b { + Edge1 + } else if c > -b && -a >= c { + Edge0 + } else { + Face + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactPair { + pub a: Vector3, + pub b: Vector3, +} + +#[derive(Clone, Copy, Debug)] +pub struct TriangleFixup { + /// Whether the triangle is the other side of the pair. This flips the + /// input for classification and chooses which contact points to project. + pub reverse: bool, + pub edge_cos_bend_normal_threshold: f32, + pub convexity_epsilon: f32, + /// Native r9 flag: relaxes disabled vertices and near-flat edge handling. + pub is_object: bool, +} + +/// Returns native acceptance. A successful fixup can change both the normal +/// and one side of every contact pair. Pair count is capped at native capacity. +pub fn fix_up_triangle( + triangle: TriangleFeature, + normal: &mut Vector3, + contacts: &mut [ContactPair], + settings: TriangleFixup, +) -> bool { + assert!(contacts.len() <= 16); + let toward = if settings.reverse { + neg(*normal) + } else { + *normal + }; + let projection = dot(toward, triangle.normal); + let face_tolerance = f32::from_bits(0x3F7F_F62B); + let region = if projection.abs() < face_tolerance { + triangle.classify(normalize( + sub(toward, scale(triangle.normal, projection)), + 2, + )) + } else { + TriangleRegion::Face + }; + let one_sided = triangle.flags & TriangleFeature::ONE_SIDED != 0; + use TriangleRegion::*; + let edge = match region { + Edge0 => Some(0), + Edge1 => Some(1), + Edge2 => Some(2), + _ => None, + }; + let vertex = match region { + Vertex0 => Some(0), + Vertex1 => Some(1), + Vertex2 => Some(2), + _ => None, + }; + if triangle.flags & TriangleFeature::USE_EDGE_COSINES == 0 { + if region == Face { + return !one_sided || projection > 0.0; + } + if one_sided { + if projection > face_tolerance { + return true; + } + if projection < 0.0 { + return false; + } + } else if projection.abs() > face_tolerance { + return true; + } + return edge + .map(|i| triangle.edge_convex(i)) + .unwrap_or_else(|| !triangle.vertex_disabled(vertex.unwrap())); + } + if region == Face { + return !one_sided || projection > 0.0; + } + if let Some(i) = edge { + if !one_sided { + return (if triangle.edge_convex(i) { + projection + } else { + -projection + }) >= triangle.edge_cosines[i]; + } + return one_sided_edge(triangle, i, projection, normal, contacts, settings); + } + let vertex = vertex.unwrap(); + // Native vertex adjacency: (edge2,edge0), (edge0,edge1), (edge1,edge2). + let first = (vertex + 2) % 3; + let second = vertex; + if !one_sided { + return !triangle.vertex_disabled(vertex) + && [first, second].into_iter().all(|i| { + edge_cos_test( + neg(triangle.edges[2 - i]), + triangle.normal, + toward, + triangle.edge_cosines[i], + triangle.edge_convex(i), + true, + ) + }); + } + let disabled = triangle.vertex_disabled(vertex) && !settings.is_object; + if projection > 0.0 && disabled { + // TU3-only recovery for a disabled vertex bordering exactly one convex + // edge. Skate 2's corresponding routine does not have this path. + let edge = match (triangle.edge_convex(first), triangle.edge_convex(second)) { + (true, false) => second, + (false, true) => first, + _ => return false, + }; + let cosine = triangle.edge_cosines[edge]; + if !(cosine <= f32::from_bits(0x3F78_51EC) && cosine > f32::from_bits(0x3A83_126F)) { + return false; + } + let direction = triangle.edges[2 - if edge == second { first } else { second }]; + let bent = normalize(cross(cross(direction, toward), direction), 1); + bend(normal, contacts, bent, settings.reverse); + return true; + } + if disabled { + return false; + } + [first, second].into_iter().all(|i| { + edge_cos_test( + neg(triangle.edges[2 - i]), + triangle.normal, + toward, + triangle.edge_cosines[i], + triangle.edge_convex(i), + false, + ) + }) +} + +fn one_sided_edge( + t: TriangleFeature, + edge: usize, + projection: f32, + normal: &mut Vector3, + contacts: &mut [ContactPair], + s: TriangleFixup, +) -> bool { + let cosine = t.edge_cosines[edge]; + // Scalar fadds, not an epsilon comparison on a vector dot product. + if t.edge_convex(edge) + || (s.is_object && ((cosine - 1.0) as f64).abs() < f64::from_bits(0x3EE4_F8B5_88E3_68F1)) + { + return projection + s.convexity_epsilon >= cosine; + } + if cosine > s.edge_cos_bend_normal_threshold || cosine <= f32::from_bits(0x3A83_126F) { + return false; + } + let squared = 1.0 - cosine * cosine; + let threshold = if squared == 0.0 { + 0.0 + } else { + squared * inverse_length_squared(squared, 2) + }; + if projection > threshold { + bend(normal, contacts, t.normal, s.reverse); + true + } else { + false + } +} + +fn bend(normal: &mut Vector3, contacts: &mut [ContactPair], direction: Vector3, reverse: bool) { + *normal = if reverse { neg(direction) } else { direction }; + for pair in contacts { + if reverse { + pair.b = madd(direction, dot(sub(pair.b, pair.a), direction), pair.a); + } else { + pair.a = madd(direction, dot(sub(pair.a, pair.b), direction), pair.b); + } + } +} diff --git a/crates/skate-core/src/physics/collision/mod.rs b/crates/skate-core/src/physics/collision/mod.rs new file mode 100644 index 00000000..03d30f27 --- /dev/null +++ b/crates/skate-core/src/physics/collision/mod.rs @@ -0,0 +1,13 @@ +//! TU3 feature-prism collision. Geometry and triangle adjacency come from the +//! collision mesh, independently of rendering and material response. +mod arithmetic; +mod edge; +mod fixup; +mod sphere_triangle; +mod world_contact; + +pub use fixup::{ContactPair, TriangleFeature, TriangleFixup, TriangleRegion, fix_up_triangle}; +pub use sphere_triangle::{Sphere, SphereTrianglePair, Triangle, intersect_sphere_triangle}; +pub use world_contact::{ + WorldContact, WorldContactSettings, sphere_triangle_world_contact, world_separation_limit, +}; diff --git a/crates/skate-core/src/physics/collision/sphere_triangle.rs b/crates/skate-core/src/physics/collision/sphere_triangle.rs new file mode 100644 index 00000000..6bbad434 --- /dev/null +++ b/crates/skate-core/src/physics/collision/sphere_triangle.rs @@ -0,0 +1,249 @@ +//! Sphere/triangle execution path of PrimitivePairIntersect (82AD3CD8). +//! Both primitive orders, two-sided geometry, finite edges, vertices, fatness, +//! separating-direction refinement and complete triangle fixups are preserved. +use super::{ContactPair, TriangleFeature, TriangleFixup, arithmetic::*, fix_up_triangle}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug)] +pub struct Sphere { + pub center: Vector3, + pub radius: f32, +} + +/// A world-space GPTriangle. Normal, unit directions and lengths are produced +/// by the native collision-geometry loader; rendering normals are not suitable. +#[derive(Clone, Copy, Debug)] +pub struct Triangle { + pub vertices: [Vector3; 3], + pub feature: TriangleFeature, + pub edge_lengths: [f32; 3], + pub fatness: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SphereTrianglePair { + pub separating_direction: Vector3, + pub separating_distance: f32, + /// Pair normal points from primitive A toward primitive B. The downstream + /// physics contact producer owns its conversion to solver normal convention. + pub normal: Vector3, + pub points: ContactPair, + pub distance: f32, + pub triangle_region: u32, +} + +/// Native pair query on already-built GP primitives. `triangle_is_a` selects +/// primitive order. An accepted candidate can have positive distance: filtering +/// candidate contacts belongs to the caller, just as in TU3. +pub fn intersect_sphere_triangle( + sphere: Sphere, + triangle: Triangle, + minimum_separating_distance: f32, + triangle_is_a: bool, +) -> Option { + let prism = build_prism( + sphere, + triangle, + minimum_separating_distance, + triangle_is_a, + false, + )?; + let SphereTrianglePrism { + separating_direction, + separating_distance, + mut normal, + raw_points, + triangle_region, + } = prism; + let (fat_a, fat_b) = if triangle_is_a { + (triangle.fatness, sphere.radius) + } else { + (sphere.radius, triangle.fatness) + }; + let points = ContactPair { + a: madd(normal, fat_a, raw_points.a), + b: sub(raw_points.b, scale(normal, fat_b)), + }; + let distance = dot(sub(points.b, points.a), normal); + // 82AD40C0..4308 publishes points/fatness/distances BEFORE fixup. The + // fixup's reprojection edits its local prism; TU3 does not recopy those + // points into this result. Only the result normal is shared with fixup. + let settings = TriangleFixup { + reverse: !triangle_is_a, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + }; + if !fix_up_triangle(triangle.feature, &mut normal, &mut [raw_points], settings) { + return None; + } + Some(SphereTrianglePair { + separating_direction, + separating_distance, + normal, + points, + distance, + triangle_region, + }) +} + +pub(super) struct SphereTrianglePrism { + pub separating_direction: Vector3, + pub separating_distance: f32, + pub normal: Vector3, + pub raw_points: ContactPair, + pub triangle_region: u32, +} + +/// Shared SAT/features/prism work. The two native callers have different +/// single-point refinement gates and publish their results in different order. +pub(super) fn build_prism( + sphere: Sphere, + triangle: Triangle, + minimum_separating_distance: f32, + triangle_is_a: bool, + contact_query: bool, +) -> Option { + let axis = triangle.feature.normal; + let (a, b) = interval_gaps(sphere, triangle, axis, triangle_is_a); + // 82ACF070 chooses the second gap on ties. + let (separating_direction, separating_distance) = + if a > b { (neg(axis), a) } else { (axis, b) }; + let (fat_a, fat_b) = if triangle_is_a { + (triangle.fatness, sphere.radius) + } else { + (sphere.radius, triangle.fatness) + }; + if separating_distance > (fat_b + minimum_separating_distance) + fat_a { + return None; + } + let (face_point, triangle_region) = + point_face(sphere.center, triangle, separating_direction, triangle_is_a); + let raw_points = if triangle_is_a { + ContactPair { + a: face_point, + b: sphere.center, + } + } else { + ContactPair { + a: sphere.center, + b: face_point, + } + }; + let delta = sub(raw_points.b, raw_points.a); + let squared = dot(delta, delta); + let inverse_length = inverse_length_squared(squared, 2); + let gate = if contact_query { + // 8277B830..B8A8 tests the refined LENGTH, whereas 82AD3CD8 tests + // squared length. Both compare against the literal 821408EC. + if squared == 0.0 { + 0.0 + } else { + squared * inverse_length + } + } else { + squared + }; + let mut normal = separating_direction; + if gate > f32::from_bits(0x3400_0000) { + let candidate = scale(delta, inverse_length); + let (a, b) = interval_gaps(sphere, triangle, candidate, triangle_is_a); + let maximum = if a > b { a } else { b }; + if separating_distance <= maximum { + normal = if a > b { neg(candidate) } else { candidate }; + } + } + Some(SphereTrianglePrism { + separating_direction, + separating_distance, + normal, + raw_points, + triangle_region, + }) +} + +fn interval_gaps( + sphere: Sphere, + triangle: Triangle, + direction: Vector3, + triangle_is_a: bool, +) -> (f32, f32) { + // 82ADD800 / 82ADE3B8. GP intervals exclude primitive fatness. + let s = dot(direction, sphere.center); + let p = triangle.vertices.map(|v| dot(direction, v)); + let lo = min(min(p[0], p[1]), p[2]); + let hi = max(max(p[0], p[1]), p[2]); + if triangle_is_a { + (lo - s, s - hi) + } else { + (s - hi, lo - s) + } +} +fn min(a: f32, b: f32) -> f32 { + if a < b { a } else { b } +} +fn max(a: f32, b: f32) -> f32 { + if a > b { a } else { b } +} + +fn point_face(point: Vector3, t: Triangle, axis: Vector3, triangle_is_a: bool) -> (Vector3, u32) { + // The sphere contributes no axes or edges (82ACEA30), so the selected + // direction is the triangle face normal. Thus GetMaximumFeature's face + // branch is the complete reachable branch for this primitive pair. + let query = if triangle_is_a { axis } else { neg(axis) }; + let same = (dot(query, t.feature.normal) < 0.0) == triangle_is_a; + let indices = if same { [0, 1, 2] } else { [2, 1, 0] }; + let vertices = if same { [0, 2, 1] } else { [0, 1, 2] }; + let mut region = if same { 8 } else { 0 }; + let mut projected = madd(axis, dot(axis, sub(t.vertices[0], point)), point); + let mut best = 0usize; + let mut violation = 0.0; + let planes = indices.map(|i| { + let direction = if same { + t.feature.edges[i] + } else { + neg(t.feature.edges[i]) + }; + let plane = cross(direction, axis); + let squared = dot(plane, plane); + // Feature::BuildEdgePlanes (82AC6F88): one refinement and select-zero. + // CRT initializer 82F837D8 copies 8212BF78 into 830BDD40. The mapped + // image alone contains zero here; live TU3 uses 0x34000000. + scale( + plane, + if squared > f32::from_bits(0x3400_0000) { + inverse_length_squared(squared, 1) + } else { + 0.0 + }, + ) + }); + for i in 0..3 { + let d = dot(planes[i], sub(projected, t.vertices[vertices[i]])); + if i == 0 || d > violation { + best = i; + violation = d; + } + } + if violation > 0.0 { + projected = sub(projected, scale(planes[best], violation)); + let i = indices[best]; + let base = t.vertices[vertices[best]]; + let direction = if same { + t.feature.edges[i] + } else { + neg(t.feature.edges[i]) + }; + let along = dot(sub(projected, base), direction); + let (p, r) = if along < 0.0 { + (base, 1) + } else if along > t.edge_lengths[i] { + (madd(direction, t.edge_lengths[i], base), 3) + } else { + (madd(direction, along, base), 2) + }; + projected = p; + region += best as u32 * 2 + r; + } + (projected, region) +} diff --git a/crates/skate-core/src/physics/collision/world_contact.rs b/crates/skate-core/src/physics/collision/world_contact.rs new file mode 100644 index 00000000..9db8bd22 --- /dev/null +++ b/crates/skate-core/src/physics/collision/world_contact.rs @@ -0,0 +1,95 @@ +//! Physics/world pair query 8277B720, called by triangle dispatch 8277BC58. +//! This is distinct from the general Volume query (82AD3CD8). +use super::{ + ContactPair, Sphere, Triangle, TriangleFixup, arithmetic::*, fix_up_triangle, + sphere_triangle::build_prism, +}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug)] +pub struct WorldContactSettings { + /// Per-volume padding at cached primitive +112, excluding its radius. + pub volume_padding: f32, + /// Query settings +0. Bounds the approaching velocity's prediction. + pub maximum_separating_distance: f32, + /// Query settings +4 and +8, passed directly to triangle fixup. + pub edge_cos_bend_normal_threshold: f32, + pub convexity_epsilon: f32, + /// World-query context byte +61, forwarded to TU3 vertex handling. + pub is_object: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct WorldContact { + /// B (world) toward A (moving primitive), ready for contact construction. + pub normal: Vector3, + pub points: ContactPair, +} + +/// 8277BF0C..BF8C. The original predicts using the literal 1/60, independent +/// of the simulation timestep. Do not replace it with a caller's frame dt. +pub fn world_separation_limit( + velocity: Vector3, + triangle_normal: Vector3, + padding: f32, + maximum: f32, +) -> f32 { + let approach = dot( + neg(triangle_normal), + scale(velocity, f32::from_bits(0x3C88_8889)), + ); + let positive = if -approach >= 0.0 { 0.0 } else { approach }; + let limited = if maximum - positive >= 0.0 { + positive + } else { + maximum + }; + limited + padding +} + +/// Complete sphere branch of the physics/world pair query. Input GP geometry +/// is in world space; world broad phase, exclusion tables and duplicate-contact +/// buffering remain the producer's responsibilities. +pub fn sphere_triangle_world_contact( + sphere: Sphere, + triangle: Triangle, + velocity: Vector3, + settings: WorldContactSettings, +) -> Option { + let separation = world_separation_limit( + velocity, + triangle.feature.normal, + settings.volume_padding, + settings.maximum_separating_distance, + ); + let prism = build_prism(sphere, triangle, separation, false, true)?; + let mut normal = prism.normal; + let mut points = [prism.raw_points]; + if !fix_up_triangle( + triangle.feature, + &mut normal, + &mut points, + TriangleFixup { + reverse: true, + edge_cos_bend_normal_threshold: settings.edge_cos_bend_normal_threshold, + convexity_epsilon: settings.convexity_epsilon, + is_object: settings.is_object, + }, + ) { + return None; + } + // 8277B9E0..BA7C: fixup precedes publication. Both fatness operations + // use separate multiply and add/subtract (the Volume query uses an FMA). + let a = scale(normal, sphere.radius); + Some(WorldContact { + normal: neg(normal), + points: ContactPair { + a: Vector3::new( + points[0].a.x + a.x, + points[0].a.y + a.y, + points[0].a.z + a.z, + ), + b: sub(points[0].b, scale(normal, triangle.fatness)), + }, + }) +} diff --git a/crates/skate-core/src/physics/constraint_batch.rs b/crates/skate-core/src/physics/constraint_batch.rs new file mode 100644 index 00000000..2f31bbbc --- /dev/null +++ b/crates/skate-core/src/physics/constraint_batch.rs @@ -0,0 +1,20 @@ +//! Joint/drive batch eligibility: TU3 82AE39D0 and 82AE1778. +//! +//! Native clears the output counts, traverses the registered list in order, +//! and compiles a row only when at least one endpoint has state bit 4. This +//! does not register, wake, freeze or reorder bodies. + +pub fn compile_active( + registered: impl IntoIterator, + endpoint_states: impl Fn(&T) -> [u32; 2], + mut compile: impl FnMut(T) -> R, +) -> Vec { + registered + .into_iter() + .filter(|constraint| { + let [a, b] = endpoint_states(constraint); + (a | b) & 4 != 0 + }) + .map(&mut compile) + .collect() +} diff --git a/crates/skate-core/src/physics/constraint_frames.rs b/crates/skate-core/src/physics/constraint_frames.rs new file mode 100644 index 00000000..a4f5d2b3 --- /dev/null +++ b/crates/skate-core/src/physics/constraint_frames.rs @@ -0,0 +1,190 @@ +//! Original TU3 constraint-frame arithmetic shared by joint and drive builders. +//! 82AE0DB0 constructs quaternion-component rows directly from bilinear products. +//! The builders compose frames and construct bases without normalizing inputs. +use super::{ + native_arithmetic, + rigid_body::{RetailPackedWorldInverseInertia, RetailQuaternion}, +}; +use crate::math::{Basis3, Vector3}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct QuaternionRows { + pub axes: [Vector3; 3], + pub relative: RetailQuaternion, +} + +/// 82AE0DB0..0F88. Keep each rounded product and the original addition order; +/// rotating a relative quaternion's axes is only algebraically equivalent for +/// exact unit quaternions and has different finite-precision behavior. +pub(crate) fn quaternion_rows(a: RetailQuaternion, b: RetailQuaternion) -> QuaternionRows { + let xx = a.x * b.x; + let xy = a.x * b.y; + let xz = a.x * b.z; + let xw = a.x * b.w; + let yx = a.y * b.x; + let yy = a.y * b.y; + let yz = a.y * b.z; + let yw = a.y * b.w; + let zx = a.z * b.x; + let zy = a.z * b.y; + let zz = a.z * b.z; + let zw = a.z * b.w; + let wx = a.w * b.x; + let wy = a.w * b.y; + let wz = a.w * b.z; + let ww = a.w * b.w; + QuaternionRows { + axes: [ + Vector3::new( + ((ww + xx) - yy) - zz, + ((yx + xy) + wz) + zw, + ((xz + zx) - wy) - yw, + ), + Vector3::new( + ((yx + xy) - wz) - zw, + ((ww + yy) - zz) - xx, + ((yz + zy) + wx) + xw, + ), + Vector3::new( + ((xz + zx) + yw) + wy, + ((zy + yz) - wx) - xw, + ((ww + zz) - xx) - yy, + ), + ], + relative: RetailQuaternion { + x: ((wx - xw) + zy) - yz, + y: ((wy - yw) + xz) - zx, + z: ((wz - zw) + yx) - xy, + w: ((ww + zz) + yy) + xx, + }, + } +} + +/// Original quaternion multiplication at 82AE3C10..3C60 / 82AE1B94..1C4C. +pub(crate) fn compose(a: RetailQuaternion, b: RetailQuaternion) -> RetailQuaternion { + let cross = cross(Vector3::new(a.x, a.y, a.z), Vector3::new(b.x, b.y, b.z)); + RetailQuaternion { + x: a.x.mul_add(b.w, b.x.mul_add(a.w, cross.x)), + y: a.y.mul_add(b.w, b.y.mul_add(a.w, cross.y)), + z: a.z.mul_add(b.w, b.z.mul_add(a.w, cross.z)), + w: a.w * b.w - native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]), + } +} + +/// Independently verified at Drive Build 82AE1CC4..1D90: sqrt(2) scaling, +/// 0.5 - component squared, paired residuals, and masks 822FB8A0/B0/C0. +/// This does not depend on Joint Build's unresolved RQD-gather shuffle at +/// 82AE3D98; it accepts an already known quaternion. +pub(crate) fn basis(q: RetailQuaternion) -> Basis3 { + let root_two = f32::from_bits(0x3fb5_04f3); + let x = q.x * root_two; + let y = q.y * root_two; + let z = q.z * root_two; + let w = q.w * root_two; + let dx = (-x).mul_add(x, 0.5); + let dy = (-y).mul_add(y, 0.5); + let dz = (-z).mul_add(z, 0.5); + let xy = x * y; + let yz = y * z; + let zx = z * x; + let wz = w * z; + let wx = w * x; + let wy = w * y; + Basis3 { + columns: [ + [dy + dz, xy + wz, zx - wy], + [xy - wz, dz + dx, yz + wx], + [zx + wy, yz - wx, dx + dy], + ], + } +} + +pub(crate) fn transform_direction(b: Basis3, v: Vector3) -> Vector3 { + let c = b.columns; + Vector3::new( + c[2][0].mul_add(v.z, c[1][0].mul_add(v.y, c[0][0] * v.x)), + c[2][1].mul_add(v.z, c[1][1].mul_add(v.y, c[0][1] * v.x)), + c[2][2].mul_add(v.z, c[1][2].mul_add(v.y, c[0][2] * v.x)), + ) +} + +pub(crate) fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} + +/// Source adds both cross-product terms directly into the incoming rate. +pub(crate) fn point_rate(linear: Vector3, angular: Vector3, arm: Vector3) -> Vector3 { + Vector3::new( + arm.z + .mul_add(angular.y, (-arm.y).mul_add(angular.z, linear.x)), + arm.x + .mul_add(angular.z, (-arm.z).mul_add(angular.x, linear.y)), + arm.y + .mul_add(angular.x, (-arm.x).mul_add(angular.y, linear.z)), + ) +} + +pub(crate) fn multiply_add(v: Vector3, scalar: f32, addend: Vector3) -> Vector3 { + Vector3::new( + v.x.mul_add(scalar, addend.x), + v.y.mul_add(scalar, addend.y), + v.z.mul_add(scalar, addend.z), + ) +} + +pub(crate) fn reciprocal(value: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + let residual = (-r).mul_add(value, 1.0); + r = r.mul_add(residual, r); + } + r +} + +pub(crate) fn reciprocal_sqrt(value: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_square_root_estimate(value); + for _ in 0..2 { + let squared = r * r; + let half = r * 0.5; + let residual = (-value).mul_add(squared, 1.0); + r = half.mul_add(residual, r); + } + r +} + +/// Drive 82AE2430..2490: X product, then the Y and Z fused additions. +pub(crate) fn multiply_inertia(t: RetailPackedWorldInverseInertia, v: Vector3) -> Vector3 { + inertia_tail( + t, + v, + Vector3::new(t.full.x * v.x, t.full.y * v.x, t.full.z * v.x), + ) +} + +/// Joint 82AE470C..4758 starts the same contraction with a fused +zero. +pub(crate) fn multiply_inertia_from_zero( + t: RetailPackedWorldInverseInertia, + v: Vector3, +) -> Vector3 { + inertia_tail( + t, + v, + Vector3::new( + t.full.x.mul_add(v.x, 0.0), + t.full.y.mul_add(v.x, 0.0), + t.full.z.mul_add(v.x, 0.0), + ), + ) +} + +fn inertia_tail(t: RetailPackedWorldInverseInertia, v: Vector3, first: Vector3) -> Vector3 { + Vector3::new( + t.full.z.mul_add(v.z, t.full.y.mul_add(v.y, first.x)), + t.split.z.mul_add(v.z, t.split.y.mul_add(v.y, first.y)), + t.split.x.mul_add(v.z, t.split.z.mul_add(v.y, first.z)), + ) +} diff --git a/crates/skate-core/src/physics/construction/authored_transforms.rs b/crates/skate-core/src/physics/construction/authored_transforms.rs new file mode 100644 index 00000000..e1d62c2f --- /dev/null +++ b/crates/skate-core/src/physics/construction/authored_transforms.rs @@ -0,0 +1,85 @@ +//! Authored seven-part matrices passed to Part::SetTransform by TU3 82C0ADF0. +//! This function is called by construction/reset before world-pose publication. +use super::RetailAffineTransform; +use crate::math::{Basis3, Vector3}; + +/// Resolved stock attributes read by InitializeTransforms. Attribute lookup +/// failure selects the engine's runtime fallback scalar at830D0850; callers +/// resolving absent attributes must supply that actual initialized value. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AuthoredTransformInputs { + /// physicsdeck.DeckMidLength; full attribute key C94D65091DCB828B. + pub deck_mid_length: f32, + /// physicswheels.WheelXDist; full attribute key 5A84C435DC340AF8. + pub wheel_x_distance: f32, + /// physicstrucks layout+4, +8, +16. + pub truck_z_position_front: f32, + pub truck_z_position_back: f32, + pub truck_y_position: f32, +} + +impl AuthoredTransformInputs { + /// Actual stock XML scalar words, not constructor output snapshots. + pub const STOCK: Self = Self { + deck_mid_length: f32::from_bits(0x3f17_0a3d), + wheel_x_distance: f32::from_bits(0x3dc2_8f5c), + truck_z_position_front: f32::from_bits(0xbd54_fdf4), + truck_z_position_back: f32::from_bits(0xbd54_fdf4), + truck_y_position: f32::from_bits(0xbd67_6c8b), + }; +} + +/// Complete authored matrix calculation, in body order wheels0..3, trucks4..5, +/// deck6. Native publication order is deck6 first, then bodies0..5. Front truck +/// is identity; back truck uses the actual SinCos(pi/2) result about Y. The +/// tiny polynomial cosine residual is retained, never replaced by exact zero. +pub fn authored_body_transforms(input: AuthoredTransformInputs) -> [RetailAffineTransform; 7] { + // fmadds82C0AEE4 and fnmsubs82C0AF60: one rounded multiply/add each. + let front_z = input + .deck_mid_length + .mul_add(0.5, input.truck_z_position_front); + let back_z = -input + .deck_mid_length + .mul_add(0.5, input.truck_z_position_back); + let y = input.truck_y_position; + let x = input.wheel_x_distance; + let mut transforms = [RetailAffineTransform::IDENTITY; 7]; + for (part, translation) in [ + Vector3::new(-x, y, front_z), + Vector3::new(x, y, front_z), + Vector3::new(-x, y, back_z), + Vector3::new(x, y, back_z), + Vector3::new(0.0, y, front_z), + Vector3::new(0.0, y, back_z), + ] + .into_iter() + .enumerate() + { + transforms[part].translation = translation; + } + // Literal pi/2 word at820C69C8. Shared polynomial82473A08 was re-audited + // against fresh disassembly and constants822F97C0..822F985F for this path. + let (sin, cos) = crate::trigonometry::sin_cos(f32::from_bits(0x3fc9_0fdb)); + transforms[5].basis = Basis3 { + columns: [[cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]], + }; + transforms +} + +/// Native affine rows, ready for reset_skateboard's `authored` argument. All +/// fourth lanes are zero, including the affine translation:82C0ADF0 explicitly +/// clears them. This does not perform the downstream COM/quaternion conversion. +pub fn authored_body_pose_records(input: AuthoredTransformInputs) -> [[u32; 16]; 7] { + authored_body_transforms(input).map(|transform| { + let mut words = [0; 16]; + for (axis, column) in transform.basis.columns.into_iter().enumerate() { + for (component, value) in column.into_iter().enumerate() { + words[axis * 4 + component] = value.to_bits(); + } + } + words[12] = transform.translation.x.to_bits(); + words[13] = transform.translation.y.to_bits(); + words[14] = transform.translation.z.to_bits(); + words + }) +} diff --git a/crates/skate-core/src/physics/construction/board_parts.rs b/crates/skate-core/src/physics/construction/board_parts.rs new file mode 100644 index 00000000..97211ffb --- /dev/null +++ b/crates/skate-core/src/physics/construction/board_parts.rs @@ -0,0 +1,104 @@ +//! Wheel/truck mass-input producers in TU3 82C0AA78 and 82C0A6F8. +//! Allocation, material publication and final inverse inertia are separate stages. + +use super::{MassShape, PrimitiveMass, primitive_mass}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelMassSettings { + /// physicswheels.WheelRadius; 82C0AB44..82C0AB8C. + pub radius: f32, + /// physicswheels.WheelMass; 82C0ACC8..82C0AD04. + pub mass: f32, + /// physics_world.SkateboardMassFactor; 82C0AD0C..82C0AD58. + pub mass_factor: f32, +} + +impl WheelMassSettings { + /// Decoded stock collection inputs, not cached mass-property outputs. + pub const STOCK: Self = Self { + radius: f32::from_bits(0x3cfd_f3b6), + mass: f32::from_bits(0x3da9_fbe7), + mass_factor: f32::from_bits(0x40a0_0000), + }; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TruckMassSettings { + pub wheel_radius: f32, + pub wheel_x_distance: f32, + /// physicstrucks layout +52 and +56. + pub radius_scalar: f32, + pub half_height_scalar: f32, + /// physicstrucks layout +40 and physics_world.SkateboardMassFactor. + pub mass: f32, + pub mass_factor: f32, +} + +impl TruckMassSettings { + pub const STOCK: Self = Self { + wheel_radius: f32::from_bits(0x3cfd_f3b6), + wheel_x_distance: f32::from_bits(0x3dc2_8f5c), + radius_scalar: f32::from_bits(0x3f09_999a), + half_height_scalar: f32::from_bits(0x3fac_cccd), + mass: f32::from_bits(0x3ec7_ae14), + mass_factor: f32::from_bits(0x40a0_0000), + }; +} + +/// Actual shape dimensions and requested mass passed to 82AE7770. Its inverse +/// principal moments still require the unresolved native reciprocal estimate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PartMassInput { + pub shape: MassShape, + pub requested_mass: f32, +} + +/// 82C0AA78 creates one shared sphere and applies the same mass inputs to all +/// four wheel definitions. WheelCapsuleLength is not read by this function. +pub fn wheel_mass_input(settings: WheelMassSettings) -> PartMassInput { + PartMassInput { + shape: MassShape::Sphere { + radius: settings.radius, + }, + requested_mass: settings.mass * settings.mass_factor, + } +} + +/// 82C0A6F8 -> 82AD9708: capsule radius and half-length each include the final +/// multiplication by one half. Both truck definitions share this shape. +pub fn truck_mass_input(settings: TruckMassSettings) -> PartMassInput { + PartMassInput { + shape: MassShape::Capsule { + radius: (settings.wheel_radius * settings.radius_scalar) * 0.5, + half_length: (settings.wheel_x_distance * settings.half_height_scalar) * 0.5, + }, + requested_mass: settings.mass * settings.mass_factor, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ForwardMassProperties { + pub primitive: PrimitiveMass, + pub mass: f32, + pub principal_moments: Vector3, +} + +/// The supported primitive stage of 82AE7770: shape calculation, density-one +/// mass fallback at 82AE79EC, then principal-moment scaling at 82AE7A24. +/// An unsupported shape needs the aggregate producer and returns no result. +/// This does not supply inverse inertia or claim the part is ready to simulate. +pub fn forward_mass_properties(input: PartMassInput) -> Option { + let primitive = primitive_mass(input.shape)?; + let mass = if input.requested_mass < f32::MIN_POSITIVE { + primitive.volume + } else { + input.requested_mass + }; + let moments = primitive.moments_per_unit_mass; + Some(ForwardMassProperties { + primitive, + mass, + principal_moments: Vector3::new(moments.x * mass, moments.y * mass, moments.z * mass), + }) +} diff --git a/crates/skate-core/src/physics/construction/deck_geometry.rs b/crates/skate-core/src/physics/construction/deck_geometry.rs new file mode 100644 index 00000000..35774ffc --- /dev/null +++ b/crates/skate-core/src/physics/construction/deck_geometry.rs @@ -0,0 +1,331 @@ +//! Deck collision children constructed by TU3 82C09290 and 82C0A2A0/3E0/510. +//! Allocation is host-owned; dimensions, child order, flags and arithmetic are +//! source-derived. Mass uses the actual primitive/triangle-AABB dispatch in +//! 82AE7058, including children whose collision-enable bit is clear. + +use super::{MassMoments, MassShape, primitive_mass}; +use crate::{ + math::Vector3, + physics::{drive_frames::RetailAffineTransform, native_arithmetic}, + trigonometry, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DeckGeometrySettings { + pub width: f32, + pub mid_length: f32, + pub thickness: f32, + /// Both fans read DeckBackEndSize. DeckFrontEndSize is not read here. + pub back_end_size: f32, + pub front_end_angle_degrees: f32, + pub back_end_angle_degrees: f32, + /// DeckEndCapsules (Int32), despite the triangles produced by this loop. + pub end_capsule_count: i32, + pub enable_deck_volume_collisions: bool, + pub enable_end_volume_collisions: bool, +} + +impl DeckGeometrySettings { + /// physicsdeck/default.xml, retaining the original Float words. + pub const STOCK: Self = Self { + width: f32::from_bits(0x3E75_C28F), + mid_length: f32::from_bits(0x3F17_0A3D), + thickness: f32::from_bits(0x3C75_C28F), + back_end_size: f32::from_bits(0x3E28_F5C3), + front_end_angle_degrees: f32::from_bits(0x4150_0000), + back_end_angle_degrees: f32::from_bits(0x4148_0000), + end_capsule_count: 5, + enable_deck_volume_collisions: true, + enable_end_volume_collisions: true, + }; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DeckShape { + RoundedBox { + half_extents: Vector3, + radius: f32, + }, + Capsule { + radius: f32, + half_length: f32, + }, + Sphere { + radius: f32, + }, + Triangle { + vertices: [Vector3; 3], + fatness: f32, + edge_cosines: [f32; 3], + /// Volume flags excluding the separately exposed collision-enable bit. + /// These are Volume flags, not GPTriangle feature flags. + volume_flags: u32, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DeckChild { + pub shape: DeckShape, + pub transform: RetailAffineTransform, + pub collision_enabled: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DeckGeometry { + pub children: Vec, +} + +impl DeckGeometry { + pub fn new(settings: DeckGeometrySettings) -> Self { + let half_width = settings.width * 0.5; + let half_length = settings.mid_length * 0.5; + let half_thickness = settings.thickness * 0.5; + let radius = half_thickness * f32::from_bits(0x3F66_6666); + let mut children = vec![DeckChild { + shape: DeckShape::RoundedBox { + half_extents: Vector3::new( + half_width - radius, + half_thickness - radius, + half_length - radius, + ), + radius, + }, + transform: RetailAffineTransform::IDENTITY, + collision_enabled: settings.enable_deck_volume_collisions, + }]; + + // 82E0B1E0: midpoint = first + (second-first)/2. These endpoints are + // parallel to local Z, so the orientation branch leaves identity. + let longitudinal_half_delta = (-half_length - half_length) * 0.5; + let capsule_half_length = vector_length_z(longitudinal_half_delta); + let capsule_center_z = half_length + longitudinal_half_delta; + let arc_width = half_width - half_thickness; + for x in [arc_width, half_thickness - half_width] { + children.push(DeckChild { + shape: DeckShape::Capsule { + radius: half_thickness, + half_length: capsule_half_length, + }, + transform: translated(Vector3::new(x, 0.0, capsule_center_z)), + collision_enabled: settings.enable_end_volume_collisions, + }); + } + // 82C0A3E0 retains the sphere constructor's enabled flag, independent + // of the two authored deck collision toggles. + for z in [f32::from_bits(0x3E70_A3D7), f32::from_bits(0xBE70_A3D7)] { + children.push(DeckChild { + shape: DeckShape::Sphere { radius: 0.035 }, + transform: translated(Vector3::new(0.0, f32::from_bits(0xBCA3_D70A), z)), + collision_enabled: true, + }); + } + + let radians = f32::from_bits(0x3C8E_FA35); + let back_angle = settings.back_end_angle_degrees * radians; + let front_angle = settings.front_end_angle_degrees * radians; + let back_slope = (trigonometry::sin(back_angle), cosine(back_angle)); + let front_slope = (trigonometry::sin(front_angle), cosine(front_angle)); + let end_length = settings.back_end_size - half_thickness; + let mut previous_back = (trigonometry::sin(0.0), cosine(0.0)); + let mut previous_front = previous_back; + for segment in 1..=settings.end_capsule_count { + let back_arc = + (segment as f32 * f32::from_bits(0xC049_0FDB)) / settings.end_capsule_count as f32; + let next_back = (trigonometry::sin(back_arc), cosine(back_arc)); + children.push(fan_triangle( + previous_back, + next_back, + back_slope, + arc_width, + end_length, + -half_length, + half_thickness, + false, + settings.enable_deck_volume_collisions, + )); + previous_back = next_back; + let front_arc = + (segment as f32 * f32::from_bits(0x4049_0FDB)) / settings.end_capsule_count as f32; + let next_front = (trigonometry::sin(front_arc), cosine(front_arc)); + children.push(fan_triangle( + previous_front, + next_front, + front_slope, + arc_width, + end_length, + half_length, + half_thickness, + true, + settings.enable_deck_volume_collisions, + )); + previous_front = next_front; + } + Self { children } + } + + pub fn mass_moments(&self) -> MassMoments { + let mut total = MassMoments::ZERO; + for child in &self.children { + total.add(child.mass_moments()); + } + // The aggregate volume's transform is identity (82AD7740). + total.transform(RetailAffineTransform::IDENTITY.basis, Vector3::ZERO); + total + } +} + +impl DeckChild { + pub fn mass_moments(&self) -> MassMoments { + let (shape, transform) = match self.shape { + DeckShape::Sphere { radius } => (MassShape::Sphere { radius }, self.transform), + DeckShape::Capsule { + radius, + half_length, + } => ( + MassShape::Capsule { + radius, + half_length, + }, + self.transform, + ), + DeckShape::RoundedBox { + half_extents, + radius, + } => ( + MassShape::RoundedBox { + half_extents, + radius, + }, + self.transform, + ), + DeckShape::Triangle { + vertices, fatness, .. + } => { + // TU3 TriangleVolume::GetBBox 82ADDC40, null transform branch: + // min(v0,min(v1,v2))-fatness, max(v0,max(v1,v2))+fatness. + // 82AE7058 deliberately uses this box for triangle mass; no + // triangle pose transform is applied again after this branch. + let axis = |get: fn(Vector3) -> f32| { + let a = get(vertices[0]); + let b = get(vertices[1]); + let c = get(vertices[2]); + let low = native_arithmetic::vector_min(a, native_arithmetic::vector_min(b, c)) + - fatness; + let high = + native_arithmetic::vector_max(a, native_arithmetic::vector_max(b, c)) + + fatness; + let half = (high - low) * 0.5; + (half, low + half) + }; + let x = axis(|v| v.x); + let y = axis(|v| v.y); + let z = axis(|v| v.z); + ( + MassShape::RoundedBox { + half_extents: Vector3::new(x.0, y.0, z.0), + radius: 0.0, + }, + translated(Vector3::new(x.1, y.1, z.1)), + ) + } + }; + let mut moments = MassMoments::from_primitive( + primitive_mass(shape).expect("every deck child follows a supported source mass branch"), + ); + moments.transform(transform.basis, transform.translation); + moments + } +} + +fn translated(translation: Vector3) -> RetailAffineTransform { + RetailAffineTransform { + translation, + ..RetailAffineTransform::IDENTITY + } +} + +#[allow(clippy::too_many_arguments)] +fn fan_triangle( + previous: (f32, f32), + next: (f32, f32), + slope: (f32, f32), + width: f32, + length: f32, + center_z: f32, + fatness: f32, + front: bool, + enabled: bool, +) -> DeckChild { + let point = |arc: (f32, f32)| { + let along_end = arc.0 * length; + let elevation = if front { along_end } else { -along_end }; + Vector3::new( + arc.1 * width, + slope.0 * elevation, + along_end.mul_add(slope.1, center_z), + ) + }; + DeckChild { + shape: DeckShape::Triangle { + vertices: [ + Vector3::new(0.0, 0.0, center_z), + point(previous), + point(next), + ], + fatness, + edge_cosines: [1.0, -1.0, 1.0], + volume_flags: 0x3E2, + }, + transform: RetailAffineTransform::IDENTITY, + collision_enabled: enabled, + } +} + +/// Length path in 82E0B1E0; the shared estimate retains its explicitly marked +/// Xenon arithmetic approximation boundary until hardware validation. +fn vector_length_z(z: f32) -> f32 { + let squared = native_arithmetic::dot3([0.0, 0.0, z, 0.0], [0.0, 0.0, z, 0.0]); + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + let residual = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(residual, inverse); + } + if squared == 0.0 { + 0.0 + } else { + squared * inverse + } +} + +/// Standalone cosine 82473930. Its even-power tree differs from SinCos. +fn cosine(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3E22_F983)).round_ties_even(); + let x = (-f32::from_bits(0x40C9_0FDB)).mul_add(turns, angle); + let x2 = x * x; + let x4 = x2 * x2; + let x6 = x4 * x2; + let x8 = x4 * x4; + let x10 = x6 * x4; + let x12 = x6 * x6; + let x14 = x8 * x6; + let x16 = x8 * x8; + let x18 = x10 * x8; + let x20 = x10 * x10; + let x22 = x12 * x10; + let mut result = (-0.5_f32).mul_add(x2, 1.0); + for (power, coefficient) in [ + (x4, 0x3D2A_AAAB), + (x6, 0xBAB6_0B61), + (x8, 0x37D0_0D01), + (x10, 0xB493_F27E), + (x12, 0x310F_76C8), + (x14, 0xAD49_CBA5), + (x16, 0x2957_3F9F), + (x18, 0xA534_13C3), + (x20, 0x20F2_A15D), + (x22, 0x9C86_71CB), + ] { + result = f32::from_bits(coefficient).mul_add(power, result); + } + result +} diff --git a/crates/skate-core/src/physics/construction/mass_moments.rs b/crates/skate-core/src/physics/construction/mass_moments.rs new file mode 100644 index 00000000..407d655c --- /dev/null +++ b/crates/skate-core/src/physics/construction/mass_moments.rs @@ -0,0 +1,127 @@ +//! Volume moments accumulated by TU3 82AE6CB8/82AE6D88/82AE6F88 and reduced +//! to a local principal frame by 82AE7508. This is the aggregate mass producer; +//! geometry construction and requested-mass scaling remain separate stages. + +use super::{PrimitiveMass, principal_axes::diagonalize, refined_reciprocal}; +use crate::math::{Basis3, Vector3}; +use crate::physics::rigid_body::RetailLocalMassFrame; + +/// Integrals of [x,y,z,1] outer products, in column order. The last column +/// holds first moments and volume. Keeping all lanes preserves native sums. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MassMoments { + pub columns: [[f32; 4]; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AggregateMassProperties { + pub volume: f32, + pub local_mass_frame: RetailLocalMassFrame, + pub moments_per_unit_mass: Vector3, +} + +impl MassMoments { + pub const ZERO: Self = Self { + columns: [[0.0; 4]; 4], + }; + + /// 82AE6CB8 converts primitive principal inertia into volume moments. + pub fn from_primitive(primitive: PrimitiveMass) -> Self { + let inertia = primitive.moments_per_unit_mass; + let volume = primitive.volume; + let x = (((inertia.z + inertia.y) - inertia.x) * volume) * 0.5; + let y = (((inertia.z + inertia.x) - inertia.y) * volume) * 0.5; + let z = (((inertia.y + inertia.x) - inertia.z) * volume) * 0.5; + Self { + columns: [ + [x, 0.0, 0.0, 0.0], + [0.0, y, 0.0, 0.0], + [0.0, 0.0, z, 0.0], + [0.0, 0.0, 0.0, volume], + ], + } + } + + /// 82AE6D88: H * moments * transpose(H). The source explicitly sets the + /// basis fourth lanes to zero and the translation fourth lane to one. + pub fn transform(&mut self, basis: Basis3, translation: Vector3) { + let mut affine = [[0.0; 4]; 4]; + for (column, axis) in affine[..3].iter_mut().zip(basis.columns) { + column[..3].copy_from_slice(&axis); + } + affine[3] = [translation.x, translation.y, translation.z, 1.0]; + let left = self.columns.map(|column| product(affine, column)); + self.columns = core::array::from_fn(|column| { + product(left, core::array::from_fn(|row| affine[row][column])) + }); + } + + /// 82AE6F88 adds successful children in volume order, without weighting or + /// normalizing them individually. Collision-enable flags are not read here. + pub fn add(&mut self, child: Self) { + for (output, value) in self.columns.iter_mut().zip(child.columns) { + for (output, value) in output.iter_mut().zip(value) { + *output += value; + } + } + } + + /// 82AE7508 mutates the aggregate into its center-of-mass frame before + /// diagonalizing its inertia. Preserve this mutation and native axis order. + pub fn principal_properties(&mut self) -> AggregateMassProperties { + let volume = self.columns[3][3]; + let inverse_volume = refined_reciprocal(volume); + let center = Vector3::new( + self.columns[3][0] * inverse_volume, + self.columns[3][1] * inverse_volume, + self.columns[3][2] * inverse_volume, + ); + self.transform( + RetailLocalMassFrame::IDENTITY.basis, + Vector3::new(-center.x, -center.y, -center.z), + ); + let m = self.columns; + let xy = (m[1][0] + m[0][1]) * -0.5; + let xz = (m[2][0] + m[0][2]) * -0.5; + let yz = (m[2][1] + m[1][2]) * -0.5; + let mut inertia = [ + [m[1][1] + m[2][2], xy, xz], + [xy, m[0][0] + m[2][2], yz], + [xz, yz, m[0][0] + m[1][1]], + ]; + let axes = diagonalize(&mut inertia); + let maximum_yz = + select_nonnegative(inertia[1][1] - inertia[2][2], inertia[1][1], inertia[2][2]); + let maximum = select_nonnegative(inertia[0][0] - maximum_yz, inertia[0][0], maximum_yz); + let floor = maximum * 0.0050000004; + let moments = core::array::from_fn::<_, 3, _>(|i| { + select_nonnegative(inertia[i][i] - floor, inertia[i][i], floor) * inverse_volume + }); + AggregateMassProperties { + volume, + local_mass_frame: RetailLocalMassFrame { + basis: Basis3 { + columns: core::array::from_fn(|i| core::array::from_fn(|j| axes[j][i])), + }, + translation: center, + }, + moments_per_unit_mass: Vector3::new(moments[0], moments[1], moments[2]), + } + } +} + +fn product(matrix: [[f32; 4]; 4], vector: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| { + matrix[3][i].mul_add( + vector[3], + matrix[2][i].mul_add( + vector[2], + matrix[1][i].mul_add(vector[1], matrix[0][i] * vector[0]), + ), + ) + }) +} + +fn select_nonnegative(selector: f32, positive: f32, negative: f32) -> f32 { + if selector >= 0.0 { positive } else { negative } +} diff --git a/crates/skate-core/src/physics/construction/primitive_mass.rs b/crates/skate-core/src/physics/construction/primitive_mass.rs new file mode 100644 index 00000000..35dd8814 --- /dev/null +++ b/crates/skate-core/src/physics/construction/primitive_mass.rs @@ -0,0 +1,142 @@ +//! Forward principal moments and volume from TU3 82AE7228/82AE6B78. +//! These quantities precede mass scaling and inverse-inertia construction. + +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MassShape { + Sphere { + radius: f32, + }, + /// The capsule's longitudinal principal axis is local Z. + Capsule { + radius: f32, + half_length: f32, + }, + RoundedBox { + half_extents: Vector3, + radius: f32, + }, + Cylinder { + radius: f32, + half_length: f32, + padding: f32, + }, + /// Triangle, aggregate, or another type rejected by 82AE7228. The caller + /// must use the aggregate calculation; no moments are produced here. + Unsupported, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrimitiveMass { + pub moments_per_unit_mass: Vector3, + pub volume: f32, +} + +const MIN_RADIUS: f32 = f32::from_bits(0x33d6_bf95); +const SPHERE_VOLUME: f32 = f32::from_bits(0x4086_0a92); +const FOUR_THIRDS: f32 = f32::from_bits(0x3faa_aaab); +const TWO_THIRDS: f32 = f32::from_bits(0x3f2a_aaab); +const ONE_THIRD: f32 = f32::from_bits(0x3eaa_aaab); +const ONE_TWELFTH: f32 = f32::from_bits(0x3daa_aaab); +const PI: f32 = f32::from_bits(0x4049_0fdb); +const TWO_PI: f32 = f32::from_bits(0x40c9_0fdb); + +// The native fsubs/fsel chooses the right operand for an unordered difference. +// Keep this ordered comparison, including its signed-zero operand selection. +fn greater_extent(left: f32, right: f32) -> f32 { + if left - right >= 0.0 { + left + } else { + right + } +} + +/// Complete shape dispatch in 82AE7228. Unsupported shapes return no result, +/// matching the native false return and absence of output writes. All supported +/// branches retain the native scalar operation order and fused multiply-adds. +pub fn primitive_mass(shape: MassShape) -> Option { + Some(match shape { + MassShape::Unsupported => return None, + MassShape::Sphere { radius } => { + let radius = greater_extent(radius, MIN_RADIUS); + let radius_squared = radius * radius; + let moment = radius_squared * 0.4; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(moment, moment, moment), + volume: (radius_squared * radius) * SPHERE_VOLUME, + } + } + MassShape::Capsule { + radius, + half_length, + } => { + let length_squared = half_length * half_length; + let radius = greater_extent(greater_extent(radius, MIN_RADIUS), half_length * 0.05); + let numerator = radius + .mul_add(1.6, half_length * 0.75) + .mul_add(radius, length_squared * 4.0) + .mul_add(radius, length_squared * half_length); + let denominator = radius.mul_add(4.0, half_length * 3.0); + let transverse = numerator / denominator; + let axial = (radius * radius) * 0.4; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(transverse, transverse, axial), + volume: ((radius.mul_add(FOUR_THIRDS, half_length * 2.0) * radius) * radius) * PI, + } + } + MassShape::RoundedBox { + half_extents, + radius, + } => rounded_box_mass(half_extents, radius), + MassShape::Cylinder { + radius, + half_length, + padding, + } => { + let radius = radius + padding; + let half_length = half_length + padding; + let radius_squared = radius * radius; + let transverse = + (half_length * half_length).mul_add(4.0, radius_squared * 3.0) * ONE_TWELFTH; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(transverse, transverse, radius_squared * 0.5), + volume: ((half_length * radius) * radius) * TWO_PI, + } + } + }) +} + +/// Complete 82AE6B78: native rounded-box dimension floor, moments and volume. +fn rounded_box_mass(half_extents: Vector3, radius: f32) -> PrimitiveMass { + let largest = greater_extent( + half_extents.x, + greater_extent(half_extents.y, half_extents.z), + ); + let floor = greater_extent((largest + radius) * 0.05, MIN_RADIUS) - radius; + let x = greater_extent(half_extents.x, floor); + let y = greater_extent(half_extents.y, floor); + let z = greater_extent(half_extents.z, floor); + let radius_moment = (radius * radius) * 0.5; + let x_squared = x * x; + let y_squared = y * y; + let z_squared = z * z; + let xy_radius = (x + y) * radius; + let yz_radius = (y + z) * radius; + let xz_radius = (x + z) * radius; + let moment_x = + (yz_radius.mul_add(2.0, z_squared) + y_squared).mul_add(ONE_THIRD, radius_moment); + let moment_y = + (xz_radius.mul_add(2.0, x_squared) + z_squared).mul_add(ONE_THIRD, radius_moment); + let moment_z = + (xy_radius.mul_add(2.0, x_squared) + y_squared).mul_add(ONE_THIRD, radius_moment); + let rounded_edges = (((radius.mul_add(TWO_THIRDS, x) + y) + z) * radius) * TWO_PI; + let face_products = (y + z).mul_add(x, y * z); + let box_volume = ((x * y) * z) * 8.0; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(moment_x, moment_y, moment_z), + volume: face_products + .mul_add(8.0, rounded_edges) + .mul_add(radius, box_volume), + } +} diff --git a/crates/skate-core/src/physics/construction/principal_axes.rs b/crates/skate-core/src/physics/construction/principal_axes.rs new file mode 100644 index 00000000..65812a81 --- /dev/null +++ b/crates/skate-core/src/physics/construction/principal_axes.rs @@ -0,0 +1,84 @@ +//! TU3 inertia diagonalization: 82AE8388 and its three rotation helpers. +//! Rows here are mathematical rows. The caller transposes the resulting axes +//! into RenderWare's Ri/Up/At storage. No eigenvalue sorting is performed. + +use super::refined_reciprocal; + +pub(super) type Matrix3 = [[f32; 3]; 3]; + +pub(super) fn diagonalize(inertia: &mut Matrix3) -> Matrix3 { + let mut axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let mut remaining = 10; + loop { + // 82AE84A4..8680: squared lower-triangle residual, relative to the + // smallest squared diagonal. Preserve comparison and FMA ordering. + let diagonal = [inertia[0][0], inertia[1][1], inertia[2][2]].map(|x| x * x); + let smallest = if diagonal[1] > diagonal[0] { + if diagonal[2] > diagonal[0] { + diagonal[0] + } else { + diagonal[2] + } + } else if diagonal[2] > diagonal[1] { + diagonal[1] + } else { + diagonal[2] + }; + let residual = inertia[2][1].mul_add( + inertia[2][1], + inertia[1][0].mul_add(inertia[1][0], inertia[2][0] * inertia[2][0]), + ); + if !(residual > smallest * 1.0e-24) || remaining == 0 { + break; + } + for row in 1..3 { + for column in 0..row { + let (sine, cosine) = rotation(inertia, row, column); + rotate_columns(&mut axes, row, column, sine, cosine); + rotate_columns(inertia, row, column, sine, cosine); + rotate_rows(inertia, row, column, sine, cosine); + } + } + remaining -= 1; + } + axes +} + +/// 82AE7AD8. The zero off-diagonal path is exactly the identity rotation. +fn rotation(matrix: &Matrix3, row: usize, column: usize) -> (f32, f32) { + let off_diagonal = matrix[row][column]; + if off_diagonal == 0.0 { + return (0.0, 1.0); + } + let difference = matrix[column][column] - matrix[row][row]; + let theta = refined_reciprocal(off_diagonal) * (difference * 0.5); + let length = theta.mul_add(theta, 1.0).sqrt(); + let tangent = if theta > 0.0 { + 1.0 / (length + theta) + } else { + -1.0 / (length - theta) + }; + let cosine = 1.0 / tangent.mul_add(tangent, 1.0).sqrt(); + (cosine * tangent, cosine) +} + +/// 82AE7C30: independently rounded products for the subtraction and one FMA +/// for the addition. Combining the subtraction into an FMA changes the port. +fn rotate_columns(matrix: &mut Matrix3, a: usize, b: usize, sine: f32, cosine: f32) { + for row in matrix { + let left = row[a]; + let right = row[b]; + row[a] = left * cosine - right * sine; + row[b] = left.mul_add(sine, right * cosine); + } +} + +/// 82AE7FD0 applies the matching rotation across the two matrix rows. +fn rotate_rows(matrix: &mut Matrix3, a: usize, b: usize, sine: f32, cosine: f32) { + let left = matrix[a]; + let right = matrix[b]; + for column in 0..3 { + matrix[a][column] = left[column] * cosine - right[column] * sine; + matrix[b][column] = left[column].mul_add(sine, right[column] * cosine); + } +} diff --git a/crates/skate-core/src/physics/contact.rs b/crates/skate-core/src/physics/contact.rs new file mode 100644 index 00000000..bf5b3e3b --- /dev/null +++ b/crates/skate-core/src/physics/contact.rs @@ -0,0 +1,330 @@ +//! Skate 3 TU3's RenderWare contact-record construction. +//! +//! This is the scalar form of the VMX128 block in TU3's two recovered +//! add-contact paths. It builds the 256-byte logical `rw::physics::Contact` +//! payload consumed in place by `ContactBatchBuild`; it does not solve the +//! contact or move a body. + +use crate::math::Vector3; + +pub mod tu3 { + /// Game-side physical-material combine helper used before both recovered + /// collision-to-contact record writers. + pub const COMBINE_PHYSICAL_MATERIALS: u32 = 0x8276_3078; + /// First recovered collision-to-contact record block, end exclusive. + pub const GENERATE_CONTACT_A: u32 = 0x8277_A828; + pub const GENERATE_CONTACT_A_END: u32 = 0x8277_AA88; + /// Second field-for-field collision-to-contact record block, end exclusive. + pub const GENERATE_CONTACT_B: u32 = 0x8277_BFAC; + pub const GENERATE_CONTACT_B_END: u32 = 0x8277_C21C; + /// In-place contact Jacobian builder. + pub const CONTACT_BATCH_BUILD: u32 = 0x82AE_10C8; +} + +/// Exact TU3 constant used to reject a degenerate velocity-derived tangent. +/// +/// The Xbox data table at `0x830382B0` contains `0x00800000` in every lane. +/// This is the smallest positive normal binary32 value, not a tuned gameplay +/// threshold. +pub const CONTACT_TANGENT_MINIMUM_SQUARED: f32 = f32::from_bits(0x0080_0000); +pub const CONTACT_FALLBACK_HALF: f32 = f32::from_bits(0x3F00_0000); + +/// The five 16-byte body vectors copied into a contact's solver workspace. +/// +/// Original member names and offsets are independently present in preserved +/// Skate-era EA SDK DWARF and are confirmed by TU3's loads: +/// +/// - `mCom/mId` at body `+0x10`; +/// - `mIfull/mInvm` at `+0x70`; +/// - `mIsplt/mState` at `+0x80`; +/// - `mForce/mKine` at `+0x90`; +/// - `mTorque/mCool` at `+0xa0`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactBodyState { + /// ID written to the contact header from the collision queue's body-ref + /// entry. It is kept separate from `reaction_id` because TU3 loads them + /// from different records. + pub contact_body_id: u32, + pub center_of_mass: Vector3, + pub reaction_id: u32, + /// Retail packed world inverse-inertia vector + /// `mIfull = (Ixx, Ixy, Ixz)`. + pub inverse_inertia_full: Vector3, + pub inverse_mass: f32, + /// Retail packed world inverse-inertia vector + /// `mIsplt = (Izz, Iyy, Iyz)`. + pub inverse_inertia_split: Vector3, + pub state: u32, + pub force_acceleration: Vector3, + pub kinetic_energy: f32, + pub torque_acceleration: Vector3, + pub cool_down: u32, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, +} + +/// Collision result and already-combined material values consumed by TU3's +/// contact generator. +/// +/// The material-combine routine that produces the three scalars is outside the +/// recovered block and remains deliberately explicit at this boundary. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactInput { + pub position_on_a: Vector3, + pub position_on_b: Vector3, + /// Positive normal impulse acts along this vector on A and opposite it on + /// B. For wheel A over static ground B, this points upward. Collision query + /// adapters must preserve this solver convention when ordering the pair. + pub normal: Vector3, + pub restitution: f32, + pub static_friction: f32, + pub dynamic_friction: f32, + pub tag: u32, +} + +/// Three scalar fields in the game-side physical material record consumed by +/// TU3 `0x82763078`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactMaterial { + pub static_friction: f32, + pub dynamic_friction: f32, + pub restitution: f32, +} + +/// Exact game-side material combine used immediately before the recovered +/// contact writers. +/// +/// TU3 performs three independent ordered comparisons: +/// +/// - static friction is the greater operand; +/// - dynamic friction is the greater operand; +/// - restitution is the lesser operand. +/// +/// The comparisons are kept explicit instead of using `f32::max`/`min` +/// because the retail branch behavior for unordered inputs is operand-order +/// sensitive: equality and unordered comparisons select B for all three fields. +/// Scalar lfs/stfs also quiet a selected signalling NaN while preserving payload. +pub fn combine_contact_materials( + material_a: RetailContactMaterial, + material_b: RetailContactMaterial, +) -> RetailContactMaterial { + let static_friction = if material_a.static_friction > material_b.static_friction { + material_a.static_friction + } else { + material_b.static_friction + }; + let dynamic_friction = if material_a.dynamic_friction > material_b.dynamic_friction { + material_a.dynamic_friction + } else { + material_b.dynamic_friction + }; + let restitution = if material_a.restitution < material_b.restitution { + material_a.restitution + } else { + material_b.restitution + }; + RetailContactMaterial { + static_friction: material_scalar_store(static_friction), + dynamic_friction: material_scalar_store(dynamic_friction), + restitution: material_scalar_store(restitution), + } +} + +fn material_scalar_store(value: f32) -> f32 { + if value.is_nan() { + f32::from_bits(value.to_bits() | 0x0040_0000) + } else { + value + } +} + +/// Scalar representation of the complete logical 256-byte retail contact. +/// +/// `body_a_workspace` and `body_b_workspace` correspond to the ten alternating +/// 16-byte vectors at offsets `0x60..0xff`. Keeping those copied values in the +/// record is important: `ContactBatchBuild` reads them from the contact and +/// does not dereference the live bodies. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContact { + pub position_on_a: Vector3, + pub body_a_id: u32, + pub position_on_b: Vector3, + pub body_b_id: u32, + pub normal: Vector3, + pub restitution: f32, + pub tangent_0: Vector3, + pub static_friction: f32, + pub tangent_1: Vector3, + pub dynamic_friction: f32, + /// Velocity at B's contact point minus velocity at A's contact point. + pub relative_velocity: Vector3, + pub tag: u32, + pub body_a_workspace: RetailContactWorkspace, + pub body_b_workspace: RetailContactWorkspace, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactWorkspace { + pub center_of_mass: Vector3, + pub reaction_id: u32, + pub inverse_inertia_full: Vector3, + pub inverse_mass: f32, + pub inverse_inertia_split: Vector3, + pub state: u32, + pub force_acceleration: Vector3, + pub kinetic_energy: f32, + pub torque_acceleration: Vector3, + pub cool_down: u32, +} + +impl From for RetailContactWorkspace { + fn from(body: RetailContactBodyState) -> Self { + Self { + center_of_mass: body.center_of_mass, + reaction_id: body.reaction_id, + inverse_inertia_full: body.inverse_inertia_full, + inverse_mass: body.inverse_mass, + inverse_inertia_split: body.inverse_inertia_split, + state: body.state, + force_acceleration: body.force_acceleration, + kinetic_energy: body.kinetic_energy, + torque_acceleration: body.torque_acceleration, + cool_down: body.cool_down, + } + } +} + +/// Port of TU3 `0x8277A828..0x8277AA88` and its duplicate +/// `0x8277BFAC..0x8277C21C`. +/// +/// VMX128 operation order is retained at the scalar-expression level: +/// +/// 1. compute each contact arm from the copied body center of mass; +/// 2. compute point rates as `linear + angular cross arm`; +/// 3. derive tangent 0 from `relative_velocity cross normal`; +/// 4. if that axis is subnormal, use the exact X/Z projection fallback; +/// 5. derive tangent 1 as `normal cross tangent_0`; +/// 6. copy the ten body-state vectors used by `ContactBatchBuild`. +/// +/// The retail input normal is already unit length. This routine intentionally +/// does not renormalize it or repair malformed collision input. +pub fn generate_contact( + input: RetailContactInput, + body_a: RetailContactBodyState, + body_b: RetailContactBodyState, +) -> RetailContact { + let arm_a = sub(input.position_on_a, body_a.center_of_mass); + let arm_b = sub(input.position_on_b, body_b.center_of_mass); + let point_velocity_a = point_velocity(body_a.linear_velocity, body_a.angular_velocity, arm_a); + let point_velocity_b = point_velocity(body_b.linear_velocity, body_b.angular_velocity, arm_b); + let relative_velocity = sub(point_velocity_b, point_velocity_a); + + let velocity_tangent = cross(relative_velocity, input.normal); + let velocity_tangent_squared = length_squared(velocity_tangent); + let (unnormalized_tangent, tangent_squared) = + if velocity_tangent_squared >= CONTACT_TANGENT_MINIMUM_SQUARED { + (velocity_tangent, velocity_tangent_squared) + } else { + fallback_tangent(input.normal) + }; + + // TU3 issues one reciprocal-square-root estimate and applies it to both + // axes. `tangent_1` therefore assumes the collision normal is unit length. + let inverse_length = super::reciprocal_sqrt::estimate(tangent_squared); + let tangent_0 = scale(unnormalized_tangent, inverse_length); + // The second cross product rounds the positive product first, then uses + // vnmsubfp for the negative term; tangent_0 uses the opposite order. + let n = input.normal; + let t = unnormalized_tangent; + let tangent_1 = scale( + Vector3::new( + (-t.y).mul_add(n.z, t.z.mul_add(n.y, 0.0)), + (-t.z).mul_add(n.x, t.x.mul_add(n.z, 0.0)), + (-t.x).mul_add(n.y, t.y.mul_add(n.x, 0.0)), + ), + inverse_length, + ); + + RetailContact { + position_on_a: input.position_on_a, + body_a_id: body_a.contact_body_id, + position_on_b: input.position_on_b, + body_b_id: body_b.contact_body_id, + normal: input.normal, + restitution: input.restitution, + tangent_0, + static_friction: input.static_friction, + tangent_1, + dynamic_friction: input.dynamic_friction, + relative_velocity, + tag: input.tag, + body_a_workspace: body_a.into(), + body_b_workspace: body_b.into(), + } +} + +/// Exact normal-only fallback selected by the TU3 VMX block. +fn fallback_tangent(normal: Vector3) -> (Vector3, f32) { + let candidate_x = Vector3::new( + (-normal.x).mul_add(normal.x, 1.0), + (-normal.y).mul_add(normal.x, 0.0), + (-normal.z).mul_add(normal.x, 0.0), + ); + let candidate_z = Vector3::new( + (-normal.x).mul_add(normal.z, 0.0), + (-normal.y).mul_add(normal.z, 0.0), + (-normal.z).mul_add(normal.z, 1.0), + ); + if (-normal.x).mul_add(normal.x, CONTACT_FALLBACK_HALF) >= -0.0 { + (candidate_x, candidate_x.x) + } else { + ( + Vector3::new(-candidate_z.x, -candidate_z.y, -candidate_z.z), + candidate_z.z, + ) + } +} + +fn point_velocity(linear: Vector3, omega: Vector3, arm: Vector3) -> Vector3 { + // Native folds the linear velocity into two fused cross-product terms. + Vector3::new( + arm.z.mul_add(omega.y, (-arm.y).mul_add(omega.z, linear.x)), + arm.x.mul_add(omega.z, (-arm.z).mul_add(omega.x, linear.y)), + arm.y.mul_add(omega.x, (-arm.x).mul_add(omega.y, linear.z)), + ) +} + +fn sub(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new(left.x - right.x, left.y - right.y, left.z - right.z) +} + +fn scale(vector: Vector3, scalar: f32) -> Vector3 { + Vector3::new( + vector.x.mul_add(scalar, 0.0), + vector.y.mul_add(scalar, 0.0), + vector.z.mul_add(scalar, 0.0), + ) +} + +fn cross(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new( + left.y.mul_add(right.z, (-left.z).mul_add(right.y, 0.0)), + left.z.mul_add(right.x, (-left.x).mul_add(right.z, 0.0)), + left.x.mul_add(right.y, (-left.y).mul_add(right.x, 0.0)), + ) +} + +fn dot(left: Vector3, right: Vector3) -> f32 { + left.z.mul_add( + right.z, + left.y.mul_add(right.y, left.x.mul_add(right.x, 0.0)), + ) +} + +fn length_squared(vector: Vector3) -> f32 { + dot(vector, vector) +} + +#[cfg(test)] +#[path = "tests/contact.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/contact_feedback/board_reports.rs b/crates/skate-core/src/physics/contact_feedback/board_reports.rs new file mode 100644 index 00000000..15b29003 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/board_reports.rs @@ -0,0 +1,144 @@ +//! Solver observations consumed by the board's post-physics update. +//! +//! TU3 82AE1608 observes completed contact rows after integration; 827682B0 +//! then constructs at most sixteen reports per board, in contact order. +//! Rust owns the report storage and part identifiers. No guest pointers or +//! copied native map/locking infrastructure are needed for this single board. +use crate::math::Vector3; + +use super::super::{ + assembly::BodySnapshot, + board::{BODY_COUNT, BodyId}, + board_step::CollisionBody, + contact_solver::RetailContactJacobian, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardContactReport { + pub part: BodyId, + pub other: CollisionBody, + pub is_body_a: bool, + /// Oriented toward this board, as ContactInfo+16. + pub normal: Vector3, + pub position: Vector3, + /// Linear velocities after integration. Native does not add omega cross r. + pub relative_linear_velocity: Vector3, + pub other_surface: u16, + /// The underlying spy retains A's orientation even in a B-side report. + pub normal_force_on_a: Vector3, + pub friction_force_on_a: Vector3, + pub tangents: [Vector3; 2], +} + +/// Replace this frame's reports from the actual solved rows. All seven board +/// parts share one report budget and one owner; their self contacts are excluded +/// from reporting, independently of whether they participated in the solver. +pub(crate) fn collect( + output: &mut Vec, + contacts: &[RetailContactJacobian], + bodies: &[BodySnapshot; BODY_COUNT], + frequency: f32, +) { + output.clear(); + let frequency_squared = frequency * frequency; + for contact in contacts { + let words = contact.words(); + let impulse = contact.accumulated_impulse(); + // 82AE1668..168C: enabled spy and strictly positive normal response. + if words[11] & 8 == 0 || !(impulse[0] > 0.0) { + continue; + } + //Other assemblies share this solve. Their own output owners consume + //their spies; only board/world records are routed to this collector. + if !((words[31] < BODY_COUNT as u32 && words[43] == u32::MAX) + || (words[43] < BODY_COUNT as u32 && words[31] == u32::MAX)) { + continue; + } + let a = body_id(words[31]); + let b = body_id(words[43]); + let (part, other, is_body_a) = match (a, b) { + (CollisionBody::Board(part), CollisionBody::StaticWorld) => (part, b, true), + (CollisionBody::StaticWorld, CollisionBody::Board(part)) => (part, a, false), + // 82768418: same board/owner exclusion; no world report subscriber. + _ => continue, + }; + if output.len() == 16 { + break; + } + let normal = vector(words, 28); + let tangents = [vector(words, 40), vector(words, 52)]; + let a_position = add(center(a, bodies), vector(words, 0)); + let b_position = add(center(b, bodies), vector(words, 4)); + let a_weight = f32::from_bits(words[35]); + let b_weight = f32::from_bits(words[39]); + let inverse_weight = 1.0 / (b_weight + a_weight); + let position = combine(a_position, a_weight, scale(b_position, b_weight)); + let friction = combine(tangents[0], impulse[1], scale(tangents[1], impulse[2])); + let sign = if is_body_a { 1.0 } else { -1.0 }; + let this = CollisionBody::Board(part); + output.push(BoardContactReport { + part, + other, + is_body_a, + normal: scale(normal, sign), + position: scale(position, inverse_weight), + relative_linear_velocity: subtract(velocity(this, bodies), velocity(other, bodies)), + other_surface: if is_body_a { + words[55] as u16 + } else { + (words[55] >> 16) as u16 + }, + normal_force_on_a: scale(scale(normal, impulse[0]), frequency_squared), + friction_force_on_a: scale(friction, frequency_squared), + tangents, + }); + } +} + +fn body_id(value: u32) -> CollisionBody { + if value == u32::MAX { + CollisionBody::StaticWorld + } else { + CollisionBody::Board(BodyId::ORDER[value as usize]) + } +} + +fn center(body: CollisionBody, bodies: &[BodySnapshot; BODY_COUNT]) -> Vector3 { + match body { + CollisionBody::Board(id) => bodies[id.index()].rates.position, + CollisionBody::StaticWorld => Vector3::ZERO, + CollisionBody::Attached(_) => unreachable!("collector selects board/world contacts above"), + } +} + +fn velocity(body: CollisionBody, bodies: &[BodySnapshot; BODY_COUNT]) -> Vector3 { + match body { + CollisionBody::Board(id) => bodies[id.index()].rates.linear_velocity, + CollisionBody::StaticWorld => Vector3::ZERO, + CollisionBody::Attached(_) => unreachable!("collector selects board/world contacts above"), + } +} + +fn vector(words: &[u32; 64], offset: usize) -> Vector3 { + Vector3::new( + f32::from_bits(words[offset]), + f32::from_bits(words[offset + 1]), + f32::from_bits(words[offset + 2]), + ) +} +fn scale(v: Vector3, scale: f32) -> Vector3 { + Vector3::new(v.x * scale, v.y * scale, v.z * scale) +} +fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +fn subtract(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +fn combine(a: Vector3, weight: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(weight, b.x), + a.y.mul_add(weight, b.y), + a.z.mul_add(weight, b.z), + ) +} diff --git a/crates/skate-core/src/physics/contact_feedback/collision_info.rs b/crates/skate-core/src/physics/contact_feedback/collision_info.rs new file mode 100644 index 00000000..6fd9a583 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/collision_info.rs @@ -0,0 +1,73 @@ +use super::CollisionInfo; + +fn word(bytes: &[u8], offset: usize) -> u32 { + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) +} + +fn put(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} + +fn up(bytes: &mut [u8], offset: usize) { + bytes[offset..offset + 16].fill(0); + put(bytes, offset + 4, 0x3f80_0000); +} + +/// Complete 82C00CA0 selective per-frame reset. Wheel surface/audio/object +/// slots and line-test state survive; this differs from startup 82C00D68. +pub fn reset_frame_collision_info(info: &mut CollisionInfo) { + up(info, 0); + for i in 0..7 { + up(info, 32 + i * 16); + for offset in [144, 368, 480] { + info[offset + i * 16..offset + i * 16 + 16].fill(0); + } + info[780 + i] = 0; + } + for i in 4..7 { + for offset in [692, 720, 748] { + put(info, offset + i * 4, 0); + } + } + info[592..608].fill(0); + info[688..692].fill(1); + for offset in [776, 788, 796, 800] { + put(info, offset, 0); + } + info[804] = 0; + info[805] = 0; + let flags = word(info, 808) & 0x01ff_ffff; + put(info, 808, flags); +} + +/// Complete 82C08818 surface vote. Noncontacting wheels still contribute one +/// vote; contacting wheels contribute four. Equal counts favor the lower ID. +/// IDs14/15 are accumulated but not considered by the native winner scan. +pub fn choose_physics_surface(info: &mut CollisionInfo) { + let surfaces = std::array::from_fn(|i| word(info, 692 + i * 4)); + let contacts = std::array::from_fn(|i| info[780 + i] != 0); + let result = choose_surface(surfaces, contacts, word(info, 808) & 0x0200_0000 != 0); + put(info, 776, result); +} + +/// Typed82C08818 boundary. Values are the retained wheel surface outputs; +/// query misses and contact replacements belong to their original producers. +pub fn choose_surface(surfaces: [u32; 4], contacts: [bool; 4], forced_twelve: bool) -> u32 { + let mut votes = [0u32; 16]; + for i in 0..4 { + let surface = surfaces[i] as usize; + if surface != 0 { + assert!(surface < 16, "native surface histogram index exceeded"); + votes[surface] += if contacts[i] { 4 } else { 1 }; + } + } + let mut winner = 1; + let mut maximum = 0; + for (surface, &count) in votes.iter().enumerate().take(14).skip(1) { + if count > maximum { + maximum = count; + winner = surface as u32; + } + } + if forced_twelve { 12 } else { winner } +} diff --git a/crates/skate-core/src/physics/contact_feedback/compression.rs b/crates/skate-core/src/physics/contact_feedback/compression.rs new file mode 100644 index 00000000..e30c9ba3 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/compression.rs @@ -0,0 +1,111 @@ +use crate::physics::board_pose::{PartPose, PoseMatrix}; + +type V = [f32; 4]; +type M = [V; 4]; + +fn matrix(words: PoseMatrix) -> M { + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(words[i * 4 + j]))) +} +fn madd(a: V, s: f32, b: V) -> V { + std::array::from_fn(|i| a[i].mul_add(s, b[i])) +} +fn scale(a: V, s: f32) -> V { + a.map(|v| v * s) +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} + +/// Complete 82585B58. Live bodies return their mass-frame basis/COM directly. +/// A body-less part with a local mass frame removes that frame from its cache. +/// This is distinct from 82585CB0's geometric-part transform getter. +pub fn part_mass_transform(part: &PartPose) -> PoseMatrix { + if let Some(body) = &part.body { + return std::array::from_fn(|i| body[[16, 20, 24, 4][i / 4] + i % 4]); + } + let Some(local) = part.local_mass_frame else { + return part.transform; + }; + let local = matrix(local); + let basis: [V; 3] = std::array::from_fn(|i| [local[0][i], local[1][i], local[2][i], 0.0]); + let neg = local[3].map(|v| 0.0 - v); + let position = madd( + basis[0], + neg[0], + madd(basis[1], neg[1], scale(basis[2], neg[2])), + ); + let cached = matrix(part.transform); + let result: M = std::array::from_fn(|i| { + let v = if i == 3 { position } else { basis[i] }; + let first = if i == 3 { + madd(cached[0], v[0], cached[3]) + } else { + scale(cached[0], v[0]) + }; + madd(cached[2], v[2], madd(cached[1], v[1], first)) + }); + std::array::from_fn(|i| result[i / 4][i % 4].to_bits()) +} + +/// Complete 82C08968: observe wheel COM positions in inverse deck mass space, +/// average native pairs0/1 and2/3, subtract board+8380, store+8372/+8376 only. +pub fn calculate_wheel_compressions(board: &mut [u8; 8400], parts: &[PartPose; 7]) { + let deck = matrix(part_mass_transform(&parts[6])); + let wheels = std::array::from_fn(|i| matrix(part_mass_transform(&parts[i]))[3]); + let distance = f32::from_bits(u32::from_be_bytes(board[8380..8384].try_into().unwrap())); + let heights = average_wheel_compressions(deck, wheels, distance); + for (offset, height) in [(8372, heights[0]), (8376, heights[1])] { + let word = if height.is_nan() { + height.to_bits() | 0x0040_0000 + } else { + height.to_bits() + }; + board[offset..offset + 4].copy_from_slice(&word.to_be_bytes()); + } +} + +///82C08968, with live mass-frame observations rather than a byte-buffer owner. +///Rest height is physicstrucks/default.TruckYPos, copied to8380 by82C08D18. +pub fn average_wheel_compressions(deck: M, wheel_positions: [V; 4], rest_height: f32) -> [f32; 2] { + let c0 = cross(deck[1], deck[2]); + let c1 = cross(deck[2], deck[0]); + let c2 = cross(deck[0], deck[1]); + let determinant = dot(deck[0], c0); + // Native reciprocal estimate and two refinements. + let mut inverse = reciprocal_estimate(determinant); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(determinant, 1.0), inverse); + } + let basis: [V; 3] = std::array::from_fn(|i| scale([c0[i], c1[i], c2[i], c1[i]], inverse)); + let neg = deck[3].map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)); + let position = madd( + basis[2], + neg[2], + madd(basis[1], neg[1], scale(basis[0], neg[0])), + ); + let heights: [f32; 4] = std::array::from_fn(|i| { + let p = wheel_positions[i]; + let local = madd( + basis[2], + p[2], + madd(basis[1], p[1], madd(basis[0], p[0], position)), + ); + local[1] + }); + [ + (heights[0] + heights[1]) * 0.5 - rest_height, + (heights[2] + heights[3]) * 0.5 - rest_height, + ] +} + +fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} diff --git a/crates/skate-core/src/physics/contact_feedback/mod.rs b/crates/skate-core/src/physics/contact_feedback/mod.rs new file mode 100644 index 00000000..a64019f1 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/mod.rs @@ -0,0 +1,20 @@ +//! TU3 post-solver contact observations and their native record lifetimes. +pub(crate) mod board_reports; +mod collision_info; +mod compression; +mod reports; +mod spy; + +pub use board_reports::BoardContactReport; +pub use collision_info::{choose_physics_surface, choose_surface, reset_frame_collision_info}; +pub use compression::{ + average_wheel_compressions, calculate_wheel_compressions, part_mass_transform, +}; +pub use reports::{ + ContactReportBackend, ContactReportBuffer, ContactReportDispatchBackend, ContactTarget, + create_contact_reports, get_contact_reports, resolve_contact_reports, +}; +pub use spy::spy_contact_jacobians; + +/// SkateboardBody+64..+876, retained as guest big-endian bytes. +pub type CollisionInfo = [u8; 812]; diff --git a/crates/skate-core/src/physics/contact_feedback/reports.rs b/crates/skate-core/src/physics/contact_feedback/reports.rs new file mode 100644 index 00000000..21040190 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/reports.rs @@ -0,0 +1,130 @@ +/// Registered target for a rigid-body pointer (Island+80 lookup). +#[derive(Clone, Copy, Debug)] +pub struct ContactTarget { + pub body: u32, + pub owner: u32, + pub tag: u32, +} + +/// Supplied storage preserves every unwritten byte when records are reused. +pub struct ContactReportBuffer { + pub count: u32, + pub records: [[u8; 96]; 16], +} + +/// Container/locking boundary of 827682B0. The map operation inserts an absent +/// Body key; clearing resets existing vector ends without erasing keys/storage. +/// Allocated report slots and their padding are supplied by the backend. +pub trait ContactReportBackend { + fn lock(&mut self); + fn clear_report_counts(&mut self); + fn lookup_target(&mut self, rigid_body: u32) -> Option; + fn get_or_insert_reports(&mut self, body: u32) -> usize; + fn reports(&mut self, handle: usize) -> &mut ContactReportBuffer; + fn linear_velocity(&mut self, rigid_body: u32) -> [u32; 4]; + fn unlock(&mut self); +} + +/// Complete 82768250 lookup lifetime: lock, operator[] (possibly inserts), +/// unlock, return the live vector handle. No detached report copy is produced. +pub fn get_contact_reports(body: u32, backend: &mut impl ContactReportBackend) -> usize { + backend.lock(); + let handle = backend.get_or_insert_reports(body); + backend.unlock(); + handle +} + +/// Live tree traversal and Body virtual+8 dispatch boundary of 827685C8. +/// The backend retains native map order and resolves next after each callback. +pub trait ContactReportDispatchBackend { + fn first_report_entry(&mut self) -> Option; + fn next_report_entry(&mut self, handle: usize) -> Option; + fn report_count(&mut self, handle: usize) -> u32; + fn dispatch_reports(&mut self, handle: usize); +} + +/// Complete 827685C8. Visits every map entry, invokes only nonempty live vectors, +/// and performs no list clearing or local locking around callbacks. +pub fn resolve_contact_reports(backend: &mut impl ContactReportDispatchBackend) { + let mut entry = backend.first_report_entry(); + while let Some(handle) = entry { + if backend.report_count(handle) != 0 { + backend.dispatch_reports(handle); + } + entry = backend.next_report_entry(handle); + } +} + +/// Complete 827682B0 report construction/filter/order, with its native map and +/// lock operations supplied by the backend. `spy_base` retains native pointer +/// identity; reports borrow that simulation storage through the consuming phase. +pub fn create_contact_reports( + spies: &[[u32; 28]], + spy_base: u32, + backend: &mut impl ContactReportBackend, +) { + backend.lock(); + backend.clear_report_counts(); + for (index, spy) in spies.iter().enumerate() { + let a = backend.lookup_target(spy[24]); + let b = backend.lookup_target(spy[25]); + let a_body = a.map_or(0, |target| target.body); + let b_body = b.map_or(0, |target| target.body); + let a_owner = a.map_or(0, |target| target.owner); + let b_owner = b.map_or(0, |target| target.owner); + let same_body = a.is_some() && b.is_some() && a_body == b_body; + let excluded = same_body || (a_owner != 0 && a_owner == b_owner); + for (target, is_a) in [(a, true), (b, false)] { + let Some(target) = target else { + continue; + }; + // Native operator[] runs even for a subsequently excluded pair. + let handle = backend.get_or_insert_reports(target.body); + let count = backend.reports(handle).count; + if count >= 16 || excluded { + continue; + } + let other = if is_a { spy[25] } else { spy[24] }; + let this = if is_a { spy[24] } else { spy[25] }; + let other_velocity = backend.linear_velocity(other).map(f32::from_bits); + let this_velocity = backend.linear_velocity(this).map(f32::from_bits); + let normal: [u32; 4] = std::array::from_fn(|i| { + (f32::from_bits(spy[i]) * if is_a { 1.0 } else { -1.0 }).to_bits() + }); + let relative: [u32; 4] = + std::array::from_fn(|i| (this_velocity[i] - other_velocity[i]).to_bits()); + let buffer = backend.reports(handle); + let record = &mut buffer.records[count as usize]; + put( + record, + 0, + spy_base.wrapping_add((index as u32).wrapping_mul(112)), + ); + for i in 0..4 { + put(record, 16 + i * 4, normal[i]); + put(record, 32 + i * 4, spy[12 + i]); + put(record, 48 + i * 4, relative[i]); + } + put(record, 64, target.tag); + put( + record, + 68, + if is_a { + spy[26] & 0xffff + } else { + spy[26] >> 16 + }, + ); + put(record, 72, target.body); + put(record, 76, if is_a { b_body } else { a_body }); + record[80] = 0; + record[81] = u8::from(is_a); + buffer.count += 1; + } + } + backend.unlock(); +} + +fn put(record: &mut [u8; 96], offset: usize, value: u32) { + record[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} diff --git a/crates/skate-core/src/physics/contact_feedback/spy.rs b/crates/skate-core/src/physics/contact_feedback/spy.rs new file mode 100644 index 00000000..d3d75ab4 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/spy.rs @@ -0,0 +1,63 @@ +/// Complete TU3 82AE1608. Compacts eligible256-byte compiled contact rows into +///112-byte spies at the same buffer start. `spy_count` increments from its input +///value; it does not choose the write offset and is not reset by this routine. +/// `body_center` resolves the exact body pointer to its packed COM/ID vector. +pub fn spy_contact_jacobians( + storage: &mut [u32], + contact_count: u32, + spy_count: &mut u32, + frequency: f32, + mut body_center: impl FnMut(u32) -> [u32; 4], +) { + assert!(storage.len() >= contact_count as usize * 64); + let squared_frequency = frequency * frequency; + let mut write = 0; + for index in 0..contact_count as usize { + let start = index * 64; + if storage[start + 11] & 8 == 0 { + continue; + } + let impulse = vector(storage, start + 20); + if !(impulse[0] > 0.0) { + continue; + } + let a = storage[start + 31]; + let b = storage[start + 43]; + let b_center = body_center(b).map(f32::from_bits); + let a_center = body_center(a).map(f32::from_bits); + let a_position = vector(storage, start); + let b_position = vector(storage, start + 4); + let a_weight = f32::from_bits(storage[start + 35]); + let b_weight = f32::from_bits(storage[start + 39]); + let normal = vector(storage, start + 28); + let tangent0 = vector(storage, start + 40); + let tangent1 = vector(storage, start + 52); + let tangent: [f32; 4] = + std::array::from_fn(|i| tangent0[i].mul_add(impulse[1], tangent1[i] * impulse[2])); + let inverse = 1.0 / (b_weight + a_weight); + let position: [f32; 4] = std::array::from_fn(|i| { + (a_center[i] + a_position[i]) + .mul_add(a_weight, (b_center[i] + b_position[i]) * b_weight) + * inverse + }); + let tag = storage[start + 55]; + // All source vectors/scalars are loaded before the in-place stores. + storage[write + 24] = a; + storage[write + 25] = b; + storage[write + 26] = tag; + for i in 0..4 { + storage[write + 16 + i] = ((normal[i] * impulse[0]) * squared_frequency).to_bits(); + storage[write + 20 + i] = (tangent[i] * squared_frequency).to_bits(); + storage[write + i] = normal[i].to_bits(); + storage[write + 4 + i] = tangent0[i].to_bits(); + storage[write + 8 + i] = tangent1[i].to_bits(); + storage[write + 12 + i] = position[i].to_bits(); + } + *spy_count = spy_count.wrapping_add(1); + write += 28; + } +} + +fn vector(words: &[u32], offset: usize) -> [f32; 4] { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} diff --git a/crates/skate-core/src/physics/contact_layout.rs b/crates/skate-core/src/physics/contact_layout.rs new file mode 100644 index 00000000..e69a1fcf --- /dev/null +++ b/crates/skate-core/src/physics/contact_layout.rs @@ -0,0 +1,39 @@ +//! Serialization of the uncompiled TU3 256-byte contact, in guest word order. +use super::contact::{RetailContact, RetailContactWorkspace}; +use crate::math::Vector3; + +pub(super) fn encode(c: RetailContact) -> [u32; 64] { + let mut words = [0; 64]; + let header = [ + lanes(c.position_on_a, c.body_a_id), + lanes(c.position_on_b, c.body_b_id), + lanes(c.normal, c.restitution.to_bits()), + lanes(c.tangent_0, c.static_friction.to_bits()), + lanes(c.tangent_1, c.dynamic_friction.to_bits()), + lanes(c.relative_velocity, c.tag), + ]; + for (i, v) in header.into_iter().enumerate() { + words[i * 4..i * 4 + 4].copy_from_slice(&v); + } + for (i, (a, b)) in workspace(c.body_a_workspace) + .into_iter() + .zip(workspace(c.body_b_workspace)) + .enumerate() + { + words[24 + i * 8..28 + i * 8].copy_from_slice(&a); + words[28 + i * 8..32 + i * 8].copy_from_slice(&b); + } + words +} +fn lanes(v: Vector3, w: u32) -> [u32; 4] { + [v.x.to_bits(), v.y.to_bits(), v.z.to_bits(), w] +} +fn workspace(w: RetailContactWorkspace) -> [[u32; 4]; 5] { + [ + lanes(w.center_of_mass, w.reaction_id), + lanes(w.inverse_inertia_full, w.inverse_mass.to_bits()), + lanes(w.inverse_inertia_split, w.state), + lanes(w.force_acceleration, w.kinetic_energy.to_bits()), + lanes(w.torque_acceleration, w.cool_down), + ] +} diff --git a/crates/skate-core/src/physics/contact_solver.rs b/crates/skate-core/src/physics/contact_solver.rs new file mode 100644 index 00000000..dcf05cba --- /dev/null +++ b/crates/skate-core/src/physics/contact_solver.rs @@ -0,0 +1,87 @@ +//! TU3 ContactBatchBuild and the shared Horus contact solver. +//! Typed contacts are encoded only at the compiled-row boundary. Contact +//! iteration has a readable direct-source implementation; the native builder's +//! reciprocal-estimate dependency remains unavailable. +use super::{contact::RetailContact, rigid_body::RetailReactionCorrections}; +use crate::math::Vector3; + +pub mod tu3 { + pub const CONTACT_BATCH_BUILD: u32 = 0x82AE_10C8; + pub const ITERATIVE_CONSTRAINT_SOLVER: u32 = 0x82AE_27D0; +} +pub const ACTIVE_BODY: u32 = 4; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactJacobian { + pub(crate) words: [u32; 64], + pub reaction_index_a: usize, + pub reaction_index_b: usize, +} +impl RetailContactJacobian { + /// Native compiled layout, for inspection and independent comparisons. + pub fn words(&self) -> &[u32; 64] { + &self.words + } + pub fn accumulated_impulse(&self) -> [f32; 4] { + self.lanes(5) + } + /// Weighted velocity targets in xyz and position-only target in w. + pub fn target_impulse(&self) -> [f32; 4] { + self.lanes(6) + } + pub fn static_friction(&self) -> f32 { + f32::from_bits(self.words[15]) + } + pub fn dynamic_friction(&self) -> f32 { + f32::from_bits(self.words[19]) + } + pub fn angular_response_a(&self) -> [Vector3; 3] { + core::array::from_fn(|i| self.xyz(8 + i * 3)) + } + pub fn angular_response_b(&self) -> [Vector3; 3] { + core::array::from_fn(|i| self.xyz(9 + i * 3)) + } + fn lanes(&self, vector: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(self.words[vector * 4 + i])) + } + fn xyz(&self, vector: usize) -> Vector3 { + let v = self.lanes(vector); + Vector3::new(v[0], v[1], v[2]) + } +} + +/// Requests native contact construction. The unresolved reciprocal estimate +/// currently stops this call explicitly; callers must not treat compiled-row +/// solver tests as evidence that this producer is ready for gameplay. +/// Each input body workspace must include forces accumulated for this tick. +pub fn build_contact_jacobian(contact: RetailContact, time_step: f32) -> RetailContactJacobian { + assert!(time_step.is_finite() && time_step > 0.0); + let mut words = super::contact_layout::encode(contact); + super::solver::compile_contact(&mut words, time_step); + RetailContactJacobian { + words, + reaction_index_a: contact.body_a_workspace.reaction_id as usize, + reaction_index_b: contact.body_b_workspace.reaction_id as usize, + } +} + +/// Contact-only entry point uses the same kernel and reaction layout as the +/// complete contact -> joint -> drive iteration. Friction reads the preceding +/// normal impulse, before this pass publishes its new normal impulse. +pub fn solve_contact_jacobians( + contacts: &mut [RetailContactJacobian], + reactions: &mut [RetailReactionCorrections], + maximum_iterations: u32, +) { + super::solver::solve_constraints(contacts, &mut [], &mut [], reactions, maximum_iterations); +} +pub fn solve_contact_jacobian_iteration( + contacts: &mut [RetailContactJacobian], + reactions: &mut [RetailReactionCorrections], +) { + solve_contact_jacobians(contacts, reactions, 1); +} + +#[cfg(test)] +#[path = "tests/contact_solver.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/deck_angular_correction.rs b/crates/skate-core/src/physics/deck_angular_correction.rs new file mode 100644 index 00000000..1428f6ff --- /dev/null +++ b/crates/skate-core/src/physics/deck_angular_correction.rs @@ -0,0 +1,218 @@ +//! Deck angular corrections from TU3 82C07000 and 82C075B8. +//! These add angular acceleration before the shared solve. They never set yaw +//! or orientation directly. Both native fixed-step conversions are retained. +use super::{ + board_motion_output::{dot, inverse_length_squared}, + native_arithmetic::reciprocal_estimate, + rigid_body::RetailBodyRates, +}; +use crate::math::{Basis3, Vector3}; +const STEP: f32 = f32::from_bits(0x3c88_8889); +const NORMAL_MINIMUM: f32 = f32::from_bits(0x3586_37bd); //82F826F8 ->830BD350. + +/// 82C07000 first limits the requested angular displacement against the +/// displacement already supplied by angular velocity along that same axis. +pub fn apply_limited_displacement(body: &mut RetailBodyRates, requested: Vector3) { + let squared = dot(requested, requested); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let direction = if length > NORMAL_MINIMUM { + scale(requested, inverse) + } else { + Vector3::ZERO + }; + let existing = scale( + direction, + dot(direction, scale(body.angular_velocity, STEP)), + ); + let remainder = subtract(requested, existing); + let remainder = if dot(remainder, requested) < 0.0 { + Vector3::ZERO + } else { + remainder + }; + let selected = if dot(existing, requested) < 0.0 { + requested + } else { + remainder + }; + apply_angular_displacement(body, selected); +} + +/// 82C075B8. The inverse of the current world inverse-inertia tensor converts +/// displacement/step to torque; another step division and the original tensor +/// convert it to angular acceleration. Do not algebraically cancel the tensor +/// pair: its rounded cofactors and ordered products are observable. +pub fn apply_angular_displacement(body: &mut RetailBodyRates, displacement: Vector3) { + let tensor = body.world_inverse_inertia; + let inverse = invert_symmetric(tensor); + let inverse_step = reciprocal_refined(STEP); + let target_rate = scale(displacement, inverse_step); + let momentum = multiply(inverse, target_rate); + let torque = scale(momentum, reciprocal_refined(STEP)); + let acceleration = multiply(tensor, torque); + body.torque_acceleration = Vector3::new( + body.torque_acceleration.x + acceleration.x, + body.torque_acceleration.y + acceleration.y, + body.torque_acceleration.z + acceleration.z, + ); + // Native accumulator +172 shares its vector with the cooldown word. + body.cool_down = 0; +} + +fn invert_symmetric(tensor: Basis3) -> Basis3 { + let [a, b, c] = tensor.columns.map(|[x, y, z]| Vector3::new(x, y, z)); + let first = cross(b, c); + let second = cross(c, a); + let third = cross(a, b); + let inverse_det = reciprocal_refined(dot(a, first)); + Basis3 { + columns: [ + [first.x, second.x, third.x].map(|x| x * inverse_det), + [first.y, second.y, third.y].map(|x| x * inverse_det), + [first.z, second.z, third.z].map(|x| x * inverse_det), + ], + } +} +fn reciprocal_refined(value: f32) -> f32 { + let estimate = reciprocal_estimate(value); + let first = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + first.mul_add((-first).mul_add(value, 1.0), first) +} +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +fn multiply(matrix: Basis3, v: Vector3) -> Vector3 { + let lane = |i: usize| { + matrix.columns[2][i].mul_add( + v.z, + matrix.columns[1][i].mul_add(v.y, matrix.columns[0][i] * v.x), + ) + }; + Vector3::new(lane(0), lane(1), lane(2)) +} +fn scale(v: Vector3, x: f32) -> Vector3 { + Vector3::new(v.x * x, v.y * x, v.z * x) +} +fn subtract(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::rigid_body::RetailQuaternion; + fn body() -> RetailBodyRates { + let identity = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }; + RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: identity, + world_inverse_inertia: Basis3 { + columns: [[3.0, 0.2, 0.1], [0.2, 2.0, 0.3], [0.1, 0.3, 1.0]], + }, + position: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 3, + } + } + #[test] + fn correction_enters_accumulator_and_respects_existing_angular_motion() { + let request = Vector3::new(0.02, 0.01, -0.03); + let mut state = body(); + apply_angular_displacement(&mut state, request); + for (actual, want) in [ + (state.torque_acceleration.x, 72.0), + (state.torque_acceleration.y, 36.0), + (state.torque_acceleration.z, -108.0), + ] { + assert!((actual - want).abs() < 0.0001); + } + assert_eq!(state.orientation, RetailQuaternion::IDENTITY); + assert_eq!(state.cool_down, 0); + let mut overshoot = body(); + overshoot.angular_velocity = Vector3::new(0.0, 3.0, 0.0); + apply_limited_displacement(&mut overshoot, Vector3::new(0.0, 0.02, 0.0)); + assert_eq!(overshoot.torque_acceleration, Vector3::ZERO); + let mut against = body(); + against.angular_velocity = Vector3::new(0.0, -3.0, 0.0); + apply_limited_displacement(&mut against, Vector3::new(0.0, 0.02, 0.0)); + assert!((against.torque_acceleration.y - 72.0).abs() < 0.0001); + let mut ground = body(); + ground.basis = Basis3 { columns: [[0., -1., 0.], [1., 0., 0.], [0., 0., 1.]] }; + apply_ground_body_torque(&mut ground); + assert!((ground.torque_acceleration.x + 1.815).abs() < 0.00001); + assert!((ground.torque_acceleration.y + 0.121).abs() < 0.00001); + assert!((ground.torque_acceleration.z + 0.0605).abs() < 0.00001); + assert_eq!(ground.force_acceleration, Vector3::ZERO); + } +} + +/// Ground board update82D389DC..82D38C98. Skateboard+16 is the cached deck +/// rigid body (ctor82C01278); this torque therefore enters the same accumulator. +/// The inline pow calculation has fixed inputs10 and-1. Retain its two +/// coefficient trees and reciprocal refinements instead of replacing it by.1. +pub fn apply_ground_body_torque(body: &mut RetailBodyRates) { + let local = Vector3::new(0.0, ground_torque_scalar(), 0.0); + let world = multiply(body.basis, local); + let acceleration = multiply(body.world_inverse_inertia, world); + body.torque_acceleration = Vector3::new( + acceleration.x + body.torque_acceleration.x, + acceleration.y + body.torque_acceleration.y, + acceleration.z + body.torque_acceleration.z, + ); + body.cool_down = 0; +} +fn ground_torque_scalar() -> f32 { + // Positive fixed input10 has exponent3 and mantissa1.25. Thus the native + // vlog estimate followed by floor selects3; its sign/special-value masks + // cannot select another branch for these immutable instruction inputs. + let x = 0.25_f32; + let square = x * x; + let cube = x * square; + let fourth = square * square; + let log = [ + 0x3fb8aa0e, 0xbf389e52, 0x3ef5162d, 0xbeb1d204, 0x3e77adbd, 0xbe0cd4fb, 0x3d5541c6, + 0xbc188b0b, + ] + .map(f32::from_bits); + let low = cube.mul_add(log[3], square.mul_add(log[2], x.mul_add(log[1], log[0]))); + let high = cube.mul_add(log[7], square.mul_add(log[6], x.mul_add(log[5], log[4]))); + let exponent = (-x).mul_add(fourth.mul_add(high, low), -3.0); + let integral = exponent.floor(); + let fraction = exponent - integral; + let square = fraction * fraction; + let cube = fraction * square; + let fourth = square * square; + let exp = [ + 0x3f800000, 0xbf317218, 0x3e75fded, 0xbd6357ca, 0x3c1d8c54, 0xbaae1854, 0x391aa7d7, + 0xb7364261, + ] + .map(f32::from_bits); + let low = cube.mul_add( + exp[3], + square.mul_add(exp[2], fraction.mul_add(exp[1], exp[0])), + ); + let high = cube.mul_add( + exp[7], + square.mul_add(exp[6], fraction.mul_add(exp[5], exp[4])), + ); + let polynomial = fourth.mul_add(high, low); + //vexptefp sees an integral exponent here, so its power of two is exact. + let power_of_two = f32::from_bits(((127 + integral as i32) as u32) << 23); + (power_of_two * reciprocal_refined(polynomial)) * f32::from_bits(0xc0c1_999a) +} + diff --git a/crates/skate-core/src/physics/drive_frames.rs b/crates/skate-core/src/physics/drive_frames.rs new file mode 100644 index 00000000..66459b34 --- /dev/null +++ b/crates/skate-core/src/physics/drive_frames.rs @@ -0,0 +1,431 @@ +//! Scalar port of Skate 3 TU3's skateboard drive-frame construction. +//! +//! `SkateboardBody::SetDriveFrames2` (`0x82C0C088`) writes an identity matrix +//! to the child/second-body frame and writes the computed relative matrix to +//! the parent/first-body frame. The retail matrices are affine column-basis +//! matrices (`Ri`, `Up`, `At`, translation). This module keeps that convention +//! explicit and does not substitute a Bevy joint. + +use super::{ + board::*, + drive_parameters::{RetailDriveFrameRaw, RetailDriveFramesRaw}, + mass::retail_deck_mass_properties, + native_arithmetic, + rigid_body::RetailQuaternion, +}; +use crate::{ + math::{Basis3, Vector3}, + trigonometry, +}; + +#[path = "construction/authored_transforms.rs"] +mod authored; +pub use authored::{AuthoredTransformInputs, authored_body_pose_records, authored_body_transforms}; + +pub mod tu3 { + pub const CALCULATE_TRUCK_TRANSFORMS: u32 = 0x82C0_BC90; + pub const SET_DRIVE_FRAMES_2: u32 = 0x82C0_C088; + pub const MATRIX_TO_FRAME_A: u32 = 0x82BD_3A10; + pub const MATRIX_TO_FRAME_B: u32 = 0x82BD_3BD0; + pub const PART_SET_TRANSFORM: u32 = 0x82BD_4318; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailAffineTransform { + /// Column vectors in retail `Ri`, `Up`, `At` order. + pub basis: Basis3, + pub translation: Vector3, +} + +impl RetailAffineTransform { + pub const IDENTITY: Self = Self { + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + translation: Vector3::ZERO, + }; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveFrame { + pub orientation: RetailQuaternion, + pub translation: Vector3, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveFrames { + /// Native child/second-body record written by `SetChildFrame` at +0/+16. + pub body_a: RetailDriveFrame, + /// Native parent/first-body record written by `SetParentFrame` at +32/+48. + pub body_b: RetailDriveFrame, +} + +/// Inputs read by TU3 `SkateboardBody::CalculateTruckTransforms`. +/// +/// The two longitudinal fields retain the decoded retail attribute names. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailTruckTransformInputs { + pub deck_mid_length: f32, + pub truck_z_position_front: f32, + pub truck_z_position_back: f32, + pub truck_y_position: f32, + pub truck_rotation_axis_angle_degrees: f32, +} + +pub const RETAIL_DEFAULT_TRUCK_TRANSFORM_INPUTS: RetailTruckTransformInputs = + RetailTruckTransformInputs { + deck_mid_length: RETAIL_DECK_MID_LENGTH, + truck_z_position_front: RETAIL_TRUCK_Z_POSITION_FRONT, + truck_z_position_back: RETAIL_TRUCK_Z_POSITION_BACK, + truck_y_position: RETAIL_TRUCK_Y_POSITION, + truck_rotation_axis_angle_degrees: RETAIL_TRUCK_ROTATION_AXIS_ANGLE_DEGREES, + }; + +/// TU3 `SkateboardBody::CalculateTruckTransforms` (`0x82C0BC90`) evaluated +/// from the stock skater-collection inputs. +/// +/// The returned order is the native `+7712`, `+7776` storage order. The names +/// of the front/back Z inputs are retained even though the native slots do not +/// follow an intuitive front/back naming order. +pub fn default_truck_transforms() -> [RetailAffineTransform; 2] { + calculate_truck_transforms(RETAIL_DEFAULT_TRUCK_TRANSFORM_INPUTS) +} + +/// Authored part matrices from `82C0ADF0`, evaluated from stock collection inputs. +/// These are the inputs to Part::SetTransform, not settled or live COM poses. +pub fn default_body_transforms() -> [RetailAffineTransform; BODY_COUNT] { + authored_body_transforms(AuthoredTransformInputs::STOCK) +} + +/// Live center-of-mass transforms emitted by TU3 `Part::SetTransform` +/// (`0x82BD4318`) from the seven authored part transforms. +pub fn default_live_body_transforms() -> [RetailAffineTransform; BODY_COUNT] { + let mut transforms = default_body_transforms(); + let deck_mass_frame = retail_deck_mass_properties().local_mass_frame; + transforms[BodyId::Deck.index()] = compose_affine( + transforms[BodyId::Deck.index()], + inverse_affine(RetailAffineTransform { + basis: deck_mass_frame.basis, + translation: deck_mass_frame.translation, + }), + ); + transforms +} + +/// Quaternions written beside the live center-of-mass transforms by +/// `Part::SetTransform`. +pub fn default_live_body_orientations() -> [RetailQuaternion; BODY_COUNT] { + default_live_body_transforms().map(|transform| retail_quaternion_from_basis(transform.basis)) +} + +/// Deck-center height that places the initialized wheel spheres exactly on a +/// level Y=0 riding surface. +/// +/// This is a geometry-derived fixture placement, not a tuned suspension or +/// contact constant. +pub const RETAIL_LEVEL_GROUND_DECK_CENTER_HEIGHT: f32 = + RETAIL_WHEEL_RADIUS - RETAIL_TRUCK_Y_POSITION; + +/// Zero-steering truck/deck frames in native drive order. TU3 consumes the +/// `+7776` transform before the `+7712` transform. +pub fn default_truck_drive_frames() -> [RetailDriveFrames; 2] { + let base = default_truck_transforms(); + [ + set_drive_frames_2(RetailAffineTransform::IDENTITY, base[1]), + set_drive_frames_2(RetailAffineTransform::IDENTITY, base[0]), + ] +} + +/// Wheel/truck drive frames emitted by the four `CreateWheelDrives` calls. +pub fn default_wheel_drive_frames() -> [RetailDriveFrames; 4] { + let positive = wheel_drive_frame(AuthoredTransformInputs::STOCK.wheel_x_distance); + let negative = wheel_drive_frame(-AuthoredTransformInputs::STOCK.wheel_x_distance); + [positive, negative, positive, negative] +} + +/// Matrix stage of `SkateboardBody::SetDriveFrames2`. +/// +/// The first input is the parent/first body's world transform and the second +/// input is the child/second body's world transform. TU3 writes: +/// +/// - identity to the child frame through `SetChildFrame` (`0x82BD3A10`); +/// - `transpose(parent.basis) * child.basis` and the corresponding relative +/// translation to the parent frame through `SetParentFrame` (`0x82BD3BD0`). +/// +pub fn set_drive_frames_2( + parent_world: RetailAffineTransform, + child_world: RetailAffineTransform, +) -> RetailDriveFrames { + let parent_frame = relative_parent_transform(parent_world, child_world); + let child_frame = RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }; + + RetailDriveFrames { + body_a: child_frame, + body_b: RetailDriveFrame { + orientation: retail_quaternion_from_basis(parent_frame.basis), + translation: parent_frame.translation, + }, + } +} + +/// Dominant-component matrix-to-quaternion calculation in TU3 child/parent +/// frame setters `0x82BD3A10` and `0x82BD3BD0`. +/// +/// The function evaluates the same four component candidates as the vector +/// implementation, selects trace/X/Y/Z with its strict comparisons, and uses +/// the accepted Xenon reciprocal-square-root estimate with two recovered fused +/// refinements. Matrix storage remains column-major `Ri`, `Up`, `At`. +pub fn retail_quaternion_from_basis(basis: Basis3) -> RetailQuaternion { + let m00 = basis.columns[0][0]; + let m01 = basis.columns[1][0]; + let m02 = basis.columns[2][0]; + let m10 = basis.columns[0][1]; + let m11 = basis.columns[1][1]; + let m12 = basis.columns[2][1]; + let m20 = basis.columns[0][2]; + let m21 = basis.columns[1][2]; + let m22 = basis.columns[2][2]; + + let trace = (m00 + m11) + m22; + if trace > 0.0 { + quaternion_candidate( + (m00 + m11) + (m22 + 1.0), + [m21 - m12, m02 - m20, m10 - m01, 0.0], + 3, + ) + } else if m00 > m11 && m00 > m22 { + quaternion_candidate( + (m00 - m11) + ((0.0 - m22) + 1.0), + [0.0, m01 + m10, m02 + m20, m21 - m12], + 0, + ) + } else if m11 > m22 { + quaternion_candidate( + ((0.0 - m00) + m11) + ((0.0 - m22) + 1.0), + [m01 + m10, 0.0, m12 + m21, m02 - m20], + 1, + ) + } else { + quaternion_candidate( + ((0.0 - m00) - m11) + (m22 + 1.0), + [m02 + m20, m12 + m21, 0.0, m10 - m01], + 2, + ) + } +} + +fn refined_reciprocal_square_root(value: f32) -> f32 { + let mut estimate = native_arithmetic::reciprocal_square_root_estimate(value); + for _ in 0..2 { + let square = estimate * estimate; + let half_estimate = estimate * 0.5; + let residual = (-value).mul_add(square, 1.0); + estimate = half_estimate.mul_add(residual, estimate); + } + estimate +} + +fn wheel_drive_frame(z_offset: f32) -> RetailDriveFrames { + RetailDriveFrames { + body_a: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }, + body_b: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::new(0.0, 0.0, z_offset), + }, + } +} + +fn inverse_affine(transform: RetailAffineTransform) -> RetailAffineTransform { + let columns = transform.basis.columns; + let inverse_basis = Basis3 { + columns: [ + [columns[0][0], columns[1][0], columns[2][0]], + [columns[0][1], columns[1][1], columns[2][1]], + [columns[0][2], columns[1][2], columns[2][2]], + ], + }; + let translated = multiply_basis_vector(inverse_basis, transform.translation); + RetailAffineTransform { + basis: inverse_basis, + translation: Vector3::new(0.0 - translated.x, 0.0 - translated.y, 0.0 - translated.z), + } +} + +fn compose_affine( + parent: RetailAffineTransform, + local: RetailAffineTransform, +) -> RetailAffineTransform { + let translated = multiply_basis_vector(parent.basis, local.translation); + RetailAffineTransform { + basis: multiply_basis(parent.basis, local.basis), + translation: Vector3::new( + translated.x + parent.translation.x, + translated.y + parent.translation.y, + translated.z + parent.translation.z, + ), + } +} + +fn multiply_basis_vector(basis: Basis3, vector: Vector3) -> Vector3 { + let component = |lane| { + let first = vector.x * basis.columns[0][lane]; + let second = vector.y.mul_add(basis.columns[1][lane], first); + vector.z.mul_add(basis.columns[2][lane], second) + }; + Vector3::new(component(0), component(1), component(2)) +} + +fn quaternion_candidate(sum: f32, companion: [f32; 4], dominant: usize) -> RetailQuaternion { + let inverse_root = refined_reciprocal_square_root(sum); + let half_inverse_root = inverse_root * 0.5; + let half_root = (sum * inverse_root) * 0.5; + let mut lanes = companion.map(|value| value * half_inverse_root); + lanes[dominant] = half_root; + RetailQuaternion { + x: lanes[0], + y: lanes[1], + z: lanes[2], + w: lanes[3], + } +} + +impl From for RetailDriveFramesRaw { + fn from(frames: RetailDriveFrames) -> Self { + Self { + body_a: raw_frame(frames.body_a), + body_b: raw_frame(frames.body_b), + } + } +} + +fn raw_frame(frame: RetailDriveFrame) -> RetailDriveFrameRaw { + RetailDriveFrameRaw { + quaternion_lanes: [ + frame.orientation.x.to_bits(), + frame.orientation.y.to_bits(), + frame.orientation.z.to_bits(), + frame.orientation.w.to_bits(), + ], + translation_lanes: [ + frame.translation.x.to_bits(), + frame.translation.y.to_bits(), + frame.translation.z.to_bits(), + 0, + ], + } +} + +pub fn calculate_truck_transforms(inputs: RetailTruckTransformInputs) -> [RetailAffineTransform; 2] { + const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); + const HALF: f32 = f32::from_bits(0x3F00_0000); + const NEGATIVE_HALF_PI: f32 = f32::from_bits(0xBFC9_0FDB); + const POSITIVE_HALF_PI: f32 = f32::from_bits(0x3FC9_0FDB); + + let alpha = inputs.truck_rotation_axis_angle_degrees * DEGREES_TO_RADIANS; + let front_z = inputs + .deck_mid_length + .mul_add(HALF, inputs.truck_z_position_front); + let back_z = -inputs + .deck_mid_length + .mul_add(HALF, inputs.truck_z_position_back); + + [ + RetailAffineTransform { + basis: multiply_basis(rotation_x(alpha), rotation_y(POSITIVE_HALF_PI)), + translation: Vector3::new(0.0, inputs.truck_y_position, back_z), + }, + RetailAffineTransform { + basis: multiply_basis(rotation_x(-alpha), rotation_y(NEGATIVE_HALF_PI)), + translation: Vector3::new(0.0, inputs.truck_y_position, front_z), + }, + ] +} + +fn rotation_x(angle: f32) -> Basis3 { + let (sin, cos) = trigonometry::sin_cos(angle); + Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]], + } +} + +fn rotation_y(angle: f32) -> Basis3 { + let (sin, cos) = trigonometry::sin_cos(angle); + Basis3 { + columns: [[cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]], + } +} + +fn multiply_basis(left: Basis3, right: Basis3) -> Basis3 { + Basis3 { + columns: right.columns.map(|column| { + core::array::from_fn(|lane| { + let first = column[0] * left.columns[0][lane]; + let second = column[1].mul_add(left.columns[1][lane], first); + column[2].mul_add(left.columns[2][lane], second) + }) + }), + } +} + +fn relative_parent_transform( + parent_world: RetailAffineTransform, + child_world: RetailAffineTransform, +) -> RetailAffineTransform { + RetailAffineTransform { + basis: transpose_multiply_basis(parent_world.basis, child_world.basis), + translation: transpose_relative_translation( + parent_world.basis, + parent_world.translation, + child_world.translation, + ), + } +} + +fn transpose_multiply_basis(parent: Basis3, child: Basis3) -> Basis3 { + Basis3 { + columns: core::array::from_fn(|column| { + let child_column = child.columns[column]; + core::array::from_fn(|lane| { + let parent_column = parent.columns[lane]; + let first = child_column[0] * parent_column[0]; + let second = child_column[1].mul_add(parent_column[1], first); + child_column[2].mul_add(parent_column[2], second) + }) + }), + } +} + +fn transpose_relative_translation( + parent_basis: Basis3, + parent_translation: Vector3, + child_translation: Vector3, +) -> Vector3 { + let neg_parent = Vector3::new( + 0.0 - parent_translation.x, + 0.0 - parent_translation.y, + 0.0 - parent_translation.z, + ); + let component = |parent_column: [f32; 3]| { + let result = neg_parent.z * parent_column[2]; + let result = neg_parent.y.mul_add(parent_column[1], result); + let result = neg_parent.x.mul_add(parent_column[0], result); + let result = child_translation.x.mul_add(parent_column[0], result); + let result = child_translation.y.mul_add(parent_column[1], result); + child_translation.z.mul_add(parent_column[2], result) + }; + Vector3::new( + component(parent_basis.columns[0]), + component(parent_basis.columns[1]), + component(parent_basis.columns[2]), + ) +} + +#[cfg(test)] +#[path = "tests/drive_frames.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/drive_parameters.rs b/crates/skate-core/src/physics/drive_parameters.rs new file mode 100644 index 00000000..6448928f --- /dev/null +++ b/crates/skate-core/src/physics/drive_parameters.rs @@ -0,0 +1,189 @@ +//! Recovered drive parameter layout and TU3 truck/wheel construction branches. +const fn retail_f32(bits: u32) -> f32 { + f32::from_bits(bits) +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum RetailDriveType { + NoDrive = 0, + SoftDrive = 1, + HardDrive = 2, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveParams { + pub spring_or_max_velocity: f32, + pub damping: f32, + pub max_strength: f32, + pub drive_type: RetailDriveType, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveDynamics { + pub linear: RetailDriveParams, + pub angular: RetailDriveParams, +} + +/// Separate collections read by TU3 CreateDrives and SetTruckDriveDynamics. +/// `physicstrucks_drives.UseSoftDrives` is not read by this TU3 path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailTruckDriveSettings { + /// physicstrucks layout +0; gates only the linear configuration. + pub use_linear_drives: bool, + /// physicsdeck full attribute 103FDC19514D4246; read only when enabled. + pub use_hard_linear_drives: bool, + /// physicstrucks_drives layout +24, +28 and +20 respectively. + pub angular_displacement: f32, + pub angular_damping: f32, + pub angular_strength: f32, +} + +impl RetailTruckDriveSettings { + /// Decoded default collections, not truck geometry/twist-limit fields. + pub const STOCK: Self = Self { + use_linear_drives: false, + use_hard_linear_drives: true, + angular_displacement: 10.0, + angular_damping: 0.0, + angular_strength: 11.0, + }; +} + +impl RetailDriveParams { + pub const DISABLED: Self = Self { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + }; +} + +/// Constructor 82C06298 zeros both halves at +7152/+7168, then CreateDrives +/// 82C0B770 creates the truck pair and applies these settings. Postphysics only +/// refreshes frames in TU3; it does not rerun this parameter setup every tick. +pub fn retail_truck_drive_dynamics(settings: RetailTruckDriveSettings) -> RetailDriveDynamics { + let linear = if !settings.use_linear_drives { + RetailDriveParams::DISABLED + } else if settings.use_hard_linear_drives { + RetailDriveParams { + spring_or_max_velocity: retail_f32(0x45BB_7FFF), + damping: 0.0, + max_strength: retail_f32(0x48AF_C7FF), + drive_type: RetailDriveType::HardDrive, + } + } else { + RetailDriveParams { + spring_or_max_velocity: retail_f32(0x47C3_5000), + // 82C0B858..B8EC: native sqrt(100000) refinement, then *2. + damping: soft_linear_damping(), + max_strength: retail_f32(0x48AF_C7FF), + drive_type: RetailDriveType::SoftDrive, + } + }; + RetailDriveDynamics { + linear, + angular: RetailDriveParams { + spring_or_max_velocity: settings.angular_displacement * retail_f32(0x426F_FFFF), + damping: settings.angular_damping, + max_strength: settings.angular_strength * retail_f32(0x4560_FFFE), + drive_type: RetailDriveType::HardDrive, + }, + } +} + +pub const fn retail_wheel_drive_dynamics(use_hard_drives: bool) -> RetailDriveDynamics { + if use_hard_drives { + RetailDriveDynamics { + linear: RetailDriveParams { + spring_or_max_velocity: retail_f32(0x45BB_7FFF), + damping: 0.0, + max_strength: retail_f32(0x48AF_C7FF), + drive_type: RetailDriveType::HardDrive, + }, + angular: RetailDriveParams { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + }, + } + } else { + RetailDriveDynamics { + linear: RetailDriveParams { + spring_or_max_velocity: retail_f32(0x4561_0000), + damping: retail_f32(0x4270_0000), + max_strength: retail_f32(0x470C_9FFF), + drive_type: RetailDriveType::SoftDrive, + }, + angular: RetailDriveParams { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + }, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveFrameRaw { + /// Retail quaternion in observed `(x, y, z, w)` lane order. + pub quaternion_lanes: [u32; 4], + /// Affine translation vector copied from matrix offset `0x30`. + pub translation_lanes: [u32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveFramesRaw { + /// Frame emitted by helper `0x82BD3A10`. + pub body_a: RetailDriveFrameRaw, + /// Frame emitted by helper `0x82BD3BD0`. + pub body_b: RetailDriveFrameRaw, +} + +/// Original TU3 82C0B8A8..B8EC: two reciprocal-square-root refinements, +/// multiply by the spring, select zero for zero spring, then multiply by two. +/// The estimate uses our independent Rust arithmetic; hardware parity is unverified. +fn soft_linear_damping() -> f32 { + let spring = retail_f32(0x47C3_5000); // 822F91F0 + let mut reciprocal_root = super::native_arithmetic::reciprocal_square_root_estimate(spring); + for _ in 0..2 { + let squared = reciprocal_root * reciprocal_root; + let half = reciprocal_root * 0.5; + let residual = (-spring).mul_add(squared, 1.0); + reciprocal_root = half.mul_add(residual, reciprocal_root); + } + let root = if spring == 0.0 { + 0.0 + } else { + spring * reciprocal_root + }; + root * retail_f32(0x4000_0000) // 82060C50 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enabled_soft_linear_trucks_have_critical_damping() { + let settings = RetailTruckDriveSettings { + use_linear_drives: true, + use_hard_linear_drives: false, + ..RetailTruckDriveSettings::STOCK + }; + let dynamics = retail_truck_drive_dynamics(settings); + assert_eq!(dynamics.linear.drive_type, RetailDriveType::SoftDrive); + assert_eq!(dynamics.linear.spring_or_max_velocity, 100_000.0); + assert!((dynamics.linear.damping - 632.455_5).abs() < 0.000_1); + assert_eq!(dynamics.linear.max_strength.to_bits(), 0x48AF_C7FF); + assert_eq!( + dynamics.angular, + retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK).angular + ); + } +} diff --git a/crates/skate-core/src/physics/drive_preparation/mod.rs b/crates/skate-core/src/physics/drive_preparation/mod.rs new file mode 100644 index 00000000..206d33a5 --- /dev/null +++ b/crates/skate-core/src/physics/drive_preparation/mod.rs @@ -0,0 +1,38 @@ +//! Drive-frame preparation in TU3 Island::Step_Solver2 82763B08. +//! The active-drive list is visited before thaw/partition and island-table +//! construction. Every island mode runs this loop, regardless of body flags. + +/// Normalize each nonnull frame record in native active-list order. Entries +/// are frame-record indices, not drive/body indices; duplicate references are +/// processed repeatedly just as live pointers are in 82763BB4..82763C50. +/// Null frames are skipped. Translations and all their packed lanes survive. +pub fn normalize_active_drive_frames( + frames: &mut [[u32; 16]], + active_frame_indices: &[Option], +) { + for index in active_frame_indices.iter().flatten() { + normalize_drive_frames(&mut frames[*index]); + } +} + +/// The two quaternion operations in the same stage. No zero-length or finite +/// fallback is supplied here. +pub fn normalize_drive_frames(frames: &mut [u32; 16]) { + for offset in [0, 8] { + let q: [f32; 4] = core::array::from_fn(|i| f32::from_bits(frames[offset + i])); + let d = quaternion_squared_length(q); + let mut e = super::reciprocal_sqrt::estimate(d); + for _ in 0..2 { + let squared = e * e; + let half = e * 0.5; + e = half.mul_add((-d).mul_add(squared, 1.0), e); + } + for i in 0..4 { + frames[offset + i] = (q[i] * e).to_bits(); + } + } +} + +fn quaternion_squared_length(q: [f32; 4]) -> f32 { + super::native_arithmetic::dot4(q, q) +} diff --git a/crates/skate-core/src/physics/drive_solver.rs b/crates/skate-core/src/physics/drive_solver.rs new file mode 100644 index 00000000..e453fb62 --- /dev/null +++ b/crates/skate-core/src/physics/drive_solver.rs @@ -0,0 +1,128 @@ +//! Scalar port of Skate 3 TU3's RenderWare drive Jacobian and solve path. +//! +//! A retail drive is not a generic six-degree-of-freedom Bevy joint. TU3 +//! builds three linear and three quaternion-component constraint rows into a +//! 384-byte workspace, then solves each drive sequentially. This module keeps +//! those rows in named scalar fields and preserves the observed soft/hard +//! coefficient and impulse-limit equations. + +use super::{ + drive_frames::{RetailDriveFrame, RetailDriveFrames}, + drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}, + rigid_body::{RetailPackedWorldInverseInertia, RetailQuaternion, RetailReactionCorrections}, +}; +use crate::math::Vector3; +mod builder; +pub use builder::build_drive_rows; + +pub mod tu3 { + pub const DRIVE_JACOBIAN_BUILD: u32 = 0x82AE_1AE8; + pub const ITERATIVE_CONSTRAINT_SOLVER: u32 = 0x82AE_27D0; +} + +pub const ACTIVE_BODY: u32 = 4; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveBodyState { + pub reaction_index: usize, + pub state: u32, + pub orientation: RetailQuaternion, + /// Native Body+64/+80/+96, independently read for the anchor transform. + pub basis: crate::math::Basis3, + pub center_of_mass: Vector3, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub inverse_mass: f32, + pub world_inverse_inertia: RetailPackedWorldInverseInertia, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveRows { + /// Body paired with `DriveFrames::body_a`. + pub frame_a_body: RetailDriveBodyState, + /// Body paired with `DriveFrames::body_b`. + pub frame_b_body: RetailDriveBodyState, + pub arm_a: Vector3, + pub arm_b: Vector3, + /// World-space columns of the frame-B basis. + pub linear_axes: [Vector3; 3], + /// Normalized world-space quaternion-component Jacobian axes. + /// + /// Unlike an ordinary rotation basis, these three axes are generally not + /// orthogonal. + pub angular_axes: [Vector3; 3], + pub linear_inverse_effective_mass: [f32; 3], + pub angular_inverse_effective_mass: [f32; 3], + pub linear_softness: f32, + pub angular_softness: f32, + /// Preconditioned target impulses in constraint-row order. + pub linear_target_impulse: [f32; 3], + pub angular_target_impulse: [f32; 3], + pub linear_maximum_impulse: [f32; 3], + pub angular_maximum_impulse: [f32; 3], + pub accumulated_linear_impulse: [f32; 3], + pub accumulated_angular_impulse: [f32; 3], +} + +/// Runs TU3's drive portion of the sequential iterative constraint solver. +pub fn solve_drive_rows( + drives: &mut [RetailDriveRows], + reactions: &mut [RetailReactionCorrections], + maximum_iterations: u32, +) { + super::solver::solve_constraints(&mut [], &mut [], drives, reactions, maximum_iterations); +} + +/// One drive pass through the same native kernel used by the shared solver. +/// Both linear and angular candidates read the initial reactions of this +/// drive, before either candidate is applied (82AE2ED0..82AE2FB0). +pub fn solve_drive_iteration( + drives: &mut [RetailDriveRows], + reactions: &mut [RetailReactionCorrections], +) { + solve_drive_rows(drives, reactions, 1); +} + +fn is_active(body: RetailDriveBodyState) -> bool { + body.state & ACTIVE_BODY == ACTIVE_BODY +} + +fn basis_columns(basis: crate::math::Basis3) -> [Vector3; 3] { + basis + .columns + .map(|column| Vector3::new(column[0], column[1], column[2])) +} + +fn project(vector: Vector3, axes: [Vector3; 3]) -> [f32; 3] { + axes.map(|axis| dot(vector, axis)) +} + +fn point_rate(linear: Vector3, angular: Vector3, arm: Vector3) -> Vector3 { + super::constraint_frames::point_rate(linear, angular, arm) +} + +const fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} + +const fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} + +const fn scale(value: Vector3, factor: f32) -> Vector3 { + Vector3::new(value.x * factor, value.y * factor, value.z * factor) +} + +fn dot(a: Vector3, b: Vector3) -> f32 { + super::native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} + +fn cross(a: Vector3, b: Vector3) -> Vector3 { + super::constraint_frames::cross(a, b) +} + +#[cfg(test)] +#[path = "tests/drive_solver.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/drive_solver/builder.rs b/crates/skate-core/src/physics/drive_solver/builder.rs new file mode 100644 index 00000000..5a2e8592 --- /dev/null +++ b/crates/skate-core/src/physics/drive_solver/builder.rs @@ -0,0 +1,296 @@ +//! TU3 drive Jacobian construction, separate from iterative solving. +use super::*; +use crate::physics::{constraint_frames, native_arithmetic}; +/// Scalar translation of TU3 `DriveJacobian::Build`. +/// +/// RenderWare pairs frame A with internal `Drive::m_bodyA` at `Drive+0x10` +/// and frame B with `Drive::m_bodyB` at `Drive+0x14`. `Simulation::AddDrive` +/// stores its second body argument as internal A and its first as internal B. +/// The linear error is frame-B minus frame-A; the solver therefore applies +/// positive impulse to frame A and negative impulse to frame B. +pub fn build_drive_rows( + frame_a_body: RetailDriveBodyState, + frame_b_body: RetailDriveBodyState, + frames: RetailDriveFrames, + dynamics: RetailDriveDynamics, + time_step: f32, +) -> RetailDriveRows { + debug_assert!(time_step.is_finite() && time_step > 0.0); + + let frame_a_world = world_frame(frame_a_body, frames.body_a); + let frame_b_world = world_frame(frame_b_body, frames.body_b); + let arm_a = frame_a_world.arm; + let arm_b = frame_b_world.arm; + let linear_axes = basis_columns(constraint_frames::basis(frame_b_world.orientation)); + + let raw_angular = + constraint_frames::quaternion_rows(frame_a_world.orientation, frame_b_world.orientation); + let components = [ + raw_angular.relative.x, + raw_angular.relative.y, + raw_angular.relative.z, + ]; + let squared = components + .map(|c| native_arithmetic::vector_min(native_arithmetic::vector_max(c * c, 0.0), 1.0)); + // 830BDB40 selects bytes [0,4,8,8] in every word: any saturated + // quaternion component replaces ALL three axes with frame B's basis. + let singular = squared.iter().any(|&c| c == 1.0); + let inverse_lengths = if singular { + [1.0; 3] + } else { + squared.map(|c| constraint_frames::reciprocal_sqrt(1.0 - c)) + }; + let angular_axes = core::array::from_fn(|i| { + scale( + if singular { + linear_axes[i] + } else { + raw_angular.axes[i] + }, + inverse_lengths[i], + ) + }); + let angular_position_error = + core::array::from_fn(|i| (components[i] * inverse_lengths[i]) * 2.0); + + let linear_position_error = sub(frame_b_world.position, frame_a_world.position); + let linear_velocity_displacement = sub( + scale( + point_rate( + frame_b_body.linear_velocity, + frame_b_body.angular_velocity, + arm_b, + ), + time_step, + ), + scale( + point_rate( + frame_a_body.linear_velocity, + frame_a_body.angular_velocity, + arm_a, + ), + time_step, + ), + ); + let linear_acceleration_displacement = sub( + scale( + scale( + point_rate( + frame_b_body.force_acceleration, + frame_b_body.torque_acceleration, + arm_b, + ), + time_step, + ), + time_step, + ), + scale( + scale( + point_rate( + frame_a_body.force_acceleration, + frame_a_body.torque_acceleration, + arm_a, + ), + time_step, + ), + time_step, + ), + ); + let angular_velocity_displacement = scale( + sub(frame_b_body.angular_velocity, frame_a_body.angular_velocity), + time_step, + ); + let angular_acceleration_displacement = scale( + scale( + sub( + frame_b_body.torque_acceleration, + frame_a_body.torque_acceleration, + ), + time_step, + ), + time_step, + ); + let linear_inverse_effective_mass = core::array::from_fn(|index| { + constraint_frames::reciprocal(linear_effective_mass( + frame_a_body, + frame_b_body, + arm_a, + arm_b, + linear_axes[index], + )) + }); + let angular_inverse_effective_mass = core::array::from_fn(|index| { + constraint_frames::reciprocal(angular_effective_mass( + frame_a_body, + frame_b_body, + angular_axes[index], + )) + }); + + let linear_coefficients = build_coefficients( + dynamics.linear, + components_of(linear_position_error), + components_of(linear_velocity_displacement), + components_of(linear_acceleration_displacement), + time_step, + ); + // Linear weights combine in world space before the projection (20A8..20D0). + let displacement = linear_coefficients.target_displacement; + let linear_target = project( + Vector3::new(displacement[0], displacement[1], displacement[2]), + linear_axes, + ); + let angular_coefficients = build_coefficients( + dynamics.angular, + angular_position_error, + project(angular_velocity_displacement, angular_axes), + project(angular_acceleration_displacement, angular_axes), + time_step, + ); + RetailDriveRows { + frame_a_body, + frame_b_body, + arm_a, + arm_b, + linear_axes, + angular_axes, + linear_inverse_effective_mass, + angular_inverse_effective_mass, + linear_softness: linear_coefficients.softness, + angular_softness: angular_coefficients.softness, + linear_target_impulse: core::array::from_fn(|i| { + linear_target[i] * linear_inverse_effective_mass[i] + }), + angular_target_impulse: core::array::from_fn(|i| { + angular_coefficients.target_displacement[i] * angular_inverse_effective_mass[i] + }), + linear_maximum_impulse: linear_inverse_effective_mass + .map(|v| v * linear_coefficients.maximum_strength), + angular_maximum_impulse: angular_inverse_effective_mass + .map(|v| v * angular_coefficients.maximum_strength), + accumulated_linear_impulse: [0.0; 3], + accumulated_angular_impulse: [0.0; 3], + } +} + +#[derive(Clone, Copy)] +struct WorldFrame { + orientation: RetailQuaternion, + arm: Vector3, + position: Vector3, +} + +fn world_frame(body: RetailDriveBodyState, frame: RetailDriveFrame) -> WorldFrame { + let arm = constraint_frames::transform_direction(body.basis, frame.translation); + WorldFrame { + orientation: constraint_frames::compose(body.orientation, frame.orientation), + arm, + position: add(body.center_of_mass, arm), + } +} + +#[derive(Clone, Copy)] +struct DriveCoefficients { + softness: f32, + target_displacement: [f32; 3], + maximum_strength: f32, +} + +fn components_of(v: Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} + +/// 82AE1E68..20B8 and the matching angular block219C..237C. +/// Inputs are already scaled in their source order; the hard cap affects only +/// position error, before the rate and acceleration displacements are added. +fn build_coefficients( + params: RetailDriveParams, + mut position_error: [f32; 3], + velocity_displacement: [f32; 3], + acceleration_displacement: [f32; 3], + time_step: f32, +) -> DriveCoefficients { + let damping = time_step * params.damping; + let (position_weight, velocity_weight, acceleration_weight) = + if params.drive_type == RetailDriveType::SoftDrive { + let spring = (time_step * params.spring_or_max_velocity) * time_step; + let inverse = constraint_frames::reciprocal((1.0 + spring) + damping); + ( + inverse * spring, + inverse * damping, + inverse * (spring + damping), + ) + } else { + (constraint_frames::reciprocal(1.0 + damping) * 1.0, 1.0, 1.0) + }; + if params.drive_type == RetailDriveType::HardDrive { + let squared = native_arithmetic::dot3( + [position_error[0], position_error[1], position_error[2], 0.0], + [position_error[0], position_error[1], position_error[2], 0.0], + ); + let maximum = time_step * params.spring_or_max_velocity; + if squared > maximum * maximum { + let factor = maximum * constraint_frames::reciprocal_sqrt(squared); + position_error = position_error.map(|v| v * factor); + } + } + DriveCoefficients { + softness: acceleration_weight, + target_displacement: core::array::from_fn(|i| { + let rate = velocity_displacement[i] * velocity_weight; + let position_and_rate = position_error[i].mul_add(position_weight, rate); + acceleration_displacement[i].mul_add(acceleration_weight, position_and_rate) + }), + // Type zero follows the same native strength load; its authored params + // normally provide zero strength rather than the builder inventing it. + maximum_strength: (time_step * params.max_strength) * time_step, + } +} + +fn linear_effective_mass( + frame_a: RetailDriveBodyState, + frame_b: RetailDriveBodyState, + arm_a: Vector3, + arm_b: Vector3, + axis: Vector3, +) -> f32 { + let mut denominator = 0.0; + if is_active(frame_a) { + let angular = cross(arm_a, axis); + denominator += frame_a.inverse_mass + + dot( + angular, + constraint_frames::multiply_inertia(frame_a.world_inverse_inertia, angular), + ); + } + if is_active(frame_b) { + let angular = cross(arm_b, axis); + denominator += frame_b.inverse_mass + + dot( + angular, + constraint_frames::multiply_inertia(frame_b.world_inverse_inertia, angular), + ); + } + denominator +} + +fn angular_effective_mass( + frame_a: RetailDriveBodyState, + frame_b: RetailDriveBodyState, + axis: Vector3, +) -> f32 { + let mut denominator = 0.0; + if is_active(frame_a) { + denominator += dot( + axis, + constraint_frames::multiply_inertia(frame_a.world_inverse_inertia, axis), + ); + } + if is_active(frame_b) { + denominator += dot( + axis, + constraint_frames::multiply_inertia(frame_b.world_inverse_inertia, axis), + ); + } + denominator +} diff --git a/crates/skate-core/src/physics/filtered_state.rs b/crates/skate-core/src/physics/filtered_state.rs new file mode 100644 index 00000000..cacf0984 --- /dev/null +++ b/crates/skate-core/src/physics/filtered_state.rs @@ -0,0 +1,164 @@ +//! Complete filtered physical-state update82DE5BA0, with reset82DE5588. +//! This conditions the actual selected physics state; collision alone does not +//! select that state. Skate2 named82E2D800 identifies fields; TU3 governs branches. +use crate::animation::{output::attributes::AttributeName, skeleton_input::name::encode}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u32)] +pub enum FilteredCategory { + #[default] + Invalid = 0, + Ground = 1, + Air = 2, + Grind = 3, + Wipeout = 4, + Teleport = 5, + Offboard = 6, + OffboardAir = 7, +} + +/// PhysOut.Grinds fields copied when the selected state is400..405. Names are +/// native five-word strings, never addresses or collection hashes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GrindState { + pub kind: i32, + pub scorable_id: i32, + pub name: AttributeName, + pub scoring_name: AttributeName, + pub on_front: bool, + pub crouch: f32, + pub pathed_guid: u64, + pub local_guid: u64, +} +impl Default for GrindState { + fn default() -> Self { + Self { + kind: -1, scorable_id: -1, + // Initializer82F8AF08 encodes NUL text82060799 to830C0D60. + name: encode(b""), scoring_name: encode(b""), + on_front: false, crouch: 0.0, + pathed_guid: u64::MAX, local_guid: u64::MAX, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct FilteredStateInput { + /// Selected PhysOut.State category12 and state16, not filtered categories. + pub physics_category: i32, + pub physics_state: i32, + /// PhysOut.Collision3477 and16, after actual collision publication. + pub anything_in_contact: bool, + pub physics_surface_type: i32, + /// PhysOut.Ground316, owned by ground-state wall-ride exit calculations. + pub wall_ride_exit: bool, + /// PhysOut.Air443, owned by the trajectory/grind target decision. + pub targeting_grind: bool, + /// PhysOut.OffBoard320 and315 respectively. + pub offboard_has_landed: bool, + pub offboard_on_deck: bool, + pub grind: GrindState, + /// EmpiricalMeasurements82DE5858 updates conditioner196 before this call. + pub last_grind_distance: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FilteredStateOutput { + pub category: FilteredCategory, + pub previous_category: FilteredCategory, + pub grinding: bool, + pub grind: GrindState, + pub last_grind_distance: f32, +} + +#[derive(Clone, Debug)] +pub struct FilteredState { + pub category: FilteredCategory, + pub previous_category: FilteredCategory, + pub previous_physics_state: i32, + pub air_count: i32, + pub nonspecific_count: i32, + pub nonspecific_collision_free_count: i32, + pub nonspecific_collision_count: i32, + pub frames_since_ground_stairs: i32, + pub must_change: bool, + // Nongrind publication clears the output metadata, not these cached fields. + cached_grind: GrindState, +} +impl Default for FilteredState { + fn default() -> Self { + Self { + category: FilteredCategory::Invalid, + previous_category: FilteredCategory::Invalid, + previous_physics_state: 0, + air_count: 0, nonspecific_count: 0, + nonspecific_collision_free_count: 0, nonspecific_collision_count: 0, + frames_since_ground_stairs: 0, must_change: true, + cached_grind: GrindState::default(), + } + } +} +impl FilteredState { + pub fn reset(&mut self) { *self = Self::default(); } + + pub fn update(&mut self, input: FilteredStateInput) -> FilteredStateOutput { + use FilteredCategory::*; + let old = self.category; + self.previous_category = old; + self.must_change = old == Wipeout || old == Invalid + || self.previous_physics_state == 702 && input.physics_state != 702; + self.air_count = count(self.air_count, input.physics_category == 200); + self.nonspecific_count = count(self.nonspecific_count, input.physics_state == 701); + self.nonspecific_collision_free_count = count(self.nonspecific_collision_free_count, + input.physics_state == 701 && !input.anything_in_contact); + self.nonspecific_collision_count = count(self.nonspecific_collision_count, + input.physics_state == 701 && input.anything_in_contact); + self.frames_since_ground_stairs = count(self.frames_since_ground_stairs, + !(input.physics_category == 100 && input.physics_surface_type == 8)); + self.category = match input.physics_state { + 100 => if input.wall_ride_exit { Air } else { Ground }, + 101..=105 | 602 => Ground, + 200 => { + let air_delay_passed = self.air_count > 3; + if old == Air { + // TU3 adds contact return to ground, absent in the named S2 body. + if air_delay_passed && input.anything_in_contact || self.must_change { + Ground + } else { Air } + } else { + let may_enter_air = air_delay_passed + && (old != Ground || self.frames_since_ground_stairs > 9); + if may_enter_air || self.must_change { Air } else { old } + } + } + 201 => if input.anything_in_contact && !input.targeting_grind && self.air_count > 5 { + Ground + } else { Air }, + 202 | 600 | 601 => Air, + 300 => Wipeout, + 400..=405 => { self.cached_grind = input.grind; Grind } + 500 | 502 => Offboard, + 501 => if input.offboard_has_landed { Offboard } else { OffboardAir }, + 503 => if input.offboard_on_deck { Ground } else { Offboard }, + 702 => Teleport, + // Native701 and unrecognized states retain the prior category. + _ => old, + }; + self.previous_physics_state = input.physics_state; + let grinding = self.category == Grind; + FilteredStateOutput { + category: self.category, + previous_category: self.previous_category, + grinding, + grind: if grinding { self.cached_grind } else { GrindState::default() }, + last_grind_distance: input.last_grind_distance, + } + } +} +fn count(previous: i32, active: bool) -> i32 { + if active { previous.wrapping_add(1) } else { 0 } +} + +#[cfg(test)] +#[path = "tests/filtered_state.rs"] +mod tests; \ No newline at end of file diff --git a/crates/skate-core/src/physics/foot_physical_output.rs b/crates/skate-core/src/physics/foot_physical_output.rs new file mode 100644 index 00000000..ac73f402 --- /dev/null +++ b/crates/skate-core/src/physics/foot_physical_output.rs @@ -0,0 +1,136 @@ +//! Full original SkeletonIK::FillPhysOut_FeetDeckInteraction82BF22A0. +//! Physical toe positions and derivatives are observed in the physical board's +//! frame. They are distinct from animation targets and contact-query validity. +use super::{ + native_arithmetic::reciprocal_estimate, + skeleton_animation_record::AnimationPartTransform as Transform, + skeleton_body::SkeletonPhysicalRecord, +}; + +#[derive(Clone, Copy, Debug)] +pub struct FootPhysicalSettings { + pub deck_half_width: f32, + pub deck_total_half_length: f32, + /// Original physics_skeletonik layout64 FootOnDeckPadding. + pub padding: [f32; 4], +} +#[derive(Clone, Debug, Default)] +pub struct FootPhysicalState { + /// IK3136/3152. Reset82BEDB5C/60 writes both vectors to zero. + pub previous_local_toes: [[f32; 4]; 2], +} +#[derive(Clone, Copy, Debug, Default)] +pub struct FootPhysicalOutput { + /// PhysOutSkeleton192/208: change in local physical toe positions / dt. + pub local_velocity: [[f32; 4]; 2], + /// PhysOutSkeleton224/240: physical record velocities15/19, copied intact. + pub world_velocity: [[f32; 4]; 2], + /// PhysOutSkeleton600/601. Strict XYZ containment, with authored padding. + pub within_deck_box: [bool; 2], +} +impl FootPhysicalState { + pub fn reset(&mut self) { + *self = Self::default(); + } + pub fn update( + &mut self, + record: &SkeletonPhysicalRecord, + dt: f32, + settings: FootPhysicalSettings, + ) -> FootPhysicalOutput { + let inverse = inverse_physical_board(&record.pose[0]); + let positions = [15usize, 19].map(|part| transform_point(&inverse, record.pose[part][3])); + let mut inverse_dt = reciprocal_estimate(dt); + for _ in 0..2 { + inverse_dt = inverse_dt.mul_add((-inverse_dt).mul_add(dt, 1.0), inverse_dt); + } + let local_velocity = core::array::from_fn(|foot| { + core::array::from_fn(|lane| { + (positions[foot][lane] - self.previous_local_toes[foot][lane]) * inverse_dt + }) + }); + self.previous_local_toes = positions; + //82165A10 is zero: only authored padding supplies the box's Y extent. + let dimensions = [ + settings.deck_half_width, + 0.0, + settings.deck_total_half_length, + 0.0, + ]; + let bounds: [f32; 4] = + core::array::from_fn(|lane| dimensions[lane] + settings.padding[lane]); + FootPhysicalOutput { + local_velocity, + world_velocity: [record.velocities[15], record.velocities[19]], + within_deck_box: positions.map(|p| { + p[0].abs() < bounds[0] && p[1].abs() < bounds[1] && p[2].abs() < bounds[2] + }), + } + } +} + +fn inverse_physical_board(board: &Transform) -> Transform { + let mut inverse: Transform = [[0.0; 4]; 4]; + for axis in 0..3 { + for lane in 0..3 { + inverse[axis][lane] = board[lane][axis]; + } + } + //82BF2398..241C builds -P.z * inverseZ, then Y and X in source order. + for lane in 0..4 { + let z = -board[3][2] * inverse[2][lane]; + let yz = (-board[3][1]).mul_add(inverse[1][lane], z); + inverse[3][lane] = (-board[3][0]).mul_add(inverse[0][lane], yz); + } + inverse +} +fn transform_point(frame: &Transform, p: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|lane| { + let x = frame[0][lane].mul_add(p[0], frame[3][lane]); + let xy = frame[1][lane].mul_add(p[1], x); + frame[2][lane].mul_add(p[2], xy) + }) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn moving_board_frame_separates_world_rates_from_relative_foot_motion() { + let mut record = SkeletonPhysicalRecord::default(); + record.pose[0][3] = [10.0, 2.0, 3.0, 0.0]; + record.pose[15][3] = [10.2, 2.0, 3.3, 0.0]; + record.pose[19][3] = [9.8, 2.0, 2.7, 0.0]; + record.velocities[15] = [4.0, 0.0, 0.0, 0.0]; + record.velocities[19] = [5.0, 0.0, 0.0, 0.0]; + let settings = FootPhysicalSettings { + deck_half_width: 0.3, + deck_total_half_length: 0.5, + padding: [0.0, 0.1, 0.0, 0.0], + }; + let mut state = FootPhysicalState::default(); + let initial = state.update(&record, 0.25, settings); + assert_eq!(initial.within_deck_box, [true, true]); + assert_eq!( + initial.world_velocity, + [record.velocities[15], record.velocities[19]] + ); + for part in [0, 15, 19] { + record.pose[part][3][0] += 1.0; + } + let moved = state.update(&record, 0.25, settings); + assert!( + moved + .local_velocity + .iter() + .flatten() + .all(|value| value.abs() < 0.00001) + ); + let mut boundaries = SkeletonPhysicalRecord::default(); + boundaries.pose[15][3] = [0.3, 0.0, 0.0, 0.0]; + boundaries.pose[19][3] = [0.0, 0.1, 0.0, 0.0]; + assert_eq!( + state.update(&boundaries, 0.25, settings).within_deck_box, + [false, false] + ); + } +} diff --git a/crates/skate-core/src/physics/force_queue.rs b/crates/skate-core/src/physics/force_queue.rs new file mode 100644 index 00000000..b0838ca1 --- /dev/null +++ b/crates/skate-core/src/physics/force_queue.rs @@ -0,0 +1,100 @@ +//! Board force delivery: TU3 append 0x82C03EF0 and consumer 0x82C03718. +use crate::math::{Basis3, Vector3}; + +use super::point_force::{RetailForceAccumulator, accumulate_point_force}; + +pub const FORCE_CAPACITY: usize = 21; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct QueuedPointForce { + /// Native tag is retained for attribution; it does not select a body. + pub tag: u32, + pub force_world: Vector3, + pub point_body: Vector3, +} + +#[derive(Clone, Debug)] +pub struct BoardForceQueue { + entries: [QueuedPointForce; FORCE_CAPACITY], + count: usize, +} + +impl Default for BoardForceQueue { + fn default() -> Self { + Self { + entries: [QueuedPointForce::default(); FORCE_CAPACITY], + count: 0, + } + } +} + +impl BoardForceQueue { + /// TU3 drops the new force when 21 entries are already present. Repeated + /// tags remain separate entries, in append order; they are not summed here. + pub fn append(&mut self, entry: QueuedPointForce) -> bool { + if self.count == FORCE_CAPACITY { + return false; + } + self.entries[self.count] = entry; + self.count += 1; + true + } + + pub fn entries(&self) -> &[QueuedPointForce] { + &self.entries[..self.count] + } + + /// Every record acts on the deck. The caller resolves the native collection + /// force-point Y offset; the native null-collection fallback is zero. + /// Consumption does not clear the records in Skate 3. + pub fn apply_to_deck( + &self, + mut accumulator: RetailForceAccumulator, + deck_basis: Basis3, + inverse_mass: f32, + world_inverse_inertia: Basis3, + force_point_y_offset: f32, + ) -> RetailForceAccumulator { + for entry in self.entries() { + let point = Vector3::new( + entry.point_body.x + 0.0, + entry.point_body.y + force_point_y_offset, + entry.point_body.z + 0.0, + ); + accumulator = accumulate_point_force( + accumulator, + entry.force_world, + point, + deck_basis, + inverse_mass, + world_inverse_inertia, + ); + } + accumulator + } + + /// Separate lifecycle operation, corresponding to the queue reset at + /// 0x82C02360..0x82C02368 (also reset when the board is repositioned). + pub fn clear(&mut self) { + self.count = 0; + } +} + +/// TU3 0x82C06F58: accumulate alternating body masses, then combine. The +/// grouped binary32 additions differ from an ordinary left-to-right sum. +/// Native input is live-body inverse mass at +124, in definition order. +pub fn total_body_mass(inverse_masses: &[f32]) -> f32 { + let mut even = 0.0_f32; + let mut odd = 0.0_f32; + let mut pairs = inverse_masses.chunks_exact(2); + for pair in &mut pairs { + even = 1.0 / pair[0] + even; + odd = 1.0 / pair[1] + odd; + } + let paired = odd + even; + paired + + pairs + .remainder() + .first() + .map_or(0.0, |inverse| 1.0 / inverse) +} diff --git a/crates/skate-core/src/physics/ground_hang_geometry.rs b/crates/skate-core/src/physics/ground_hang_geometry.rs new file mode 100644 index 00000000..49bba161 --- /dev/null +++ b/crates/skate-core/src/physics/ground_hang_geometry.rs @@ -0,0 +1,189 @@ +//! Geometry actually observed by Ground::ManageHangUps82D39510. +//! The caller reads only query completion, geometry kind104 and flag29 from +//! 82C20C08; unrelated grind surface classifications are not used in this path. +use super::{ + board_motion_output::{dot, inverse_length_squared}, + board_world::BoardWorld, +}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug)] +pub struct HangGeometryInput { + /// Processed1312,1328 and1232, respectively. + pub edge_start: Vector3, + pub edge_end: Vector3, + pub reference_point: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct HangLine { + pub start: Vector3, + pub end: Vector3, + pub radius: f32, +} + +/// Full six-line branch82C20728 used by82D397A0. Input's optional seventh +/// line byte is zeroed by82C20530 and never set by this ground caller. +pub fn hang_lines(input: HangGeometryInput, deck_center_to_truck: f32) -> Option<[HangLine; 6]> { + let delta = sub(input.edge_end, input.edge_start); + let first = cross(Vector3::new(0., 1., 0.), delta); + let raw_up = cross(delta, first); + let (up, length) = normalize_or_retain(raw_up); + let (direction, _) = normalize_or_retain(delta); + if length < f32::from_bits(0x3727_c5ac) { + return None; + } + let on_edge = madd( + direction, + dot(sub(input.reference_point, input.edge_start), direction), + input.edge_start, + ); + // Preserve the native pair of subtractions rather than canceling them. + let center = sub(input.reference_point, sub(input.reference_point, on_edge)); + let side = cross(up, direction); + let short_up = scale(up, 0.04); + let near_side = scale(side, 0.09); + let far_side = scale(side, deck_center_to_truck); + let far_up = scale(up, deck_center_to_truck * f32::from_bits(0x3f87_ae14)); + let last_up = scale(up, 0.09); + let line = |center: Vector3, up: Vector3, radius| HangLine { + start: add(center, up), + end: sub(center, up), + radius, + }; + Some([ + line(add(center, near_side), short_up, 0.), + line(sub(center, near_side), short_up, 0.), + line(add(center, far_side), far_up, 0.), + line(sub(center, far_side), far_up, 0.), + line(center, short_up, 0.), + line(add(center, last_up), near_side, 0.001), + ]) +} + +/// 82C20ED4..82C20F84 and Ground's82D397EC..82D39810. We execute all six +/// native queries, then use the exact two classification fields read by Ground. +pub fn detect_hung_geometry( + world: &BoardWorld, + input: HangGeometryInput, + deck_center_to_truck: f32, +) -> Result { + let Some(lines) = hang_lines(input, deck_center_to_truck) else { + return Ok(false); + }; + let mut fractions = [None; 6]; + for (i, line) in lines.into_iter().enumerate() { + fractions[i] = world + .query_swept_line(line.start, line.end, line.radius)? + .map(|hit| hit.geometry.fraction); + } + Ok(hung_classification(fractions)) +} +fn hung_classification(hits: [Option; 6]) -> bool { + if (hits[0].is_some() && hits[1].is_some()) || hits[4].is_some() { + return false; + } + let kind = if hits[0].is_some() { + if hits[2].is_some_and(|fraction| fraction < 0.65) { + 2 + } else { + 1 + } + } else if hits[1].is_some() { + if hits[3].is_some_and(|fraction| fraction < 0.65) { + 2 + } else { + 1 + } + } else { + 0 + }; + kind != 2 +} +fn normalize_or_retain(v: Vector3) -> (Vector3, f32) { + let square = dot(v, v); + let inverse = inverse_length_squared(square, 2); + let length = if square == 0. { 0. } else { square * inverse }; + ( + if length > f32::from_bits(0x3586_37bd) { + scale(v, inverse) + } else { + v + }, + length, + ) +} +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +fn scale(a: Vector3, k: f32) -> Vector3 { + Vector3::new(a.x * k, a.y * k, a.z * k) +} +fn madd(a: Vector3, k: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(k, b.x), + a.y.mul_add(k, b.y), + a.z.mul_add(k, b.z), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn hanging_geometry_distinguishes_rail_side_and_blocked_deck() { + let input = HangGeometryInput { + edge_start: Vector3::ZERO, + edge_end: Vector3::new(0., 0., 2.), + reference_point: Vector3::new(4., 5., 1.), + }; + let lines = hang_lines(input, 0.243).unwrap(); + assert_eq!(lines[0].start, Vector3::new(0.09, 0.04, 1.)); + assert_eq!(lines[0].end, Vector3::new(0.09, -0.04, 1.)); + assert_eq!(lines[1].start.x, -0.09); + assert_eq!(lines[5].radius, 0.001); + assert_eq!(lines[5].start, Vector3::new(0.09, 0.09, 1.)); + assert_eq!(lines[5].end, Vector3::new(-0.09, 0.09, 1.)); + assert!(hung_classification([ + Some(0.5), + None, + None, + None, + None, + None + ])); + assert!(!hung_classification([ + Some(0.5), + None, + Some(0.64), + None, + None, + None + ])); + assert!(!hung_classification([ + Some(0.5), + Some(0.5), + None, + None, + None, + None + ])); + assert!(!hung_classification([ + None, + None, + None, + None, + Some(0.5), + None + ])); + } +} diff --git a/crates/skate-core/src/physics/hook_drive/frames.rs b/crates/skate-core/src/physics/hook_drive/frames.rs new file mode 100644 index 00000000..e11c8b0b --- /dev/null +++ b/crates/skate-core/src/physics/hook_drive/frames.rs @@ -0,0 +1,71 @@ +//! Full 82C00940/82C00AF0 matrix33 conversion. Unlike Part::SetTransform, +//! these functions do not normalize the selected quaternion afterward. +type V = [f32; 4]; +fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +fn mul(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] * b[i]) +} +pub(super) fn rsqrt(v: f32) -> f32 { + let mut e = super::super::reciprocal_sqrt::estimate(v); + for _ in 0..2 { + let s = e * e; + let h = e * 0.5; + e = h.mul_add((-v).mul_add(s, 1.0), e); + } + e +} +/// 82C00940 preserves both translations and the parent quaternion. +pub fn set_child_angular_frame(frames: &mut [u32; 16], basis: [u32; 12]) { + frames[..4].copy_from_slice(&convert(basis)); +} +/// 82C00AF0 preserves both translations and the child quaternion. +pub fn set_parent_angular_frame(frames: &mut [u32; 16], basis: [u32; 12]) { + frames[8..12].copy_from_slice(&convert(basis)); +} +fn convert(basis: [u32; 12]) -> [u32; 4] { + quaternion(core::array::from_fn(|i| { + core::array::from_fn(|j| f32::from_bits(basis[i * 4 + j])) + })) + .map(f32::to_bits) +} +fn quaternion(m: [V; 3]) -> V { + let [ri, up, at] = m; + // Sign masks are initialized by TU3 82F83360/90/C0, not image defaults. + let rx = [ri[0], ri[0], -ri[0], -ri[0]]; + let uy = [up[1], -up[1], up[1], -up[1]]; + let az = [at[2], -at[2], -at[2], at[2]]; + let first = add(rx, uy); + let d = add(first, add(az, [1.0; 4])); + let inv = d.map(rsqrt); + let roots = mul(d, inv); + let halves = mul([0.5; 4], inv); + let a = [up[2], at[0], ri[1], 0.5]; + let b = [at[1], ri[2], up[0], 0.0]; + let minus = sub(a, b); + let plus = add(a, b); + let y = mul( + [plus[2], minus[3], plus[0], minus[1]], + [halves[2], roots[2], halves[2], halves[2]], + ); + let z = mul( + [plus[1], plus[0], minus[3], minus[2]], + [halves[3], halves[3], roots[3], halves[3]], + ); + let x = mul( + [minus[3], plus[2], plus[1], minus[0]], + [roots[1], halves[1], halves[1], halves[1]], + ); + let w = mul(minus, [halves[0], halves[0], halves[0], roots[0]]); + let yz = if up[1] > at[2] { y } else { z }; + let xyz = if ri[0] > up[1] && ri[0] > at[2] { + x + } else { + yz + }; + if first[0] + at[2] > 0.0 { w } else { xyz } +} diff --git a/crates/skate-core/src/physics/hook_drive/mod.rs b/crates/skate-core/src/physics/hook_drive/mod.rs new file mode 100644 index 00000000..dfbfc8d4 --- /dev/null +++ b/crates/skate-core/src/physics/hook_drive/mod.rs @@ -0,0 +1,92 @@ +//! TU3 hook-drive state. The hook is a separate target part, child of deck 6. +//! Registration, target publication and the caller's state transition remain +//! explicit; changing these live dynamics does not recreate the drive. + +mod frames; +pub use frames::{set_child_angular_frame, set_parent_angular_frame}; + +use super::drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HookDriveState { + /// JointFrames at SkateboardBody+896: child quaternion/translation, parent + /// quaternion/translation, four words each in guest lane order. + pub frames: [u32; 16], + /// Live linear then angular parameters at SkateboardBody+960. + pub dynamics: [u32; 8], +} + +impl HookDriveState { + /// Frame/dynamics initialization in 82C0D330. This does not manufacture the + /// separate hook assembly, allocate a drive, or choose a gameplay pose. + pub fn initial() -> Self { + let mut state = Self { + frames: [0; 16], + dynamics: [0; 8], + }; + let identity = + [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0].map(f32::to_bits); + set_child_angular_frame(&mut state.frames, identity); + set_parent_angular_frame(&mut state.frames, identity); + state.disable_linear(); + state.disable_angular(); + state + } + + /// 82C04708, including its inline linear enable and 82C0D5C0 call. + /// TU3 reads no caller strength multiplier here. + pub fn enable_animation_soft(&mut self, animated: &mut u8) { + *animated = 1; + self.dynamics[..4].copy_from_slice(&soft(5000.0, f32::from_bits(0x44e0_fffe))); + self.enable_angular_soft(); + } + + /// Complete 82C0D5C0. Other fields and the caller's animated flag survive. + pub fn enable_angular_soft(&mut self) { + self.dynamics[4..].copy_from_slice(&soft(20000.0, f32::from_bits(0x470c_9fff))); + } + + /// Complete 82C05658: animated flag, angular soft, then linear hard/zero. + pub fn enable_angular_only(&mut self, animated: &mut u8) { + *animated = 1; + self.enable_angular_soft(); + self.disable_linear(); + } + + /// Inline Ground Enter82D375D0..5FC and LetGo82D75450..98 operation. + /// The blend flag and stored board quaternion are not inputs or outputs. + pub fn disable_animation(&mut self, animated: &mut u8) { + *animated = 0; + self.disable_linear(); + self.disable_angular(); + } + + pub fn disable_linear(&mut self) { + self.dynamics[..4].copy_from_slice(&[0, 0, 0, 2]); + } + pub fn disable_angular(&mut self) { + self.dynamics[4..].copy_from_slice(&[0, 0, 0, 2]); + } + + pub fn solver_dynamics(&self) -> RetailDriveDynamics { + let params = |i| RetailDriveParams { + spring_or_max_velocity: f32::from_bits(self.dynamics[i]), + damping: f32::from_bits(self.dynamics[i + 1]), + max_strength: f32::from_bits(self.dynamics[i + 2]), + drive_type: match self.dynamics[i + 3] { + 1 => RetailDriveType::SoftDrive, + 2 => RetailDriveType::HardDrive, + _ => RetailDriveType::NoDrive, + }, + }; + RetailDriveDynamics { + linear: params(0), + angular: params(4), + } + } +} + +fn soft(spring: f32, strength: f32) -> [u32; 4] { + let damping = (spring * frames::rsqrt(spring)) * 2.0; + [spring.to_bits(), damping.to_bits(), strength.to_bits(), 1] +} diff --git a/crates/skate-core/src/physics/joint_builder.rs b/crates/skate-core/src/physics/joint_builder.rs new file mode 100644 index 00000000..dbb1f371 --- /dev/null +++ b/crates/skate-core/src/physics/joint_builder.rs @@ -0,0 +1,447 @@ +//! Semantic port of TU3 `rw::physics::JointJacobian::Build`. +//! +//! This module is intentionally separate from generic engine joints. It +//! accepts the exact 64-byte parameter and 80-byte frame records emitted by +//! `SkateboardBody::CreateJoints`, plus the rigid-body fields read by +//! `0x82AE3BC8`, and emits the 384-byte workspace consumed by the joint branch +//! of `0x82AE27D0`. +//! +//! Evidence labels used below: +//! - **Observed**: direct field loads, stores, constants, or branches in TU3. +//! - **Derived**: scalar algebra preserving the observed VMX data flow. +//! - **Inferred**: source-level naming where the executable has no type name. + +use super::{ + constraint_frames, + joint_records::{RetailJointFramesRaw, RetailJointParametersRaw}, + joint_solver::RetailJointJacobian, + native_arithmetic, + rigid_body::{RetailPackedWorldInverseInertia, RetailQuaternion}, +}; +use crate::math::{Basis3, Vector3}; +mod arithmetic; +use arithmetic::*; + +pub mod tu3 { + pub const JOINT_BATCH_BUILD: u32 = 0x82AE_39D0; + pub const JOINT_JACOBIAN_BUILD: u32 = 0x82AE_3BC8; + pub const JACOBIAN_RQD_CREATE: u32 = 0x82AE_0DB0; + pub const ITERATIVE_CONSTRAINT_SOLVER: u32 = 0x82AE_27D0; + + pub const ACTIVE_BODY: u32 = 4; + pub const JACOBIAN_BYTES: usize = 0x180; + pub const JACOBIAN_WORDS: usize = JACOBIAN_BYTES / 4; + + /// Observed TU3 vector constant used as the finite "unbounded" value. + pub const FINITE_INFINITY: f32 = f32::from_bits(0x7F7F_FFFF); + /// Observed singularity guard initialized at `0x830BDE50`. + pub const NEAR_PARALLEL: f32 = f32::from_bits(0x3F7F_FF58); +} + +/// Explicit body snapshot consumed by `JointJacobian::Build`. +/// +/// All fields are **Observed** inputs. `basis` is kept separate from +/// `orientation` because TU3 reads both rather than rebuilding one from the +/// other. Likewise the reaction address is metadata copied into the raw +/// Jacobian; this port never dereferences it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailJointBodyInput { + pub reaction_guest_address: u32, + pub state: u32, + pub orientation: RetailQuaternion, + pub center_of_mass: Vector3, + pub basis: Basis3, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub inverse_mass: f32, + pub world_inverse_inertia: RetailPackedWorldInverseInertia, +} + +/// Complete explicit call state for TU3 `JointJacobian::Build`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailJointBuildInput { + /// **Observed** 64-byte record at `Joint+4`. + pub parameters: RetailJointParametersRaw, + /// **Observed** 80-byte record at `Joint+0`. + pub frames: RetailJointFramesRaw, + /// **Observed** body at `Joint+0x10`. + pub body_a: RetailJointBodyInput, + /// **Observed** body at `Joint+0x14`. + pub body_b: RetailJointBodyInput, + /// **Observed** `Simulation+0xA0`. + pub time_step: f32, + /// **Observed** source `Joint*`, retained in vector 12's fourth word. + pub joint_guest_address: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct JointParameters { + linear_position_allowance: Vector3, + linear_velocity_allowance: Vector3, + twist_velocity_allowance: f32, + swing_velocity_allowance: f32, + swing_threshold: f32, + twist_threshold: f32, + swing_mode: u32, + twist_mode: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct JointFrames { + orientation_a: RetailQuaternion, + anchor_a: Vector3, + orientation_b: RetailQuaternion, + anchor_b: Vector3, + linear_orientation_b: RetailQuaternion, +} + +#[derive(Clone, Copy, Debug)] +struct Rqd { + relative: RetailQuaternion, + axes: [Vector3; 3], +} + +/// Builds TU3's fixed six-lane, 384-byte joint workspace. +/// +/// The three linear and three angular accumulators begin at zero because the +/// retail builder clears all three 128-byte cache lines on every batch build. +pub fn build_retail_joint_jacobian(input: RetailJointBuildInput) -> RetailJointJacobian { + debug_assert!(input.time_step.is_finite() && input.time_step > 0.0); + + let parameters = decode_parameters(input.parameters); + let frames = decode_frames(input.frames); + let active_a = input.body_a.state & tu3::ACTIVE_BODY != 0; + let active_b = input.body_b.state & tu3::ACTIVE_BODY != 0; + + // Observed composition order: body orientation followed by local frame. + let world_orientation_a = quaternion_multiply(input.body_a.orientation, frames.orientation_a); + let world_orientation_b = quaternion_multiply(input.body_b.orientation, frames.orientation_b); + let world_linear_orientation = + quaternion_multiply(input.body_b.orientation, frames.linear_orientation_b); + let angular_basis_a = basis_from_quaternion_retail(world_orientation_a); + let angular_basis_b = basis_from_quaternion_retail(world_orientation_b); + let linear_basis = basis_from_quaternion_retail(world_linear_orientation); + + let arm_a = multiply_basis(input.body_a.basis, frames.anchor_a); + let arm_b = multiply_basis(input.body_b.basis, frames.anchor_b); + let linear_axes = basis_columns(linear_basis); + // Retained matrix product: the original gathers an RQD quaternion before + // constructing this matrix. The VA extension of 82AE3D98 (10242A35) is + // not independently decoded, so its rounding path remains unverified. + let relative_matrix = relative_basis(angular_basis_a, angular_basis_b); + let rqd = create_rqd(world_orientation_a, world_orientation_b); + let (angular_axes, angular_raw_low, angular_raw_high) = angular_rows( + parameters, + angular_basis_a, + angular_basis_b, + relative_matrix, + rqd, + ); + + let inverse_mass_a = if active_a { + input.body_a.inverse_mass + } else { + 0.0 + }; + let inverse_mass_b = if active_b { + input.body_b.inverse_mass + } else { + 0.0 + }; + let inertia_a = if active_a { + input.body_a.world_inverse_inertia + } else { + zero_inertia() + }; + let inertia_b = if active_b { + input.body_b.world_inverse_inertia + } else { + zero_inertia() + }; + + let linear_inverse_effective_mass = core::array::from_fn(|index| { + let a = cross(arm_a, linear_axes[index]); + let b = cross(arm_b, linear_axes[index]); + let ia = constraint_frames::multiply_inertia_from_zero(inertia_a, a); + let ib = constraint_frames::multiply_inertia_from_zero(inertia_b, b); + // 82AE4814..4990 combines the two bodies component-wise, then uses + // (Z + Y) + (X + inverse_mass_A + inverse_mass_B), not two dot sums. + let x = b.x.mul_add(ib.x, a.x.mul_add(ia.x, 0.0)); + let y = b.y.mul_add(ib.y, a.y.mul_add(ia.y, 0.0)); + let z = b.z.mul_add(ib.z, a.z.mul_add(ia.z, 0.0)); + let denominator = (z + y) + (x + (inverse_mass_a + inverse_mass_b)); + native_arithmetic::reciprocal_estimate(denominator) + }); + let angular_inverse_effective_mass = core::array::from_fn(|index| { + let axis = angular_axes[index]; + let response = add( + constraint_frames::multiply_inertia_from_zero(inertia_a, axis), + constraint_frames::multiply_inertia_from_zero(inertia_b, axis), + ); + let x = axis.x.mul_add(response.x, 0.0); + let y = axis.y.mul_add(response.y, 0.0); + let z = axis.z.mul_add(response.z, 0.0); + // 82AE496C..49A0: Z + (X + Y), one estimate, then the RQD factor 1/2. + 0.5 * native_arithmetic::reciprocal_estimate(z + (x + y)) + }); + + let point_velocity_a = point_rate( + input.body_a.linear_velocity, + input.body_a.angular_velocity, + arm_a, + ); + let point_velocity_b = point_rate( + input.body_b.linear_velocity, + input.body_b.angular_velocity, + arm_b, + ); + // Native prediction is computed before the active-body inertia mask. + // An inactive body can still supply the source-owned kinematic rates. + let point_acceleration_a = point_rate( + input.body_a.force_acceleration, + input.body_a.torque_acceleration, + arm_a, + ); + let point_acceleration_b = point_rate( + input.body_b.force_acceleration, + input.body_b.torque_acceleration, + arm_b, + ); + let displacement_a = scale( + super::constraint_frames::multiply_add( + point_acceleration_a, + input.time_step, + point_velocity_a, + ), + input.time_step, + ); + let displacement_b = scale( + super::constraint_frames::multiply_add( + point_acceleration_b, + input.time_step, + point_velocity_b, + ), + input.time_step, + ); + let relative_displacement = sub(displacement_b, displacement_a); + let predicted_separation = sub( + add(add(input.body_b.center_of_mass, arm_b), displacement_b), + add(add(input.body_a.center_of_mass, arm_a), displacement_a), + ); + let local_rate_displacement = project(relative_displacement, linear_axes); + let local_separation = project(predicted_separation, linear_axes); + + let mut linear_low = [0.0; 3]; + let mut linear_high = [0.0; 3]; + let position_allowance = vector_to_array(parameters.linear_position_allowance); + let velocity_allowance = vector_to_array(parameters.linear_velocity_allowance); + for lane in 0..3 { + let displacement_low = local_separation[lane] - position_allowance[lane]; + let displacement_high = local_separation[lane] + position_allowance[lane]; + let velocity_low = + local_rate_displacement[lane] - velocity_allowance[lane] * input.time_step; + let velocity_high = + local_rate_displacement[lane] + velocity_allowance[lane] * input.time_step; + let raw_low = vmx_max(displacement_low, vmx_min(velocity_low, displacement_high)); + let raw_high = vmx_min(displacement_high, vmx_max(velocity_high, displacement_low)); + linear_low[lane] = raw_low * linear_inverse_effective_mass[lane]; + linear_high[lane] = raw_high * linear_inverse_effective_mass[lane]; + } + + let relative_angular_rate = sub( + super::constraint_frames::multiply_add( + input.body_b.torque_acceleration, + input.time_step, + sub(input.body_b.angular_velocity, input.body_a.angular_velocity), + ), + scale(input.body_a.torque_acceleration, input.time_step), + ); + let local_angular_displacement = + project(scale(relative_angular_rate, input.time_step), angular_axes); + let angular_velocity_allowance = [ + parameters.twist_velocity_allowance, + parameters.swing_velocity_allowance, + parameters.swing_velocity_allowance, + ]; + let mut angular_low = [0.0; 3]; + let mut angular_high = [0.0; 3]; + for lane in 0..3 { + let predicted_low = angular_raw_low[lane] + local_angular_displacement[lane]; + let predicted_high = angular_raw_high[lane] + local_angular_displacement[lane]; + let velocity_low = + local_angular_displacement[lane] - angular_velocity_allowance[lane] * input.time_step; + let velocity_high = angular_velocity_allowance[lane] + .mul_add(input.time_step, local_angular_displacement[lane]); + let raw_low = vmx_max(predicted_low, vmx_min(velocity_low, predicted_high)); + let raw_high = vmx_min(predicted_high, vmx_max(velocity_high, predicted_low)); + angular_low[lane] = raw_low * angular_inverse_effective_mass[lane]; + angular_high[lane] = raw_high * angular_inverse_effective_mass[lane]; + } + + let linear_projection = scaled_projection_columns(linear_axes, linear_inverse_effective_mass); + let angular_projection = + scaled_projection_columns(angular_axes, angular_inverse_effective_mass); + let inertia_columns_a = packed_inertia_columns(inertia_a, inverse_mass_a); + let inertia_columns_b = packed_inertia_columns(inertia_b, inverse_mass_b); + + let mut output = RetailJointJacobian { + words: [0; tu3::JACOBIAN_WORDS], + }; + write_vector( + &mut output, + 0, + [ + arm_a.x, + arm_a.y, + arm_a.z, + f32::from_bits(input.body_a.reaction_guest_address), + ], + ); + write_vector( + &mut output, + 1, + [ + arm_b.x, + arm_b.y, + arm_b.z, + f32::from_bits(input.body_b.reaction_guest_address), + ], + ); + // Vectors 2 and 3 are the cleared linear/angular accumulators. + write_vector( + &mut output, + 4, + [ + linear_projection[0][0], + linear_projection[0][1], + linear_projection[0][2], + 0.0, + ], + ); + write_vector( + &mut output, + 5, + [ + angular_projection[0][0], + angular_projection[0][1], + angular_projection[0][2], + 0.0, + ], + ); + write_vector( + &mut output, + 6, + [ + linear_projection[1][0], + linear_projection[1][1], + linear_projection[1][2], + angular_low[0], + ], + ); + write_vector( + &mut output, + 7, + [ + angular_projection[1][0], + angular_projection[1][1], + angular_projection[1][2], + angular_low[1], + ], + ); + write_vector( + &mut output, + 8, + [ + linear_projection[2][0], + linear_projection[2][1], + linear_projection[2][2], + angular_high[0], + ], + ); + write_vector( + &mut output, + 9, + [ + angular_projection[2][0], + angular_projection[2][1], + angular_projection[2][2], + angular_high[1], + ], + ); + write_vector( + &mut output, + 10, + [linear_low[0], linear_low[1], linear_low[2], angular_low[2]], + ); + write_vector( + &mut output, + 11, + [ + linear_high[0], + linear_high[1], + linear_high[2], + angular_high[2], + ], + ); + write_vector( + &mut output, + 12, + [ + linear_axes[0].x, + linear_axes[0].y, + linear_axes[0].z, + f32::from_bits(input.joint_guest_address), + ], + ); + // Packed carry lanes remain unverified pending direct TU3 instruction review. + write_vector( + &mut output, + 13, + [ + linear_axes[1].x, + linear_axes[1].y, + linear_axes[1].z, + linear_axes[1].x, + ], + ); + write_vector( + &mut output, + 14, + [ + linear_axes[2].x, + linear_axes[2].y, + linear_axes[2].z, + linear_axes[0].y, + ], + ); + for lane in 0..3 { + write_vector( + &mut output, + 15 + lane, + [ + angular_axes[lane].x, + angular_axes[lane].y, + angular_axes[lane].z, + angular_axes[lane].x, + ], + ); + } + for lane in 0..3 { + write_vector(&mut output, 18 + lane, inertia_columns_a[lane]); + write_vector(&mut output, 21 + lane, inertia_columns_b[lane]); + } + output +} + +fn write_vector(output: &mut RetailJointJacobian, vector: usize, value: [f32; 4]) { + let start = vector * 4; + for lane in 0..4 { + output.words[start + lane] = value[lane].to_bits(); + } +} + +#[cfg(test)] +#[path = "tests/joint_builder.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/joint_builder/arithmetic.rs b/crates/skate-core/src/physics/joint_builder/arithmetic.rs new file mode 100644 index 00000000..876a1afa --- /dev/null +++ b/crates/skate-core/src/physics/joint_builder/arithmetic.rs @@ -0,0 +1,259 @@ +//! Original TU3 joint angular limits and shared constraint-frame arithmetic. +use super::*; +use crate::physics::constraint_frames; +pub(super) fn decode_parameters(raw: RetailJointParametersRaw) -> JointParameters { + JointParameters { + linear_position_allowance: vector_from_words(&raw.words, 0), + linear_velocity_allowance: vector_from_words(&raw.words, 4), + twist_velocity_allowance: f32::from_bits(raw.words[8]), + swing_velocity_allowance: f32::from_bits(raw.words[9]), + swing_threshold: f32::from_bits(raw.words[12]), + twist_threshold: f32::from_bits(raw.words[13]), + swing_mode: raw.words[14], + twist_mode: raw.words[15], + } +} + +pub(super) fn decode_frames(raw: RetailJointFramesRaw) -> JointFrames { + JointFrames { + orientation_a: quaternion_from_words(&raw.words, 0), + anchor_a: vector_from_words(&raw.words, 4), + orientation_b: quaternion_from_words(&raw.words, 8), + anchor_b: vector_from_words(&raw.words, 12), + linear_orientation_b: quaternion_from_words(&raw.words, 16), + } +} + +pub(super) fn angular_rows( + parameters: JointParameters, + basis_a: Basis3, + basis_b: Basis3, + relative: [[f32; 3]; 3], + rqd: Rqd, +) -> ([Vector3; 3], [f32; 3], [f32; 3]) { + let finite_infinity = tu3::FINITE_INFINITY; + // Native defaults are all three frame-B axes, including skipped limits. + let mut axes = basis_columns(basis_b); + let mut low = [-finite_infinity; 3]; + let mut high = [finite_infinity; 3]; + + match parameters.swing_mode { + 0 => { + axes[1] = rqd.axes[1]; + axes[2] = rqd.axes[2]; + low[1] = 2.0 * rqd.relative.y; + high[1] = low[1]; + low[2] = 2.0 * rqd.relative.z; + high[2] = low[2]; + } + 1 => { + // 82AE4178..4188 skips the cone calculation when all splatted + // cosine lanes compare >= the original 0.99999 guard. + if !(relative[0][0] >= tu3::NEAR_PARALLEL) { + let first_a = basis_column(basis_a, 0); + let first_b = basis_column(basis_b, 0); + let perpendicular_z = relative[0][2]; + let perpendicular_y = relative[0][1]; + let squared = + perpendicular_y.mul_add(perpendicular_y, perpendicular_z * perpendicular_z); + let root = squared * constraint_frames::reciprocal_sqrt(squared); + let root = if squared == 0.0 { 0.0 } else { root }; + let inverse_length = constraint_frames::reciprocal(root) * 1.0; + axes[1] = scale(cross(first_a, first_b), inverse_length); + axes[2] = cross(axes[1], first_a); + // The bound is written even while inside the cone, allowing + // the predicted rate to reach the limit during this step. + low[1] = (parameters.swing_threshold - relative[0][0]) * inverse_length; + } + } + 2 | 3 => { + axes[1] = basis_column(basis_b, 1); + if parameters.twist_mode == 0 { + axes[2] = rqd.axes[2]; + low[2] = 2.0 * rqd.relative.z; + high[2] = low[2]; + } else { + axes[2] = cross(basis_column(basis_a, 0), axes[1]); + low[2] = -relative[0][1]; + high[2] = low[2]; + } + if parameters.swing_mode == 2 { + let denominator = relative[0][2]; + // 82AE42F4..4304: equality skips the reciprocal and retains + // both unbounded values. NaN is not equal to zero. + if denominator != 0.0 { + let correction = constraint_frames::reciprocal(denominator) + * (parameters.swing_threshold - relative[0][0]); + if denominator < 0.0 { + high[1] = correction; + } else { + low[1] = correction; + } + } + } + } + _ => { + axes[1] = basis_column(basis_b, 1); + axes[2] = basis_column(basis_b, 2); + } + } + + match parameters.twist_mode { + 0 => { + axes[0] = rqd.axes[0]; + low[0] = 2.0 * rqd.relative.x; + high[0] = low[0]; + } + 1 => { + axes[0] = basis_column(basis_a, 0); + // Derived matrix-index form matching the emitted TU3 subtraction. + let denominator = relative[2][1] - relative[1][2]; + // 82AE4434..4444 also retains the unbounded interval at zero. + if denominator != 0.0 { + let numerator = (1.0 + relative[0][0]) * parameters.twist_threshold + - (relative[1][1] + relative[2][2]); + let correction = constraint_frames::reciprocal(denominator) * numerator; + if denominator < 0.0 { + high[0] = correction; + } else { + low[0] = correction; + } + } + } + _ => { + axes[0] = basis_column(basis_b, 0); + } + } + + (axes, low, high) +} + +pub(super) fn create_rqd(world_a: RetailQuaternion, world_b: RetailQuaternion) -> Rqd { + let rows = constraint_frames::quaternion_rows(world_a, world_b); + Rqd { + relative: rows.relative, + axes: rows.axes, + } +} + +pub(super) fn relative_basis(a: Basis3, b: Basis3) -> [[f32; 3]; 3] { + core::array::from_fn(|row| { + core::array::from_fn(|column| dot3(basis_column(a, row), basis_column(b, column))) + }) +} + +pub(super) fn scaled_projection_columns( + axes: [Vector3; 3], + inverse_effective_mass: [f32; 3], +) -> [[f32; 3]; 3] { + core::array::from_fn(|component| { + core::array::from_fn(|row| { + vector_to_array(axes[row])[component] * inverse_effective_mass[row] + }) + }) +} + +pub(super) fn packed_inertia_columns( + packed: RetailPackedWorldInverseInertia, + inverse_mass: f32, +) -> [[f32; 4]; 3] { + [ + [packed.full.x, packed.full.y, packed.full.z, inverse_mass], + [packed.full.y, packed.split.y, packed.split.z, inverse_mass], + [packed.full.z, packed.split.z, packed.split.x, inverse_mass], + ] +} + +pub(super) fn zero_inertia() -> RetailPackedWorldInverseInertia { + RetailPackedWorldInverseInertia { + full: Vector3::ZERO, + split: Vector3::ZERO, + } +} + +pub(super) fn basis_from_quaternion_retail(q: RetailQuaternion) -> Basis3 { + constraint_frames::basis(q) +} + +pub(super) fn quaternion_multiply(a: RetailQuaternion, b: RetailQuaternion) -> RetailQuaternion { + constraint_frames::compose(a, b) +} + +pub(super) fn point_rate(linear: Vector3, angular: Vector3, arm: Vector3) -> Vector3 { + constraint_frames::point_rate(linear, angular, arm) +} + +pub(super) fn project(vector: Vector3, axes: [Vector3; 3]) -> [f32; 3] { + axes.map(|axis| dot3(vector, axis)) +} + +pub(super) fn multiply_basis(basis: Basis3, vector: Vector3) -> Vector3 { + constraint_frames::transform_direction(basis, vector) +} + +pub(super) fn basis_columns(basis: Basis3) -> [Vector3; 3] { + core::array::from_fn(|index| basis_column(basis, index)) +} + +pub(super) fn basis_column(basis: Basis3, index: usize) -> Vector3 { + Vector3::new( + basis.columns[index][0], + basis.columns[index][1], + basis.columns[index][2], + ) +} + +pub(super) fn vector_from_words(words: &[u32; N], offset: usize) -> Vector3 { + Vector3::new( + f32::from_bits(words[offset]), + f32::from_bits(words[offset + 1]), + f32::from_bits(words[offset + 2]), + ) +} + +pub(super) fn quaternion_from_words( + words: &[u32; N], + offset: usize, +) -> RetailQuaternion { + RetailQuaternion { + x: f32::from_bits(words[offset]), + y: f32::from_bits(words[offset + 1]), + z: f32::from_bits(words[offset + 2]), + w: f32::from_bits(words[offset + 3]), + } +} + +pub(super) fn vector_to_array(value: Vector3) -> [f32; 3] { + [value.x, value.y, value.z] +} + +pub(super) fn cross(left: Vector3, right: Vector3) -> Vector3 { + constraint_frames::cross(left, right) +} + +pub(super) fn dot3(left: Vector3, right: Vector3) -> f32 { + crate::physics::native_arithmetic::dot3( + [left.x, left.y, left.z, 0.0], + [right.x, right.y, right.z, 0.0], + ) +} + +pub(super) fn add(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new(left.x + right.x, left.y + right.y, left.z + right.z) +} + +pub(super) fn sub(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new(left.x - right.x, left.y - right.y, left.z - right.z) +} + +pub(super) fn scale(value: Vector3, scalar: f32) -> Vector3 { + Vector3::new(value.x * scalar, value.y * scalar, value.z * scalar) +} + +pub(super) fn vmx_min(left: f32, right: f32) -> f32 { + crate::physics::native_arithmetic::vector_min(left, right) +} + +pub(super) fn vmx_max(left: f32, right: f32) -> f32 { + crate::physics::native_arithmetic::vector_max(left, right) +} diff --git a/crates/skate-core/src/physics/joint_records.rs b/crates/skate-core/src/physics/joint_records.rs new file mode 100644 index 00000000..fa703ac4 --- /dev/null +++ b/crates/skate-core/src/physics/joint_records.rs @@ -0,0 +1,241 @@ +//! TU3 skateboard joint definitions reconstructed from stock collection data. +//! +//! `SkateboardBody::CreateJoints` (`0x82C0C268`) builds two deck/truck joints +//! followed by four truck/wheel joints. This module emits the native parameter +//! and frame payloads that `Assembly::Initialize` consumes; it does not replace +//! them with a third-party constraint type. + +use super::{ + board::BodyId, + drive_frames::{ + default_truck_drive_frames, retail_quaternion_from_basis, AuthoredTransformInputs, + RetailDriveFrame, RetailDriveFrames, + }, + rigid_body::RetailQuaternion, +}; +use crate::{ + math::{Basis3, Vector3}, + trigonometry, +}; + +pub mod tu3 { + pub const CREATE_JOINTS: u32 = 0x82C0_C268; + pub const ASSEMBLY_INITIALIZE: u32 = 0x82AD_FAF8; + pub const ASSEMBLY_POPULATE: u32 = 0x82AD_FBB8; + pub const FRAME_PACK_0: u32 = 0x82C0_0940; + pub const FRAME_PACK_1: u32 = 0x82C0_0AF0; + pub const FRAME_PACK_2: u32 = 0x82BD_3858; +} + +pub const JOINT_COUNT: usize = 6; + +const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); +const SIMULATION_FREQUENCY: f32 = f32::from_bits(0x426F_FFFF); +const HALF_PI: f32 = f32::from_bits(0x3FC9_0FDB); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailJointSettings { + pub truck_twist_limit_degrees: f32, + pub truck_twist_angle_degrees: f32, + pub wheel_swing_limit_degrees: f32, +} + +impl RetailJointSettings { + /// Values decoded from the stock `physicstrucks` and `physicswheels` + /// collection XMLs read by `CreateJoints`. + pub const STOCK: Self = Self { + truck_twist_limit_degrees: f32::from_bits(0x42AC_3333), + truck_twist_angle_degrees: f32::from_bits(0x4160_0000), + wheel_swing_limit_degrees: f32::from_bits(0x4974_2400), + }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointParametersRaw { + pub words: [u32; 16], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointFramesRaw { + pub words: [u32; 20], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RetailJointRecord { + pub definition_body_0: BodyId, + pub definition_body_1: BodyId, + pub parameters: RetailJointParametersRaw, + pub frames: RetailJointFramesRaw, +} + +impl RetailJointRecord { + /// Body written to live `Joint+0x10` by assembly activation. + pub const fn live_body_a(self) -> BodyId { + self.definition_body_1 + } + + /// Body written to live `Joint+0x14` by assembly activation. + pub const fn live_body_b(self) -> BodyId { + self.definition_body_0 + } +} + +/// Complete stock record set in the unrolled write order of TU3 +/// `SkateboardBody::CreateJoints`. +pub fn default_joint_records() -> [RetailJointRecord; JOINT_COUNT] { + joint_records(RetailJointSettings::STOCK) +} + +pub fn joint_records(settings: RetailJointSettings) -> [RetailJointRecord; JOINT_COUNT] { + let truck_parameters = truck_parameters(settings); + let wheel_parameters = wheel_parameters(settings); + let truck_frames = default_truck_drive_frames().map(truck_joint_frames); + let wheel_orientation = wheel_joint_orientation(); + let wheel_offset = AuthoredTransformInputs::STOCK.wheel_x_distance; + let positive_wheel_frames = wheel_joint_frames(wheel_orientation, wheel_offset); + let negative_wheel_frames = wheel_joint_frames(wheel_orientation, -wheel_offset); + + [ + joint( + BodyId::Deck, + BodyId::FrontTruck, + truck_parameters, + truck_frames[0], + ), + joint( + BodyId::Deck, + BodyId::BackTruck, + truck_parameters, + truck_frames[1], + ), + joint( + BodyId::FrontTruck, + BodyId::RightFrontWheel, + wheel_parameters, + positive_wheel_frames, + ), + joint( + BodyId::FrontTruck, + BodyId::LeftFrontWheel, + wheel_parameters, + negative_wheel_frames, + ), + joint( + BodyId::BackTruck, + BodyId::RightBackWheel, + wheel_parameters, + positive_wheel_frames, + ), + joint( + BodyId::BackTruck, + BodyId::LeftBackWheel, + wheel_parameters, + negative_wheel_frames, + ), + ] +} + +fn truck_parameters(settings: RetailJointSettings) -> RetailJointParametersRaw { + let angle_radians = settings.truck_twist_angle_degrees * DEGREES_TO_RADIANS; + let (_, angle_cosine) = trigonometry::sin_cos(angle_radians); + let twist_velocity = + (settings.truck_twist_limit_degrees * DEGREES_TO_RADIANS) * SIMULATION_FREQUENCY; + let mut words = [0; 16]; + words[8] = twist_velocity.to_bits(); + // The native parameter block retains the angular boundary in its spare + // fourth lane while the solver consumes cos(angle) from word 13. + words[11] = angle_radians.to_bits(); + words[12] = 1.0_f32.to_bits(); + words[13] = angle_cosine.to_bits(); + words[14] = 0; + words[15] = 1; + RetailJointParametersRaw { words } +} + +fn wheel_parameters(settings: RetailJointSettings) -> RetailJointParametersRaw { + let swing_velocity = + (settings.wheel_swing_limit_degrees * DEGREES_TO_RADIANS) * SIMULATION_FREQUENCY; + let mut words = [0; 16]; + words[9] = swing_velocity.to_bits(); + words[12] = 1.0_f32.to_bits(); + words[13] = 1.0_f32.to_bits(); + words[14] = 3; + words[15] = 0; + RetailJointParametersRaw { words } +} + +fn truck_joint_frames(frames: RetailDriveFrames) -> RetailJointFramesRaw { + pack_frames(frames.body_a, frames.body_b, frames.body_b.orientation) +} + +fn wheel_joint_orientation() -> RetailQuaternion { + let (sin, cos) = trigonometry::sin_cos(HALF_PI); + retail_quaternion_from_basis(Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]], + }) +} + +fn wheel_joint_frames(orientation: RetailQuaternion, z_offset: f32) -> RetailJointFramesRaw { + pack_frames( + RetailDriveFrame { + orientation, + translation: Vector3::ZERO, + }, + RetailDriveFrame { + orientation, + translation: Vector3::new(0.0, 0.0, z_offset), + }, + orientation, + ) +} + +fn pack_frames( + body_a: RetailDriveFrame, + body_b: RetailDriveFrame, + linear_orientation_b: RetailQuaternion, +) -> RetailJointFramesRaw { + let mut words = [0; 20]; + write_quaternion(&mut words, 0, body_a.orientation); + write_vector(&mut words, 4, body_a.translation); + write_quaternion(&mut words, 8, body_b.orientation); + write_vector(&mut words, 12, body_b.translation); + write_quaternion(&mut words, 16, linear_orientation_b); + RetailJointFramesRaw { words } +} + +fn write_quaternion(words: &mut [u32; 20], offset: usize, value: RetailQuaternion) { + words[offset..offset + 4].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + value.w.to_bits(), + ]); +} + +fn write_vector(words: &mut [u32; 20], offset: usize, value: Vector3) { + words[offset..offset + 3].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + ]); +} + +const fn joint( + definition_body_0: BodyId, + definition_body_1: BodyId, + parameters: RetailJointParametersRaw, + frames: RetailJointFramesRaw, +) -> RetailJointRecord { + RetailJointRecord { + definition_body_0, + definition_body_1, + parameters, + frames, + } +} + +#[cfg(test)] +#[path = "tests/joint_records.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/joint_solver.rs b/crates/skate-core/src/physics/joint_solver.rs new file mode 100644 index 00000000..e3bc6805 --- /dev/null +++ b/crates/skate-core/src/physics/joint_solver.rs @@ -0,0 +1,82 @@ +//! Semantic port of Skate 3 TU3's six-degree joint iteration. +//! +//! Skate 3 builds one 384-byte `JointJacobian` for each of the six skateboard +//! assembly joints. `rw::physics::Simulation::Solve` visits contacts, joints, +//! and drives in that order on each configured outer iteration. The joint loop +//! starts near `0x82AE2BC8`; the loop near `0x82AE2E78` is the drive family. +//! +//! Generated-code validation is withdrawn. All arithmetic and packed-lane +//! semantics require independent review against TU3 IDA before parity claims. + +use crate::physics::rigid_body::RetailReactionCorrections; + +pub mod tu3 { + pub const SIMULATION_SOLVE: u32 = 0x82AE_27D0; + pub const JOINT_SOLVE_LOOP: u32 = 0x82AE_2BC8; + pub const DRIVE_SOLVE_LOOP: u32 = 0x82AE_2E78; + pub const JOINT_BATCH_BUILD: u32 = 0x82AE_39D0; + pub const JOINT_JACOBIAN_BUILD: u32 = 0x82AE_3BC8; + pub const JACOBIAN_BYTES: usize = 384; + pub const REACTION_BYTES: usize = 64; +} + +const JACOBIAN_VECTOR_COUNT: usize = tu3::JACOBIAN_BYTES / 16; +const REACTION_VECTOR_COUNT: usize = tu3::REACTION_BYTES / 16; + +/// Raw guest word order of TU3's 384-byte `JointJacobian`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointJacobian { + pub words: [u32; JACOBIAN_VECTOR_COUNT * 4], +} + +/// Four raw vectors indexed by a rigid body's solver id. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointReactionBlock { + pub words: [u32; REACTION_VECTOR_COUNT * 4], +} + +impl RetailJointReactionBlock { + /// Packs the two reaction vectors consumed by the joint-family branch. + /// + /// TU3 leaves reaction vectors one and three to the contact family's + /// position/orientation correction. + pub fn from_reactions(reactions: RetailReactionCorrections) -> Self { + let mut words = [0; REACTION_VECTOR_COUNT * 4]; + words[0] = reactions.linear_displacement.x.to_bits(); + words[1] = reactions.linear_displacement.y.to_bits(); + words[2] = reactions.linear_displacement.z.to_bits(); + words[8] = reactions.angular_displacement.x.to_bits(); + words[9] = reactions.angular_displacement.y.to_bits(); + words[10] = reactions.angular_displacement.z.to_bits(); + Self { words } + } + + /// Writes back only the two joint-owned velocity correction vectors. + pub fn write_velocity_reactions(self, reactions: &mut RetailReactionCorrections) { + reactions.linear_displacement.x = f32::from_bits(self.words[0]); + reactions.linear_displacement.y = f32::from_bits(self.words[1]); + reactions.linear_displacement.z = f32::from_bits(self.words[2]); + reactions.angular_displacement.x = f32::from_bits(self.words[8]); + reactions.angular_displacement.y = f32::from_bits(self.words[9]); + reactions.angular_displacement.z = f32::from_bits(self.words[10]); + } +} + +/// One joint pass using TU3's fused vector operations and projector. +pub fn solve_joint_iteration( + jacobian: &mut RetailJointJacobian, + reaction_a: &mut RetailJointReactionBlock, + reaction_b: &mut RetailJointReactionBlock, +) { + super::solver::solve_joint_record( + &mut jacobian.words, + &mut reaction_a.words, + &mut reaction_b.words, + ); +} + +#[cfg(test)] +#[path = "tests/joint_solver.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/manual.rs b/crates/skate-core/src/physics/manual.rs new file mode 100644 index 00000000..551a24da --- /dev/null +++ b/crates/skate-core/src/physics/manual.rs @@ -0,0 +1,7 @@ +//! Source-grounded manual-balance controller units (TU3 82C047F0). +//! Complete gameplay integration and numerical dependencies are tracked in +//! docs/agents/overnight/gameplay.md. +pub mod angle; +pub mod controller; +pub mod settings; +pub mod state; diff --git a/crates/skate-core/src/physics/manual/angle.rs b/crates/skate-core/src/physics/manual/angle.rs new file mode 100644 index 00000000..dcc84ac4 --- /dev/null +++ b/crates/skate-core/src/physics/manual/angle.rs @@ -0,0 +1,30 @@ +//! Finite scalar Angle::Normalize 8258DB98, from direct TU3 disassembly. +//! Kept local pending coordinator's shared angle-operation audit. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AngleError { + /// Native fctiwz result for nonfinite/overflow input is not reconstructed. + IntegerConversionUnavailable, +} + +pub fn normalize(angle: f32) -> Result { + let pi = f32::from_bits(0x4049_0fdb); + if angle >= -pi && angle < pi { + return Ok(angle); + } + let turns = angle * f32::from_bits(0x3e22_f983); + if !turns.is_finite() || turns < i32::MIN as f32 || turns as f64 > i32::MAX as f64 { + return Err(AngleError::IntegerConversionUnavailable); + } + let whole_turns = turns as i32; + let tau = f32::from_bits(0x40c9_0fdb); + // fnmsubs performs fused -(count*tau - angle), then binary32 rounding. + let reduced = -((whole_turns as f32).mul_add(tau, -angle)); + Ok(if reduced >= pi { + reduced - tau + } else if reduced < -pi { + reduced + tau + } else { + reduced + }) +} diff --git a/crates/skate-core/src/physics/manual/controller.rs b/crates/skate-core/src/physics/manual/controller.rs new file mode 100644 index 00000000..04b96d53 --- /dev/null +++ b/crates/skate-core/src/physics/manual/controller.rs @@ -0,0 +1,213 @@ +//! CalcManualEffect 82C047F0 control/scalar response and output construction. +//! The native angle-between-vectors measurement remains a required dependency. +use super::{ + angle, + settings::{ManualMode, ManualSettings}, + state::ManualState, +}; + +#[derive(Clone, Copy, Debug)] +pub struct ManualInput { + /// ProcessedPhysIn+2720 and +2620. + pub balance: f32, + pub flipped_controls: f32, + /// +2664, +2616 and +2768. Noise time is independent of manual elapsed. + pub procedural_noise_time: f32, + pub absolute_speed: f32, + pub animation_noise: f32, + /// +2604; this controller does not replace it with a fixed 1/60. + pub timestep: f32, + /// The explicit CalcManualEffect argument; Ground callers pass false. + pub powersliding: bool, + /// Processed2468 bit29 selects BrakeTiltAngle and correction-force gates. + pub braking: bool, + /// Processed2472 bits27 and26, respectively. Physical axle identities are + /// not assumed here; the signed balance gates are preserved directly. + pub positive_balance_contact: bool, + pub negative_balance_contact: bool, + /// Processed2468 bit20 reverses the point-selection balance sign. + pub reversed_point_selection: bool, + /// Reference frame from board+292, matrix+752: first and third columns. + pub reference_x: [f32; 4], + pub reference_z: [f32; 4], + /// Deck part6 transform's z-axis from 82585CB0, not an animation pose. + pub deck_z: [f32; 4], + /// Processed transform third column+224 and angular velocity+416. + pub velocity_frame_z: [f32; 4], + pub angular_velocity_world: [f32; 4], + /// Native body caches. Producers/original names remain unresolved; these + /// layout identities stay at the input boundary instead of guessed levers. + pub correction_point_7888: [f32; 4], + pub correction_point_7952: [f32; 4], +} + +/// Exact 8296EC98 dependency: degeneracy gates, normalization, acos and signed +/// cross-product selection. There is deliberately no host atan2/acos fallback. +pub trait ManualAngleMeasurement { + type Error; + fn angle_between( + &mut self, + deck_z: [f32; 4], + reference_z: [f32; 4], + reference_x: [f32; 4], + ) -> Result; +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ManualError { + Angle(angle::AngleError), + Measurement(E), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManualEffect { + /// Native result+0, applied with ApplyAngularDisplacementToDeck. + pub angular_displacement: [f32; 4], + /// Result+16/+32 are explicitly zeroed by this function. + pub force_world: [f32; 4], + pub force_point_body: [f32; 4], + /// Result+48/+64, plus result+80/+81 status bytes. + pub corrective_force_world: [f32; 4], + pub corrective_point_body: [f32; 4], + pub correction_active: bool, + pub opposing_motion_without_correction: bool, +} + +/// An unavailable measurement/conversion aborts the tick. Already executed +/// scalar-state writes remain visible, but no effect is returned for publication. +pub fn calculate( + state: &mut ManualState, + settings: &ManualSettings, + mode: ManualMode, + input: &ManualInput, + geometry: &mut G, +) -> Result> { + let mut effect = ManualEffect { + angular_displacement: [0.0; 4], + force_world: [0.0; 4], + force_point_body: [0.0; 4], + corrective_force_world: [0.0; 4], + corrective_point_body: [0.0; 4], + correction_active: false, + opposing_motion_without_correction: false, + }; + let mut output_scale = 1.0; + if input.balance == 0.0 { + state.reset(); + } else { + let norm = |value| angle::normalize(value).map_err(ManualError::Angle); + let gains = if input.powersliding { + settings.powerslide + } else { + settings.manual + }; + let scaled_balance = input.balance * input.flipped_controls; + let sign = if scaled_balance > 0.0 { 1.0 } else { -1.0 }; + let signed_target = scaled_balance.abs().mul_add(0.75, 0.25) * sign; + let started = if state.elapsed == 0.0 { 0.0 } else { 1.0 }; + let degrees_to_radians = f32::from_bits(0x3c8e_fa35); + let base_angle = + norm((settings.maximum_tilt_degrees * signed_target) * degrees_to_radians)?; + let target = if input.braking { + norm((settings.brake_tilt_degrees * signed_target) * degrees_to_radians)? + } else { + let phase = (input.procedural_noise_time * settings.procedural_noise_frequency) + * f32::from_bits(0x40c9_0fdb); + let noise = crate::trigonometry::sin(phase) * settings.procedural_noise_scale; + let speed_scale = settings.noise_vs_speed.evaluate(input.absolute_speed); + let animated = input.animation_noise * settings.animation_noise_scale; + let disturbance = animated.mul_add(input.flipped_controls, noise); + let noise_angle = norm(speed_scale * disturbance)?; + norm(noise_angle + base_angle)? + }; + if state.elapsed == 0.0 { + state.angular_correction = norm(settings.start_torque_scale * target)?; + } + state.target_angle = target; + let measured = geometry + .angle_between(input.deck_z, input.reference_z, input.reference_x) + .map_err(ManualError::Measurement)?; + let measured = norm(measured)?; + let angle_change = measured - state.measured_angle; + state.measured_angle = measured; + let error = clamp_symmetric(norm(target - measured)?, settings.maximum_angle_error); + let weighted_error = error * f32::from_bits(0x3d4c_cccd); + state.filtered_angle_error = state + .filtered_angle_error + .mul_add(f32::from_bits(0x3f73_3333), weighted_error); + let derivative = clamp_symmetric( + (angle_change * started) * gains.derivative, + settings.derivative_limit, + ); + let wheel_scale = if input.positive_balance_contact != input.negative_balance_contact { + 0.5 + } else { + 1.0 + }; + let wrong_contact = (!input.positive_balance_contact && input.balance > 0.0) + || (!input.negative_balance_contact && input.balance < 0.0); + if wrong_contact { + state.angular_correction = + (settings.torque_bleed_without_contact * state.angular_correction) * wheel_scale; + output_scale = settings.torque_scale_without_contact * wheel_scale; + } else { + state.angular_correction = ((state.angular_correction + derivative) + + state.filtered_angle_error * gains.integral) + + error * gains.proportional; + } + apply_correction(&mut effect, input, mode, target, measured); + state.elapsed = input.timestep + state.elapsed; + } + let correction = state.angular_correction * output_scale; + effect.angular_displacement = input.reference_x.map(|component| (-component) * correction); + Ok(effect) +} + +fn apply_correction( + effect: &mut ManualEffect, + input: &ManualInput, + mode: ManualMode, + target: f32, + measured: f32, +) { + let frame = input.velocity_frame_z; + let omega = input.angular_velocity_world; + let local_z = frame[2].mul_add(omega[2], frame[1].mul_add(omega[1], frame[0] * omega[0])); + if !input.braking || !(0.0 > local_z * input.balance) { + return; + } + if !mode.corrective_force_enabled { + effect.opposing_motion_without_correction = true; + return; + } + if !(local_z.abs() > mode.correction_angular_speed_threshold) { + return; + } + let radians_to_degrees = f32::from_bits(0x4265_2ee1); + let error_degrees = (target * radians_to_degrees) - (measured * radians_to_degrees); + if !(error_degrees.abs() < 1.0) { + return; + } + let alternate = if input.reversed_point_selection { + input.balance < 0.0 + } else { + input.balance > 0.0 + }; + effect.corrective_point_body = if alternate { + input.correction_point_7888 + } else { + input.correction_point_7952 + }; + effect.corrective_force_world = omega.map(|component| (component * -1.0) * 400.0); + effect.correction_active = true; +} + +fn clamp_symmetric(value: f32, bound: f32) -> f32 { + let lower = -bound; + let lower_clamped = if lower - value >= 0.0 { lower } else { value }; + if bound - lower_clamped >= 0.0 { + lower_clamped + } else { + bound + } +} diff --git a/crates/skate-core/src/physics/manual/settings.rs b/crates/skate-core/src/physics/manual/settings.rs new file mode 100644 index 00000000..a733b7b2 --- /dev/null +++ b/crates/skate-core/src/physics/manual/settings.rs @@ -0,0 +1,42 @@ +//! physics_manual stock layout at global830CFDA4+200, wrapper+4. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct ManualGains { + pub proportional: f32, + pub integral: f32, + pub derivative: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ManualSettings { + /// NoiseVsSpeed has a 16-byte header; X/Y arrays start at +96/+128. + pub noise_vs_speed: PointGraph<8>, + /// +160 TorqueScalarWithNoContact; applies to emitted displacement, not state. + pub torque_scale_without_contact: f32, + /// +164 TorqueBleedOffWithNoContact, +172 StartTorqueScalar. + pub torque_bleed_without_contact: f32, + pub start_torque_scale: f32, + /// +184 ProceduralNoiseScalar, +188 ProceduralNoiseFreq. + pub procedural_noise_scale: f32, + pub procedural_noise_frequency: f32, + /// +200/204/208 PowerScalar_P/I/D; +232/236/240 ManualScalar_P/I/D. + pub powerslide: ManualGains, + pub manual: ManualGains, + /// +216 MaxTiltAngle (degrees); +224 MaxAngleDiff (native radians). + pub maximum_tilt_degrees: f32, + pub maximum_angle_error: f32, + /// +244 D_Scalar_Limit, +248 BrakeTiltAngle (degrees), +252 AnimationNoiseScalar. + pub derivative_limit: f32, + pub brake_tilt_degrees: f32, + pub animation_noise_scale: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ManualMode { + /// physics_mode+32 Hash_84CB2F459F3FC811, compared to abs(local angular Z). + /// Original field name remains unresolved; this name describes its use. + pub correction_angular_speed_threshold: f32, + /// physics_mode+36 Hash_F8CBC0F5FEF2240E, read as a byte. + pub corrective_force_enabled: bool, +} diff --git a/crates/skate-core/src/physics/manual/state.rs b/crates/skate-core/src/physics/manual/state.rs new file mode 100644 index 00000000..c9ab3ebc --- /dev/null +++ b/crates/skate-core/src/physics/manual/state.rs @@ -0,0 +1,60 @@ +//! The five board-owned persistent fields used by CalcManualEffect. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManualState { + /// Board+264: 0.95/0.05 filtered clamped angle error. + pub filtered_angle_error: f32, + /// Board+268: normalized requested angle for this tick. + pub target_angle: f32, + /// Board+272: normalized measured deck angle, retained for the next derivative. + pub measured_angle: f32, + /// Board+276: accumulated controller output, scaled to angular displacement. + pub angular_correction: f32, + /// Board+280: zero also identifies the first active controller tick. + pub elapsed: f32, +} + +impl ManualState { + /// 82C04EEC..4EFC when ProcessedPhysIn+2720 equals either signed zero. + /// This resets only these five controller fields, not heading state+284. + pub fn reset(&mut self) { + self.filtered_angle_error = 0.0; + self.target_angle = 0.0; + self.measured_angle = 0.0; + self.elapsed = 0.0; + self.angular_correction = 0.0; + } + + /// Manual-state slice of Ground Enter 82D376F4..7750. The caller still + /// owns all other entry work, including the returned velocity operation. + /// `previous_category` is ProcessedPhysIn+2516; 100 is the ground category. + pub fn enter_ground( + &mut self, + previous_category: u32, + powerslide_exit_scale: f32, + ) -> ManualEntryContinuation { + if previous_category == 100 { + // +268 target, +272 measurement and +280 timer are preserved. + self.angular_correction *= powerslide_exit_scale; + self.filtered_angle_error *= powerslide_exit_scale; + ManualEntryContinuation::Continue + } else { + // Ground's inline reset82D3770C..771C writes +276 before +280. + self.filtered_angle_error = 0.0; + self.target_angle = 0.0; + self.measured_angle = 0.0; + self.angular_correction = 0.0; + self.elapsed = 0.0; + ManualEntryContinuation::RemoveVelocityIntoGround + } + } +} + +/// Explicit dependency returned by the entry-state slice, never a no-op +/// replacement for Ground's call to RemoveVelIntoGroundForManual 82D37960. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use] +pub enum ManualEntryContinuation { + Continue, + RemoveVelocityIntoGround, +} diff --git a/crates/skate-core/src/physics/mass.rs b/crates/skate-core/src/physics/mass.rs new file mode 100644 index 00000000..5ae5c78a --- /dev/null +++ b/crates/skate-core/src/physics/mass.rs @@ -0,0 +1,354 @@ +//! Recovered board-part mass properties, separate from integration. +use super::{ + native_arithmetic, + rigid_body::{RetailBodyMassProperties, RetailInertiaDynamics, RetailLocalMassFrame}, +}; +use crate::math::{Basis3, Vector3}; + +#[path = "construction/primitive_mass.rs"] +mod primitive_mass; +pub use primitive_mass::{MassShape, PrimitiveMass, primitive_mass}; +#[path = "construction/board_parts.rs"] +mod board_parts; +pub use board_parts::{ + ForwardMassProperties, PartMassInput, TruckMassSettings, WheelMassSettings, + forward_mass_properties, truck_mass_input, wheel_mass_input, +}; + +#[path = "construction/mass_moments.rs"] +mod mass_moments; +#[path = "construction/principal_axes.rs"] +mod principal_axes; +pub use mass_moments::{AggregateMassProperties, MassMoments}; +#[path = "construction/deck_geometry.rs"] +mod deck_geometry; +pub use deck_geometry::{DeckChild, DeckGeometry, DeckGeometrySettings, DeckShape}; + +/// TU3 ComputeMassProperties82AE7770 for one centered primitive. Skeleton +/// parts use this same finalizer as board parts, including volume fallback +/// when the requested mass is strictly below MIN_POSITIVE; NaN is preserved. +pub fn primitive_mass_properties( + input: PartMassInput, + maximum_angular_velocity: f32, + angular_drag: f32, +) -> Option { + let primitive = primitive_mass(input.shape)?; + let mass = if input.requested_mass < f32::MIN_POSITIVE { + primitive.volume + } else { + input.requested_mass + }; + let inertia = primitive.moments_per_unit_mass; + Some(finalize_principal_mass( + RetailLocalMassFrame::IDENTITY, + mass, + Vector3::new(inertia.x * mass, inertia.y * mass, inertia.z * mass), + maximum_angular_velocity, + angular_drag, + )) +} + +/// TU3 ComputeMassProperties82AE7770 after compound-volume accumulation. +/// The caller may apply a source-owned part-frame override after this result. +pub fn aggregate_mass_properties( + mut moments: MassMoments, + requested_mass: f32, + maximum_angular_velocity: f32, + angular_drag: f32, +) -> RetailBodyMassProperties { + let properties = moments.principal_properties(); + // 82AE79EC BO4/BI24 skips fallback when CR6.LT is clear, including NaN. + let mass = if requested_mass < f32::MIN_POSITIVE { + properties.volume + } else { + requested_mass + }; + let inertia = properties.moments_per_unit_mass; + finalize_principal_mass( + inverse_mass_frame(properties.local_mass_frame, inertia), + mass, + Vector3::new(inertia.x * mass, inertia.y * mass, inertia.z * mass), + maximum_angular_velocity, + angular_drag, + ) +} + +/// 82AE78BC..79DC: omit negligible local-frame corrections, otherwise store +/// inverseBodyLTM. The inverse translation starts with Z, then adds Y and X +/// through fused operations, as in the native matrix transpose/inverse path. +fn inverse_mass_frame(forward: RetailLocalMassFrame, inertia: Vector3) -> RetailLocalMassFrame { + let center = forward.translation; + let center_squared = native_arithmetic::dot3( + [center.x, center.y, center.z, 0.0], + [center.x, center.y, center.z, 0.0], + ); + let minimum_offset_squared = + ((inertia.y + inertia.z) + inertia.x) * f32::from_bits(0x3586_37BE); + let translated = center_squared >= minimum_offset_squared; + let rotated = forward + .basis + .columns + .iter() + .enumerate() + .any(|(column, axis)| { + axis.iter().enumerate().any(|(row, value)| { + let identity = if column == row { 1.0 } else { 0.0 }; + (*value - identity).abs() > f32::from_bits(0x3A83_126F) + }) + }); + if !translated && !rotated { + return RetailLocalMassFrame::IDENTITY; + } + let columns = core::array::from_fn::<_, 3, _>(|column| { + core::array::from_fn(|row| forward.basis.columns[row][column]) + }); + let negative_center = [0.0 - center.x, 0.0 - center.y, 0.0 - center.z]; + let translation = core::array::from_fn::<_, 3, _>(|row| { + negative_center[0].mul_add( + columns[0][row], + negative_center[1].mul_add(columns[1][row], negative_center[2] * columns[2][row]), + ) + }); + RetailLocalMassFrame { + basis: Basis3 { columns }, + translation: Vector3::new(translation[0], translation[1], translation[2]), + } +} + +#[cfg(test)] +#[path = "tests/deck_geometry.rs"] +mod deck_geometry_tests; +#[cfg(test)] +#[path = "tests/mass_moments.rs"] +mod mass_moment_tests; + +pub const RETAIL_UNBOUNDED_VELOCITY: f32 = f32::from_bits(0x7F7F_FFFF); +pub const RETAIL_WHEEL_MAXIMUM_ANGULAR_VELOCITY: f32 = f32::from_bits(0x476A_5FFF); + +/// Stock wheel properties produced by TU3 `0x82C0AA78 -> 0x82AE7770`. +pub fn retail_wheel_mass_properties() -> RetailBodyMassProperties { + wheel_mass_properties(WheelMassSettings::STOCK) +} + +pub fn wheel_mass_properties(settings: WheelMassSettings) -> RetailBodyMassProperties { + let forward = forward_mass_properties(wheel_mass_input(settings)) + .expect("the stock wheel is a supported sphere"); + finalize_principal_mass( + RetailLocalMassFrame::IDENTITY, + forward.mass, + forward.principal_moments, + RETAIL_WHEEL_MAXIMUM_ANGULAR_VELOCITY, + 0.0, + ) +} + +/// Stock truck properties produced by TU3 `0x82C0A6F8 -> 0x82AE7770`. +pub fn retail_truck_mass_properties() -> RetailBodyMassProperties { + truck_mass_properties(TruckMassSettings::STOCK) +} + +pub fn truck_mass_properties(settings: TruckMassSettings) -> RetailBodyMassProperties { + let forward = forward_mass_properties(truck_mass_input(settings)) + .expect("the stock truck is a supported capsule"); + finalize_principal_mass( + RetailLocalMassFrame::IDENTITY, + forward.mass, + forward.principal_moments, + RETAIL_UNBOUNDED_VELOCITY, + 0.0, + ) +} + +/// TU3 deck construction82C09290 -> mass accumulation82AE7058 -> +/// ComputeMassProperties82AE7770. Every authored child contributes to mass. +pub fn deck_mass_properties( + geometry: &DeckGeometry, + mass: f32, + angular_drag: f32, +) -> RetailBodyMassProperties { + let mut properties = aggregate_mass_properties( + geometry.mass_moments(), + mass, + RETAIL_UNBOUNDED_VELOCITY, + angular_drag, + ); + // 82C0A264..278 overrides the deck inverse-body local transform after + // calculating its principal inertia. Keep that inertia, reset the frame. + properties.local_mass_frame = RetailLocalMassFrame::IDENTITY; + properties +} + +/// Stock convenience constructor; the game supplies its loaded XML settings. +pub fn retail_deck_mass_properties() -> RetailBodyMassProperties { + let geometry = DeckGeometry::new(DeckGeometrySettings::STOCK); + // physicsdeck.DeckMass * physics_world.SkateboardMassFactor, + // DeckAngularDrag * the refined fixed-step Simulation::frequency. + deck_mass_properties( + &geometry, + 6.0, + f32::from_bits(0x3EE6_6666) * f32::from_bits(0x426F_FFFF), + ) +} + +/// Body order used by `SkateboardBody`: wheels 0..3, trucks 4..5, deck 6. +pub fn default_skateboard_mass_properties() -> [RetailBodyMassProperties; 7] { + [ + retail_wheel_mass_properties(), + retail_wheel_mass_properties(), + retail_wheel_mass_properties(), + retail_wheel_mass_properties(), + retail_truck_mass_properties(), + retail_truck_mass_properties(), + retail_deck_mass_properties(), + ] +} + +/// Final finite path in TU3 `ComputeMassProperties` (`0x82AE7770`). The Xenon +/// reciprocal estimate is followed by two fused Newton refinements for each +/// principal moment. Inverse mass and the spherical energy term use scalar +/// division in the recovered function. +fn finalize_principal_mass( + local_mass_frame: RetailLocalMassFrame, + mass: f32, + principal_moments: Vector3, + maximum_angular_velocity: f32, + angular_drag: f32, +) -> RetailBodyMassProperties { + let inverse_tensor = Vector3::new( + refined_reciprocal(principal_moments.x), + refined_reciprocal(principal_moments.y), + refined_reciprocal(principal_moments.z), + ); + let smallest_inverse = native_arithmetic::vector_min( + native_arithmetic::vector_min(inverse_tensor.x, inverse_tensor.y), + inverse_tensor.z, + ); + RetailBodyMassProperties { + local_mass_frame, + dynamics: RetailInertiaDynamics { + inverse_tensor, + inverse_mass: 1.0 / mass, + spherical: 1.0 / smallest_inverse, + maximum_linear_velocity: RETAIL_UNBOUNDED_VELOCITY, + maximum_angular_velocity, + linear_drag: 0.0, + angular_drag, + }, + } +} + +fn refined_reciprocal(value: f32) -> f32 { + let mut estimate = native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + let residual = (-estimate).mul_add(value, 1.0); + estimate = estimate.mul_add(residual, estimate); + } + estimate +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_mass_finalizers_preserve_nan_and_use_volume_only_below_threshold() { + // A unit cube has volume one. Both original shape paths converge at + // 82AE79E8/79EC: fallback only when CR6.LT is set by requested mass. + let shape = MassShape::RoundedBox { + half_extents: Vector3::new(0.5, 0.5, 0.5), + radius: 0.0, + }; + let moments = MassMoments::from_primitive(primitive_mass(shape).unwrap()); + for (requested_mass, expected_inverse) in [(f32::NAN, f32::NAN), (0.0, 1.0), (2.0, 0.5)] { + let primitive = primitive_mass_properties( + PartMassInput { + shape, + requested_mass, + }, + RETAIL_UNBOUNDED_VELOCITY, + 0.0, + ) + .unwrap(); + let aggregate = + aggregate_mass_properties(moments, requested_mass, RETAIL_UNBOUNDED_VELOCITY, 0.0); + for body in [primitive, aggregate] { + if expected_inverse.is_nan() { + assert!(body.dynamics.inverse_mass.is_nan()); + } else { + assert_eq!(body.dynamics.inverse_mass, expected_inverse); + } + } + } + } + + #[test] + fn stock_simple_parts_reach_recovered_finalizer_words() { + let wheel = retail_wheel_mass_properties().dynamics; + assert_eq!( + [ + wheel.inverse_tensor.x.to_bits(), + wheel.inverse_tensor.y.to_bits(), + wheel.inverse_tensor.z.to_bits(), + wheel.inverse_mass.to_bits(), + wheel.spherical.to_bits(), + ], + [ + 0x45C3_E491, + 0x45C3_E491, + 0x45C3_E491, + 0x401A_3785, + 0x3927_466F, + ] + ); + + let truck = retail_truck_mass_properties().dynamics; + assert_eq!( + [ + truck.inverse_tensor.x.to_bits(), + truck.inverse_tensor.y.to_bits(), + truck.inverse_tensor.z.to_bits(), + truck.inverse_mass.to_bits(), + truck.spherical.to_bits(), + ], + [ + 0x438E_E68E, + 0x438E_E68E, + 0x4690_4D8B, + 0x3F03_4835, + 0x3B65_4E66, + ] + ); + } + + #[test] + fn deck_frame_override_retains_geometry_driven_inertia() { + let stock = DeckGeometry::new(DeckGeometrySettings::STOCK); + let deck = deck_mass_properties(&stock, 6.0, 27.0); + assert_eq!(deck.local_mass_frame, RetailLocalMassFrame::IDENTITY); + assert_eq!(deck.dynamics.inverse_mass, 1.0 / 6.0); + let doubled_mass = deck_mass_properties(&stock, 12.0, 27.0); + for (one, two) in [ + ( + deck.dynamics.inverse_tensor.x, + doubled_mass.dynamics.inverse_tensor.x, + ), + ( + deck.dynamics.inverse_tensor.y, + doubled_mass.dynamics.inverse_tensor.y, + ), + ( + deck.dynamics.inverse_tensor.z, + doubled_mass.dynamics.inverse_tensor.z, + ), + ] { + assert!((one - 2.0 * two).abs() < 1e-5); + } + let mut wider = DeckGeometrySettings::STOCK; + wider.width *= 1.5; + let changed = deck_mass_properties(&DeckGeometry::new(wider), 6.0, 27.0); + assert_ne!( + deck.dynamics.inverse_tensor, + changed.dynamics.inverse_tensor + ); + } +} diff --git a/crates/skate-core/src/physics/mod.rs b/crates/skate-core/src/physics/mod.rs new file mode 100644 index 00000000..13deab92 --- /dev/null +++ b/crates/skate-core/src/physics/mod.rs @@ -0,0 +1,70 @@ +//! Rigid-body kernels and connected-board simulation, separate from collision +//! queries and player behavior. +pub mod assembly; +pub mod board; +pub mod board_animation; +pub mod board_ground; +pub mod board_dynamic_normal; +pub mod board_input_output; +pub mod board_motion_output; +pub mod board_pose; +pub mod board_runtime; +pub mod board_step; +pub mod board_toolkit; +pub mod board_world; +pub mod collision; +pub mod centre_of_mass_filter; +pub mod constraint_batch; +mod constraint_frames; +pub mod contact; +pub mod contact_feedback; +mod contact_layout; +pub mod contact_solver; +pub mod drive_frames; +pub mod drive_parameters; +pub mod drive_preparation; +pub mod drive_solver; +pub mod force_queue; +pub mod foot_physical_output; +pub mod hook_drive; +pub mod joint_builder; +pub mod joint_records; +pub mod joint_solver; +pub mod manual; +pub mod mass; +pub(crate) mod native_arithmetic; +pub mod point_force; +pub(crate) mod reciprocal_sqrt; +pub mod rigid_body; +pub mod solver; +pub mod startup; +mod triangle_closest; +pub mod triangle_query; +mod triangle_sweep; +mod triangle_sweep_roots; +mod triangle_sweep_walk; +pub mod truck_frames; +pub mod world_contact; + +#[cfg(test)] +#[path = "tests/world_dispatch.rs"] +mod world_dispatch_tests; + +pub mod board_probes; +pub mod deck_angular_correction; +pub mod filtered_state; +pub mod ground_hang_geometry; +#[cfg(test)] +#[path = "tests/primitive_query.rs"] +mod primitive_query_tests; +pub mod skeleton_animation_record; +pub mod skeleton_air_frames; +pub mod skeleton_board_frames; +pub mod skeleton_output; +pub mod skeleton_board_offset; +pub mod skeleton_body; +pub mod skeleton_landing; +pub mod skeleton_motion; +pub mod skeleton_root; +pub mod skeleton_general; +pub mod skateboard_controller; diff --git a/crates/skate-core/src/physics/native_arithmetic.rs b/crates/skate-core/src/physics/native_arithmetic.rs new file mode 100644 index 00000000..da9f3288 --- /dev/null +++ b/crates/skate-core/src/physics/native_arithmetic.rs @@ -0,0 +1,33 @@ +//! Independent host math for the operations used by recovered TU3 callers. +//! +//! Uses Rust floating-point operations without external estimate tables or +//! translator-specific special cases. Callers retain their recovered refinement +//! steps. This is working PC math, not bit-exact Xenon instruction emulation. + +pub(crate) fn reciprocal_estimate(value: f32) -> f32 { + value.recip() +} + +pub(crate) fn reciprocal_square_root_estimate(value: f32) -> f32 { + value.sqrt().recip() +} + +pub(crate) fn dot3(left: [f32; 4], right: [f32; 4]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + +pub(crate) fn dot4(left: [f32; 4], right: [f32; 4]) -> f32 { + dot3(left, right) + left[3] * right[3] +} + +pub(crate) fn vector_min(left: f32, right: f32) -> f32 { + left.min(right) +} + +pub(crate) fn vector_max(left: f32, right: f32) -> f32 { + left.max(right) +} + +#[cfg(test)] +#[path = "tests/native_arithmetic.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/point_force.rs b/crates/skate-core/src/physics/point_force.rs new file mode 100644 index 00000000..b52a7c33 --- /dev/null +++ b/crates/skate-core/src/physics/point_force.rs @@ -0,0 +1,51 @@ +//! TU3 queued point-force application, 0x82C037DC..0x82C03908. +use crate::math::{Basis3, Vector3}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailForceAccumulator { + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub cool_down: u32, +} + +/// Apply one queued force using the native multiply / fused-add order. +/// `application_point_body` already includes the collection's force-point Y +/// offset (added by the queue consumer at 0x82C037EC). +pub fn accumulate_point_force( + mut accumulator: RetailForceAccumulator, + force_world: Vector3, + application_point_body: Vector3, + deck_basis: Basis3, + inverse_mass: f32, + world_inverse_inertia: Basis3, +) -> RetailForceAccumulator { + // 0x82C03850: vmulfp; 0x82C03878..0x82C03894: scalar adds. + accumulator.force_acceleration.x += force_world.x * inverse_mass; + accumulator.force_acceleration.y += force_world.y * inverse_mass; + accumulator.force_acceleration.z += force_world.z * inverse_mass; + let arm = multiply_basis(deck_basis, application_point_body); + // 0x82C0387C / 0x82C038B0 / 0x82C038B4: multiply, fused negative + // multiply-add, permutation. Fusing the positive term instead differs. + let torque = Vector3::new( + (-arm.z).mul_add(force_world.y, arm.y * force_world.z), + (-arm.x).mul_add(force_world.z, arm.z * force_world.x), + (-arm.y).mul_add(force_world.x, arm.x * force_world.y), + ); + let angular = multiply_basis(world_inverse_inertia, torque); + accumulator.torque_acceleration.x += angular.x; + accumulator.torque_acceleration.y += angular.y; + accumulator.torque_acceleration.z += angular.z; + accumulator.cool_down = 0; + accumulator +} + +fn multiply_basis(basis: Basis3, v: Vector3) -> Vector3 { + // Native multiplies column 0, then fuses column 1, then column 2. + let lane = |i: usize| { + basis.columns[2][i].mul_add( + v.z, + basis.columns[1][i].mul_add(v.y, basis.columns[0][i] * v.x), + ) + }; + Vector3::new(lane(0), lane(1), lane(2)) +} diff --git a/crates/skate-core/src/physics/reciprocal_sqrt.rs b/crates/skate-core/src/physics/reciprocal_sqrt.rs new file mode 100644 index 00000000..0616380a --- /dev/null +++ b/crates/skate-core/src/physics/reciprocal_sqrt.rs @@ -0,0 +1,5 @@ +//! Compatibility entry point for recovered callers that use `vrsqrtefp`. + +pub(crate) fn estimate(value: f32) -> f32 { + super::native_arithmetic::reciprocal_square_root_estimate(value) +} diff --git a/crates/skate-core/src/physics/rigid_body.rs b/crates/skate-core/src/physics/rigid_body.rs new file mode 100644 index 00000000..0be86006 --- /dev/null +++ b/crates/skate-core/src/physics/rigid_body.rs @@ -0,0 +1,431 @@ +//! TU3 RenderWare rigid-body accumulation and integration primitives. +//! +//! These routines are ports of the arithmetic in the Skate 3 TU3 executable, +//! not a wrapper around Bevy or a generic rigid-body engine. The caller supplies +//! solver corrections; this module does not own collision or solver scheduling. + +pub use super::point_force::{RetailForceAccumulator, accumulate_point_force}; +use crate::math::{Basis3, Vector3}; +mod dynamic_update; +pub use dynamic_update::{DynamicUpdateResult, dynamic_update_packed}; + +pub mod tu3 { + /// Per-active-body integration routine called by + /// `rw::physics::Simulation::BatchIntegrator`. + pub const BATCH_INTEGRATOR_BODY: u32 = 0x82AE_6590; + /// `Sk8::Physics::Skateboard::ApplyQueuedSkateboardForces`. + pub const APPLY_QUEUED_SKATEBOARD_FORCES: u32 = 0x82C0_3718; +} + +/// Exact scalar fields read from TU3's `rw::physics::Simulation` by +/// `0x82AE6590`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailSimulationStep { + pub time_step: f32, + pub frequency: f32, + pub cool_down: u32, + pub minimum_energy: f32, + pub gravity_acceleration: Vector3, +} + +impl RetailSimulationStep { + pub fn fixed_60_hz(cool_down: u32, minimum_energy: f32, gravity_acceleration: Vector3) -> Self { + let time_step = f32::from_bits(0x3C88_8889); + Self { + time_step, + frequency: time_step.recip(), + cool_down, + minimum_energy, + gravity_acceleration, + } + } +} + +/// Exact scalar layout of `rw::physics::Inertia` after its inverse-tensor +/// vector. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailInertiaDynamics { + pub inverse_tensor: Vector3, + pub inverse_mass: f32, + pub spherical: f32, + pub maximum_linear_velocity: f32, + pub maximum_angular_velocity: f32, + pub linear_drag: f32, + pub angular_drag: f32, +} + +/// Inverse local principal-axis frame stored by TU3 `ComputeMassProperties` +/// (`0x82AE7770`) at `PartDefinition + 64` (`inverseBodyLTM`). +/// +/// The stock wheel and truck retain identity. Deck construction `0x82C09290` +/// also explicitly resets this frame to identity after computing aggregate +/// principal inertia; that override does not discard the calculated inertia. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailLocalMassFrame { + /// Column vectors in RenderWare `Ri`, `Up`, `At` order. + pub basis: Basis3, + pub translation: Vector3, +} + +impl RetailLocalMassFrame { + pub const IDENTITY: Self = Self { + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + translation: Vector3::ZERO, + }; +} + +/// Complete per-part mass data needed by the TU3 rigid-body integrator. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailBodyMassProperties { + pub local_mass_frame: RetailLocalMassFrame, + pub dynamics: RetailInertiaDynamics, +} + +/// The four 16-byte correction vectors indexed by `RigidBody::mId`. +/// +/// The first and third vectors participate in the velocity-producing frame +/// displacement. The second and fourth vectors correct position and +/// orientation only. This separation is directly visible in `0x82AE6590`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RetailReactionCorrections { + pub linear_displacement: Vector3, + pub position_displacement: Vector3, + pub angular_displacement: Vector3, + pub orientation_displacement: Vector3, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailBodyRates { + pub orientation: RetailQuaternion, + pub basis: Basis3, + pub world_inverse_inertia: Basis3, + pub position: Vector3, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, + /// Acceleration accumulator. `RigidBody::AddForce` has already multiplied + /// physical force by inverse mass before this field reaches the integrator. + pub force_acceleration: Vector3, + /// Angular-acceleration accumulator. Point-force torque has already passed + /// through the body's world inverse inertia. + pub torque_acceleration: Vector3, + pub kinetic_energy: f32, + pub cool_down: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailBodyRateStep { + pub state: RetailBodyRates, + /// Rotation increment consumed by the quaternion branch in the same retail + /// function. Keeping this explicit prevents a presentation system from + /// replacing it with a surface-normal snap. + pub orientation_displacement: Vector3, + pub linear_speed_squared: f32, + pub angular_speed_squared: f32, +} + +/// Skate-era RenderWare's six-value packed symmetric world inverse-inertia +/// tensor. +/// +/// The lane mapping is: +/// +/// - `mIfull = (Ixx, Ixy, Ixz)`; +/// - `mIsplt = (Izz, Iyy, Iyz)`. +/// +/// This retained lane mapping requires independent TU3 instruction review. +/// Earlier generated-code validation has been withdrawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailPackedWorldInverseInertia { + pub full: Vector3, + pub split: Vector3, +} + +/// RenderWare quaternion lane order is `(x, y, z, w)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailQuaternion { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} + +impl RetailQuaternion { + pub const IDENTITY: Self = Self { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + }; +} + +/// Typed adapter to the complete TU3 `RigidBody::DynamicUpdate` (`0x82AE6590`). +/// The packed entry point additionally preserves guest metadata and clears its +/// mutable reaction record. This value-taking adapter returns the updated body. +/// +/// The order matters: +/// +/// 1. acceleration is integrated to a candidate velocity; +/// 2. candidate velocity is converted to a frame displacement; +/// 3. solver correction displacements are added; +/// 4. position is advanced; +/// 5. velocity is reconstructed from displacement with retail drag; +/// 6. linear and angular speed caps are applied; +/// 7. energy/cool-down is updated; +/// 8. force/torque accumulators are reset for the next step. +/// +/// `BoardStep` supplies all four correction vectors after the shared contact, +/// joint and drive solve. Player-state scheduling remains a separate owner. +pub fn integrate_body_rates( + mut body: RetailBodyRates, + inertia: RetailInertiaDynamics, + simulation: RetailSimulationStep, + reactions: RetailReactionCorrections, +) -> RetailBodyRateStep { + let mut words = [0_u32; 44]; + words[..4].copy_from_slice(&[ + body.orientation.x.to_bits(), + body.orientation.y.to_bits(), + body.orientation.z.to_bits(), + body.orientation.w.to_bits(), + ]); + for (offset, value) in [ + (4, body.position), + (8, body.linear_velocity), + (12, body.angular_velocity), + (36, body.force_acceleration), + (40, body.torque_acceleration), + ] { + words[offset..offset + 3].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + ]); + } + words[31] = inertia.inverse_mass.to_bits(); + words[39] = body.kinetic_energy.to_bits(); + words[43] = body.cool_down; + let native_inertia = [ + inertia.inverse_tensor.x, + inertia.inverse_tensor.y, + inertia.inverse_tensor.z, + 0.0, + inertia.inverse_mass, + inertia.spherical, + inertia.maximum_linear_velocity, + inertia.maximum_angular_velocity, + inertia.linear_drag, + inertia.angular_drag, + ] + .map(f32::to_bits); + let mut correction_words = [0; 16]; + for (index, value) in [ + reactions.linear_displacement, + reactions.position_displacement, + reactions.angular_displacement, + reactions.orientation_displacement, + ] + .into_iter() + .enumerate() + { + correction_words[index * 4..index * 4 + 3].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + ]); + } + let result = dynamic_update_packed( + &mut words, + &native_inertia, + simulation, + &mut correction_words, + ); + let vector = |offset| { + Vector3::new( + f32::from_bits(words[offset]), + f32::from_bits(words[offset + 1]), + f32::from_bits(words[offset + 2]), + ) + }; + body.orientation = RetailQuaternion { + x: f32::from_bits(words[0]), + y: f32::from_bits(words[1]), + z: f32::from_bits(words[2]), + w: f32::from_bits(words[3]), + }; + body.position = vector(4); + body.linear_velocity = vector(8); + body.angular_velocity = vector(12); + body.force_acceleration = vector(36); + body.torque_acceleration = vector(40); + body.kinetic_energy = f32::from_bits(words[39]); + body.cool_down = words[43]; + body.basis = Basis3 { + columns: core::array::from_fn(|i| { + core::array::from_fn(|j| f32::from_bits(words[16 + i * 4 + j])) + }), + }; + body.world_inverse_inertia = unpack_world_inverse_inertia(vector(28), vector(32)); + RetailBodyRateStep { + state: body, + orientation_displacement: Vector3::new( + result.orientation_displacement[0], + result.orientation_displacement[1], + result.orientation_displacement[2], + ), + linear_speed_squared: result.linear_speed_squared, + angular_speed_squared: result.angular_speed_squared, + } +} + +/// Quaternion branch from TU3 `0x82AE66A8..0x82AE67B8`. +/// +/// Mapping the VMX128 word permutations back to RenderWare's public +/// `(x,y,z,w)` lane order gives: +/// +/// `q += 0.5 * Quaternion(angular_displacement, 0) * q` +/// +/// followed by normalization. This pre-multiplication order is important: +/// swapping it changes the board's world-space angular response. +pub fn integrate_orientation( + orientation: RetailQuaternion, + angular_displacement: Vector3, +) -> RetailQuaternion { + let q = dynamic_update::orientation( + [orientation.x, orientation.y, orientation.z, orientation.w], + [ + angular_displacement.x, + angular_displacement.y, + angular_displacement.z, + 0.0, + ], + ); + RetailQuaternion { + x: q[0], + y: q[1], + z: q[2], + w: q[3], + } +} + +/// `Ri`, `Up`, and `At` columns rebuilt immediately after TU3 normalizes the +/// quaternion in `0x82AE6590`. +pub fn basis_from_quaternion(q: RetailQuaternion) -> Basis3 { + let columns = dynamic_update::quaternion_basis([q.x, q.y, q.z, q.w]); + Basis3 { + columns: columns.map(|column| [column[0], column[1], column[2]]), + } +} + +/// Native packed inverse-inertia rebuild in 82AE6590, with its fused order. +pub fn world_inverse_inertia(basis: Basis3, inverse_tensor: Vector3) -> Basis3 { + let native_basis = basis.columns.map(|v| [v[0], v[1], v[2], 0.0]); + let tensor = [inverse_tensor.x, inverse_tensor.y, inverse_tensor.z].map(f32::to_bits); + let (full, split) = dynamic_update::inverse_inertia(native_basis, tensor); + unpack_world_inverse_inertia( + Vector3::new(full[0], full[1], full[2]), + Vector3::new(split[0], split[1], split[2]), + ) +} + +fn unpack_world_inverse_inertia(full: Vector3, split: Vector3) -> Basis3 { + Basis3 { + columns: [ + [full.x, full.y, full.z], + [full.y, split.y, split.z], + [full.z, split.z, split.x], + ], + } +} + +/// Packs a symmetric world inverse-inertia tensor in RenderWare's `mIfull` / +/// `mIsplt` lane order. +pub fn pack_world_inverse_inertia(tensor: Basis3) -> RetailPackedWorldInverseInertia { + RetailPackedWorldInverseInertia { + full: Vector3::new( + tensor.columns[0][0], + tensor.columns[1][0], + tensor.columns[2][0], + ), + split: Vector3::new( + tensor.columns[2][2], + tensor.columns[1][1], + tensor.columns[2][1], + ), + } +} + +/// Multiplies a vector by the exact symmetric tensor represented by retail +/// `mIfull` and `mIsplt`. +pub fn multiply_packed_world_inverse_inertia( + tensor: RetailPackedWorldInverseInertia, + vector: Vector3, +) -> Vector3 { + Vector3::new( + tensor.full.x * vector.x + tensor.full.y * vector.y + tensor.full.z * vector.z, + tensor.full.y * vector.x + tensor.split.y * vector.y + tensor.split.z * vector.z, + tensor.full.z * vector.x + tensor.split.z * vector.y + tensor.split.x * vector.z, + ) +} + +#[cfg(test)] +fn multiply_basis(basis: Basis3, vector: Vector3) -> Vector3 { + Vector3::new( + basis.columns[0][0] * vector.x + + basis.columns[1][0] * vector.y + + basis.columns[2][0] * vector.z, + basis.columns[0][1] * vector.x + + basis.columns[1][1] * vector.y + + basis.columns[2][1] * vector.z, + basis.columns[0][2] * vector.x + + basis.columns[1][2] * vector.y + + basis.columns[2][2] * vector.z, + ) +} + +#[cfg(test)] +#[path = "tests/rigid_body.rs"] +mod tests; + +// Existing equation-level unit checks retain their independent scalar helpers. +#[cfg(test)] +fn column(basis: Basis3, index: usize) -> Vector3 { + let c = basis.columns[index]; + Vector3::new(c[0], c[1], c[2]) +} +#[cfg(test)] +fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +#[cfg(test)] +fn scale(v: Vector3, s: f32) -> Vector3 { + Vector3::new(v.x * s, v.y * s, v.z * s) +} +#[cfg(test)] +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + ) +} +#[cfg(test)] +fn dot(a: Vector3, b: Vector3) -> f32 { + a.x * b.x + a.y * b.y + a.z * b.z +} +#[cfg(test)] +fn length_squared(v: Vector3) -> f32 { + dot(v, v) +} +#[cfg(test)] +fn normalize_quaternion(q: RetailQuaternion) -> RetailQuaternion { + let inverse = (q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w) + .sqrt() + .recip(); + RetailQuaternion { + x: q.x * inverse, + y: q.y * inverse, + z: q.z * inverse, + w: q.w * inverse, + } +} diff --git a/crates/skate-core/src/physics/rigid_body/dynamic_update.rs b/crates/skate-core/src/physics/rigid_body/dynamic_update.rs new file mode 100644 index 00000000..ea4a23ef --- /dev/null +++ b/crates/skate-core/src/physics/rigid_body/dynamic_update.rs @@ -0,0 +1,192 @@ +//! RigidBody::DynamicUpdate82AE6590 reconstruction candidate. +//! Guest pointers are opaque metadata; the caller supplies their referenced data. +use super::RetailSimulationStep; +type V = [f32; 4]; + +#[derive(Clone, Copy, Debug)] +pub struct DynamicUpdateResult { + pub orientation_displacement: [f32; 3], + /// Native cap-selected squared speeds used by the sleep metric. + pub linear_speed_squared: f32, + pub angular_speed_squared: f32, +} + +/// Update every native body field and clear all 64 reaction bytes. `body` is the +/// 176-byte RigidBody prefix; `inertia` is its 40-byte Inertia record. Metadata +/// lanes, including pointers and IDs, are preserved. No activation is performed. +pub fn dynamic_update_packed( + body: &mut [u32; 44], + inertia: &[u32; 10], + simulation: RetailSimulationStep, + reactions: &mut [u32; 16], +) -> DynamicUpdateResult { + let dt = [simulation.time_step; 4]; + let linear = madd( + madd(load(body, 36), dt, load(body, 8)), + dt, + load(reactions, 0), + ); + let angular = madd( + madd(load(body, 40), dt, load(body, 12)), + dt, + load(reactions, 8), + ); + let position = add(add(load(body, 4), load(reactions, 4)), linear); + store_xyz(body, 4, position); + let rotation = add(angular, load(reactions, 12)); + let q = orientation(load(body, 0), rotation); + body[..4].copy_from_slice(&q.map(f32::to_bits)); + let basis = quaternion_basis(q); + for (offset, column) in [16, 20, 24].into_iter().zip(basis) { + store_xyz(body, offset, column); + } + let tensor = inertia[..3].try_into().unwrap(); + let (full, split) = inverse_inertia(basis, tensor); + store_xyz(body, 28, full); + store_xyz(body, 32, split); + let rate = |drag: u32| { + let difference = simulation.frequency - f32::from_bits(drag); + if difference > 0.0 { difference } else { 0.0 } + }; + let (omega, angular_squared) = cap( + mul(angular, [rate(inertia[9]); 4]), + f32::from_bits(inertia[7]), + ); + let (velocity, linear_squared) = cap( + mul(linear, [rate(inertia[8]); 4]), + f32::from_bits(inertia[6]), + ); + // Scalar fmuls followed by fmadds; cap branches publish the squared cap, + // rather than recalculating squared speed from the rounded scaled vector. + let factor = f32::from_bits(inertia[5]) * f32::from_bits(body[31]); + let energy = factor.mul_add(angular_squared, linear_squared); + body[43] = if !(energy < simulation.minimum_energy) { + 0 + } else { + let cooldown = if !(energy > f32::from_bits(body[39])) { + body[43].wrapping_add(1) + } else { + body[43] + }; + cooldown.min(simulation.cool_down) + }; + body[39] = energy.to_bits(); + store_xyz(body, 8, velocity); + store_xyz(body, 12, omega); + reactions.fill(0); + let gravity = simulation.gravity_acceleration; + store_xyz(body, 36, [gravity.x, gravity.y, gravity.z, 0.0]); + store_xyz(body, 40, [0.0; 4]); + DynamicUpdateResult { + orientation_displacement: rotation[..3].try_into().unwrap(), + linear_speed_squared: linear_squared, + angular_speed_squared: angular_squared, + } +} + +pub(super) fn orientation(q: V, rotation: V) -> V { + // 82AE6680..67B8: preserve the native cross-product and normalization order. + let cross = perm( + nmsub( + perm(rotation, [1, 2, 0, 3]), + q, + mul(rotation, perm(q, [1, 2, 0, 3])), + ), + [1, 2, 0, 3], + ); + let mut increment = mul(madd(rotation, [q[3]; 4], cross), [0.5; 4]); + increment[3] = dot(rotation, q, false) * -0.5; + let candidate = add(q, increment); + mul( + candidate, + [refined_rsqrt(dot(candidate, candidate, true)); 4], + ) +} + +pub(super) fn quaternion_basis(q: V) -> [V; 3] { + // 82139A00 contains sqrt(2) rounded to binary32. Replacing these products + // with 2*q_i*q_j changes the native basis words. + let s = mul(q, [f32::from_bits(0x3FB5_04F3); 4]); + let d = nmsub(s, s, [0.5; 4]); + let diagonal = add(d, perm(d, [1, 2, 0, 0])); + let products = mul(s, perm(s, [1, 2, 0, 0])); + let w_products = mul([s[3]; 4], perm(s, [2, 0, 1, 0])); + let plus = add(products, w_products); + let minus = sub(products, w_products); + [ + [diagonal[1], plus[0], minus[2], 0.0], + [minus[0], diagonal[2], plus[1], 0.0], + [plus[2], minus[1], diagonal[0], 0.0], + ] +} + +pub(super) fn inverse_inertia(basis: [V; 3], tensor: [u32; 3]) -> (V, V) { + let [ri, up, at] = basis; + let r = mul(ri, [f32::from_bits(tensor[0]); 4]); + let u = mul(up, [f32::from_bits(tensor[1]); 4]); + let a = mul(at, [f32::from_bits(tensor[2]); 4]); + let full = madd(a, [at[0]; 4], madd(r, [ri[0]; 4], mul(u, [up[0]; 4]))); + let p = |v| perm(v, [2, 1, 1, 3]); + let s = |v| perm(v, [2, 1, 2, 3]); + let split = madd(p(at), s(a), madd(p(ri), s(r), mul(p(up), s(u)))); + (full, split) +} + +fn cap(value: V, maximum: f32) -> (V, f32) { + let squared = dot(value, value, false); + let maximum_squared = maximum * maximum; + if squared > maximum_squared { + let ratio = maximum_squared / squared; + let root = ratio * refined_rsqrt(ratio); + let scale = if ratio == 0.0 { 0.0 } else { root }; + (mul(value, [scale; 4]), maximum_squared) + } else { + (value, squared) + } +} + +fn refined_rsqrt(value: f32) -> f32 { + let mut estimate = super::super::reciprocal_sqrt::estimate(value); + for _ in 0..2 { + let square = estimate * estimate; + let half_estimate = estimate * 0.5; + let residual = (-value).mul_add(square, 1.0); + estimate = half_estimate.mul_add(residual, estimate); + } + estimate +} + +fn dot(a: V, b: V, four: bool) -> f32 { + if four { + crate::physics::native_arithmetic::dot4(a, b) + } else { + crate::physics::native_arithmetic::dot3(a, b) + } +} + +fn load(words: &[u32], offset: usize) -> V { + core::array::from_fn(|i| f32::from_bits(words[offset + i])) +} +fn store_xyz(words: &mut [u32], offset: usize, value: V) { + for i in 0..3 { + words[offset + i] = value[i].to_bits(); + } +} +fn perm(value: V, indices: [usize; 4]) -> V { + indices.map(|i| value[i]) +} +fn mul(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] * b[i]) +} +fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +fn madd(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| a[i].mul_add(b[i], c[i])) +} +fn nmsub(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| (-a[i]).mul_add(b[i], c[i])) +} diff --git a/crates/skate-core/src/physics/skateboard_controller.rs b/crates/skate-core/src/physics/skateboard_controller.rs new file mode 100644 index 00000000..a76d3433 --- /dev/null +++ b/crates/skate-core/src/physics/skateboard_controller.rs @@ -0,0 +1,47 @@ +//! Original SkateboardController's lifecycle, TU3 82D74DD8/82D75EA0. +//! This is the hand-held/retrieval controller. Normal riding stops it. +use crate::player::lifecycle::{SkateboardControllerActions, SkateboardControllerFields}; + +pub struct SkateboardController { + /// The same mutable fields consumed by PhysicalPlayer state changes. + pub fields: SkateboardControllerFields, +} +impl Default for SkateboardController { + fn default() -> Self { + Self::new() + } +} +impl SkateboardController { + pub const fn new() -> Self { + //82D74EFC/4F00/4F04, after the retrieval-progress initializer. + Self { + fields: SkateboardControllerFields { + word_444: 0, + state_448: 0, + system_on_452: false, + }, + } + } + + ///82D75EA0. PhysicalPlayer invokes this for requested states outside + ///500..502. The active-held release remains the real LetGoOfSkateboard + ///action; do not replace it with a transform reset or an empty callback. + pub fn stop(&mut self, actions: &mut impl SkateboardControllerActions) { + if !self.fields.system_on_452 { + return; + } + let previous = self.fields.state_448; + self.fields.word_444 = 0; + if previous != 0 { + actions.let_go_of_skateboard(); + self.fields.state_448 = 0; + } + self.fields.system_on_452 = false; + } + + ///The player passes this to Skeleton::UpdatePostPhysics. Native868 + ///is the count of seven board parts in contact, not wheel count869. + pub fn request_partial_ragdoll(&self, part_contact_count: u8) -> bool { + self.fields.state_448 == 1 && part_contact_count != 0 + } +} diff --git a/crates/skate-core/src/physics/skeleton_air_frames.rs b/crates/skate-core/src/physics/skeleton_air_frames.rs new file mode 100644 index 00000000..a422dac1 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_air_frames.rs @@ -0,0 +1,105 @@ +//! Original TU3 animated-board and COM-controlled air frame calculations. +//! 82BDDA10, 82BDE600 and 82BDE7B8. Persistent frames remain in their owners. +use super::{ + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY, compose_affine}, + skeleton_board_frames::SkeletonBoardFrames, + skeleton_root::{SkeletonRootFrames, inverse_rigid, orthonormalize}, +}; +use crate::trigonometry; + +/// 82B985E8 publishes the one-frame request and retained frame count; +/// 82BDE920 loads that count with lwz before unsigned-to-floating conversion. +#[derive(Clone, Copy, Debug)] +pub struct AirDismountRevert { + pub requested: bool, + pub frames: u32, + pub goofy: bool, +} + +/// 82BDDA5C..DBBC, after the common UpdateRootTransforms82BE0318. +/// The skate root uses the unblended animation target in this path. +pub fn prepare_animated( + roots: &SkeletonRootFrames, + board: &mut SkeletonBoardFrames, + mapped_board: &Transform, + flags: &mut u32, +) -> Transform { + let local = compose_affine(&roots.animation_to_board, mapped_board); + let mut placement = IDENTITY; + placement[3] = roots.predicted_board_position; + let target = compose_affine(&placement, &local); + *flags |= 1 << 19; + board.animation_target = target; + board.skate_root = target; + board.update_com_lift(&roots.animation_to_world, board.centre_of_mass, 0.0); + target +} + +/// 82BDE7B8: preserve prior heading on entry, apply an authored dismount +/// revert, then place the current animation COM at the integrated air target. +/// It does not update animation_to_board/inverse_board or consume prediction. +pub fn update_known_air_roots( + roots: &mut SkeletonRootFrames, + reckoning: &Transform, + target_com: [f32; 4], + animation_com: [f32; 4], + revert: AirDismountRevert, +) { + if roots.initialize_heading { + roots.heading_alignment = + compose_affine(&inverse_rigid(reckoning), &roots.animation_to_world); + //82BDE8EC overwrites the entire affine translation with zero. + roots.heading_alignment[3] = [0.0; 4]; + roots.initialize_heading = false; + } + if revert.requested { + let mut angle = f32::from_bits(0x4049_0fdb) / revert.frames as f32; + if !revert.goofy { + angle = -angle; + } + let (sin, cos) = trigonometry::sin_cos(angle); + let rotation = [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [0.0; 4], + ]; + roots.heading_alignment = + orthonormalize(compose_affine(&roots.heading_alignment, &rotation)); + } + let mut world = compose_affine(reckoning, &roots.heading_alignment); + //82BDEB98..BB8: rotate COM without adding the prior translation. + world[3] = std::array::from_fn(|lane| { + let x = world[0][lane] * animation_com[0]; + let y = world[1][lane].mul_add(animation_com[1], x); + target_com[lane] - world[2][lane].mul_add(animation_com[2], y) + }); + roots.animation_to_world = orthonormalize(world); + roots.world_to_animation = inverse_rigid(&roots.animation_to_world); +} + +///82BDE634..6DC. ApplyBoardAnimation follows before the completion below. +pub fn prepare_known_air( + roots: &SkeletonRootFrames, + board: &mut SkeletonBoardFrames, + mapped_board: &Transform, + flags: &mut u32, +) -> Transform { + let target = compose_affine(&roots.animation_to_world, mapped_board); + *flags |= 1 << 19; + board.animation_target = target; + target +} + +///82BDE748..7A4, after applying the target and the board velocity update. +pub fn finish_known_air( + roots: &mut SkeletonRootFrames, + board: &mut SkeletonBoardFrames, + effective_board: Transform, +) { + board.physical_board = effective_board; + board.skate_root = effective_board; + board.update_com_lift(&roots.animation_to_world, board.centre_of_mass, 0.0); + roots.predicted_board_position = effective_board[3]; + roots.supplied_prediction = Some(effective_board[3]); +} diff --git a/crates/skate-core/src/physics/skeleton_animation_record.rs b/crates/skate-core/src/physics/skeleton_animation_record.rs new file mode 100644 index 00000000..b1b6d9b3 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_animation_record.rs @@ -0,0 +1,205 @@ +//! Animation-record COM producer82BEBF88 and mass normalization82BEBAA8. +//! Skeleton embeds this source state at6464, so com_to_deck_world4544 is the +//! ProcessData82BD8918 input published from Skeleton11008 to Processed752. +use super::rigid_body::{RetailQuaternion, basis_from_quaternion}; +use crate::math::Vector3; + +/// Native affine columns X,Y,Z,translation. Keep all four stored lanes; +/// UpdateAnimRecord copies24 complete transforms, not just the positions. +pub type AnimationPartTransform = [[f32; 4]; 4]; +pub const IDENTITY: AnimationPartTransform = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0], +]; + +/// Forward PhysicsParamBoneData frame from SkeletonData::Init82BD6E24..6E98. +/// The same sqrt(2), fused negative product and lane permutations are used by +/// RigidBody::DynamicUpdate. Keep the otherwise unused fourth rotation lanes: +/// native permute masks822FB8A0/B0/C0 duplicate Z.z, Y.x and X.y respectively. +/// This is the forward frame, not the inverse stored by the later init slice. +pub fn physics_bone_frame(quaternion: [f32; 4], translation: [f32; 4]) -> AnimationPartTransform { + let [x, y, z, w] = quaternion; + let [ri, up, at] = basis_from_quaternion(RetailQuaternion { x, y, z, w }).columns; + [ + [ri[0], ri[1], ri[2], at[2]], + [up[0], up[1], up[2], up[0]], + [at[0], at[1], at[2], ri[1]], + translation, + ] +} + +/// ProcessData82BD8BB4..8CAC: gather each named global animation bone, then +/// right-multiply its forward physics frame. Native maps names to hierarchy +/// indices in82BD6CB4..6CE4; the host loader provides those verified indices. +/// The result is the input to the following skeleton IK and adjustment stages, +/// which must run before UpdateAnimRecord for the final physical pose. +pub fn map_animation_parts( + global_bones: &[AnimationPartTransform], + bone_indices: &[usize; 24], + physics_frames: &[AnimationPartTransform; 24], +) -> Result<[AnimationPartTransform; 24], &'static str> { + if bone_indices.iter().any(|i| *i >= global_bones.len()) { + return Err("physics skeleton bone index is outside the animation hierarchy"); + } + Ok(std::array::from_fn(|part| { + let animation = &global_bones[bone_indices[part]]; + let physics = &physics_frames[part]; + std::array::from_fn(|column| { + if column == 3 { + transform_point(animation, physics[3]) + } else { + std::array::from_fn(|lane| { + let x = physics[column][0] * animation[0][lane]; + let y = physics[column][1].mul_add(animation[1][lane], x); + physics[column][2].mul_add(animation[2][lane], y) + }) + } + }) + })) +} + +#[derive(Clone, Debug)] +pub struct SkeletonAnimationMasses { + /// Native misleadingly calls these part masses. They are bone-box volumes + /// before density and animated/ragdoll physical mass scaling. + pub part_weights: [f32; 24], + pub total: f32, + pub fractional: [f32; 24], +} +impl SkeletonAnimationMasses { + /// Full weight assignment slice82BE4900/82BE5428. Board part0 is weight0; + /// other parts use original PhysicsParamBoneData.size32, not collider size. + /// Shapes0/1/2 all use the same box product. Native unrecognized shapes>=3 + /// retain the iteration's initialized0. A hat on head1 takes its own branch + /// and still uses the original head box product, excluding the hat volume. + pub fn from_bone_data( + sizes: [Vector3; 24], + collision_shapes: [u32; 24], + head_has_hat: bool, + ) -> Self { + let weights = std::array::from_fn(|i| { + if i == 0 || collision_shapes[i] >= 3 && !(i == 1 && head_has_hat) { + 0.0 + } else { + (sizes[i].x * sizes[i].y) * sizes[i].z + } + }); + Self::normalize(weights) + } + + ///82BEBAA8: ordered24 scalar additions, one scalar single-precision divide, + /// then24 multiplies. No clamping, epsilon, density or re-normalization. + pub fn normalize(part_weights: [f32; 24]) -> Self { + let mut total = 0.0f32; + for (i, weight) in part_weights.iter().enumerate() { + // Preserve native operand order, including signed zero/NaN cases. + total = if i % 8 == 0 || i % 8 == 7 { + *weight + total + } else { + total + *weight + }; + } + let reciprocal = 1.0f32 / total; + Self { + part_weights, + total, + fractional: part_weights.map(|v| v * reciprocal), + } + } +} + +#[derive(Clone, Debug)] +pub struct SkeletonAnimationRecord { + pub pose: [AnimationPartTransform; 24], + pub centre_of_mass: [f32; 4], + pub centre_of_mass_delta: [f32; 4], + pub com_to_deck_world: [f32; 4], + pub com_to_deck_world_delta: [f32; 4], + pub reset_scalar: f32, +} +impl Default for SkeletonAnimationRecord { + fn default() -> Self { + // Constructor82BEB8C8 initializes24 animation transforms via82BF30C0, + // then all four vectors and resetScalar5224 to0. + Self { + pose: [IDENTITY; 24], + centre_of_mass: [0.0; 4], + centre_of_mass_delta: [0.0; 4], + com_to_deck_world: [0.0; 4], + com_to_deck_world_delta: [0.0; 4], + reset_scalar: 0.0, + } + } +} +impl SkeletonAnimationRecord { + /// Animation fields of SkeletonState::Reset82BEBBB0. The function leaves + /// animation transforms, fractional masses and resetScalar unchanged. + pub fn reset_history(&mut self) { + self.centre_of_mass = [0.0; 4]; + self.centre_of_mass_delta = [0.0; 4]; + self.com_to_deck_world = [0.0; 4]; + self.com_to_deck_world_delta = [0.0; 4]; + } + + pub fn update( + &mut self, + pose: &[AnimationPartTransform; 24], + animation_to_board: &AnimationPartTransform, + masses: &SkeletonAnimationMasses, + ) { + self.pose = *pose; + let mut com = [0.0f32; 4]; + for (part, weight) in self.pose.iter().zip(masses.fractional) { + for (lane, value) in com.iter_mut().enumerate() { + *value = part[3][lane].mul_add(weight, *value); + } + } + self.centre_of_mass_delta = std::array::from_fn(|i| com[i] - self.centre_of_mass[i]); + self.centre_of_mass = com; + // Native transforms both points separately then subtracts, retaining + // each affine FMA's rounding; do not rotate their difference instead. + let transformed_com = transform_point(animation_to_board, com); + let transformed_deck = transform_point(animation_to_board, self.pose[0][3]); + let relative = std::array::from_fn(|i| transformed_com[i] - transformed_deck[i]); + self.com_to_deck_world_delta = + std::array::from_fn(|i| (relative[i] - self.com_to_deck_world[i]) * self.reset_scalar); + self.com_to_deck_world = relative; + self.reset_scalar = 1.0; + } + + pub fn com_to_deck(&self) -> Vector3 { + let v = self.com_to_deck_world; + Vector3::new(v[0], v[1], v[2]) + } +} +pub fn transform_point(transform: &AnimationPartTransform, point: [f32; 4]) -> [f32; 4] { + std::array::from_fn(|i| { + let x = transform[0][i].mul_add(point[0], transform[3][i]); + let y = transform[1][i].mul_add(point[1], x); + transform[2][i].mul_add(point[2], y) + }) +} + +#[cfg(test)] +#[path = "tests/skeleton_animation_record.rs"] +mod tests; + +/// Native affine product used by ProcessData and board-offset application. +pub fn compose_affine( + parent: &AnimationPartTransform, + child: &AnimationPartTransform, +) -> AnimationPartTransform { + std::array::from_fn(|column| { + if column == 3 { + transform_point(parent, child[3]) + } else { + std::array::from_fn(|lane| { + let x = child[column][0] * parent[0][lane]; + let y = child[column][1].mul_add(parent[1][lane], x); + child[column][2].mul_add(parent[2][lane], y) + }) + } + }) +} diff --git a/crates/skate-core/src/physics/skeleton_board_frames.rs b/crates/skate-core/src/physics/skeleton_board_frames.rs new file mode 100644 index 00000000..141db27f --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_board_frames.rs @@ -0,0 +1,152 @@ +//! Ground skeleton frame publication82BDF530 and COM lift82BDE310. +//! The authored board11728, world target15952 and physical board12496 have +//! different producers. Keep them separate through the animation/physics tick. +use super::{ + native_arithmetic::reciprocal_estimate, + skeleton_animation_record::{ + AnimationPartTransform as Transform, IDENTITY, compose_affine, transform_point, + }, + skeleton_root::{SkeletonRootFrames, orthonormalize}, +}; + +pub struct SkeletonBoardFrames { + pub physical_board: Transform, + pub skate_root: Transform, + pub animation_target: Transform, + pub com_frame: Transform, + pub lifted_com_frame: Transform, + pub centre_of_mass: [f32; 4], + pub previous_centre_of_mass: [f32; 4], + pub com_velocity: [f32; 4], + pub local_centre_of_mass: [f32; 4], + pub local_board_position: [f32; 4], + ///Skeleton16412, a per-update height limiter, not a timestep spring. + pub lift_height: f32, +} +impl Default for SkeletonBoardFrames { + fn default() -> Self { + Self { + physical_board: IDENTITY, + skate_root: IDENTITY, + animation_target: IDENTITY, + com_frame: IDENTITY, + lifted_com_frame: IDENTITY, + centre_of_mass: [0.; 4], + previous_centre_of_mass: [0.; 4], + com_velocity: [0.; 4], + local_centre_of_mass: [0.; 4], + local_board_position: [0.; 4], + lift_height: 0., + } + } +} +impl SkeletonBoardFrames { + ///Reset82BD9990: the retained COM seed is the supplied spawn position; + ///the two COM drive frames initially share spawn plus one world-Y unit. + ///The following physical observations replace the seed with weighted COM. + pub fn reset(&mut self, spawn: Transform) { + self.com_frame = spawn; + self.com_frame[3][1] += 1.0; + self.lifted_com_frame = self.com_frame; + self.centre_of_mass = spawn[3]; + self.local_centre_of_mass = [0.; 4]; + } + ///82BD9EA8, after the physical record observation: inverse-transform + ///the retained physical COM and actual board position independently. + pub fn publish_local_observations( + &mut self, + roots: &SkeletonRootFrames, + physical_board: &Transform, + ) { + self.local_centre_of_mass = transform_point(&roots.world_to_animation, self.centre_of_mass); + self.local_board_position = transform_point(&roots.world_to_animation, physical_board[3]); + } + + ///82BD9FB0..82BDA060 after animation/physics errors are updated. This is + ///physical weighted COM4464, not physical hips23 or authored hips height. + pub fn publish_centre_of_mass(&mut self, com: [f32; 4], dt: f32, flags_2472: u32) { + self.previous_centre_of_mass = self.centre_of_mass; + self.centre_of_mass = com; + let mut inverse = reciprocal_estimate(dt); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(dt, 1.0), inverse); + } + self.com_velocity = + std::array::from_fn(|i| (com[i] - self.previous_centre_of_mass[i]) * inverse); + if flags_2472 & 0x400 != 0 { + //Native updates previous COM after calculating velocity. + self.previous_centre_of_mass = com; + } + } + + ///82BDF55C..F774, following UpdateRootTransforms and preceding GeneralUpdate. + ///Returns the complete frame published to ProcessedPhysIn0..48. + pub fn prepare_ground( + &mut self, + roots: &SkeletonRootFrames, + mapped_board: &Transform, + actual_board: Transform, + flags_2468: &mut u32, + ) -> Transform { + let local_target = compose_affine(&roots.animation_to_board, mapped_board); + let mut world_position = IDENTITY; + world_position[3] = roots.predicted_board_position; + self.animation_target = compose_affine(&world_position, &local_target); + *flags_2468 &= !0x80000; + self.physical_board = actual_board; + self.skate_root = actual_board; + self.update_com_lift(&roots.animation_to_world, self.centre_of_mass, 0.0); + self.animation_target + } + + ///82BDF7C0. Teleport shares the authored target composition, but its COM + ///drive frame starts one world-Y unit above the physical deck position. + pub fn prepare_teleport( + &mut self, + roots: &SkeletonRootFrames, + mapped_board: &Transform, + actual_board: Transform, + flags_2468: &mut u32, + ) -> Transform { + let local_target = compose_affine(&roots.animation_to_board, mapped_board); + let mut world_position = IDENTITY; + world_position[3] = roots.predicted_board_position; + self.animation_target = compose_affine(&world_position, &local_target); + *flags_2468 &= !0x80000; + self.physical_board = actual_board; + self.skate_root = actual_board; + let mut position = actual_board[3]; + position[1] += 1.0; //82BDFA20..30 replaces Y only. + self.update_com_lift(&roots.animation_to_world, position, 0.0); + self.animation_target + } + + ///82BDE310. Preserve both fsel comparisons and the later vector FMA. + pub fn update_com_lift( + &mut self, + animation_to_world: &Transform, + position: [f32; 4], + requested_height: f32, + ) { + let step = f32::from_bits(0x3D23_D70A); //82216FEC .04 + let low = self.lift_height - step; + let high = self.lift_height + step; + let above_low = if low - requested_height >= 0.0 { + low + } else { + requested_height + }; + self.lift_height = if high - above_low >= 0.0 { + above_low + } else { + high + }; + let mut frame = *animation_to_world; + frame[3] = position; + self.com_frame = orthonormalize(frame); + self.lifted_com_frame = self.com_frame; + let height = self.lift_height + f32::from_bits(0x3E99_999A); //820D06C0 .3 + self.lifted_com_frame[3] = + std::array::from_fn(|i| self.com_frame[1][i].mul_add(height, self.com_frame[3][i])); + } +} diff --git a/crates/skate-core/src/physics/skeleton_board_offset.rs b/crates/skate-core/src/physics/skeleton_board_offset.rs new file mode 100644 index 00000000..a2be5228 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_board_offset.rs @@ -0,0 +1,107 @@ +//! Complete Skeleton::UpdateSkateboardOffsetTransform82BDD630 and +//! SkeletonIK::AddOffsetTransform82BF1C68. Counters are animation updates. +use super::{ + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}; + +#[derive(Clone, Debug)] +pub struct SkateboardOffset { + pub transform: AnimationPartTransform, + pub orientation_frames: f32, + pub height_frames: f32, + pub orientation_refreshed: bool, + pub height_refreshed: bool, +} +impl Default for SkateboardOffset { + fn default() -> Self { + // Skeleton constructor82BD76E8..7708 and82BD79A0..79BC. + Self { + transform: IDENTITY, + orientation_frames: 0.0, + height_frames: 0.0, + orientation_refreshed: false, + height_refreshed: false, + } + } +} +impl SkateboardOffset { + /// The offboard, landing-on-board and grind-air producers refresh both + /// channels for15 updates after writing their independently calculated frame. + pub fn refresh_transform(&mut self, transform: AnimationPartTransform) { + self.transform = transform; + self.orientation_frames = 15.0; + self.height_frames = 15.0; + self.orientation_refreshed = true; + self.height_refreshed = true; + } + + /// LandingAdjust writes only Y and its own duration, preserving X/Z/basis. + pub fn refresh_height(&mut self, height: f32, frames: f32) { + self.transform[3][1] = height; + self.height_frames = frames; + self.height_refreshed = true; + } + + /// Update decay first, then apply the resulting offset to the board and + /// four IK targets. Positive counters select application before decrement. + pub fn update( + &mut self, + board_pose: &mut AnimationPartTransform, + targets: &mut [AnimationPartTransform; 4], + ) { + let mut apply = false; + if self.orientation_frames > 0.0 { + apply = true; + if !self.orientation_refreshed { + let ratio = (self.orientation_frames - 1.0) / self.orientation_frames; + let weight = ratio * ratio; + let identity_weight = 1.0 - weight; + for (axis, identity) in IDENTITY.iter().enumerate().take(3) { + for (lane, value) in self.transform[axis].iter_mut().enumerate() { + *value = value.mul_add(weight, identity[lane] * identity_weight); + } + } + // vrlimi mask4 retains Y. X/Z/W belong to orientation decay. + for lane in [0, 2, 3] { + self.transform[3][lane] *= weight; + } + let unnormalized = self.transform; + // Native Gram-Schmidt traverses At,Up,Ri in that order. + for axis in (0..3).rev() { + let mut vector = unnormalized[axis]; + for prior in ((axis + 1)..3).rev() { + let projection = dot3(self.transform[prior], unnormalized[axis]); + for (lane, value) in vector.iter_mut().enumerate() { + *value -= self.transform[prior][lane] * projection; + } + } + let reciprocal = inverse_length_squared(dot3(vector, vector), 2); + self.transform[axis] = vector.map(|v| v * reciprocal); + } + self.orientation_frames -= 1.0; + } + } + if self.height_frames > 0.0 { + apply = true; + if !self.height_refreshed { + let ratio = (self.height_frames - 1.0) / self.height_frames; + self.transform[3][1] *= ratio * ratio; + self.height_frames -= 1.0; + } + } + if apply { + *board_pose = compose_affine(&self.transform, board_pose); + for target in targets { + *target = compose_affine(&self.transform, target); + } + } + self.orientation_refreshed = false; + self.height_refreshed = false; + } +} + +#[cfg(test)] +#[path = "tests/skeleton_board_offset.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/skeleton_body/collision_feedback.rs b/crates/skate-core/src/physics/skeleton_body/collision_feedback.rs new file mode 100644 index 00000000..202c5df2 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_feedback.rs @@ -0,0 +1,223 @@ +//! SkeletonCollision observations from original TU3 82BD4A30/82BD5DA0. +//! Reports must come from the completed solver, in native report order. +use super::{SkeletonCollisionSettings, SkeletonPhysicalRecord}; +use crate::physics::skeleton_animation_record::AnimationPartTransform; + +pub(super) type V = [f32; 4]; + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonFeedbackSettings { + pub body: SkeletonCollisionSettings, + pub small_object_mass: f32, + pub ground_plane_max_distance: f32, + pub ground_plane_max_angle: f32, + pub skater_scalar: f32, + pub ai_scalar: f32, + pub groin_offset: V, + pub face_offset: V, + pub groin_radius: f32, + pub face_radius: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonContactBody { + pub state_flags: u32, + pub inverse_mass: f32, + pub linear_velocity: V, +} + +/// The useful fields of original 96-byte ContactInfo and its solved spy. +/// `normal` already has the report-side sign; `solved_vector` is spy+80. +#[derive(Clone, Copy, Debug)] +pub struct SkeletonContactReport { + pub part: usize, + pub normal: V, + pub point: V, + pub tag: u32, + pub other_group: u32, + pub other_entity: Option, + pub body_a: SkeletonContactBody, + pub body_b: SkeletonContactBody, + pub side_a: bool, + pub solved_vector: V, +} + +/// Original UpdatePostPhysics82BD80D8 builds these values before Update. +pub struct SkeletonCollisionInput<'a> { + pub dt: f32, + pub plane_point: V, + pub plane_normal: V, + pub reference_velocity: V, + pub com_velocity: V, + pub ragdoll: bool, + /// Processed2472 bit0x8000, which suppresses normal ground filtering. + pub disable_ground_filter: bool, + /// Processed2472 bit0x10000000 selects AISkeletonScalar. + pub ai_collision_scalar: bool, + /// Category500, excluding states501 and503. + pub offboard: bool, + pub entering_offboard: bool, + pub category_600: bool, + pub request_partial_ragdoll: bool, + pub physical: &'a SkeletonPhysicalRecord, + pub part_weights: &'a [f32; 24], + /// Actual GetPartTransform frames used by CheckConflictingContacts. + pub body_frames: &'a [AnimationPartTransform; 26], +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct BoneContact { + pub normal: V, + pub specific_normal: V, + pub tangent: V, + pub specific_tangent: V, + pub point: V, + pub force: f32, + pub specific_force: f32, + pub tag: u32, + pub specific_tag: u32, + pub groups: [bool; 4], +} +#[derive(Clone, Copy, Debug)] +pub struct ContactRegion { + pub force: f32, + pub weighted_force: f32, + pub tangent_speed: f32, + pub material_flags: u32, + pub normal: V, + pub part: Option, +} +impl Default for ContactRegion { + fn default() -> Self { + Self { + force: 0.0, + weighted_force: 0.0, + tangent_speed: 0.0, + material_flags: 0, + normal: [0.0; 4], + part: None, + } + } +} +#[derive(Clone, Copy, Debug, Default)] +pub struct ContactPlane { + pub normal: V, + pub part: usize, +} +#[derive(Clone, Copy, Debug)] +pub struct SpecificContact { + pub part: usize, + pub local_point: V, + pub world_point: V, + pub radius_squared: f32, + pub current: bool, + pub recent: bool, +} +#[derive(Clone, Copy, Debug, Default)] +pub struct SkeletonContactFlags { + pub material_6: bool, + pub noncompliant: bool, + /// Native4070. + pub compliant: bool, + pub recovering: bool, + pub group_8: bool, + pub nonboard: bool, + pub ragdoll: bool, + pub conflicting: bool, + pub impaled: bool, + /// Native4077. This does not itself contain the pose-error vector16272. + pub has_impulse: bool, + pub foot_board: bool, + pub material_10: bool, + pub material_11: bool, + pub material_12: bool, + pub any: bool, +} + +pub struct SkeletonCollisionFeedback { + pub settings: SkeletonFeedbackSettings, + pub contact_age: [f32; 24], + pub priority: [f32; 24], + pub compliant: [bool; 24], + pub current: [bool; 24], + pub bones: [BoneContact; 24], + pub regions: [ContactRegion; 8], + pub planes: Vec, + pub specific: [SpecificContact; 2], + pub highest_normal: V, + pub foot_normal: V, + pub material_normals: [V; 2], + pub timer: f32, + /// Native4028. Reset is zero; Update recovers toward one. + pub drive_weight: f32, + pub maximum_priority: f32, + pub wipeout_times: [f32; 3], + pub maximum_skater_force: f32, + pub other_skater: i32, + pub maximum_group_8_force: f32, + pub maximum_group_11_force: f32, + pub material_12_height: f32, + pub flags: SkeletonContactFlags, +} + +impl SkeletonCollisionFeedback { + /// Constructor82BD5F48/6038 plus the final Body constructor Reset. Static + /// compliant settings survive Reset, while the priority array is cleared. + pub fn new(settings: SkeletonFeedbackSettings) -> Self { + let specific = [ + (23, settings.groin_offset, settings.groin_radius), + (1, settings.face_offset, settings.face_radius), + ] + .map(|(part, local_point, radius)| SpecificContact { + part, + local_point, + world_point: [0.0; 4], + radius_squared: radius * radius, + current: false, + recent: false, + }); + Self { + settings, + contact_age: [0.0; 24], + priority: [0.0; 24], + compliant: settings.body.compliant, + current: [false; 24], + bones: [BoneContact::default(); 24], + regions: [ContactRegion::default(); 8], + planes: Vec::with_capacity(20), + specific, + highest_normal: [0.0, 1.0, 0.0, 0.0], + foot_normal: [0.0, 1.0, 0.0, 0.0], + material_normals: [[0.0; 4]; 2], + timer: 0.0, + drive_weight: 0.0, + maximum_priority: 0.0, + wipeout_times: [0.0; 3], + maximum_skater_force: 0.0, + other_skater: -1, + maximum_group_8_force: 0.0, + maximum_group_11_force: 0.0, + material_12_height: 0.0, + flags: SkeletonContactFlags::default(), + } + } + + ///82BD5DA0 preserves compliant settings, specific-point histories and4082. + pub fn reset(&mut self) { + let specific = self.specific; + let compliant = self.compliant; + let any = self.flags.any; + *self = Self::new(self.settings); + self.specific = specific; + self.compliant = compliant; + self.flags.any = any; + } + + /// SetUpNormal82BE7280 calls Reset before reinstalling authored priority + /// and compliant settings. A subsequent constructor Reset clears priority. + pub fn set_up_normal(&mut self) { + self.reset(); + self.priority = self.settings.body.priority; + self.compliant = self.settings.body.compliant; + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_filter.rs b/crates/skate-core/src/physics/skeleton_body/collision_filter.rs new file mode 100644 index 00000000..863b9d53 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_filter.rs @@ -0,0 +1,71 @@ +//! Original82BD5A00: select the angular interval of active contact planes. +use super::{SkeletonCollisionFeedback, collision_feedback::V, collision_vector::*}; +use crate::{ + math::Vector3, + riding::collision_response::signed_angle, + trigonometry::{cos, sin}, +}; + +impl SkeletonCollisionFeedback { + pub fn filter_error(&self, error: V, axis: V) -> V { + let projected = sub(error, scale(axis, dot(error, axis))); + let direction = normalize(projected); + if dot(direction, direction) < 0.1 { + return projected; + } + let half_pi = f32::from_bits(0x3fc9_0fdb); + let mut minimum = half_pi; + let mut maximum = -half_pi; + let mut found = false; + for plane in &self.planes { + let normal = normalize(sub(plane.normal, scale(axis, dot(plane.normal, axis)))); + if dot(normal, normal) < 0.1 { + continue; + } + let angle = signed_angle(vector(direction), vector(normal), vector(axis)); + let turns = angle * f32::from_bits(0x3e22_f983); + let fractional = turns - turns.floor(); + let centered = fractional - if fractional > 0.5 { 1.0 } else { 0.0 }; + let wrapped = centered * f32::from_bits(0x40c9_0fdb); + if wrapped > -half_pi && wrapped < half_pi { + if wrapped < minimum { + minimum = wrapped; + } + if wrapped > maximum { + maximum = wrapped; + } + found = true; + } + } + let angle = (maximum + minimum) * 0.5; + let magnitude = if found { cos(angle) } else { 0.0 }; + //82BD35B8 rotates identity around the supplied axis before applying + //the projected error. Preserve its product/FMA and standalone trig. + let c = cos(angle); + let s = sin(angle); + let [x, y, z, _] = axis; + let sx = s * x; + let sy = s * y; + let sz = s * z; + let t = 1.0 - c; + let tx = t * x; + let ty = t * y; + let tz = t * z; + let rotation = [ + [x.mul_add(tx, c), tx.mul_add(y, sz), tx * z - sy], + [ty * x - sz, ty.mul_add(y, c), ty.mul_add(z, sx)], + [tz.mul_add(x, sy), tz * y - sx, z.mul_add(tz, c)], + ]; + let mut result = [0.0; 4]; + for i in 0..3 { + result[i] = rotation[2][i].mul_add( + projected[2], + rotation[1][i].mul_add(projected[1], rotation[0][i] * projected[0]), + ) * magnitude; + } + result + } +} +fn vector(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_mode.rs b/crates/skate-core/src/physics/skeleton_body/collision_mode.rs new file mode 100644 index 00000000..93ae440c --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_mode.rs @@ -0,0 +1,342 @@ +//! Original normal body setup82BE7280 and driven selector82D913C0 modes1/5/6. +//! Collision volume groups and part metadata are distinct from body state bits. +use super::{ANIMATION_PART_COUNT, PART_COUNT, SkeletonBody, SkeletonCollisionFeedback}; +use crate::physics::{contact::RetailContactMaterial, rigid_body::world_inverse_inertia}; + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonCollisionSettings { + pub enabled: bool, + pub normal_material: RetailContactMaterial, + /// Original collision classification settings copied by82BE745C/7468. + pub compliant: [bool; ANIMATION_PART_COUNT], + pub priority: [f32; ANIMATION_PART_COUNT], + pub effect_time: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonPartCollision { + /// Volume+92 bit0. Other volume flags are owned by geometry construction. + pub enabled: bool, + /// Volume+84; group4 is written by DisableCollisionWithWorld. + pub volume_group: u32, + /// Original Part+92, distinct from its volume group. + pub part_group: u32, + pub material: RetailContactMaterial, +} + +pub struct SkeletonCollisionMode { + pub parts: [SkeletonPartCollision; PART_COUNT], + pub disable_count: [u32; ANIMATION_PART_COUNT], + /// Original Body8224 gates postphysics countdown/reenabling. + pub pending_reenable: bool, + pub assembly_group: u32, + pub partial_ragdoll: bool, + pub is_ragdoll: bool, + pub settings: SkeletonCollisionSettings, + ///82BE82C0 table: true suppresses this pair before geometric collision. + pub self_culling: [[bool; PART_COUNT]; PART_COUNT], + normal_self_culling: [[bool; PART_COUNT]; PART_COUNT], +} + +impl SkeletonCollisionMode { + ///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) { + self.is_ragdoll = false; + feedback.reset(); + self.disable_count = [0; ANIMATION_PART_COUNT]; + self.pending_reenable = false; + } + + /// Full normal constructor state after82BE4290 and SetUpNormal. The final + /// argument is the actual constructor byte8227, not a guessed AI identity. + pub fn new_normal(settings: SkeletonCollisionSettings, cull_all_self_pairs: bool) -> Self { + //82BE5130..5164 assigns this zero material to parts0,24,25. + let zero = RetailContactMaterial { + static_friction: 0.0, + dynamic_friction: 0.0, + restitution: 0.0, + }; + let mut result = Self { + parts: std::array::from_fn(|part| SkeletonPartCollision { + enabled: false, + volume_group: if part == 0 { 4 } else { 0 }, + part_group: 5, + material: if (1..24).contains(&part) { + settings.normal_material + } else { + zero + }, + }), + disable_count: [0; ANIMATION_PART_COUNT], + pending_reenable: false, //82BE4660, following the24 counter clears. + assembly_group: 5, + partial_ragdoll: false, + is_ragdoll: false, + settings, + self_culling: [[true; PART_COUNT]; PART_COUNT], + normal_self_culling: [[true; PART_COUNT]; PART_COUNT], + }; + if !cull_all_self_pairs { + for (a, b) in [(14, 4), (14, 8), (23, 4), (23, 8), (4, 18), (8, 22)] { + result.self_culling[a][b] = false; + result.self_culling[b][a] = false; + } + } + if settings.enabled { + result.normal_collision(); + } else { + result.disable_all(true); + } + result.normal_self_culling = result.self_culling; + result + } + + ///Normal riding and ascending-Air collision. Mode6 calls82BE3700, whose + ///root/extra disabling, group5 and eligible-bone writes match mode5's result. + pub fn select_driven(&mut self, mode: u32) -> Result<(), &'static str> { + match mode { + 3 => { + self.select_biped(); + return Ok(()); + } + 5 | 6 => self.normal_collision(), + 1 => { + //82BE3808 enables eligible bones before switching the group. + self.enable_eligible_bones(); + self.set_group(3); + } + _ => return Err("Skeleton collision selector requires its non-driven physical setup"), + } + self.partial_ragdoll = false; + self.disable_root(); + self.parts[24].enabled = false; + self.parts[25].enabled = false; + Ok(()) + } + + ///82BE70D8. Does not consult BoneHasCollision, clear positive disable + ///counts, alter materials or disable the two extra volumes. + pub fn normal_collision(&mut self) { + self.set_group(5); + self.disable_root(); + self.enable_eligible_bones(); + } + + ///82BE6FF8. Bone volume group values survive this operation. + pub fn disable_all(&mut self, clear_counts: bool) { + self.disable_root(); + self.parts[24].enabled = false; + self.parts[25].enabled = false; + for part in 1..24 { + self.parts[part].enabled = false; + if clear_counts { + self.disable_count[part] = 0; + } + } + } + + ///82BE7190. This operation tests the counter but does not decrement it. + pub fn enable_bone(&mut self, part: usize) { + if self.disable_count[part] == 0 { + self.parts[part].enabled = true; + self.parts[part].volume_group = 0; + } + self.parts[part].part_group = 5; + } + + ///82BD8364..83BC runs after collision observations and before82BD9FB0. + /// It only changes the enabled bit, preserving each volume's group. + pub fn finish_contact_frame(&mut self) { + if !self.pending_reenable { + return; + } + let mut remaining = false; + for part in 0..ANIMATION_PART_COUNT { + if (self.disable_count[part] as i32) > 0 { + self.disable_count[part] -= 1; + let enabled = self.disable_count[part] == 0; + self.parts[part].enabled = enabled; + remaining |= !enabled; + } + } + self.pending_reenable = remaining; + } + + ///82BE71F0, unlike the whole-body normal path, uses BoneHasCollision. + pub fn normal_bone(&mut self, part: usize, has_collision: bool) { + if has_collision { + if self.disable_count[part] == 0 { + self.parts[part].enabled = true; + self.parts[part].volume_group = 0; + } + } else { + self.parts[part].enabled = false; + self.parts[part].volume_group = 4; + self.disable_count[part] = 0; + } + } + + /// Physical-property portion of82BE7280. The coordinator separately resets + /// the real SkeletonCollision observations and reinstalls normal joint + /// limits in that method's original order. This method does not fake either. + pub fn restore_normal_properties(&mut self, skeleton: &mut SkeletonBody) { + self.self_culling = self.normal_self_culling; + self.set_group(5); + for part in 0..PART_COUNT { + let animated = skeleton.definition.parts[part]; + let body = &mut skeleton.bodies_mut()[part]; + body.inertia.linear_drag = 0.0; + body.inertia.angular_drag = 0.0; + if (1..24).contains(&part) { + body.inertia.inverse_mass = animated.inverse_mass_animated; + body.inertia.inverse_tensor = animated.animated.dynamics.inverse_tensor; + let tensor = body.inertia.inverse_tensor; + let mut minimum = tensor.x; + if minimum >= tensor.y { + minimum = tensor.y; + } + if minimum >= tensor.z { + minimum = tensor.z; + } + body.inertia.spherical = 1.0 / minimum; + body.rates.world_inverse_inertia = world_inverse_inertia(body.rates.basis, tensor); + self.parts[part].material = self.settings.normal_material; + } + } + if self.settings.enabled { + self.normal_collision(); + } else { + self.disable_all(true); + } + self.is_ragdoll = false; + } + + ///Physical properties of82BE6D60; joint limits are set first by its caller. + pub fn apply_ragdoll_properties( + &mut self, + skeleton: &mut SkeletonBody, + inverse_mass: bool, + inverse_inertia: bool, + drag: [f32; 2], + materials: [RetailContactMaterial; 2], + ) { + //Exact raw82BE8900..A664 stores, not the asymmetric decompiler output. + const CULLED: [u32; PART_COUNT] = [ + 0x03FFFFFF, 0x03FFF447, 0x03FFFC47, 0x039B8479, 0x03BB8479, 0x039F8479, 0x03FFFFFF, + 0x03B987C1, 0x03BB87C1, 0x03F987C1, 0x03FFFFFF, 0x03FFFC45, 0x03FFFC47, 0x03FFFC47, + 0x03FFFC47, 0x03FFFFFF, 0x038FFFFF, 0x038FFD7F, 0x03CFFC67, 0x03FFFFFF, 0x03F8FFFF, + 0x03F8FFD7, 0x03FCFE47, 0x03FFFFFF, 0x03FFFFFF, 0x03FFFFFF, + ]; + //Constructor byte8227 selects the all-culled table for both modes. + if self + .normal_self_culling + .iter() + .flatten() + .all(|culled| *culled) + { + self.self_culling = [[true; PART_COUNT]; PART_COUNT]; + } else { + self.self_culling = + std::array::from_fn(|a| std::array::from_fn(|b| CULLED[a] & (1 << b) != 0)); + } + self.set_group(6); + for body in skeleton.bodies_mut() { + body.inertia.linear_drag = drag[0]; + body.inertia.angular_drag = drag[1]; + } + for part in 1..24 { + let authored = skeleton.definition.parts[part]; + let body = &mut skeleton.bodies_mut()[part]; + self.disable_count[part] = 0; + self.parts[part].enabled = true; + self.pending_reenable = false; + self.parts[part].material = materials[usize::from(matches!(part, 1 | 3 | 7))]; + if inverse_mass { + body.inertia.inverse_mass = authored.inverse_mass_ragdoll; + } + if inverse_inertia { + let tensor = authored.ragdoll.dynamics.inverse_tensor; + body.inertia.inverse_tensor = tensor; + let mut minimum = tensor.x; + if minimum >= tensor.y { + minimum = tensor.y; + } + if minimum >= tensor.z { + minimum = tensor.z; + } + body.inertia.spherical = 1.0 / minimum; + } + body.rates.world_inverse_inertia = + world_inverse_inertia(body.rates.basis, body.inertia.inverse_tensor); + } + self.is_ragdoll = true; + } + + ///The post-body portions of original selector modes7..10. + pub fn finish_ragdoll_request(&mut self, mode: u32) { + match mode { + 7 | 10 => { + //Skeleton82BE3350 clears partial mode, root and extra volumes. + self.partial_ragdoll = false; + self.disable_root(); + self.parts[24].enabled = false; + self.parts[25].enabled = false; + } + 8 => { + //82D915A0..C4 uses constructor material3076 for all bones. + for part in 1..24 { + self.parts[part].material = RetailContactMaterial { + static_friction: 0.5, + dynamic_friction: 0.3, + restitution: 0.4, + }; + } + } + 9 => self.set_group(7), + _ => unreachable!("Only original Wipeout requests8..11 enter this adapter"), + } + } + + fn set_group(&mut self, group: u32) { + self.assembly_group = group; + for part in &mut self.parts { + part.part_group = group; + } + } + ///Request4 branch82D914B0 and Body82BE5248. Existing physical masses, + ///joint limits, material and self-pair table survive this policy change. + fn select_biped(&mut self) { + self.set_group(6); + self.disable_all(false); + for part in [17, 21, 15, 19, 16, 20] { + self.enable_bone(part); + self.parts[part].part_group = 17; + } + for part in [5, 4, 3, 9, 8, 7, 2, 1] { + self.enable_bone(part); + } + for part in [6, 10, 11, 12, 13, 14, 23] { + self.enable_bone(part); + self.parts[part].part_group = 18; + } + self.partial_ragdoll = true; + for part in [24, 25] { + self.parts[part].part_group = 20; + self.parts[part].enabled = true; + } + } + fn disable_root(&mut self) { + self.parts[0].volume_group = 4; + self.parts[0].enabled = false; + self.disable_count[0] = 0; + } + fn enable_eligible_bones(&mut self) { + for part in 1..24 { + if self.disable_count[part] == 0 { + self.parts[part].enabled = true; + self.parts[part].volume_group = 0; + } + } + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_update.rs b/crates/skate-core/src/physics/skeleton_body/collision_update.rs new file mode 100644 index 00000000..4b528f7a --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_update.rs @@ -0,0 +1,356 @@ +//! Complete contact traversal and classification of TU3 82BD4A30. +use super::collision_feedback::*; +use super::collision_vector::{ + add, clamp_length, cross, dot, length, normalize, scale, sub, transform, +}; + +// Original read-only table820CFCB0. Part0 is not a region participant. +const REGIONS: [usize; 24] = [ + usize::MAX, + 0, + 1, + 2, + 2, + 2, + 1, + 3, + 3, + 3, + 1, + 1, + 1, + 1, + 1, + 6, + 6, + 4, + 4, + 7, + 7, + 5, + 5, + 1, +]; + +impl SkeletonCollisionFeedback { + pub fn update( + &mut self, + input: &SkeletonCollisionInput<'_>, + reports: &[SkeletonContactReport], + ) { + self.begin_frame(input); + let reference_speed = length(input.reference_velocity); + let direction = normalize(input.reference_velocity); + for report in reports { + // Original constructor enables reports for the24 animated parts. + // A different owner must not silently index this state as part24/25. + assert!( + report.part < 24, + "SkeletonCollision report part exceeds native24-part array" + ); + let part = report.part; + let material = (report.tag >> 7) & 31; + self.flags.any = true; + if report.other_group == 4 && [15, 16, 19, 20].contains(&part) { + self.flags.foot_board = true; + } + if material == 6 { + self.flags.material_6 = true; + } + if material == 12 { + self.flags.material_12 = true; + self.material_12_height = report.point[1]; + } + if self.ignore_ground(input, report) { + continue; + } + self.observe_normal(input.ragdoll, report, material); + let mut specific = false; + for point in &mut self.specific { + let delta = sub(report.point, point.world_point); + if part == point.part && dot(delta, delta) < point.radius_squared { + point.current = true; + point.recent = true; + specific = true; + } + } + // This gate precedes priority replacement and all force recording. + if self.planes.len() >= 20 { + continue; + } + self.contact_age[part] = 0.0; + self.current[part] = true; + if self.priority[part] > self.maximum_priority { + self.maximum_priority = self.priority[part]; + self.planes.clear(); + } + if self.priority[part] == self.maximum_priority { + self.planes.push(ContactPlane { + normal: report.normal, + part, + }); + } + + let mass_a = mass(report.body_a); + let mass_b = mass(report.body_b); + let other_mass = if report.side_a { mass_b } else { mass_a }; + let small = other_mass > 0.0001 && other_mass < self.settings.small_object_mass; + let factor = ((mass_b + mass_a) / (mass_b * mass_a)) * f32::from_bits(0x3991_A2B5); + let weighted_force = + length(scale(report.solved_vector, factor)) * input.part_weights[part]; + let part_velocity = input.physical.velocities[part]; + let own_velocity = if input.ragdoll { + scale(add(input.com_velocity, part_velocity), 0.5) + } else if input.offboard || input.entering_offboard { + let mut velocity = input.com_velocity; + velocity[1] = 0.0; + velocity + } else { + let projected = scale(direction, dot(part_velocity, direction)); + clamp_length(projected, reference_speed) + }; + let other = if report.side_a { + report.body_b + } else { + report.body_a + }; + let relative = sub(other.linear_velocity, own_velocity); + let mut force = dot(relative, report.normal).abs(); + if small { + force *= 0.5; + } + match report.other_group { + 5 => { + force *= if input.ai_collision_scalar { + self.settings.ai_scalar + } else { + self.settings.skater_scalar + }; + self.maximum_skater_force = maximum(self.maximum_skater_force, force); + if let Some(entity) = report.other_entity { + self.other_skater = entity; + } + self.flags.nonboard = true; + } + 8 => { + self.flags.group_8 = true; + self.maximum_group_8_force = maximum(self.maximum_group_8_force, force); + self.flags.nonboard = true; + } + 11 => { + self.maximum_group_11_force = maximum(self.maximum_group_11_force, force); + self.flags.nonboard = true; + } + 4 => (), + _ => self.flags.nonboard = true, + } + if part > 0 { + self.record_force(report, specific, relative, force, weighted_force); + self.timer = self.settings.body.effect_time; + } + if !self.compliant[part] || small { + self.flags.noncompliant = true; + } else { + self.flags.compliant = true; + } + self.flags.has_impulse = self.flags.compliant || input.offboard; + } + if input.ragdoll { + self.flags.impaled = self.check_impaled(); + } else { + self.flags.conflicting = self.check_conflicting(input); + } + let duration = self.settings.body.effect_time; + self.drive_weight = if duration == 0.0 { + 1.0 + } else { + let ratio = self.timer / duration; + let low = if -ratio >= 0.0 { 0.0 } else { ratio }; + 1.0 - if 1.0 - low >= 0.0 { low } else { 1.0 } + }; + self.flags.recovering = self.drive_weight < 1.0; + } + + fn begin_frame(&mut self, input: &SkeletonCollisionInput<'_>) { + self.timer -= input.dt; + self.wipeout_times[2] += input.dt; + self.flags = SkeletonContactFlags { + ragdoll: input.ragdoll, + ..Default::default() + }; + self.planes.clear(); + self.maximum_priority = 0.0; + self.maximum_skater_force = 0.0; + self.other_skater = -1; + self.maximum_group_8_force = 0.0; + self.maximum_group_11_force = 0.0; + self.material_12_height = 0.0; + self.highest_normal = [0.0, -1.0, 0.0, 0.0]; + self.foot_normal = [0.0; 4]; + self.material_normals = [[0.0, -1.0, 0.0, 0.0]; 2]; + for part in 0..24 { + self.contact_age[part] -= input.dt; + self.current[part] = false; + self.bones[part] = BoneContact::default(); + } + for region in &mut self.regions { + // Unlike Reset, Update leaves the material flag untouched. + *region = ContactRegion { + material_flags: region.material_flags, + ..Default::default() + }; + } + if !input.ragdoll { + self.wipeout_times = [0.0; 3]; + } + for point in &mut self.specific { + point.recent &= input.ragdoll; + point.current = false; + point.world_point = transform(input.physical.pose[point.part], point.local_point); + } + if input.request_partial_ragdoll { + self.timer = self.settings.body.effect_time; + } + } + + fn ignore_ground( + &self, + input: &SkeletonCollisionInput<'_>, + report: &SkeletonContactReport, + ) -> bool { + if input.ragdoll || (input.disable_ground_filter && !input.category_600) { + return false; + } + let distance = dot(input.plane_normal, sub(report.point, input.plane_point)); + let threshold = if input.offboard { + 0.3 + } else { + self.settings.ground_plane_max_distance + }; + distance < threshold + && (input.offboard + || dot(report.normal, input.plane_normal).abs() + > self.settings.ground_plane_max_angle) + } + fn observe_normal(&mut self, ragdoll: bool, report: &SkeletonContactReport, material: u32) { + let normal = report.normal; + if normal[1] > self.highest_normal[1] { + self.highest_normal = normal; + } + if ragdoll { + if material == 10 && normal[1] > self.material_normals[0][1] { + self.material_normals[0] = normal; + self.flags.material_10 = true; + } else if material == 11 && normal[1] > self.material_normals[1][1] { + self.material_normals[1] = normal; + self.flags.material_11 = true; + } + } else if (report.part == 15 || report.part == 19) && normal[1] > self.foot_normal[1] { + self.foot_normal = normal; + } + } + fn record_force( + &mut self, + report: &SkeletonContactReport, + specific: bool, + relative: V, + force: f32, + weighted_force: f32, + ) { + let bone = &mut self.bones[report.part]; + let region = &mut self.regions[REGIONS[report.part]]; + let previous = if specific { + bone.specific_force + } else { + bone.force + }; + let tangent = if force > previous || force > region.force { + cross(relative, report.normal) + } else { + [0.0; 4] + }; + if force > previous { + if specific { + bone.specific_force = force; + bone.specific_normal = report.normal; + bone.specific_tangent = tangent; + bone.specific_tag = report.tag; + } else { + bone.force = force; + bone.normal = report.normal; + bone.tangent = tangent; + bone.point = report.point; + bone.tag = report.tag; + } + for (i, group) in [8, 11, 5].into_iter().enumerate() { + bone.groups[i] |= report.other_group == group; + } + bone.groups[3] |= ![8, 11, 5].contains(&report.other_group); + } + if force > region.force { + *region = ContactRegion { + force, + weighted_force, + tangent_speed: length(tangent), + material_flags: report.tag & 0x7f, + normal: report.normal, + part: Some(report.part), + }; + } + } + /// Original82BD5770 calls GetPartTransform, not animation-pose lookup. + fn check_conflicting(&self, input: &SkeletonCollisionInput<'_>) -> bool { + for (i, a) in self.regions.iter().enumerate() { + let Some(part_a) = a.part else { + continue; + }; + if !(a.force > 0.0) || !self.compliant[part_a] { + continue; + } + for (j, b) in self.regions.iter().enumerate() { + let Some(part_b) = b.part else { + continue; + }; + if i == j || !(b.force > 0.0) || !self.compliant[part_b] { + continue; + } + if dot(a.normal, b.normal) < -0.7 + && dot( + sub(input.body_frames[part_b][3], input.body_frames[part_a][3]), + a.normal, + ) < 0.0 + { + return true; + } + } + } + false + } + fn check_impaled(&self) -> bool { + for i in 1..23 { + let a = self.bones[i]; + if !(a.force > 0.0) { + continue; + } + for b in &self.bones[i + 1..24] { + if b.force > 0.0 + && dot(a.normal, b.normal) < -0.99 + && dot(a.normal, sub(a.point, b.point)).abs() < 0.03 + { + return true; + } + } + } + false + } +} +fn mass(body: SkeletonContactBody) -> f32 { + 1.0 / if body.state_flags == 1 { + 0.001 + } else { + body.inverse_mass + } +} +fn maximum(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { a } else { b } +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_vector.rs b/crates/skate-core/src/physics/skeleton_body/collision_vector.rs new file mode 100644 index 00000000..b94e8964 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_vector.rs @@ -0,0 +1,66 @@ +//! Original vector arithmetic used by82BD4A30 and82BD5A00. +use super::collision_feedback::V; +use crate::physics::{ + native_arithmetic::dot3, reciprocal_sqrt::estimate, + skeleton_animation_record::AnimationPartTransform, +}; +pub(super) fn dot(a: V, b: V) -> f32 { + dot3(a, b) +} +pub(super) fn scale(v: V, s: f32) -> V { + v.map(|v| v * s) +} +pub(super) fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn add(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn inverse_length(square: f32) -> f32 { + let mut inverse = estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + inverse +} +pub(super) fn length(v: V) -> f32 { + let square = dot(v, v); + let result = square * inverse_length(square); + if square == 0.0 { 0.0 } else { result } +} +pub(super) fn normalize(v: V) -> V { + let square = dot(v, v); + let inverse = inverse_length(square); + let size = if square == 0.0 { 0.0 } else { square * inverse }; + if size > 1.0e-6 { + scale(v, inverse) + } else { + [0.0; 4] + } +} +///82BD3D90 keeps all stored lanes, including the unused fourth velocity lane. +pub(super) fn clamp_length(v: V, maximum: f32) -> V { + let size = length(v); + if size < f32::from_bits(0x3780_0000) { + return v; + } + let bounded = if maximum - size >= 0.0 { size } else { maximum }; + let mut inverse = crate::physics::native_arithmetic::reciprocal_estimate(size); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(size, 1.0), inverse); + } + scale(scale(v, bounded), inverse) +} +pub(super) fn transform(m: AnimationPartTransform, v: V) -> V { + std::array::from_fn(|i| { + m[2][i].mul_add(v[2], m[1][i].mul_add(v[1], m[0][i].mul_add(v[0], m[3][i]))) + }) +} +pub(super) fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} diff --git a/crates/skate-core/src/physics/skeleton_body/construction.rs b/crates/skate-core/src/physics/skeleton_body/construction.rs new file mode 100644 index 00000000..a58477c0 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/construction.rs @@ -0,0 +1,181 @@ +//! SkeletonBody::Init82BE4900, head82BE5428 and extra capsules82BE5180. +use super::{ANIMATION_PART_COUNT, PART_COUNT}; +use crate::{math::{Basis3, Vector3}, physics::{ + mass::{MassMoments, MassShape, PartMassInput, RETAIL_UNBOUNDED_VELOCITY, + aggregate_mass_properties, primitive_mass, primitive_mass_properties}, + native_arithmetic, + rigid_body::{RetailBodyMassProperties, RetailLocalMassFrame}, + skeleton_animation_record::SkeletonAnimationMasses, +}}; + +#[derive(Clone, Copy, Debug)] +pub struct BoneSettings { + pub mass_factor: f32, + pub ragdoll_mass_factor: f32, + pub has_collision: bool, + pub use_root_drive: bool, + pub volume_type: u32, + pub volume_scalar: f32, + pub num_parents: u32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonBodySettings { + /// Native MassOfSkeleton multiplies each original bone-box volume. It is + /// not a requested total body mass and must not normalize the part masses. + pub density: f32, + pub root_radius: f32, + pub root_half_length: f32, + pub capsule_radius_scalar: f32, + pub capsule_length_scalar: f32, + pub ragdoll_inverse_mass_factor: f32, + pub inertia_multiply_type: u32, + pub inertia_factor: f32, +} + +/// Authored cylinder and transform from physics_hat, before head aggregation. +#[derive(Clone, Copy, Debug)] +pub struct HatGeometry { + pub radius: f32, + pub half_length: f32, + pub basis: Basis3, + pub translation: Vector3, +} +impl HatGeometry { + ///82BE55A0..569C: authored Euler angles feed XMVectorSinCos directly. + pub fn from_offsets(radius: f32, thickness: f32, angles: Vector3, translation: Vector3) -> Self { + let (sx,cx) = crate::trigonometry::sin_cos(angles.x); + let (sy,cy) = crate::trigonometry::sin_cos(angles.y); + let (sz,cz) = crate::trigonometry::sin_cos(angles.z); + let cxsz = cx*sz; + let sxsz = sx*sz; + let sxcz = sx*cz; + let cxcz = cx*cz; + Self { radius, half_length: thickness*0.5, translation, + basis: Basis3 { columns: [ + [cy*cz, cy*sz, -sy], + [sy*sxcz-cxsz, sy.mul_add(sxsz,cxcz), cy*sx], + [sy.mul_add(cxcz,sxsz), sy*cxsz-sxcz, cy*cx], + ]} } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonPart { + /// Part1 can additionally contain the separately described hat cylinder. + pub shape: MassShape, + pub hat: Option, + pub animated: RetailBodyMassProperties, + pub ragdoll: RetailBodyMassProperties, + pub inverse_mass_animated: f32, + pub inverse_mass_ragdoll: f32, +} + +pub struct SkeletonBodyDefinition { + pub parts: [SkeletonPart; PART_COUNT], + pub bones: [BoneSettings; ANIMATION_PART_COUNT], + pub animation_masses: SkeletonAnimationMasses, +} +impl SkeletonBodyDefinition { + pub fn new(sizes: [Vector3; ANIMATION_PART_COUNT], + bones: [BoneSettings; ANIMATION_PART_COUNT], settings: SkeletonBodySettings, + hat: Option) -> Result { + let mut parts = Vec::with_capacity(PART_COUNT); + for i in 0..ANIMATION_PART_COUNT { + let b = bones[i]; + let size = sizes[i]; + let shape = if i == 0 { + MassShape::Capsule { radius: settings.root_radius, + half_length: settings.root_half_length } + } else if i == 1 && hat.is_some() || b.volume_type == 1 { + let x = size.x * 0.5; + let y = size.y * 0.5; + MassShape::Sphere { radius: (if x-y >= 0.0 {x} else {y}) * b.volume_scalar } + } else { match b.volume_type { + 0 => { + let scalar = b.volume_scalar * 0.5; + MassShape::RoundedBox { half_extents: Vector3::new( + size.x*scalar, size.y*scalar, size.z*scalar), radius: 0.0 } + } + 2 => { + let radius = (size.x * settings.capsule_radius_scalar) * 0.5; + let length = size.z * 0.5 - radius; + MassShape::Capsule { radius: b.volume_scalar * radius, + half_length: ((if -length >= 0.0 {0.0} else {length}) + * settings.capsule_length_scalar) * b.volume_scalar } + } + _ => return Err("Unresolved non-root skeleton volume type"), + }}; + let base_mass = if i == 0 { f32::from_bits(0x3A83_126F) } + else { settings.density * ((size.x * size.y) * size.z) }; + let head_hat = if i == 1 {hat} else {None}; + let animated_mass = b.mass_factor * base_mass; + let ragdoll_mass = b.ragdoll_mass_factor * base_mass; + let mut animated = mass_properties(shape, head_hat, animated_mass)?; + let ragdoll = mass_properties(shape, head_hat, ragdoll_mass)?; + adjust_inertia(&mut animated, settings.inertia_multiply_type, settings.inertia_factor); + parts.push(SkeletonPart { shape, hat: head_hat, animated, ragdoll, + inverse_mass_animated: 1.0 / animated_mass, + inverse_mass_ragdoll: settings.ragdoll_inverse_mass_factor / ragdoll_mass }); + } + //82BE4F80..4FC4 passes these independent radius/half-length/mass words. + for (radius, half_length, mass) in [ + (0x3E6B_851F, 0x3DCC_CCCD, 0x3DCC_CCCD), + (0x3EB8_51EC, 0x3E85_1EB8, 0x3C23_D70A), + ] { + let mass = f32::from_bits(mass); + let shape = MassShape::Capsule { radius: f32::from_bits(radius), + half_length: f32::from_bits(half_length) }; + let mut properties = mass_properties(shape, None, mass)?; + properties.dynamics.maximum_linear_velocity = f32::from_bits(0x41EF_FFFF); + properties.dynamics.maximum_angular_velocity = f32::from_bits(0x41EF_FFFF); + adjust_inertia(&mut properties, 1, 5.0); + parts.push(SkeletonPart { shape, hat: None, animated: properties, + ragdoll: properties, inverse_mass_animated: 1.0/mass, + inverse_mass_ragdoll: 1.0/mass }); + } + Ok(Self { parts: parts.try_into().map_err(|_| "Skeleton part count")?, + animation_masses: SkeletonAnimationMasses::from_bone_data( + sizes, bones.map(|b| b.volume_type), hat.is_some()), bones }) + } +} + +fn mass_properties(shape: MassShape, hat: Option, mass: f32) + -> Result { + if let Some(hat) = hat { + // Init82BE5428 uses cylinder first, then the head sphere. Preserve + // accumulation order and the later identity mass-frame override. + let mut moments = MassMoments::from_primitive(primitive_mass(MassShape::Cylinder { + radius: hat.radius, half_length: hat.half_length, padding: 0.0 + }).ok_or("Invalid skeleton hat primitive")?); + moments.transform(hat.basis, hat.translation); + moments.add(MassMoments::from_primitive(primitive_mass(shape).ok_or("Invalid head primitive")?)); + let mut properties = aggregate_mass_properties(moments, mass, RETAIL_UNBOUNDED_VELOCITY, 0.0); + properties.local_mass_frame = RetailLocalMassFrame::IDENTITY; + Ok(properties) + } else { + primitive_mass_properties(PartMassInput { shape, requested_mass: mass }, + RETAIL_UNBOUNDED_VELOCITY, 0.0).ok_or("Invalid skeleton primitive") + } +} + +/// DebugMultiplyInertiaTensor82E0B070 / SetSphericalInertiaTensor82E0B110. +fn adjust_inertia(properties: &mut RetailBodyMassProperties, kind: u32, factor: f32) { + let d = &mut properties.dynamics; + if kind == 1 { + let mut reciprocal = native_arithmetic::reciprocal_estimate(factor); + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-reciprocal).mul_add(factor, 1.0), reciprocal); + } + d.inverse_tensor = Vector3::new(d.inverse_tensor.x*reciprocal, + d.inverse_tensor.y*reciprocal, d.inverse_tensor.z*reciprocal); + let xy = if d.inverse_tensor.x < d.inverse_tensor.y {d.inverse_tensor.x} else {d.inverse_tensor.y}; + let xyz = if xy < d.inverse_tensor.z {xy} else {d.inverse_tensor.z}; + d.spherical = 1.0 / xyz; + } else if kind == 2 { + let reciprocal = 1.0 / (factor * 3.0); + let value = ((d.inverse_tensor.x + d.inverse_tensor.y) + d.inverse_tensor.z) * reciprocal; + d.inverse_tensor = Vector3::new(value,value,value); + d.spherical = 1.0 / value; + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/drive_dynamics.rs b/crates/skate-core/src/physics/skeleton_body/drive_dynamics.rs new file mode 100644 index 00000000..8dc62f07 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/drive_dynamics.rs @@ -0,0 +1,134 @@ +//! BoneDrives::EnableDrive82BECCE0 and mode-five interpolation82BECFB8. +//! A bone shares its interpolation counter between the LOCAL and LOCAL_ROOT +//! channels. Dispatch order is therefore observable even within one tick. +use crate::physics::drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}; + +const FREQUENCY: f32 = f32::from_bits(0x426F_FFFF); +const FREQUENCY_SQUARED: f32 = f32::from_bits(0x4560_FFFE); +const HARD_VELOCITY: f32 = f32::from_bits(0x4415_FFFF); +const HARD_STRENGTH: f32 = f32::from_bits(0x470C_9FFF); + +#[derive(Clone, Copy, Debug)] +pub struct AnimationDriveSettings { + pub linear_strength: f32, + pub linear_displacement: f32, + pub angular_strength: f32, + pub angular_displacement: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct DriveInterpolation { + pub spring: [f32; 2], + pub strength: [f32; 2], + pub damping: [f32; 2], +} + +#[derive(Clone, Copy, Debug)] +pub struct BoneDriveSettings { + pub animation: [AnimationDriveSettings; 2], + pub collision_soft_displacement: f32, + pub collision_soft_strength: f32, + pub ragdoll_soft_displacement: f32, + pub ragdoll_soft_strength: f32, + pub transition_linear: DriveInterpolation, + pub transition_angular: DriveInterpolation, + pub transition_calls: f32, +} + +#[derive(Clone, Debug)] +pub struct BoneDriveDynamics { + pub channels: [RetailDriveDynamics; 2], + pub mode: u32, + pub strengths: [f32; 2], + pub transition_counter: f32, + pub transition_active: bool, +} +impl Default for BoneDriveDynamics { + fn default() -> Self { + Self { + channels: [RetailDriveDynamics { + linear: RetailDriveParams::DISABLED, + angular: RetailDriveParams::DISABLED, + }; 2], + mode: 0, strengths: [0.0; 2], transition_counter: 0.0, + transition_active: false, + } + } +} +impl BoneDriveDynamics { + pub fn enable(&mut self, channel: usize, strength: f32, settings: BoneDriveSettings) { + assert!(channel < 2); + if self.mode > 5 { return; } + if self.mode != 5 { self.transition_active = false; } + let animation = settings.animation[channel]; + let dynamics = match self.mode { + 0 => RetailDriveDynamics { + linear: hard(animation.linear_displacement * FREQUENCY, + animation.linear_strength * FREQUENCY_SQUARED), + angular: hard(animation.angular_displacement * FREQUENCY, + animation.angular_strength * FREQUENCY_SQUARED), + }, + 1 => RetailDriveDynamics { + linear: hard(HARD_VELOCITY, HARD_STRENGTH), + angular: hard(HARD_VELOCITY, HARD_STRENGTH), + }, + 2 => RetailDriveDynamics { + linear: hard(0.0, 0.0), + angular: hard(animation.angular_displacement * FREQUENCY, + animation.angular_strength * FREQUENCY_SQUARED), + }, + 3 => RetailDriveDynamics { + linear: soft(0.0, 0.0, 0.0), + angular: soft(settings.collision_soft_displacement * strength, 200.0, + (settings.collision_soft_strength * strength) * FREQUENCY_SQUARED), + }, + 4 => RetailDriveDynamics { + linear: if strength <= 0.5 { soft(0.0, 0.0, 0.0) } else { + soft((settings.ragdoll_soft_displacement * strength) * 0.5, 200.0, + (strength * settings.ragdoll_soft_strength) * FREQUENCY_SQUARED) + }, + angular: soft(settings.ragdoll_soft_displacement * strength, 200.0, + (strength * settings.ragdoll_soft_strength) * FREQUENCY_SQUARED), + }, + 5 => { + if !self.transition_active { + self.transition_active = true; + self.transition_counter = 1.0; + } else if self.transition_counter < settings.transition_calls { + self.transition_counter += 1.0; + } + let progress = self.transition_counter / settings.transition_calls; + RetailDriveDynamics { + linear: interpolate(settings.transition_linear, strength, progress), + angular: interpolate(settings.transition_angular, strength, progress), + } + }, + _ => unreachable!(), + }; + self.channels[channel] = dynamics; + } +} + +fn hard(velocity: f32, strength: f32) -> RetailDriveParams { + RetailDriveParams { spring_or_max_velocity: velocity, damping: 0.0, + max_strength: strength, drive_type: RetailDriveType::HardDrive } +} +fn soft(spring: f32, damping: f32, strength: f32) -> RetailDriveParams { + RetailDriveParams { spring_or_max_velocity: spring, damping, + max_strength: strength, drive_type: RetailDriveType::SoftDrive } +} +fn interpolate(settings: DriveInterpolation, strength: f32, progress: f32) -> RetailDriveParams { + // Original82BED100..114 are scalar fmadds (primary59,extended29). + // The retained older pseudocode expands them to multiply+add expressions. + let spring0 = settings.spring[0] * strength; + let strength0 = settings.strength[0] * strength; + let spring = (settings.spring[1] * strength - spring0).mul_add(progress, spring0); + let force = (settings.strength[1] * strength - strength0).mul_add(progress, strength0); + let damping = (settings.damping[1] - settings.damping[0]).mul_add(progress, settings.damping[0]); + let nonnegative = |value| if value >= 0.0 { value } else { 0.0 }; + RetailDriveParams { + spring_or_max_velocity: nonnegative(spring) * FREQUENCY, + max_strength: nonnegative(force) * FREQUENCY_SQUARED, + damping: nonnegative(damping), drive_type: RetailDriveType::HardDrive, + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/drive_frames.rs b/crates/skate-core/src/physics/skeleton_body/drive_frames.rs new file mode 100644 index 00000000..6b52c06f --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/drive_frames.rs @@ -0,0 +1,42 @@ +//! BoneDrives::SetDriveFrames82BD6FD0. A common animation-space frame is +//! expressed in the child and parent coordinates before quaternion packing. +use crate::{math::{Basis3, Vector3}, physics::{ + drive_frames::{RetailDriveFrame, RetailDriveFrames, retail_quaternion_from_basis}, + drive_preparation::normalize_drive_frames, + rigid_body::RetailQuaternion, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}}; + +pub fn bone_drive_frames(child_animation: &AnimationPartTransform, + inverse_child: &AnimationPartTransform, inverse_parent: &AnimationPartTransform, +) -> RetailDriveFrames { + let mut common = IDENTITY; + common[3] = child_animation[3]; + let child = compose_affine(inverse_child, &common); + let parent = compose_affine(inverse_parent, &common); + RetailDriveFrames { body_a: pack(child), body_b: pack(parent) } +} + +/// Step_Solver2 normalizes each active pair before building the drive rows. +pub fn prepare_bone_drive_frames(frames: RetailDriveFrames) -> RetailDriveFrames { + let mut words = [0;16]; + for (i, frame) in [frames.body_a, frames.body_b].into_iter().enumerate() { + let q = frame.orientation; + let p = frame.translation; + words[i*8..i*8+8].copy_from_slice(&[q.x,q.y,q.z,q.w,p.x,p.y,p.z,0.0].map(f32::to_bits)); + } + normalize_drive_frames(&mut words); + let frame = |offset: usize| { + let f = |i: usize| f32::from_bits(words[offset+i]); + RetailDriveFrame { orientation: RetailQuaternion { x:f(0),y:f(1),z:f(2),w:f(3) }, + translation: Vector3::new(f(4),f(5),f(6)) } + }; + RetailDriveFrames { body_a:frame(0), body_b:frame(8) } +} +fn pack(frame: AnimationPartTransform) -> RetailDriveFrame { + RetailDriveFrame { + orientation: retail_quaternion_from_basis(Basis3 { + columns: std::array::from_fn(|i| [frame[i][0],frame[i][1],frame[i][2]]) }), + translation: Vector3::new(frame[3][0],frame[3][1],frame[3][2]), + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/drives.rs b/crates/skate-core/src/physics/skeleton_body/drives.rs new file mode 100644 index 00000000..d17b47f5 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/drives.rs @@ -0,0 +1,226 @@ +//! Original SkeletonDrives82BEA670/82BEB488: two channels per physical bone +//! and four independent static targets, all solved in the same island. +use super::{ + ANIMATION_PART_COUNT, BoneDriveDynamics, BoneDriveSettings, PART_COUNT, SkeletonTargets, + TARGET_COUNT, bone_drive_frames, prepare_bone_drive_frames, + targets::{HARD_STRENGTH, HARD_VELOCITY, TARGET_PARTS, hard}, +}; +use crate::physics::{ + assembly::BodySnapshot, + board_pose::orthonormalize_part_basis, + drive_frames::RetailDriveFrames, + drive_solver::{RetailDriveBodyState, RetailDriveRows, build_drive_rows}, + rigid_body::{RetailSimulationStep, pack_world_inverse_inertia}, + skeleton_animation_record::{AnimationPartTransform, compose_affine}, + skeleton_root::inverse_rigid, +}; + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonDriveSettings { + pub bone: BoneDriveSettings, + pub enabled: bool, + pub strength: [f32; 2], + /// InitAttribData82BE7860 applies CollisionDriveScalar to each part's + /// LOCAL and LOCAL_ROOT collision strengths before use in Update. + pub collision_strength: [[f32; 2]; ANIMATION_PART_COUNT], +} +pub struct BoneDrives { + pub parent: [usize; 2], + pub active: [bool; 2], + pub frames: [RetailDriveFrames; 2], + pub dynamics: BoneDriveDynamics, +} +pub struct SkeletonDrives { + pub targets: SkeletonTargets, + pub bones: [Option; ANIMATION_PART_COUNT], + pub settings: SkeletonDriveSettings, +} +/// Identity stays parallel to eligible rows. Native m_spy requests force +/// observation; it does not disable a drive or remove it from the solve. +#[derive(Clone, Copy, Debug)] +pub enum SkeletonDriveIdentity { + Target(usize), + Bone { part: usize, channel: usize }, +} +pub struct SkeletonDriveBatch { + pub rows: Vec, + pub identities: Vec, + pub spy: Vec, +} +impl SkeletonDrives { + pub fn new( + initial_bones: &[AnimationPartTransform; ANIMATION_PART_COUNT], + initial_mapped: &[AnimationPartTransform; ANIMATION_PART_COUNT], + parents: &[Option; ANIMATION_PART_COUNT], + animation_to_world: AnimationPartTransform, + spawn: AnimationPartTransform, + simulation: RetailSimulationStep, + settings: SkeletonDriveSettings, + ) -> Result { + if parents.iter().flatten().any(|p| *p >= ANIMATION_PART_COUNT) + || parents[0].is_some() + || parents[23].is_some() + || parents[1..23].iter().any(Option::is_none) + { + return Err("Skeleton drives require22 bone pairs and two roots"); + } + let inverses = initial_bones.map(|m| inverse_rigid(&m)); + let bones = std::array::from_fn(|part| { + parents[part].map(|parent| { + let frames = [parent, 23].map(|p| { + bone_drive_frames(&initial_bones[part], &inverses[part], &inverses[p]) + }); + let mut dynamics = BoneDriveDynamics::default(); + // Init82BED180: LOCAL mode2 followed by LOCAL_ROOT mode0. + dynamics.mode = 2; + dynamics.enable(0, 1.0, settings.bone); + dynamics.mode = 0; + dynamics.enable(1, 1.0, settings.bone); + BoneDrives { + parent: [parent, 23], + active: [true, true], + frames, + dynamics, + } + }) + }); + let mut targets = SkeletonTargets::new(simulation); + let frame = initial_mapped[23]; + let normalized = + orthonormalize_part_basis(std::array::from_fn(|i| frame[i / 4][i % 4].to_bits())); + let hips = + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(normalized[i * 4 + j]))); + targets.reset(compose_affine(&animation_to_world, &hips), spawn); + Ok(Self { + targets, + bones, + settings, + }) + } + + ///82BEB488 consumes the24 IK-adjusted frames in animation coordinates. + /// Collision weight comes from the actual SkeletonCollision state owner. + pub fn update( + &mut self, + pose: &[AnimationPartTransform; ANIMATION_PART_COUNT], + partial: bool, + collision_weight: f32, + ) { + let inverses = pose.map(|frame| inverse_rigid(&frame)); + let settings = self.settings; + let base = if settings.enabled { + settings.strength + } else { + [0.0; 2] + }; + self.targets.dynamics[0].linear = hard(HARD_VELOCITY, HARD_STRENGTH); + for part in 1..23 { + let Some(bone) = self.bones[part].as_mut() else { + continue; + }; + let selected = !partial || matches!(part,1..=5|7..=9); + let strengths = if !(collision_weight >= 1.0) && selected { + bone.dynamics.mode = 3; + std::array::from_fn(|channel| { + let c = settings.collision_strength[part][channel]; + //82BEB6F0/F4 are fmadds, followed by scalar fmuls. + (1.0 - c).mul_add(collision_weight, c) * settings.strength[channel] + }) + } else { + bone.dynamics.mode = 0; + base + }; + bone.dynamics.strengths = strengths; + for channel in 0..2 { + if !bone.active[channel] { + continue; + } + bone.frames[channel] = bone_drive_frames( + &pose[part], + &inverses[part], + &inverses[bone.parent[channel]], + ); + bone.dynamics + .enable(channel, strengths[channel], settings.bone); + } + } + } + + /// Native order: four targets, then LOCAL and LOCAL_ROOT for parts1..22. + /// The caller appends rows to the board/contact/joint solve before any + /// integration and borrows targets.bodies directly into that same step. + pub fn build( + &mut self, + bodies: &[BodySnapshot; PART_COUNT], + reaction_base: usize, + target_reaction_base: usize, + time_step: f32, + ) -> SkeletonDriveBatch { + let mut batch = SkeletonDriveBatch { + rows: Vec::with_capacity(48), + identities: Vec::with_capacity(48), + spy: Vec::with_capacity(48), + }; + for target in 0..TARGET_COUNT { + self.targets.frames[target] = prepare_bone_drive_frames(self.targets.frames[target]); + let part = TARGET_PARTS[target]; + let a = bodies[part]; + let b = self.targets.bodies[target]; + if (a.state_flags | b.state_flags) & 4 != 0 { + batch.rows.push(build_drive_rows( + drive_body(a, reaction_base + part), + drive_body(b, target_reaction_base + target), + self.targets.frames[target], + self.targets.dynamics[target], + time_step, + )); + batch.identities.push(SkeletonDriveIdentity::Target(target)); + batch.spy.push(target >= 2); + } + } + for part in 1..23 { + let Some(bone) = self.bones[part].as_mut() else { + continue; + }; + for channel in 0..2 { + if !bone.active[channel] { + continue; + } + bone.frames[channel] = prepare_bone_drive_frames(bone.frames[channel]); + let parent = bone.parent[channel]; + let a = bodies[part]; + let b = bodies[parent]; + if (a.state_flags | b.state_flags) & 4 != 0 { + batch.rows.push(build_drive_rows( + drive_body(a, reaction_base + part), + drive_body(b, reaction_base + parent), + bone.frames[channel], + bone.dynamics.channels[channel], + time_step, + )); + batch + .identities + .push(SkeletonDriveIdentity::Bone { part, channel }); + batch.spy.push(true); + } + } + } + batch + } +} +fn drive_body(body: BodySnapshot, reaction_index: usize) -> RetailDriveBodyState { + let rates = body.rates; + RetailDriveBodyState { + reaction_index, + state: body.state_flags, + orientation: rates.orientation, + basis: rates.basis, + center_of_mass: rates.position, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/errors.rs b/crates/skate-core/src/physics/skeleton_body/errors.rs new file mode 100644 index 00000000..1d205389 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/errors.rs @@ -0,0 +1,97 @@ +//! Original physical pose errors82BEBEA8 and normal collision response82BE2438. +use super::{ + ExtraTargetPositions, SkeletonCollisionFeedback, SkeletonPhysicalRecord, collision_feedback::V, + collision_vector::*, +}; +use crate::physics::skeleton_animation_record::AnimationPartTransform; + +#[derive(Clone, Debug, Default)] +pub struct SkeletonPoseErrors { + /// SkeletonState4752, one vector per animated part. + pub parts: [V; 24], + /// SkeletonState5136/5152: the two additional physical target errors. + pub extra: [V; 2], + /// SkeletonState5168/5184/5200, written by UpdateExtraTargets. + pub targets: [V; 3], +} +#[derive(Clone, Copy, Debug)] +pub struct SkeletonNormalError { + /// Skeleton16272: physical error constrained by actual contact planes. + pub impulse: V, + /// Normal82BE2438 clears Skeleton16288 and16304. + pub extra: [V; 2], + /// Skeleton16384, length before contact-plane filtering. + pub maximum_error: f32, +} +impl SkeletonPoseErrors { + ///82BEBBB0 clears only target histories. The next82BEBEA8 replaces errors. + pub fn reset_history(&mut self) { + self.targets = [[0.0; 4]; 3]; + } + pub fn set_targets(&mut self, positions: ExtraTargetPositions) { + self.targets = [positions.com, positions.lifted_com, positions.following_com]; + } + ///82BEBEA8. These are actual part-frame errors, not COM or force proxies. + pub fn update( + &mut self, + physical: &SkeletonPhysicalRecord, + animation_to_world: AnimationPartTransform, + drive_frames: &[AnimationPartTransform; 24], + ) { + self.parts[0] = [0.0; 4]; + for part in 1..24 { + self.parts[part] = sub( + physical.pose[part][3], + transform(animation_to_world, drive_frames[part][3]), + ); + } + for i in 0..2 { + self.extra[i] = sub(physical.pose[24 + i][3], self.targets[1 + i]); + } + } + ///82BE2438. Strict comparison and the first raw-pair tie behavior differ + /// from the filtered-pair tie; preserve both selection sequences. + pub fn normal_response( + &self, + collision: &SkeletonCollisionFeedback, + support_normal: V, + ) -> SkeletonNormalError { + let candidates = [23, 17, 18, 16, 21, 22, 20, 1].map(|part| self.parts[part]); + let filtered = + candidates.map(|candidate| collision.filter_error(candidate, support_normal)); + let mut impulse = filtered[0]; + for candidate in &filtered[1..] { + if dot(*candidate, *candidate) > dot(impulse, impulse) { + impulse = *candidate; + } + } + SkeletonNormalError { + impulse, + extra: [[0.0; 4]; 2], + maximum_error: self.maximum_error(), + } + } + ///82BD9FB0 partial-ragdoll branch copies the two extra errors directly, + /// then82BE2310 computes the same raw eight-part maximum length. + pub fn partial_response(&self) -> SkeletonNormalError { + SkeletonNormalError { + impulse: self.extra[0], + extra: self.extra, + maximum_error: self.maximum_error(), + } + } + fn maximum_error(&self) -> f32 { + let candidates = [23, 17, 18, 16, 21, 22, 20, 1].map(|part| self.parts[part]); + let mut maximum = if dot(candidates[0], candidates[0]) > dot(candidates[1], candidates[1]) { + candidates[0] + } else { + candidates[1] + }; + for candidate in &candidates[2..] { + if dot(*candidate, *candidate) > dot(maximum, maximum) { + maximum = *candidate; + } + } + length(maximum) + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/joints.rs b/crates/skate-core/src/physics/skeleton_body/joints.rs new file mode 100644 index 00000000..8d2c5699 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/joints.rs @@ -0,0 +1,156 @@ +//! SkeletonBody::InitJoints82BE5B30 and SetUpNormalJointLimits82BE58A8. +//! Frames attach the child to its nearest physical ancestor; the initial +//! animation bone poses and authored volume frames are distinct inputs. +use super::{ANIMATION_PART_COUNT, JOINT_COUNT, PART_COUNT}; +use crate::{math::Basis3, trigonometry, physics::{ + assembly::BodySnapshot, + constraint_batch::compile_active, + drive_frames::retail_quaternion_from_basis, + joint_builder::{RetailJointBodyInput, RetailJointBuildInput, build_retail_joint_jacobian}, + joint_records::{RetailJointFramesRaw, RetailJointParametersRaw}, + native_arithmetic, + rigid_body::pack_world_inverse_inertia, + skeleton_animation_record::{AnimationPartTransform, compose_affine, physics_bone_frame}, + skeleton_root::inverse_rigid, + solver::JointConstraint, +}}; + +#[derive(Clone, Copy, Debug)] +pub struct JointBone { + pub parent_orientation: [f32; 4], + pub joint_orientation: [f32; 4], + pub volume_frame: AnimationPartTransform, + pub swing_limit: f32, + pub twist_limit: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct JointSettings { + pub ball_joint: bool, + pub swing_angle: f32, + pub twist_angle: f32, + pub swing_ragdoll: f32, + pub twist_ragdoll: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonJointSettings { + pub displacement_limit: [f32; 4], + pub twist_displacement_limit: f32, + pub swing_displacement_limit: f32, + pub enforce_swing_free: bool, + pub enforce_twist_free: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonJoint { + pub parent: usize, + pub child: usize, + pub parameters: RetailJointParametersRaw, + pub frames: RetailJointFramesRaw, +} + +pub struct SkeletonJoints { + pub records: [SkeletonJoint; JOINT_COUNT], +} +impl SkeletonJoints { + pub fn new( + initial_bones: &[AnimationPartTransform; ANIMATION_PART_COUNT], + parents: &[Option; ANIMATION_PART_COUNT], + bones: &[JointBone; ANIMATION_PART_COUNT], + settings: &[JointSettings; JOINT_COUNT], + global: SkeletonJointSettings, + ) -> Result { + let mut records = Vec::with_capacity(JOINT_COUNT); + for child in 0..ANIMATION_PART_COUNT { + let Some(parent) = parents[child] else { continue }; + if parent >= ANIMATION_PART_COUNT || parent == child { + return Err("Invalid physical skeleton joint parent"); + } + let setting = *settings.get(records.len()).ok_or("Too many physical skeleton joints")?; + let bone = bones[child]; + let inverse_child_volume = inverse_rigid(&bone.volume_frame); + let inverse_parent_volume = inverse_rigid(&bones[parent].volume_frame); + let parent_orientation = physics_bone_frame(bone.parent_orientation, [0.0; 4]); + let joint_orientation = physics_bone_frame(bone.joint_orientation, [0.0; 4]); + let parent_joint = compose_affine(&inverse_parent_volume, + &inverse_rigid(&parent_orientation)); + let relative_bones = compose_affine(&inverse_rigid(&initial_bones[parent]), + &initial_bones[child]); + let parent_anchor = compose_affine(&inverse_parent_volume, &relative_bones)[3]; + let child_frame = compose_affine(&inverse_child_volume, &joint_orientation); + let parent_frame = compose_affine(&parent_joint, &joint_orientation); + let mut frames = RetailJointFramesRaw { words: [0; 20] }; + pack_frame(&mut frames.words[..8], child_frame, inverse_child_volume[3]); + pack_frame(&mut frames.words[8..16], parent_frame, parent_anchor); + // InitJoints passes the identity matrix to SetLinearFrame82BD3858. + frames.words[19] = 1.0f32.to_bits(); + + let mut parameters = RetailJointParametersRaw { words: [0; 16] }; + let frequency = inverse_native_step(); + parameters.words[4..8].copy_from_slice( + &global.displacement_limit.map(|v| (v * frequency).to_bits())); + let scalar_frequency = f32::from_bits(0x426F_FFFF); + parameters.words[8] = (global.twist_displacement_limit * scalar_frequency).to_bits(); + parameters.words[9] = (global.swing_displacement_limit * scalar_frequency).to_bits(); + parameters.words[14] = if setting.ball_joint || global.enforce_swing_free { 4 } else { 1 }; + parameters.words[15] = if setting.ball_joint || global.enforce_twist_free { 2 } else { 1 }; + // SetUpNormal runs after InitJoints and writes limits even for a + // free/ball joint. It does not replace the mode selectors. + let swing = minimum_angle(bone.swing_limit * setting.swing_angle); + let twist = minimum_angle(bone.twist_limit * setting.twist_angle); + parameters.words[10..14].copy_from_slice( + &[swing, twist, trigonometry::cos(swing), trigonometry::cos(twist)].map(f32::to_bits)); + records.push(SkeletonJoint { parent, child, parameters, frames }); + } + Ok(Self { records: records.try_into().map_err(|_| "Physical skeleton requires22 joints")? }) + } + + /// Append these to the same constraint pass as board contacts/joints/drives. + /// Reaction indices are supplied by the unified simulation owner. + pub fn build(&self, bodies: &[BodySnapshot; PART_COUNT], + reaction_base: usize, time_step: f32) -> Vec { + compile_active(&self.records, + |record| [bodies[record.child].state_flags, bodies[record.parent].state_flags], + |record| JointConstraint { + jacobian: build_retail_joint_jacobian(RetailJointBuildInput { + parameters: record.parameters, frames: record.frames, + body_a: joint_body(bodies[record.child]), + body_b: joint_body(bodies[record.parent]), + time_step, joint_guest_address: 0, + }), + reaction_a: reaction_base + record.child, + reaction_b: reaction_base + record.parent, + }) + } +} + +fn inverse_native_step() -> f32 { + let step = f32::from_bits(0x3C88_8889); //820849C8 + let mut reciprocal = native_arithmetic::reciprocal_estimate(step); + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-reciprocal).mul_add(step, 1.0), reciprocal); + } + reciprocal +} +fn minimum_angle(value: f32) -> f32 { + let minimum = f32::from_bits(0x3C23_D70A); + if value - minimum >= 0.0 { value } else { minimum } +} +fn pack_frame(words: &mut [u32], frame: AnimationPartTransform, anchor: [f32; 4]) { + let q = retail_quaternion_from_basis(Basis3 { + columns: std::array::from_fn(|i| [frame[i][0],frame[i][1],frame[i][2]]) }); + words[..4].copy_from_slice(&[q.x,q.y,q.z,q.w].map(f32::to_bits)); + words[4..8].copy_from_slice(&anchor.map(f32::to_bits)); +} +fn joint_body(body: BodySnapshot) -> RetailJointBodyInput { + let rates = body.rates; + RetailJointBodyInput { + reaction_guest_address: 0, state: body.state_flags, + orientation: rates.orientation, center_of_mass: rates.position, + basis: rates.basis, linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/mod.rs b/crates/skate-core/src/physics/skeleton_body/mod.rs new file mode 100644 index 00000000..dec178af --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/mod.rs @@ -0,0 +1,38 @@ +//! Physical skater assembly and its postphysics observations. +//! The body has 26 parts; animation and COM weighting use its first 24. +mod record; +pub use record::SkeletonPhysicalRecord; +mod construction; +pub use construction::{BoneSettings, HatGeometry, SkeletonBodyDefinition, SkeletonBodySettings, SkeletonPart}; +mod runtime; +pub use runtime::SkeletonBody; +mod joints; +pub use joints::{JointBone, JointSettings, SkeletonJointSettings, SkeletonJoint, SkeletonJoints}; +mod drive_dynamics; +pub use drive_dynamics::{AnimationDriveSettings, DriveInterpolation, BoneDriveSettings, BoneDriveDynamics}; +mod drive_frames; +pub use drive_frames::{bone_drive_frames, prepare_bone_drive_frames}; +mod targets; +pub use targets::{SkeletonTargets, TARGET_COUNT}; +mod target_update; +pub use target_update::{SkeletonTargetInput, SkeletonTargetUpdate, ExtraTargetPositions}; +mod drives; +pub use drives::{SkeletonDriveSettings, BoneDrives, SkeletonDrives, SkeletonDriveIdentity, SkeletonDriveBatch}; +mod collision_mode; +pub use collision_mode::{SkeletonCollisionSettings, SkeletonPartCollision, SkeletonCollisionMode}; +mod collision_feedback; +mod collision_update; +mod collision_vector; +mod collision_filter; +pub use collision_feedback::{SkeletonFeedbackSettings, SkeletonContactBody, + SkeletonContactReport, SkeletonCollisionInput, BoneContact, ContactRegion, + ContactPlane, SpecificContact, SkeletonContactFlags, SkeletonCollisionFeedback}; +mod errors; +pub use errors::{SkeletonPoseErrors, SkeletonNormalError}; + +pub const PART_COUNT: usize = 26; +pub const ANIMATION_PART_COUNT: usize = 24; +pub const JOINT_COUNT: usize = 22; +#[cfg(test)] +#[path = "tests/collision_feedback.rs"] +mod collision_feedback_tests; diff --git a/crates/skate-core/src/physics/skeleton_body/record.rs b/crates/skate-core/src/physics/skeleton_body/record.rs new file mode 100644 index 00000000..4df18454 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/record.rs @@ -0,0 +1,68 @@ +//! SkeletonState's physical record: TU3 82BEBD28 and 82BEC1E8. +use super::{ANIMATION_PART_COUNT, PART_COUNT}; +use crate::physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}; + +#[derive(Clone, Debug)] +pub struct SkeletonPhysicalRecord { + /// SkeletonState1552: actual part frames, including the mass-frame inverse. + pub pose: [AnimationPartTransform; PART_COUNT], + /// SkeletonState3216: previous positions used by the fixed-step derivative. + pub positions: [[f32; 4]; PART_COUNT], + /// SkeletonState3632: pose-derived velocities, not rigid body rates. + pub velocities: [[f32; 4]; PART_COUNT], + /// SkeletonState4048: difference from the preceding derived velocity. + pub velocity_changes: [[f32; 4]; PART_COUNT], + /// SkeletonState4464/4480; only the first 24 weighted parts contribute. + pub centre_of_mass: [f32; 4], + pub centre_of_mass_velocity: [f32; 4], + pub timestep: f32, +} +impl Default for SkeletonPhysicalRecord { + fn default() -> Self { + // Constructor82BEB8C8 initializes all26 physical transforms and history. + Self { pose: [IDENTITY; PART_COUNT], positions: [[0.0; 4]; PART_COUNT], + velocities: [[0.0; 4]; PART_COUNT], velocity_changes: [[0.0; 4]; PART_COUNT], + centre_of_mass: [0.0; 4], centre_of_mass_velocity: [0.0; 4], timestep: 0.0 } + } +} +impl SkeletonPhysicalRecord { + /// Physical subset of Reset82BEBBB0. The native reset preserves physical + /// COM, velocity changes and timestep; other animation/error owners reset + /// their own fields at this same phase. + pub fn reset(&mut self, parts: &[AnimationPartTransform; PART_COUNT]) { + self.pose = *parts; + for (i, part) in parts.iter().enumerate() { + self.positions[i] = part[3]; + self.velocities[i] = [0.0; 4]; + } + } + + /// The caller supplies the actual GetPartTransform results. Native part0 + /// uses the explicit board-frame argument; all other parts come from the + /// skeleton assembly. Keep this distinction when the board drives a proxy. + pub fn update(&mut self, parts: &[AnimationPartTransform; PART_COUNT], + board_frame: AnimationPartTransform, fractional: &[f32; ANIMATION_PART_COUNT]) { + self.timestep = f32::from_bits(0x3C88_8889); + for (i, part) in parts.iter().enumerate() { + let transform = if i == 0 { board_frame } else { *part }; + self.pose[i] = transform; + for lane in 0..4 { + let velocity = (transform[3][lane] - self.positions[i][lane]) + * f32::from_bits(0x426F_FFFF); + self.velocity_changes[i][lane] = velocity - self.velocities[i][lane]; + self.velocities[i][lane] = velocity; + } + self.positions[i] = transform[3]; + } + let mut position = [0.0f32; 4]; + let mut velocity = [0.0f32; 4]; + for (i, weight) in fractional.iter().enumerate() { + for lane in 0..4 { + position[lane] = self.pose[i][3][lane].mul_add(*weight, position[lane]); + velocity[lane] = self.velocities[i][lane].mul_add(*weight, velocity[lane]); + } + } + self.centre_of_mass = position; + self.centre_of_mass_velocity = velocity; + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/runtime.rs b/crates/skate-core/src/physics/skeleton_body/runtime.rs new file mode 100644 index 00000000..bc212f6e --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/runtime.rs @@ -0,0 +1,142 @@ +//! Actual 26-part ownership and Reset82BD9990 / physical-record publication. +use super::{SkeletonBodyDefinition, SkeletonPhysicalRecord, PART_COUNT, ANIMATION_PART_COUNT}; +use crate::physics::{ + assembly::BodySnapshot, + board_pose::{PartPose, PoseMatrix, part_transform, set_part_transform, orthonormalize_part_basis}, + board_runtime::{BoardMotion, initialized_body}, + rigid_body::{RetailBodyRates, RetailInertiaDynamics, RetailLocalMassFrame, RetailSimulationStep}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, + skeleton_root::inverse_rigid, +}; + +pub struct SkeletonBody { + pub definition: SkeletonBodyDefinition, + pub record: SkeletonPhysicalRecord, + pub animation_to_world: AnimationPartTransform, + bodies: [BodySnapshot; PART_COUNT], +} +impl SkeletonBody { + /// `authored` is SkeletonData's initialization pose after forward physical + /// bone-frame mapping. The actor's native initialization pose producer must + /// supply it; the current graph's first frame is a separate publication. + pub fn new(definition: SkeletonBodyDefinition, + authored: &[AnimationPartTransform; ANIMATION_PART_COUNT], + spawn: AnimationPartTransform, simulation: RetailSimulationStep) -> Self { + // GetPartFrame82BD9878 normalizes the mapped animation frame before + // Reset composes the world alignment. PartSetTransform normalizes + // again after applying the local mass frame; the two stages differ. + let authored = authored.map(|frame| floats(orthonormalize_part_basis(words(frame)))); + let animation_to_world = compose_affine(&spawn, &inverse_rigid(&authored[0])); + let bodies = std::array::from_fn(|i| { + let desired = if i < ANIMATION_PART_COUNT { + compose_affine(&animation_to_world, &authored[i]) + } else { spawn }; + let mass = definition.parts[i].animated; + let mut inertia = mass.dynamics; + // SetUpNormal82BE7280 loads the independently cached inverse mass + // for the 23 skater bones. Root/extra bodies retain their setup. + if (1..ANIMATION_PART_COUNT).contains(&i) { + inertia.inverse_mass = definition.parts[i].inverse_mass_animated; + } + let mut part = PartPose { transform: words(desired), + local_mass_frame: Some(mass_words(mass.local_mass_frame)), + body: Some([0;44]), inertia: Some(inertia_words(inertia)) }; + set_part_transform(&mut part, words(desired)); + initialized_body(&part, inertia, simulation, BoardMotion::Active) + }); + let mut result = Self { definition, bodies, animation_to_world, + record: SkeletonPhysicalRecord::default() }; + // SkeletonInit82BD8010..804C makes two real physical observations. + // The second eliminates the constructor's zero-position derivative. + let pose = result.part_transforms(); + result.record.update(&pose, pose[0], &result.definition.animation_masses.fractional); + result.record.update(&pose, pose[0], &result.definition.animation_masses.fractional); + result + } + pub fn bodies(&self) -> &[BodySnapshot; PART_COUNT] { &self.bodies } + /// The shared solver owns these same states; do not maintain a second + /// kinematic pose array beside the states receiving its corrections. + pub fn bodies_mut(&mut self) -> &mut [BodySnapshot; PART_COUNT] { &mut self.bodies } + /// Skeleton::SetPartTransform82BE2A38 updates the live part through + ///82BD4318 and then retains the ORIGINAL requested physical-record frame. + ///Only pose and its inertia cache change; solved velocities survive. + pub fn set_part_transform(&mut self, part: usize, frame: AnimationPartTransform) { + use crate::{math::{Basis3, Vector3}, physics::rigid_body::RetailQuaternion}; + let body = &mut self.bodies[part]; + let mut pose = PartPose { + transform: words(frame), + local_mass_frame: Some(mass_words(self.definition.parts[part].animated.local_mass_frame)), + body: Some(body_words(body.rates)), + inertia: Some(inertia_words(body.inertia)), + }; + set_part_transform(&mut pose, words(frame)); + let packed = pose.body.unwrap(); + let f = |i| f32::from_bits(packed[i]); + body.rates.orientation = RetailQuaternion { x:f(0), y:f(1), z:f(2), w:f(3) }; + body.rates.position = Vector3::new(f(4),f(5),f(6)); + body.rates.basis = Basis3 { columns: [ + [f(16),f(17),f(18)], [f(20),f(21),f(22)], [f(24),f(25),f(26)]] }; + body.rates.world_inverse_inertia = Basis3 { columns: [ + [f(28),f(29),f(30)], [f(29),f(33),f(34)], [f(30),f(34),f(32)]] }; + self.record.pose[part] = frame; + } + pub fn part_transforms(&self) -> [AnimationPartTransform; PART_COUNT] { + std::array::from_fn(|i| { + let part = PartPose { transform: words(IDENTITY), + local_mass_frame: Some(mass_words(self.definition.parts[i].animated.local_mass_frame)), + body: Some(body_words(self.bodies[i].rates)), inertia: None }; + floats(part_transform(&part)) + }) + } + pub fn publish_physical_record(&mut self, board: AnimationPartTransform) { + let pose = self.part_transforms(); + self.record.update(&pose, board, &self.definition.animation_masses.fractional); + } + /// ApplyDisplacementToPart82BE7788. Despite its name this writes the + /// rigid body's force accumulator and clears cooldown; it is not a pose + /// adjustment. GeneralUpdate calls it for all24 animation parts. + pub fn apply_part_displacement(&mut self, part: usize, displacement: [f32;4]) { + use crate::physics::native_arithmetic; + let step = f32::from_bits(0x3C88_8889); + let mut inverse_step = native_arithmetic::reciprocal_estimate(step); + for _ in 0..2 { + inverse_step = inverse_step.mul_add((-inverse_step).mul_add(step, 1.0), inverse_step); + } + let body = &mut self.bodies[part]; + let value = displacement.map(|d| (d * inverse_step) * body.inertia.inverse_mass); + body.rates.force_acceleration.x += value[0]; + body.rates.force_acceleration.y += value[1]; + body.rates.force_acceleration.z += value[2]; + body.rates.cool_down = 0; + } +} + +fn words(frame: AnimationPartTransform) -> PoseMatrix { + std::array::from_fn(|i| frame[i/4][i%4].to_bits()) +} +fn floats(frame: PoseMatrix) -> AnimationPartTransform { + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(frame[i*4+j]))) +} +fn mass_words(frame: RetailLocalMassFrame) -> PoseMatrix { + let mut m = IDENTITY; + for (column, axis) in frame.basis.columns.iter().enumerate() { + m[column][..3].copy_from_slice(axis); + } + m[3] = [frame.translation.x,frame.translation.y,frame.translation.z,0.0]; + words(m) +} +fn inertia_words(d: RetailInertiaDynamics) -> [u32;10] { + [d.inverse_tensor.x,d.inverse_tensor.y,d.inverse_tensor.z,0.0,d.inverse_mass, + d.spherical,d.maximum_linear_velocity,d.maximum_angular_velocity, + d.linear_drag,d.angular_drag].map(f32::to_bits) +} +fn body_words(rates: RetailBodyRates) -> [u32;44] { + let mut w = [0;44]; + let q = rates.orientation; + w[..4].copy_from_slice(&[q.x,q.y,q.z,q.w].map(f32::to_bits)); + w[4..7].copy_from_slice(&[rates.position.x,rates.position.y,rates.position.z].map(f32::to_bits)); + for (i,axis) in rates.basis.columns.iter().enumerate() { + w[16+i*4..19+i*4].copy_from_slice(&axis.map(f32::to_bits)); + } + w +} diff --git a/crates/skate-core/src/physics/skeleton_body/target_update.rs b/crates/skate-core/src/physics/skeleton_body/target_update.rs new file mode 100644 index 00000000..48cbf87b --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/target_update.rs @@ -0,0 +1,105 @@ +//! Original Skeleton::UpdateHookPositions82BE1618 and extra followers82BE18B8. +//! Their target changes precede the returned teleport/large-displacement gate. +use super::{SkeletonBody, SkeletonTargets}; +use crate::{ + math::Vector3, + physics::{ + board_pose::orthonormalize_rotation, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + }, + riding::ground_orientation::clamp_length, +}; + +pub struct SkeletonTargetInput<'a> { + ///Skeleton11792, mapped physical animation hips, not physical COM. + pub animation_hips: &'a Transform, + ///Skeleton11728, mapped physical animation board. + pub animation_board: &'a Transform, + ///Skeleton11920. + pub animation_to_world: &'a Transform, + ///Skeleton12368, inverse of the actual physical board. + pub inverse_board: &'a Transform, + ///Skeleton15760, published by the current movement branch. + pub skate_root: &'a Transform, + ///Skeleton15824 and15888 from the actual COM/lift producer. + pub com_frame: &'a Transform, + pub lifted_com_frame: &'a Transform, + ///Skeleton16508. + pub teleporting: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct ExtraTargetPositions { + ///Skeleton11632,11648,11664 respectively. + pub com: [f32; 4], + pub lifted_com: [f32; 4], + pub following_com: [f32; 4], +} +pub struct SkeletonTargetUpdate { + ///Skeleton12240, inverse physical board * animated board in world space. + pub animation_board_to_physics: Transform, + pub positions: ExtraTargetPositions, + ///False triggers GeneralUpdate's real teleport/reset behavior after all + ///target and velocity writes, not a guard suppressing those writes. + pub continuous: bool, +} + +impl SkeletonTargets { + pub fn update_positions( + &mut self, + input: SkeletonTargetInput<'_>, + skeleton: &mut SkeletonBody, + ) -> SkeletonTargetUpdate { + let hips = compose_affine(input.animation_to_world, input.animation_hips); + let board = compose_affine(input.animation_to_world, input.animation_board); + let animation_board_to_physics = compose_affine(input.inverse_board, &board); + let previous = self.transform(0)[3]; + let change = std::array::from_fn(|i| previous[i] - hips[3][i]); + self.set_transform(0, orthonormalize(hips)); + self.set_transform(1, orthonormalize(*input.skate_root)); + let positions = + self.update_extra_targets(skeleton, input.com_frame, input.lifted_com_frame); + //82BE18A0 checks strictly greater; unordered distance alone does not + //force this returnfalse. Preserve the independent teleport byte test. + let continuous = !input.teleporting && !(dot3(change, change) > 1.0); + SkeletonTargetUpdate { + animation_board_to_physics, + positions, + continuous, + } + } + + ///Complete82BE18B8, also called by Reset82BD9990 after constructing its + ///COM frames. Replaces only physical extra-body velocities, never forces. + pub fn update_extra_targets( + &mut self, + skeleton: &mut SkeletonBody, + com_frame: &Transform, + lifted_com_frame: &Transform, + ) -> ExtraTargetPositions { + self.set_transform(2, *lifted_com_frame); + self.set_transform(3, *com_frame); + let positions = ExtraTargetPositions { + com: com_frame[3], + lifted_com: lifted_com_frame[3], + following_com: com_frame[3], + }; + //GetPartTransform includes the actual local mass frame. Keep that + //source even though these centered extra capsules have identity frames. + let parts = skeleton.part_transforms(); + for (part, target) in [(24, lifted_com_frame[3]), (25, com_frame[3])] { + let current = parts[part][3]; + let velocity = Vector3::new( + (target[0] - current[0]) * 60.0, + (target[1] - current[1]) * 60.0, + (target[2] - current[2]) * 60.0, + ); + skeleton.bodies_mut()[part].rates.linear_velocity = clamp_length(velocity, 40.0); + } + positions + } +} +fn orthonormalize(frame: Transform) -> Transform { + let words = orthonormalize_rotation(std::array::from_fn(|i| frame[i / 4][i % 4].to_bits())); + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(words[i * 4 + j]))) +} diff --git a/crates/skate-core/src/physics/skeleton_body/targets.rs b/crates/skate-core/src/physics/skeleton_body/targets.rs new file mode 100644 index 00000000..f1683132 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/targets.rs @@ -0,0 +1,154 @@ +//! Original TU3 SkeletonDrives::InitHooks82BEAB70 creates four static targets. +//! These physical constraint endpoints contain no runtime interception code. +use crate::{ + math::{Basis3, Vector3}, + physics::{ + assembly::BodySnapshot, + board_pose::{PartPose, orthonormalize_rotation, set_part_transform}, + board_runtime::{BoardMotion, initialized_body}, + drive_frames::{RetailDriveFrame, RetailDriveFrames, retail_quaternion_from_basis}, + drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}, + rigid_body::{RetailInertiaDynamics, RetailQuaternion, RetailSimulationStep}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + }, +}; + +pub const TARGET_COUNT: usize = 4; +pub const TARGET_PARTS: [usize; TARGET_COUNT] = [23, 0, 24, 25]; +pub(super) const HARD_VELOCITY: f32 = f32::from_bits(0x4415_FFFF); +pub(super) const HARD_STRENGTH: f32 = f32::from_bits(0x470C_9FFF); + +pub struct SkeletonTargets { + pub bodies: [BodySnapshot; TARGET_COUNT], + pub frames: [RetailDriveFrames; TARGET_COUNT], + pub dynamics: [RetailDriveDynamics; TARGET_COUNT], +} +impl SkeletonTargets { + pub fn new(simulation: RetailSimulationStep) -> Self { + let bodies = std::array::from_fn(|_| { + let mut part = PartPose { + transform: words(IDENTITY), + local_mass_frame: Some(words(IDENTITY)), + body: Some([0; 44]), + inertia: None, + }; + set_part_transform(&mut part, words(IDENTITY)); + // Original82BEAED4 inserts STATIC_BODY(1), giving zero inverse + // mass/tensor/rates. The centered.1m box's authored100kg and its + // disabled collision volume never participate in that response. + initialized_body(&part, STATIC_INERTIA, simulation, BoardMotion::Static) + }); + let identity = RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }; + //82BEAD34..ADC8: X=(0,1,0),Y=(0,0,1),Z=(1,0,0). + let extra = RetailDriveFrame { + orientation: retail_quaternion_from_basis(Basis3 { + columns: [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]], + }), + ..identity + }; + let frames = std::array::from_fn(|i| RetailDriveFrames { + body_a: if i >= 2 { extra } else { identity }, + body_b: identity, + }); + let strong = hard(HARD_VELOCITY, HARD_STRENGTH); + let root = hard(f32::from_bits(0x426F_FFFF), HARD_STRENGTH); + Self { + bodies, + frames, + dynamics: [ + RetailDriveDynamics { + linear: strong, + angular: strong, + }, + RetailDriveDynamics { + linear: root, + angular: root, + }, + RetailDriveDynamics { + linear: hard(0.0, 0.0), + angular: strong, + }, + RetailDriveDynamics { + linear: hard(0.0, 0.0), + angular: strong, + }, + ], + } + } + /// Reset82BD9DB8/9DE0 normalizes these two desired matrices before + /// PartSetTransform. The other two target parts retain their pose. + pub fn reset(&mut self, hips: AnimationPartTransform, spawn: AnimationPartTransform) { + for (index, frame) in [hips, spawn].into_iter().enumerate() { + self.set_transform(index, floats(orthonormalize_rotation(words(frame)))); + } + } + /// Native PartSetTransform preserves velocity and force state while + /// changing the body transform. Its local mass frame is identity here. + pub fn set_transform(&mut self, index: usize, frame: AnimationPartTransform) { + let body = &mut self.bodies[index]; + let mut part = PartPose { + transform: words(frame), + local_mass_frame: Some(words(IDENTITY)), + body: Some([0; 44]), + inertia: None, + }; + set_part_transform(&mut part, words(frame)); + let w = part.body.expect("Target has an actual rigid body"); + let f = |i| f32::from_bits(w[i]); + body.rates.orientation = RetailQuaternion { + x: f(0), + y: f(1), + z: f(2), + w: f(3), + }; + body.rates.position = Vector3::new(f(4), f(5), f(6)); + body.rates.basis = Basis3 { + columns: std::array::from_fn(|i| [f(16 + i * 4), f(17 + i * 4), f(18 + i * 4)]), + }; + } + pub fn transform(&self, index: usize) -> AnimationPartTransform { + let body = self.bodies[index]; + let mut m = IDENTITY; + for i in 0..3 { + m[i][..3].copy_from_slice(&body.rates.basis.columns[i]); + } + let p = body.rates.position; + m[3] = [p.x, p.y, p.z, 0.0]; + m + } + /// Ground::PredictFutureDeck82D38630 moves target0, not physical hips. + pub fn apply_future_deck_displacement(&mut self, displacement: Vector3) { + let mut frame = self.transform(0); + frame[3][0] += displacement.x; + frame[3][1] += displacement.y; + frame[3][2] += displacement.z; + self.set_transform(0, frame); + } +} + +const STATIC_INERTIA: RetailInertiaDynamics = RetailInertiaDynamics { + inverse_tensor: Vector3::ZERO, + inverse_mass: 0.0, + spherical: 0.0, + maximum_linear_velocity: 0.0, + maximum_angular_velocity: 0.0, + linear_drag: 0.0, + angular_drag: 0.0, +}; +pub(super) fn hard(velocity: f32, strength: f32) -> RetailDriveParams { + RetailDriveParams { + spring_or_max_velocity: velocity, + damping: 0.0, + max_strength: strength, + drive_type: RetailDriveType::HardDrive, + } +} +fn words(frame: AnimationPartTransform) -> [u32; 16] { + std::array::from_fn(|i| frame[i / 4][i % 4].to_bits()) +} +fn floats(frame: [u32; 16]) -> AnimationPartTransform { + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(frame[i * 4 + j]))) +} 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 new file mode 100644 index 00000000..6631a1d7 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::physics::{contact::RetailContactMaterial, skeleton_animation_record::IDENTITY}; + +fn settings() -> SkeletonFeedbackSettings { + SkeletonFeedbackSettings { + body: SkeletonCollisionSettings { + enabled: true, + normal_material: RetailContactMaterial { + static_friction: 0.0, + dynamic_friction: 0.0, + restitution: 0.0, + }, + compliant: [true; 24], + priority: [0.0; 24], + effect_time: 0.25, + }, + small_object_mass: 5.5, + ground_plane_max_distance: 0.3, + ground_plane_max_angle: 0.8, + skater_scalar: 0.5, + ai_scalar: 1.0, + groin_offset: [0.0; 4], + face_offset: [0.0; 4], + groin_radius: 0.0, + face_radius: 0.0, + } +} +fn input<'a>( + physical: &'a SkeletonPhysicalRecord, + frames: &'a [[[f32; 4]; 4]; 26], + weights: &'a [f32; 24], +) -> SkeletonCollisionInput<'a> { + SkeletonCollisionInput { + dt: 0.125, + plane_point: [0.0; 4], + plane_normal: [0.0, 1.0, 0.0, 0.0], + reference_velocity: [2.0, 0.0, 0.0, 0.0], + com_velocity: [0.0; 4], + ragdoll: false, + disable_ground_filter: false, + ai_collision_scalar: false, + offboard: false, + entering_offboard: false, + category_600: false, + request_partial_ragdoll: false, + physical, + part_weights: weights, + body_frames: frames, + } +} +fn report(normal: [f32; 4]) -> SkeletonContactReport { + SkeletonContactReport { + part: 2, + normal, + point: [0.0; 4], + tag: 0, + other_group: 0, + other_entity: None, + body_a: SkeletonContactBody { + state_flags: 4, + inverse_mass: 0.5, + linear_velocity: [2.0, 0.0, 0.0, 0.0], + }, + body_b: SkeletonContactBody { + state_flags: 1, + inverse_mass: 0.0, + linear_velocity: [0.0; 4], + }, + side_a: true, + solved_vector: [100.0, 0.0, 0.0, 0.0], + } +} + +#[test] +fn support_contact_is_filtered_but_wall_contact_changes_drives_and_recovers() { + let mut physical = SkeletonPhysicalRecord::default(); + physical.velocities[2] = [2.0, 0.0, 0.0, 0.0]; + let frames = [IDENTITY; 26]; + let weights = [1.0; 24]; + let input = input(&physical, &frames, &weights); + let mut collision = SkeletonCollisionFeedback::new(settings()); + collision.update(&input, &[report([0.0, 1.0, 0.0, 0.0])]); + assert!(collision.flags.any); + assert!(!collision.flags.compliant); + assert_eq!(collision.drive_weight, 1.0); + collision.update(&input, &[report([-1.0, 0.0, 0.0, 0.0])]); + assert!(collision.flags.compliant && collision.flags.has_impulse); + assert_eq!(collision.bones[2].force, 2.0); + assert_eq!(collision.drive_weight, 0.0); + assert!(collision.regions[1].weighted_force.is_finite()); + collision.update(&input, &[]); + assert_eq!(collision.drive_weight, 0.5); + collision.update(&input, &[]); + assert_eq!(collision.drive_weight, 1.0); +} + +#[test] +fn tiny_dynamic_opponent_is_noncompliant_even_for_compliant_bone() { + let mut physical = SkeletonPhysicalRecord::default(); + physical.velocities[2] = [2.0, 0.0, 0.0, 0.0]; + let frames = [IDENTITY; 26]; + let weights = [1.0; 24]; + let input = input(&physical, &frames, &weights); + let mut contact = report([-1.0, 0.0, 0.0, 0.0]); + contact.body_b.state_flags = 4; + contact.body_b.inverse_mass = 1.0; + let mut collision = SkeletonCollisionFeedback::new(settings()); + collision.update(&input, &[contact]); + assert_eq!(collision.bones[2].force, 1.0); + assert!(collision.flags.noncompliant); + assert!(!collision.flags.compliant && !collision.flags.has_impulse); +} + +#[test] +fn contact_planes_constrain_physical_pose_error_without_inventing_impulse() { + let mut collision = SkeletonCollisionFeedback::new(settings()); + let axis = [0.0, 1.0, 0.0, 0.0]; + let error = [2.0, 3.0, 0.0, 0.0]; + assert_eq!(collision.filter_error(error, axis), [0.0; 4]); + collision.planes.push(ContactPlane { + normal: [1.0, 0.0, 0.0, 0.0], + part: 2, + }); + let filtered = collision.filter_error(error, axis); + assert!((filtered[0] - 2.0).abs() < 1e-5); + assert_eq!(filtered[1], 0.0); + let diagonal = 0.5_f32.sqrt(); + collision.planes[0].normal = [diagonal, 0.0, diagonal, 0.0]; + let projected = collision.filter_error(error, axis); + assert!((projected[0] - 1.0).abs() < 1e-5); + assert!((projected[2] - 1.0).abs() < 1e-5); + collision.planes[0].normal = [-1.0, 0.0, 0.0, 0.0]; + assert_eq!(collision.filter_error(error, axis), [0.0; 4]); +} diff --git a/crates/skate-core/src/physics/skeleton_general.rs b/crates/skate-core/src/physics/skeleton_general.rs new file mode 100644 index 00000000..95a197bb --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_general.rs @@ -0,0 +1,87 @@ +//! Original Skeleton::GeneralUpdate82BDCA38 and discontinuity reset82BE2798. +//! This operates on the same physical bodies later borrowed by the solver. +use super::{ + native_arithmetic::reciprocal_estimate, + rigid_body::RetailSimulationStep, + skeleton_animation_record::{ + AnimationPartTransform as Transform, SkeletonAnimationRecord, compose_affine, + transform_point, + }, + skeleton_body::SkeletonBody, +}; +use crate::math::Vector3; + +///82BDCA50..CAB8: root-position derivative, two reciprocal refinements, +///then history replacement. It is distinct from animation trajectory velocity. +pub fn root_velocity(previous: &mut [f32; 4], current: [f32; 4], dt: f32) -> [f32; 4] { + let difference: [f32; 4] = std::array::from_fn(|i| current[i] - previous[i]); + let mut inverse = reciprocal_estimate(dt); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(dt, 1.0), inverse); + } + let velocity = difference.map(|v| v * inverse); + *previous = current; + velocity +} + +///82BDCB50..CC18 uses its fixed-step literal, not the caller timestep. +///The resulting displacement enters ApplyDisplacementToPart82BE7788, which +///updates force accumulators and cooldown, rather than body translations. +pub fn apply_gravity_displacement(body: &mut SkeletonBody, processed_gravity: f32) { + let step = f32::from_bits(0x3C88_8889); + let displacement = + [0.0, -processed_gravity, 0.0, 0.0].map(|value| ((value * 0.5) * step) * step); + for part in 0..24 { + body.apply_part_displacement(part, displacement); + } +} + +pub struct ResetObservation { + ///Skeleton16208, current adjusted animation hips translation. + pub local_hips: [f32; 4], + ///Skeleton16144 and16160 receive this value;16176 is zeroed. + pub world_hips: [f32; 4], +} + +///82BE2798: reset physical pose to the current mapped animation after a +///target discontinuity. Reset rates exactly here; ordinary postphysics pose +///correction must preserve rates and uses SkeletonBody::set_part_transform. +pub fn reset_to_animation( + body: &mut SkeletonBody, + animation: &mut SkeletonAnimationRecord, + drive_pose: &[Transform; 24], + animation_to_world: &Transform, + com_frame: Transform, + simulation: RetailSimulationStep, +) -> ResetObservation { + for part in 0..24 { + let world = compose_affine(animation_to_world, &drive_pose[part]); + body.set_part_transform(part, world); + reset_rates(body, part, simulation); + } + for part in [24, 25] { + body.set_part_transform(part, com_frame); + reset_rates(body, part, simulation); + } + let local_hips = drive_pose[23][3]; + let world_hips = transform_point(animation_to_world, local_hips); + //82BEBBB0 obtains all26 current GetPartTransform results before clearing + //their position/velocity histories and the four animation COM histories. + let current = body.part_transforms(); + body.record.reset(¤t); + animation.reset_history(); + ResetObservation { + local_hips, + world_hips, + } +} + +fn reset_rates(body: &mut SkeletonBody, part: usize, simulation: RetailSimulationStep) { + let rates = &mut body.bodies_mut()[part].rates; + rates.linear_velocity = Vector3::ZERO; + rates.angular_velocity = Vector3::ZERO; + //Original Reset copies the Simulation+144 gravity vector into forceXYZ; + //it does not clear force to zero or reset energy/cooldown. + rates.force_acceleration = simulation.gravity_acceleration; + rates.torque_acceleration = Vector3::ZERO; +} diff --git a/crates/skate-core/src/physics/skeleton_landing.rs b/crates/skate-core/src/physics/skeleton_landing.rs new file mode 100644 index 00000000..2eb1e292 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_landing.rs @@ -0,0 +1,250 @@ +//! Complete TU3 Skeleton::UpdateLandingAdjust82BD9028. This owns the four +//! landing modes and history; it does not synthesize physical COM observations. +use super::skeleton_board_offset::SkateboardOffset; +use crate::point_graph::PointGraph; +const STEP: f32 = f32::from_bits(0x3C88_8889); + +#[derive(Clone, Debug)] +pub struct LandingSettings { + pub manual_blend: PointGraph<4>, + pub grind_blend: PointGraph<4>, + pub coffin_height: PointGraph<4>, + pub minimum_height: f32, + pub maximum_velocity: f32, + pub manual_damping: f32, + pub manual_spring: f32, + pub ground_minimum_compression_time: f32, + pub ground_damping: f32, + pub ground_spring: f32, + pub grind_animation_target_time: f32, + pub grind_damping: f32, + pub grind_spring: f32, + pub grind_target_delta: f32, + pub desired_com_height: f32, + pub coffin_time: f32, + pub coffin_maximum_velocity: f32, + pub coffin_blend_frames: f32, + pub coffin_base_height: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct LandingInput { + pub filtered_state: u32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub balance: f32, + /// dot(Processed608 physical COM velocity, Processed544 ground up). + pub physical_com_velocity_along_up: f32, + /// dot(Skeleton10928 physical COM - Skeleton9680 physical board, up). + pub physical_com_height: f32, + /// Previous animation COM Skeleton10960.y minus current board pose12672.y. + pub animation_com_height: f32, +} + +#[derive(Clone, Debug)] +pub struct LandingAdjustment { + pub active: bool, + pub kind: u32, + pub time: f32, + pub position: f32, + pub velocity: f32, + pub previous_com_velocity: f32, + pub previous_animation_height: f32, + pub previous_filtered_state: u32, + pub desired_grind_com: f32, +} +impl Default for LandingAdjustment { + fn default() -> Self { + // Skeleton ctor82BD7A9C..7ABC. + Self { + active: false, + kind: 0, + time: 0.0, + position: 0.0, + velocity: 0.0, + previous_com_velocity: 0.0, + previous_animation_height: f32::MAX, + previous_filtered_state: 0, + desired_grind_com: 0.0, + } + } +} +impl LandingAdjustment { + pub fn update( + &mut self, + input: LandingInput, + settings: &LandingSettings, + offset: &mut SkateboardOffset, + ) { + let from_offboard = self.previous_filtered_state == 6 && input.filtered_state == 1; + let from_air = self.previous_filtered_state == 2 && input.filtered_state == 1; + let ground = (from_offboard || from_air) && input.flags_2476 & 0x1000_0000 != 0; + let manual = from_air && input.balance != 0.0; + let grind = self.previous_filtered_state == 2 + && input.filtered_state == 3 + && input.flags_2468 & 0x20 == 0; + let coffin_flag = input.flags_2472 & 1 != 0; + let coffin = coffin_flag && (grind || from_air); + let cancel = input.flags_2476 & 0x4000_0000 != 0 && !coffin_flag; + if cancel { + self.active = false; + } + let mut first_ground_update = false; + if !cancel && !self.active && (ground || coffin || manual || grind) { + self.time = 0.0; + self.active = true; + self.kind = if coffin { + 3 + } else if manual { + 2 + } else if ground { + 0 + } else { + 1 + }; + first_ground_update = self.kind == 0; + let velocity = if grind || coffin { + self.previous_com_velocity + } else { + input.physical_com_velocity_along_up + }; + let lower = select( + -settings.maximum_velocity - velocity, + -settings.maximum_velocity, + velocity, + ); + self.velocity = select(-lower, lower, 0.0); + self.previous_animation_height = f32::MAX; + self.position = input.physical_com_height; + } + if self.active { + let mut spring = 0.0; + let mut damping = 0.0; + let mut blend = 0.0; + let mut release = false; + let mut allow_upwards = true; + match self.kind { + 0 => { + spring = settings.ground_spring; + damping = settings.ground_damping; + allow_upwards = false; + blend = 1.0; + release = input.filtered_state != 1 || input.flags_2476 & 0x1000_0000 == 0; + } + 1 => { + spring = settings.grind_spring; + damping = settings.grind_damping; + blend = settings.grind_blend.evaluate(self.time); + release = input.filtered_state != 3; + } + 2 => { + spring = settings.manual_spring; + damping = settings.manual_damping; + blend = settings.manual_blend.evaluate(self.time); + release = input.filtered_state != 1; + } + 3 => { + blend = 1.0; + release = input.filtered_state != 1 + || self.time > settings.coffin_time + || !coffin_flag; + } + _ => {} + } + let animation_height = input.animation_com_height; + if self.kind == 3 { + let ratio = self.velocity.abs() / settings.coffin_maximum_velocity; + let positive_ratio = select(-ratio, 0.0, ratio); + let scale = select(1.0 - positive_ratio, positive_ratio, 1.0); + let height = settings + .coffin_height + .evaluate(self.time / settings.coffin_time); + self.position = height.mul_add(scale, settings.coffin_base_height); + } else { + let mut target = settings.desired_com_height; + if self.kind == 1 { + if self.time > settings.grind_animation_target_time { + let minimum = self.desired_grind_com - settings.grind_target_delta; + let maximum = self.desired_grind_com + settings.grind_target_delta; + let lower = select(minimum - animation_height, minimum, animation_height); + target = select(maximum - lower, lower, maximum); + } + self.desired_grind_com = target; + } + let displacement = self.position - target; + let previous_velocity = self.velocity; + // fnmsubs of damping*velocity and negative spring displacement. + let spring_force = -(displacement * spring); + let acceleration = -(previous_velocity.mul_add(damping, -spring_force)); + let candidate = acceleration.mul_add(STEP, previous_velocity); + let lower = select( + -settings.maximum_velocity - candidate, + -settings.maximum_velocity, + candidate, + ); + let maximum = if allow_upwards { + settings.maximum_velocity + } else { + 0.0 + }; + self.velocity = select(maximum - lower, lower, maximum); + let integrated = + ((self.velocity + previous_velocity) * 0.5).mul_add(STEP, displacement); + let height = integrated + target; + self.position = select( + height - settings.minimum_height, + height, + settings.minimum_height, + ); + } + let mut displacement = animation_height - self.position; + if first_ground_update { + let correction = self.velocity * f32::from_bits(0x3C75_C28F); + let lower = select(-0.1 - correction, -0.1, correction); + let down = select(-lower, lower, 0.0); + let combined = down + displacement; + displacement = select(combined, combined, 0.0); + } + if self.kind == 0 + && self.time > settings.ground_minimum_compression_time + && self.previous_animation_height < animation_height + && displacement > 0.0 + { + release = true; + } + if self.kind == 1 && input.flags_2468 & 0x20 != 0 { + release = true; + } + if self.time > 1.5 { + release = true; + } + let force_blend = input.flags_2468 & (1 << 21) != 0; + if !release && blend >= 0.05 || force_blend { + let mut frames = if self.kind == 3 { + settings.coffin_blend_frames + } else { + 15.0 + }; + if force_blend { + self.active = false; + frames = 6.0; + } + offset.refresh_height(displacement * blend, frames); + } else { + self.active = false; + } + self.time += STEP; + self.previous_animation_height = animation_height; + } + self.previous_com_velocity = input.physical_com_velocity_along_up; + self.previous_filtered_state = input.filtered_state; + } +} +fn select(selector: f32, positive: f32, negative: f32) -> f32 { + if selector >= 0.0 { positive } else { negative } +} + +#[cfg(test)] +#[path = "tests/skeleton_landing.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/skeleton_motion.rs b/crates/skate-core/src/physics/skeleton_motion.rs new file mode 100644 index 00000000..66422b2e --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_motion.rs @@ -0,0 +1,96 @@ +//! Trajectory and board-orientation publications inside ProcessData82BD8918. +use super::{ + native_arithmetic::reciprocal_estimate, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}; + +pub struct SkeletonMotion { + ///Skeleton12048,12112,12176. Later skeleton modes may set the next frame. + pub trajectory: AnimationPartTransform, + pub inverse_trajectory: AnimationPartTransform, + pub next_trajectory: AnimationPartTransform, + ///Skeleton16320: current trajectory translation/dt, transformed to world. + pub velocity_world: [f32; 4], + ///Skeleton16500, initialized zero by82BD7A6C. + previous_board_at_y: f32, +} + +impl Default for SkeletonMotion { + fn default() -> Self { + //82BD7260 seeds the three frames from82139A10/20/30 and zero pos. + Self { + trajectory: IDENTITY, + inverse_trajectory: IDENTITY, + next_trajectory: IDENTITY, + velocity_world: [0.; 4], + previous_board_at_y: 0., + } + } +} + +impl SkeletonMotion { + ///82BE3538 resets only the board-orientation history, not trajectory. + pub fn reset_board_orientation_history(&mut self) { + self.previous_board_at_y = 0.0; + } + + ///82BD89D0..8BA0. Bone0 is the source trajectory, not the animated foot. + ///Preserve the two reciprocal refinements after the shared estimate model. + pub fn process_trajectory( + &mut self, + trajectory_bone: &AnimationPartTransform, + animation_to_world: &AnimationPartTransform, + dt: f32, + ) { + self.trajectory = compose_affine(trajectory_bone, &self.next_trajectory); + self.next_trajectory = self.trajectory; + let mut reciprocal = reciprocal_estimate(dt); + for _ in 0..2 { + let error = (-reciprocal).mul_add(dt, 1.0); + reciprocal = reciprocal.mul_add(error, reciprocal); + } + let velocity = self.trajectory[3].map(|v| v * reciprocal); + self.velocity_world = std::array::from_fn(|lane| { + let x = animation_to_world[0][lane] * velocity[0]; + let y = animation_to_world[1][lane].mul_add(velocity[1], x); + animation_to_world[2][lane].mul_add(velocity[2], y) + }); + let source = self.trajectory; + self.inverse_trajectory = std::array::from_fn(|axis| { + if axis < 3 { + [source[0][axis], source[1][axis], source[2][axis], 0.] + } else { + [0.; 4] + } + }); + self.inverse_trajectory[3] = std::array::from_fn(|lane| { + let z = (0.0 - source[3][2]) * self.inverse_trajectory[2][lane]; + let y = (0.0 - source[3][1]).mul_add(self.inverse_trajectory[1][lane], z); + (0.0 - source[3][0]).mul_add(self.inverse_trajectory[0][lane], y) + }); + } + + ///82BD8CB0..8D2C precedes the landing/board-offset adjustment. Only sets + ///the side-on flag; this stage does not clear an already published flag. + pub fn publish_unadjusted_board(board: &AnimationPartTransform, flags_2472: &mut u32) { + if board[0][1].abs() > f32::from_bits(0x3f35_c28f) { + *flags_2472 |= 0x8000; + } + } + + ///82BD8E14..8E50 uses board At.y, not height or translation. Returns the + ///difference published to Processed2768 and updates its complementary bits. + pub fn publish_adjusted_board( + &mut self, + board: &AnimationPartTransform, + flags_2468: &mut u32, + ) -> f32 { + let at_y = board[2][1]; + let delta = at_y - self.previous_board_at_y; + self.previous_board_at_y = at_y; + let positive = at_y > 0.0; + *flags_2468 = + (*flags_2468 & !0x1800) | (u32::from(positive) << 12) | (u32::from(!positive) << 11); + delta + } +} diff --git a/crates/skate-core/src/physics/skeleton_output/board.rs b/crates/skate-core/src/physics/skeleton_output/board.rs new file mode 100644 index 00000000..de14e84e --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/board.rs @@ -0,0 +1,179 @@ +//! Board portion of original TU3 physical pose worker82DB993C..A55C. +use crate::physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, +}; +use crate::{math::Vector3, riding::collision_response::signed_angle, trigonometry::sin_cos}; + +#[derive(Clone, Copy)] +pub struct Settings { + pub truck_tilt_scalar: f32, + pub truck_tilt_max_angle: f32, + pub truck_tilt_wobble_scalar: f32, + pub truck_displacement_max: f32, +} +#[derive(Clone, Copy)] +pub struct BoneIndices { + pub front_truck: usize, + pub back_truck: usize, + pub front_left_wheel: usize, + pub front_right_wheel: usize, + pub back_left_wheel: usize, + pub back_right_wheel: usize, +} +impl BoneIndices { + pub fn all(self) -> [usize; 6] { + [ + self.front_truck, + self.back_truck, + self.front_left_wheel, + self.front_right_wheel, + self.back_left_wheel, + self.back_right_wheel, + ] + } +} +pub struct Input<'a> { + /// Raw dynamic-body matrices82585B58, native parts0..5; not82585CB0. + pub bodies: &'a [Transform; 6], + /// SkeletonPhysicalRecord.part0, captured separately by82DB670C. + pub skeleton_board: &'a Transform, + /// SkateboardBody7840/7904, not7712/7776 steering-drive base frames. + pub truck_frames: &'a [Transform; 2], + pub deck_wobble_tilt: f32, + pub deck_wobble_squish: f32, + /// CalculateAverageWheelCompressions82C08968, front8372/back8376. + pub average_compression: [f32; 2], +} +pub fn publish( + input: Input<'_>, + settings: &Settings, + bones: BoneIndices, + locals: &mut [Transform], +) { + let y = [0.0, 1.0, 0.0, 0.0]; + let z = [0.0, 0.0, 1.0, 0.0]; + let wheels: [Transform; 4] = core::array::from_fn(|wheel| { + let up = inverse_direction(&input.bodies[4 + wheel / 2], input.bodies[wheel][1]); + rotation_x(angle(up, y, z)) + }); + //82DB9C60/9E00 choose opposite axle directions at the two ends. + let mut front = inverse_direction( + input.skeleton_board, + normalize_safe(subtract(input.bodies[0][3], input.bodies[1][3])), + ); + let mut back = inverse_direction( + input.skeleton_board, + normalize_safe(subtract(input.bodies[3][3], input.bodies[2][3])), + ); + if 0.5 > dot3(front, front) { + front = input.truck_frames[0][2]; + } + if 0.5 > dot3(back, back) { + back = input.truck_frames[1][2].map(|v| -v); + } + let negative_x = [-1.0, -0.0, -0.0, -0.0]; + let back_angle = wrap(angle(back, negative_x, input.truck_frames[1][0])); + let front_angle = wrap(angle(front, negative_x, input.truck_frames[0][0])); + let wobble = input.deck_wobble_tilt * settings.truck_tilt_wobble_scalar; + let back_tilt = clamp( + (-settings.truck_tilt_scalar).mul_add(back_angle, -wobble), + -settings.truck_tilt_max_angle, + settings.truck_tilt_max_angle, + ); + let front_tilt = clamp( + (-settings.truck_tilt_scalar).mul_add(front_angle, wobble), + -settings.truck_tilt_max_angle, + settings.truck_tilt_max_angle, + ); + for (side, bone, tilt) in [ + (0, bones.front_truck, front_tilt), + (1, bones.back_truck, back_tilt), + ] { + let compression = if input.average_compression[side] < f32::from_bits(0x3A83_126F) { + 0.0 + } else { + input.average_compression[side] + }; + let displacement = clamp( + compression - input.deck_wobble_squish, + 0.0, + settings.truck_displacement_max, + ); + let rotated = compose_affine(&locals[bone], &rotation_x(tilt)); + //82DBA130..A318 left-multiplies the rotated local by translation. + let translation = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, displacement, 0.0, 0.0], + ]; + locals[bone] = compose_affine(&translation, &rotated); + } + //Native physical-body order and animation-name order differ. + for (wheel, bone) in [ + bones.front_left_wheel, + bones.front_right_wheel, + bones.back_left_wheel, + bones.back_right_wheel, + ] + .into_iter() + .enumerate() + { + locals[bone] = compose_affine(&locals[bone], &wheels[wheel]); + } +} +fn angle(a: [f32; 4], b: [f32; 4], axis: [f32; 4]) -> f32 { + let vector = |v: [f32; 4]| Vector3::new(v[0], v[1], v[2]); + signed_angle(vector(a), vector(b), vector(axis)) +} +fn inverse_direction(frame: &Transform, value: [f32; 4]) -> [f32; 4] { + let mut output = [0.0; 4]; + for lane in 0..3 { + let x = value[0] * frame[lane][0]; + let y = value[1].mul_add(frame[lane][1], x); + output[lane] = value[2].mul_add(frame[lane][2], y); + } + output +} +fn normalize_safe(value: [f32; 4]) -> [f32; 4] { + let squared = dot3(value, value); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + if length > f32::from_bits(0x3586_37BD) { + value.map(|v| v * inverse) + } else { + [0.0; 4] + } +} +fn subtract(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| a[i] - b[i]) +} +fn wrap(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3E22_F983); + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40C9_0FDB) +} +fn clamp(value: f32, minimum: f32, maximum: f32) -> f32 { + let low = if minimum - value >= 0.0 { + minimum + } else { + value + }; + if maximum - low >= 0.0 { low } else { maximum } +} +fn rotation_x(angle: f32) -> Transform { + let (sine, cosine) = sin_cos(angle); + //vperm822FB890 replicates X in W; vrlimi2 replaces only Z. + [ + [1.0, 0.0, 0.0, 1.0], + [0.0, cosine, sine, 0.0], + [0.0, -sine, cosine, 0.0], + [0.0; 4], + ] +} diff --git a/crates/skate-core/src/physics/skeleton_output/correction.rs b/crates/skate-core/src/physics/skeleton_output/correction.rs new file mode 100644 index 00000000..fd8139c8 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/correction.rs @@ -0,0 +1,79 @@ +//! PostWipeoutCheck82BD83E0's error correction before wobble and physical IK. +use crate::physics::{ + native_arithmetic::{dot3, vector_min}, + skeleton_body::SkeletonBody, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct CorrectionState { + ///Skeleton16224, initialized zero at82BD7918. + pub board_prediction_error: [f32; 4], + ///Skeleton16388, initialized false at82BD7998 and set by Ground::Update. + pub pending: bool, +} +impl CorrectionState { + ///UpdatePostPhysics82BD8194..81B0, before collision and error feedback: + ///actual board GetPartTransform translation minus predicted16112. + pub fn observe_board(&mut self, actual: [f32; 4], predicted: [f32; 4]) { + self.board_prediction_error = core::array::from_fn(|i| actual[i] - predicted[i]); + } + pub fn apply( + &mut self, + body: &mut SkeletonBody, + ground_normal: [f32; 4], + wipeout: bool, + flags_2468: u32, + flags_2476: u32, + ) { + apply( + body, + &mut self.pending, + self.board_prediction_error, + ground_normal, + wipeout, + flags_2468, + flags_2476, + ); + } +} + +///Skeleton16224 is the already-produced board prediction error, and16388 says the +///record adjustment remains pending. This function does not synthesize errors. +pub fn apply( + body: &mut SkeletonBody, + pending: &mut bool, + error: [f32; 4], + ground_normal: [f32; 4], + wipeout: bool, + flags_2468: u32, + flags_2476: u32, +) { + if wipeout && flags_2476 & (1 << 30) != 0 { + //82BE2AB0 moves live parts0..23 only when correction length²<1. + if 1.0 > dot3(error, error) { + for part in 0..24 { + let mut frame = body.record.pose[part]; + for lane in 0..4 { + frame[3][lane] += error[lane]; + } + body.set_part_transform(part, frame); + } + } + *pending = false; + } else if !wipeout && flags_2468 & (1 << 18) == 0 && *pending { + let distance = dot3(error, ground_normal); + let allowed = vector_min(0.0, distance); + let offset: [f32; 4] = core::array::from_fn(|i| { + let tangent = error[i] - ground_normal[i] * distance; + ground_normal[i].mul_add(allowed, tangent) + }); + //82BEC2C0 starts at record part1 and translates through23. It leaves + //the observed board, solver states, COM and velocity history alone. + for part in 1..24 { + for lane in 0..4 { + body.record.pose[part][3][lane] += offset[lane]; + } + } + *pending = false; + } +} diff --git a/crates/skate-core/src/physics/skeleton_output/mod.rs b/crates/skate-core/src/physics/skeleton_output/mod.rs new file mode 100644 index 00000000..d985abad --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/mod.rs @@ -0,0 +1,67 @@ +//! Original TU3 physical pose worker82DB95E8, submitted by82DB6698. +//! Updates the existing animation buffers after the physical solve. +pub mod board; +pub mod wobble; +pub mod correction; + +use super::skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}; +use crate::animation::foot_ik::{drive::Geometry, transforms::inverse_rigid}; + +/// Stock SkeletonData ids, nearest physical ancestors, and volume inverses. +pub struct SkeletonOutput { + pub bone_indices: [usize; 24], + pub geometry: Geometry, + pub board_bones: board::BoneIndices, + pub board_settings: board::Settings, +} +pub struct Input<'a> { + /// Skeleton8016, all24 solved physical volume transforms in world space. + pub physical_parts: &'a [Transform; 24], + /// Skeleton11984, captured by82DB66C0 before submitting this worker. + pub world_to_animation: &'a Transform, + pub board: board::Input<'a>, +} +impl SkeletonOutput { + /// Buffers must contain the evaluated stock animation for this same frame. + /// Unrepresented bones retain their animation. Board locals are modified + /// after the mapped globals; the worker does not rebuild the full hierarchy. + pub fn publish( + &self, + input: Input<'_>, + globals: &mut [Transform], + locals: &mut [Transform], + ) -> Result<(), &'static str> { + if globals.len() != locals.len() + || self.bone_indices.iter().any(|&i| i >= globals.len()) + || self.board_bones.all().iter().any(|&i| i >= locals.len()) + { + return Err("physical pose output does not match the stock animation hierarchy"); + } + //82DB96B8..97E4: volume -> animation bone -> animation space. + for part in 0..24 { + let world = compose_affine( + &input.physical_parts[part], + &self.geometry.inverse_part_frames[part], + ); + globals[self.bone_indices[part]] = compose_affine(input.world_to_animation, &world); + } + //82DB97F4..9938 descends through the physical mapping. This inverse + //is a rigid transpose, distinct from the authored volume inverse. + for part in (0..24).rev() { + let bone = self.bone_indices[part]; + locals[bone] = if let Some(parent) = self.geometry.parents[part] { + compose_affine( + &inverse_rigid(&globals[self.bone_indices[parent]]), + &globals[bone], + ) + } else { + globals[bone] + }; + } + board::publish(input.board, &self.board_settings, self.board_bones, locals); + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/physics/skeleton_output/tests.rs b/crates/skate-core/src/physics/skeleton_output/tests.rs new file mode 100644 index 00000000..2671db21 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/tests.rs @@ -0,0 +1,111 @@ +use super::*; +use crate::{physics::skeleton_animation_record::IDENTITY, point_graph::PointGraph}; + +#[test] +fn solved_volume_is_converted_back_to_bone_and_preserves_unmapped_animation() { + let mut forward = [IDENTITY; 24]; + forward[1][3][0] = 2.0; + let mut parents = [None; 24]; + parents[1] = Some(2); + let output = SkeletonOutput { + bone_indices: core::array::from_fn(|i| i), + geometry: Geometry::new(parents, &forward).unwrap(), + board_bones: board::BoneIndices { + front_truck: 25, + back_truck: 26, + front_left_wheel: 27, + front_right_wheel: 28, + back_left_wheel: 29, + back_right_wheel: 30, + }, + board_settings: board::Settings { + truck_tilt_scalar: 1.0, + truck_tilt_max_angle: 1.0, + truck_tilt_wobble_scalar: 1.0, + truck_displacement_max: 1.0, + }, + }; + let mut physical = [IDENTITY; 24]; + physical[1][3][0] = 12.0; + physical[2][3][0] = 7.0; + let mut world_to_anim = IDENTITY; + world_to_anim[3][0] = -3.0; + let mut globals = [IDENTITY; 36]; + let mut locals = [IDENTITY; 36]; + locals[35][3][1] = 9.0; + let mut bodies = [IDENTITY; 6]; + bodies[0][3][0] = -1.0; + bodies[1][3][0] = 1.0; + bodies[2][3][0] = 1.0; + bodies[3][3][0] = -1.0; + output + .publish( + Input { + physical_parts: &physical, + world_to_animation: &world_to_anim, + board: board::Input { + bodies: &bodies, + skeleton_board: &physical[0], + truck_frames: &[IDENTITY; 2], + deck_wobble_tilt: 0.0, + deck_wobble_squish: 0.0, + average_compression: [0.0; 2], + }, + }, + &mut globals, + &mut locals, + ) + .unwrap(); + assert!((globals[1][3][0] - 7.0).abs() < 1e-6); + assert!((locals[1][3][0] - 3.0).abs() < 1e-6); + assert_eq!(locals[35][3][1], 9.0); +} + +#[test] +fn wobble_emits_final_sample_before_expiry_and_selects_real_phase_curves() { + let curve = |value| PointGraph { + x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], + y: [value; 8], + }; + let settings = wobble::Settings { + takeoff_tilt: curve(2.0), + landing_tilt: curve(3.0), + takeoff_squish: curve(4.0), + landing_squish: curve(5.0), + maximum_time: 0.0, + }; + let mut state = wobble::Wobble::default(); + assert!(!state.update(&settings).sampled); + state.trigger(true, true); + let final_sample = state.update(&settings); + assert!(final_sample.sampled); + assert!(!final_sample.remains_active); + assert_eq!(final_sample.tilt, -3.0); + assert_eq!(final_sample.squish, 5.0); + assert_eq!(state.time, 0.0); + assert!(!state.update(&settings).sampled); + state.trigger(false, false); + assert_eq!(state.update(&settings).tilt, 2.0); +} + +#[test] +fn wobble_rotates_about_deck_forward_then_compresses_along_new_up() { + let mut board = IDENTITY; + board[3] = [4.0, 5.0, 6.0, 0.0]; + wobble::apply( + wobble::Output { + sampled: true, + tilt: core::f32::consts::FRAC_PI_2, + squish: 0.25, + remains_active: true, + }, + &mut board, + ); + assert!((board[0][0]).abs() < 2e-6); + assert!((board[0][1] - 1.0).abs() < 2e-6); + assert!((board[1][0] + 1.0).abs() < 2e-6); + assert_eq!(&board[2][..3], &[0.0, 0.0, 1.0]); + assert!((board[3][0] - 3.75).abs() < 2e-6); + assert!((board[3][1] - 5.0).abs() < 2e-6); + assert_eq!(board[3][2], 6.0); +} diff --git a/crates/skate-core/src/physics/skeleton_output/wobble.rs b/crates/skate-core/src/physics/skeleton_output/wobble.rs new file mode 100644 index 00000000..611e8aff --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/wobble.rs @@ -0,0 +1,97 @@ +//! SkateboardWobble constructor82BF2E48, trigger82BF2F18, update82BF2FB8. +use crate::point_graph::PointGraph; + +pub struct Settings { + pub takeoff_tilt: PointGraph<8>, + pub landing_tilt: PointGraph<8>, + pub takeoff_squish: PointGraph<8>, + pub landing_squish: PointGraph<8>, + pub maximum_time: f32, +} +pub struct Wobble { + pub active: bool, + pub landing: bool, + pub time: f32, + pub amplitude: f32, + pub direction: f32, + selected_landing_curves: bool, +} +impl Default for Wobble { + fn default() -> Self { + //Constructor copies landing curves while initializing type17=false. + Self { + active: false, + landing: false, + time: 0.0, + amplitude: 0.0, + direction: 1.0, + selected_landing_curves: true, + } + } +} +#[derive(Clone, Copy, Default)] +pub struct Output { + ///Native out0 reports whether this update evaluated the effect, including + ///the final sample whose clock expires it. Return value is active AFTER. + pub sampled: bool, + pub tilt: f32, + pub squish: f32, + pub remains_active: bool, +} +impl Wobble { + pub fn trigger(&mut self, landing: bool, reverse: bool) { + self.selected_landing_curves = landing; + self.landing = landing; + self.active = true; + self.time = 0.0; + self.amplitude = 1.0; + self.direction = if reverse { -1.0 } else { 1.0 }; + } + pub fn update(&mut self, settings: &Settings) -> Output { + if !self.active { + return Output::default(); + } + let (tilt, squish) = if self.selected_landing_curves { + (&settings.landing_tilt, &settings.landing_squish) + } else { + (&settings.takeoff_tilt, &settings.takeoff_squish) + }; + let squish = squish.evaluate(self.time) * self.amplitude; + let tilt = (tilt.evaluate(self.time) * self.direction) * self.amplitude; + self.time += f32::from_bits(0x3C88_8889); + if self.time > settings.maximum_time { + self.active = false; + self.time = 0.0; + } + Output { + sampled: true, + tilt, + squish, + remains_active: self.active, + } + } +} + +/// PostWipeoutCheck82BD857C..86D4 modifies the observed board record only. +///This wobble is not a force or a SetPartTransform on the simulated deck. +pub fn apply( + output: Output, + board: &mut crate::physics::skeleton_animation_record::AnimationPartTransform, +) { + if !output.sampled { + return; + } + use crate::{physics::skeleton_animation_record::compose_affine, trigonometry::sin_cos}; + let (sine, cosine) = sin_cos(output.tilt); + //822FB890 permute and vrlimi mask2 retain the native packed W lanes. + let rotation = [ + [cosine, sine, 0.0, cosine], + [-sine, cosine, 0.0, -sine], + [0.0, 0.0, 1.0, 0.0], + [0.0; 4], + ]; + *board = compose_affine(board, &rotation); + for lane in 0..4 { + board[3][lane] = board[1][lane].mul_add(output.squish, board[3][lane]); + } +} diff --git a/crates/skate-core/src/physics/skeleton_root.rs b/crates/skate-core/src/physics/skeleton_root.rs new file mode 100644 index 00000000..7797eca0 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_root.rs @@ -0,0 +1,174 @@ +//! Skeleton::UpdateRootTransforms82BE0318: actual board prediction and the +//! retained animation-to-board/world frames. Full Reckoning is an input owner. +use super::{ + board_ground::angle_between, + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}; +use crate::{math::Vector3, trigonometry}; + +#[derive(Clone, Debug)] +pub struct SkeletonRootFrames { + pub board: AnimationPartTransform, + pub inverse_board: AnimationPartTransform, + pub previous_board_position: [f32; 4], + pub predicted_board_position: [f32; 4], + pub supplied_prediction: Option<[f32; 4]>, + pub animation_to_board: AnimationPartTransform, + pub animation_to_world: AnimationPartTransform, + pub world_to_animation: AnimationPartTransform, + pub heading_alignment: AnimationPartTransform, + pub initialize_heading: bool, +} +impl Default for SkeletonRootFrames { + fn default() -> Self { + // Skeleton ctor82BD7260 matrix seeds,16416=0 and16417=1. + Self { + board: IDENTITY, + inverse_board: IDENTITY, + previous_board_position: [0.0; 4], + predicted_board_position: [0.0; 4], + supplied_prediction: None, + animation_to_board: IDENTITY, + animation_to_world: IDENTITY, + world_to_animation: IDENTITY, + heading_alignment: IDENTITY, + initialize_heading: true, + } + } +} +impl SkeletonRootFrames { + ///82BDFE58: teleport prediction uses the current physical deck position; + ///the heading and remaining root composition match82BE0318. + pub fn update_teleport( + &mut self, + board: AnimationPartTransform, + animation_board: &AnimationPartTransform, + reckoning_frame_816: &AnimationPartTransform, + ) { + self.initialize_heading = true; + self.supplied_prediction = Some(board[3]); + self.update(board, [0.0; 4], 0.0, animation_board, reckoning_frame_816); + //82BDFF0C differs from ordinary update's previous-position snapshot. + self.previous_board_position = board[3]; + } + + /// Reset82BD9990 writes these two frames from the initial mapped pose. + /// Other root histories retain their independently initialized state. + pub fn reset_initial_alignment(&mut self, animation_to_world: AnimationPartTransform) { + self.animation_to_world = animation_to_world; + self.world_to_animation = inverse_rigid(&animation_to_world); + } + + pub fn update( + &mut self, + board: AnimationPartTransform, + board_velocity: [f32; 4], + time_step: f32, + animation_board: &AnimationPartTransform, + reckoning: &AnimationPartTransform, + ) { + self.previous_board_position = self.board[3]; + self.board = board; + self.inverse_board = inverse_rigid(&board); + self.predicted_board_position = self.supplied_prediction.take().unwrap_or_else(|| { + std::array::from_fn(|i| board_velocity[i].mul_add(time_step, board[3][i])) + }); + if self.initialize_heading { + //8296EC98 uses one rsqrt refinement, angle in[0,2pi], not atan2. + let at = Vector3::new(animation_board[2][0], 0.0, animation_board[2][2]); + let forward = Vector3::new(0.0, 0.0, 1.0); + let mut angle = angle_between(at, forward); + // Normalize before the sign test, exactly as the angle helper. + let at4 = [at.x, at.y, at.z, 0.0]; + let sq = dot3(at4, at4); + if sq > f32::from_bits(0x38D1_B717) { + let inverse = inverse_length_squared(sq, 1); + let x = at.x * inverse; + // dot(cross(normalized_at, forward), world_up) == -x. + if 0.0 > -x { + angle = f32::from_bits(0x40C9_0FDB) - angle; + } + } + let (sin, cos) = trigonometry::sin_cos(angle); + self.heading_alignment = [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [0.0; 4], + ]; + self.initialize_heading = false; + } + let mut remove_board_translation = IDENTITY; + remove_board_translation[3] = animation_board[3].map(|v| -v); + let local = compose_affine(&self.heading_alignment, &remove_board_translation); + self.animation_to_board = compose_affine(reckoning, &local); + let mut world_translation = IDENTITY; + world_translation[3] = self.predicted_board_position; + self.animation_to_world = + orthonormalize(compose_affine(&world_translation, &self.animation_to_board)); + self.world_to_animation = inverse_rigid(&self.animation_to_world); + } +} + +///825C5710: reverse-axis Gram-Schmidt, two rsqrt refinements, translation copy. +pub(crate) fn orthonormalize(source: AnimationPartTransform) -> AnimationPartTransform { + let mut result = source; + for axis in (0..3).rev() { + let mut vector = source[axis]; + for prior in ((axis + 1)..3).rev() { + let projection = dot3(result[prior], source[axis]); + for (lane, value) in vector.iter_mut().enumerate() { + *value -= result[prior][lane] * projection; + } + } + let reciprocal = inverse_length_squared(dot3(vector, vector), 2); + result[axis] = vector.map(|v| v * reciprocal); + } + result +} + +/// Inline rigid inverse: transposeXYZ, zero stored W, translation Z,Y,X FMA. +pub(crate) fn inverse_rigid(source: &AnimationPartTransform) -> AnimationPartTransform { + let mut result = IDENTITY; + for axis in 0..3 { + result[axis] = [source[0][axis], source[1][axis], source[2][axis], 0.0]; + } + let translation = source[3].map(|v| 0.0 - v); + result[3] = std::array::from_fn(|i| { + let z = translation[2] * result[2][i]; + let y = translation[1].mul_add(result[1][i], z); + translation[0].mul_add(result[0][i], y) + }); + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn actual_board_prediction_and_source_heading_build_the_animation_frame() { + let mut roots = SkeletonRootFrames::default(); + let mut board = IDENTITY; + board[3] = [10.0, 2.0, 0.0, 0.0]; + let mut animation = IDENTITY; + animation[3] = [1.0, 2.0, 3.0, 0.0]; + roots.update(board, [5.0, 0.0, 0.0, 0.0], 0.02, &animation, &IDENTITY); + assert!((roots.predicted_board_position[0] - 10.1).abs() < 1e-6); + let recentered = compose_affine(&roots.animation_to_board, &animation); + assert!(recentered[3].iter().all(|v| v.abs() < 1e-6)); + assert!(!roots.initialize_heading); + roots.supplied_prediction = Some([30.0, 40.0, 50.0, 0.0]); + roots.update(board, [5.0, 0.0, 0.0, 0.0], 0.02, &animation, &IDENTITY); + assert_eq!(roots.predicted_board_position, [30.0, 40.0, 50.0, 0.0]); + assert_eq!(roots.supplied_prediction, None); + assert_eq!(roots.previous_board_position, board[3]); + let product = compose_affine(&roots.world_to_animation, &roots.animation_to_world); + for axis in 0..4 { + for lane in 0..4 { + assert!((product[axis][lane] - IDENTITY[axis][lane]).abs() < 1e-5); + } + } + } +} diff --git a/crates/skate-core/src/physics/solver.rs b/crates/skate-core/src/physics/solver.rs new file mode 100644 index 00000000..7db35c49 --- /dev/null +++ b/crates/skate-core/src/physics/solver.rs @@ -0,0 +1,66 @@ +//! Shared TU3 constraint iteration (`0x82AE27D0`). +//! +//! Constraint families must exchange reactions on every pass. Solving each +//! family to completion independently changes the physical result. +use super::{ + contact_solver::RetailContactJacobian, drive_solver::RetailDriveRows, + joint_solver::RetailJointJacobian, rigid_body::RetailReactionCorrections, +}; +mod contact; +pub mod contact_build; +mod drive; +mod joint; +pub mod packed; +mod packing; + +pub(crate) fn compile_contact(record: &mut [u32; 64], time_step: f32) { + contact_build::build(record, time_step); +} + +pub(crate) fn solve_joint_record(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + joint::solve(record, a, b); +} + +/// Host indices replace the two guest reaction pointers stored in a Jacobian. +#[derive(Clone, Debug)] +pub struct JointConstraint { + pub jacobian: RetailJointJacobian, + pub reaction_a: usize, + pub reaction_b: usize, +} + +/// Runs the retail contact → joint → drive ordering. The caller clears the +/// reaction workspace and builds fresh rows once per simulation step. +pub fn solve_constraints( + contacts: &mut [RetailContactJacobian], + joints: &mut [JointConstraint], + drives: &mut [RetailDriveRows], + reactions: &mut [RetailReactionCorrections], + iterations: u32, +) { + let mut c: Vec<_> = contacts.iter().map(packing::contact).collect(); + let mut j: Vec<_> = joints + .iter() + .map(|j| packed::Joint { + words: j.jacobian.words, + reaction_a: j.reaction_a, + reaction_b: j.reaction_b, + }) + .collect(); + let mut d: Vec<_> = drives.iter().map(packing::drive).collect(); + let mut r: Vec<_> = reactions.iter().copied().map(packing::reaction).collect(); + packed::solve(&mut c, &mut j, &mut d, &mut r, iterations); + for (contact, native) in contacts.iter_mut().zip(c) { + contact.words = native.words; + } + for (joint, native) in joints.iter_mut().zip(j) { + joint.jacobian.words = native.words; + } + for (drive, native) in drives.iter_mut().zip(d) { + drive.accumulated_linear_impulse = packing::read_xyz(&native.words, 2); + drive.accumulated_angular_impulse = packing::read_xyz(&native.words, 3); + } + for (reaction, native) in reactions.iter_mut().zip(r) { + *reaction = packing::unpack_reaction(native); + } +} diff --git a/crates/skate-core/src/physics/solver/contact.rs b/crates/skate-core/src/physics/solver/contact.rs new file mode 100644 index 00000000..3c421872 --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact.rs @@ -0,0 +1,73 @@ +//! Contact iteration, TU3 82AE2914..82AE2B8C. +//! +//! The compiled row boundary is decoded once. Calculations preserve the source +//! operation order for finite inputs; console exceptional-float behavior has +//! not been established by hardware captures. +#[path = "contact_data.rs"] +mod data; +use data::{ContactRows, Reaction}; + +pub(super) fn solve(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + let rows = ContactRows::read(record); + let mut reaction_a = Reaction::read(a); + let mut reaction_b = Reaction::read(b); + let position_a = reaction_a.point_position(rows.arm_a); + let position_b = reaction_b.point_position(rows.arm_b); + let velocity_a = reaction_a.point_velocity(rows.arm_a); + let velocity_b = reaction_b.point_velocity(rows.arm_b); + let position_error: [f32; 4] = core::array::from_fn(|i| position_b[i] - position_a[i]); + let total_error: [f32; 4] = + core::array::from_fn(|i| (velocity_b[i] - velocity_a[i]) + position_error[i]); + + // 82AE2A94..2ABC: the first three impulses respond to combined position + // and velocity correction. The fourth responds only to position error. + let candidate: [f32; 4] = core::array::from_fn(|lane| { + let error = if lane == 3 { + position_error + } else { + total_error + }; + let mut impulse = rows.accumulated[lane] + rows.target[lane]; + for (component, &value) in error[..3].iter().enumerate() { + let coefficient_lane = if lane == 3 { 0 } else { lane }; + impulse = rows.correction_columns[component][coefficient_lane].mul_add(value, impulse); + } + impulse + }); + + // 82AE2AC4..2B08: friction uses the preceding normal impulse. Crossing + // the static limit selects the dynamic limit. The two normal lanes use + // [0, f32::MAX]. Ordered comparisons also preserve the native selectors. + let next: [f32; 4] = core::array::from_fn(|lane| { + let normal = if lane == 1 || lane == 2 { + rows.accumulated[0] + } else { + 0.0 + }; + let upper_offset = if lane == 0 || lane == 3 { + f32::MAX + } else { + 0.0 + }; + let static_low = (-rows.static_friction).mul_add(normal, 0.0); + let static_high = rows.static_friction.mul_add(normal, upper_offset); + let dynamic_low = (-rows.dynamic_friction).mul_add(normal, 0.0); + let dynamic_high = rows.dynamic_friction.mul_add(normal, upper_offset); + let lower_selected = if candidate[lane] >= static_low { + candidate[lane] + } else { + dynamic_low + }; + if static_high >= candidate[lane] { + lower_selected + } else { + dynamic_high + } + }); + let change = core::array::from_fn(|lane| next[lane] - rows.accumulated[lane]); + rows.publish_impulses(record, next); + reaction_a.apply_contact(&rows, change, true); + reaction_b.apply_contact(&rows, change, false); + reaction_a.write(a); + reaction_b.write(b); +} diff --git a/crates/skate-core/src/physics/solver/contact_build.rs b/crates/skate-core/src/physics/solver/contact_build.rs new file mode 100644 index 00000000..e51a182b --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build.rs @@ -0,0 +1,95 @@ +//! TU3 ContactBatchBuild `82AE10C8`: contact geometry, mass response and targets. +//! +//! Arithmetic is reconstructed for finite inputs and finite intermediates. The +//! reciprocal response uses the isolated accepted Xenon `vrefp` approximation; +//! callers can still inject captured hardware results through +//! [`ContactMassResponse`] without changing the recovered builder order. +mod geometry; +mod input; +mod publication; + +use crate::math::Vector3; + +/// Complete preparation before the native reciprocal-estimate operation. +#[derive(Clone, Debug, PartialEq)] +pub struct ContactPreparation { + pub arms: [Vector3; 2], + pub axes: [Vector3; 3], + pub active: [bool; 2], + pub inverse_mass: [f32; 2], + pub point_acceleration: [Vector3; 2], + /// XYZ inverse-inertia response; W is the native packed carry value. + pub angular_response_a: [[f32; 4]; 3], + pub angular_response_b: [[f32; 4]; 3], + /// Angular response plus both inverse masses, in normal/tangent order. + pub effective_mass: [f32; 3], + pub separation_projection: [f32; 3], + pub restitution_projection: [f32; 3], + pub predicted_separation_projection: [f32; 3], + reaction_ids: [u32; 2], + body_ids: [u32; 2], + static_friction_bits: u32, + dynamic_friction_bits: u32, + contact_tag: u32, + combined_state_bit_8: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ContactBuildError { + ReciprocalEstimateUnavailable { effective_mass: [f32; 3] }, +} + +/// Supplies the three native `vrefp` results without changing their arguments. +pub trait ContactMassResponse { + fn inverse_effective_mass( + &mut self, + effective_mass: [f32; 3], + ) -> Result<[f32; 3], ContactBuildError>; +} + +pub struct UnavailableContactMassResponse; + +impl ContactMassResponse for UnavailableContactMassResponse { + fn inverse_effective_mass( + &mut self, + effective_mass: [f32; 3], + ) -> Result<[f32; 3], ContactBuildError> { + Err(ContactBuildError::ReciprocalEstimateUnavailable { effective_mass }) + } +} + +/// Default host implementation of the three `vrefp` lanes at `0x82AE158C`. +/// The approximation is isolated in `native_arithmetic` so hardware traces can +/// replace it without changing contact geometry or publication. +pub struct NativeContactMassResponse; + +impl ContactMassResponse for NativeContactMassResponse { + fn inverse_effective_mass( + &mut self, + effective_mass: [f32; 3], + ) -> Result<[f32; 3], ContactBuildError> { + Ok(effective_mass.map(crate::physics::native_arithmetic::reciprocal_estimate)) + } +} + +pub fn prepare_contact(record: &[u32; 64], time_step: f32) -> ContactPreparation { + geometry::prepare(input::ContactInput::decode(record), time_step) +} + +/// Builds all rows privately and publishes only after mass response succeeds. +pub fn build_with_response( + record: &mut [u32; 64], + time_step: f32, + response: &mut impl ContactMassResponse, +) -> Result<(), ContactBuildError> { + let prepared = prepare_contact(record, time_step); + let inverse_response = response.inverse_effective_mass(prepared.effective_mass)?; + *record = publication::compile(&prepared, inverse_response); + Ok(()) +} + +#[track_caller] +pub(super) fn build(record: &mut [u32; 64], time_step: f32) { + build_with_response(record, time_step, &mut NativeContactMassResponse) + .expect("the native contact mass response is infallible"); +} diff --git a/crates/skate-core/src/physics/solver/contact_build/geometry.rs b/crates/skate-core/src/physics/solver/contact_build/geometry.rs new file mode 100644 index 00000000..9940486c --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build/geometry.rs @@ -0,0 +1,119 @@ +//! Contact arms and inverse-inertia response, `82AE11BC..82AE1588`. +use super::{input::ContactInput, ContactPreparation}; +use crate::math::Vector3; + +fn components(v: Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} + +fn difference(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} + +fn cross(arm: Vector3, axis: Vector3) -> [f32; 3] { + [ + (-arm.z).mul_add(axis.y, arm.y.mul_add(axis.z, 0.0)), + (-arm.x).mul_add(axis.z, arm.z.mul_add(axis.x, 0.0)), + (-arm.y).mul_add(axis.x, arm.x.mul_add(axis.y, 0.0)), + ] +} + +fn inertia_response(full: Vector3, split: Vector3, mass: f32, jacobian: [f32; 3]) -> [f32; 4] { + let columns = [ + [full.x, full.y, full.z, mass], + [full.y, split.y, split.z, mass], + [full.z, split.z, split.x, mass], + ]; + core::array::from_fn(|component| { + let first = columns[0][component].mul_add(jacobian[0], 0.0); + let second = columns[1][component].mul_add(jacobian[1], first); + columns[2][component].mul_add(jacobian[2], second) + }) +} + +fn project(axes: [Vector3; 3], value: Vector3) -> [f32; 3] { + axes.map(|axis| { + let x = axis.x.mul_add(value.x, 0.0); + let xy = axis.y.mul_add(value.y, x); + axis.z.mul_add(value.z, xy) + }) +} + +pub(super) fn prepare(input: ContactInput, time_step: f32) -> ContactPreparation { + let arms = + core::array::from_fn(|body| difference(input.positions[body], input.bodies[body].center)); + let active = input.bodies.each_ref().map(|body| body.state & 4 == 4); + let inverse_mass = core::array::from_fn(|body| { + if active[body] { + input.bodies[body].inverse_mass + } else { + 0.0 + } + }); + let jacobians: [[[f32; 3]; 3]; 2] = arms.map(|arm| input.axes.map(|axis| cross(arm, axis))); + let responses: [[[f32; 4]; 3]; 2] = core::array::from_fn(|body| { + let (full, split) = if active[body] { + ( + input.bodies[body].inverse_inertia_full, + input.bodies[body].inverse_inertia_split, + ) + } else { + (Vector3::ZERO, Vector3::ZERO) + }; + jacobians[body].map(|jacobian| inertia_response(full, split, inverse_mass[body], jacobian)) + }); + let effective_mass = core::array::from_fn(|axis| { + let products: [f32; 3] = core::array::from_fn(|component| { + let a = jacobians[0][axis][component].mul_add(responses[0][axis][component], 0.0); + jacobians[1][axis][component].mul_add(responses[1][axis][component], a) + }); + (products[2] + (products[0] + inverse_mass[0])) + (products[1] + inverse_mass[1]) + }); + let point_acceleration = core::array::from_fn(|body| { + if !active[body] { + return Vector3::ZERO; + } + let arm = arms[body]; + let force = input.bodies[body].force_acceleration; + let torque = input.bodies[body].torque_acceleration; + Vector3::new( + arm.z.mul_add(torque.y, (-arm.y).mul_add(torque.z, force.x)), + arm.x.mul_add(torque.z, (-arm.z).mul_add(torque.x, force.y)), + arm.y.mul_add(torque.x, (-arm.x).mul_add(torque.y, force.z)), + ) + }); + let separation = difference(input.positions[1], input.positions[0]); + let relative_acceleration = difference(point_acceleration[1], point_acceleration[0]); + let scaled_velocity = components(input.relative_velocity).map(|v| time_step.mul_add(v, 0.0)); + let acceleration = + components(relative_acceleration).map(|v| v.mul_add(time_step * time_step, 0.0)); + let predicted = core::array::from_fn::<_, 3, _>(|i| { + scaled_velocity[i] + (components(separation)[i] + acceleration[i]) + }); + let restitution = scaled_velocity.map(|v| (-v).mul_add(input.restitution, 0.0)); + ContactPreparation { + arms, + axes: input.axes, + active, + inverse_mass, + point_acceleration, + angular_response_a: responses[0], + angular_response_b: responses[1], + effective_mass, + separation_projection: project(input.axes, separation), + restitution_projection: project( + input.axes, + Vector3::new(restitution[0], restitution[1], restitution[2]), + ), + predicted_separation_projection: project( + input.axes, + Vector3::new(predicted[0], predicted[1], predicted[2]), + ), + reaction_ids: input.bodies.each_ref().map(|body| body.reaction_id), + body_ids: input.body_ids, + static_friction_bits: input.static_friction_bits, + dynamic_friction_bits: input.dynamic_friction_bits, + contact_tag: input.contact_tag, + combined_state_bit_8: (input.bodies[0].state | input.bodies[1].state) & 8, + } +} diff --git a/crates/skate-core/src/physics/solver/contact_build/input.rs b/crates/skate-core/src/physics/solver/contact_build/input.rs new file mode 100644 index 00000000..66335442 --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build/input.rs @@ -0,0 +1,58 @@ +//! Decodes the uncompiled contact copied by the native contact producer. +use crate::math::Vector3; + +pub(super) struct BodyInput { + pub center: Vector3, + pub inverse_inertia_full: Vector3, + pub inverse_inertia_split: Vector3, + pub inverse_mass: f32, + pub state: u32, + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub reaction_id: u32, +} + +pub(super) struct ContactInput { + pub positions: [Vector3; 2], + pub axes: [Vector3; 3], + pub relative_velocity: Vector3, + pub restitution: f32, + pub bodies: [BodyInput; 2], + pub body_ids: [u32; 2], + pub static_friction_bits: u32, + pub dynamic_friction_bits: u32, + pub contact_tag: u32, +} + +fn vector(words: &[u32; 64], row: usize) -> Vector3 { + Vector3::new( + f32::from_bits(words[row * 4]), + f32::from_bits(words[row * 4 + 1]), + f32::from_bits(words[row * 4 + 2]), + ) +} + +impl ContactInput { + pub fn decode(words: &[u32; 64]) -> Self { + Self { + positions: [vector(words, 0), vector(words, 1)], + axes: core::array::from_fn(|axis| vector(words, 2 + axis)), + relative_velocity: vector(words, 5), + restitution: f32::from_bits(words[11]), + bodies: core::array::from_fn(|body| BodyInput { + center: vector(words, 6 + body), + inverse_inertia_full: vector(words, 8 + body), + inverse_inertia_split: vector(words, 10 + body), + inverse_mass: f32::from_bits(words[(8 + body) * 4 + 3]), + state: words[(10 + body) * 4 + 3], + force_acceleration: vector(words, 12 + body), + torque_acceleration: vector(words, 14 + body), + reaction_id: words[(6 + body) * 4 + 3], + }), + body_ids: [words[3], words[7]], + static_friction_bits: words[15], + dynamic_friction_bits: words[19], + contact_tag: words[23], + } + } +} diff --git a/crates/skate-core/src/physics/solver/contact_build/publication.rs b/crates/skate-core/src/physics/solver/contact_build/publication.rs new file mode 100644 index 00000000..d85beed7 --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build/publication.rs @@ -0,0 +1,94 @@ +//! Scaled Jacobian and restitution target publication, `82AE1590..82AE15F4`. +use super::ContactPreparation; +use crate::math::Vector3; + +fn vector_row(words: &mut [u32; 64], row: usize, xyz: Vector3, carry: u32) { + words[row * 4..row * 4 + 4].copy_from_slice(&[ + xyz.x.to_bits(), + xyz.y.to_bits(), + xyz.z.to_bits(), + carry, + ]); +} + +fn float_row(words: &mut [u32; 64], row: usize, values: [f32; 4]) { + words[row * 4..row * 4 + 4].copy_from_slice(&values.map(f32::to_bits)); +} + +pub(super) fn compile(prepared: &ContactPreparation, inverse_response: [f32; 3]) -> [u32; 64] { + let mut words = [0; 64]; + for body in 0..2 { + vector_row( + &mut words, + body, + prepared.arms[body], + prepared.reaction_ids[body], + ); + } + let axes = prepared.axes.map(|axis| [axis.x, axis.y, axis.z]); + let carry = [ + prepared.combined_state_bit_8, + prepared.static_friction_bits, + prepared.dynamic_friction_bits, + ]; + for component in 0..3 { + let column = core::array::from_fn::<_, 3, _>(|axis| { + axes[axis][component].mul_add(inverse_response[axis], 0.0) + }); + vector_row( + &mut words, + 2 + component, + Vector3::new(column[0], column[1], column[2]), + carry[component], + ); + } + // Row 5 is the newly cleared accumulated impulse, including its carry. + let separation = prepared.separation_projection[0].mul_add(inverse_response[0], 0.0); + let restitution = prepared.restitution_projection[0].mul_add(inverse_response[0], 0.0); + let predicted = core::array::from_fn::<_, 3, _>(|axis| { + prepared.predicted_separation_projection[axis].mul_add(inverse_response[axis], 0.0) + }); + let mut normal_adjustment = if restitution >= 0.0 { + 0.0 + } else { + separation + restitution + }; + if separation >= 0.0 { + normal_adjustment = restitution; + } + if 0.0 >= predicted[0] { + normal_adjustment = 0.0; + } + float_row( + &mut words, + 6, + [ + predicted[0] - normal_adjustment, + predicted[1] - 0.0, + predicted[2] - 0.0, + separation, + ], + ); + let identifiers = [ + prepared.body_ids[0], + prepared.body_ids[1], + prepared.contact_tag, + ]; + for axis in 0..3 { + vector_row( + &mut words, + 7 + axis * 3, + prepared.axes[axis], + identifiers[axis], + ); + let mut a = prepared.angular_response_a[axis]; + let mut b = prepared.angular_response_b[axis]; + if axis == 0 { + a[3] = prepared.inverse_mass[0]; + b[3] = prepared.inverse_mass[1]; + } + float_row(&mut words, 8 + axis * 3, a); + float_row(&mut words, 9 + axis * 3, b); + } + words +} diff --git a/crates/skate-core/src/physics/solver/contact_data.rs b/crates/skate-core/src/physics/solver/contact_data.rs new file mode 100644 index 00000000..decd583d --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_data.rs @@ -0,0 +1,121 @@ +//! Named view of the compiled contact and shared reaction records. +//! Fourth components retain the native packed layout, including inverse mass +//! alongside each body's first angular response. +type Components = [f32; 4]; + +pub(super) struct ContactRows { + pub arm_a: Components, + pub arm_b: Components, + pub correction_columns: [Components; 3], + pub static_friction: f32, + pub dynamic_friction: f32, + pub accumulated: Components, + pub target: Components, + axes: [Components; 3], + angular_a: [Components; 3], + angular_b: [Components; 3], +} +impl ContactRows { + pub fn read(words: &[u32]) -> Self { + assert_eq!(words.len(), 64); + Self { + arm_a: read(words, 0), + arm_b: read(words, 1), + correction_columns: core::array::from_fn(|i| read(words, 2 + i)), + static_friction: f32::from_bits(words[15]), + dynamic_friction: f32::from_bits(words[19]), + accumulated: read(words, 5), + target: read(words, 6), + axes: core::array::from_fn(|i| read(words, 7 + 3 * i)), + angular_a: core::array::from_fn(|i| read(words, 8 + 3 * i)), + angular_b: core::array::from_fn(|i| read(words, 9 + 3 * i)), + } + } + pub fn publish_impulses(&self, words: &mut [u32], impulse: Components) { + write(words, 5, impulse); + } +} + +pub(super) struct Reaction { + linear: Components, + position: Components, + angular: Components, + orientation: Components, +} +impl Reaction { + pub fn read(words: &[u32]) -> Self { + assert_eq!(words.len(), 16); + Self { + linear: read(words, 0), + position: read(words, 1), + angular: read(words, 2), + orientation: read(words, 3), + } + } + pub fn point_position(&self, arm: Components) -> Components { + point_correction(arm, self.position, self.orientation) + } + pub fn point_velocity(&self, arm: Components) -> Components { + point_correction(arm, self.linear, self.angular) + } + pub fn apply_contact(&mut self, rows: &ContactRows, change: Components, body_a: bool) { + let response = if body_a { + &rows.angular_a + } else { + &rows.angular_b + }; + let inverse_mass = response[0][3]; + for lane in 0..4 { + let mut linear_impulse = rows.axes[0][lane].mul_add(change[0], 0.0); + linear_impulse = rows.axes[1][lane].mul_add(change[1], linear_impulse); + linear_impulse = rows.axes[2][lane].mul_add(change[2], linear_impulse); + let position_impulse = rows.axes[0][lane].mul_add(change[3], 0.0); + if body_a { + for (axis, &impulse) in change[..3].iter().enumerate() { + self.angular[lane] = response[axis][lane].mul_add(impulse, self.angular[lane]); + } + self.orientation[lane] = + response[0][lane].mul_add(change[3], self.orientation[lane]); + self.linear[lane] = linear_impulse.mul_add(inverse_mass, self.linear[lane]); + self.position[lane] = position_impulse.mul_add(inverse_mass, self.position[lane]); + } else { + for (axis, &impulse) in change[..3].iter().enumerate() { + self.angular[lane] = + (-response[axis][lane]).mul_add(impulse, self.angular[lane]); + } + self.orientation[lane] = + (-response[0][lane]).mul_add(change[3], self.orientation[lane]); + self.linear[lane] = (-linear_impulse).mul_add(inverse_mass, self.linear[lane]); + self.position[lane] = + (-position_impulse).mul_add(inverse_mass, self.position[lane]); + } + } + } + pub fn write(&self, words: &mut [u32]) { + for (row, value) in [self.linear, self.position, self.angular, self.orientation] + .into_iter() + .enumerate() + { + write(words, row, value); + } + } +} + +// 82AE297C..2A18: linear + angular cross arm. Fuse the first product into +// linear before subtracting the second; a separate cross product rounds early. +fn point_correction(arm: Components, linear: Components, angular: Components) -> Components { + core::array::from_fn(|lane| { + let next = if lane == 3 { 3 } else { (lane + 1) % 3 }; + let previous = if lane == 3 { 3 } else { (lane + 2) % 3 }; + let first = angular[next].mul_add(arm[previous], linear[lane]); + (-angular[previous]).mul_add(arm[next], first) + }) +} +fn read(words: &[u32], row: usize) -> Components { + core::array::from_fn(|lane| f32::from_bits(words[4 * row + lane])) +} +fn write(words: &mut [u32], row: usize, value: Components) { + for (lane, value) in value.into_iter().enumerate() { + words[4 * row + lane] = value.to_bits(); + } +} diff --git a/crates/skate-core/src/physics/solver/drive.rs b/crates/skate-core/src/physics/solver/drive.rs new file mode 100644 index 00000000..d6a43793 --- /dev/null +++ b/crates/skate-core/src/physics/solver/drive.rs @@ -0,0 +1,65 @@ +//! TU3 drive pass 82AE2E78..82AE30D8, reconstructed from direct disassembly. +//! Finite compiled coefficients are required; exceptional SIMD arithmetic is +//! not claimed to match hardware. Linear/angular softness lanes are retained. + +use super::joint::data::{ + ConstraintGeometry, ReactionState, project_correction, publish_reactions, read_column, + write_column, xyz, +}; + +pub(super) fn solve(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + let geometry = ConstraintGeometry::decode(record); + let mut body_a = ReactionState::decode(a); + let mut body_b = ReactionState::decode(b); + let (relative_point, relative_angular) = geometry.relative_corrections(&body_a, &body_b); + let old_linear = read_column(record, 32); + let old_angular = read_column(record, 48); + let linear_response = + project_correction(&geometry.linear_projection, relative_point, old_linear); + let angular_response = + project_correction(&geometry.angular_projection, relative_angular, old_angular); + let linear_target = read_column(record, 160); + let angular_target = read_column(record, 176); + let linear_limits = [ + read_column(record, 96)[3], + read_column(record, 112)[3], + linear_target[3], + ]; + let angular_limits = [ + read_column(record, 128)[3], + read_column(record, 144)[3], + angular_target[3], + ]; + + let mut linear_impulse = old_linear; + let mut angular_impulse = old_angular; + for axis in 0..3 { + let linear_candidate = linear_response[axis].mul_add(old_linear[3], linear_target[axis]); + let angular_candidate = + angular_response[axis].mul_add(old_angular[3], angular_target[axis]); + linear_impulse[axis] = symmetric_impulse_limit(linear_candidate, linear_limits[axis]); + angular_impulse[axis] = symmetric_impulse_limit(angular_candidate, angular_limits[axis]); + } + let linear_change = xyz(core::array::from_fn(|axis| { + linear_impulse[axis] - old_linear[axis] + })); + let angular_change = xyz(core::array::from_fn(|axis| { + angular_impulse[axis] - old_angular[axis] + })); + // 82AE2FB8/2FC4 select XYZ from the new impulses while retaining each + // accumulator's fourth (softness) lane from its old value. + write_column(record, 32, linear_impulse); + write_column(record, 48, angular_impulse); + geometry.apply_impulse_changes(&mut body_a, &mut body_b, linear_change, angular_change); + publish_reactions(&body_a, &body_b, a, b); +} + +fn symmetric_impulse_limit(candidate: f32, limit: f32) -> f32 { + let upper_limited = if candidate < limit { candidate } else { limit }; + let negative_limit = 0.0 - limit; + if upper_limited > negative_limit { + upper_limited + } else { + negative_limit + } +} diff --git a/crates/skate-core/src/physics/solver/joint.rs b/crates/skate-core/src/physics/solver/joint.rs new file mode 100644 index 00000000..07167c59 --- /dev/null +++ b/crates/skate-core/src/physics/solver/joint.rs @@ -0,0 +1,75 @@ +//! TU3 joint pass 82AE2BC8..82AE2E44, reconstructed from direct disassembly. +//! Finite compiled coefficients are required; exceptional SIMD arithmetic is +//! not claimed to match hardware. The packed public boundary remains unchanged. + +#[path = "joint_data.rs"] +pub(super) mod data; +use data::{ + ConstraintGeometry, ReactionState, project_correction, publish_reactions, read_column, + write_column, xyz, +}; + +pub(super) fn solve(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + let geometry = ConstraintGeometry::decode(record); + let mut body_a = ReactionState::decode(a); + let mut body_b = ReactionState::decode(b); + let (relative_point, relative_angular) = geometry.relative_corrections(&body_a, &body_b); + let old_linear = read_column(record, 32); + let old_angular = read_column(record, 48); + let linear_candidate = + project_correction(&geometry.linear_projection, relative_point, old_linear); + let angular_candidate = + project_correction(&geometry.angular_projection, relative_angular, old_angular); + + let linear_low = read_column(record, 160); + let linear_high = read_column(record, 176); + // 82AE2C90..2CAC gathers angular bounds from packed fourth lanes. The + // fourth output duplicates the Z bound; joint accumulators store all lanes. + let angular_low = [ + read_column(record, 96)[3], + read_column(record, 112)[3], + linear_low[3], + linear_low[3], + ]; + let angular_high = [ + read_column(record, 128)[3], + read_column(record, 144)[3], + linear_high[3], + linear_high[3], + ]; + let angular_impulse = core::array::from_fn(|axis| { + joint_limit_correction( + angular_candidate[axis], + angular_low[axis], + angular_high[axis], + ) + }); + let linear_impulse = core::array::from_fn(|axis| { + joint_limit_correction(linear_candidate[axis], linear_low[axis], linear_high[axis]) + }); + let angular_change = xyz(core::array::from_fn(|axis| { + angular_impulse[axis] - old_angular[axis] + })); + let linear_change = xyz(core::array::from_fn(|axis| { + linear_impulse[axis] - old_linear[axis] + })); + + // The two candidates above used the same incoming body corrections. Native + // stores angular accumulation first, then linear accumulation, then applies + // the changes through both bodies' inverse mass and inertia. + write_column(record, 48, angular_impulse); + write_column(record, 32, linear_impulse); + geometry.apply_impulse_changes(&mut body_a, &mut body_b, linear_change, angular_change); + publish_reactions(&body_a, &body_b, a, b); +} + +/// Native two-sided limit projection at 82AE2CCC..82AE2D00. These offsets are +/// preconditioned limit errors from the builder, not impulse min/max bounds. +/// An error inside the permitted interval produces zero correction. +fn joint_limit_correction(candidate: f32, low: f32, high: f32) -> f32 { + let high_error = high + candidate; + let low_error = low + candidate; + let negative_correction = if high_error < 0.0 { high_error } else { 0.0 }; + let positive_correction = if low_error > 0.0 { low_error } else { 0.0 }; + positive_correction + negative_correction +} diff --git a/crates/skate-core/src/physics/solver/joint_data.rs b/crates/skate-core/src/physics/solver/joint_data.rs new file mode 100644 index 00000000..cb28aa30 --- /dev/null +++ b/crates/skate-core/src/physics/solver/joint_data.rs @@ -0,0 +1,171 @@ +//! Decoded joint/drive geometry and shared reaction application. +//! +//! Record boundaries are four-float columns because native solver stores also +//! update their fourth lanes. Physical vectors/impulses have three components; +//! the fourth values are packed carry fields, not extra spatial dimensions. + +use crate::math::Vector3; + +pub(in crate::physics::solver) fn read_column(words: &[u32], byte_offset: usize) -> [f32; 4] { + core::array::from_fn(|component| f32::from_bits(words[byte_offset / 4 + component])) +} + +pub(in crate::physics::solver) fn write_column( + words: &mut [u32], + byte_offset: usize, + values: [f32; 4], +) { + for (destination, value) in words[byte_offset / 4..byte_offset / 4 + 4] + .iter_mut() + .zip(values) + { + *destination = value.to_bits(); + } +} + +pub(in crate::physics::solver) fn xyz(values: [f32; 4]) -> Vector3 { + Vector3::new(values[0], values[1], values[2]) +} + +/// `82AE1AE8` and `82AE3BC8` write these same response fields. Columns at +/// 64/96/128 and 80/112/144 project relative corrections into constraint axes; +/// 192..224 and 240..272 convert impulse changes back into world space. +pub(in crate::physics::solver) struct ConstraintGeometry { + pub arm_a: Vector3, + pub arm_b: Vector3, + pub linear_projection: [[f32; 4]; 3], + pub angular_projection: [[f32; 4]; 3], + linear_axes: [[f32; 4]; 3], + angular_axes: [[f32; 4]; 3], + inverse_inertia_a: [[f32; 4]; 3], + inverse_inertia_b: [[f32; 4]; 3], +} + +impl ConstraintGeometry { + pub fn decode(words: &[u32]) -> Self { + assert_eq!(words.len(), 96); + Self { + arm_a: xyz(read_column(words, 0)), + arm_b: xyz(read_column(words, 16)), + linear_projection: core::array::from_fn(|axis| read_column(words, 64 + axis * 32)), + angular_projection: core::array::from_fn(|axis| read_column(words, 80 + axis * 32)), + linear_axes: core::array::from_fn(|axis| read_column(words, 192 + axis * 16)), + angular_axes: core::array::from_fn(|axis| read_column(words, 240 + axis * 16)), + inverse_inertia_a: core::array::from_fn(|axis| read_column(words, 288 + axis * 16)), + inverse_inertia_b: core::array::from_fn(|axis| read_column(words, 336 + axis * 16)), + } + } + + /// Both candidates must read this incoming snapshot before either impulse + /// is applied: 82AE2C00..2CC4 (joint), 82AE2E90..2F80 (drive). + pub fn relative_corrections(&self, a: &ReactionState, b: &ReactionState) -> (Vector3, Vector3) { + let point_a = point_correction(a, self.arm_a); + let point_b = point_correction(b, self.arm_b); + ( + Vector3::new( + point_b.x - point_a.x, + point_b.y - point_a.y, + point_b.z - point_a.z, + ), + Vector3::new( + b.angular[0] - a.angular[0], + b.angular[1] - a.angular[1], + b.angular[2] - a.angular[2], + ), + ) + } + + /// 82AE2D24..2E40 / 82AE2FE0..30D4. One linear and one angular impulse + /// change affect both bodies through their own arm and inverse inertia. + pub fn apply_impulse_changes( + &self, + a: &mut ReactionState, + b: &mut ReactionState, + linear_change: Vector3, + angular_change: Vector3, + ) { + let linear_impulse = project_correction(&self.linear_axes, linear_change, [0.0; 4]); + let angular_impulse = xyz(project_correction( + &self.angular_axes, + angular_change, + [0.0; 4], + )); + let torque_a = torque_at_offset(self.arm_a, xyz(linear_impulse), angular_impulse); + let torque_b = torque_at_offset(self.arm_b, xyz(linear_impulse), angular_impulse); + let inverse_mass_a = self.inverse_inertia_a[0][3]; + let inverse_mass_b = self.inverse_inertia_b[0][3]; + // Native publication order: linear A, linear B, angular A, angular B. + for (component, impulse) in linear_impulse.into_iter().enumerate() { + a.linear[component] = impulse.mul_add(inverse_mass_a, a.linear[component]); + b.linear[component] = (-impulse).mul_add(inverse_mass_b, b.linear[component]); + } + a.angular = project_correction(&self.inverse_inertia_a, torque_a, a.angular); + for component in 0..4 { + let after_x = + (-self.inverse_inertia_b[0][component]).mul_add(torque_b.x, b.angular[component]); + let after_y = (-self.inverse_inertia_b[1][component]).mul_add(torque_b.y, after_x); + b.angular[component] = + (-self.inverse_inertia_b[2][component]).mul_add(torque_b.z, after_y); + } + } +} + +/// Velocity-producing displacement corrections at reaction offsets 0/32. +/// Contact-only pose corrections at 16/48 are neither read nor overwritten. +pub(in crate::physics::solver) struct ReactionState { + linear: [f32; 4], + angular: [f32; 4], +} + +impl ReactionState { + pub fn decode(words: &[u32]) -> Self { + assert_eq!(words.len(), 16); + Self { + linear: read_column(words, 0), + angular: read_column(words, 32), + } + } +} + +pub(in crate::physics::solver) fn publish_reactions( + a: &ReactionState, + b: &ReactionState, + words_a: &mut [u32], + words_b: &mut [u32], +) { + write_column(words_a, 0, a.linear); + write_column(words_b, 0, b.linear); + write_column(words_a, 32, a.angular); + write_column(words_b, 32, b.angular); +} + +/// Matrix projection with the native X, Y, Z fused accumulation order. The +/// incoming accumulated impulse is the initial value for candidate projection. +pub(in crate::physics::solver) fn project_correction( + columns: &[[f32; 4]; 3], + correction: Vector3, + initial: [f32; 4], +) -> [f32; 4] { + core::array::from_fn(|component| { + let after_x = columns[0][component].mul_add(correction.x, initial[component]); + let after_y = columns[1][component].mul_add(correction.y, after_x); + columns[2][component].mul_add(correction.z, after_y) + }) +} + +fn point_correction(reaction: &ReactionState, arm: Vector3) -> Vector3 { + let [x, y, z, _] = reaction.angular; + Vector3::new( + (-z).mul_add(arm.y, y.mul_add(arm.z, reaction.linear[0])), + (-x).mul_add(arm.z, z.mul_add(arm.x, reaction.linear[1])), + (-y).mul_add(arm.x, x.mul_add(arm.y, reaction.linear[2])), + ) +} + +fn torque_at_offset(arm: Vector3, impulse: Vector3, angular_impulse: Vector3) -> Vector3 { + Vector3::new( + (-arm.z).mul_add(impulse.y, arm.y.mul_add(impulse.z, angular_impulse.x)), + (-arm.x).mul_add(impulse.z, arm.z.mul_add(impulse.x, angular_impulse.y)), + (-arm.y).mul_add(impulse.x, arm.x.mul_add(impulse.y, angular_impulse.z)), + ) +} diff --git a/crates/skate-core/src/physics/solver/packed.rs b/crates/skate-core/src/physics/solver/packed.rs new file mode 100644 index 00000000..560eeb7e --- /dev/null +++ b/crates/skate-core/src/physics/solver/packed.rs @@ -0,0 +1,58 @@ +//! Native solver records with host indices replacing guest reaction pointers. +use super::{contact, drive, joint}; + +#[derive(Clone, Debug)] +pub struct Constraint { + pub words: [u32; WORDS], + pub reaction_a: usize, + pub reaction_b: usize, +} + +pub type Contact = Constraint<64>; +pub type Joint = Constraint<96>; +pub type Drive = Constraint<96>; +pub type Reaction = [u32; 16]; + +/// Complete contact → joint → drive loops of HorusPipeline (82AE27D0). +/// Contact position corrections remain distinct from velocity-producing +/// reactions; all families see preceding writes during the same iteration. +pub fn solve( + contacts: &mut [Contact], + joints: &mut [Joint], + drives: &mut [Drive], + reactions: &mut [Reaction], + iterations: u32, +) { + validate(contacts, reactions.len()); + validate(joints, reactions.len()); + validate(drives, reactions.len()); + for _ in 0..iterations { + pass(contacts, reactions, contact::solve); + pass(joints, reactions, joint::solve); + pass(drives, reactions, drive::solve); + } +} + +fn validate(constraints: &[Constraint], count: usize) { + for c in constraints { + assert_ne!(c.reaction_a, c.reaction_b); + assert!(c.reaction_a < count && c.reaction_b < count); + } +} + +fn pass( + constraints: &mut [Constraint], + reactions: &mut [Reaction], + kernel: fn(&mut [u32], &mut [u32], &mut [u32]), +) { + for c in constraints { + let (a, b) = if c.reaction_a < c.reaction_b { + let (left, right) = reactions.split_at_mut(c.reaction_b); + (&mut left[c.reaction_a], &mut right[0]) + } else { + let (left, right) = reactions.split_at_mut(c.reaction_a); + (&mut right[0], &mut left[c.reaction_b]) + }; + kernel(&mut c.words, a, b); + } +} diff --git a/crates/skate-core/src/physics/solver/packing.rs b/crates/skate-core/src/physics/solver/packing.rs new file mode 100644 index 00000000..14b46489 --- /dev/null +++ b/crates/skate-core/src/physics/solver/packing.rs @@ -0,0 +1,136 @@ +//! Semantic builder outputs → native compiled-record layout. +use super::{packed, *}; +use crate::math::Vector3; +use crate::physics::rigid_body::RetailPackedWorldInverseInertia; + +pub(super) fn contact(c: &RetailContactJacobian) -> packed::Contact { + packed::Contact { + words: *c.words(), + reaction_a: c.reaction_index_a, + reaction_b: c.reaction_index_b, + } +} + +pub(super) fn drive(d: &RetailDriveRows) -> packed::Drive { + let mut words = [0; 96]; + vector(&mut words, 0, d.arm_a, 0.0); + vector(&mut words, 1, d.arm_b, 0.0); + row( + &mut words, + 2, + d.accumulated_linear_impulse, + d.linear_softness, + ); + row( + &mut words, + 3, + d.accumulated_angular_impulse, + d.angular_softness, + ); + for component in 0..3 { + row( + &mut words, + 4 + component * 2, + core::array::from_fn(|r| { + xyz(d.linear_axes[r])[component] * d.linear_inverse_effective_mass[r] + }), + 0.0, + ); + row( + &mut words, + 5 + component * 2, + core::array::from_fn(|r| { + xyz(d.angular_axes[r])[component] * d.angular_inverse_effective_mass[r] + }), + 0.0, + ); + vector(&mut words, 12 + component, d.linear_axes[component], 0.0); + vector(&mut words, 15 + component, d.angular_axes[component], 0.0); + } + row( + &mut words, + 10, + d.linear_target_impulse, + d.linear_maximum_impulse[2], + ); + row( + &mut words, + 11, + d.angular_target_impulse, + d.angular_maximum_impulse[2], + ); + words[6 * 4 + 3] = d.linear_maximum_impulse[0].to_bits(); + words[7 * 4 + 3] = d.linear_maximum_impulse[1].to_bits(); + words[8 * 4 + 3] = d.angular_maximum_impulse[0].to_bits(); + words[9 * 4 + 3] = d.angular_maximum_impulse[1].to_bits(); + for (index, body) in [(18, d.frame_a_body), (21, d.frame_b_body)] { + if body.state & 4 != 0 { + let columns = inertia_columns(body.world_inverse_inertia); + for (i, column) in columns.into_iter().enumerate() { + vector( + &mut words, + index + i, + column, + if i == 0 { body.inverse_mass } else { 0.0 }, + ); + } + } + } + packed::Drive { + words, + reaction_a: d.frame_a_body.reaction_index, + reaction_b: d.frame_b_body.reaction_index, + } +} + +pub(super) fn reaction(r: RetailReactionCorrections) -> packed::Reaction { + let mut words = [0; 16]; + for (i, v) in [ + r.linear_displacement, + r.position_displacement, + r.angular_displacement, + r.orientation_displacement, + ] + .into_iter() + .enumerate() + { + vector(&mut words, i, v, 0.0); + } + words +} +pub(super) fn unpack_reaction(words: packed::Reaction) -> RetailReactionCorrections { + let v = |i| { + let v = read_xyz(&words, i); + Vector3::new(v[0], v[1], v[2]) + }; + RetailReactionCorrections { + linear_displacement: v(0), + position_displacement: v(1), + angular_displacement: v(2), + orientation_displacement: v(3), + } +} +pub(super) fn read_xyz(words: &[u32], i: usize) -> [f32; 3] { + core::array::from_fn(|j| f32::from_bits(words[4 * i + j])) +} +fn row(words: &mut [u32], i: usize, v: [f32; 3], w: f32) { + words[4 * i..4 * i + 4].copy_from_slice(&[ + v[0].to_bits(), + v[1].to_bits(), + v[2].to_bits(), + w.to_bits(), + ]); +} +fn vector(words: &mut [u32], i: usize, v: Vector3, w: f32) { + row(words, i, xyz(v), w); +} +fn xyz(v: Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} +fn inertia_columns(i: RetailPackedWorldInverseInertia) -> [Vector3; 3] { + [ + Vector3::new(i.full.x, i.full.y, i.full.z), + Vector3::new(i.full.y, i.split.y, i.split.z), + Vector3::new(i.full.z, i.split.z, i.split.x), + ] +} diff --git a/crates/skate-core/src/physics/startup/mod.rs b/crates/skate-core/src/physics/startup/mod.rs new file mode 100644 index 00000000..8c27deb7 --- /dev/null +++ b/crates/skate-core/src/physics/startup/mod.rs @@ -0,0 +1,7 @@ +//! Recovered TU3 board reset operations. Caller-owned spawn selection, state +//! transitions and activation are separate from resetting these native records. + +mod reset; +mod skateboard; +pub use reset::{reset_board_body, reset_collision_info}; +pub use skateboard::reset_skateboard; diff --git a/crates/skate-core/src/physics/startup/reset.rs b/crates/skate-core/src/physics/startup/reset.rs new file mode 100644 index 00000000..d4d13bfa --- /dev/null +++ b/crates/skate-core/src/physics/startup/reset.rs @@ -0,0 +1,76 @@ +//! Complete 82C0D680 and its 82C00D68 dependency, guest big-endian record bytes. + +fn word(bytes: &[u8], offset: usize) -> u32 { + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) +} +fn put(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} +fn up(bytes: &mut [u8], offset: usize) { + bytes[offset..offset + 16].fill(0); + put(bytes, offset + 4, 0x3f80_0000); +} + +/// Complete CollisionInfo initialization/reset at 82C00D68. This is distinct +/// from the selective per-frame reset at 82C00CA0. Unwritten bytes survive. +pub fn reset_collision_info(info: &mut [u8; 812]) { + up(info, 0); + up(info, 16); + for i in 0..7 { + up(info, 32 + i * 16); + for offset in [144, 256, 368, 480] { + info[offset + i * 16..offset + i * 16 + 16].fill(0); + } + for offset in [692, 720, 748] { + put(info, offset + i * 4, 0); + } + info[780 + i] = 0; + } + info[592..608].fill(0); + for i in 0..4 { + up(info, 608 + i * 16); + put(info, 672 + i * 4, 0); + info[688 + i] = 0; + } + for offset in [776, 788, 792, 796, 800] { + put(info, offset, 0); + } + info[804] = 0; + info[805] = 0; + let flags = word(info, 808) & 0x01ff_ffff; + put(info, 808, flags); +} + +/// Complete SkateboardBody reset at 82C0D680. `bodies` are assembly parts in +/// their native order; each gravity vector is read from that body's Simulation +/// at +144. The hook assembly is separate and is not traversed here. +/// +/// XYZ rates/torque clear; force XYZ receives the simulation vector. Fourth +/// lanes, pose, body flags/cooldown and all other body words survive. Ancillary +/// board bytes are preserved except for stores present in the native function. +/// Returns the original byte at board+8384, matching native r3. +pub fn reset_board_body( + board: &mut [u8; 8400], + bodies: &mut [[u32; 44]], + simulation_gravity: &[[u32; 4]], +) -> u8 { + assert_eq!(bodies.len(), simulation_gravity.len()); + for (body, gravity) in bodies.iter_mut().zip(simulation_gravity) { + body[8..11].fill(0); + body[12..15].fill(0); + body[36..39].copy_from_slice(&gravity[..3]); + body[40..43].fill(0); + } + put(board, 7680, 0); + put(board, 7684, 0); + reset_collision_info((&mut board[64..876]).try_into().unwrap()); + let old_flags = board[8384]; + board[8384] &= 0x3f; + for offset in [7968, 8032] { + board[offset..offset + 52].fill(0); + board[offset + 52] = 0; + } + board[1008..1044].fill(0); + board[1044] = 0; + old_flags +} diff --git a/crates/skate-core/src/physics/startup/skateboard.rs b/crates/skate-core/src/physics/startup/skateboard.rs new file mode 100644 index 00000000..8de5bccc --- /dev/null +++ b/crates/skate-core/src/physics/startup/skateboard.rs @@ -0,0 +1,61 @@ +use super::reset_board_body; +use crate::physics::board_pose::{PartPose, PoseMatrix, set_board_transform, set_part_transform}; +/// Complete 82C05F50 reset orchestration. `authored` supplies the seven matrices +/// produced by InitializeTransforms82C0ADF0 for the current stock geometry; +/// deck6 is published first, then wheels0..3 and trucks4..5. This explicit input +/// does not select a spawn or change joint anchors to a presumed settled pose. +/// +/// `wrapper` is the native Skateboard prefix; `wrapper_base` only reconstructs +/// its three force-queue pointers. `body_state` is its SkateboardBody record. +/// Parts must have live bodies, as in the native seven-part board assembly. +/// Processed+2468 bit20 flips all four lanes of Ri and At before SetTransform. +/// The hook receives that target separately and its body rates are not reset. +#[allow(clippy::too_many_arguments)] +pub fn reset_skateboard( + wrapper: &mut [u8; 1360], + wrapper_base: u32, + body_state: &mut [u8; 8400], + parts: &mut [PartPose; 7], + hook: &mut PartPose, + authored: [PoseMatrix; 7], + mut requested: PoseMatrix, + processed_flags_2468: u32, + gravity: &[[u32; 4]; 7], +) -> u32 { + assert!(parts.iter().all(|part| part.body.is_some())); + for i in [6, 0, 1, 2, 3, 4, 5] { + set_part_transform(&mut parts[i], authored[i]); + } + if processed_flags_2468 & 0x0010_0000 != 0 { + for i in [0, 1, 2, 3, 8, 9, 10, 11] { + requested[i] ^= 0x8000_0000; + } + } + set_board_transform(parts, hook, requested); + let mut bodies = core::array::from_fn::<_, 7, _>(|i| parts[i].body.unwrap()); + reset_board_body(body_state, &mut bodies, gravity); + for (part, body) in parts.iter_mut().zip(bodies) { + part.body = Some(body); + } + put(wrapper, 256, 0); + put(body_state, 7680, 0); + put(body_state, 7684, 0); + wrapper[240..256].fill(0); + put(wrapper, 252, 0x3f80_0000); + wrapper[290] = 0; + body_state[960..992].fill(0); + put(body_state, 972, 2); + put(body_state, 988, 2); + put(wrapper, 324, wrapper_base.wrapping_add(352)); + put(wrapper, 320, wrapper_base.wrapping_add(352)); + put(wrapper, 328, wrapper_base.wrapping_add(1360)); + put(wrapper, 260, 0); + wrapper[192..208].fill(0); + put(wrapper, 196, 0x3f80_0000); + wrapper[291] = 0; + 240 +} + +fn put(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} diff --git a/crates/skate-core/src/physics/tests/assembly.rs b/crates/skate-core/src/physics/tests/assembly.rs new file mode 100644 index 00000000..10e942e9 --- /dev/null +++ b/crates/skate-core/src/physics/tests/assembly.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::{ + math::Vector3, + physics::{ + drive_frames::{ + default_live_body_orientations, default_live_body_transforms, default_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + mass::default_skateboard_mass_properties, + rigid_body::{RetailReactionCorrections, basis_from_quaternion, world_inverse_inertia}, + solver::solve_constraints, + }, +}; + +fn bodies() -> [BodySnapshot; BODY_COUNT] { + let poses = default_live_body_transforms(); + let orientations = default_live_body_orientations(); + let masses = default_skateboard_mass_properties(); + core::array::from_fn(|i| { + let basis = basis_from_quaternion(orientations[i]); + BodySnapshot { + state_flags: 4, + inertia: masses[i].dynamics, + rates: RetailBodyRates { + orientation: orientations[i], + basis, + world_inverse_inertia: world_inverse_inertia( + basis, + masses[i].dynamics.inverse_tensor, + ), + position: poses[i].translation, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }, + } + }) +} + +fn inactive_hook() -> BoardHook { + let mut body = bodies()[6]; + body.state_flags = 0; + BoardHook { + body, + drive: super::super::hook_drive::HookDriveState::initial(), + } +} + +fn solve(targets: [f32; 2]) -> [RetailReactionCorrections; BODY_COUNT] { + let mut hook = inactive_hook(); + let frames = prepare_drive_frames(default_truck_transforms(), targets, &mut hook); + let mut constraints = BoardConstraints::build( + &bodies(), + &hook, + frames, + retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + f32::from_bits(0x3C88_8889), + ); + let mut reactions = [RetailReactionCorrections::default(); BODY_COUNT + 1]; + solve_constraints( + &mut [], + &mut constraints.joints, + &mut constraints.drives, + &mut reactions, + 25, + ); + reactions[..BODY_COUNT].try_into().unwrap() +} + +#[test] +fn native_pair_order_and_stock_constraint_counts_are_preserved() { + let mut hook = inactive_hook(); + let frames = prepare_drive_frames(default_truck_transforms(), [0.0; 2], &mut hook); + let constraints = BoardConstraints::build( + &bodies(), + &hook, + frames, + retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + f32::from_bits(0x3C88_8889), + ); + let pairs: Vec<_> = constraints + .joints + .into_iter() + .map(|j| (j.reaction_a, j.reaction_b)) + .collect(); + assert_eq!(pairs, [(4, 6), (5, 6), (0, 4), (1, 4), (2, 5), (3, 5)]); + assert_eq!( + constraints + .drives + .into_iter() + .map(|d| (d.frame_a_body.reaction_index, d.frame_b_body.reaction_index)) + .collect::>(), + [(4, 6), (5, 6), (7, 6)] + ); +} + +#[test] +fn steering_reaches_wheels_through_shared_joint_and_drive_reactions() { + let rest = solve([0.0; 2]); + let turn = solve([0.3; 2]); + for body in BodyId::ORDER { + let r = turn[body.index()]; + assert_ne!( + r.angular_displacement, + rest[body.index()].angular_displacement, + "{body:?}" + ); + for v in [r.linear_displacement, r.angular_displacement] { + assert!( + v.x.is_finite() && v.y.is_finite() && v.z.is_finite(), + "{body:?}" + ); + } + } + // Internal constraints exchange momentum; they do not propel the board. + let snapshots = bodies(); + let momentum = turn + .iter() + .zip(snapshots) + .fold(Vector3::ZERO, |sum, (r, b)| { + Vector3::new( + sum.x + r.linear_displacement.x / b.inertia.inverse_mass, + sum.y + r.linear_displacement.y / b.inertia.inverse_mass, + sum.z + r.linear_displacement.z / b.inertia.inverse_mass, + ) + }); + assert!( + momentum.x.abs() < 1e-5 && momentum.y.abs() < 1e-5 && momentum.z.abs() < 1e-5, + "{momentum:?}" + ); +} diff --git a/crates/skate-core/src/physics/tests/board_ground.rs b/crates/skate-core/src/physics/tests/board_ground.rs new file mode 100644 index 00000000..f77e0e08 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_ground.rs @@ -0,0 +1,132 @@ +use super::*; + +fn report(part: BodyId, normal: Vector3) -> BoardContactReport { + BoardContactReport { + part, other: CollisionBody::StaticWorld, is_body_a: true, + normal, position: Vector3::ZERO, relative_linear_velocity: Vector3::ZERO, + other_surface: 0, normal_force_on_a: UP, friction_force_on_a: Vector3::ZERO, + tangents: [Vector3::ZERO; 2], + } +} + +#[test] +fn lost_wheel_support_keeps_wheel_normal_but_rebuilds_overall_normal() { + let mut ground = BoardGroundState::default(); + let mut lines = WheelLineState::default(); + lines.publish([Some(WheelLineHit { fraction: 0.1, normal: UP, surface_tag: 0 }); 4]); + ground.update(&[report(BodyId::RightFrontWheel, UP)], &lines, UP, 80.0, false); + assert_eq!(ground.wheel_contact_count, 1); + let retained = ground.wheel_normal; + ground.update(&[report(BodyId::Deck, Vector3::new(1.0, 0.0, 0.0))], &lines, UP, 80.0, false); + assert_eq!(ground.wheel_contact_count, 0); + assert_eq!(ground.part_contact_count, 1); + assert_eq!(ground.wheel_normal, retained); + assert!(ground.overall_normal.x > 0.99999); + assert!(ground.overall_normal.y.abs() < 0.00001); +} + +#[test] +fn no_hit_preserves_line_geometry_and_contact_status_does_not_come_from_lines() { + let mut lines = WheelLineState::default(); + let normal = Vector3::new(0.6, 0.8, 0.0); + lines.publish([Some(WheelLineHit { fraction: 0.2, normal, surface_tag: 3 << 7 }); 4]); + let distances = lines.distances; + lines.publish([None; 4]); + assert_eq!(lines.normals, [normal; 4]); + assert_eq!(lines.distances, distances); + assert_eq!(lines.physics_surfaces, [0; 4]); + assert_eq!(lines.minimum_distance, WHEEL_LINE_LENGTH); + let mut ground = BoardGroundState::default(); + ground.update(&[], &lines, UP, 80.0, false); + assert_eq!(ground.part_contact_count, 0); + assert_eq!(ground.wheel_contact_count, 0); + assert_eq!(ground.wheel_normal, UP); + assert_eq!(ground.overall_normal, UP); +} + +#[test] +fn per_part_selection_uses_highest_y_and_keeps_first_on_ties() { + let first = report(BodyId::Deck, Vector3::new(0.6, 0.8, 0.0)); + let tied = report(BodyId::Deck, Vector3::new(-0.6, 0.8, 0.0)); + let lower = report(BodyId::Deck, Vector3::new(0.0, 0.4, 0.9)); + let mut ground = BoardGroundState::default(); + ground.update(&[first, tied, lower], &WheelLineState::default(), UP, 80.0, false); + assert_eq!(ground.parts[6].normal, first.normal); + assert_eq!(ground.part_contact_count, 1); +} + +#[test] +fn observed_acceleration_retains_each_part_velocity_and_resets_with_collision_info() { + let mut state = super::BoardGroundState::default(); + let first = core::array::from_fn(|i| crate::math::Vector3::new(i as f32, 2.0, -1.0)); + state.sample_accelerations(first, 0.5); + assert_eq!(state.accelerations[6], crate::math::Vector3::new(12.0, 4.0, -2.0)); + state.sample_accelerations(first, 0.25); + assert_eq!(state.accelerations, [crate::math::Vector3::ZERO; 7]); + let mut next = first; + next[6].x += 1.0; + state.sample_accelerations(next, 0.25); + assert_eq!(state.accelerations[6], crate::math::Vector3::new(4.0, 0.0, 0.0)); + assert_eq!(state.accelerations[5], crate::math::Vector3::ZERO); + let reset = super::BoardGroundState::default(); + assert_eq!(reset.previous_velocities, [crate::math::Vector3::ZERO; 7]); + assert_eq!(reset.accelerations, [crate::math::Vector3::ZERO; 7]); +} + +#[test] +fn impact_uses_preceding_velocity_even_after_the_solver_stops_the_board() { + let mut ground = BoardGroundState::default(); + let mut before = [Vector3::ZERO; BODY_COUNT]; + before[6] = Vector3::new(-8.0, -3.0, 0.0); + ground.sample_accelerations(before, 1.0 / 60.0); + let wall = report(BodyId::Deck, Vector3::new(1.0, 0.0, 0.0)); + let floor = report(BodyId::Deck, UP); + //Both actual solver reports have zero remaining relative velocity. The + //wall still has the larger incoming impact, while the floor wins normal Y. + ground.update(&[wall, floor], &WheelLineState::default(), UP, 80.0, false); + ground.sample_accelerations([Vector3::ZERO; BODY_COUNT], 1.0 / 60.0); + assert_eq!(ground.closing_velocity, Vector3::new(-8.0, -0.0, -0.0)); + assert_eq!(ground.maximum_closing_speed, 8.0); + assert_eq!(ground.parts[6].normal, UP); + ground.update(&[], &WheelLineState::default(), UP, 80.0, false); + assert_eq!(ground.closing_velocity, Vector3::ZERO); + assert_eq!(ground.maximum_closing_speed, 0.0); +} + +#[test] +fn surface_flags_use_actual_contacts_and_reset_each_frame() { + let mut ground = BoardGroundState::default(); + let mut first = report(BodyId::Deck, UP); + first.other_surface = 12 << 7; + first.position.y = 2.0; + let mut last = first; + last.position.y = 5.0; + let mut lines = WheelLineState::default(); + lines.physics_surfaces[0] = 8; + ground.update(&[first, last], &lines, UP, 80.0, false); + assert_eq!(ground.collision_flags, 1 << 25); + assert_eq!(ground.surface_twelve_height, 5.0); + //A wheel ray reporting surface8 alone does not set the physical flag. + ground.update(&[report(BodyId::RightFrontWheel, UP)], &lines, UP, 80.0, false); + assert_eq!(ground.collision_flags, 1 << 31); + ground.update(&[], &lines, UP, 80.0, false); + assert_eq!(ground.collision_flags, 0); + assert_eq!(ground.surface_twelve_height, 0.0); +} + +#[test] +fn opposing_contacts_use_deck_projection_range_and_reset_without_deck_contacts() { + let mut state = BoardGroundState::default(); + let lines = WheelLineState::default(); + let reports = [ + report(BodyId::Deck, Vector3::new(0.0, 0.75, 0.0)), + report(BodyId::Deck, Vector3::new(0.0, -0.5, 0.0)), + report(BodyId::FrontTruck, Vector3::new(0.0, -1.0, 0.0)), + ]; + state.update(&reports, &lines, UP, 80.0, false); + assert_eq!(state.opposing_contact, 1.25); + state.update(&reports, &lines, Vector3::new(1.0, 0.0, 0.0), 80.0, false); + assert_eq!(state.opposing_contact, 0.0); + state.update(&reports[2..], &lines, UP, 80.0, false); + assert_eq!(state.opposing_contact, 0.0); +} diff --git a/crates/skate-core/src/physics/tests/board_runtime.rs b/crates/skate-core/src/physics/tests/board_runtime.rs new file mode 100644 index 00000000..1e269445 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_runtime.rs @@ -0,0 +1,207 @@ +use super::*; +use crate::physics::{ + drive_frames::{AuthoredTransformInputs, authored_body_transforms, default_truck_transforms}, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + force_queue::QueuedPointForce, +}; + +fn masses() -> [RetailBodyMassProperties; BODY_COUNT] { + core::array::from_fn(|i| RetailBodyMassProperties { + local_mass_frame: RetailLocalMassFrame::IDENTITY, + dynamics: RetailInertiaDynamics { + inverse_tensor: Vector3::new(2.0 + i as f32, 3.0 + i as f32, 4.0 + i as f32), + inverse_mass: 0.5, + spherical: 0.0, + maximum_linear_velocity: 1000.0, + maximum_angular_velocity: 1000.0, + linear_drag: 0.0, + angular_drag: 0.0, + }, + }) +} + +fn simulation(gravity: Vector3) -> RetailSimulationStep { + RetailSimulationStep::fixed_60_hz(30, 0.001, gravity) +} + +fn runtime(gravity: Vector3, mode: BoardMotion) -> BoardRuntime { + BoardRuntime::new( + masses(), + authored_body_transforms(AuthoredTransformInputs::STOCK), + RetailAffineTransform::IDENTITY, + simulation(gravity), + mode, + ) +} + +fn settings(gravity: Vector3, iterations: u32) -> BoardStepSettings { + BoardStepSettings { + simulation: simulation(gravity), + iterations, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + } +} + +fn close(actual: Vector3, expected: Vector3) { + assert!( + (actual.x - expected.x).abs() < 2e-5, + "{actual:?} != {expected:?}" + ); + assert!( + (actual.y - expected.y).abs() < 2e-5, + "{actual:?} != {expected:?}" + ); + assert!( + (actual.z - expected.z).abs() < 2e-5, + "{actual:?} != {expected:?}" + ); +} + +#[test] +fn spawn_preserves_each_mass_frame_and_keeps_the_hook_separate() { + let mut data = masses(); + data[BodyId::Deck.index()].local_mass_frame = RetailLocalMassFrame { + basis: Basis3 { + columns: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + }, + translation: Vector3::new(0.2, -0.03, 0.01), + }; + let spawn = RetailAffineTransform { + basis: Basis3 { + columns: [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]], + }, + translation: Vector3::new(3.0, 4.0, 5.0), + }; + let gravity = Vector3::new(0.0, -9.81, 0.0); + let board = BoardRuntime::new( + data, + authored_body_transforms(AuthoredTransformInputs::STOCK), + spawn, + simulation(gravity), + BoardMotion::Active, + ); + close( + board.body_transform(BodyId::Deck).translation, + Vector3::new(2.99, 4.2, 4.97), + ); + let deck_part = board.part_transforms()[BodyId::Deck.index()]; + close(deck_part.translation, spawn.translation); + for i in 0..3 { + close( + Vector3::new( + deck_part.basis.columns[i][0], + deck_part.basis.columns[i][1], + deck_part.basis.columns[i][2], + ), + Vector3::new( + spawn.basis.columns[i][0], + spawn.basis.columns[i][1], + spawn.basis.columns[i][2], + ), + ); + } + for (i, body) in board.bodies().iter().enumerate() { + assert_eq!(body.inertia, data[i].dynamics); + assert_eq!(body.state_flags, 4); + assert_eq!(body.rates.force_acceleration, gravity); + assert_eq!(body.rates.kinetic_energy, f32::MAX); + assert_eq!(body.rates.cool_down, 0); + } + close(board.hook_transform().translation, spawn.translation); + assert_ne!( + board.hook_transform().translation, + board.body_transform(BodyId::Deck).translation + ); + assert_eq!(board.hook().body.state_flags, 1); + assert_eq!(board.hook().body.inertia.inverse_mass, 0.0); + assert_eq!( + board.hook().body.rates.world_inverse_inertia.columns, + [[0.0; 3]; 3] + ); + assert_eq!(board.hook().body.rates.force_acceleration, Vector3::ZERO); +} + +#[test] +fn integration_updates_the_same_state_read_by_presentation_and_force_producers() { + let mut board = runtime(Vector3::ZERO, BoardMotion::Active); + board.forces_mut().append(QueuedPointForce { + tag: 7, + force_world: Vector3::new(0.0, 0.0, 14.0), + point_body: Vector3::ZERO, + }); + let initial = board.body_transform(BodyId::Deck).translation; + let config = settings(Vector3::ZERO, 0); + let dt = config.simulation.time_step; + board.advance(&[], [0.0; 2], config); + let deck = board.bodies()[BodyId::Deck.index()]; + close(deck.rates.linear_velocity, Vector3::new(0.0, 0.0, 7.0 * dt)); + close( + board.part_transforms()[BodyId::Deck.index()].translation, + Vector3::new(initial.x, initial.y, initial.z + 7.0 * dt * dt), + ); + assert_eq!(board.forces().entries().len(), 1); + board.clear_forces(); + board.advance(&[], [0.0; 2], config); + close( + board.bodies()[BodyId::Deck.index()].rates.linear_velocity, + deck.rates.linear_velocity, + ); + close( + board.part_transforms()[BodyId::Deck.index()].translation, + Vector3::new(initial.x, initial.y, initial.z + 14.0 * dt * dt), + ); +} + +#[test] +fn moving_the_static_hook_changes_constraint_response_without_moving_its_body() { + let mut target = runtime(Vector3::ZERO, BoardMotion::Active); + let mut baseline = runtime(Vector3::ZERO, BoardMotion::Active); + let mut animated = 0; + target.hook_mut().drive.enable_animation_soft(&mut animated); + baseline + .hook_mut() + .drive + .enable_animation_soft(&mut animated); + let requested = RetailAffineTransform { + translation: Vector3::new(0.0, 0.2, 0.0), + ..RetailAffineTransform::IDENTITY + }; + target.set_hook_transform(requested); + let config = settings(Vector3::ZERO, 25); + target.advance(&[], [0.0; 2], config); + baseline.advance(&[], [0.0; 2], config); + assert!( + target.bodies()[BodyId::Deck.index()] + .rates + .linear_velocity + .y + > baseline.bodies()[BodyId::Deck.index()] + .rates + .linear_velocity + .y + ); + assert_eq!(target.hook_transform().translation, requested.translation); + assert_eq!(target.hook().body.rates.linear_velocity, Vector3::ZERO); + assert_eq!(target.hook().body.rates.angular_velocity, Vector3::ZERO); + assert_eq!(target.hook().body.inertia, ZERO_INERTIA); +} + +#[test] +fn explicit_inactive_modes_do_not_integrate_under_gravity() { + let gravity = Vector3::new(0.0, -9.81, 0.0); + for mode in [BoardMotion::Frozen, BoardMotion::Static] { + let mut board = runtime(gravity, mode); + let before = board.body_transform(BodyId::Deck); + board.advance(&[], [0.0; 2], settings(gravity, 25)); + assert_eq!(board.body_transform(BodyId::Deck), before); + assert_eq!(board.bodies()[6].state_flags, mode.flags()); + assert_eq!(board.bodies()[6].rates.cool_down, 30); + if mode == BoardMotion::Static { + assert_eq!(board.bodies()[6].inertia.inverse_mass, 0.0); + } else { + assert_eq!(board.bodies()[6].inertia, masses()[6].dynamics); + } + } +} diff --git a/crates/skate-core/src/physics/tests/board_step.rs b/crates/skate-core/src/physics/tests/board_step.rs new file mode 100644 index 00000000..ab76e082 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_step.rs @@ -0,0 +1,331 @@ +use super::*; +use crate::physics::{ + drive_frames::{ + default_live_body_orientations, default_live_body_transforms, default_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + force_queue::QueuedPointForce, + mass::default_skateboard_mass_properties, + rigid_body::{RetailBodyRates, basis_from_quaternion, world_inverse_inertia}, +}; +use crate::riding::braking::{BrakeInput, BrakeSettings, calculate_braking}; +use crate::riding::push::{PushInput, PushLimits, enqueue_push}; + +#[path = "hook_step.rs"] +mod hook_step; +#[path = "contact_reports.rs"] +mod contact_reports; + +fn bodies() -> [BodySnapshot; BODY_COUNT] { + let poses = default_live_body_transforms(); + let orientations = default_live_body_orientations(); + let masses = default_skateboard_mass_properties(); + core::array::from_fn(|i| { + let basis = basis_from_quaternion(orientations[i]); + BodySnapshot { + state_flags: 4, + inertia: masses[i].dynamics, + rates: RetailBodyRates { + orientation: orientations[i], + basis, + world_inverse_inertia: world_inverse_inertia( + basis, + masses[i].dynamics.inverse_tensor, + ), + position: poses[i].translation, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }, + } + }) +} + +// Deliberately inactive synthetic hook isolates the existing board-only cases. +// Its body values are test inputs, not a gameplay initialization contract. +fn inactive_hook() -> BoardHook { + let mut body = bodies()[6]; + body.state_flags = 0; + BoardHook { + body, + drive: super::super::hook_drive::HookDriveState::initial(), + } +} + +fn settings(iterations: u32) -> BoardStepSettings { + BoardStepSettings { + simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO), + iterations, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + } +} + +fn momentum_z(bodies: &[BodySnapshot; BODY_COUNT]) -> f32 { + bodies + .iter() + .map(|b| b.rates.linear_velocity.z / b.inertia.inverse_mass) + .sum() +} + +#[test] +fn push_is_delivered_through_constraints_into_all_seven_integrated_bodies() { + let mut bodies = bodies(); + let before = bodies; + let mut unforced = before; + let mut queue = BoardForceQueue::default(); + let config = settings(25); + let push = enqueue_push( + PushInput { + flags_2468: 0x0200_0000, + flags_2472: 0, + target_speed: 3.0, + current_speed: 0.0, + absolute_body_speed: 0.0, + scale: f32::NAN, + delta_seconds: config.simulation.time_step, + direction: Vector3::new(0.0, 0.0, 1.0), + }, + PushLimits { + maximum_pushable_speed: 10.0, + low_speed_change: 0.1, + high_speed_change: 0.05, + }, + &bodies.map(|b| b.inertia.inverse_mass), + &mut queue, + ); + let mut tick = BoardStep::default(); + // The preserved research explicitly characterizes noncoincident anchors + // in the constructor pose (TRANSITION_PHYSICS_EVIDENCE.md). Compare the + // push against that same unforced pose, not an invented settled board. + BoardStep::default().advance( + &mut unforced, + &mut inactive_hook(), + &BoardForceQueue::default(), + &[], + [0.0; 2], + config, + ); + tick.advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + for id in BodyId::ORDER { + let after = bodies[id.index()].rates; + assert!( + after.linear_velocity.z > unforced[id.index()].rates.linear_velocity.z, + "{id:?}" + ); + assert_ne!(after.position, before[id.index()].rates.position, "{id:?}"); + assert_eq!(after.force_acceleration, Vector3::ZERO); + assert_eq!(after.torque_acceleration, Vector3::ZERO); + } + let expected_momentum = push.vector.z * config.simulation.time_step; + assert!((momentum_z(&bodies) - expected_momentum).abs() < 1e-4); + assert_eq!( + queue.entries().len(), + 1, + "native consumer leaves queue intact" + ); + assert_eq!( + tick.reactions, + [RetailReactionCorrections::default(); REACTION_COUNT] + ); + // Clearing is owned by GeneralUpdate. A subsequent coast tick must not + // repeat either the push or last frame's accumulated constraint impulses. + queue.clear(); + tick.advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + assert!((momentum_z(&bodies) - expected_momentum).abs() < 1e-4); +} + +#[test] +fn simulation_uses_the_supplied_iteration_count_and_one_timestep() { + let mut zero_pass = bodies(); + let mut solved = zero_pass; + let mut queue = BoardForceQueue::default(); + queue.append(QueuedPointForce { + tag: 3, + force_world: Vector3::new(0.0, 0.0, 60.0), + point_body: Vector3::ZERO, + }); + let mut config = settings(0); + config.simulation.time_step = 1.0 / 120.0; + config.simulation.frequency = 1.0; // StartFrame must replace a stale reciprocal. + BoardStep::default().advance( + &mut zero_pass, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + config.iterations = 25; + BoardStep::default().advance( + &mut solved, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + assert_eq!(zero_pass[0].rates.linear_velocity, Vector3::ZERO); + assert!(solved[0].rates.linear_velocity.z > 0.0); + assert!((momentum_z(&solved) - 0.5).abs() < 1e-4); + assert!((momentum_z(&zero_pass) - 0.5).abs() < 1e-4); +} + +#[test] +fn steering_changes_integrated_truck_wheel_and_deck_orientations() { + let mut straight = bodies(); + let mut turning = straight; + let queue = BoardForceQueue::default(); + BoardStep::default().advance( + &mut straight, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + settings(25), + ); + BoardStep::default().advance( + &mut turning, + &mut inactive_hook(), + &queue, + &[], + [0.3; 2], + settings(25), + ); + for id in BodyId::ORDER { + assert_ne!( + straight[id.index()].rates.orientation, + turning[id.index()].rates.orientation, + "{id:?}" + ); + } +} + +#[test] +fn native_braking_force_reaches_whole_assembly_momentum() { + for speed in [-4.0, 4.0] { + let mut bodies = bodies(); + for body in &mut bodies { + body.rates.linear_velocity.z = speed; + } + let before = momentum_z(&bodies); + let brake = calculate_braking( + BrakeInput { + flags_2468: 0x4000_0000, + input_2728: 0.5, + signed_speed: speed, + absolute_body_speed: speed.abs(), + surface_factor: 0.7, + direction: Vector3::new(0.0, 0.0, 1.0), + }, + BrakeSettings { + input_force: 200.0, + override_force: 100.0, + minimum_speed: 0.8, + }, + ); + let mut queue = BoardForceQueue::default(); + queue.append(brake); + let config = settings(25); + BoardStep::default().advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + let after = momentum_z(&bodies); + assert!(after.abs() < before.abs()); + assert!((after - before - brake.force_world.z * config.simulation.time_step).abs() < 1e-4); + } +} + +#[test] +fn no_active_bodies_skips_compile_solve_and_integration() { + let mut bodies = bodies(); + for b in &mut bodies { + b.state_flags = 0; + } + let before = bodies.map(|b| b.rates); + BoardStep::default().advance( + &mut bodies, + &mut inactive_hook(), + &BoardForceQueue::default(), + &[], + [0.3; 2], + settings(25), + ); + assert_eq!(before, bodies.map(|b| b.rates)); +} + +#[test] +fn contact_workspace_contains_this_ticks_queued_force() { + let mut bodies = bodies(); + let point = bodies[6].rates.position; + let collision = BoardCollision { + body_a: CollisionBody::StaticWorld, + body_b: CollisionBody::Board(BodyId::Deck), + contact: RetailContactInput { + position_on_a: point, + position_on_b: point, + normal: Vector3::new(0.0, -1.0, 0.0), + restitution: 0.0, + static_friction: 0.0, + dynamic_friction: 0.0, + tag: 0, + }, + }; + let mut queue = BoardForceQueue::default(); + queue.append(QueuedPointForce { + tag: 2, + force_world: Vector3::new(0.0, -60.0, 0.0), + point_body: Vector3::ZERO, + }); + let mut tick = BoardStep::default(); + let config = settings(25); + let mut free = bodies; + BoardStep::default().advance( + &mut free, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + tick.advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[collision], + [0.0; 2], + config, + ); + let row = tick.contacts[0]; + assert!(row.accumulated_impulse()[0] > 0.0); + assert!(bodies[6].rates.linear_velocity.y.abs() < free[6].rates.linear_velocity.y.abs()); + let expected_target = config.simulation.time_step.powi(2) * 60.0; + assert!((row.target_impulse()[0] - expected_target).abs() < 1e-9); + assert_eq!( + tick.reactions[WORLD_REACTION], + RetailReactionCorrections::default() + ); +} diff --git a/crates/skate-core/src/physics/tests/board_world.rs b/crates/skate-core/src/physics/tests/board_world.rs new file mode 100644 index 00000000..b8ac4ea1 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_world.rs @@ -0,0 +1,261 @@ +use super::*; +use crate::{ + math::Basis3, + physics::{ + board::{BODY_COUNT, RETAIL_TRUCK_Y_POSITION, RETAIL_WHEEL_RADIUS}, + board_runtime::BoardMotion, + board_step::BoardStepSettings, + collision::TriangleFeature, + drive_frames::{ + AuthoredTransformInputs, RetailAffineTransform, authored_body_transforms, + default_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + rigid_body::{ + RetailBodyMassProperties, RetailInertiaDynamics, RetailLocalMassFrame, + RetailSimulationStep, + }, + }, +}; + +fn material(a: f32, b: f32, c: f32) -> RetailContactMaterial { + RetailContactMaterial { + static_friction: a, + dynamic_friction: b, + restitution: c, + } +} +fn triangle() -> WorldTriangle { + WorldTriangle::from_vertices( + [ + Vector3::new(-10.0, 0.0, -10.0), + Vector3::new(0.0, 0.0, 10.0), + Vector3::new(10.0, 0.0, -10.0), + ], + material(0.8, 0.6, 0.2), + 42, + TriangleFeature::ONE_SIDED | 0xe0, + [1.0; 3], + 0.0, + ) + .unwrap() +} +fn config() -> WheelWorldSettings { + WheelWorldSettings { + radius: RETAIL_WHEEL_RADIUS, + material: material(0.2, 0.1, 0.8), + query: WorldContactSettings { + volume_padding: 0.0, + maximum_separating_distance: 0.0, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + }, + retention: ContactRetentionSettings { + capacity: 100, + duplicate_distance_squared: 0.000001, + deferred_reduction: false, + }, + } +} +fn board(gap: f32, gravity: Vector3) -> BoardRuntime { + let mass = RetailBodyMassProperties { + local_mass_frame: RetailLocalMassFrame::IDENTITY, + dynamics: RetailInertiaDynamics { + inverse_tensor: Vector3::new(3.0, 3.0, 3.0), + inverse_mass: 0.5, + spherical: 0.0, + maximum_linear_velocity: 1000.0, + maximum_angular_velocity: 1000.0, + linear_drag: 0.0, + angular_drag: 0.0, + }, + }; + BoardRuntime::new( + [mass; BODY_COUNT], + authored_body_transforms(AuthoredTransformInputs::STOCK), + RetailAffineTransform { + translation: Vector3::new( + 0.0, + RETAIL_WHEEL_RADIUS - RETAIL_TRUCK_Y_POSITION + gap, + 0.0, + ), + ..RetailAffineTransform::IDENTITY + }, + RetailSimulationStep::fixed_60_hz(30, 0.001, gravity), + BoardMotion::Active, + ) +} +fn close(a: f32, b: f32) { + assert!((a - b).abs() < 2e-6, "{a} != {b}"); +} + +#[test] +fn finite_world_geometry_produces_four_ordered_wheel_contacts_with_combined_materials() { + let mut world = BoardWorld::new(vec![triangle()]); + let board = board(0.0, Vector3::ZERO); + let contacts = world.query(&board, config()); + assert_eq!(contacts.len(), 4); + for (index, collision) in contacts.iter().enumerate() { + assert_eq!(collision.body_a, CollisionBody::Board(BodyId::ORDER[index])); + assert_eq!(collision.body_b, CollisionBody::StaticWorld); + close(collision.contact.normal.y, 1.0); + close(collision.contact.position_on_a.y, 0.0); + close(collision.contact.position_on_b.y, 0.0); + assert_eq!(collision.contact.static_friction, 0.8); + assert_eq!(collision.contact.dynamic_friction, 0.6); + assert_eq!(collision.contact.restitution, 0.2); + assert_eq!(collision.contact.tag, 42); + } +} + +#[test] +fn contact_query_uses_live_poses_and_rejects_air_remote_geometry_and_backfaces() { + let mut world = BoardWorld::new(vec![triangle()]); + let mut board = board(0.0, Vector3::ZERO); + assert_eq!(world.query(&board, config()).len(), 4); + for body in board.bodies_mut() { + body.rates.position.y += 1.0; + } + assert!(world.query(&board, config()).is_empty()); + for body in board.bodies_mut() { + body.rates.position.y -= 1.0; + body.rates.position.x += 30.0; + } + assert!(world.query(&board, config()).is_empty()); + for body in board.bodies_mut() { + body.rates.position.x -= 30.0; + body.rates.position.y -= RETAIL_WHEEL_RADIUS * 1.5; + } + assert!(world.query(&board, config()).is_empty()); +} + +#[test] +fn approaching_velocity_padding_and_world_fatness_control_real_contact_acceptance() { + let mut world = BoardWorld::new(vec![triangle()]); + let mut board = board(0.01, Vector3::ZERO); + let mut settings = config(); + settings.query.maximum_separating_distance = 0.05; + assert!(world.query(&board, settings).is_empty()); + for body in board.bodies_mut() { + body.rates.linear_velocity.y = -1.0; + } + assert_eq!(world.query(&board, settings).len(), 4); + settings.query.maximum_separating_distance = 0.004; + assert!(world.query(&board, settings).is_empty()); + for body in board.bodies_mut() { + body.rates.linear_velocity.y = 1.0; + } + settings.query.volume_padding = 0.02; + assert_eq!(world.query(&board, settings).len(), 4); + settings.query.volume_padding = 0.0; + let mut thick = triangle(); + thick.triangle.fatness = 0.02; + let mut thick_world = BoardWorld::new(vec![thick]); + let contacts = thick_world.query(&board, settings); + assert_eq!(contacts.len(), 4); + close(contacts[0].contact.position_on_b.y, 0.02); +} + +#[test] +fn duplicate_triangles_and_explicit_contact_capacity_do_not_multiply_solver_rows() { + let board = board(0.0, Vector3::ZERO); + let mut world = BoardWorld::new(vec![triangle(), triangle()]); + assert_eq!(world.query(&board, config()).len(), 4); + assert_eq!(world.dropped_contacts(), 4); + let mut settings = config(); + settings.retention.capacity = 3; + assert_eq!(world.query(&board, settings).len(), 3); + assert!(world.dropped_contacts() > 0); + settings.retention.capacity = 100; + settings.retention.deferred_reduction = true; + let mut repeated = BoardWorld::new(vec![triangle(); 8]); + assert_eq!(repeated.query(&board, settings).len(), 4); +} + +#[test] +fn world_contacts_feed_supporting_reactions_into_the_persistent_board_solver() { + let gravity = Vector3::new(0.0, -9.8, 0.0); + let mut supported = board(0.0, gravity); + let mut falling = board(0.0, gravity); + let mut world = BoardWorld::new(vec![triangle()]); + let collisions = world.query(&supported, config()); + assert_eq!(collisions.len(), 4); + let settings = BoardStepSettings { + simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, gravity), + iterations: 25, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + }; + supported.advance(collisions, [0.0; 2], settings); + falling.advance(&[], [0.0; 2], settings); + let momentum = |board: &BoardRuntime| { + board + .bodies() + .iter() + .map(|body| body.rates.linear_velocity.y / body.inertia.inverse_mass) + .sum::() + }; + assert!(momentum(&supported) > momentum(&falling) + 0.01); + assert_eq!( + supported.hook().body.rates.world_inverse_inertia, + Basis3 { + columns: [[0.0; 3]; 3] + } + ); +} + +#[test] +fn host_geometry_rejects_degenerate_triangles() { + assert!( + WorldTriangle::from_vertices( + [Vector3::ZERO; 3], + material(0.0, 0.0, 0.0), + 0, + 0, + [0.0; 3], + 0.0 + ) + .is_none() + ); +} + +#[test] +fn attached_volume_contact_reaches_its_actual_solver_body() { + use crate::physics::board_step::AttachedStep; + let mut board = board(0.0, Vector3::ZERO); + let mut attached = board.bodies()[0]; + attached.rates.position = Vector3::new(0.0, RETAIL_WHEEL_RADIUS, 0.0); + attached.rates.linear_velocity.y = -1.0; + let before = attached.rates.linear_velocity.y; + let volume = BoardWorldVolume { + body: CollisionBody::Attached(0), + primitive: ContactPrimitive::Sphere(Sphere { + center: attached.rates.position, + radius: RETAIL_WHEEL_RADIUS, + }), + linear_velocity: attached.rates.linear_velocity, + material: material(0.0, 0.0, 0.0), + }; + let mut world = BoardWorld::new(vec![triangle(), triangle()]); + let config = config(); + let contacts = world.query_primitives(&[volume], config.query, config.retention); + assert_eq!(contacts.len(), 1, "retention must preserve attached body identity"); + assert_eq!(contacts[0].body_a, CollisionBody::Attached(0)); + let settings = BoardStepSettings { + simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO), + iterations: 25, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + }; + board.advance_attached(contacts, [0.0; 2], settings, AttachedStep { + bodies: vec![&mut attached], + contacts: &mut [], + joints: &mut [], + drives: &mut [], + }); + assert!(attached.rates.linear_velocity.y > before + 0.5); + assert!(board.contact_reports().is_empty(), "skater contacts are not board observations"); +} diff --git a/crates/skate-core/src/physics/tests/contact.rs b/crates/skate-core/src/physics/tests/contact.rs new file mode 100644 index 00000000..64adaa9c --- /dev/null +++ b/crates/skate-core/src/physics/tests/contact.rs @@ -0,0 +1,134 @@ +use super::*; + +fn body(contact_body_id: u32, center_of_mass: Vector3) -> RetailContactBodyState { + RetailContactBodyState { + contact_body_id, + center_of_mass, + reaction_id: contact_body_id + 10, + inverse_inertia_full: Vector3::new(1.0, 2.0, 3.0), + inverse_mass: 0.25, + inverse_inertia_split: Vector3::new(4.0, 5.0, 6.0), + state: 7, + force_acceleration: Vector3::new(8.0, 9.0, 10.0), + kinetic_energy: 11.0, + torque_acceleration: Vector3::new(12.0, 13.0, 14.0), + cool_down: 15, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + } +} + +fn input(normal: Vector3) -> RetailContactInput { + RetailContactInput { + position_on_a: Vector3::new(2.0, 3.0, 4.0), + position_on_b: Vector3::new(2.0, 3.0, 4.0), + normal, + restitution: 0.1, + static_friction: 0.8, + dynamic_friction: 0.7, + tag: 0x1234_5678, + } +} + +#[test] +fn material_combine_matches_tu3_branch_directions() { + let combined = combine_contact_materials( + RetailContactMaterial { + static_friction: f32::from_bits(0x3F4C_CCCD), + dynamic_friction: f32::from_bits(0x3F33_3333), + restitution: f32::from_bits(0x3E4C_CCCD), + }, + RetailContactMaterial { + static_friction: f32::from_bits(0x3F00_0000), + dynamic_friction: f32::from_bits(0x3F66_6666), + restitution: f32::from_bits(0x3DCC_CCCD), + }, + ); + + assert_eq!(combined.static_friction.to_bits(), 0x3F4C_CCCD); + assert_eq!(combined.dynamic_friction.to_bits(), 0x3F66_6666); + assert_eq!(combined.restitution.to_bits(), 0x3DCC_CCCD); +} + +#[test] +fn material_combine_is_commutative_for_finite_values() { + let a = RetailContactMaterial { + static_friction: 0.8, + dynamic_friction: 0.7, + restitution: 0.0, + }; + let b = RetailContactMaterial { + static_friction: 0.5, + dynamic_friction: 0.9, + restitution: 0.2, + }; + + assert_eq!( + combine_contact_materials(a, b), + combine_contact_materials(b, a) + ); +} + +fn assert_close(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1.0e-6, + "actual {actual}, expected {expected}" + ); +} + +fn assert_vec_close(actual: Vector3, expected: Vector3) { + assert_close(actual.x, expected.x); + assert_close(actual.y, expected.y); + assert_close(actual.z, expected.z); +} + +#[test] +fn angular_contact_rates_use_omega_cross_arm() { + let mut body_a = body(1, Vector3::new(1.0, 3.0, 4.0)); + body_a.angular_velocity = Vector3::new(0.0, 0.0, 2.0); + let contact = generate_contact( + input(Vector3::new(0.0, -1.0, 0.0)), + body_a, + body(2, Vector3::new(2.0, 3.0, 4.0)), + ); + + assert_vec_close(contact.relative_velocity, Vector3::new(0.0, -2.0, 0.0)); +} + +#[test] +fn contact_frame_is_orthogonal() { + let normal = Vector3::new(0.36, 0.48, 0.8); + let mut body_b = body(2, Vector3::ZERO); + body_b.linear_velocity = Vector3::new(2.0, -1.0, 0.5); + let contact = generate_contact(input(normal), body(1, Vector3::ZERO), body_b); + + assert_close(dot(contact.normal, contact.tangent_0), 0.0); + assert_close(dot(contact.normal, contact.tangent_1), 0.0); + assert_close(dot(contact.tangent_0, contact.tangent_1), 0.0); + assert_vec_close(cross(contact.normal, contact.tangent_0), contact.tangent_1); +} + +#[test] +fn contact_copies_header_ids_materials_tag_and_body_workspaces() { + let body_a = body(41, Vector3::new(1.0, 2.0, 3.0)); + let body_b = body(52, Vector3::new(4.0, 5.0, 6.0)); + let collision = input(Vector3::new(0.0, 1.0, 0.0)); + let contact = generate_contact(collision, body_a, body_b); + + assert_eq!(contact.body_a_id, 41); + assert_eq!(contact.body_b_id, 52); + assert_eq!(contact.restitution, collision.restitution); + assert_eq!(contact.static_friction, collision.static_friction); + assert_eq!(contact.dynamic_friction, collision.dynamic_friction); + assert_eq!(contact.tag, collision.tag); + assert_eq!(contact.body_a_workspace, body_a.into()); + assert_eq!(contact.body_b_workspace, body_b.into()); + assert_eq!(contact.body_a_workspace.reaction_id, 51); + assert_eq!(contact.body_b_workspace.reaction_id, 62); +} + +#[test] +fn recovered_tangent_constants_keep_their_exact_binary32_bits() { + assert_eq!(CONTACT_TANGENT_MINIMUM_SQUARED.to_bits(), 0x0080_0000); + assert_eq!(CONTACT_FALLBACK_HALF.to_bits(), 0x3F00_0000); +} diff --git a/crates/skate-core/src/physics/tests/contact_reports.rs b/crates/skate-core/src/physics/tests/contact_reports.rs new file mode 100644 index 00000000..97a7d9d8 --- /dev/null +++ b/crates/skate-core/src/physics/tests/contact_reports.rs @@ -0,0 +1,102 @@ +use super::*; + +#[test] +fn reports_use_solved_forces_and_post_integration_pose_and_velocity() { + for is_a in [true, false] { + let mut body_state = bodies(); + let point = body_state[6].rates.position; + let board = CollisionBody::Board(BodyId::Deck); + let world = CollisionBody::StaticWorld; + let collision = BoardCollision { + body_a: if is_a { board } else { world }, + body_b: if is_a { world } else { board }, + contact: RetailContactInput { + position_on_a: point, + position_on_b: point, + normal: Vector3::new(0.0, if is_a { 1.0 } else { -1.0 }, 0.0), + restitution: 0.0, + static_friction: 0.0, + dynamic_friction: 0.0, + tag: if is_a { 5 << 16 | 7 } else { 7 << 16 | 5 }, + }, + }; + let mut queue = BoardForceQueue::default(); + queue.append(QueuedPointForce { + tag: 2, + force_world: Vector3::new(0.0, -60.0, 0.0), + point_body: Vector3::ZERO, + }); + let mut tick = BoardStep::default(); + let config = settings(25); + tick.advance( + &mut body_state, + &mut inactive_hook(), + &queue, + &[collision], + [0.0; 2], + config, + ); + let report = tick.contact_reports()[0]; + assert_eq!(report.part, BodyId::Deck); + assert_eq!(report.other_surface, 7); + assert_eq!(report.is_body_a, is_a); + assert_eq!(report.normal.y, 1.0); + let expected = body_state[6].rates.position; + assert!((report.position.x - expected.x).abs() < 1e-10); + assert!((report.position.y - expected.y).abs() < 1e-10); + assert!((report.position.z - expected.z).abs() < 1e-10); + assert_eq!( + report.relative_linear_velocity, + body_state[6].rates.linear_velocity + ); + assert!(report.normal_force_on_a.y * if is_a { 1.0 } else { -1.0 } > 0.0); + + // A later empty/inactive tick must not expose the previous contact. + for body in &mut body_state { + body.state_flags = 0; + } + tick.advance( + &mut body_state, + &mut inactive_hook(), + &BoardForceQueue::default(), + &[], + [0.0; 2], + config, + ); + assert!(tick.contact_reports().is_empty()); + } +} + +#[test] +fn report_budget_is_shared_by_parts_and_keeps_native_contact_order() { + let mut rows = Vec::new(); + for i in 0..20 { + let mut words = [0; 64]; + words[11] = 8; + words[20] = 1.0_f32.to_bits(); + words[29] = 1.0_f32.to_bits(); + words[31] = i % 7; + words[35] = 1.0_f32.to_bits(); + words[43] = u32::MAX; + words[55] = i + 1; + rows.push(RetailContactJacobian { + words, + reaction_index_a: (i % 7) as usize, + reaction_index_b: WORLD_REACTION, + }); + } + let mut output = Vec::new(); + board_reports::collect(&mut output, &rows, &bodies(), 60.0); + assert_eq!( + output.iter().map(|r| r.other_surface).collect::>(), + (1..=16).collect::>() + ); + rows[0].words[20] = 0; + rows[1].words[11] = 0; + rows[2].words[43] = 6; // Different part, same board: excluded from reports. + board_reports::collect(&mut output, &rows, &bodies(), 60.0); + assert_eq!( + output.iter().map(|r| r.other_surface).collect::>(), + (4..=19).collect::>() + ); +} diff --git a/crates/skate-core/src/physics/tests/contact_solver.rs b/crates/skate-core/src/physics/tests/contact_solver.rs new file mode 100644 index 00000000..87a97ae6 --- /dev/null +++ b/crates/skate-core/src/physics/tests/contact_solver.rs @@ -0,0 +1 @@ +// Generated-output comparisons were removed. Independent solver validation is pending. diff --git a/crates/skate-core/src/physics/tests/deck_geometry.rs b/crates/skate-core/src/physics/tests/deck_geometry.rs new file mode 100644 index 00000000..163f3948 --- /dev/null +++ b/crates/skate-core/src/physics/tests/deck_geometry.rs @@ -0,0 +1,174 @@ +use super::{DeckChild, DeckGeometry, DeckGeometrySettings, DeckShape, MassMoments}; +use crate::{math::Vector3, physics::drive_frames::RetailAffineTransform}; + +fn close(actual: f32, expected: f32, tolerance: f32) { + assert!( + (actual - expected).abs() <= tolerance, + "{actual} != {expected}" + ); +} + +#[test] +fn stock_deck_has_actual_child_order_dimensions_and_source_flags() { + let deck = DeckGeometry::new(DeckGeometrySettings::STOCK); + assert_eq!(deck.children.len(), 15); + let DeckShape::RoundedBox { + half_extents, + radius, + } = deck.children[0].shape + else { + panic!() + }; + close(radius, 0.00675, 1e-9); + close(half_extents.x + radius, 0.12, 1e-8); + close(half_extents.y + radius, 0.0075, 1e-9); + close(half_extents.z + radius, 0.295, 3e-8); + for (index, x) in [(1, 0.1125), (2, -0.1125)] { + let DeckShape::Capsule { + radius, + half_length, + } = deck.children[index].shape + else { + panic!() + }; + close(radius, 0.0075, 1e-9); + close(half_length, 0.295, 3e-8); + close(deck.children[index].transform.translation.x, x, 1e-8); + assert_eq!( + deck.children[index].transform.basis, + RetailAffineTransform::IDENTITY.basis + ); + } + for (index, z) in [(3, 0.235), (4, -0.235)] { + assert_eq!( + deck.children[index].shape, + DeckShape::Sphere { radius: 0.035 } + ); + assert_eq!( + deck.children[index].transform.translation, + Vector3::new(0.0, -0.02, z) + ); + } + for (index, child) in deck.children.iter().enumerate().skip(5) { + let DeckShape::Triangle { + vertices, + fatness, + edge_cosines, + volume_flags, + } = child.shape + else { + panic!() + }; + assert_eq!(fatness, 0.0075); + assert_eq!(edge_cosines, [1.0, -1.0, 1.0]); + assert_eq!(volume_flags, 0x3E2); + assert_eq!( + vertices[0], + Vector3::new(0.0, 0.0, if index % 2 == 1 { -0.295 } else { 0.295 }) + ); + } +} + +#[test] +fn fans_join_at_shared_vertices_and_use_independent_end_angles() { + let deck = DeckGeometry::new(DeckGeometrySettings::STOCK); + for side in [0, 1] { + let triangles: Vec<_> = deck.children[5 + side..] + .iter() + .step_by(2) + .map(|child| { + let DeckShape::Triangle { vertices, .. } = child.shape else { + panic!() + }; + vertices + }) + .collect(); + for pair in triangles.windows(2) { + assert_eq!(pair[0][2], pair[1][1]); + } + close(triangles[0][1].x, 0.1125, 1e-8); + close(triangles[4][2].x, -0.1125, 2e-7); + for vertices in triangles { + assert!(vertices[2].y >= -1e-7); + let from_center = (vertices[2].z - vertices[0].z).abs(); + if from_center > 0.01 { + let actual_angle = (vertices[2].y / from_center).atan().to_degrees(); + close(actual_angle, if side == 0 { 12.5 } else { 13.0 }, 0.0001); + } + } + } +} + +#[test] +fn collision_toggles_do_not_remove_mass_and_spheres_remain_enabled() { + let stock = DeckGeometry::new(DeckGeometrySettings::STOCK); + let mut settings = DeckGeometrySettings::STOCK; + settings.enable_deck_volume_collisions = false; + settings.enable_end_volume_collisions = false; + let disabled = DeckGeometry::new(settings); + assert_eq!(stock.mass_moments(), disabled.mass_moments()); + for (index, child) in disabled.children.iter().enumerate() { + assert_eq!(child.collision_enabled, index == 3 || index == 4); + } +} + +#[test] +fn triangle_mass_uses_source_fat_aabb_instead_of_triangle_area() { + let child = DeckChild { + shape: DeckShape::Triangle { + vertices: [ + Vector3::new(-2.0, 1.0, -1.0), + Vector3::new(3.0, 1.0, -1.0), + Vector3::new(1.0, 1.0, 5.0), + ], + fatness: 0.5, + edge_cosines: [1.0, -1.0, 1.0], + volume_flags: 0x3E2, + }, + transform: RetailAffineTransform::IDENTITY, + collision_enabled: true, + }; + let mut moments = child.mass_moments(); + let properties = moments.principal_properties(); + // Its fat AABB is [-2.5,0.5,-1.5]..[3.5,1.5,5.5]. The source rounded-box + // minimum radius is tiny but nonzero; allow its documented numerical floor. + close(properties.volume, 42.0, 0.0001); + close(properties.local_mass_frame.translation.x, 0.5, 2e-6); + close(properties.local_mass_frame.translation.y, 1.0, 2e-6); + close(properties.local_mass_frame.translation.z, 2.0, 2e-6); +} + +#[test] +fn stock_mass_is_computed_from_children_and_changes_with_authored_dimensions() { + let stock = DeckGeometry::new(DeckGeometrySettings::STOCK); + let mut moments = stock.mass_moments(); + assert_ne!(moments, MassMoments::ZERO); + let properties = moments.principal_properties(); + assert!(properties.volume > 0.0); + close(properties.local_mass_frame.translation.x, 0.0, 1e-6); + assert!(properties.local_mass_frame.translation.y.is_finite()); + let inertia = properties.moments_per_unit_mass; + assert!(inertia.x > 0.0 && inertia.y > 0.0 && inertia.z > 0.0); + let mut settings = DeckGeometrySettings::STOCK; + settings.back_end_size *= 1.5; + let extended = DeckGeometry::new(settings); + let mut extended_moments = extended.mass_moments(); + let extended_properties = extended_moments.principal_properties(); + assert!(extended_properties.volume > properties.volume); + assert_ne!(extended_moments, moments); + for index in 0..5 { + assert_eq!(stock.children[index], extended.children[index]); + } + for index in 5..15 { + assert_ne!(stock.children[index], extended.children[index]); + } +} + +#[test] +fn authored_count_controls_both_end_fans() { + let mut settings = DeckGeometrySettings::STOCK; + settings.end_capsule_count = 3; + assert_eq!(DeckGeometry::new(settings).children.len(), 11); + settings.end_capsule_count = 0; + assert_eq!(DeckGeometry::new(settings).children.len(), 5); +} diff --git a/crates/skate-core/src/physics/tests/drive_frames.rs b/crates/skate-core/src/physics/tests/drive_frames.rs new file mode 100644 index 00000000..d3fa8b15 --- /dev/null +++ b/crates/skate-core/src/physics/tests/drive_frames.rs @@ -0,0 +1,102 @@ +use super::*; + +#[test] +fn identity_pair_has_bit_exact_identity_matrix_stage() { + let parent_frame = relative_parent_transform( + RetailAffineTransform::IDENTITY, + RetailAffineTransform::IDENTITY, + ); + + assert_eq!( + basis_bits(parent_frame.basis), + [ + [0x3F80_0000, 0, 0], + [0, 0x3F80_0000, 0], + [0, 0, 0x3F80_0000], + ] + ); + assert_eq!(vector_bits(parent_frame.translation), [0; 3]); +} + +#[test] +fn default_truck_transforms_match_tu3_stock_finite_path_bits() { + let [slot_7712, slot_7776] = default_truck_transforms(); + + assert_eq!( + basis_bits(slot_7712.basis), + [ + [0xB34B_C7FA, 0x3F03_D987, 0xBF5B_6F50], + [0, 0x3F5B_6F51, 0x3F03_D988], + [0x3F7F_FFFF, 0x32D1_E8FB, 0xB32E_ACAF], + ] + ); + assert_eq!( + vector_bits(slot_7712.translation), + [0, 0xBD67_6C8B, 0xBE78_D4FD,] + ); + + assert_eq!( + basis_bits(slot_7776.basis), + [ + [0xB34B_C7FA, 0x3F03_D987, 0x3F5B_6F50], + [0, 0x3F5B_6F51, 0xBF03_D988], + [0xBF7F_FFFF, 0xB2D1_E8FB, 0xB32E_ACAF], + ] + ); + assert_eq!( + vector_bits(slot_7776.translation), + [0, 0xBD67_6C8B, 0x3E78_D4FD,] + ); +} + +#[test] +fn matrix_stage_preserves_tu3_vmx_fused_order() { + let [slot_7712, slot_7776] = default_truck_transforms(); + let relative = relative_parent_transform(slot_7712, slot_7776); + assert_eq!( + basis_bits(relative.basis), + [ + [0xBEF0_5E95, 0x3F62_08D7, 0xB395_B9AE], + [0x3F62_08D7, 0x3EF0_5E96, 0x3333_ED95], + [0x3395_B9AE, 0xB333_ED95, 0xBF7F_FFFE], + ] + ); + + // This finite vector distinguishes TU3's six-stage translation + // accumulation from pre-subtracting the two translations before a dot. + let parent_translation = vector_from_bits([0x43D5_62ED, 0xC356_E21F, 0x4408_E252]); + let child_translation = vector_from_bits([0x43FA_50C2, 0xC28B_22BA, 0xC3E6_0F58]); + let translation = + transpose_relative_translation(slot_7712.basis, parent_translation, child_translation); + assert_eq!( + vector_bits(translation), + [0x446A_A4A8, 0xC3C5_35F5, 0x4293_B75A,] + ); +} + +#[test] +fn derived_level_spawn_places_all_four_wheel_spheres_on_y_zero() { + let bodies = default_body_transforms(); + + for wheel in &bodies[..4] { + let wheel_bottom = + RETAIL_LEVEL_GROUND_DECK_CENTER_HEIGHT + wheel.translation.y - RETAIL_WHEEL_RADIUS; + assert!(wheel_bottom.abs() <= f32::EPSILON); + } +} + +fn basis_bits(basis: Basis3) -> [[u32; 3]; 3] { + basis.columns.map(|column| column.map(f32::to_bits)) +} + +fn vector_bits(vector: Vector3) -> [u32; 3] { + [vector.x.to_bits(), vector.y.to_bits(), vector.z.to_bits()] +} + +fn vector_from_bits(bits: [u32; 3]) -> Vector3 { + Vector3::new( + f32::from_bits(bits[0]), + f32::from_bits(bits[1]), + f32::from_bits(bits[2]), + ) +} diff --git a/crates/skate-core/src/physics/tests/drive_solver.rs b/crates/skate-core/src/physics/tests/drive_solver.rs new file mode 100644 index 00000000..f9d9ebc3 --- /dev/null +++ b/crates/skate-core/src/physics/tests/drive_solver.rs @@ -0,0 +1,213 @@ +use super::*; + +const DT: f32 = f32::from_bits(0x3C88_8889); + +fn active_body(reaction_index: usize) -> RetailDriveBodyState { + RetailDriveBodyState { + reaction_index, + state: ACTIVE_BODY, + orientation: RetailQuaternion::IDENTITY, + basis: crate::math::Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + center_of_mass: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::new(0.0, -9.8, 0.0), + torque_acceleration: Vector3::ZERO, + inverse_mass: 1.0, + world_inverse_inertia: RetailPackedWorldInverseInertia { + full: Vector3::new(1.0, 0.0, 0.0), + split: Vector3::new(1.0, 1.0, 0.0), + }, + } +} + +fn no_drive() -> RetailDriveParams { + RetailDriveParams { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + } +} + +fn frames_with_translation(translation: Vector3) -> RetailDriveFrames { + RetailDriveFrames { + body_a: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }, + body_b: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation, + }, + } +} + +fn assert_near(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1.0e-6, + "actual={actual:?} expected={expected:?}" + ); +} + +#[test] +fn rate_and_acceleration_terms_use_retail_dt_and_dt_squared() { + let mut frame_b = active_body(1); + frame_b.linear_velocity = Vector3::new(1.0, 0.0, 0.0); + frame_b.force_acceleration = Vector3::new(1.0, -9.8, 0.0); + let rows = build_drive_rows( + active_body(0), + frame_b, + frames_with_translation(Vector3::ZERO), + RetailDriveDynamics { + linear: RetailDriveParams { + spring_or_max_velocity: 100.0, + damping: 0.0, + max_strength: 100000.0, + drive_type: RetailDriveType::HardDrive, + }, + angular: no_drive(), + }, + DT, + ); + + assert_near(rows.linear_target_impulse[0], 0.5 * (DT + DT * DT)); +} +#[test] +fn off_center_linear_impulse_does_not_change_same_pass_angular_candidate() { + let mut frames = frames_with_translation(Vector3::ZERO); + frames.body_a.translation = Vector3::new(0.0, 1.0, 0.0); + frames.body_b.translation = Vector3::new(0.0, 1.0, 0.0); + let params = RetailDriveParams { + spring_or_max_velocity: 100.0, + damping: 0.0, + max_strength: 100000.0, + drive_type: RetailDriveType::HardDrive, + }; + let mut rows = [build_drive_rows( + active_body(0), + active_body(1), + frames, + RetailDriveDynamics { + linear: params, + angular: params, + }, + DT, + )]; + rows[0].linear_target_impulse = [0.0, 0.0, 0.1]; + rows[0].angular_target_impulse = [0.0; 3]; + let mut reactions = [RetailReactionCorrections::default(); 2]; + solve_drive_iteration(&mut rows, &mut reactions); + // 82AE2ED0 reads the angular difference BEFORE the linear reaction writes + // at 82AE307C. The former port incorrectly canceled this induced torque. + assert_eq!(rows[0].accumulated_angular_impulse, [0.0; 3]); + assert!(reactions[0].angular_displacement.x > 0.0); + assert!(reactions[1].angular_displacement.x < 0.0); + solve_drive_iteration(&mut rows, &mut reactions); + assert!(rows[0].accumulated_angular_impulse[0] < 0.0); +} + +fn moving_drive(params: RetailDriveParams) -> RetailDriveRows { + let mut b = active_body(1); + b.center_of_mass = Vector3::new(2.0, 0.0, 0.0); + b.linear_velocity = Vector3::new(2.0, 0.0, 0.0); + b.force_acceleration.x = 4.0; + build_drive_rows( + active_body(0), + b, + frames_with_translation(Vector3::ZERO), + RetailDriveDynamics { + linear: params, + angular: no_drive(), + }, + 0.5, + ) +} + +#[test] +fn hard_drive_caps_position_before_adding_rate_and_acceleration() { + // Original 82AE1F78..20D0: position 2 is capped to 1, then weighted by + // 1/(1+c*dt)=1/2. Rate and acceleration each contribute 1 afterward. + let rows = moving_drive(RetailDriveParams { + spring_or_max_velocity: 2.0, + damping: 2.0, + max_strength: 16.0, + drive_type: RetailDriveType::HardDrive, + }); + assert_near(rows.linear_target_impulse[0], 1.25); + assert_near(rows.linear_softness, 1.0); + assert_near(rows.linear_maximum_impulse[0], 2.0); +} + +#[test] +fn soft_drive_weights_spring_damping_and_acceleration_separately() { + // Original 82AE1E68..1F30: k*dt^2=2, c*dt=1, denominator=4. + // Position/rate/acceleration weights are 1/2, 1/4, 3/4 respectively. + let rows = moving_drive(RetailDriveParams { + spring_or_max_velocity: 8.0, + damping: 2.0, + max_strength: 16.0, + drive_type: RetailDriveType::SoftDrive, + }); + assert_near(rows.linear_target_impulse[0], 1.0); + assert_near(rows.linear_softness, 0.75); +} + +#[test] +fn saturated_quaternion_component_uses_all_frame_b_axes() { + let mut b = active_body(1); + b.orientation = RetailQuaternion { + x: 1.0, + y: 0.0, + z: 0.0, + w: 0.0, + }; + b.basis.columns = [[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]]; + let rows = build_drive_rows( + active_body(0), + b, + frames_with_translation(Vector3::ZERO), + RetailDriveDynamics { + linear: no_drive(), + angular: RetailDriveParams { + spring_or_max_velocity: 100.0, + damping: 0.0, + max_strength: 100.0, + drive_type: RetailDriveType::HardDrive, + }, + }, + 0.5, + ); + // Original 82AE20A0..216C uses one saturated component to select all axes. + for i in 0..3 { + let a = rows.angular_axes[i]; + assert_near(a.x, b.basis.columns[i][0]); + assert_near(a.y, b.basis.columns[i][1]); + assert_near(a.z, b.basis.columns[i][2]); + } + assert_near(rows.angular_target_impulse[0], 1.0); + assert!(rows.angular_target_impulse.iter().all(|v| v.is_finite())); +} + +#[test] +fn drive_anchor_uses_independent_body_basis() { + let mut a = active_body(0); + a.basis.columns = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]; + let mut frames = frames_with_translation(Vector3::ZERO); + frames.body_a.translation = Vector3::new(2.0, 0.0, 0.0); + let rows = build_drive_rows( + a, + active_body(1), + frames, + RetailDriveDynamics { + linear: no_drive(), + angular: no_drive(), + }, + DT, + ); + // The body quaternion deliberately remains identity: the source reads + // the independently stored Body+64/+80/+96 for this point transformation. + assert_eq!(rows.arm_a, Vector3::new(0.0, 0.0, 2.0)); +} diff --git a/crates/skate-core/src/physics/tests/filtered_state.rs b/crates/skate-core/src/physics/tests/filtered_state.rs new file mode 100644 index 00000000..1c0d9bbb --- /dev/null +++ b/crates/skate-core/src/physics/tests/filtered_state.rs @@ -0,0 +1,52 @@ +use super::*; + +fn input(state: i32, category: i32) -> FilteredStateInput { + FilteredStateInput { + physics_state: state, physics_category: category, + anything_in_contact: false, physics_surface_type: 0, + wall_ride_exit: false, targeting_grind: false, + offboard_has_landed: false, offboard_on_deck: false, + grind: GrindState::default(), last_grind_distance: 0.0, + } +} + +#[test] +fn stairs_preserve_ground_then_contact_returns_air_to_ground() { + let mut state = FilteredState::default(); + let mut stairs = input(100, 100); + stairs.physics_surface_type = 8; + assert_eq!(state.update(stairs).category, FilteredCategory::Ground); + for _ in 0..9 { + assert_eq!(state.update(input(200, 200)).category, FilteredCategory::Ground); + } + assert_eq!(state.update(input(200, 200)).category, FilteredCategory::Air); + let mut touching = input(200, 200); + touching.anything_in_contact = true; + let output = state.update(touching); + assert_eq!(output.category, FilteredCategory::Ground); + assert_eq!(output.previous_category, FilteredCategory::Air); + state.update(input(702, 700)); + assert_eq!(state.update(input(200, 200)).category, FilteredCategory::Air); +} + +#[test] +fn nonspecific_keeps_grind_metadata_and_leaving_clears_only_publication() { + let mut state = FilteredState::default(); + let mut grinding = input(403, 400); + grinding.grind = GrindState { + kind: 3, scorable_id: 21, name: encode(b"fs smith"), + scoring_name: encode(b"smith"), on_front: true, crouch: 0.25, + pathed_guid: 0x123456789abcdef0, local_guid: 0xfedcba9876543210, + }; + let entered = state.update(grinding); + assert_eq!(entered.grind, grinding.grind); + let retained = state.update(input(701, 700)); + assert!(retained.grinding); + assert_eq!(retained.grind, grinding.grind); + let left = state.update(input(100, 100)); + assert!(!left.grinding); + assert_eq!(left.grind, GrindState::default()); + assert_eq!(state.cached_grind, grinding.grind); + state.reset(); + assert_eq!(state.cached_grind, GrindState::default()); +} \ No newline at end of file diff --git a/crates/skate-core/src/physics/tests/hook_step.rs b/crates/skate-core/src/physics/tests/hook_step.rs new file mode 100644 index 00000000..ee7ee2b4 --- /dev/null +++ b/crates/skate-core/src/physics/tests/hook_step.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn an_attached_body_exchanges_reactions_with_the_board_before_both_integrate() { + let mut expected = bodies(); + let mut actual = expected; + let mut target = inactive_hook(); + target.body.state_flags = 4; + target.body.rates.position.x += 0.2; + target.drive.enable_animation_soft(&mut 0); + let mut attached_body = target.body; + let config = settings(25); + let frames = prepare_drive_frames(config.base_truck_transforms, [0.;2], &mut target); + let mut rows = BoardConstraints::build(&actual, &target, frames, + config.truck_dynamics, config.simulation.time_step).drives; + let mut row = rows.pop().unwrap(); + row.frame_a_body.reaction_index = ATTACHED_REACTION_BASE; + let mut attached_drives = [row]; + let queue = BoardForceQueue::default(); + BoardStep::default().advance(&mut expected, &mut target, &queue, &[], [0.;2], config); + BoardStep::default().advance_attached(&mut actual, &mut inactive_hook(), &queue, + &[], [0.;2], config, AttachedStep { bodies: vec![&mut attached_body], + contacts: &mut [], joints: &mut [], drives: &mut attached_drives }); + for (a,b) in actual.iter().zip(expected.iter()) { + assert_eq!(a.rates.position, b.rates.position); + assert_eq!(a.rates.linear_velocity, b.rates.linear_velocity); + } + assert_eq!(attached_body.rates.position, target.body.rates.position); + assert_eq!(attached_body.rates.linear_velocity, target.body.rates.linear_velocity); + assert!(attached_drives[0].accumulated_linear_impulse.iter().any(|v| *v != 0.)); +} + +#[test] +fn hook_drive_exchanges_momentum_and_integrates_without_teleporting_deck() { + let mut parts = bodies(); + let mut hook = inactive_hook(); + hook.body.state_flags = 4; + hook.body.rates.position.x += 0.2; + let mut animated = 0; + hook.drive.enable_animation_soft(&mut animated); + let mut disabled = hook.clone(); + disabled.drive.disable_animation(&mut animated); + let mut free = parts; + let original_deck = parts[6].rates.position; + let queue = BoardForceQueue::default(); + let mut tick = BoardStep::default(); + let config = settings(25); + tick.advance(&mut parts, &mut hook, &queue, &[], [0.0; 2], config); + BoardStep::default().advance(&mut free, &mut disabled, &queue, &[], [0.0; 2], config); + assert_ne!(parts[6].rates.position.x, free[6].rates.position.x); + assert_ne!(hook.body.rates.position.x, disabled.body.rates.position.x); + assert_ne!(parts[6].rates.position.x, hook.body.rates.position.x); + assert_ne!(parts[6].rates.position, original_deck); + let momentum = parts + .iter() + .chain(core::iter::once(&hook.body)) + .map(|b| b.rates.linear_velocity.x / b.inertia.inverse_mass) + .sum::(); + assert!( + momentum.abs() < 1e-4, + "internal hook drive changed momentum: {momentum}" + ); + assert_eq!( + tick.reactions, + [RetailReactionCorrections::default(); REACTION_COUNT] + ); +} diff --git a/crates/skate-core/src/physics/tests/joint_builder.rs b/crates/skate-core/src/physics/tests/joint_builder.rs new file mode 100644 index 00000000..7c738311 --- /dev/null +++ b/crates/skate-core/src/physics/tests/joint_builder.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn output_stride_is_the_observed_three_cache_lines() { + assert_eq!(core::mem::size_of::(), 0x180); +} + +#[test] +fn inactive_body_contributes_no_mass_or_inertia() { + // Synthetic identity bodies replace removed generated constructor output. + let body = RetailJointBodyInput { + reaction_guest_address: 0, + state: 0, + orientation: RetailQuaternion::IDENTITY, + center_of_mass: Vector3::ZERO, + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + inverse_mass: 1.0, + world_inverse_inertia: RetailPackedWorldInverseInertia { + full: Vector3::new(1.0, 0.0, 0.0), + split: Vector3::new(1.0, 1.0, 0.0), + }, + }; + let mut frames = RetailJointFramesRaw { words: [0; 20] }; + for index in [3, 11, 19] { + frames.words[index] = 1.0f32.to_bits(); + } + let built = build_retail_joint_jacobian(RetailJointBuildInput { + parameters: RetailJointParametersRaw { words: [0; 16] }, + frames, + body_a: body, + body_b: body, + time_step: 0.25, + joint_guest_address: 0, + }); + assert_eq!(&built.words[84..96], &[0; 12]); +} + +fn angular_parameters(swing_mode: u32, twist_mode: u32) -> JointParameters { + JointParameters { + linear_position_allowance: Vector3::ZERO, + linear_velocity_allowance: Vector3::ZERO, + twist_velocity_allowance: 0.0, + swing_velocity_allowance: 0.0, + swing_threshold: 0.5, + twist_threshold: 0.5, + swing_mode, + twist_mode, + } +} + +fn identity_basis() -> Basis3 { + Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + } +} + +#[test] +fn angular_limit_zero_denominators_retain_native_intervals() { + let basis = identity_basis(); + let relative = basis.columns; + let rqd = create_rqd(RetailQuaternion::IDENTITY, RetailQuaternion::IDENTITY); + for mode in [1, 2] { + let (axes, low, high) = + angular_rows(angular_parameters(mode, 1), basis, basis, relative, rqd); + assert_eq!(axes, basis_columns(basis)); + // Original cone cosine guard, planar zero reciprocal guard and twist + // zero reciprocal guard retain the initialized unlimited intervals. + assert_eq!(low[0], -tu3::FINITE_INFINITY); + assert_eq!(high[0], tu3::FINITE_INFINITY); + assert_eq!(low[1], -tu3::FINITE_INFINITY); + assert_eq!(high[1], tu3::FINITE_INFINITY); + if mode == 2 { + assert_eq!(low[2], 0.0); + assert_eq!(high[2], 0.0); + } + } +} + +#[test] +fn cone_inside_limit_still_publishes_predictive_bound() { + let a = identity_basis(); + let b = Basis3 { + columns: [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]], + }; + let relative = [[0.8, -0.6, 0.0], [0.6, 0.8, 0.0], [0.0, 0.0, 1.0]]; + let (_, low, _) = angular_rows( + angular_parameters(1, 2), + a, + b, + relative, + create_rqd(RetailQuaternion::IDENTITY, RetailQuaternion::IDENTITY), + ); + // 82AE4238 writes (0.5-0.8)/0.6 even though the current angle is inside + // the limit; the velocity bounds can then constrain this tick's motion. + assert!((low[1] + 0.5).abs() < 1.0e-6); +} + +#[test] +fn rqd_rows_are_bilinear_without_unit_quaternion_reconstruction() { + let q = RetailQuaternion { + x: 1.0, + y: 2.0, + z: 3.0, + w: 4.0, + }; + let rqd = create_rqd(q, RetailQuaternion::IDENTITY); + // Original 82AE0DB0's bilinear products reduce to these values when B=1. + assert_eq!( + rqd.axes, + [ + Vector3::new(4.0, 3.0, -2.0), + Vector3::new(-3.0, 4.0, 1.0), + Vector3::new(2.0, -1.0, 4.0) + ] + ); + assert_eq!( + rqd.relative, + RetailQuaternion { + x: -1.0, + y: -2.0, + z: -3.0, + w: 4.0 + } + ); +} diff --git a/crates/skate-core/src/physics/tests/joint_records.rs b/crates/skate-core/src/physics/tests/joint_records.rs new file mode 100644 index 00000000..f8009c9a --- /dev/null +++ b/crates/skate-core/src/physics/tests/joint_records.rs @@ -0,0 +1,146 @@ +use core::mem::size_of; + +use super::*; + +#[test] +fn raw_record_sizes_match_tu3_owner_strides() { + assert_eq!(size_of::(), 64); + assert_eq!(size_of::(), 80); +} + +#[test] +fn stock_records_preserve_unrolled_definition_and_live_pair_order() { + let joints = default_joint_records(); + assert_eq!( + joints.map(|joint| (joint.definition_body_0, joint.definition_body_1)), + [ + (BodyId::Deck, BodyId::FrontTruck), + (BodyId::Deck, BodyId::BackTruck), + (BodyId::FrontTruck, BodyId::RightFrontWheel), + (BodyId::FrontTruck, BodyId::LeftFrontWheel), + (BodyId::BackTruck, BodyId::RightBackWheel), + (BodyId::BackTruck, BodyId::LeftBackWheel), + ] + ); + assert_eq!( + joints.map(|joint| (joint.live_body_a(), joint.live_body_b())), + [ + (BodyId::FrontTruck, BodyId::Deck), + (BodyId::BackTruck, BodyId::Deck), + (BodyId::RightFrontWheel, BodyId::FrontTruck), + (BodyId::LeftFrontWheel, BodyId::FrontTruck), + (BodyId::RightBackWheel, BodyId::BackTruck), + (BodyId::LeftBackWheel, BodyId::BackTruck), + ] + ); +} + +#[test] +fn stock_parameter_arithmetic_matches_create_joints_words() { + let joints = default_joint_records(); + assert_eq!( + joints[0].parameters.words, + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x42B4_53D1, + 0, + 0, + 0x3E7A_35DD, + 0x3F80_0000, + 0x3F78_654D, + 0, + 1, + ] + ); + assert_eq!( + joints[2].parameters.words, + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x497F_A9D8, + 0, + 0, + 0x3F80_0000, + 0x3F80_0000, + 3, + 0, + ] + ); +} + +#[test] +fn stock_frame_construction_matches_native_payloads() { + let joints = default_joint_records(); + assert_eq!( + joints[0].frames.words, + [ + 0, + 0, + 0, + 0x3F80_0000, + 0, + 0, + 0, + 0, + 0xBE41_8051, + 0xBF2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + 0, + 0xBD67_6C8B, + 0x3E78_D4FD, + 0, + 0xBE41_8051, + 0xBF2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + ] + ); + assert_eq!( + joints[1].frames.words, + [ + 0, + 0, + 0, + 0x3F80_0000, + 0, + 0, + 0, + 0, + 0x3E41_8051, + 0x3F2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + 0, + 0xBD67_6C8B, + 0xBE78_D4FD, + 0, + 0x3E41_8051, + 0x3F2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + ] + ); + let wheel_orientation = [0x3F35_04F3, 0, 0, 0x3F35_04F3]; + assert_eq!(&joints[2].frames.words[0..4], &wheel_orientation); + assert_eq!(&joints[2].frames.words[8..12], &wheel_orientation); + assert_eq!(&joints[2].frames.words[16..20], &wheel_orientation); + assert_eq!(joints[2].frames.words[14], 0x3DC2_8F5C); + assert_eq!(joints[3].frames.words[14], 0xBDC2_8F5C); + assert_eq!(joints[4].frames, joints[2].frames); + assert_eq!(joints[5].frames, joints[3].frames); +} diff --git a/crates/skate-core/src/physics/tests/joint_solver.rs b/crates/skate-core/src/physics/tests/joint_solver.rs new file mode 100644 index 00000000..c2a85e62 --- /dev/null +++ b/crates/skate-core/src/physics/tests/joint_solver.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn raw_layout_matches_tu3_strides() { + assert_eq!(core::mem::size_of::(), 384); + assert_eq!(core::mem::size_of::(), 64); +} + +#[test] +fn reaction_bridge_preserves_contact_only_position_corrections() { + let mut reactions = RetailReactionCorrections { + linear_displacement: crate::math::Vector3::new(1.0, 2.0, 3.0), + position_displacement: crate::math::Vector3::new(4.0, 5.0, 6.0), + angular_displacement: crate::math::Vector3::new(7.0, 8.0, 9.0), + orientation_displacement: crate::math::Vector3::new(10.0, 11.0, 12.0), + }; + let mut block = RetailJointReactionBlock::from_reactions(reactions); + block.words[0] = 13.0f32.to_bits(); + block.words[9] = 14.0f32.to_bits(); + block.write_velocity_reactions(&mut reactions); + + assert_eq!( + reactions.linear_displacement, + crate::math::Vector3::new(13.0, 2.0, 3.0) + ); + assert_eq!( + reactions.angular_displacement, + crate::math::Vector3::new(7.0, 14.0, 9.0) + ); + assert_eq!( + reactions.position_displacement, + crate::math::Vector3::new(4.0, 5.0, 6.0) + ); + assert_eq!( + reactions.orientation_displacement, + crate::math::Vector3::new(10.0, 11.0, 12.0) + ); +} diff --git a/crates/skate-core/src/physics/tests/mass_moments.rs b/crates/skate-core/src/physics/tests/mass_moments.rs new file mode 100644 index 00000000..85e73fc3 --- /dev/null +++ b/crates/skate-core/src/physics/tests/mass_moments.rs @@ -0,0 +1,141 @@ +use crate::{ + math::{Basis3, Vector3}, + physics::mass::{MassMoments, PrimitiveMass}, +}; + +fn close(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 2.0e-5, "{actual} != {expected}"); +} + +#[test] +fn translated_primitive_recovers_center_without_parallel_axis_inertia() { + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + let identity = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }; + moments.transform(identity, Vector3::new(2.0, -1.0, 3.0)); + let result = moments.principal_properties(); + assert_eq!( + result.local_mass_frame.translation, + Vector3::new(2.0, -1.0, 3.0) + ); + close(result.moments_per_unit_mass.x, 2.0); + close(result.moments_per_unit_mass.y, 3.0); + close(result.moments_per_unit_mass.z, 4.0); +} + +#[test] +fn rotated_mass_reconstructs_independent_world_inertia() { + let sine = 0.6; + let cosine = 0.8; + let basis = Basis3 { + columns: [[cosine, sine, 0.0], [-sine, cosine, 0.0], [0.0, 0.0, 1.0]], + }; + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + moments.transform(basis, Vector3::new(0.0, 0.0, 0.0)); + let result = moments.principal_properties(); + let columns = result.local_mass_frame.basis.columns; + let diagonal = [ + result.moments_per_unit_mass.x, + result.moments_per_unit_mass.y, + result.moments_per_unit_mass.z, + ]; + // R diag(2,3,4) R^T for the independently specified rotation. + let expected = [[2.36, -0.48, 0.0], [-0.48, 2.64, 0.0], [0.0, 0.0, 4.0]]; + for row in 0..3 { + for column in 0..3 { + let actual: f32 = (0..3) + .map(|axis| columns[axis][row] * diagonal[axis] * columns[axis][column]) + .sum(); + close(actual, expected[row][column]); + } + } +} + +#[test] +fn two_translated_equal_volumes_preserve_parallel_axis_contribution() { + let primitive = PrimitiveMass { + moments_per_unit_mass: Vector3::new(1.0, 1.0, 1.0), + volume: 3.0, + }; + let identity = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }; + let mut total = MassMoments::ZERO; + for x in [-2.0, 2.0] { + let mut child = MassMoments::from_primitive(primitive); + child.transform(identity, Vector3::new(x, 0.0, 0.0)); + total.add(child); + } + let result = total.principal_properties(); + assert_eq!(result.volume, 6.0); + close(result.moments_per_unit_mass.x, 1.0); + close(result.moments_per_unit_mass.y, 5.0); + close(result.moments_per_unit_mass.z, 5.0); +} + +#[test] +fn aggregate_finalizer_stores_inverse_frame_and_keeps_world_inertia() { + use crate::physics::mass::aggregate_mass_properties; + let rotation = Basis3 { + columns: [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]], + }; + let center = Vector3::new(2.0, -1.0, 3.0); + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + moments.transform(rotation, center); + let result = aggregate_mass_properties(moments, 2.0, 20.0, 0.25); + let inverse = result.local_mass_frame; + let translation = [ + inverse.translation.x, + inverse.translation.y, + inverse.translation.z, + ]; + for row in 0..3 { + // Applying inverseBodyLTM to the authored COM must produce the origin. + let local_center = inverse.basis.columns[0][row] * center.x + + inverse.basis.columns[1][row] * center.y + + inverse.basis.columns[2][row] * center.z + + translation[row]; + close(local_center, 0.0); + } + let tensor = result.dynamics.inverse_tensor; + let principal = [1.0 / tensor.x, 1.0 / tensor.y, 1.0 / tensor.z]; + // I_world = transpose(inverse_basis) * I_principal * inverse_basis. + let expected = [[4.72, -0.96, 0.0], [-0.96, 5.28, 0.0], [0.0, 0.0, 8.0]]; + for row in 0..3 { + for column in 0..3 { + let actual: f32 = (0..3) + .map(|axis| { + inverse.basis.columns[row][axis] + * principal[axis] + * inverse.basis.columns[column][axis] + }) + .sum(); + close(actual, expected[row][column]); + } + } +} + +#[test] +fn finalizer_omits_source_negligible_frame_correction() { + use crate::physics::{mass::aggregate_mass_properties, rigid_body::RetailLocalMassFrame}; + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + moments.transform( + RetailLocalMassFrame::IDENTITY.basis, + Vector3::new(0.0001, 0.0, 0.0), + ); + let result = aggregate_mass_properties(moments, 2.0, 20.0, 0.0); + assert_eq!(result.local_mass_frame, RetailLocalMassFrame::IDENTITY); +} diff --git a/crates/skate-core/src/physics/tests/native_arithmetic.rs b/crates/skate-core/src/physics/tests/native_arithmetic.rs new file mode 100644 index 00000000..b4cef0f0 --- /dev/null +++ b/crates/skate-core/src/physics/tests/native_arithmetic.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn host_math_normalizes_a_direction_without_using_its_homogeneous_lane() { + let direction = [3.0, 4.0, 0.0, 100.0]; + let inverse_length = reciprocal_square_root_estimate(dot3(direction, direction)); + let unit = direction.map(|v| v * inverse_length); + assert!((dot3(unit, unit) - 1.0).abs() < 1.0e-6); + assert_eq!(dot3(direction, [4.0, -3.0, 0.0, 50.0]), 0.0); + assert_eq!(dot4(direction, [0.0, 0.0, 0.0, 1.0]), 100.0); +} + +#[test] +fn host_math_handles_scalar_geometry_and_ordinary_float_boundaries() { + assert_eq!(reciprocal_estimate(4.0), 0.25); + assert_eq!(reciprocal_square_root_estimate(16.0), 0.25); + assert_eq!(reciprocal_estimate(-0.0), f32::NEG_INFINITY); + assert_eq!(reciprocal_square_root_estimate(f32::INFINITY), 0.0); + assert!(reciprocal_square_root_estimate(-1.0).is_nan()); + assert_eq!(vector_min(-2.0, 3.0), -2.0); + assert_eq!(vector_max(-2.0, 3.0), 3.0); +} diff --git a/crates/skate-core/src/physics/tests/primitive_query.rs b/crates/skate-core/src/physics/tests/primitive_query.rs new file mode 100644 index 00000000..0cdc5034 --- /dev/null +++ b/crates/skate-core/src/physics/tests/primitive_query.rs @@ -0,0 +1,277 @@ +use crate::{ + math::{Basis3, Vector3}, + physics::{ + collision::{ + Sphere, Triangle, TriangleFeature, WorldContactSettings, sphere_triangle_world_contact, + }, + world_contact::{ + ContactPrimitive, primitive_triangle_world_contacts, transform_triangle_volume, + triangle_from_volume, + }, + }, +}; + +fn v(x: f32, y: f32, z: f32) -> Vector3 { + Vector3::new(x, y, z) +} +fn floor() -> Triangle { + triangle_from_volume( + [v(-5.0, 0.0, -5.0), v(0.0, 0.0, 5.0), v(5.0, 0.0, -5.0)], + 0.0, + [-1.0; 3], + 2, + ) +} +fn settings(padding: f32) -> WorldContactSettings { + WorldContactSettings { + volume_padding: padding, + maximum_separating_distance: 1.0, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + } +} +fn close(a: f32, b: f32) { + assert!((a - b).abs() < 2.0e-5, "{a} != {b}"); +} +fn same(a: Vector3, b: Vector3) { + close(a.x, b.x); + close(a.y, b.y); + close(a.z, b.z); +} +fn identity() -> Basis3 { + Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + } +} + +#[test] +fn volume_builder_uses_native_winding_edges_and_flags() { + let triangle = triangle_from_volume( + [v(0.0, 0.0, 0.0), v(0.0, 0.0, 4.0), v(3.0, 0.0, 0.0)], + 0.02, + [1.0, -1.0, 1.0], + 0x3e3, + ); + same(triangle.feature.normal, v(0.0, 1.0, 0.0)); + same(triangle.feature.edges[0], v(1.0, 0.0, 0.0)); + same(triangle.feature.edges[1], v(-0.6, 0.0, 0.8)); + same(triangle.feature.edges[2], v(0.0, 0.0, -1.0)); + for (actual, expected) in triangle.edge_lengths.into_iter().zip([3.0, 5.0, 4.0]) { + close(actual, expected); + } + assert_eq!(triangle.feature.flags, 0x3e1); + assert_eq!(triangle.feature.edge_cosines, [1.0, -1.0, 1.0]); + assert_eq!(triangle.fatness, 0.02); +} + +#[test] +fn volume_transform_keeps_local_normal_and_rebuilds_world_edges() { + let triangle = transform_triangle_volume( + [v(0.0, 0.0, 0.0), v(0.0, 0.0, 4.0), v(3.0, 0.0, 0.0)], + 0.02, + [-1.0; 3], + 2, + Basis3 { + columns: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + }, + v(4.0, 5.0, 6.0), + ); + same(triangle.feature.normal, v(-1.0, 0.0, 0.0)); + same(triangle.vertices[2], v(4.0, 8.0, 6.0)); + same(triangle.feature.edges[0], v(0.0, 1.0, 0.0)); + close(triangle.edge_lengths[1], 5.0); +} + +#[test] +fn generic_sphere_world_path_matches_existing_independent_sphere_branch() { + let sphere = Sphere { + center: v(0.4, 0.13, -0.2), + radius: 0.15, + }; + let mut triangle = floor(); + triangle.fatness = 0.01; + let generic = primitive_triangle_world_contacts( + ContactPrimitive::Sphere(sphere), + triangle, + Vector3::ZERO, + settings(0.0), + ) + .unwrap(); + let previous = + sphere_triangle_world_contact(sphere, triangle, Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(generic.count, 1); + same(generic.normal, previous.normal); + same(generic.points[0].a, previous.points.a); + same(generic.points[0].b, previous.points.b); + same(generic.normal, v(0.0, 1.0, 0.0)); + close(generic.points[0].a.y, -0.02); + close(generic.points[0].b.y, 0.01); +} + +#[test] +fn capsule_face_keeps_both_axial_end_contacts() { + let capsule = ContactPrimitive::Capsule { + center: v(0.0, 0.1, 0.0), + axis: v(1.0, 0.0, 0.0), + half_length: 0.4, + radius: 0.15, + }; + let result = + primitive_triangle_world_contacts(capsule, floor(), Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(result.count, 2); + same(result.normal, v(0.0, 1.0, 0.0)); + let mut xs = result.points[..2].iter().map(|p| p.a.x).collect::>(); + xs.sort_by(f32::total_cmp); + close(xs[0], -0.4); + close(xs[1], 0.4); + for p in &result.points[..2] { + close(p.a.y, -0.05); + close(p.b.y, 0.0); + close(p.a.x, p.b.x); + } +} + +#[test] +fn rounded_box_face_produces_all_four_corner_contacts() { + let shape = ContactPrimitive::RoundedBox { + center: v(0.0, 0.09, 0.0), + basis: identity(), + half_extents: v(0.4, 0.08, 0.2), + radius: 0.02, + }; + let result = + primitive_triangle_world_contacts(shape, floor(), Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(result.count, 4); + same(result.normal, v(0.0, 1.0, 0.0)); + for p in &result.points[..result.count] { + close(p.a.y, -0.01); + close(p.b.y, 0.0); + close(p.a.x.abs(), 0.4); + close(p.a.z.abs(), 0.2); + } +} + +#[test] +fn moving_triangle_face_preserves_three_vertex_contacts() { + let moving = triangle_from_volume( + [v(-0.4, 0.01, -0.2), v(0.0, 0.01, 0.2), v(0.4, 0.01, -0.2)], + 0.02, + [-1.0; 3], + 2, + ); + let result = primitive_triangle_world_contacts( + ContactPrimitive::Triangle(moving), + floor(), + Vector3::ZERO, + settings(0.0), + ) + .unwrap(); + assert_eq!(result.count, 3); + same(result.normal, v(0.0, 1.0, 0.0)); + for p in &result.points[..3] { + close(p.a.y, -0.01); + close(p.b.y, 0.0); + } +} + +#[test] +fn prediction_uses_fixed_sixtieth_and_separate_volume_padding() { + let sphere = ContactPrimitive::Sphere(Sphere { + center: v(0.0, 0.2, 0.0), + radius: 0.1, + }); + assert!( + primitive_triangle_world_contacts(sphere, floor(), Vector3::ZERO, settings(0.0)).is_none() + ); + let predicted = + primitive_triangle_world_contacts(sphere, floor(), v(0.0, -6.0, 0.0), settings(0.001)) + .unwrap(); + close(predicted.points[0].a.y, 0.1); + assert!( + primitive_triangle_world_contacts(sphere, floor(), v(0.0, 6.0, 0.0), settings(0.001)) + .is_none() + ); +} + +#[test] +fn one_sided_world_triangle_rejects_below_surface() { + let mut triangle = floor(); + triangle.feature.flags |= TriangleFeature::ONE_SIDED; + let below = ContactPrimitive::Sphere(Sphere { + center: v(0.0, -0.05, 0.0), + radius: 0.1, + }); + assert!( + primitive_triangle_world_contacts(below, triangle, Vector3::ZERO, settings(0.0)).is_none() + ); + let above = ContactPrimitive::Sphere(Sphere { + center: v(0.0, 0.05, 0.0), + radius: 0.1, + }); + assert!( + primitive_triangle_world_contacts(above, triangle, Vector3::ZERO, settings(0.0)).is_some() + ); +} + +#[test] +fn triangle_box_edge_cross_axis_rejects_despite_overlapping_face_intervals() { + // All three box-axis intervals and the triangle normal overlap. The + // diagonal triangle edge x+z=2.1 is sqrt(0.005) beyond box corner(1,1), + // so this separation can only be found by an edge-cross SAT direction. + let triangle = triangle_from_volume( + [v(0.0, 0.0, 2.1), v(3.0, 0.0, 3.0), v(2.1, 0.0, 0.0)], + 0.0, + [-1.0; 3], + 0xe2, + ); + let shape = |radius| ContactPrimitive::RoundedBox { + center: Vector3::ZERO, + basis: identity(), + half_extents: v(1.0, 1.0, 1.0), + radius, + }; + assert!( + primitive_triangle_world_contacts(shape(0.05), triangle, Vector3::ZERO, settings(0.0)) + .is_none() + ); + let result = + primitive_triangle_world_contacts(shape(0.08), triangle, Vector3::ZERO, settings(0.0)) + .unwrap(); + assert_eq!(result.count, 1); + same( + result.normal, + v( + -std::f32::consts::FRAC_1_SQRT_2, + 0.0, + -std::f32::consts::FRAC_1_SQRT_2, + ), + ); + same(result.points[0].b, v(1.05, 0.0, 1.05)); + let offset = 0.08 * std::f32::consts::FRAC_1_SQRT_2; + same(result.points[0].a, v(1.0 + offset, 0.0, 1.0 + offset)); +} + +#[test] +fn rotated_box_and_triangle_keep_the_plane_manifold() { + let c = 0.8660254; + let basis = Basis3 { + columns: [[c, 0.5, 0.0], [-0.5, c, 0.0], [0.0, 0.0, 1.0]], + }; + let triangle = + transform_triangle_volume(floor().vertices, 0.0, [-1.0; 3], 2, basis, v(2.0, 3.0, 4.0)); + let shape = ContactPrimitive::RoundedBox { + center: v(2.0 - 0.09 * 0.5, 3.0 + 0.09 * c, 4.0), + basis, + half_extents: v(0.4, 0.08, 0.2), + radius: 0.02, + }; + let result = + primitive_triangle_world_contacts(shape, triangle, Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(result.count, 4); + same(result.normal, v(-0.5, c, 0.0)); + for p in &result.points[..result.count] { + close((p.a.x - 2.0) * -0.5 + (p.a.y - 3.0) * c, -0.01); + close((p.b.x - 2.0) * -0.5 + (p.b.y - 3.0) * c, 0.0); + } +} diff --git a/crates/skate-core/src/physics/tests/rigid_body.rs b/crates/skate-core/src/physics/tests/rigid_body.rs new file mode 100644 index 00000000..e94638c1 --- /dev/null +++ b/crates/skate-core/src/physics/tests/rigid_body.rs @@ -0,0 +1,274 @@ +use super::*; +use crate::physics::mass::*; + +const IDENTITY: Basis3 = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], +}; + +fn assert_close(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1.0e-6, + "actual {actual}, expected {expected}" + ); +} + +fn assert_vec_close(actual: Vector3, expected: Vector3) { + assert_close(actual.x, expected.x); + assert_close(actual.y, expected.y); + assert_close(actual.z, expected.z); +} + +#[test] +fn recovered_deck_mass_frame_is_finite_right_handed_and_orthonormal() { + let frame = retail_deck_mass_properties().local_mass_frame; + let right = column(frame.basis, 0); + let up = column(frame.basis, 1); + let at = column(frame.basis, 2); + for value in [ + right.x, + right.y, + right.z, + up.x, + up.y, + up.z, + at.x, + at.y, + at.z, + frame.translation.x, + frame.translation.y, + frame.translation.z, + ] { + assert!(value.is_finite()); + } + assert!((length_squared(right) - 1.0).abs() < 2.0e-5); + assert!((length_squared(up) - 1.0).abs() < 2.0e-5); + assert!((length_squared(at) - 1.0).abs() < 2.0e-5); + assert!(dot(right, up).abs() < 2.0e-5); + assert!(dot(right, at).abs() < 2.0e-5); + assert!(dot(up, at).abs() < 2.0e-5); + assert!(dot(cross(right, up), at) > 0.99998); +} + +#[test] +fn force_accumulator_applies_inverse_mass_and_point_torque() { + let output = accumulate_point_force( + RetailForceAccumulator { + force_acceleration: Vector3::new(1.0, 2.0, 3.0), + torque_acceleration: Vector3::ZERO, + cool_down: 7, + }, + Vector3::new(0.0, 12.0, 0.0), + Vector3::new(0.0, 0.0, 2.0), + IDENTITY, + 0.5, + IDENTITY, + ); + assert_vec_close(output.force_acceleration, Vector3::new(1.0, 8.0, 3.0)); + assert_vec_close(output.torque_acceleration, Vector3::new(-24.0, 0.0, 0.0)); + assert_eq!(output.cool_down, 0); +} + +#[test] +fn body_step_uses_solver_displacements_without_tangent_projection() { + let simulation = RetailSimulationStep { + time_step: 0.25, + frequency: 4.0, + cool_down: 8, + minimum_energy: 0.0, + gravity_acceleration: Vector3::new(0.0, -9.8, 0.0), + }; + let output = integrate_body_rates( + RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: IDENTITY, + world_inverse_inertia: IDENTITY, + position: Vector3::new(1.0, 2.0, 3.0), + linear_velocity: Vector3::new(4.0, 0.0, 0.0), + angular_velocity: Vector3::new(0.0, 2.0, 0.0), + force_acceleration: Vector3::new(0.0, -4.0, 0.0), + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }, + RetailInertiaDynamics { + inverse_tensor: Vector3::new(1.0, 1.0, 1.0), + inverse_mass: 0.5, + spherical: 1.0, + maximum_linear_velocity: 100.0, + maximum_angular_velocity: 100.0, + linear_drag: 0.0, + angular_drag: 0.0, + }, + simulation, + RetailReactionCorrections { + linear_displacement: Vector3::new(0.0, 0.25, 0.0), + position_displacement: Vector3::new(0.0, 0.0, 0.5), + angular_displacement: Vector3::new(0.0, 0.0, 0.25), + orientation_displacement: Vector3::new(0.125, 0.0, 0.0), + }, + ); + + assert_vec_close(output.state.position, Vector3::new(2.0, 2.0, 3.5)); + assert_vec_close(output.state.linear_velocity, Vector3::new(4.0, 0.0, 0.0)); + assert_vec_close(output.state.angular_velocity, Vector3::new(0.0, 2.0, 1.0)); + assert_vec_close( + output.orientation_displacement, + Vector3::new(0.125, 0.5, 0.25), + ); + assert_vec_close( + output.state.force_acceleration, + simulation.gravity_acceleration, + ); + assert_vec_close(output.state.torque_acceleration, Vector3::ZERO); + assert_eq!( + output.state.orientation, + integrate_orientation(RetailQuaternion::IDENTITY, output.orientation_displacement) + ); +} + +#[test] +fn drag_is_frequency_minus_drag_on_frame_displacement() { + let output = integrate_body_rates( + RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: IDENTITY, + world_inverse_inertia: IDENTITY, + position: Vector3::ZERO, + linear_velocity: Vector3::new(8.0, 0.0, 0.0), + angular_velocity: Vector3::new(0.0, 6.0, 0.0), + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 100.0, + cool_down: 0, + }, + RetailInertiaDynamics { + inverse_tensor: Vector3::new(1.0, 1.0, 1.0), + inverse_mass: 1.0, + spherical: 1.0, + maximum_linear_velocity: 100.0, + maximum_angular_velocity: 100.0, + linear_drag: 2.0, + angular_drag: 3.0, + }, + RetailSimulationStep { + time_step: 0.25, + frequency: 4.0, + cool_down: 8, + minimum_energy: 0.0, + gravity_acceleration: Vector3::ZERO, + }, + RetailReactionCorrections::default(), + ); + assert_vec_close(output.state.linear_velocity, Vector3::new(4.0, 0.0, 0.0)); + assert_vec_close(output.state.angular_velocity, Vector3::new(0.0, 1.5, 0.0)); +} + +#[test] +fn speed_caps_and_retail_cool_down_branch_are_separate() { + let mut body = RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: IDENTITY, + world_inverse_inertia: IDENTITY, + position: Vector3::ZERO, + linear_velocity: Vector3::new(10.0, 0.0, 0.0), + angular_velocity: Vector3::new(0.0, 8.0, 0.0), + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 200.0, + cool_down: 2, + }; + let inertia = RetailInertiaDynamics { + inverse_tensor: Vector3::new(1.0, 1.0, 1.0), + inverse_mass: 0.5, + spherical: 2.0, + maximum_linear_velocity: 3.0, + maximum_angular_velocity: 2.0, + linear_drag: 0.0, + angular_drag: 0.0, + }; + let simulation = RetailSimulationStep { + time_step: 0.25, + frequency: 4.0, + cool_down: 4, + minimum_energy: 20.0, + gravity_acceleration: Vector3::ZERO, + }; + let first = integrate_body_rates( + body, + inertia, + simulation, + RetailReactionCorrections::default(), + ); + assert_close(first.state.linear_velocity.x, 3.0); + assert_close(first.state.angular_velocity.y, 2.0); + assert_close(first.state.kinetic_energy, 13.0); + assert_eq!(first.state.cool_down, 3); + + body = first.state; + let second = integrate_body_rates( + body, + inertia, + simulation, + RetailReactionCorrections::default(), + ); + assert_eq!(second.state.cool_down, 4); +} + +#[test] +fn quaternion_branch_uses_retail_left_multiplication_and_normalizes() { + let quarter_turn_y = RetailQuaternion { + x: 0.0, + y: core::f32::consts::FRAC_1_SQRT_2, + z: 0.0, + w: core::f32::consts::FRAC_1_SQRT_2, + }; + let angular = Vector3::new(0.2, 0.3, 0.4); + let actual = integrate_orientation(quarter_turn_y, angular); + + let old_vector = Vector3::new(quarter_turn_y.x, quarter_turn_y.y, quarter_turn_y.z); + let left_product_vector = add(scale(angular, quarter_turn_y.w), cross(angular, old_vector)); + let expected = normalize_quaternion(RetailQuaternion { + x: quarter_turn_y.x + 0.5 * left_product_vector.x, + y: quarter_turn_y.y + 0.5 * left_product_vector.y, + z: quarter_turn_y.z + 0.5 * left_product_vector.z, + w: quarter_turn_y.w - 0.5 * dot(angular, old_vector), + }); + assert_eq!(actual, expected); + assert_close( + actual.x * actual.x + actual.y * actual.y + actual.z * actual.z + actual.w * actual.w, + 1.0, + ); +} + +#[test] +fn quaternion_basis_and_world_inverse_inertia_preserve_body_axes() { + let quarter_turn_y = RetailQuaternion { + x: 0.0, + y: core::f32::consts::FRAC_1_SQRT_2, + z: 0.0, + w: core::f32::consts::FRAC_1_SQRT_2, + }; + let basis = basis_from_quaternion(quarter_turn_y); + assert_vec_close(column(basis, 0), Vector3::new(0.0, 0.0, -1.0)); + assert_vec_close(column(basis, 1), Vector3::new(0.0, 1.0, 0.0)); + assert_vec_close(column(basis, 2), Vector3::new(1.0, 0.0, 0.0)); + + let world = world_inverse_inertia(basis, Vector3::new(2.0, 3.0, 5.0)); + assert_vec_close(column(world, 0), Vector3::new(5.0, 0.0, 0.0)); + assert_vec_close(column(world, 1), Vector3::new(0.0, 3.0, 0.0)); + assert_vec_close(column(world, 2), Vector3::new(0.0, 0.0, 2.0)); +} + +#[test] +fn packed_world_inverse_inertia_uses_retail_lane_order() { + let symmetric = Basis3 { + columns: [[2.0, 3.0, 5.0], [3.0, 7.0, 11.0], [5.0, 11.0, 13.0]], + }; + let packed = pack_world_inverse_inertia(symmetric); + assert_eq!(packed.full, Vector3::new(2.0, 3.0, 5.0)); + assert_eq!(packed.split, Vector3::new(13.0, 7.0, 11.0)); + assert_vec_close( + multiply_packed_world_inverse_inertia(packed, Vector3::new(17.0, 19.0, 23.0)), + multiply_basis(symmetric, Vector3::new(17.0, 19.0, 23.0)), + ); +} diff --git a/crates/skate-core/src/physics/tests/skeleton_animation_record.rs b/crates/skate-core/src/physics/tests/skeleton_animation_record.rs new file mode 100644 index 00000000..107fda32 --- /dev/null +++ b/crates/skate-core/src/physics/tests/skeleton_animation_record.rs @@ -0,0 +1,55 @@ +use super::*; + +#[test] +fn com_uses_original_bone_volume_excludes_board_and_preserves_reset_order() { + let mut sizes = [Vector3::new(0.0, 0.0, 0.0); 24]; + sizes[0] = Vector3::new(100.0, 100.0, 100.0); + sizes[1] = Vector3::new(1.0, 1.0, 1.0); + sizes[2] = Vector3::new(1.0, 1.0, 3.0); + let masses = SkeletonAnimationMasses::from_bone_data(sizes, [1; 24], false); + assert_eq!(masses.total, 4.0); + assert_eq!(masses.fractional[0..3], [0.0, 0.25, 0.75]); + let mut pose = [IDENTITY; 24]; + pose[0][3] = [1.0, 0.0, 0.0, 0.0]; + pose[1][3] = [1.0, 2.0, 0.0, 0.0]; + pose[2][3] = [1.0, 6.0, 0.0, 0.0]; + let mut to_board = IDENTITY; + to_board[3] = [100.0, 20.0, -10.0, 0.0]; + let mut record = SkeletonAnimationRecord::default(); + record.update(&pose, &to_board, &masses); + assert_eq!(record.centre_of_mass, [1.0, 5.0, 0.0, 0.0]); + assert_eq!(record.com_to_deck_world, [0.0, 5.0, 0.0, 0.0]); + assert_eq!(record.com_to_deck_world_delta, [0.0; 4]); + pose[1][3][1] += 4.0; + record.update(&pose, &to_board, &masses); + assert_eq!(record.com_to_deck_world_delta, [0.0, 1.0, 0.0, 0.0]); + record.reset_history(); + assert_eq!(record.reset_scalar, 1.0); + assert_eq!(record.pose, pose); +} +#[test] +fn global_bone_then_physics_frame_preserves_native_mapping_and_lanes() { + let local = physics_bone_frame([0.0, 0.0, 0.0, 1.0], [1.0, 2.0, 3.0, 7.0]); + assert_eq!(local[0], [1.0, 0.0, 0.0, 1.0]); + assert_eq!(local[1], [0.0, 1.0, 0.0, 0.0]); + assert_eq!(local[2], [0.0, 0.0, 1.0, 0.0]); + // A global quarter turn around Z makes multiplication order observable. + let global = [ + [0.0, 1.0, 0.0, 11.0], + [-1.0, 0.0, 0.0, 13.0], + [0.0, 0.0, 1.0, 17.0], + [10.0, 20.0, 30.0, 19.0], + ]; + let mut indices = [0; 24]; + indices[1] = 1; + let mut frames = [IDENTITY; 24]; + frames[1] = local; + let mapped = map_animation_parts(&[IDENTITY, global], &indices, &frames).unwrap(); + assert_eq!(mapped[0], IDENTITY); + assert_eq!(mapped[1][0], global[0]); + assert_eq!(mapped[1][1], global[1]); + assert_eq!(mapped[1][2], global[2]); + assert_eq!(mapped[1][3], [8.0, 21.0, 33.0, 107.0]); + indices[23] = 2; + assert!(map_animation_parts(&[IDENTITY, global], &indices, &frames).is_err()); +} diff --git a/crates/skate-core/src/physics/tests/skeleton_board_offset.rs b/crates/skate-core/src/physics/tests/skeleton_board_offset.rs new file mode 100644 index 00000000..b5378448 --- /dev/null +++ b/crates/skate-core/src/physics/tests/skeleton_board_offset.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn refresh_applies_without_decay_then_height_and_xz_decay_independently() { + let mut offset = SkateboardOffset::default(); + let mut transform = IDENTITY; + transform[3] = [15.0, 30.0, 45.0, 0.0]; + offset.refresh_transform(transform); + let mut board = IDENTITY; + let mut targets = [IDENTITY; 4]; + offset.update(&mut board, &mut targets); + assert_eq!(board[3], transform[3]); + assert_eq!(targets[3][3], transform[3]); + assert_eq!(offset.orientation_frames, 15.0); + offset.refresh_height(10.0, 7.0); + board = IDENTITY; + targets = [IDENTITY; 4]; + offset.update(&mut board, &mut targets); + let ratio = 14.0f32 / 15.0; + assert_eq!(offset.orientation_frames, 14.0); + assert_eq!(offset.height_frames, 7.0); + assert_eq!(board[3][0], 15.0 * (ratio * ratio)); + assert_eq!(board[3][1], 10.0); + assert_eq!(board, targets[0]); + offset.orientation_frames = 0.0; + offset.height_frames = 1.0; + board = IDENTITY; + offset.update(&mut board, &mut targets); + assert_eq!(offset.height_frames, 0.0); + assert_eq!(offset.transform[3][1], 0.0); + assert!(!offset.height_refreshed); +} diff --git a/crates/skate-core/src/physics/tests/skeleton_landing.rs b/crates/skate-core/src/physics/tests/skeleton_landing.rs new file mode 100644 index 00000000..1581e2f2 --- /dev/null +++ b/crates/skate-core/src/physics/tests/skeleton_landing.rs @@ -0,0 +1,40 @@ +use super::*; + +fn settings() -> LandingSettings { + let graph = PointGraph { x: [0.0, 0.3, 0.6, 1.0], y: [1.0;4] }; + LandingSettings { + manual_blend: graph, grind_blend: graph, coffin_height: graph, + minimum_height: 0.5, maximum_velocity: 8.0, manual_damping: 17.5, + manual_spring: 60.0, ground_minimum_compression_time: 0.05, + ground_damping: 6.0, ground_spring: 60.0, grind_animation_target_time: 0.15, + grind_damping: 10.0, grind_spring: 60.0, grind_target_delta: 0.01, + desired_com_height: 0.85, coffin_time: 0.04, coffin_maximum_velocity: 5.0, + coffin_blend_frames: 7.0, coffin_base_height: 0.2, + } +} +#[test] +fn ground_landing_uses_physical_height_velocity_and_preserves_release_order() { + let mut adjustment = LandingAdjustment::default(); + let mut offset = SkateboardOffset::default(); + let mut input = LandingInput { + filtered_state: 2, flags_2468: 0, flags_2472: 0, flags_2476: 0, + balance: 0.0, physical_com_velocity_along_up: -2.0, + physical_com_height: 0.8, animation_com_height: 1.0, + }; + adjustment.update(input, &settings(), &mut offset); + assert!(!adjustment.active); + input.filtered_state = 1; + input.flags_2476 = 0x1000_0000; + adjustment.update(input, &settings(), &mut offset); + assert!(adjustment.active); + assert_eq!(adjustment.kind, 0); + assert!((adjustment.velocity + 1.75).abs() < 1e-6); + assert!((offset.transform[3][1] - 0.205).abs() < 1e-6); + assert_eq!(offset.height_frames, 15.0); + assert!(offset.height_refreshed); + input.filtered_state = 2; + adjustment.update(input, &settings(), &mut offset); + assert!(!adjustment.active); + assert_eq!(adjustment.time, STEP + STEP); + assert_eq!(adjustment.previous_filtered_state, 2); +} \ No newline at end of file diff --git a/crates/skate-core/src/physics/tests/triangle_query.rs b/crates/skate-core/src/physics/tests/triangle_query.rs new file mode 100644 index 00000000..e32fded4 --- /dev/null +++ b/crates/skate-core/src/physics/tests/triangle_query.rs @@ -0,0 +1,114 @@ +use super::*; + +fn vertices() -> [Vector3; 3] { + [ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(1.0, 0.0, 0.0), + ] +} + +#[test] +fn wheel_segment_preserves_winding_fraction_and_barycentric_coordinates() { + let hit = thin_triangle( + Vector3::new(0.25, 0.1, 0.25), + Vector3::new(0.0, -0.2, 0.0), + vertices(), + ) + .unwrap(); + assert!((hit.fraction - 0.5).abs() < 1e-6); + assert!(hit.position.y.abs() < 1e-7); + assert!((hit.normal.y - 1.0).abs() < 1e-6); + assert_eq!(hit.volume_parameter, [0.25, 0.25, 0.0]); + assert!( + thin_triangle( + Vector3::new(0.25, -0.1, 0.25), + Vector3::new(0.0, 0.2, 0.0), + vertices() + ) + .is_none() + ); + assert!( + thin_triangle( + Vector3::new(0.75, 0.1, 0.75), + Vector3::new(0.0, -0.2, 0.0), + vertices() + ) + .is_none() + ); +} + +#[test] +fn native_tolerance_accepts_small_endpoint_overrun_without_clamping() { + let start = Vector3::new(0.25, 0.200001, 0.25); + let hit = thin_triangle(start, Vector3::new(0.0, -0.2, 0.0), vertices()).unwrap(); + assert!(hit.fraction > 1.0); + assert!( + thin_triangle( + Vector3::new(0.25, 0.201, 0.25), + Vector3::new(0.0, -0.2, 0.0), + vertices() + ) + .is_none() + ); + assert!(thin_triangle(start, Vector3::ZERO, vertices()).is_none()); +} + +fn sweep(start: Vector3, delta: Vector3, radius: f32, fatness: f32) -> Option { + let mut result = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + triangle_segment(&mut result, start, delta, vertices(), radius, fatness).then_some(result) +} + +#[test] +fn camera_sweep_resolves_face_edges_vertices_and_query_radius_offset() { + let delta = Vector3::new(0.0, -2.0, 0.0); + let face = sweep(Vector3::new(0.25, 1.0, 0.25), delta, 0.1, 0.0).unwrap(); + assert!((face.fraction - 0.45).abs() < 1e-6); + assert!(face.position.y.abs() < 1e-6); + let rounded_face = sweep(Vector3::new(0.25, 1.0, 0.25), delta, 0.1, 0.2).unwrap(); + assert!((rounded_face.fraction - 0.35).abs() < 1e-6); + assert!((rounded_face.position.y - 0.2).abs() < 1e-6); + let edge = sweep(Vector3::new(-0.06, 1.0, 0.5), delta, 0.1, 0.0).unwrap(); + assert!((edge.fraction - 0.46).abs() < 1e-5); + assert!((edge.normal.x + 0.6).abs() < 1e-4); + assert!((edge.normal.y - 0.8).abs() < 1e-4); + assert!(edge.position.x.abs() < 1e-5 && edge.position.y.abs() < 1e-5); + assert_eq!(edge.volume_parameter, [0.5, 0.0, 0.0]); + let corner = sweep(Vector3::new(-0.04, 1.0, -0.03), delta, 0.1, 0.0).unwrap(); + let expected = (1.0 - (0.01_f32 - 0.0025).sqrt()) * 0.5; + assert!((corner.fraction - expected).abs() < 1e-5); + assert!(dot(corner.position, corner.position) < 1e-9); + assert_eq!(corner.volume_parameter, [0.0; 3]); + assert!(sweep(Vector3::new(-0.2, 1.0, 0.5), delta, 0.1, 0.0).is_none()); +} + +#[test] +fn rounded_query_handles_reverse_face_overlap_and_parallel_motion() { + let reverse = sweep( + Vector3::new(0.25, -1.0, 0.25), + Vector3::new(0.0, 2.0, 0.0), + 0.1, + 0.0, + ) + .unwrap(); + assert!((reverse.fraction - 0.45).abs() < 1e-6); + assert!((reverse.normal.y + 1.0).abs() < 1e-6); + let overlap = sweep(Vector3::new(0.25, 0.05, 0.25), Vector3::ZERO, 0.1, 0.0).unwrap(); + assert_eq!(overlap.fraction, 0.0); + assert!((overlap.volume_parameter[2] - 0.0075).abs() < 1e-6); + let parallel = sweep( + Vector3::new(-1.0, 0.06, 0.5), + Vector3::new(2.0, 0.0, 0.0), + 0.1, + 0.0, + ) + .unwrap(); + assert!((parallel.fraction - 0.46).abs() < 1e-5); + assert!((parallel.normal.x + 0.8).abs() < 1e-4); + assert!((parallel.normal.y - 0.6).abs() < 1e-4); +} diff --git a/crates/skate-core/src/physics/tests/truck_frames.rs b/crates/skate-core/src/physics/tests/truck_frames.rs new file mode 100644 index 00000000..09030175 --- /dev/null +++ b/crates/skate-core/src/physics/tests/truck_frames.rs @@ -0,0 +1,16 @@ +use super::*; +use crate::physics::drive_frames::default_truck_transforms; + +#[test] +fn each_target_changes_only_its_native_drive_and_keeps_anchor() { + let base = default_truck_transforms(); + let rest = steering_drive_frames(base, [0.0; 2]); + let first = steering_drive_frames(base, [0.4, 0.0]); + assert_eq!(first[0], rest[0]); + assert_ne!(first[1].body_b.orientation, rest[1].body_b.orientation); + assert_eq!(first[1].body_b.translation, rest[1].body_b.translation); + let second = steering_drive_frames(base, [0.0, 0.4]); + assert_eq!(second[1], rest[1]); + assert_ne!(second[0].body_b.orientation, rest[0].body_b.orientation); + assert_eq!(second[0].body_b.translation, rest[0].body_b.translation); +} diff --git a/crates/skate-core/src/physics/tests/world_dispatch.rs b/crates/skate-core/src/physics/tests/world_dispatch.rs new file mode 100644 index 00000000..ba763320 --- /dev/null +++ b/crates/skate-core/src/physics/tests/world_dispatch.rs @@ -0,0 +1,351 @@ +use super::world_contact::{FeaturePrism, MaximumFeature, find_feature_intersection_prism}; + +const NORMAL: [u32; 4] = [0, 1.0f32.to_bits(), 0, 0]; +const SENTINEL: u32 = 0xdead_beef; + +fn vector(value: [f32; 4]) -> [u32; 4] { + value.map(f32::to_bits) +} +fn point(x: f32, y: f32, z: f32) -> MaximumFeature { + let mut feature = [0; 144]; + feature[0] = 100; + feature[136..140].copy_from_slice(&vector([x, y, z, 1.0])); + feature +} +fn segment(start: [f32; 3], end: [f32; 3]) -> MaximumFeature { + let mut feature = [0; 144]; + feature[0] = 100; + feature[140] = 1; + let delta: [f32; 3] = std::array::from_fn(|i| end[i] - start[i]); + let length = delta.iter().map(|v| v * v).sum::().sqrt(); + feature[4..8].copy_from_slice(&vector([start[0], start[1], start[2], 1.0])); + feature[8..12].copy_from_slice(&vector([ + delta[0] / length, + delta[1] / length, + delta[2] / length, + 0.0, + ])); + feature[16..20].fill(length.to_bits()); + feature +} +fn face(vertices: &[[f32; 2]], height: f32) -> MaximumFeature { + let mut feature = [0; 144]; + feature[0] = 100; + feature[140] = vertices.len() as u32; + feature[132..136].copy_from_slice(&NORMAL); + for (edge, &start) in vertices.iter().enumerate() { + let end = vertices[(edge + 1) % vertices.len()]; + let line = segment([start[0], height, start[1]], [end[0], height, end[1]]); + let offset = 4 + edge * 16; + feature[offset..offset + 16].copy_from_slice(&line[4..20]); + let dx = f32::from_bits(line[8]); + let dz = f32::from_bits(line[10]); + feature[offset + 8..offset + 12].copy_from_slice(&vector([dz, 0.0, -dx, 0.0])); + } + feature +} +fn square(x: f32, z: f32, height: f32) -> MaximumFeature { + face( + &[ + [x - 1.0, z - 1.0], + [x + 1.0, z - 1.0], + [x + 1.0, z + 1.0], + [x - 1.0, z + 1.0], + ], + height, + ) +} +fn query(a: &mut MaximumFeature, b: &mut MaximumFeature) -> FeaturePrism { + let mut result = [SENTINEL; 136]; + result[128..132].copy_from_slice(&NORMAL); + result[133] = 0; + assert_eq!( + find_feature_intersection_prism(&mut result, a, b, NORMAL), + 1 + ); + result +} +fn assert_point(output: &FeaturePrism, side: usize, index: usize, expected: [f32; 3]) { + for axis in 0..3 { + let actual = f32::from_bits(output[side * 64 + index * 4 + axis]); + assert!( + (actual - expected[axis]).abs() < 2.0e-5, + "side={side} point={index} axis={axis}: {actual} != {}", + expected[axis] + ); + } +} +fn assert_untouched_tail(output: &FeaturePrism) { + let count = output[132] as usize; + assert!(output[count * 4..64].iter().all(|&v| v == SENTINEL)); + assert!(output[64 + count * 4..128].iter().all(|&v| v == SENTINEL)); + assert_eq!(&output[134..136], &[SENTINEL; 2]); +} + +#[test] +fn point_point_copies_both_points_and_preserves_prism_state() { + let mut a = point(2.0, 3.0, 4.0); + let mut b = point(-3.0, 8.0, 6.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 1); + assert_eq!(&output[..4], &a[136..140]); + assert_eq!(&output[64..68], &b[136..140]); + assert_eq!(&output[128..132], &NORMAL); + assert_eq!(output[133], 0); + assert_eq!((a[0], b[0]), (100, 100)); + assert_untouched_tail(&output); +} + +#[test] +fn point_segment_selects_before_interior_and_after_tags_in_both_orders() { + for (x, projected, tag) in [ + (-1.0, 0.0, 101), + (0.0, 0.0, 100), + (1.0, 1.0, 100), + (2.0, 2.0, 100), + (3.0, 2.0, 99), + ] { + for reverse in [false, true] { + let mut line = segment([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]); + let mut p = point(x, 2.0, 0.0); + let output = if reverse { + query(&mut p, &mut line) + } else { + query(&mut line, &mut p) + }; + assert_eq!(output[132], 1); + assert_point(&output, usize::from(reverse), 0, [projected, 0.0, 0.0]); + assert_point(&output, usize::from(!reverse), 0, [x, 2.0, 0.0]); + assert_eq!(line[0], tag); + assert_eq!(p[0], 100); + assert_untouched_tail(&output); + } + } +} + +#[test] +fn point_face_projects_and_clamps_to_boundary_in_both_orders() { + for (z, projected, tag) in [(0.0, 0.0, 100), (-2.0, -1.0, 102)] { + for reverse in [false, true] { + let mut polygon = square(0.0, 0.0, 0.0); + let mut p = point(0.0, 2.0, z); + let output = if reverse { + query(&mut p, &mut polygon) + } else { + query(&mut polygon, &mut p) + }; + assert_eq!(output[132], 1); + assert_point(&output, usize::from(reverse), 0, [0.0, 0.0, projected]); + assert_point(&output, usize::from(!reverse), 0, [0.0, 2.0, z]); + assert_eq!(polygon[0], tag); + assert_untouched_tail(&output); + } + } +} + +#[test] +fn segment_segment_covers_crossing_overlap_and_disjoint_endpoints() { + let mut a = segment([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]); + let mut b = segment([0.0, 1.0, -1.0], [0.0, 1.0, 1.0]); + let crossing = query(&mut a, &mut b); + assert_eq!(crossing[132], 1); + assert_point(&crossing, 0, 0, [0.0, 0.0, 0.0]); + assert_point(&crossing, 1, 0, [0.0, 1.0, 0.0]); + let mut b = segment([0.0, 1.0, 0.0], [2.0, 1.0, 0.0]); + let overlap = query(&mut a, &mut b); + assert_eq!(overlap[132], 2); + for side in 0..2 { + assert_point(&overlap, side, 0, [0.0, side as f32, 0.0]); + assert_point(&overlap, side, 1, [1.0, side as f32, 0.0]); + } + let mut b = segment([3.0, 1.0, 0.0], [4.0, 1.0, 0.0]); + let disjoint = query(&mut a, &mut b); + assert_eq!(disjoint[132], 1); + assert_point(&disjoint, 0, 0, [1.0, 0.0, 0.0]); + assert_point(&disjoint, 1, 0, [3.0, 1.0, 0.0]); +} + +#[test] +fn segment_face_keeps_clipped_two_point_manifold_in_both_orders() { + for reverse in [false, true] { + let mut polygon = square(0.0, 0.0, 0.0); + let mut line = segment([-2.0, 1.0, 0.0], [2.0, 1.0, 0.0]); + // For this winding, -Y cross edge points into the clipping polygon. + let direction = vector([0.0, -1.0, 0.0, 0.0]); + let mut output = [SENTINEL; 136]; + output[128..132].copy_from_slice(&direction); + output[133] = 0; + let (a, b) = if reverse { + (&mut line, &mut polygon) + } else { + (&mut polygon, &mut line) + }; + assert_eq!(find_feature_intersection_prism(&mut output, a, b, direction), 1); + assert_eq!(output[132], 2); + for (index, x) in [-1.0, 1.0].into_iter().enumerate() { + assert_point(&output, usize::from(reverse), index, [x, 0.0, 0.0]); + assert_point(&output, usize::from(!reverse), index, [x, 1.0, 0.0]); + } + assert_untouched_tail(&output); + } +} + +#[test] +fn quad_triangle_emits_edge_pairs_then_inside_vertex_with_stable_swapped_order() { + let expected = [[1.0, -0.5], [1.0, 0.5], [0.5, 1.0], [0.0, 1.0], [0.0, -0.5]]; + for reverse in [false, true] { + let mut quad = square(0.0, 0.0, 0.0); + let mut triangle = face(&[[0.0, -0.5], [2.0, -0.5], [0.0, 1.5]], 1.0); + let output = if reverse { + query(&mut triangle, &mut quad) + } else { + query(&mut quad, &mut triangle) + }; + assert_eq!(output[132], 5); + for (index, [x, z]) in expected.into_iter().enumerate() { + assert_point(&output, usize::from(reverse), index, [x, 0.0, z]); + assert_point(&output, usize::from(!reverse), index, [x, 1.0, z]); + } + assert_eq!(output[133], 0); + assert_eq!((quad[0], triangle[0]), (100, 100)); + assert_untouched_tail(&output); + } +} + +#[test] +fn quad_quad_orders_crossings_before_both_containment_passes() { + let mut a = square(0.0, 0.0, 0.0); + let mut b = square(1.0, 0.5, 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 4); + for (index, [x, z]) in [[1.0, -0.5], [0.0, 1.0], [1.0, 1.0], [0.0, -0.5]] + .into_iter() + .enumerate() + { + assert_point(&output, 0, index, [x, 0.0, z]); + assert_point(&output, 1, index, [x, 1.0, z]); + } + assert_untouched_tail(&output); +} + +#[test] +fn coincident_quad_boundaries_keep_native_duplicate_edge_intersections() { + let mut a = square(0.0, 0.0, 0.0); + let mut b = square(0.0, 0.0, 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 8); + for index in 0..8 { + assert_eq!(f32::from_bits(output[index * 4]).abs(), 1.0); + assert_eq!(f32::from_bits(output[index * 4 + 2]).abs(), 1.0); + } + assert_untouched_tail(&output); +} + +#[test] +fn general_triangle_containment_returns_b_vertices_without_duplicates() { + let vertices = [[-1.0, -1.0], [1.0, -1.0], [0.0, 1.0]]; + let mut a = face(&vertices, 0.0); + let mut b = face(&vertices, 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 3); + for (index, [x, z]) in vertices.into_iter().enumerate() { + assert_point(&output, 0, index, [x, 0.0, z]); + assert_point(&output, 1, index, [x, 1.0, z]); + } + assert_untouched_tail(&output); +} + +#[test] +fn general_triangle_crossings_form_the_six_corner_intersection() { + let mut a = face(&[[-2.0, -1.0], [2.0, -1.0], [0.0, 2.0]], 0.0); + let mut b = face(&[[-2.0, 1.0], [0.0, -2.0], [2.0, 1.0]], 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 6); + let expected = [ + [-4.0 / 3.0, 0.0], + [-2.0 / 3.0, -1.0], + [2.0 / 3.0, -1.0], + [4.0 / 3.0, 0.0], + [2.0 / 3.0, 1.0], + [-2.0 / 3.0, 1.0], + ]; + for [x, z] in expected { + assert!((0..6).any(|index| { + (f32::from_bits(output[index * 4]) - x).abs() < 2.0e-5 + && (f32::from_bits(output[index * 4 + 2]) - z).abs() < 2.0e-5 + })); + } + for index in 0..6 { + let x = f32::from_bits(output[index * 4]); + let z = f32::from_bits(output[index * 4 + 2]); + assert_point(&output, 1, index, [x, 1.0, z]); + } + assert_untouched_tail(&output); +} + +#[test] +fn disjoint_faces_fall_back_to_closest_features_and_correct_normal() { + let mut a = face(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], 0.0); + let mut b = face(&[[3.0, 3.0], [4.0, 3.0], [3.0, 4.0]], 0.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 1); + assert_point(&output, 0, 0, [0.5, 0.0, 0.5]); + assert_point(&output, 1, 0, [3.0, 0.0, 3.0]); + assert_eq!((a[0], b[0]), (4, 1)); + assert_eq!(output[133], 1); + assert!((f32::from_bits(output[128]) - std::f32::consts::FRAC_1_SQRT_2).abs() < 2.0e-5); + assert!((f32::from_bits(output[130]) - std::f32::consts::FRAC_1_SQRT_2).abs() < 2.0e-5); + assert_untouched_tail(&output); +} + +#[test] +fn specialized_quad_rejection_reaches_parallel_edge_fallback() { + let mut a = square(0.0, 0.0, 0.0); + let mut b = square(3.0, 0.0, 0.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 2); + for (index, z) in [-1.0, 1.0].into_iter().enumerate() { + assert_point(&output, 0, index, [1.0, 0.0, z]); + assert_point(&output, 1, index, [2.0, 0.0, z]); + } + assert_eq!((a[0], b[0]), (4, 8)); + assert_eq!(output[133], 1); + assert_eq!(&output[128..132], &vector([1.0, 0.0, 0.0, 0.0])); + assert_untouched_tail(&output); +} + +#[test] +fn specialized_containment_handles_each_face_as_the_enclosed_feature() { + for triangle_is_enclosed in [false, true] { + for reverse in [false, true] { + let (mut quad, mut triangle, expected) = if triangle_is_enclosed { + ( + square(0.0, 0.0, 0.0), + face(&[[-0.5, -0.5], [0.5, -0.5], [0.0, 0.5]], 1.0), + vec![[-0.5, -0.5], [0.5, -0.5], [0.0, 0.5]], + ) + } else { + ( + square(0.0, 0.0, 0.0), + face(&[[-5.0, -3.0], [5.0, -3.0], [0.0, 5.0]], 1.0), + vec![[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]], + ) + }; + let output = if reverse { + query(&mut triangle, &mut quad) + } else { + query(&mut quad, &mut triangle) + }; + assert_eq!(output[132] as usize, expected.len()); + for (index, [x, z]) in expected.into_iter().enumerate() { + assert_point(&output, usize::from(reverse), index, [x, 0.0, z]); + assert_point(&output, usize::from(!reverse), index, [x, 1.0, z]); + assert_eq!(output[index * 4 + 3], 1.0f32.to_bits()); + assert_eq!(output[64 + index * 4 + 3], 1.0f32.to_bits()); + } + assert_eq!(output[133], 0); + assert_untouched_tail(&output); + } + } +} + diff --git a/crates/skate-core/src/physics/triangle_closest.rs b/crates/skate-core/src/physics/triangle_closest.rs new file mode 100644 index 00000000..2afcdeb9 --- /dev/null +++ b/crates/skate-core/src/physics/triangle_closest.rs @@ -0,0 +1,92 @@ +//! TU3 triangle closest-feature calculation82ADE8B8. +use super::triangle_query::{dot, madd, sub}; +use crate::math::Vector3; + +pub(super) struct ClosestTrianglePoint { + pub point: Vector3, + /// Vertex0..2, edge AB/AC/BC3..5, face6, as used by82ADEB70. + pub region: u32, + pub u: f32, + pub v: f32, +} + +pub(super) fn closest(point: Vector3, vertices: [Vector3; 3]) -> ClosestTrianglePoint { + let [origin, b, c] = vertices; + let edge_b = sub(b, origin); + let edge_c = sub(c, origin); + let toward_origin = sub(origin, point); + let bb = dot(edge_b, edge_b); + let cc = dot(edge_c, edge_c); + let bc = dot(edge_b, edge_c); + let bp = dot(edge_b, toward_origin); + let cp = dot(edge_c, toward_origin); + let determinant = cc.mul_add(bb, -(bc * bc)); + let u_numerator = cp.mul_add(bc, -(bp * cc)); + let v_numerator = bp.mul_add(bc, -(cp * bb)); + let opposite_length_squared = (-bc).mul_add(2.0, bb) + cc; + let feature = if determinant < f32::from_bits(0x0020_0000) { + if bb > cc { + if bb > opposite_length_squared { 0 } else { 2 } + } else if cc > opposite_length_squared { + 1 + } else { + 2 + } + } else if u_numerator + v_numerator > determinant { + if u_numerator < 0.0 { + if cp + cc < bp + bc { 1 } else { 2 } + } else if v_numerator < 0.0 { + if bp + bb < cp + bc { 0 } else { 2 } + } else { + 2 + } + } else if u_numerator < 0.0 { + if -cp > 0.0 { 1 } else { 0 } + } else if v_numerator < 0.0 { + if -bp > 0.0 { 0 } else { 1 } + } else { + 3 + }; + + let (u, v, region) = match feature { + 0 => { + if !(bp < 0.0) { + (0.0, 0.0, 0) + } else if !(-bp < bb) { + (1.0, 0.0, 1) + } else { + (-(bp / bb), 0.0, 3) + } + } + 1 => { + if !(cp < 0.0) { + (0.0, 0.0, 0) + } else if !(-cp < cc) { + (0.0, 1.0, 2) + } else { + (0.0, -(cp / cc), 4) + } + } + 2 => { + let numerator = ((cp + cc) - bc) - bp; + if !(numerator > 0.0) { + (0.0, 1.0, 2) + } else if !(numerator < opposite_length_squared) { + (1.0, 0.0, 1) + } else { + let u = numerator / opposite_length_squared; + (u, 1.0 - u, 5) + } + } + _ => { + let inverse = 1.0 / determinant; + (inverse * u_numerator, inverse * v_numerator, 6) + } + }; + ClosestTrianglePoint { + point: madd(edge_c, v, madd(edge_b, u, origin)), + region, + u, + v, + } +} diff --git a/crates/skate-core/src/physics/triangle_query.rs b/crates/skate-core/src/physics/triangle_query.rs new file mode 100644 index 00000000..e8b38eaf --- /dev/null +++ b/crates/skate-core/src/physics/triangle_query.rs @@ -0,0 +1,123 @@ +//! Native triangle segment queries used by wheel probes and camera sweeps. +//! Thin test82AC7858 and dispatcher82ADF5D8, TU3. The world owns candidate +//! storage; these routines retain the source winding, tolerances and arithmetic. +use super::native_arithmetic; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TriangleLineHit { + pub position: Vector3, + pub normal: Vector3, + pub fraction: f32, + /// Native result48.xy: weights for vertices1 and2; z is penetration data. + pub volume_parameter: [f32; 3], +} + +/// TU3 dispatcher82ADF5D8. The rounded query uses the sum of the two radii, +/// then shifts its center hit back by the query radius. As in the native output +/// structure, fields not written by a branch retain the caller's values. +pub fn triangle_segment( + result: &mut TriangleLineHit, + start: Vector3, + direction: Vector3, + vertices: [Vector3; 3], + line_radius: f32, + triangle_fatness: f32, +) -> bool { + let radius = line_radius + triangle_fatness; + if radius == 0.0 { + if let Some(hit) = thin_triangle(start, direction, vertices) { + *result = hit; + return true; + } + return false; + } + let normal = cross(sub(vertices[0], vertices[1]), sub(vertices[0], vertices[2])); + result.normal = scale(normal, inverse_length(dot(normal, normal))); + if super::triangle_sweep::sweep(result, start, direction, vertices, radius) { + result.position = sub(result.position, scale(result.normal, line_radius)); + true + } else { + false + } +} + +/// Complete zero-total-radius branch. `direction` is end minus start, not a +/// normalized ray. TU3 accepts a small margin beyond both segment endpoints; +/// do not clamp its returned fraction or replace the determinant tolerance. +pub fn thin_triangle( + start: Vector3, + direction: Vector3, + vertices: [Vector3; 3], +) -> Option { + let [a, b, c] = vertices; + let ac = sub(c, a); + let ab = sub(b, a); + let perpendicular = cross(direction, ac); + let determinant = dot(ab, perpendicular); + if !(determinant > f32::from_bits(0x322b_cc77)) { + return None; + } + let from_a = sub(start, a); + let lower = determinant * f32::from_bits(0xb727_c5ac); + let upper = determinant - lower; + let u = dot(from_a, perpendicular); + if u < lower || u > upper { + return None; + } + let perpendicular = cross(from_a, ab); + let v = dot(direction, perpendicular); + if v < lower || v + u > upper { + return None; + } + let distance = dot(ac, perpendicular); + if distance < lower || distance > upper { + return None; + } + let inverse = 1.0 / determinant; + let fraction = distance * inverse; + // Dispatcher82ADF61C..688 recomputes and normalizes this winding with + // two refinements; it does not use the Volume's cached contact normal. + let normal = cross(sub(a, b), sub(a, c)); + Some(TriangleLineHit { + position: madd(direction, fraction, start), + normal: scale(normal, inverse_length(dot(normal, normal))), + fraction, + volume_parameter: [u * inverse, v * inverse, 0.0], + }) +} + +pub(super) fn dot(a: Vector3, b: Vector3) -> f32 { + native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} +pub(super) fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn scale(v: Vector3, scale: f32) -> Vector3 { + Vector3::new(v.x * scale, v.y * scale, v.z * scale) +} +pub(super) fn madd(a: Vector3, scale: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(scale, b.x), + a.y.mul_add(scale, b.y), + a.z.mul_add(scale, b.z), + ) +} +pub(super) fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +pub(super) fn inverse_length(squared: f32) -> f32 { + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + inverse +} + +#[cfg(test)] +#[path = "tests/triangle_query.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/triangle_sweep.rs b/crates/skate-core/src/physics/triangle_sweep.rs new file mode 100644 index 00000000..2c7f4f6d --- /dev/null +++ b/crates/skate-core/src/physics/triangle_sweep.rs @@ -0,0 +1,104 @@ +//! Rounded triangle query82ADEB70: bounds, face, overlap, then feature walk. +use super::{ + native_arithmetic, + triangle_closest::closest, + triangle_query::{TriangleLineHit, cross, dot, madd, scale, sub}, + triangle_sweep_walk, +}; +use crate::math::Vector3; + +/// Literal8212BF74, also used by closest-feature and walk denominator gates. +pub(super) const MIN_RECIPROCAL: f32 = f32::from_bits(0x0020_0000); + +pub(super) fn sweep( + result: &mut TriangleLineHit, + start: Vector3, + mut delta: Vector3, + vertices: [Vector3; 3], + radius: f32, +) -> bool { + result.fraction = 0.0; + let [a, b, c] = vertices; + let ab = sub(b, a); + let ac = sub(c, a); + let mut from_a = sub(start, a); + let cross_ac = cross(delta, ac); + let mut determinant = dot(ab, cross_ac); + let mut side = 1.0; + if determinant < 0.0 { + side = -1.0; + determinant = -determinant; + } + if dot(result.normal, from_a) * side < -radius { + return false; + } + let u = dot(from_a, cross_ac) * side; + // This TU3 body uses the L1 sum, not the SDK's vector magnitude bound. + let u_margin = l1(cross_ac) * radius; + if u < -u_margin || u > u_margin + determinant { + return false; + } + let cross_ab = cross(ab, delta); + let v = dot(from_a, cross_ab) * side; + let v_margin = l1(cross_ab) * radius; + if v < -v_margin || v > v_margin + determinant || v + u > (u_margin + v_margin) + determinant { + return false; + } + if determinant > MIN_RECIPROCAL { + let offset = sub(from_a, scale(result.normal, side * radius)); + let distance = -(dot(offset, cross(ac, ab)) * side); + if distance > determinant { + return false; + } + if !(distance < 0.0) { + let inverse = 1.0 / determinant; + result.fraction = inverse * distance; + let u = dot(offset, cross_ac) * side; + if !(u < 0.0) && !(u > determinant) { + let v = dot(offset, cross_ab) * side; + if !(v < 0.0) && !(v + u > determinant) { + result.position = madd(delta, result.fraction, start); + result.normal = scale(result.normal, side); + result.volume_parameter = [inverse * u, inverse * v, 0.0]; + return true; + } + } + from_a = madd(delta, result.fraction, from_a); + delta = scale(delta, 1.0 - result.fraction); + } + } + let start = add(from_a, a); + let feature = closest(start, vertices); + let separation = sub(start, feature.point); + let squared = dot(separation, separation); + let penetration = radius.mul_add(radius, -squared); + if penetration > 0.0 { + result.position = start; + if squared > 0.0 { + let inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + let inverse = + (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + result.normal = scale(separation, inverse); + } + result.volume_parameter = [feature.u, feature.v, penetration]; + return true; + } + if feature.region == 6 { + result.position = start; + //82ADEFA0 compares the absolute determinant retained in f1; it has + // already been negated by82ADEC30 for a reverse-facing query. + if determinant < 0.0 { + result.normal = scale(result.normal, -1.0); + } + result.volume_parameter = [feature.u, feature.v, 0.0]; + return true; + } + triangle_sweep_walk::walk(result, start, delta, vertices, feature, radius) +} + +pub(super) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +fn l1(v: Vector3) -> f32 { + (v.y.abs() + v.z.abs()) + v.x.abs() +} diff --git a/crates/skate-core/src/physics/triangle_sweep_roots.rs b/crates/skate-core/src/physics/triangle_sweep_roots.rs new file mode 100644 index 00000000..edef44bc --- /dev/null +++ b/crates/skate-core/src/physics/triangle_sweep_roots.rs @@ -0,0 +1,89 @@ +//! Vertex/edge roots in TU3's triangle feature walk:82ADDB48/82ADA3E0. +use super::triangle_query::{cross, dot, sub}; +use crate::math::Vector3; + +#[derive(Clone, Copy)] +pub(super) struct Fraction { + pub numerator: f32, + pub denominator: f32, +} +impl Fraction { + pub fn value(self) -> f32 { + self.numerator / self.denominator + } + pub fn before(self, other: Self) -> bool { + self.numerator * other.denominator < other.numerator * self.denominator + } +} +pub(super) enum Root { + Away, + Miss, + Hit(Fraction), +} + +pub(super) fn sphere(start: Vector3, delta: Vector3, center: Vector3, radius: f32) -> Root { + let toward_center = sub(center, start); + let radius_squared = radius * radius; + if dot(toward_center, toward_center) < radius_squared { + return Root::Hit(Fraction { + numerator: 0.0, + denominator: 1.0, + }); + } + let approach = dot(toward_center, delta); + if !(approach > 0.0) { + return Root::Away; + } + let delta_squared = dot(delta, delta); + let perpendicular = cross(toward_center, delta); + let discriminant = delta_squared.mul_add(radius_squared, -dot(perpendicular, perpendicular)); + if discriminant < 0.0 { + return Root::Miss; + } + let overrun = approach - delta_squared; + if overrun > 0.0 && overrun * overrun > discriminant { + return Root::Miss; + } + Root::Hit(Fraction { + numerator: approach - discriminant.sqrt(), + denominator: delta_squared, + }) +} + +pub(super) fn cylinder( + start: Vector3, + delta: Vector3, + origin: Vector3, + edge: Vector3, + radius: f32, +) -> Root { + let perpendicular = cross(sub(origin, start), edge); + let distance_squared = dot(perpendicular, perpendicular); + let radius_squared = (dot(edge, edge) * radius) * radius; + if distance_squared < radius_squared { + return Root::Hit(Fraction { + numerator: 0.0, + denominator: 1.0, + }); + } + let delta_perpendicular = cross(delta, edge); + let approach = dot(perpendicular, delta_perpendicular); + if !(approach > 0.0) { + return Root::Away; + } + let delta_squared = dot(delta_perpendicular, delta_perpendicular); + let residual = approach.mul_add(approach, -(delta_squared * distance_squared)); + let discriminant = delta_squared.mul_add(radius_squared, residual); + if discriminant < 0.0 { + return Root::Miss; + } + let overrun = approach - delta_squared; + // Cylinder rejects equality here; the sphere helper uses strict greater. + if !(overrun < 0.0) && !(overrun * overrun < discriminant) { + return Root::Miss; + } + Root::Hit(Fraction { + numerator: approach - discriminant.sqrt(), + denominator: delta_squared, + }) +} diff --git a/crates/skate-core/src/physics/triangle_sweep_walk.rs b/crates/skate-core/src/physics/triangle_sweep_walk.rs new file mode 100644 index 00000000..63e4caaf --- /dev/null +++ b/crates/skate-core/src/physics/triangle_sweep_walk.rs @@ -0,0 +1,130 @@ +//! Five-step vertex/edge region walk82ADF008..82ADF5BC. +use super::{ + native_arithmetic, + triangle_closest::ClosestTrianglePoint, + triangle_query::{TriangleLineHit, dot, madd, scale, sub}, + triangle_sweep::{MIN_RECIPROCAL, add}, + triangle_sweep_roots::{self as roots, Fraction, Root}, +}; +use crate::math::Vector3; + +pub(super) fn walk( + result: &mut TriangleLineHit, + mut start: Vector3, + mut delta: Vector3, + vertices: [Vector3; 3], + closest: ClosestTrianglePoint, + radius: f32, +) -> bool { + let mut region = closest.region; + let mut vertex = closest.point; + for _ in 0..5 { + let fraction; + if region <= 2 { + let mut hit = match roots::sphere(start, delta, vertex, radius) { + Root::Away => return false, + Root::Miss => None, + Root::Hit(fraction) => Some(fraction), + }; + let adjacent = match region { + 0 => [sub(vertices[1], vertices[0]), sub(vertices[2], vertices[0])], + 1 => [sub(vertices[0], vertices[1]), sub(vertices[2], vertices[1])], + _ => [sub(vertices[0], vertices[2]), sub(vertices[1], vertices[2])], + }; + let mut next_region = region; + for (index, edge) in adjacent.into_iter().enumerate() { + let candidate = Fraction { + numerator: dot(sub(vertex, start), edge), + denominator: dot(delta, edge), + }; + if replaces(hit, candidate) { + hit = Some(candidate); + next_region = (region + if index == 0 { 6 } else { 9 }) / 2; + } + } + let Some(hit) = hit else { return false }; + fraction = hit.value(); + if next_region <= 2 { + advance_fraction(result, fraction); + result.position = madd(delta, fraction, start); + result.normal = scale(sub(result.position, vertex), inverse_radius(radius)); + result.volume_parameter = [ + if region == 1 { 1.0 } else { 0.0 }, + if region == 2 { 1.0 } else { 0.0 }, + 0.0, + ]; + return true; + } + region = next_region; + } else { + let (origin, edge) = match region { + 3 => (vertices[0], sub(vertices[1], vertices[0])), + 4 => (vertices[0], sub(vertices[2], vertices[0])), + _ => (vertices[1], sub(vertices[2], vertices[1])), + }; + vertex = origin; + let mut hit = match roots::cylinder(start, delta, origin, edge, radius) { + Root::Away => return false, + Root::Miss => None, + Root::Hit(fraction) => Some(fraction), + }; + let end = add(origin, edge); + let forward = Fraction { + numerator: dot(sub(end, start), edge), + denominator: dot(delta, edge), + }; + let backward = Fraction { + numerator: dot(sub(start, origin), edge), + denominator: -forward.denominator, + }; + if replaces(hit, forward) { + hit = Some(forward); + vertex = end; + region /= 2; + } else if replaces(hit, backward) { + hit = Some(backward); + region = (region - 3) / 2; + } + let Some(hit) = hit else { return false }; + fraction = hit.value(); + if region > 2 { + advance_fraction(result, fraction); + result.position = madd(delta, fraction, start); + let along_edge = dot(sub(result.position, origin), edge) * (1.0 / dot(edge, edge)); + let nearest = madd(edge, along_edge, origin); + result.normal = scale(sub(result.position, nearest), inverse_radius(radius)); + result.volume_parameter = match region { + 3 => [along_edge, 0.0, 0.0], + 4 => [0.0, along_edge, 0.0], + _ => [1.0 - along_edge, along_edge, 0.0], + }; + return true; + } + } + advance_fraction(result, fraction); + start = madd(delta, fraction, start); + delta = scale(delta, 1.0 - fraction); + if fraction > 1.0 { + return false; + } + } + // The source returns success after its fifth transition. Position/normal/ + // parameters have not been written by the walk on this path; preserve them. + true +} + +fn replaces(hit: Option, candidate: Fraction) -> bool { + candidate.denominator > MIN_RECIPROCAL + && candidate.numerator > 0.0 + && hit.is_none_or(|hit| !hit.before(candidate)) +} +fn advance_fraction(result: &mut TriangleLineHit, fraction: f32) { + result.fraction = (1.0 - result.fraction).mul_add(fraction, result.fraction); +} +fn inverse_radius(radius: f32) -> f32 { + let mut inverse = native_arithmetic::reciprocal_estimate(radius); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(radius, 1.0), inverse); + } + inverse +} diff --git a/crates/skate-core/src/physics/truck_frames.rs b/crates/skate-core/src/physics/truck_frames.rs new file mode 100644 index 00000000..b140a9b3 --- /dev/null +++ b/crates/skate-core/src/physics/truck_frames.rs @@ -0,0 +1,46 @@ +//! Dynamic truck frames, TU3 `SkateboardBody::SetTruckDriveFrames` (0x82C0B9C0). +use super::drive_frames::{RetailAffineTransform, RetailDriveFrames, set_drive_frames_2}; +use crate::{math::Basis3, trigonometry}; + +/// `base` is CalculateTruckTransforms output in native +7712/+7776 order. +/// Targets are +7680/+7684. Frame output follows +6768/+6832 drive order. +pub fn steering_drive_frames( + base: [RetailAffineTransform; 2], + targets: [f32; 2], +) -> [RetailDriveFrames; 2] { + let [front, back] = steering_truck_transforms(base, targets); + let transformed = [back, front]; + transformed.map(|target| set_drive_frames_2(RetailAffineTransform::IDENTITY, target)) +} + +///82C0B9C0 caches front7840/back7904 for both physical drives and the +///physical pose worker82DB6698. Preserve this separately from the base frames. +pub fn steering_truck_transforms( + base: [RetailAffineTransform; 2], + targets: [f32; 2], +) -> [RetailAffineTransform; 2] { + [ + rotate_truck(base[0], -targets[0]), + rotate_truck(base[1], targets[1]), + ] +} + +fn rotate_truck(base: RetailAffineTransform, angle: f32) -> RetailAffineTransform { + let (sin, cos) = trigonometry::sin_cos(angle); + let rotation = [[1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]]; + let columns = rotation.map(|column| { + core::array::from_fn(|lane| { + let first = column[0] * base.basis.columns[0][lane]; + let second = column[1].mul_add(base.basis.columns[1][lane], first); + column[2].mul_add(base.basis.columns[2][lane], second) + }) + }); + RetailAffineTransform { + basis: Basis3 { columns }, + translation: base.translation, + } +} + +#[cfg(test)] +#[path = "tests/truck_frames.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/world_contact/arithmetic.rs b/crates/skate-core/src/physics/world_contact/arithmetic.rs new file mode 100644 index 00000000..5c336335 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/arithmetic.rs @@ -0,0 +1,25 @@ +pub(super) type V = [f32; 3]; + +pub(super) fn vector(record: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(record[offset + i])) +} + +pub(super) fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} + +pub(super) fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3([a[0], a[1], a[2], 0.0], [b[0], b[1], b[2], 0.0]) +} + +pub(super) fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + ] +} + +pub(super) fn scale(a: V, b: f32) -> V { + a.map(|v| v * b) +} diff --git a/crates/skate-core/src/physics/world_contact/axes.rs b/crates/skate-core/src/physics/world_contact/axes.rs new file mode 100644 index 00000000..468be3f2 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/axes.rs @@ -0,0 +1,120 @@ +use crate::physics::reciprocal_sqrt::estimate; + +type V = [f32; 4]; + +/// Complete TU3 82ACEA30 candidate producer. GP records are the native 192-byte +/// representation: origin +0, normals +16/+32/+48, edges +64 onward, counts +/// bytes +140/+141. All four output lanes and speculative rejected stores are +/// preserved. Returns zero when the native function returns null. +pub fn separating_axis_candidates( + a: &[u32; 48], + b: &[u32; 48], + output: &mut [[u32; 4]; 16], +) -> usize { + let mut count = 0; + for gp in [a, b] { + let normals = gp[35] >> 24; + if (1..=3).contains(&normals) { + for index in (1..=normals as usize).rev() { + output[count].copy_from_slice(&gp[index * 4..index * 4 + 4]); + count += 1; + } + } + } + let ae = ((a[35] >> 16) & 255) as usize; + let be = ((b[35] >> 16) & 255) as usize; + assert!(ae <= 3 && be <= 3, "native GP contains at most three edges"); + for i in 0..ae { + for j in 0..be { + let candidate = cross(load(a, 16 + 4 * i), load(b, 16 + 4 * j)); + let length_squared = dot(candidate); + output[count] = candidate.map(f32::to_bits); + if length_squared > f32::from_bits(0x3a83_126f) { + output[count] = normalized(candidate, length_squared).map(f32::to_bits); + count += 1; + } + } + } + if ae == 1 && be == 1 { + let edge_a = load(a, 16); + let edge_b = load(b, 16); + let perpendicular = cross(edge_a, edge_b); + if length(perpendicular) > f32::from_bits(0x3400_0000) { + // Unlike the edge-pair loop, these two secondary directions are + // unconditionally normalized once the common perpendicular passes. + for edge in [edge_a, edge_b] { + let candidate = cross(edge, perpendicular); + output[count] = normalized(candidate, dot(candidate)).map(f32::to_bits); + count += 1; + } + } + } + if count != 0 { + return count; + } + let delta = sub(load(a, 0), load(b, 0)); + if ae == 1 && be == 1 { + let edge = load(a, 16); + append(cross(cross(delta, edge), edge), output, &mut count); + } + if ae + be == 1 { + let edge = if ae != 0 { load(a, 16) } else { load(b, 16) }; + append(cross(cross(delta, edge), edge), output, &mut count); + } + if count != 0 { + return count; + } + append(delta, output, &mut count); + count +} + +fn append(candidate: V, output: &mut [[u32; 4]; 16], count: &mut usize) { + output[*count] = candidate.map(f32::to_bits); + if length(candidate) > f32::from_bits(0x3400_0000) { + output[*count] = normalized(candidate, dot(candidate)).map(f32::to_bits); + *count += 1; + } +} + +fn load(gp: &[u32; 48], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(gp[offset + i])) +} + +fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} + +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} + +fn dot(a: V) -> f32 { + super::arithmetic::dot([a[0], a[1], a[2]], [a[0], a[1], a[2]]) +} + +fn inverse_length(squared: f32) -> f32 { + let mut r = estimate(squared); + for _ in 0..2 { + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + } + r +} + +fn normalized(a: V, squared: f32) -> V { + let reciprocal = inverse_length(squared); + a.map(|v| v * reciprocal) +} + +fn length(a: V) -> f32 { + let squared = dot(a); + if squared == 0.0 { + 0.0 + } else { + squared * inverse_length(squared) + } +} diff --git a/crates/skate-core/src/physics/world_contact/box_box_sat.rs b/crates/skate-core/src/physics/world_contact/box_box_sat.rs new file mode 100644 index 00000000..fbd76e0b --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/box_box_sat.rs @@ -0,0 +1,51 @@ +use crate::physics::{reciprocal_sqrt::estimate, world_contact::prism_math::*}; + +/// TU3 82ACF448. Unlike the triangle/box entry, edge crosses use the raw +/// reciprocal-square-root estimate without refinement. The last cross is +/// repeated to fill its final four-candidate batch; equal scores retain the +/// earlier axis. Shape fatness is excluded from these intervals. +pub(in super::super) fn box_box(a: &[u32; 48], b: &[u32; 48]) -> ([u32; 4], [u32; 4]) { + let mut axes = [[0.0; 4]; 16]; + for i in 0..3 { + axes[i] = load(a, 12 - i * 4); + axes[3 + i] = load(b, 12 - i * 4); + } + for edge_a in 0..3 { + for edge_b in 0..3 { + let axis = cross(load(a, 16 + edge_a * 4), load(b, 16 + edge_b * 4)); + axes[6 + edge_a * 3 + edge_b] = scale(axis, estimate(dot(axis, axis))); + } + } + axes[15] = axes[14]; + let scaled_a: [V; 3] = + std::array::from_fn(|i| scale(load(a, 4 + i * 4), f32::from_bits(a[28 + i]))); + let scaled_b: [V; 3] = + std::array::from_fn(|i| scale(load(b, 4 + i * 4), f32::from_bits(b[28 + i]))); + let mut best = 0.0; + let mut selected = axes[0]; + let mut selected_sign = 1.0; + for (index, axis) in axes.into_iter().enumerate() { + let a_center = dot(load(a, 0), axis); + let b_center = dot(load(b, 0), axis); + let a_extents = scaled_a.map(|extent| dot(extent, axis).abs()); + let b_extents = scaled_b.map(|extent| dot(extent, axis).abs()); + let a_radius = (a_extents[0] + a_extents[1]) + a_extents[2]; + let b_radius = (b_extents[0] + b_extents[1]) + b_extents[2]; + let forward = (a_center - a_radius) - (b_center + b_radius); + let reverse = (b_center - b_radius) - (a_center + a_radius); + let (separation, sign) = if forward > reverse { + (forward, -1.0) + } else { + (reverse, 1.0) + }; + if index == 0 || separation > best { + best = separation; + selected = axis; + selected_sign = sign; + } + } + ( + [best.to_bits(); 4], + scale(selected, selected_sign).map(f32::to_bits), + ) +} diff --git a/crates/skate-core/src/physics/world_contact/box_feature.rs b/crates/skate-core/src/physics/world_contact/box_feature.rs new file mode 100644 index 00000000..b7acf359 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/box_feature.rs @@ -0,0 +1,195 @@ +use super::{MaximumFeature, initialize_feature_segment}; +use crate::physics::reciprocal_sqrt::estimate; + +type V = [f32; 4]; +const OTHER: [[usize; 2]; 3] = [[1, 2], [0, 2], [0, 1]]; // 83044248 +const SIGNS: [[f32; 2]; 4] = [[1.0, 1.0], [1.0, -1.0], [-1.0, -1.0], [-1.0, 1.0]]; //83044260 + +/// Complete native box maximum-feature callback82AD7A98. Face winding uses the +/// full integer mode. `incoming_edge_plane` is VMX v60 at entry, copied only in +/// the edge branch without initialization by the native function. +pub fn box_maximum_feature( + gp: &[u32; 48], + mode: u32, + direction: [u32; 4], + output: &mut MaximumFeature, + incoming_edge_plane: [u32; 4], +) { + let center = load(gp, 0); + let axes: [V; 3] = std::array::from_fn(|i| load(gp, 4 + i * 4)); + let half: [f32; 3] = std::array::from_fn(|i| f32::from_bits(gp[28 + i])); + let direction = direction.map(f32::from_bits); + let mut small = 0; + let mut small_axis = 0; + let mut large_axis = 0; + for i in 0..3 { + if dot(direction, axes[i]).abs() < f32::from_bits(0x3e4c_cccd) { + small += 1; + small_axis = i; + } else { + large_axis = i; + } + } + if small == 2 { + face(center, axes, half, large_axis, mode, direction, output); + } else if small == 1 { + let [u, v] = OTHER[small_axis]; + let mut best = [0.0; 4]; + let mut score = 0.0; + for (i, sign) in SIGNS.into_iter().enumerate() { + let point = madd( + axes[v], + half[v] * sign[1], + madd(axes[u], half[u] * sign[0], center), + ); + let projected = dot(point, direction); + if i == 0 || projected > score { + best = point; + score = projected; + } + } + let negative = sub(best, scale(axes[small_axis], half[small_axis])); + let positive = madd(axes[small_axis], half[small_axis], best); + let mut segment = [0; 16]; + segment[8..12].copy_from_slice(&incoming_edge_plane); + initialize_feature_segment( + &mut segment, + negative.map(f32::to_bits), + positive.map(f32::to_bits), + ); + output[4..20].copy_from_slice(&segment); + output[140] = 1; + } else { + let mut best = [0.0; 4]; + let mut score = 0.0; + for i in 0..8 { + let sign = if i > 3 { 1.0 } else { -1.0 }; + let pair = SIGNS[i % 4]; + let point = madd( + axes[2], + half[2] * pair[1], + madd( + axes[1], + half[1] * pair[0], + madd(axes[0], half[0] * sign, center), + ), + ); + let projected = dot(point, direction); + if i == 0 || projected > score { + best = point; + score = projected; + } + } + store(output, 136, best); + output[140] = 0; + } + output[0] = 0; +} + +fn face( + center: V, + axes: [V; 3], + half: [f32; 3], + fixed: usize, + mode: u32, + direction: V, + output: &mut MaximumFeature, +) { + let sign = if dot(axes[fixed], direction) > 0.0 { + 1.0 + } else { + -1.0 + }; + let face_center = madd(axes[fixed], half[fixed] * sign, center); + let winding_sign = if fixed == 1 { -sign } else { sign }; + let [u, v] = OTHER[fixed]; + let positive_u = scale(axes[u], half[u]); + let negative_u = scale(axes[u], -half[u]); + let positive_v = scale(axes[v], half[v]); + let negative_v = scale(axes[v], -half[v]); + let p = plane(axes[v], direction, mode); + let q = plane(axes[u], direction, mode); + let len_u = half[u] * 2.0; + let len_v = half[v] * 2.0; + let points = [ + add(add(face_center, positive_u), positive_v), + add(add(face_center, positive_u), negative_v), + add(add(face_center, negative_u), negative_v), + add(add(face_center, negative_u), positive_v), + ]; + let negative_axis_u = axes[u].map(|v| -v); + let negative_axis_v = axes[v].map(|v| -v); + let (indices, directions, planes, lengths) = if u32::from(winding_sign < 0.0) == mode { + ( + [0, 1, 2, 3], + [negative_axis_v, negative_axis_u, axes[v], axes[u]], + [neg(p), neg(q), p, q], + [len_v, len_u, len_v, len_u], + ) + } else { + ( + [0, 3, 2, 1], + [negative_axis_u, negative_axis_v, axes[u], axes[v]], + [neg(q), neg(p), q, p], + [len_u, len_v, len_u, len_v], + ) + }; + for i in 0..4 { + let offset = 4 + i * 16; + store(output, offset, points[indices[i]]); + store(output, offset + 4, directions[i]); + store(output, offset + 8, planes[i]); + output[offset + 12..offset + 16].fill(lengths[i].to_bits()); + } + store(output, 132, scale(axes[fixed], -winding_sign)); + output[140] = 4; +} + +fn plane(axis: V, direction: V, mode: u32) -> V { + let mut result = if mode != 0 { + cross(axis, direction) + } else { + cross(direction, axis) + }; + let squared = dot(result, result); + if squared > f32::from_bits(0x3400_0000) { + let mut r = estimate(squared); + for _ in 0..2 { + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + } + result = scale(result, r); + } + result +} +fn load(words: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} +fn store(words: &mut [u32], offset: usize, v: V) { + words[offset..offset + 4].copy_from_slice(&v.map(f32::to_bits)); +} +fn dot(a: V, b: V) -> f32 { + super::arithmetic::dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} +fn scale(a: V, b: f32) -> V { + a.map(|v| v * b) +} +fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} +fn add(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] + b[i]) +} +fn madd(a: V, b: f32, c: V) -> V { + std::array::from_fn(|i| a[i].mul_add(b, c[i])) +} +fn neg(a: V) -> V { + a.map(|x| -x) +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} diff --git a/crates/skate-core/src/physics/world_contact/buffer.rs b/crates/skate-core/src/physics/world_contact/buffer.rs new file mode 100644 index 00000000..4407f9f3 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/buffer.rs @@ -0,0 +1,95 @@ +use super::ContactRecord; +use super::arithmetic::{dot, sub, vector}; +use super::reduction::reduce; + +/// Native TempContactBuffer state (+0/+4/+8/+12/+16, +128 records, +/// +12940/+12941/+12942 flags). Caller supplies the actual constructor settings. +/// Guest allocator/publication pointers are represented by the flush callback. +#[derive(Clone, Debug)] +pub struct ContactBuffer { + pub count: u32, + pub flushed: u32, + pub capacity: u32, + pub dropped: u32, + pub distance_squared_threshold: f32, + pub allow_flush: u8, + pub deferred_reduction: u8, + pub full: u8, + pub records: [ContactRecord; 50], +} + +impl ContactBuffer { + /// Complete 82779D60. A full physical buffer with flushing disabled sets + /// the full flag but does not increment dropped until a subsequent call. + pub fn allocate(&mut self, sink: &mut impl FnMut(&[ContactRecord])) -> Option { + assert!(self.count <= 50); + if self.count.wrapping_add(self.flushed) >= self.capacity { + if self.full == 0 { + if self.deferred_reduction != 0 { + self.reduce(); + } + if self.count.wrapping_add(self.flushed) >= self.capacity { + self.full = 1; + } + } + if self.full != 0 { + self.dropped = self.dropped.wrapping_add(1); + return None; + } + } + if self.count >= 50 { + if self.allow_flush == 0 { + self.full = 1; + return None; + } + self.flush(sink); + } + let index = self.count as usize; + self.count += 1; + Some(index) + } + + /// Complete 82779C18. Tests the most recently allocated record against all + /// previous records. The native caller, not this function, decrements count. + pub fn last_is_duplicate(&mut self) -> bool { + if self.distance_squared_threshold < 0.0 || self.deferred_reduction != 0 { + return false; + } + assert!(self.count > 0 && self.count <= 50); + let current = &self.records[self.count as usize - 1]; + for previous in &self.records[..self.count as usize - 1] { + if previous[3] != current[3] || previous[7] != current[7] { + continue; + } + let a = sub(vector(previous, 0), vector(current, 0)); + let b = sub(vector(previous, 4), vector(current, 4)); + if self.distance_squared_threshold > dot(a, a) + && dot(b, b) < self.distance_squared_threshold + && f64::from(dot(vector(previous, 8), vector(current, 8))) + > f64::from_bits(0x3fee_147a_e147_ae14) + { + self.dropped = self.dropped.wrapping_add(1); + return true; + } + } + false + } + + pub fn reduce(&mut self) { + reduce(&mut self.records, &mut self.count); + } + + /// Complete 82779E40; sink is the caller's 8277B428 publication operation. + /// Flushed counts advance by the reduced count, irrespective of sink result. + pub fn flush(&mut self, sink: &mut impl FnMut(&[ContactRecord])) { + if self.count == 0 { + return; + } + if self.deferred_reduction != 0 { + self.reduce(); + } + sink(&self.records[..self.count as usize]); + self.flushed = self.flushed.wrapping_add(self.count); + self.count = 0; + } +} diff --git a/crates/skate-core/src/physics/world_contact/dispatch.rs b/crates/skate-core/src/physics/world_contact/dispatch.rs new file mode 100644 index 00000000..16e2f594 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/dispatch.rs @@ -0,0 +1,79 @@ +use super::{ + FeaturePrism, MaximumFeature, closest_feature_segment, intersect_feature_segments, + intersect_point_face, intersect_segment_face, +}; + +#[path = "face_face.rs"] +mod face_face; +#[path = "face_specialized.rs"] +mod face_specialized; + +/// TU3 82ACE190, FindFeatureIntersectionPrism. Features contain a point (zero +/// edges), segment (one), or convex face. Both feature tags may be updated by +/// closest-feature selection. The caller owns the initial normal/override flag; +/// they change only when the recovered helpers explicitly correct the normal. +/// +/// Numerical helpers retain the core's current host arithmetic approximation; +/// this source port has not been compared with a Skate 3 hardware capture. +pub fn find_feature_intersection_prism( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, + normal: [u32; 4], +) -> u32 { + let (a_edges, b_edges) = (a[140], b[140]); + assert!( + a_edges <= 8 && b_edges <= 8, + "maximum-feature storage exceeded" + ); + match (a_edges, b_edges) { + (0, 0) => { + output[132] = 1; + output[..4].copy_from_slice(&a[136..140]); + output[64..68].copy_from_slice(&b[136..140]); + return 1; + } + (1, 0) => return segment_point(output, a, b, true), + (0, 1) => return segment_point(output, b, a, false), + (0, _) => return intersect_point_face(output, b, a, normal, false), + (_, 0) => return intersect_point_face(output, a, b, normal, true), + (1, 1) => return intersect_feature_segments(output, a, b, normal, true), + (1, _) => return intersect_segment_face(output, b, a, normal, false), + (_, 1) => return intersect_segment_face(output, a, b, normal, true), + (4, 3) => { + if face_specialized::quad_triangle(output, a, b, normal, true) != 0 { + return 1; + } + } + (3, 4) => { + if face_specialized::quad_triangle(output, b, a, normal, false) != 0 { + return 1; + } + } + (4, 4) => { + if face_specialized::quad_quad(output, a, b, normal) != 0 { + return 1; + } + } + _ => {} + } + face_face::intersect(output, a, b) +} + +fn segment_point( + output: &mut FeaturePrism, + segment: &mut MaximumFeature, + point: &MaximumFeature, + segment_is_a: bool, +) -> u32 { + output[132] = 1; + output[..4].copy_from_slice(&point[136..140]); + output[64..68].copy_from_slice(&point[136..140]); + let offset = if segment_is_a { 0 } else { 64 }; + let region = closest_feature_segment( + segment[4..20].try_into().unwrap(), + (&mut output[offset..offset + 4]).try_into().unwrap(), + ); + segment[0] = segment[0].wrapping_sub(region).wrapping_add(2); + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/face_clip.rs b/crates/skate-core/src/physics/world_contact/face_clip.rs new file mode 100644 index 00000000..28940ffa --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_clip.rs @@ -0,0 +1,106 @@ +use super::{MaximumFeature, closest_feature_segment, prism_math::*}; + +/// Complete TU3 82ACC298. Projects an exterior point onto the selected edge; +/// returns its edge/region code without modifying the feature header itself. +pub fn clamp_point_to_feature( + face: &MaximumFeature, + normal: [u32; 4], + point: &mut [u32; 4], +) -> u32 { + let normal = normal.map(f32::from_bits); + let p = point.map(f32::from_bits); + let count = (face[140] as i32).max(0) as usize; + assert!(count <= 8, "native maximum-feature storage exceeded"); + let mut selected = 0; + let mut minimum = 0.0; + let mut selected_plane = [0.0; 4]; + for edge in 0..count { + let mut plane = cross(normal, load(face, 8 + edge * 16)); + if dot(plane, plane) > f32::from_bits(0x3400_0000) { + plane = normalized(plane); + } + let value = dot(plane, sub(p, load(face, 4 + edge * 16))); + if edge == 0 || minimum > value { + minimum = value; + selected = edge; + selected_plane = plane; + } + } + if 0.0 > minimum { + *point = sub(p, scale(selected_plane, minimum)).map(f32::to_bits); + closest_feature_segment( + face[4 + selected * 16..20 + selected * 16] + .try_into() + .unwrap(), + point, + ) + selected as u32 * 2 + } else { + 0 + } +} + +/// Complete TU3 82ACC400 interval clipping. `interval` stores the two scalar +/// limits; `outside` stores the last rejecting edge origin and normalized plane. +/// Those vectors are preserved unless a near-parallel exterior edge writes them. +pub fn clip_segment_to_feature( + face: &MaximumFeature, + segment: &MaximumFeature, + normal: [u32; 4], + interval: &mut [u32; 2], + outside: &mut [u32; 8], +) -> u32 { + interval[0] = 0; + // lfs/stfs quiet an input signalling NaN in the scalar length field. + interval[1] = if f32::from_bits(segment[16]).is_nan() { + segment[16] | 0x0040_0000 + } else { + segment[16] + }; + let normal = normal.map(f32::from_bits); + let count = (face[140] as i32).max(0) as usize; + assert!(count <= 8, "native maximum-feature storage exceeded"); + let mut rejected = false; + for edge in 0..count { + let plane = cross(normal, load(face, 8 + edge * 16)); + let squared = dot(plane, plane); + let length = if squared == 0.0 { + 0.0 + } else { + squared * super::prism_math::inverse_length(squared) + }; + if length < f32::from_bits(0x3a83_126f) { + continue; + } + let plane = normalized(plane); + let origin = load(face, 4 + edge * 16); + let denominator = dot(load(segment, 8), plane); + let numerator = dot(sub(origin, load(segment, 4)), plane); + if denominator.abs() < f32::from_bits(0x3d4c_cccd) { + if numerator > 0.0 { + rejected = true; + outside[..4].copy_from_slice(&face[4 + edge * 16..8 + edge * 16]); + outside[4..].copy_from_slice(&plane.map(f32::to_bits)); + } + } else { + let position = numerator / denominator; + if denominator > 0.0 { + if position > f32::from_bits(interval[0]) { + interval[0] = position.to_bits(); + } + if f32::from_bits(interval[0]) > f32::from_bits(interval[1]) { + interval[0] = interval[1]; + return 0; + } + } else { + if position < f32::from_bits(interval[1]) { + interval[1] = position.to_bits(); + } + if f32::from_bits(interval[1]) < f32::from_bits(interval[0]) { + interval[1] = interval[0]; + return 0; + } + } + } + } + u32::from(!rejected) +} diff --git a/crates/skate-core/src/physics/world_contact/face_edge.rs b/crates/skate-core/src/physics/world_contact/face_edge.rs new file mode 100644 index 00000000..a655ac67 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_edge.rs @@ -0,0 +1,111 @@ +use super::{FeaturePrism, MaximumFeature, prism_math::*}; + +fn store(output: &mut FeaturePrism, offset: usize, point: V) { + output[offset..offset + 4].copy_from_slice(&point.map(f32::to_bits)); +} + +fn end(feature: &MaximumFeature, edge: usize) -> V { + let origin = load(feature, 4 + edge * 16); + let direction = load(feature, 8 + edge * 16); + let length = load(feature, 16 + edge * 16); + std::array::from_fn(|i| direction[i].mul_add(length[i], origin[i])) +} + +/// Complete TU3 82ACCA28 corner/edge fallback. Returns one or two point pairs; +/// the caller writes the prism count. Both feature headers are replaced here. +pub fn intersect_feature_corner_edge( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, + corner: usize, + edge: usize, + a_is_first: bool, +) -> u32 { + let (ao, bo) = if a_is_first { (0, 64) } else { (64, 0) }; + let previous = if corner != 0 { + corner - 1 + } else { + a[140] as usize - 1 + }; + assert!(corner < 8 && edge < 8 && previous < 8); + let bd = load(b, 8 + edge * 16); + let mut selected = previous; + let nearly_parallel = + |index| f32::from_bits(0x3d4c_cccd) > dot(load(a, 12 + index * 16), bd).abs(); + let parallel = if nearly_parallel(previous) { + true + } else { + selected = corner; + nearly_parallel(corner) + }; + let b_origin = load(b, 4 + edge * 16); + if !parallel { + a[0] = (corner as u32) * 2 + 1; + b[0] = (edge as u32 + 1) * 2; + let origin = load(a, 4 + corner * 16); + let mut position = dot(bd, sub(origin, b_origin)); + if position < f32::MIN_POSITIVE { + b[0] = b[0].wrapping_sub(1); + position = 0.0; + } else { + let length = load(b, 16 + edge * 16); + if length.iter().all(|&v| position > v) { + b[0] = b[0].wrapping_add(1); + position = length[0]; + } + } + store(output, ao, origin); + store(output, bo, madd(bd, position, b_origin)); + return 1; + } + a[0] = (selected as u32 + 1) * 2; + b[0] = (edge as u32 + 1) * 2; + let a_origin = load(a, 4 + selected * 16); + let ad = load(a, 8 + selected * 16); + let distance = dot(ad, sub(b_origin, a_origin)); + if distance < f32::MIN_POSITIVE { + store(output, ao, a_origin); + store(output, bo, b_origin); + a[0] = a[0].wrapping_sub(1); + b[0] = b[0].wrapping_sub(1); + return 1; + } + let al = load(a, 16 + selected * 16); + let bl = load(b, 16 + edge * 16); + if (0..4).all(|i| f32::MIN_POSITIVE > (al[i] + bl[i]) - distance) { + store(output, ao, end(a, selected)); + store(output, bo, end(b, edge)); + a[0] = a[0].wrapping_add(1); + b[0] = b[0].wrapping_add(1); + return 1; + } + if al.iter().all(|&v| f32::MIN_POSITIVE > v - distance) { + store(output, ao, end(a, selected)); + store( + output, + bo, + sub( + b_origin, + std::array::from_fn(|i| bd[i] * (al[i] - distance)), + ), + ); + } else { + store(output, ao, madd(ad, distance, a_origin)); + store(output, bo, b_origin); + } + if bl.iter().all(|&v| f32::MIN_POSITIVE > v - distance) { + store( + output, + ao + 4, + sub( + a_origin, + std::array::from_fn(|i| ad[i] * (bl[i] - distance)), + ), + ); + store(output, bo + 4, end(b, edge)); + } else { + store(output, ao + 4, a_origin); + store(output, bo + 4, madd(bd, distance, b_origin)); + } + 2 +} diff --git a/crates/skate-core/src/physics/world_contact/face_face.rs b/crates/skate-core/src/physics/world_contact/face_face.rs new file mode 100644 index 00000000..ad60b1b5 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_face.rs @@ -0,0 +1,153 @@ +use crate::physics::world_contact::{ + FeaturePrism, MaximumFeature, intersect_feature_corner_edge, prism_math::*, +}; + +const OUTSIDE_A: u32 = 0x4000_0000; +const OUTSIDE_B: u32 = 0x2000_0000; + +fn vertex(feature: &MaximumFeature, index: usize) -> V { + load(feature, 4 + index * 16) +} + +fn plane_distance(feature: &MaximumFeature, edge: usize, point: V) -> f32 { + dot( + load(feature, 12 + edge * 16), + sub(point, vertex(feature, edge)), + ) +} + +fn project_to_face(face: &MaximumFeature, point: V) -> V { + let normal = load(face, 132); + madd(normal, dot(normal, sub(vertex(face, 0), point)), point) +} + +fn append(output: &mut FeaturePrism, a: V, b: V) { + let offset = output[132] as usize * 4; + assert!(offset < 64, "feature prism point capacity exceeded"); + output[offset..offset + 4].copy_from_slice(&a.map(f32::to_bits)); + output[64 + offset..68 + offset].copy_from_slice(&b.map(f32::to_bits)); + output[132] += 1; +} + +/// General convex-face branch at 82ACE3D8, after specialized paths decline. +/// Retains native candidate order: B vertices, edge crossings, then A vertices. +/// Plane-touching vertices/crossings are included without deduplication here. +pub(super) fn intersect( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, +) -> u32 { + let (a_count, b_count) = (a[140] as usize, b[140] as usize); + let mut masks = [0u32; 8]; + let mut nearest_corner = 0; + let mut nearest_edge = 0; + let mut edge_is_a = false; + let mut largest_minimum = -f32::MAX; + output[132] = 0; + + for edge in 0..a_count { + let mut previous = b_count - 1; + let mut previous_distance = plane_distance(a, edge, vertex(b, previous)); + let mut minimum = previous_distance; + let mut minimum_corner = previous; + for corner in 0..b_count { + let distance = plane_distance(a, edge, vertex(b, corner)); + if distance < minimum { + minimum = distance; + minimum_corner = corner; + } + if distance > 0.0 { + masks[corner] |= OUTSIDE_B; + } + if !(distance * previous_distance > 0.0) { + masks[previous] |= 1 << edge; + } + previous = corner; + previous_distance = distance; + } + if minimum > largest_minimum { + largest_minimum = minimum; + nearest_corner = minimum_corner; + nearest_edge = edge; + edge_is_a = true; + } + } + + for corner in 0..b_count { + if masks[corner] & OUTSIDE_B == 0 { + let point = vertex(b, corner); + append(output, project_to_face(a, point), point); + } + } + // Containment is an early return; it must not duplicate all of A's vertices + // when coincident faces have already contributed every B vertex. + if output[132] as usize == b_count { + return 1; + } + + for edge in 0..b_count { + let mut previous = a_count - 1; + let mut previous_distance = plane_distance(b, edge, vertex(a, previous)); + let mut minimum = previous_distance; + let mut minimum_corner = previous; + for corner in 0..a_count { + let distance = plane_distance(b, edge, vertex(a, corner)); + if distance < minimum { + minimum = distance; + minimum_corner = corner; + } + if distance > 0.0 { + masks[corner] |= OUTSIDE_A; + } + let difference = previous_distance - distance; + if !(distance * previous_distance > 0.0) + && masks[edge] & (1 << previous) != 0 + && difference.abs() > f32::MIN_POSITIVE + { + let inverse_difference = 1.0 / difference; + let origin = vertex(a, previous); + let direction = load(a, 8 + previous * 16); + let length = load(a, 16 + previous * 16); + let point = std::array::from_fn(|lane| { + let numerator = length[lane] * previous_distance; + let along = inverse_difference * numerator; + direction[lane].mul_add(along, origin[lane]) + }); + append(output, point, project_to_face(b, point)); + } + previous = corner; + previous_distance = distance; + } + if minimum > largest_minimum { + largest_minimum = minimum; + nearest_corner = minimum_corner; + nearest_edge = edge; + edge_is_a = false; + } + } + for corner in 0..a_count { + if masks[corner] & OUTSIDE_A == 0 { + let point = vertex(a, corner); + append(output, point, project_to_face(b, point)); + } + } + if output[132] == 0 { + output[132] = if edge_is_a { + intersect_feature_corner_edge(output, b, a, nearest_corner, nearest_edge, false) + } else { + intersect_feature_corner_edge(output, a, b, nearest_corner, nearest_edge, true) + }; + let delta = sub(load(output, 64), load(output, 0)); + let squared = dot(delta, delta); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse_length(squared) + }; + if length > f32::MIN_POSITIVE { + output[128..132].copy_from_slice(&scale(delta, 1.0 / length).map(f32::to_bits)); + output[133] = 1; + } + } + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/face_specialized.rs b/crates/skate-core/src/physics/world_contact/face_specialized.rs new file mode 100644 index 00000000..cc5a5e64 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_specialized.rs @@ -0,0 +1,116 @@ +use crate::physics::world_contact::{FeaturePrism, MaximumFeature, prism_math::*}; + +/// TU3 82ACCEF0. The quad owns the outer edge-intersection loop even when it is +/// the second feature in the caller's pair. This order reaches contact reduction. +pub(super) fn quad_triangle( + output: &mut FeaturePrism, + quad: &MaximumFeature, + triangle: &MaximumFeature, + normal: [u32; 4], + quad_is_a: bool, +) -> u32 { + intersect(output, quad, triangle, normal, quad_is_a) +} + +/// TU3 82ACD7D8. Its calculations match 82ACCEF0 with four inner edges and four +/// vertices in both containment passes; the source transposes these into lanes. +pub(super) fn quad_quad( + output: &mut FeaturePrism, + a: &MaximumFeature, + b: &MaximumFeature, + normal: [u32; 4], +) -> u32 { + intersect(output, a, b, normal, true) +} + +fn vertex(feature: &MaximumFeature, edge: usize) -> V { + load(feature, 4 + edge * 16) +} + +fn project_to_face(face: &MaximumFeature, point: V) -> V { + let normal = load(face, 132); + madd(normal, dot(normal, sub(vertex(face, 0), point)), point) +} + +fn append( + output: &mut FeaturePrism, + count: &mut usize, + a: &MaximumFeature, + b: &MaximumFeature, + point: V, + a_is_first: bool, +) { + assert!(*count < 16, "feature prism point capacity exceeded"); + let (ao, bo) = if a_is_first { (0, 64) } else { (64, 0) }; + let offset = *count * 4; + output[ao + offset..ao + offset + 4] + .copy_from_slice(&project_to_face(a, point).map(f32::to_bits)); + output[bo + offset..bo + offset + 4] + .copy_from_slice(&project_to_face(b, point).map(f32::to_bits)); + *count += 1; +} + +// The contained-vertex passes use lane-wise multiply/FMA operations, with a +// rounded Y product followed by X and Z FMAs, instead of the VMX three-lane dot. +fn containment_dot(delta: V, normal: V) -> f32 { + delta[2].mul_add(normal[2], delta[0].mul_add(normal[0], delta[1] * normal[1])) +} + +fn intersect( + output: &mut FeaturePrism, + a: &MaximumFeature, + b: &MaximumFeature, + normal: [u32; 4], + a_is_first: bool, +) -> u32 { + let mut count = 0; + let b_count = b[140] as usize; + // Full native edge-pair validity masks, including endpoint equality. + // Only the first lane of each mask controls publication, so broadcast + // feature lengths are read from their first lane here as in the source. + for edge_a in 0..4 { + let a_origin = vertex(a, edge_a); + let a_direction = load(a, 8 + edge_a * 16); + let a_plane = load(a, 12 + edge_a * 16); + let a_length = f32::from_bits(a[16 + edge_a * 16]); + for edge_b in 0..b_count { + let b_origin = vertex(b, edge_b); + let b_direction = load(b, 8 + edge_b * 16); + let b_length = f32::from_bits(b[16 + edge_b * 16]); + let denominator = dot(b_direction, a_plane); + let offset = dot(sub(a_origin, b_origin), a_plane); + let point = madd(b_direction, reciprocal(denominator) * offset, b_origin); + let along_a = dot(sub(point, a_origin), a_direction); + let valid = denominator.abs() > f32::from_bits(0x3400_0000) + && !(offset.abs() > (b_length * denominator).abs()) + && !(0.0 > offset * denominator) + && !(along_a > a_length) + && !(0.0 > along_a); + if valid { + append(output, &mut count, a, b, point, a_is_first); + } + } + } + let normal = normal.map(f32::from_bits); + for (source, target, source_count, target_count) in [(a, b, 4, b_count), (b, a, b_count, 4)] { + for corner in 0..source_count { + let origin = vertex(source, corner); + let distance = containment_dot(sub(vertex(target, 0), origin), normal); + // The source transposes XYZ back into points with a literal W=1. + let mut point = madd(normal, distance, origin); + point[3] = 1.0; + let inside = (0..target_count).all(|edge| { + let distance = containment_dot( + sub(point, vertex(target, edge)), + load(target, 12 + edge * 16), + ); + 0.0 > distance + }); + if inside { + append(output, &mut count, a, b, point, a_is_first); + } + } + } + output[132] = count as u32; + u32::from(count != 0) +} diff --git a/crates/skate-core/src/physics/world_contact/feature.rs b/crates/skate-core/src/physics/world_contact/feature.rs new file mode 100644 index 00000000..d9ea903a --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/feature.rs @@ -0,0 +1,79 @@ +use super::MaximumFeature; +use crate::physics::reciprocal_sqrt::estimate; + +fn load(words: &[u32], offset: usize) -> [f32; 4] { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} + +/// Complete 82AC6E50 segment initialization. Writes origin, normalized direction +/// and broadcast length; preserves the unwritten plane vector at words8..12. +pub fn initialize_feature_segment(output: &mut [u32; 16], origin: [u32; 4], end: [u32; 4]) { + output[..4].copy_from_slice(&origin); + let origin = origin.map(f32::from_bits); + let end = end.map(f32::from_bits); + let delta: [f32; 4] = std::array::from_fn(|i| end[i] - origin[i]); + let squared = super::arithmetic::dot( + delta[..3].try_into().unwrap(), + delta[..3].try_into().unwrap(), + ); + // One rsqrt refinement, then one reciprocal refinement. Runtime initializer + // 82F837D8 broadcasts 8212BF78 (34000000) to threshold vector830BDD40. + let mut reciprocal = estimate(squared); + reciprocal = + (reciprocal * 0.5).mul_add((-squared).mul_add(reciprocal * reciprocal, 1.0), reciprocal); + let length = if squared > f32::from_bits(0x3400_0000) { + if squared == 0.0 { + 0.0 + } else { + squared * reciprocal + } + } else { + 0.0 + }; + output[12..16].fill(length.to_bits()); + let r = super::prism_math::reciprocal_estimate(length); + let r = r.mul_add((-r).mul_add(length, 1.0), r); + let direction = if squared > f32::from_bits(0x3400_0000) { + delta.map(|x| x * r) + } else { + [0.0; 4] + }; + output[4..8].copy_from_slice(&direction.map(f32::to_bits)); +} + +/// Complete capsule maximum-feature callback82AD98A0. `segment_scratch` carries +/// the native local64-byte segment, including its unwritten plane vector. +/// No fabricated value is assigned to those copied scratch words. Native r4 +/// mode is unused by this capsule callback. +pub fn capsule_maximum_feature( + gp: &[u32; 48], + direction: [u32; 4], + output: &mut MaximumFeature, + segment_scratch: &mut [u32; 16], +) { + let axis = load(gp, 16); + let normal = direction.map(f32::from_bits); + let projection = super::arithmetic::dot( + axis[..3].try_into().unwrap(), + normal[..3].try_into().unwrap(), + ); + let half = f32::from_bits(gp[28]); + let center = load(gp, 0); + if projection.abs() < f32::from_bits(0x3d4c_cccd) { + let scaled = axis.map(|v| v * half); + let plus = std::array::from_fn(|i| (center[i] + scaled[i]).to_bits()); + let minus = std::array::from_fn(|i| (center[i] - scaled[i]).to_bits()); + initialize_feature_segment(segment_scratch, plus, minus); + output[4..20].copy_from_slice(segment_scratch); + output[140] = 1; + } else { + let point: [f32; 4] = if projection > 0.0 { + std::array::from_fn(|i| axis[i].mul_add(half, center[i])) + } else { + std::array::from_fn(|i| center[i] - axis[i] * half) + }; + output[136..140].copy_from_slice(&point.map(f32::to_bits)); + output[140] = 0; + } + output[0] = 0; +} diff --git a/crates/skate-core/src/physics/world_contact/mod.rs b/crates/skate-core/src/physics/world_contact/mod.rs new file mode 100644 index 00000000..8f0c7d87 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/mod.rs @@ -0,0 +1,48 @@ +//! TU3 world-contact production and native temporary-contact retention. +//! General primitive dispatch remains a separate provider dependency. +mod arithmetic; +mod axes; +mod box_feature; +mod buffer; +mod dispatch; +mod face_clip; +mod face_edge; +mod feature; +mod point_feature; +mod prism_math; +mod primitive_query; +mod primitive_pair; +pub use primitive_pair::{PrimitivePairSettings, primitive_pair_contacts}; +mod projection; +mod reduction; +mod segment_face; +mod segment_prism; +mod triangle_feature; + +pub use axes::separating_axis_candidates; +pub use box_feature::box_maximum_feature; +pub use buffer::ContactBuffer; +pub use dispatch::find_feature_intersection_prism; +pub use primitive_query::{ + ContactPrimitive, PrimitiveContactManifold, primitive_triangle_world_contacts, + transform_triangle_volume, triangle_from_volume, +}; +pub use face_clip::{clamp_point_to_feature, clip_segment_to_feature}; +pub use face_edge::intersect_feature_corner_edge; +pub use feature::{capsule_maximum_feature, initialize_feature_segment}; +pub use point_feature::{closest_feature_segment, intersect_point_face}; +pub use projection::{ + PrimitiveKind, best_separating_direction, project_direction, project_directions, +}; +pub use segment_face::intersect_segment_face; +pub use segment_prism::intersect_feature_segments; +pub use triangle_feature::{build_feature_edge_planes, triangle_maximum_feature}; + +/// Maximum-feature record consumed by 82ACE190. +pub type MaximumFeature = [u32; 144]; +/// Native two point arrays, normal, count, flags and untouched trailing padding. +pub type FeaturePrism = [u32; 136]; +pub use reduction::{coplanar_contacts, select_contact_points}; + +/// Native 256-byte contact record, including both body workspaces. +pub type ContactRecord = [u32; 64]; diff --git a/crates/skate-core/src/physics/world_contact/point_feature.rs b/crates/skate-core/src/physics/world_contact/point_feature.rs new file mode 100644 index 00000000..89de46a4 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/point_feature.rs @@ -0,0 +1,78 @@ +use super::{FeaturePrism, MaximumFeature, arithmetic::dot}; + +fn load(words: &[u32], offset: usize) -> [f32; 4] { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} + +fn dot3(a: [f32; 4], b: [f32; 4]) -> f32 { + dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} + +/// Complete TU3 82AC6EE8. Region codes are before=1, interior=2, after=3. +/// The upper-end comparison tests all four length lanes; equality is interior. +pub fn closest_feature_segment(segment: &[u32; 16], point: &mut [u32; 4]) -> u32 { + let origin = load(segment, 0); + let direction = load(segment, 4); + let p = point.map(f32::from_bits); + let along = dot3(std::array::from_fn(|i| p[i] - origin[i]), direction); + if 0.0 > along { + point.copy_from_slice(&segment[..4]); + return 1; + } + let length = load(segment, 12); + let after = length.iter().all(|&v| along > v); + *point = std::array::from_fn(|i| { + direction[i] + .mul_add(if after { length[i] } else { along }, origin[i]) + .to_bits() + }); + if after { 3 } else { 2 } +} + +/// Complete TU3 82ACC160 point/face helper. `face_is_a` selects its two native +/// destination pointers within the prism; normal and untouched records survive. +/// The face header receives the selected edge index and segment-region code. +pub fn intersect_point_face( + output: &mut FeaturePrism, + face: &mut MaximumFeature, + point: &MaximumFeature, + normal: [u32; 4], + face_is_a: bool, +) -> u32 { + output[132] = 1; + let p = load(point, 136); + let normal = normal.map(f32::from_bits); + let origin = load(face, 4); + let distance = dot3(normal, std::array::from_fn(|i| origin[i] - p[i])); + let mut projected = std::array::from_fn(|i| normal[i].mul_add(distance, p[i])); + if (face[140] as i32) > 0 { + let count = face[140] as usize; + assert!(count <= 8, "native maximum-feature storage exceeded"); + let mut selected = 0; + let mut worst = 0.0; + for edge in 0..count { + let start = load(face, 4 + edge * 16); + let plane = load(face, 12 + edge * 16); + let value = dot3(plane, std::array::from_fn(|i| projected[i] - start[i])); + if edge == 0 || value > worst { + worst = value; + selected = edge; + } + } + if worst > 0.0 { + let plane = load(face, 12 + selected * 16); + let mut projected_bits = + std::array::from_fn(|i| (projected[i] - plane[i] * worst).to_bits()); + let segment = face[4 + selected * 16..20 + selected * 16] + .try_into() + .unwrap(); + let region = closest_feature_segment(segment, &mut projected_bits); + face[0] = face[0].wrapping_add((selected as u32) * 2 + region); + projected = projected_bits.map(f32::from_bits); + } + } + let (face_offset, point_offset) = if face_is_a { (0, 64) } else { (64, 0) }; + output[face_offset..face_offset + 4].copy_from_slice(&projected.map(f32::to_bits)); + output[point_offset..point_offset + 4].copy_from_slice(&point[136..140]); + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/primitive_geometry.rs b/crates/skate-core/src/physics/world_contact/primitive_geometry.rs new file mode 100644 index 00000000..54cb0590 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/primitive_geometry.rs @@ -0,0 +1,164 @@ +use super::super::{PrimitiveKind, prism_math::*}; +use crate::{ + math::{Basis3, Vector3}, + physics::{ + collision::{Sphere, Triangle, TriangleFeature}, + reciprocal_sqrt::estimate, + }, +}; + +/// World-space board geometry. Capsule axes and box basis columns are the +/// transformed native volume axes; shape radii are applied after feature fixup. +#[derive(Clone, Copy, Debug)] +pub enum ContactPrimitive { + Sphere(Sphere), + Capsule { + center: Vector3, + axis: Vector3, + half_length: f32, + radius: f32, + }, + Triangle(Triangle), + RoundedBox { + center: Vector3, + basis: Basis3, + half_extents: Vector3, + radius: f32, + }, +} + +/// Authored triangle Volume initialization and CreateGPInstance geometry +/// (82AC75B8, 82ADE468). The normal cache's dirty bit is cleared before copying +/// the Volume flags to GP flags; all other flags and edge cosines copy directly. +/// This constructor recomputes authored geometry. A cached native GPTriangle can +/// instead be passed directly as ContactPrimitive::Triangle. +pub fn triangle_from_volume( + vertices: [Vector3; 3], + fatness: f32, + edge_cosines: [f32; 3], + volume_flags: u32, +) -> Triangle { + let points = vertices.map(|v| lanes(v, 1.0)); + let raw_normal = cross(sub(points[1], points[0]), sub(points[2], points[0])); + let squared = dot(raw_normal, raw_normal); + let normal = if squared > f32::from_bits(0x3400_0000) { + scale(raw_normal, refined_inverse(squared)) + } else { + raw_normal + }; + let raw_edges = [ + sub(points[2], points[0]), + sub(points[1], points[2]), + sub(points[0], points[1]), + ]; + let inverse = raw_edges.map(|e| refined_inverse(dot(e, e))); + Triangle { + vertices, + feature: TriangleFeature { + normal: xyz(normal), + edges: std::array::from_fn(|i| xyz(scale(raw_edges[i], inverse[i]))), + flags: volume_flags & !2, + edge_cosines, + }, + // 82ADE66C..: lengths are the twice-refined reciprocal of the refined + // inverse length, not squared length multiplied by inverse length. + edge_lengths: inverse.map(reciprocal), + fatness, + } +} + +/// TriangleVolume::CreateGPInstance with a rigid Volume-to-world transform. +/// Source order computes the local normal first, transforms it without a new +/// normalization, and then builds directions and lengths from world vertices. +pub fn transform_triangle_volume( + vertices: [Vector3; 3], + fatness: f32, + edge_cosines: [f32; 3], + volume_flags: u32, + basis: Basis3, + translation: Vector3, +) -> Triangle { + let local = triangle_from_volume(vertices, fatness, edge_cosines, volume_flags); + let axes = basis.columns.map(|c| [c[0], c[1], c[2], 0.0]); + let world_vertices = vertices.map(|v| { + xyz(madd( + axes[2], + v.z, + madd(axes[1], v.y, madd(axes[0], v.x, lanes(translation, 1.0))), + )) + }); + let n = local.feature.normal; + let normal = xyz(madd(axes[2], n.z, madd(axes[1], n.y, scale(axes[0], n.x)))); + let mut result = triangle_from_volume(world_vertices, fatness, edge_cosines, volume_flags); + result.feature.normal = normal; + result +} +fn refined_inverse(squared: f32) -> f32 { + let seed = estimate(squared); + (seed * 0.5).mul_add((-squared).mul_add(seed * seed, 1.0), seed) +} + +pub(in super::super) fn pack(shape: ContactPrimitive) -> ([u32; 48], PrimitiveKind) { + let mut gp = [0; 48]; + let (kind, radius, counts, id) = match shape { + ContactPrimitive::Sphere(sphere) => { + put(&mut gp, 0, lanes(sphere.center, 1.0)); + (PrimitiveKind::Sphere, sphere.radius, 0, 1) + } + ContactPrimitive::Capsule { + center, + axis, + half_length, + radius, + } => { + put(&mut gp, 0, lanes(center, 1.0)); + put(&mut gp, 16, lanes(axis, 0.0)); + gp[28] = half_length.to_bits(); + (PrimitiveKind::Capsule, radius, 0x0001_0000, 2) + } + ContactPrimitive::Triangle(triangle) => { + put(&mut gp, 0, lanes(triangle.vertices[0], 1.0)); + put(&mut gp, 4, lanes(triangle.feature.normal, 0.0)); + put(&mut gp, 8, lanes(triangle.vertices[1], 1.0)); + put(&mut gp, 12, lanes(triangle.vertices[2], 1.0)); + for i in 0..3 { + put(&mut gp, 16 + i * 4, lanes(triangle.feature.edges[i], 0.0)); + gp[28 + i] = triangle.edge_lengths[i].to_bits(); + gp[38 + i] = triangle.feature.edge_cosines[i].to_bits(); + } + gp[37] = triangle.feature.flags; + (PrimitiveKind::Triangle, triangle.fatness, 0x0103_0000, 3) + } + ContactPrimitive::RoundedBox { + center, + basis, + half_extents, + radius, + } => { + put(&mut gp, 0, lanes(center, 1.0)); + let half = [half_extents.x, half_extents.y, half_extents.z]; + for i in 0..3 { + let v = basis.columns[i]; + let axis = [v[0], v[1], v[2], 0.0]; + put(&mut gp, 4 + i * 4, axis); + put(&mut gp, 16 + i * 4, axis); + gp[28 + i] = half[i].to_bits(); + } + (PrimitiveKind::Box, radius, 0x0303_0000, 4) + } + }; + gp[32] = radius.to_bits(); + gp[35] = counts; + gp[36] = id; + (gp, kind) +} + +pub(in super::super) fn lanes(v: Vector3, w: f32) -> V { + [v.x, v.y, v.z, w] +} +pub(in super::super) fn xyz(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn put(out: &mut [u32], offset: usize, v: V) { + out[offset..offset + 4].copy_from_slice(&v.map(f32::to_bits)); +} diff --git a/crates/skate-core/src/physics/world_contact/primitive_pair.rs b/crates/skate-core/src/physics/world_contact/primitive_pair.rs new file mode 100644 index 00000000..f635018f --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/primitive_pair.rs @@ -0,0 +1,278 @@ +//! Simulation GP-pair contact query, original Skate 3 TU3 82AD43A8. +//! +//! 8277A508 calls this after its pair-culling bitmap and bounds checks, including +//! the same-assembly branch at 8277B2C0. This is not PrimitivePairIntersect +//! 82AD3CD8: that query has a squared-distance gate and different publication. +//! Host geometry is already transformed into world space. Native GP dispatch, +//! feature/prism order, normal refinement and radius application are retained. + +use super::ContactPrimitive; +use super::{ + PrimitiveContactManifold, PrimitiveKind, best_separating_direction, + find_feature_intersection_prism, + primitive_query::{ + box_box_sat, + geometry::{pack, xyz}, + maximum, triangle_box_sat, + }, + prism_math::*, + project_direction, +}; +use crate::{ + math::Vector3, + physics::collision::{ContactPair, TriangleFixup, fix_up_triangle}, +}; + +#[derive(Clone, Copy, Debug)] +pub struct PrimitivePairSettings { + /// Volume-record +112 for A/B in 8277A508, separate from shape fatness. + pub padding_a: f32, + pub padding_b: f32, + /// Batch job +36, passed by 8277AB70; added after both volume paddings. + pub additional_padding: f32, + /// Native f2/f3 of 82AD43A8, used only for triangle feature correction. + pub edge_cos_bend_normal_threshold: f32, + pub convexity_epsilon: f32, +} + +impl PrimitivePairSettings { + /// Original skater/skater batch settings. SerializedAssembly::Create + /// 82DC3AE4/82DC3B84 stores 82165A00 into every volume's padding (+112). + /// Island::InitWorld 82765544 initializes additionalPadding to zero; + /// 82DC1848 -> 82DC19E8 -> 8277B2A4 passes it to this query. The triangle + /// constants are loaded by 8277ABEC..AC1C and forwarded at 8277A7DC..7EC. + pub const fn skater_self_collision() -> Self { + Self { + padding_a: f32::from_bits(0x3D4C_CCCD), + padding_b: f32::from_bits(0x3D4C_CCCD), + additional_padding: 0.0, + edge_cos_bend_normal_threshold: f32::from_bits(0x3F7F_BE77), + convexity_epsilon: f32::from_bits(0x3C23_D70A), + } + } +} + +/// Contacts between two simulation volumes. The normal points from B toward A; +/// `points[i].a/b` belong to the corresponding input volume. Positive geometric +/// gaps inside the native padding are retained for the solver to classify. +pub fn primitive_pair_contacts( + primitive_a: ContactPrimitive, + primitive_b: ContactPrimitive, + settings: PrimitivePairSettings, +) -> Option { + let (a, a_kind) = pack(primitive_a); + let (b, b_kind) = pack(primitive_b); + let (separation, initial_normal) = separating_direction(&a, a_kind, &b, b_kind); + // 8277A720..744 computes the pair padding before 82AD43F8..4414 adds + // geometric fatness, preserving all four scalar additions in order. + let limit = (settings.padding_b + settings.padding_a) + settings.additional_padding; + let fat_a = f32::from_bits(a[32]); + let fat_b = f32::from_bits(b[32]); + if separation > (fat_b + limit) + fat_a { + return None; + } + let mut feature_a = maximum(&a, a_kind, 1, initial_normal); + let mut feature_b = maximum(&b, b_kind, 0, initial_normal.map(|w| w ^ 0x8000_0000)); + let mut prism = [0; 136]; + if find_feature_intersection_prism(&mut prism, &mut feature_a, &mut feature_b, initial_normal) + == 0 + { + return None; + } + let count = prism[132] as usize; + let mut normal = initial_normal.map(f32::from_bits); + if count == 1 { + // 82AD44B0..4540 compares *length*, not squared length. The source + // computes both reciprocal-square-root refinements before selecting + // zero length for a zero squared distance. + let delta = sub(load(&prism, 64), load(&prism, 0)); + let squared = dot(delta, delta); + let inverse = inverse_length(squared); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + if length > f32::from_bits(0x3400_0000) { + prism[128..132].copy_from_slice(&scale(delta, inverse).map(f32::to_bits)); + prism[133] = 1; + } + } + if prism[133] != 0 { + let direction: [u32; 4] = prism[128..132].try_into().unwrap(); + let mut interval_a = [0; 12]; + let mut interval_b = [0; 12]; + project_direction(&a, a_kind, direction, &mut interval_a); + project_direction(&b, b_kind, direction, &mut interval_b); + let forward = f32::from_bits(interval_a[0]) - f32::from_bits(interval_b[4]); + let reverse = f32::from_bits(interval_b[0]) - f32::from_bits(interval_a[4]); + let flip = forward > reverse; + let refined = if flip { forward } else { reverse }; + if refined >= separation { + normal = direction.map(|w| f32::from_bits(if flip { w ^ 0x8000_0000 } else { w })); + } + } + let mut points = [ContactPair { + a: Vector3::ZERO, + b: Vector3::ZERO, + }; 16]; + for (i, point) in points[..count].iter_mut().enumerate() { + point.a = xyz(load(&prism, 4 * i)); + point.b = xyz(load(&prism, 64 + 4 * i)); + } + // General GP query corrects either triangle, A first. The skater-specific + // world query corrects only B and supplies a different is_object argument. + let mut pair_normal = xyz(normal); + for (primitive, reverse) in [(primitive_a, false), (primitive_b, true)] { + if let ContactPrimitive::Triangle(triangle) = primitive { + if !fix_up_triangle( + triangle.feature, + &mut pair_normal, + &mut points[..count], + TriangleFixup { + reverse, + edge_cos_bend_normal_threshold: settings.edge_cos_bend_normal_threshold, + convexity_epsilon: settings.convexity_epsilon, + is_object: false, + }, + ) { + return None; + } + } + } + let direction = [pair_normal.x, pair_normal.y, pair_normal.z, normal[3]]; + let offset_a = scale(direction, fat_a); + let offset_b = scale(direction, fat_b); + for point in &mut points[..count] { + // 82AD46EC..4724 multiplies before adding/subtracting, not fused. + point.a = Vector3::new( + point.a.x + offset_a[0], + point.a.y + offset_a[1], + point.a.z + offset_a[2], + ); + point.b = Vector3::new( + point.b.x - offset_b[0], + point.b.y - offset_b[1], + point.b.z - offset_b[2], + ); + } + Some(PrimitiveContactManifold { + normal: Vector3::new(-pair_normal.x, -pair_normal.y, -pair_normal.z), + points, + count, + }) +} + +/// Exact non-cylinder entries of original dispatch table 82FD56F0. +fn separating_direction( + a: &[u32; 48], + a_kind: PrimitiveKind, + b: &[u32; 48], + b_kind: PrimitiveKind, +) -> (f32, [u32; 4]) { + let (separation, direction) = match (a_kind, b_kind) { + (PrimitiveKind::Sphere, PrimitiveKind::Sphere) => { + // Specialized 82ACE9C0. The generic no-edge fallback is Z, so it + // cannot substitute for this center-to-center sphere-pair axis. + let delta = sub(load(b, 0), load(a, 0)); + let squared = dot(delta, delta); + let inverse = inverse_length(squared); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + ( + [length.to_bits(); 4], + scale(delta, inverse).map(f32::to_bits), + ) + } + (PrimitiveKind::Box, PrimitiveKind::Box) => box_box_sat::box_box(a, b), + (PrimitiveKind::Triangle, PrimitiveKind::Box) => triangle_box_sat::triangle_box(a, b), + (PrimitiveKind::Box, PrimitiveKind::Triangle) => { + let (separation, normal) = triangle_box_sat::triangle_box(b, a); + (separation, normal.map(|w| w ^ 0x8000_0000)) + } + _ => best_separating_direction(a, a_kind, b, b_kind), + }; + (f32::from_bits(separation[0]), direction) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{math::Basis3, physics::collision::Sphere}; + + fn settings() -> PrimitivePairSettings { + PrimitivePairSettings { + padding_a: 0.0, + padding_b: 0.0, + additional_padding: 0.0, + edge_cos_bend_normal_threshold: 0.0, + convexity_epsilon: 0.0, + } + } + fn sphere(center: Vector3, radius: f32) -> ContactPrimitive { + ContactPrimitive::Sphere(Sphere { center, radius }) + } + fn close(a: f32, b: f32) { + assert!((a - b).abs() < 0.00001, "{a} != {b}"); + } + #[test] + fn sphere_pair_publishes_both_surfaces_and_solver_normal() { + let a = sphere(Vector3::ZERO, 1.0); + let b = sphere(Vector3::new(1.2, 1.6, 0.0), 1.1); + let result = primitive_pair_contacts(a, b, settings()).unwrap(); + assert_eq!(result.count, 1); + close(result.normal.x, -0.6); + close(result.normal.y, -0.8); + close(result.points[0].a.x, 0.6); + close(result.points[0].a.y, 0.8); + close(result.points[0].b.x, 0.54); + close(result.points[0].b.y, 0.72); + let reverse = primitive_pair_contacts(b, a, settings()).unwrap(); + close(reverse.normal.x, -result.normal.x); + close(reverse.points[0].a.x, result.points[0].b.x); + } + #[test] + fn separated_spheres_keep_padding_contacts_without_inflating_surface_points() { + let a = sphere(Vector3::ZERO, 1.0); + let b = sphere(Vector3::new(2.25, 0.0, 0.0), 1.0); + assert!(primitive_pair_contacts(a, b, settings()).is_none()); + let s = PrimitivePairSettings { + padding_a: 0.125, + padding_b: 0.125, + ..settings() + }; + let result = primitive_pair_contacts(a, b, s).unwrap(); + close(result.points[0].a.x, 1.0); + close(result.points[0].b.x, 1.25); + } + #[test] + fn box_capsule_pair_supports_reversed_native_feature_dispatch() { + let a = ContactPrimitive::RoundedBox { + center: Vector3::ZERO, + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + half_extents: Vector3::new(1.0, 1.0, 1.0), + radius: 0.1, + }; + let b = ContactPrimitive::Capsule { + center: Vector3::new(1.4, 0.0, 0.0), + axis: Vector3::new(0.0, 1.0, 0.0), + half_length: 0.5, + radius: 0.5, + }; + let result = primitive_pair_contacts(a, b, settings()).unwrap(); + assert!(result.count > 0); + close(result.normal.x, -1.0); + for point in &result.points[..result.count] { + close(point.a.x, 1.1); + close(point.b.x, 0.9); + } + let reversed = primitive_pair_contacts(b, a, settings()).unwrap(); + close(reversed.normal.x, 1.0); + assert_eq!(reversed.count, result.count); + } +} diff --git a/crates/skate-core/src/physics/world_contact/primitive_query.rs b/crates/skate-core/src/physics/world_contact/primitive_query.rs new file mode 100644 index 00000000..999aacc8 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/primitive_query.rs @@ -0,0 +1,182 @@ +//! Physics/world primitive query 8277B720 and moving-volume/triangle dispatch +//! 8277BC58. Geometry lives in typed host-owned records; only the recovered +//! kernels' GP and feature scratch boundaries use native word layouts. +#[path = "box_box_sat.rs"] +pub(super) mod box_box_sat; +#[path = "primitive_geometry.rs"] +pub(super) mod geometry; +#[path = "triangle_box_sat.rs"] +pub(super) mod triangle_box_sat; + +use super::{ + MaximumFeature, PrimitiveKind, best_separating_direction, box_maximum_feature, + capsule_maximum_feature, find_feature_intersection_prism, prism_math::*, project_direction, + triangle_maximum_feature, +}; +use crate::{ + math::Vector3, + physics::collision::{ + ContactPair, Triangle, TriangleFixup, WorldContactSettings, fix_up_triangle, + world_separation_limit, + }, +}; +pub use geometry::{ContactPrimitive, transform_triangle_volume, triangle_from_volume}; +use geometry::{lanes, pack, xyz}; + +#[derive(Clone, Copy, Debug)] +pub struct PrimitiveContactManifold { + /// World primitive B toward moving primitive A, as expected by the solver. + pub normal: Vector3, + /// Native prism order is retained, including coincident points. + pub points: [ContactPair; 16], + pub count: usize, +} + +/// Full moving sphere/capsule/triangle/rounded-box query against a world +/// triangle. The caller retains native world-triangle then moving-volume order. +pub fn primitive_triangle_world_contacts( + primitive: ContactPrimitive, + triangle: Triangle, + velocity: Vector3, + settings: WorldContactSettings, +) -> Option { + let limit = world_separation_limit( + velocity, + triangle.feature.normal, + settings.volume_padding, + settings.maximum_separating_distance, + ); + let (a, a_kind) = pack(primitive); + let (b, b_kind) = pack(ContactPrimitive::Triangle(triangle)); + let (separation, normal) = if matches!(a_kind, PrimitiveKind::Box) { + // Reversed specialized entry82ACE968 calls triangle/box then flips + // every sign bit. Native pair direction always points A toward B. + let (separation, normal) = triangle_box_sat::triangle_box(&b, &a); + (separation, normal.map(|v| v ^ 0x8000_0000)) + } else { + best_separating_direction(&a, a_kind, &b, b_kind) + }; + contact_points( + &a, + a_kind, + &b, + b_kind, + triangle, + f32::from_bits(separation[0]), + normal, + limit, + settings, + ) +} + +fn contact_points( + a: &[u32; 48], + a_kind: PrimitiveKind, + b: &[u32; 48], + b_kind: PrimitiveKind, + triangle: Triangle, + separation: f32, + initial_normal: [u32; 4], + limit: f32, + settings: WorldContactSettings, +) -> Option { + let fat_a = f32::from_bits(a[32]); + let fat_b = f32::from_bits(b[32]); + if separation > (fat_b + limit) + fat_a { + return None; + } + let mut feature_a = maximum(a, a_kind, 1, initial_normal); + let mut feature_b = maximum(b, b_kind, 0, initial_normal.map(|v| v ^ 0x8000_0000)); + let mut prism = [0; 136]; + if find_feature_intersection_prism(&mut prism, &mut feature_a, &mut feature_b, initial_normal) + == 0 + { + return None; + } + let count = prism[132] as usize; + let mut normal = initial_normal.map(f32::from_bits); + if count == 1 { + let delta = sub(load(&prism, 64), load(&prism, 0)); + let squared = dot(delta, delta); + let inverse = inverse_length(squared); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + // This world-query gate compares length. The general Volume query + // has a different squared-distance gate and publication sequence. + if length > f32::from_bits(0x3400_0000) { + prism[128..132].copy_from_slice(&scale(delta, inverse).map(f32::to_bits)); + prism[133] = 1; + } + } + if prism[133] != 0 { + let direction: [u32; 4] = prism[128..132].try_into().unwrap(); + let mut ai = [0; 12]; + let mut bi = [0; 12]; + project_direction(a, a_kind, direction, &mut ai); + project_direction(b, b_kind, direction, &mut bi); + let forward = f32::from_bits(ai[0]) - f32::from_bits(bi[4]); + let reverse = f32::from_bits(bi[0]) - f32::from_bits(ai[4]); + let flip = forward > reverse; + let refined = if flip { forward } else { reverse }; + if refined >= separation { + normal = direction.map(|v| f32::from_bits(if flip { v ^ 0x8000_0000 } else { v })); + } + } + let mut points = [ContactPair { + a: Vector3::ZERO, + b: Vector3::ZERO, + }; 16]; + for (i, point) in points[..count].iter_mut().enumerate() { + point.a = xyz(load(&prism, 4 * i)); + point.b = xyz(load(&prism, 64 + 4 * i)); + } + let mut pair_normal = xyz(normal); + if !fix_up_triangle( + triangle.feature, + &mut pair_normal, + &mut points[..count], + TriangleFixup { + reverse: true, + edge_cos_bend_normal_threshold: settings.edge_cos_bend_normal_threshold, + convexity_epsilon: settings.convexity_epsilon, + is_object: settings.is_object, + }, + ) { + return None; + } + let normal = lanes(pair_normal, 0.0); + let a_offset = scale(normal, fat_a); + let b_offset = scale(normal, fat_b); + for point in &mut points[..count] { + // Separate multiply and add/subtract in8277BA50..BA60, after fixup. + let a = lanes(point.a, 1.0); + point.a = xyz(std::array::from_fn(|i| a[i] + a_offset[i])); + point.b = xyz(sub(lanes(point.b, 1.0), b_offset)); + } + Some(PrimitiveContactManifold { + normal: xyz(normal.map(|v| -v)), + points, + count, + }) +} + +pub(super) fn maximum(gp: &[u32; 48], kind: PrimitiveKind, mode: u32, direction: [u32; 4]) -> MaximumFeature { + let mut feature = [0; 144]; + match kind { + PrimitiveKind::Sphere => { + // Complete sphere callback82ADD7E0: center, point type, header0. + feature[136..140].copy_from_slice(&gp[..4]); + feature[140] = 0; + feature[0] = 0; + } + PrimitiveKind::Capsule => { + capsule_maximum_feature(gp, direction, &mut feature, &mut [0; 16]) + } + PrimitiveKind::Triangle => triangle_maximum_feature(gp, mode, direction, &mut feature), + PrimitiveKind::Box => box_maximum_feature(gp, mode, direction, &mut feature, [0; 4]), + } + feature +} diff --git a/crates/skate-core/src/physics/world_contact/prism_math.rs b/crates/skate-core/src/physics/world_contact/prism_math.rs new file mode 100644 index 00000000..1f6c7ed3 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/prism_math.rs @@ -0,0 +1,57 @@ +use crate::physics::reciprocal_sqrt::estimate; + +pub(super) type V = [f32; 4]; + +pub(super) fn load(words: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} + +pub(super) fn dot(a: V, b: V) -> f32 { + super::arithmetic::dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} + +pub(super) 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 { + a.map(|v| v * s) +} + +pub(super) fn madd(a: V, s: f32, b: V) -> V { + std::array::from_fn(|i| a[i].mul_add(s, b[i])) +} + +pub(super) fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} + +pub(super) fn normalized(a: V) -> V { + let squared = dot(a, a); + scale(a, inverse_length(squared)) +} + +pub(super) fn inverse_length(squared: f32) -> f32 { + let mut r = estimate(squared); + for _ in 0..2 { + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + } + r +} + +pub(super) fn reciprocal(value: f32) -> f32 { + let mut r = reciprocal_estimate(value); + for _ in 0..2 { + r = r.mul_add((-r).mul_add(value, 1.0), r); + } + r +} + +pub(super) fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} diff --git a/crates/skate-core/src/physics/world_contact/projection.rs b/crates/skate-core/src/physics/world_contact/projection.rs new file mode 100644 index 00000000..0721702b --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/projection.rs @@ -0,0 +1,128 @@ +use super::arithmetic::{dot, scale, vector}; +use super::separating_axis_candidates; + +/// Verified GP dispatch IDs from mapped TU3 table 82FD5870. Cylinder (5) and +/// invalid (0) are outside the board primitive set and have no fallback here. +#[derive(Clone, Copy, Debug)] +pub enum PrimitiveKind { + Sphere, + Capsule, + Triangle, + Box, +} + +/// Full single-direction GP interval callbacks: sphere82ADD800, +/// capsule82AD99C8, triangle82ADE3B8, box82AD8508. Output is the native48-byte +/// interval; the last16bytes are untouched. Radius/padding is handled by caller. +pub fn project_direction( + gp: &[u32; 48], + kind: PrimitiveKind, + direction: [u32; 4], + output: &mut [u32; 12], +) { + project(gp, kind, direction, output, false); +} + +/// Full batch GP callbacks: sphere82ADD820, capsule82AD9A88, +/// triangle82ADE400, box82AD86B0. Box batch scales the axes *before* dot products; +/// its single callback scales the dot products instead. Preserve that difference. +pub fn project_directions( + gp: &[u32; 48], + kind: PrimitiveKind, + directions: &[[u32; 4]], + output: &mut [[u32; 12]], +) { + assert!(output.len() >= directions.len()); + for (&direction, interval) in directions.iter().zip(output) { + project(gp, kind, direction, interval, true); + } +} + +fn project( + gp: &[u32; 48], + kind: PrimitiveKind, + direction: [u32; 4], + output: &mut [u32; 12], + batch: bool, +) { + let direction = vector(&direction, 0); + let center = dot(vector(gp, 0), direction); + let (min, max) = match kind { + PrimitiveKind::Sphere => (center, center), + PrimitiveKind::Capsule => { + let radius = dot(vector(gp, 16), direction).abs() * f32::from_bits(gp[28]); + (center - radius, center + radius) + } + PrimitiveKind::Triangle => { + let second = dot(vector(gp, 8), direction); + let third = dot(vector(gp, 12), direction); + // VMX min/max select the second operand on unordered or equal. + let min = if center < second { center } else { second }; + let max = if center > second { center } else { second }; + ( + if min < third { min } else { third }, + if max > third { max } else { third }, + ) + } + PrimitiveKind::Box => { + let extent: [f32; 3] = std::array::from_fn(|i| { + let axis = vector(gp, 4 + i * 4); + let half = f32::from_bits(gp[28 + i]); + if batch { + dot(scale(axis, half), direction).abs() + } else { + (dot(axis, direction) * half).abs() + } + }); + let radius = (extent[0] + extent[1]) + extent[2]; + (center - radius, center + radius) + } + }; + output[..4].fill(min.to_bits()); + output[4..8].fill(max.to_bits()); +} + +/// Complete generic82ACF070 plus recovered GP batch callbacks for board shapes. +/// Specialized triangle/box82ACF950 and box/box82ACF448 dispatch entries must +/// still be selected by the caller; this function is only the generic entry. +/// Returns (broadcast separation, oriented four-lane axis). +pub fn best_separating_direction( + a: &[u32; 48], + a_kind: PrimitiveKind, + b: &[u32; 48], + b_kind: PrimitiveKind, +) -> ([u32; 4], [u32; 4]) { + let mut directions = [[0; 4]; 16]; + let mut count = separating_axis_candidates(a, b, &mut directions); + if count == 0 { + directions[0] = [0, 0, 1.0f32.to_bits(), 0]; + count = 1; + } + let mut a_intervals = [[0; 12]; 16]; + let mut b_intervals = [[0; 12]; 16]; + project_directions(a, a_kind, &directions[..count], &mut a_intervals); + project_directions(b, b_kind, &directions[..count], &mut b_intervals); + let mut selected = 0; + let mut best = 0.0; + let mut flip = false; + for index in 0..count { + let forward = f32::from_bits(a_intervals[index][0]) - f32::from_bits(b_intervals[index][4]); + let reverse = f32::from_bits(b_intervals[index][0]) - f32::from_bits(a_intervals[index][4]); + // fcmpu followed by ble (!gt): unordered selects reverse without flip. + let (separation, reversed) = if forward > reverse { + (forward, true) + } else { + (reverse, false) + }; + if index == 0 || separation > best { + best = separation; + selected = index; + flip = reversed; + } + } + let mut normal = directions[selected]; + if flip { + normal.iter_mut().for_each(|v| *v ^= 0x8000_0000); + } + ([best.to_bits(); 4], normal) +} diff --git a/crates/skate-core/src/physics/world_contact/reduction.rs b/crates/skate-core/src/physics/world_contact/reduction.rs new file mode 100644 index 00000000..cd007203 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/reduction.rs @@ -0,0 +1,152 @@ +use super::ContactRecord; +use super::arithmetic::{V, cross, dot, scale, sub, vector}; + +/// Complete geometric predicate 8277A208. Body IDs are checked by its caller. +/// The native comparison uses the *smaller* absolute plane displacement. +pub fn coplanar_contacts(a: &ContactRecord, b: &ContactRecord) -> bool { + let normal = vector(a, 8); + if f32::from_bits(0x3f7f_be77) > dot(normal, vector(b, 8)) { + return false; + } + let da = dot(normal, sub(vector(a, 0), vector(b, 0))).abs(); + let db = dot(normal, sub(vector(a, 4), vector(b, 4))).abs(); + let selected = if da - db >= 0.0 { db } else { da }; + selected < f32::from_bits(0x3c23_d70a) +} + +/// Full 827798F8 point selection. Inputs are paired world-space positions; +/// output slots beyond the returned count retain the native write behavior. +/// The caller provides at least one pair, as the native function requires. +pub fn select_contact_points(a: &[V], b: &[V], normal: V, output: &mut [u32; 4]) -> usize { + assert!(!a.is_empty() && a.len() == b.len()); + let mut first = 0; + let mut origin = a[0]; + let mut depth = dot(normal, sub(a[0], b[0])); + for i in 1..a.len() { + let candidate = dot(normal, sub(a[i], b[i])); + if depth > candidate { + depth = candidate; + first = i; + origin = a[i]; + } + } + let mut third_delta = sub(a[0], origin); + let mut far_delta = third_delta; + let mut far = 0; + let mut distance = dot(far_delta, far_delta); + for (i, &point) in a.iter().enumerate().skip(1) { + let delta = sub(point, origin); + let candidate = dot(delta, delta); + if candidate > distance { + distance = candidate; + far = i; + far_delta = delta; + } + } + if first == far { + output[0] = first as u32; + return 1; + } + let mut third = 0; + let initial_area = cross(third_delta, far_delta); + let mut area = dot(initial_area, initial_area); + for (i, &point) in a.iter().enumerate().skip(1) { + let delta = sub(point, origin); + let area_vector = cross(delta, far_delta); + let candidate = dot(area_vector, area_vector); + if candidate > area { + area = candidate; + third = i; + third_delta = delta; + } + } + output[..3].copy_from_slice(&[first as u32, far as u32, third as u32]); + if third == first || third == far { + return 2; + } + let side = cross(normal, far_delta); + let sign = if dot(side, third_delta) >= 0.0 { + 1.0 + } else { + -1.0 + }; + let far_side = scale(side, sign); + let third_side = scale(cross(normal, third_delta), sign); + let edge = sub(third_delta, far_delta); + let mut fourth = 9999; + let mut best = -1.0; + for (i, &point) in a.iter().enumerate() { + let delta = sub(point, origin); + let selected = if dot(delta, far_side) > 0.0 { + if dot(delta, third_side) > 0.0 { + cross(delta, third_delta) + } else { + cross(sub(delta, far_delta), edge) + } + } else { + cross(delta, far_delta) + }; + let candidate = dot(selected, selected); + if candidate > best { + best = candidate; + fourth = i; + } + } + output[3] = fourth as u32; + 3 + usize::from(fourth != first && fourth != far && fourth != third && fourth != 9999) +} + +/// Complete 82779EB0 grouping/reduction. Retained contacts are restored to +/// original order; discarded tail records are not cleared. No drop count change. +pub(super) fn reduce(records: &mut [ContactRecord; 50], count: &mut u32) { + let original = *count as usize; + assert!(original <= 50); + let mut assigned = [false; 50]; + let mut retained = Vec::with_capacity(original); + for first in 0..original { + if assigned[first] { + continue; + } + assigned[first] = true; + let mut group = vec![first]; + for next in first + 1..original { + if !assigned[next] + && records[first][3] == records[next][3] + && records[first][7] == records[next][7] + && coplanar_contacts(&records[first], &records[next]) + { + assigned[next] = true; + group.push(next); + } + } + if group.len() <= 4 { + retained.extend(group); + } else { + let a: Vec<_> = group.iter().map(|&i| vector(&records[i], 0)).collect(); + let b: Vec<_> = group.iter().map(|&i| vector(&records[i], 4)).collect(); + let mut selected = [0, 1, 2, 3]; + let length = select_contact_points(&a, &b, vector(&records[first], 8), &mut selected); + retained.extend(selected[..length].iter().map(|&i| group[i as usize])); + } + } + if retained.len() < original { + retained.sort_unstable(); + *count = retained.len() as u32; + for (destination, source) in retained.into_iter().enumerate() { + if destination != source { + // Native 8277A300 loads/stores float fields individually. Its + // f32→f64→f32 copy quiets sNaNs; IDs/tag remain integer stores. + records[destination] = records[source].map(|bits| { + if bits & 0x7f80_0000 == 0x7f80_0000 && bits & 0x007f_ffff != 0 { + bits | 0x0040_0000 + } else { + bits + } + }); + for i in [3, 7, 23] { + records[destination][i] = records[source][i]; + } + } + } + } +} diff --git a/crates/skate-core/src/physics/world_contact/segment_face.rs b/crates/skate-core/src/physics/world_contact/segment_face.rs new file mode 100644 index 00000000..86bbab74 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/segment_face.rs @@ -0,0 +1,92 @@ +use super::{ + FeaturePrism, MaximumFeature, clamp_point_to_feature, clip_segment_to_feature, + closest_feature_segment, prism_math::*, +}; + +fn store(output: &mut FeaturePrism, offset: usize, point: V) { + output[offset..offset + 4].copy_from_slice(&point.map(f32::to_bits)); +} + +/// Complete TU3 82ACC5F0 segment/face prism and its interval/edge-clamp callees. +/// Keeps the normal correction flag, feature tags and both output-pointer orders. +pub fn intersect_segment_face( + output: &mut FeaturePrism, + face: &mut MaximumFeature, + segment: &mut MaximumFeature, + normal_bits: [u32; 4], + face_is_a: bool, +) -> u32 { + let (fo, so) = if face_is_a { (0, 64) } else { (64, 0) }; + let mut interval = [0; 2]; + // The native caller explicitly zeroes both scratch vectors before clipping. + let mut outside = [0; 8]; + let inside = clip_segment_to_feature(face, segment, normal_bits, &mut interval, &mut outside); + let normal = normal_bits.map(f32::from_bits); + let origin = load(segment, 4); + let direction = load(segment, 8); + let face_origin = load(face, 4); + let low = f32::from_bits(interval[0]); + let high = f32::from_bits(interval[1]); + if inside != 0 { + output[132] = 2; + let face_normal = load(face, 132); + for (i, distance) in [low, high].into_iter().enumerate() { + let p = madd(direction, distance, origin); + store(output, so + i * 4, p); + let t = dot(sub(face_origin, p), face_normal) / dot(normal, face_normal); + store(output, fo + i * 4, madd(normal, t, p)); + } + return 1; + } + let span = high - low; + if span > f32::from_bits(0x3a83_126f) { + output[132] = 2; + let outside_origin = load(&outside, 0); + let outside_plane = load(&outside, 4); + let projected = madd(normal, dot(normal, sub(face_origin, origin)), origin); + let projected = madd( + outside_plane, + dot(outside_plane, sub(outside_origin, projected)), + projected, + ); + let first = madd(direction, low, projected); + let second = madd(direction, span, first); + let mut first = first.map(f32::to_bits); + let mut second = second.map(f32::to_bits); + let first_code = clamp_point_to_feature(face, normal_bits, &mut first); + let second_code = clamp_point_to_feature(face, normal_bits, &mut second); + face[0] = face[0].wrapping_add(first_code.max(second_code) & !1); + output[fo..fo + 4].copy_from_slice(&first); + output[fo + 4..fo + 8].copy_from_slice(&second); + closest_feature_segment(segment[4..20].try_into().unwrap(), &mut first); + closest_feature_segment(segment[4..20].try_into().unwrap(), &mut second); + output[so..so + 4].copy_from_slice(&first); + output[so + 4..so + 8].copy_from_slice(&second); + let delta = sub(load(output, fo), first.map(f32::from_bits)); + let squared = dot(delta, delta); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse_length(squared) + }; + if length > f32::MIN_POSITIVE { + output[133] = 1; + store( + output, + 128, + normalized(cross(direction, cross(delta, direction))), + ); + } + } else { + output[132] = 1; + let p = madd(direction, low, origin); + let mut p = madd(normal, dot(normal, sub(face_origin, p)), p).map(f32::to_bits); + let code = clamp_point_to_feature(face, normal_bits, &mut p); + face[0] = face[0].wrapping_add(code); + output[fo..fo + 4].copy_from_slice(&p); + let region = closest_feature_segment(segment[4..20].try_into().unwrap(), &mut p); + segment[0] = segment[0].wrapping_sub(region).wrapping_add(2); + output[so..so + 4].copy_from_slice(&p); + } + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/segment_prism.rs b/crates/skate-core/src/physics/world_contact/segment_prism.rs new file mode 100644 index 00000000..3a2eafd4 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/segment_prism.rs @@ -0,0 +1,104 @@ +use super::{FeaturePrism, MaximumFeature, closest_feature_segment, prism_math::*}; + +fn store(output: &mut FeaturePrism, offset: usize, point: V) { + output[offset..offset + 4].copy_from_slice(&point.map(f32::to_bits)); +} + +fn endpoint(feature: &mut MaximumFeature, start: bool) -> V { + feature[0] = if start { + feature[0].wrapping_sub(1) + } else { + feature[0].wrapping_add(1) + }; + let origin = load(feature, 4); + if start { + origin + } else { + let direction = load(feature, 8); + let length = load(feature, 16); + std::array::from_fn(|i| direction[i].mul_add(length[i], origin[i])) + } +} + +/// Complete TU3 82ACBAD0 segment/segment prism, including the parallel overlap +/// and disjoint endpoint branches. Normal/flags and untouched point slots survive. +/// `a_is_first` represents the helper's two independently supplied output pointers. +pub fn intersect_feature_segments( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, + normal: [u32; 4], + a_is_first: bool, +) -> u32 { + let (ao, bo) = if a_is_first { (0, 64) } else { (64, 0) }; + let ad = load(a, 8); + let bd = load(b, 8); + let start_a = load(a, 4); + let start_b = load(b, 4); + let perpendicular = cross(ad, bd); + if dot(perpendicular, perpendicular) > f32::from_bits(0x3400_0000) { + let plane = cross(normalized(perpendicular), bd); + let mut along = dot(sub(start_b, start_a), plane) / dot(ad, plane); + if along < 0.0 { + along = 0.0; + } + let length = load(a, 16); + if length.iter().all(|&v| along > v) { + along = length[0]; + } + output[132] = 1; + let mut point = madd(ad, along, start_a).map(f32::to_bits); + let region = closest_feature_segment(b[4..20].try_into().unwrap(), &mut point); + b[0] = b[0].wrapping_sub(region).wrapping_add(2); + output[bo..bo + 4].copy_from_slice(&point); + let region = closest_feature_segment(a[4..20].try_into().unwrap(), &mut point); + a[0] = a[0].wrapping_sub(region).wrapping_add(2); + output[ao..ao + 4].copy_from_slice(&point); + return 1; + } + let normal = normal.map(f32::from_bits); + let axis = if dot(normal, ad).abs() < dot(normal, bd).abs() { + ad + } else { + bd + }; + let al = load(a, 16); + let bl = load(b, 16); + let end_a = std::array::from_fn(|i| ad[i].mul_add(al[i], start_a[i])); + let end_b = std::array::from_fn(|i| bd[i].mul_add(bl[i], start_b[i])); + let a0 = dot(axis, start_a); + let b0 = dot(axis, start_b); + let a1 = dot(axis, end_a); + let b1 = dot(axis, end_b); + // Native compare/select ordering also specifies unordered/equal operands. + let (amin, amax) = if a1 > a0 { (a0, a1) } else { (a1, a0) }; + let (bmin, bmax) = if b1 > b0 { (b0, b1) } else { (b1, b0) }; + let low = if amin > bmin { amin } else { bmin }; + let high = if bmax > amax { amax } else { bmax }; + if high > low { + let ap = normalized(cross(cross(normal, ad), ad)); + let bp = normalized(cross(cross(normal, bd), bd)); + for (index, along) in [low, high].into_iter().enumerate() { + let anchor = scale(axis, along); + for (offset, origin, direction, plane) in [(ao, start_a, ad, ap), (bo, start_b, bd, bp)] + { + let inverse = reciprocal(dot(normal, plane)); + let distance = inverse * dot(sub(origin, anchor), plane); + let projected = madd(normal, distance, anchor); + let projected = madd(direction, dot(sub(projected, origin), direction), origin); + store(output, offset + index * 4, projected); + } + } + output[132] = 2; + } else { + output[132] = 1; + let (a_start, b_start) = if bmin > amin { + (a0 > a1, b0 < b1) + } else { + (a0 < a1, b0 > b1) + }; + store(output, ao, endpoint(a, a_start)); + store(output, bo, endpoint(b, b_start)); + } + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/triangle_box_sat.rs b/crates/skate-core/src/physics/world_contact/triangle_box_sat.rs new file mode 100644 index 00000000..97838a97 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/triangle_box_sat.rs @@ -0,0 +1,67 @@ +use crate::physics::{reciprocal_sqrt::estimate, world_contact::prism_math::*}; + +/// TU3 82ACF950. Triangle normal first, then box Z/Y/X and all nine edge +/// crosses. Cross axes receive exactly one reciprocal-square-root refinement; +/// there is no small-cross rejection or normalization fallback in this entry. +pub(in super::super) fn triangle_box(triangle: &[u32; 48], box_shape: &[u32; 48]) -> ([u32; 4], [u32; 4]) { + let mut axes = [[0.0; 4]; 13]; + axes[0] = load(triangle, 4); + for i in 0..3 { + axes[1 + i] = load(box_shape, 12 - i * 4); + } + for edge in 0..3 { + for box_edge in 0..3 { + let axis = cross( + load(triangle, 16 + edge * 4), + load(box_shape, 16 + box_edge * 4), + ); + let squared = dot(axis, axis); + let seed = estimate(squared); + let inverse = (seed * 0.5).mul_add((-squared).mul_add(seed * seed, 1.0), seed); + axes[4 + edge * 3 + box_edge] = scale(axis, inverse); + } + } + let scaled_box: [V; 3] = std::array::from_fn(|i| { + scale( + load(box_shape, 4 + i * 4), + f32::from_bits(box_shape[28 + i]), + ) + }); + let mut best = 0.0; + let mut selected = axes[0]; + let mut selected_sign = 1.0; + for (index, axis) in axes.into_iter().enumerate() { + let p0 = dot(load(triangle, 0), axis); + let p1 = dot(load(triangle, 8), axis); + let p2 = dot(load(triangle, 12), axis); + // The source compares vertices 1 and 2, then combines vertex 0. + let minimum = minimum(p0, minimum(p1, p2)); + let maximum = maximum(p0, maximum(p1, p2)); + let center = dot(load(box_shape, 0), axis); + let extents = scaled_box.map(|extent| dot(extent, axis).abs()); + let radius = (extents[0] + extents[1]) + extents[2]; + let forward = minimum - (center + radius); + let reverse = (center - radius) - maximum; + let (separation, sign) = if forward > reverse { + (forward, -1.0) + } else { + (reverse, 1.0) + }; + if index == 0 || separation > best { + best = separation; + selected = axis; + selected_sign = sign; + } + } + ( + [best.to_bits(); 4], + scale(selected, selected_sign).map(f32::to_bits), + ) +} + +fn minimum(a: f32, b: f32) -> f32 { + if a < b { a } else { b } +} +fn maximum(a: f32, b: f32) -> f32 { + if a > b { a } else { b } +} diff --git a/crates/skate-core/src/physics/world_contact/triangle_feature.rs b/crates/skate-core/src/physics/world_contact/triangle_feature.rs new file mode 100644 index 00000000..81aaeaa5 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/triangle_feature.rs @@ -0,0 +1,134 @@ +use super::MaximumFeature; +use crate::physics::reciprocal_sqrt::estimate; + +type V = [f32; 4]; + +/// Full triangle maximum-feature callback82ADDD68, including near-tangent +/// edge/vertex branches. Header word0 is preserved outside the face branch. +pub fn triangle_maximum_feature( + gp: &[u32; 48], + mode: u32, + direction: [u32; 4], + out: &mut MaximumFeature, +) { + let normal = load(gp, 4); + let query = direction.map(f32::from_bits); + let projected = dot(query, normal); + let points = [load(gp, 0), load(gp, 8), load(gp, 12)]; + let edges = [load(gp, 16), load(gp, 20), load(gp, 24)]; + if projected.abs() > f32::from_bits(0x3f73_3333) { + if u32::from(projected < 0.0) == mode { + for (i, point) in [0, 2, 1].into_iter().enumerate() { + segment(out, i, points[point], edges[i], gp[28 + i]); + } + out[0] = 8; + } else { + for (i, point) in [0, 1, 2].into_iter().enumerate() { + segment(out, i, points[point], edges[2 - i].map(|x| -x), gp[30 - i]); + } + out[0] = 0; + } + store(out, 132, normal); + out[140] = 3; + } else { + let p = points.map(|x| dot(query, x)); + let e = edges.map(|x| dot(query, x).abs()); + let threshold = f32::from_bits(0x3d4c_cccd); + let (point, edge) = if p[0] > p[1] && p[0] > p[2] { + ( + 0, + if threshold > e[0] && e[2] > e[0] { + Some(0) + } else if threshold > e[2] { + Some(2) + } else { + None + }, + ) + } else if p[1] > p[2] { + ( + 1, + if threshold > e[1] && e[2] > e[1] { + Some(1) + } else if threshold > e[2] { + Some(2) + } else { + None + }, + ) + } else { + ( + 2, + if threshold > e[0] && e[1] > e[0] { + Some(0) + } else if threshold > e[1] { + Some(1) + } else { + None + }, + ) + }; + if let Some(edge) = edge { + segment(out, 0, points[[0, 2, 1][edge]], edges[edge], gp[28 + edge]); + out[140] = 1; + } else { + store(out, 136, points[point]); + out[140] = 0; + } + } + build_feature_edge_planes(out, mode, direction); +} + +/// Complete82AC6F88. Only edge plane vectors are changed. Mode0 negates the +/// incoming direction before crossing, preserving the native arithmetic order. +pub fn build_feature_edge_planes(feature: &mut MaximumFeature, mode: u32, direction: [u32; 4]) { + let count = feature[140] as i32; + if count <= 0 { + return; + } + assert!( + count <= 8, + "native feature storage holds at most eight segments" + ); + let normal = direction + .map(f32::from_bits) + .map(|x| if mode == 0 { -x } else { x }); + for i in 0..count as usize { + let offset = 4 + i * 16; + let edge = load(feature, offset + 4); + let plane = cross(edge, normal); + let squared = dot(plane, plane); + let mut r = estimate(squared); + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + let scale = if squared > f32::from_bits(0x3400_0000) { + r + } else { + 0.0 + }; + store(feature, offset + 8, plane.map(|x| x * scale)); + } +} + +fn segment(out: &mut MaximumFeature, index: usize, point: V, edge: V, length: u32) { + let offset = 4 + index * 16; + store(out, offset, point); + store(out, offset + 4, edge); + out[offset + 12..offset + 16].fill(length); +} +fn load(words: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} +fn store(words: &mut [u32], offset: usize, v: V) { + words[offset..offset + 4].copy_from_slice(&v.map(f32::to_bits)); +} +fn dot(a: V, b: V) -> f32 { + super::arithmetic::dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} diff --git a/crates/skate-core/src/player/adjust.rs b/crates/skate-core/src/player/adjust.rs new file mode 100644 index 00000000..e3337c3b --- /dev/null +++ b/crates/skate-core/src/player/adjust.rs @@ -0,0 +1,65 @@ +//! TU3 `PhysicalPlayerHiLOD::Adjust` (`0x82DB6CF0`). +//! +//! This phase runs only after the solver completion fence. It publishes the +//! solved board condition, performs the first skeleton adjustment, calls the +//! current state's post-physics slot, then performs the final skeleton update. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AdjustFields { + /// ProcessedPhysIn+2512. + pub processed_word_2512: u32, + /// ProcessedPhysIn+2604. + pub timestep_2604: f32, + /// SkateboardController+448. + pub controller_state_448: u32, + /// Player+1876 output staging +42. + pub output_flag_42: bool, + /// Player+1876 output staging +144. + pub output_scalar_144: f32, + /// Player+1876 output staging +200. + pub output_counter_200: u32, +} + +/// Required object calls in the exact order used by `0x82DB6CF0`. +pub trait AdjustServices { + /// Player vtable `+116`, then SkateboardBody `0x82C02138`. + fn board_condition_82c02138(&mut self) -> u32; + + /// BoardBody vtable `+4`, byte `+868`. Called only in controller mode 1. + fn controller_mode_one_board_flag_868(&mut self) -> bool; + + /// Skeleton update `0x82BD80D8` with the board flag and frame timestep. + fn adjust_skeleton_82bd80d8(&mut self, board_flag: bool, timestep: f32); + + /// Current physical-state vtable slot `+40`. + fn update_current_state_post_physics_vtable_40(&mut self); + + /// Player helper `0x82DB9100`; its exact result is forwarded unchanged. + fn player_output_selector_82db9100(&mut self) -> u32; + + /// Final skeleton update `0x82BD83E0`. + fn finish_skeleton_adjustment_82bd83e0(&mut self, selector: u32); +} + +/// Runs the complete TU3 Adjust wrapper after solver completion. +pub fn run_adjust(fields: &mut AdjustFields, services: &mut impl AdjustServices) { + if services.board_condition_82c02138() == 1 && fields.processed_word_2512 != 500 { + fields.output_flag_42 = true; + fields.output_scalar_144 = 0.0; + fields.output_counter_200 = fields.output_counter_200.wrapping_add(1); + } + + let board_flag = if fields.controller_state_448 == 1 { + services.controller_mode_one_board_flag_868() + } else { + false + }; + services.adjust_skeleton_82bd80d8(board_flag, fields.timestep_2604); + services.update_current_state_post_physics_vtable_40(); + let selector = services.player_output_selector_82db9100(); + services.finish_skeleton_adjustment_82bd83e0(selector); +} + +#[cfg(test)] +#[path = "tests/adjust.rs"] +mod tests; diff --git a/crates/skate-core/src/player/conditioner_capabilities.rs b/crates/skate-core/src/player/conditioner_capabilities.rs new file mode 100644 index 00000000..6b7f49ed --- /dev/null +++ b/crates/skate-core/src/player/conditioner_capabilities.rs @@ -0,0 +1,40 @@ +//! Conditioner capability publication from original Skate 3 TU3 0x8275F430. +//! +//! World writes ExternalInput+2780, then 0x82DA7888 copies that word to +//! PhysOutScoring2+204. The word gates controller and camera behavior; it is +//! not a count or mask of currently broken bones. + +/// Host settings needed by the original whole-word capability calculation. +/// There is deliberately no implicit default for these gameplay choices. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConditionerCapabilityContext { + /// Original FEBackEnd state equals 2 (in front end). This is a menu gate. + pub in_front_end: bool, + /// Original game settings +184 equals 1. UI option 29 is Hall of Meat mode. + pub hall_of_meat_enabled: bool, + /// Original challenge subsystem +328 virtual method at vtable+12 is nonzero. + /// Its precise challenge interface name remains unidentified. A host with + /// no challenge system supplies false rather than inventing that system. + pub challenge_query_active: bool, + /// Original challenge subsystem +512 points to a record whose byte 44 is + /// nonzero. Only consulted when the challenge query above is active. + pub challenge_configuration_enabled: bool, +} + +impl ConditionerCapabilityContext { + /// Preserve all bits and branch priority from 0x8275F4D8..0x8275F558. + pub fn capabilities(self) -> u32 { + if self.in_front_end { + 0 + } else if self.challenge_query_active && self.challenge_configuration_enabled { + 0xffff_fff7 + } else if self.hall_of_meat_enabled && !self.challenge_query_active { + 0xffff_f7f7 + } else { + 0x0000_01c0 + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/conditioner_capabilities/tests.rs b/crates/skate-core/src/player/conditioner_capabilities/tests.rs new file mode 100644 index 00000000..abc05f55 --- /dev/null +++ b/crates/skate-core/src/player/conditioner_capabilities/tests.rs @@ -0,0 +1,35 @@ +use super::ConditionerCapabilityContext; + +#[test] +fn complete_source_capability_branch_table() { + // Literal results from 8275F4D8..8275F558, indexed as FE/HoM/query/config. + // In particular, active challenge configuration overrides the HoM setting; + // an inactive query does not consult that configuration. FE wins over both. + let source_words = [ + 0x0000_01c0, + 0x0000_01c0, + 0x0000_01c0, + 0xffff_fff7, + 0xffff_f7f7, + 0xffff_f7f7, + 0x0000_01c0, + 0xffff_fff7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ]; + for (combination, expected) in source_words.into_iter().enumerate() { + let context = ConditionerCapabilityContext { + in_front_end: combination & 8 != 0, + hall_of_meat_enabled: combination & 4 != 0, + challenge_query_active: combination & 2 != 0, + challenge_configuration_enabled: combination & 1 != 0, + }; + assert_eq!(context.capabilities(), expected, "{context:?}"); + } +} diff --git a/crates/skate-core/src/player/frame.rs b/crates/skate-core/src/player/frame.rs new file mode 100644 index 00000000..53630c52 --- /dev/null +++ b/crates/skate-core/src/player/frame.rs @@ -0,0 +1,225 @@ +//! Stock frame order recovered from TU3 `0x82859E70`, `0x8275EB10` and +//! `0x8275ED68`. The solver boundary intentionally splits the frame because +//! the native game submits jobs before returning for the post-solver pass. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameStage { + StartFrame, + WorldPreFrame, + AnimationPhaseOne, + AnimationPhaseTwo, + WorldPrepare, + AnimationPhaseThree, + WorldPrecursor, + ActorPhysicsPublication, + PostZeroJobs, + Input, + WorldAfterInput, + PostInput, + Logic, + PreState, + State, + PostState, + CollectSolverInputs, + PrepareSolverJobs, + SubmitSolver, + WaitSolver, + Adjust, + PostAdjust, + Output, + PostOutput, + FinishOutputs, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SchedulerState { + Ready, + Running, + SolverPending, + Faulted, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ScheduleError { + WrongState { + expected: SchedulerState, + actual: SchedulerState, + }, + Runtime { + stage: FrameStage, + source: E, + }, +} + +pub trait AnimationPhases { + type Error; + + fn phase_one(&mut self) -> Result<(), Self::Error>; + fn phase_two(&mut self) -> Result<(), Self::Error>; + fn phase_three(&mut self) -> Result<(), Self::Error>; + fn publish_physics_input(&mut self) -> Result<(), Self::Error>; +} + +pub trait WorldPhases { + type Error; + + /// `82DC2FB8`; writes the shared timestep and its reciprocal and clears the + /// current job/island counters. + fn start_frame(&mut self, timestep: f32) -> Result<(), Self::Error>; + fn pre_frame(&mut self) -> Result<(), Self::Error>; + fn prepare(&mut self) -> Result<(), Self::Error>; + fn precursor(&mut self) -> Result<(), Self::Error>; + /// World-owned work at `8275E6E8`, between all Input and Post-Input calls. + fn after_input(&mut self) -> Result<(), Self::Error>; + /// `8275E928`; filtered physicals publish solver-facing work. + fn collect_solver_inputs(&mut self) -> Result<(), Self::Error>; + /// Conditioners, contact reports and final world output after Post 5-Jobs. + fn finish_outputs(&mut self) -> Result<(), Self::Error>; +} + +pub trait PhysicalPhases { + type Error; + + fn post_zero_jobs(&mut self) -> Result<(), Self::Error>; + fn input(&mut self) -> Result<(), Self::Error>; + fn post_input(&mut self) -> Result<(), Self::Error>; + fn logic(&mut self) -> Result<(), Self::Error>; + fn pre_state(&mut self) -> Result<(), Self::Error>; + fn state(&mut self) -> Result<(), Self::Error>; + fn post_state(&mut self) -> Result<(), Self::Error>; + fn adjust(&mut self) -> Result<(), Self::Error>; + fn post_adjust(&mut self) -> Result<(), Self::Error>; + fn output(&mut self) -> Result<(), Self::Error>; + fn post_output(&mut self) -> Result<(), Self::Error>; +} + +pub trait SolverPhases { + type Error; + + /// `82764480` / `82DC35E0`; builds the dependency tree after every state + /// has submitted contacts, joints, drives and external forces. + fn prepare_jobs(&mut self) -> Result<(), Self::Error>; + /// `82763B08`; finalizes collision data and submits the dependency tree. + fn submit(&mut self) -> Result<(), Self::Error>; + /// `82763D18`; the Adjust phase cannot run until this fence completes. + fn wait(&mut self) -> Result<(), Self::Error>; +} + +#[derive(Clone, Copy, Debug)] +pub struct FrameScheduler { + state: SchedulerState, +} + +impl Default for FrameScheduler { + fn default() -> Self { + Self { + state: SchedulerState::Ready, + } + } +} + +impl FrameScheduler { + pub const fn state(&self) -> SchedulerState { + self.state + } + + /// Runs the complete pre-solver half of the stock player frame. Successful + /// return means jobs were submitted and their inputs must remain alive. + pub fn run_before_solver( + &mut self, + timestep: f32, + animation: &mut A, + world: &mut W, + physical: &mut P, + solver: &mut S, + ) -> Result<(), ScheduleError> + where + A: AnimationPhases, + W: WorldPhases, + P: PhysicalPhases, + S: SolverPhases, + { + self.require(SchedulerState::Ready)?; + self.state = SchedulerState::Running; + + self.call(FrameStage::StartFrame, world.start_frame(timestep))?; + self.call(FrameStage::WorldPreFrame, world.pre_frame())?; + self.call(FrameStage::AnimationPhaseOne, animation.phase_one())?; + self.call(FrameStage::AnimationPhaseTwo, animation.phase_two())?; + self.call(FrameStage::WorldPrepare, world.prepare())?; + self.call(FrameStage::AnimationPhaseThree, animation.phase_three())?; + self.call(FrameStage::WorldPrecursor, world.precursor())?; + self.call( + FrameStage::ActorPhysicsPublication, + animation.publish_physics_input(), + )?; + self.call(FrameStage::PostZeroJobs, physical.post_zero_jobs())?; + self.call(FrameStage::Input, physical.input())?; + self.call(FrameStage::WorldAfterInput, world.after_input())?; + self.call(FrameStage::PostInput, physical.post_input())?; + self.call(FrameStage::Logic, physical.logic())?; + self.call(FrameStage::PreState, physical.pre_state())?; + self.call(FrameStage::State, physical.state())?; + self.call(FrameStage::PostState, physical.post_state())?; + self.call( + FrameStage::CollectSolverInputs, + world.collect_solver_inputs(), + )?; + self.call(FrameStage::PrepareSolverJobs, solver.prepare_jobs())?; + self.call(FrameStage::SubmitSolver, solver.submit())?; + self.state = SchedulerState::SolverPending; + Ok(()) + } + + /// Completes the stock post-solver half. The scheduler becomes reusable + /// only after every output/conditioner phase succeeds. + pub fn run_after_solver( + &mut self, + world: &mut W, + physical: &mut P, + solver: &mut S, + ) -> Result<(), ScheduleError> + where + W: WorldPhases, + P: PhysicalPhases, + S: SolverPhases, + { + self.require(SchedulerState::SolverPending)?; + self.state = SchedulerState::Running; + + self.call(FrameStage::WaitSolver, solver.wait())?; + self.call(FrameStage::Adjust, physical.adjust())?; + self.call(FrameStage::PostAdjust, physical.post_adjust())?; + self.call(FrameStage::Output, physical.output())?; + self.call(FrameStage::PostOutput, physical.post_output())?; + self.call(FrameStage::FinishOutputs, world.finish_outputs())?; + self.state = SchedulerState::Ready; + Ok(()) + } + + fn require(&self, expected: SchedulerState) -> Result<(), ScheduleError> { + if self.state == expected { + Ok(()) + } else { + Err(ScheduleError::WrongState { + expected, + actual: self.state, + }) + } + } + + fn call( + &mut self, + stage: FrameStage, + result: Result<(), E>, + ) -> Result<(), ScheduleError> { + result.map_err(|source| { + self.state = SchedulerState::Faulted; + ScheduleError::Runtime { stage, source } + }) + } +} + +#[cfg(test)] +#[path = "tests/frame.rs"] +mod tests; diff --git a/crates/skate-core/src/player/input_phase/air_output.rs b/crates/skate-core/src/player/input_phase/air_output.rs new file mode 100644 index 00000000..0dc33db3 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/air_output.rs @@ -0,0 +1,37 @@ +//! Completed Air publication shared by physical state and graph consumers. +use super::types::RawVector; +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct AirOutputFields { + ///KnownAir Fill82D36880, retained template values outside states that write them. + pub trajectory_apex_0: RawVector, + pub collision_position_16: RawVector, + pub landing_normal_32: RawVector, + pub selector_vector_48: RawVector, + pub trajectory_position_64: RawVector, + pub landing_heading_80: RawVector, + pub selector_com_position_96: RawVector, + pub jump_velocity_delta_112: RawVector, + pub launch_velocity_128: RawVector, + pub time_in_state_176: f32, + pub collision_time_180: f32, + pub collision_normal_speed_188: f32, + pub time_to_apex_196: f32, + pub trajectory_index_220: i32, + pub selected_trajectory_240: [RawVector; 4], + pub trajectory_plane_samples_336: [f32; 25], + pub known_air_valid_437: u8, + pub launched_442: u8, + pub flag_444: u8, + /// Air324 high bits used by IsHandPlanting82BA55A8. + pub handplant_flags_324: u32, + pub vector_160: RawVector, + pub scalar_184: f32, + pub landing_normal_144: RawVector, + pub jump_height_200: f32, + pub reached_apex_436: u8, + pub flag_441: u8, + pub flag_446: u8, + pub flag_447: u8, + pub flag_448: u8, + pub use_air_reckoning_452: u8, +} diff --git a/crates/skate-core/src/player/input_phase/mod.rs b/crates/skate-core/src/player/input_phase/mod.rs new file mode 100644 index 00000000..04ae05b4 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/mod.rs @@ -0,0 +1,21 @@ +//! Skate 3 TU3 `PhysicalPlayerHiLOD::Input` (`0x82DB4048`). +//! +//! The phase is split into direct field publication and an ordered runtime. +//! Separate native subsystems remain mandatory service calls; none have a +//! fallback implementation here. + +mod publication; +mod motion_math; +mod runtime; +mod types; +mod air_output; +mod pose_output; + +pub use runtime::{InputContinuation, InputPhaseError, InputPhaseServices, process_input, start_input, finish_input}; +pub use types::*; +pub use air_output::AirOutputFields; +pub use pose_output::{AnimationOutputFields, ScoringOutputFields, SkeletonOutputFields}; + +#[cfg(test)] +mod tests; + diff --git a/crates/skate-core/src/player/input_phase/motion_math.rs b/crates/skate-core/src/player/input_phase/motion_math.rs new file mode 100644 index 00000000..5ed810cd --- /dev/null +++ b/crates/skate-core/src/player/input_phase/motion_math.rs @@ -0,0 +1,58 @@ +//! Inlined physical input calculations from TU3 82DB4048. +//! Xenon estimates use the shared, explicitly approximation-derived model. +use super::types::{GroundHistoryRequest, GroundHistoryResult, PrepareJumpRequest, RawVector}; +use crate::physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::{dot3, reciprocal_estimate}, +}; + +///82DB5184..51D4: retain displacement separately from the damped velocity. +pub fn ground_history(request: GroundHistoryRequest) -> GroundHistoryResult { + let old = request.previous_position.map(f32::from_bits); + let current = request.current_position.map(f32::from_bits); + let previous = request.previous_filtered_delta.map(f32::from_bits); + let delta: [f32; 4] = std::array::from_fn(|i| current[i] - old[i]); + let timestep = f32::from_bits(request.timestep_bits); + let mut reciprocal = reciprocal_estimate(timestep); + for _ in 0..2 { + let error = (-reciprocal).mul_add(timestep, 1.0); + reciprocal = reciprocal.mul_add(error, reciprocal); + } + //Literal82072818, then8231A844 minus that coefficient. + let retention = f32::from_bits(0x3f73_3333); + let change = 1.0 - retention; + GroundHistoryResult { + delta: delta.map(f32::to_bits), + filtered_delta: std::array::from_fn(|i| { + let scaled = reciprocal * (delta[i] * change); + previous[i].mul_add(retention, scaled).to_bits() + }), + } +} + +///82DB53F0..5508 integrates the gravity component along the board velocity. +///The PrepareJump flag's direct velocity copy is handled by the caller. +pub fn prepared_jump_velocity(request: PrepareJumpRequest) -> RawVector { + let velocity = request.skateboard_vector.map(f32::from_bits); + let previous = request.previous_velocity.map(f32::from_bits); + let squared = dot3(velocity, velocity); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + //Initializer82F826F8 broadcasts82181A88 into830BD350. + let direction = if length > f32::from_bits(0x3586_37bd) { + velocity.map(|v| v * inverse) + } else { + [0.0; 4] + }; + let down_component = f32::from_bits(direction[1].to_bits() ^ 0x8000_0000); + let acceleration = down_component * f32::from_bits(0x411c_cccd); //822F8BD4 + std::array::from_fn(|i| { + (direction[i] * acceleration) + .mul_add(f32::from_bits(0x3c88_8889), previous[i]) //820849C8 + .to_bits() + }) +} diff --git a/crates/skate-core/src/player/input_phase/pose_output.rs b/crates/skate-core/src/player/input_phase/pose_output.rs new file mode 100644 index 00000000..a6e98097 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/pose_output.rs @@ -0,0 +1,40 @@ +//! Completed pose observations; original PhysOut template constructors. +use super::types::RawMatrix; + +/// Skeleton reset82DE3A28 explicitly clears these scalars and flags. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SkeletonOutputFields { + pub hips_right_angle_496: f32, + pub hips_up_angle_500: f32, + pub scalar_544: f32, + pub no_support_time_548: f32, + pub time_until_teleport_576: f32, + pub response_strength_580: f32, + pub extra_weight_584: f32, + pub response_change_588: f32, + pub flag_597: u8, + pub over_599: u8, + pub flag_600: u8, + pub flag_601: u8, + pub teleport_pending_604: u8, + pub response_changed_605: u8, + pub anim_to_world_11920: RawMatrix, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnimationOutputFields { + pub collision_time_144: f32, + pub profile_148: u32, +} +impl Default for AnimationOutputFields { + fn default() -> Self { + // Animation82DE3F38 stores FLT_MAX at144 and zero at148. + Self { collision_time_144: f32::MAX, profile_148: 0 } + } +} + +/// Scoring2 reset82DE4468 clears the conditioner capability mask at204. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ScoringOutputFields { + pub capabilities_204: u32, +} diff --git a/crates/skate-core/src/player/input_phase/publication.rs b/crates/skate-core/src/player/input_phase/publication.rs new file mode 100644 index 00000000..70155032 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/publication.rs @@ -0,0 +1,340 @@ +use crate::input::animation_packet::{self, ProcessedPacketFields}; + +use super::types::{ + AnimationInputPacket, NativeReferenceBase, NativeRelativeReference, PhysicalPlayerInput, + PlayerInputState, ProcessedPhysicsInput, RawMatrix, +}; + +pub(crate) fn publish_player_flags(player: &mut PlayerInputState, out: &mut ProcessedPhysicsInput) { + transfer_bit(&mut out.flags_2472, 14, player.flags_1296, 27); + transfer_bit(&mut out.flags_2488, 26, player.flags_1296, 20); + out.vectors_880_896_912_928_944[3] = player.queued_vector_1280; + transfer_bit(&mut out.flags_2488, 22, player.flags_1296, 18); + transfer_bit(&mut out.flags_2480, 16, player.flags_1296, 17); + player.queued_vector_1280 = [0; 4]; + player.flags_1296 &= !(1 << 20); +} + +pub(crate) fn publish_force_braking( + player: &PlayerInputState, + physical: &PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + let force = packet.force_braking_10796 != 0 + && (player.state_count_1312 as i32) > 5 + && physical.skateboard.scalar_160 < f32::from_bits(0x3dcc_cccd) + && physical.state.state_16 == 100 + && (physical.collision.wheel_count_0 as i32) > 2; + replace_bool(&mut out.flags_2476, 24, force); +} + +pub(crate) fn publish_collision_prefix( + player: &PlayerInputState, + physical: &PhysicalPlayerInput, + out: &mut ProcessedPhysicsInput, +) { + replace_byte(&mut out.flags_2484, 26, physical.collision.flag_215); + replace_byte(&mut out.flags_2484, 22, physical.collision.flag_216); + out.vectors_720_784_800_816_832_864[5] = player.manager_1852_vector_176; +} + +pub(crate) fn publish_animation_packet( + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + let mut fields = ProcessedPacketFields { + flags_2468: out.flags_2468, + flags_2476: out.flags_2476, + timestep: out.timestep_2604, + scalar_2668: out.scalar_2668, + vector_1520: out.vector_1520, + matrix_1536: out.matrix_1536, + byte_1600: out.byte_1600, + truck_tightness: out.truck_tightness_2760, + scalar_2764: out.scalar_2764, + }; + animation_packet::publish(packet.publication, &mut fields); + out.flags_2468 = fields.flags_2468; + out.flags_2476 = fields.flags_2476; + out.timestep_2604 = fields.timestep; + out.scalar_2668 = fields.scalar_2668; + out.vector_1520 = fields.vector_1520; + out.matrix_1536 = fields.matrix_1536; + out.byte_1600 = fields.byte_1600; + out.truck_tightness_2760 = fields.truck_tightness; + out.scalar_2764 = fields.scalar_2764; +} + +pub(crate) fn select_surface( + player: &PlayerInputState, + packet: &AnimationInputPacket<'_>, + surface_default_mode: u32, + out: &mut ProcessedPhysicsInput, +) -> Result<(), u32> { + let index = packet.state_variant_10928; + let Some(variant) = player.state_variants_1408.get(index as usize) else { + out.state_variant_index_2528 = index; + return Err(index); + }; + out.state_variant_ref_2548 = Some(NativeRelativeReference { + base: NativeReferenceBase::Player, + offset: 0x580 + 0x10 * index as u16, + }); + out.state_variant_index_2528 = index; + + let requested = if variant.surface_override_enabled_60 != 0 { + match surface_default_mode { + 1..=3 => 1, + 5 => 3, + 13 => 2, + value => value, + } + } else { + surface_default_mode + }; + select_surface_mode(requested, out); + Ok(()) +} + +fn select_surface_mode(requested: u32, out: &mut ProcessedPhysicsInput) { + let mode = match requested { + 1..=5 => requested, + 6 => 3, + _ => 1, + }; + let (primary, secondary) = match mode { + 1 => (24, 104), + 2 => (40, 116), + 3 => (56, 128), + 4 => (72, 140), + 5 => (88, 152), + _ => unreachable!("surface mode normalization is exhaustive"), + }; + out.surface_mode_2540 = mode; + out.surface_primary_ref_2544 = Some(NativeRelativeReference { + base: NativeReferenceBase::SurfaceSelector, + offset: primary, + }); + out.surface_secondary_ref_2552 = Some(NativeRelativeReference { + base: NativeReferenceBase::SurfaceSelector, + offset: secondary, + }); +} + +pub(crate) fn publish_external_physics( + player: &mut PlayerInputState, + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + if packet.use_external_physics_10688 != 0 { + out.flags_2472 |= 1 << 29; + out.external_physics_1616 + .copy_from(packet.external_physics_10512); + if out.external_physics_1616.flags & (1 << 26) != 0 { + player + .external_physics_cache_1008 + .copy_from(packet.external_physics_10512); + } else { + out.external_physics_1616.vectors[5..9] + .copy_from_slice(&player.external_physics_cache_1008.vectors[5..9]); + out.external_physics_1616.vectors[9] = player.external_physics_cache_1008.vectors[9]; + transfer_bit( + &mut out.external_physics_1616.flags, + 27, + player.external_physics_cache_1008.flags, + 27, + ); + out.external_physics_1616.flags |= 1 << 26; + } + } + replace_byte(&mut out.flags_2472, 28, packet.external_physics_flag_10689); +} + +pub(crate) fn publish_state_prefix( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + captured_state: u32, + out: &mut ProcessedPhysicsInput, +) { + transfer_bit(&mut out.flags_2468, 13, player.flags_1296, 31); + let has_probe_or_animation_flag = + out.flags_2468 & (1 << 2) != 0 || out.probe_1792.byte_104 != 0; + replace_bool(&mut out.flags_2468, 18, has_probe_or_animation_flag); + player.frames_since_teleport_1328 = if player.flags_1296 & (1 << 29) != 0 { + 0 + } else { + player.frames_since_teleport_1328.wrapping_add(1) + }; + out.frames_since_teleport_2584 = player.frames_since_teleport_1328; + player.flags_1296 &= !(1 << 29); + out.state_count_2564 = player.state_count_1312; + out.state_timer_2664 = player.state_timer_1344; + out.update_count_2568 = player.update_count_1316; + replace_byte(&mut out.flags_2472, 12, packet.flag_10373); + replace_byte(&mut out.flags_2468, 0, packet.flag_10786); + replace_byte(&mut out.flags_2472, 31, packet.flag_10787); + out.state_2508 = captured_state; + out.state_2504 = captured_state; + out.category_2512 = physical.state.category_12; + replace_byte(&mut out.flags_2472, 30, physical.state.flag_61); + replace_byte(&mut out.flags_2472, 18, physical.state.flag_69); + replace_byte(&mut out.flags_2480, 31, physical.state.flag_74); + out.grind_words_2532_2536 = physical.grinds.words_136_140; + out.category_2516 = physical.state.category_12; + out.player_state_value_2520 = player.state_value_1336; +} + +pub(crate) fn publish_physical_outputs( + physical: &PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + out.vectors_400_416[0] = physical.skateboard.vector_80; + out.vectors_400_416[1] = physical.skateboard.vector_96; + out.scalar_2736 = physical.skateboard.scalar_172; + out.scalar_2612 = physical.skateboard.scalar_168; + out.scalar_2656 = physical.skateboard.scalar_164; + out.scalar_2652 = physical.skateboard.scalar_160; + out.vectors_720_784_800_816_832_864[0] = physical.skateboard.vector_64; + out.vectors_720_784_800_816_832_864[1] = physical.skateboard.vector_128; + out.vectors_720_784_800_816_832_864[2] = physical.skateboard.vector_144; + + out.vectors_624_640_656_672[0] = packet.vector_10816; + out.vectors_624_640_656_672[1] = packet.vector_10832; + transfer_bit(&mut out.flags_2476, 6, packet.flags_10932, 31); + transfer_bit(&mut out.flags_2476, 5, packet.flags_10932, 30); + transfer_bit(&mut out.flags_2476, 4, packet.flags_10932, 29); + out.scalar_2696 = packet.scalar_10912; + out.vectors_624_640_656_672[2] = packet.vector_10848; + out.scalar_2700 = packet.scalar_10916; + out.vectors_624_640_656_672[3] = packet.vector_10864; + replace_byte(&mut out.flags_2480, 0, packet.flag_10369); + out.vectors_880_896_912_928_944[0] = packet.vector_10880; + out.vectors_880_896_912_928_944[1] = packet.vector_10896; + transfer_bit(&mut out.flags_2488, 24, packet.flags_10932, 22); + + out.vectors_544_560_592_608[2] = physical.reckoning.vector_64; + out.vectors_880_896_912_928_944[4] = physical.reckoning.vector_144; + replace_byte(&mut out.flags_2488, 21, physical.reckoning.flag_164); + out.vectors_544_560_592_608[3] = if physical.air.use_air_reckoning_452 != 0 { + physical.air.vector_160 + } else { + physical.reckoning.vector_16 + }; + out.vectors_544_560_592_608[0] = physical.reckoning.vector_96; + out.vectors_544_560_592_608[1] = physical.board_reckoning_side_176; + out.vectors_464_480_496_512_528[0] = physical.ground.vector_96; + out.vectors_464_480_496_512_528[4] = physical.ground.vector_64; + out.vectors_720_784_800_816_832_864[4] = physical.ground.vector_128; + replace_byte(&mut out.flags_2476, 27, physical.ground.flag_317); + replace_byte(&mut out.flags_2476, 23, physical.ground.flag_318); + + out.air_scalar_2772 = physical.air.scalar_184; + replace_byte(&mut out.flags_2476, 26, physical.air.flag_441); + replace_byte(&mut out.flags_2476, 0, physical.air.flag_446); + replace_byte(&mut out.flags_2480, 25, physical.air.flag_447); + replace_byte(&mut out.flags_2480, 24, physical.air.flag_448); + transfer_bit( + &mut out.flags_2480, + 30, + physical.component_1832_word_1876, + 28, + ); + replace_byte(&mut out.flags_2468, 9, physical.grinds.flag_318); + replace_byte(&mut out.flags_2468, 8, physical.grinds.flag_322); + + replace_byte(&mut out.flags_2468, 15, physical.collision.flag_3478); + replace_byte(&mut out.flags_2468, 16, physical.collision.flag_3475); + replace_byte(&mut out.flags_2468, 17, physical.collision.flag_3472); + out.wheel_count_2556 = physical.collision.wheel_count_0; + replace_byte(&mut out.flags_2468, 14, physical.collision.flag_3477); + out.collision_scalar_2924 = physical.collision.scalar_28; + replace_byte(&mut out.flags_2488, 30, physical.collision.flag_3481); + replace_byte(&mut out.flags_2472, 11, physical.physics.flag_32); + out.vectors_720_784_800_816_832_864[3] = physical.physics.vector_16; + replace_byte(&mut out.flags_2472, 9, physical.skeleton.flag_597); + replace_byte(&mut out.flags_2480, 10, physical.skeleton.flag_600); + replace_byte(&mut out.flags_2480, 9, physical.skeleton.flag_601); + out.filtered_state_2524 = physical.filtered_state_0; + + replace_byte(&mut out.flags_2476, 21, physical.off_board.flag_304); + replace_bool(&mut out.flags_2476, 17, physical.off_board.scalar_32 <= 0.0); + replace_byte(&mut out.flags_2480, 15, physical.off_board.flag_311); + replace_byte(&mut out.flags_2476, 16, physical.off_board.flag_315); + replace_byte(&mut out.flags_2476, 8, physical.off_board.flag_316); + out.off_board_scalar_2832 = physical.off_board.scalar_32; + replace_byte(&mut out.flags_2480, 1, physical.off_board.flag_318); + replace_byte(&mut out.flags_2484, 23, physical.off_board.flag_314); + out.vectors_880_896_912_928_944[2] = physical.off_board.vector_64; + replace_byte(&mut out.flags_2484, 16, physical.off_board.flag_328); + publish_wheel_contacts(physical, out); +} + +fn publish_wheel_contacts(physical: &PhysicalPlayerInput, out: &mut ProcessedPhysicsInput) { + let wheel = physical.collision.wheel_contact_3296_3299; + let outer_tail = wheel[2] != 0 || wheel[3] != 0; + let outer_nose = wheel[0] != 0 || wheel[1] != 0; + let left_pair = wheel[2] != 0 || wheel[0] != 0; + let right_pair = wheel[3] != 0 || wheel[1] != 0; + let switched = out.flags_2468 & (1 << 20) != 0; + replace_bool( + &mut out.flags_2472, + 27, + if switched { outer_nose } else { outer_tail }, + ); + replace_bool( + &mut out.flags_2472, + 26, + if switched { outer_tail } else { outer_nose }, + ); + replace_bool( + &mut out.flags_2472, + 25, + if switched { right_pair } else { left_pair }, + ); + replace_bool( + &mut out.flags_2472, + 24, + if switched { left_pair } else { right_pair }, + ); +} + +pub(crate) fn effective_animation_transform(source: RawMatrix, flags_2476: u32) -> RawMatrix { + let mut result = source; + if flags_2476 & (1 << 2) != 0 { + for row in [0, 2] { + for lane in &mut result[row] { + *lane = (-f32::from_bits(*lane)).to_bits(); + } + } + } + result +} + +pub(crate) fn publish_line_tests(player: &PlayerInputState, out: &mut ProcessedPhysicsInput) { + out.line_tests_960_1008_1056 = [ + player.left_line_test_1536, + player.right_line_test_1584, + player.hips_line_test_1488, + ]; + if out.line_tests_960_1008_1056[0].valid != 0 { + out.left_surface_2596 = out.line_tests_960_1008_1056[0].surface; + } + if out.line_tests_960_1008_1056[1].valid != 0 { + out.right_surface_2600 = out.line_tests_960_1008_1056[1].surface; + } +} + +pub(crate) fn replace_byte(word: &mut u32, bit: u32, source: u8) { + *word = (*word & !(1 << bit)) | (u32::from(source & 1) << bit); +} + +pub(crate) fn replace_bool(word: &mut u32, bit: u32, source: bool) { + *word = (*word & !(1 << bit)) | (u32::from(source) << bit); +} + +pub(crate) fn transfer_bit(word: &mut u32, bit: u32, source: u32, source_bit: u32) { + *word = (*word & !(1 << bit)) | (((source >> source_bit) & 1) << bit); +} diff --git a/crates/skate-core/src/player/input_phase/runtime.rs b/crates/skate-core/src/player/input_phase/runtime.rs new file mode 100644 index 00000000..f7bbdf0a --- /dev/null +++ b/crates/skate-core/src/player/input_phase/runtime.rs @@ -0,0 +1,368 @@ +use super::publication::{ + effective_animation_transform, publish_animation_packet, publish_collision_prefix, + publish_external_physics, publish_force_braking, publish_line_tests, publish_physical_outputs, + publish_player_flags, publish_state_prefix, replace_bool, replace_byte, select_surface, + transfer_bit, +}; +use super::types::{ + AnimationInputPacket, GroundHistoryRequest, PhysicalPlayerInput, PlayerInputState, + PrepareJumpRequest, ProcessedPhysicsInput, RawVector, +}; + +const FIXED_FRAME_STEP: f32 = f32::from_bits(0x3c88_8889); + +/// Mandatory boundaries called by TU3 `PhysicalPlayerHiLOD::Input`. +/// +/// Inlined motion calculations execute directly in this module. Services own +/// only separate physical subsystems and host data access. +pub trait InputPhaseServices { + type Error; + + fn update_pre_input_manager_82d81610( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error>; + fn reset_processed_input_82bf9ef0( + &mut self, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn actor_query_slot_56(&mut self) -> Result; + fn actor_query_slot_44(&mut self) -> Result; + fn reset_player_probe_82d7a330( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error>; + fn check_teleport_82db88c8( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn actor_input_available_slot_4(&mut self) -> Result; + fn transition_action_825903c8(&mut self) -> Result; + fn calculate_ground_position_82c02840( + &mut self, + physical: &PhysicalPlayerInput, + ) -> Result; + fn prepare_board_toolkit_82c013f0( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn process_skeleton_82bd8918( + &mut self, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn update_grind_manager_82d8a828( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InputPhaseError { + Service(E), + /// Native dereferences a null +2548 pointer when this packet value is >4. + InvalidStateVariant(u32), +} + +/// Pre-reset observations retained by82DB4048 across CheckTeleport. +#[derive(Debug)] +pub struct InputContinuation { + captured_state: u32, + captured_category: u32, +} + +/// Complete recovered TU3 PhysicalPlayerHiLOD::Input82DB4048. +/// The split also lets a host release subsystem borrows at CheckTeleport. +pub fn process_input( + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + let continuation = start_input(player, physical, packet, output, services)?; + finish_input(continuation, player, physical, packet, output, services) +} + +///82DB4048 through CheckTeleport82DB42DC. Complete the physical reset +///continuation before calling finish_input; captured fields must survive it. +pub fn start_input( + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result> { + let captured_state = physical.state.state_16; + player.manager_1856_counter_320 = native_nonnegative_decrement(player.manager_1856_counter_320); + services + .update_pre_input_manager_82d81610(player, physical) + .map_err(InputPhaseError::Service)?; + player.manager_1852_flag_256 = 0; + let captured_category = physical.state.category_12; + services + .reset_processed_input_82bf9ef0(output) + .map_err(InputPhaseError::Service)?; + + publish_player_flags(player, output); + output.actor_query_2948 = services + .actor_query_slot_56() + .map_err(InputPhaseError::Service)?; + output.actor_query_2952 = services + .actor_query_slot_44() + .map_err(InputPhaseError::Service)?; + publish_force_braking(player, physical, packet, output); + output.probe_1792 = player.probe; + services + .reset_player_probe_82d7a330(player, physical) + .map_err(InputPhaseError::Service)?; + publish_collision_prefix(player, physical, output); + publish_animation_packet(packet, output); + + output.state_identifier_2496 = physical.state.identifier_8; + services + .check_teleport_82db88c8(player, physical, output) + .map_err(InputPhaseError::Service)?; + Ok(InputContinuation { captured_state, captured_category }) +} + +///82DB42F0 through82DB5570, after any reset/normal-setup continuation. +pub fn finish_input( + continuation: InputContinuation, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + let InputContinuation { captured_state, captured_category } = continuation; + transfer_bit(&mut output.flags_2472, 10, player.flags_1296, 29); + publish_transition(packet, output, services)?; + update_signed_ground_time(player, physical, output); + update_time_on_ground(player, captured_category, output); + + select_surface(player, packet, physical.surface_default_mode, output) + .map_err(InputPhaseError::InvalidStateVariant)?; + replace_byte(&mut output.flags_2476, 2, packet.flag_10370); + publish_external_physics(player, packet, output); + publish_state_prefix(player, physical, packet, captured_state, output); + publish_physical_outputs(physical, packet, output); + update_timers(player, physical, output); + update_ground_position(player, physical, captured_category, output, services)?; + + output.effective_anim_transform_192 = + effective_animation_transform(physical.skeleton.anim_to_world_11920, output.flags_2476); + update_ground_history(player, captured_category, output); + publish_ground_history(player, output); + services + .prepare_board_toolkit_82c013f0(player, physical, output) + .map_err(InputPhaseError::Service)?; + publish_line_tests(player, output); + services + .process_skeleton_82bd8918(packet, physical, output) + .map_err(InputPhaseError::Service)?; + publish_post_skeleton(player, physical, output); + services + .update_grind_manager_82d8a828(player, physical, output) + .map_err(InputPhaseError::Service) +} + +fn publish_transition( + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + if !services + .actor_input_available_slot_4() + .map_err(InputPhaseError::Service)? + { + return Ok(()); + } + output.transition_2636 = if packet.suppress_transition_10376 != 0 { + 0.0 + } else { + let value = services + .transition_action_825903c8() + .map_err(InputPhaseError::Service)?; + let lower = xenon_fsel(-1.0 - value, -1.0, value); + xenon_fsel(1.0 - lower, lower, 1.0) + }; + Ok(()) +} + +fn update_signed_ground_time( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, +) { + let previous = player.signed_ground_time_1356; + player.signed_ground_time_1356 = if physical.state.signed_ground_step_84 != 0 { + xenon_fsel(previous, previous, 0.0) + FIXED_FRAME_STEP + } else { + xenon_fsel(previous, 0.0, previous) - FIXED_FRAME_STEP + }; + output.signed_ground_time_2756 = player.signed_ground_time_1356; +} + +fn update_time_on_ground( + player: &mut PlayerInputState, + captured_category: u32, + output: &mut ProcessedPhysicsInput, +) { + if captured_category == 100 { + output.flags_2472 |= 1 << 13; + player.grounded_frames_1300 = player.grounded_frames_1300.wrapping_add(1); + player.time_on_ground_1352 += output.timestep_2604; + replace_bool( + &mut player.flags_1296, + 31, + (player.grounded_frames_1300 as i32) > 6, + ); + } else { + player.time_on_ground_1352 = 0.0; + player.grounded_frames_1300 = 0; + } + output.time_on_ground_2752 = player.time_on_ground_1352; +} + +fn update_timers( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, +) { + if physical.state.state_16 == 104 { + player.skitch_timer_1376 = 2.0; + player.skitch_value_1372 = physical.state.skitch_value_40; + } + if player.skitch_timer_1376 > 0.0 { + player.skitch_timer_1376 -= output.timestep_2604; + output.skitch_value_2592 = player.skitch_value_1372; + } + if physical.ground.scalar_292 > 0.0 { + player.ground_timer_1380 = physical.ground.scalar_292; + } + if player.ground_timer_1380 > 0.0 { + player.ground_timer_1380 -= output.timestep_2604; + output.ground_timer_2848 = player.ground_timer_1380; + output.skitch_value_2592 = player.skitch_value_1372; + } + if physical.ground.scalar_296 > 0.0 { + player.secondary_ground_timer_1384 = physical.ground.scalar_296; + } + if player.secondary_ground_timer_1384 > 0.0 { + player.secondary_ground_timer_1384 -= output.timestep_2604; + output.secondary_ground_timer_2852 = player.secondary_ground_timer_1384; + } +} + +fn update_ground_position( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + captured_category: u32, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + if captured_category == 500 { + player.current_ground_position_1216 = physical.skeleton.anim_to_world_11920[3]; + player.time_off_board_1368 += FIXED_FRAME_STEP; + } else { + player.current_ground_position_1216 = services + .calculate_ground_position_82c02840(physical) + .map_err(InputPhaseError::Service)?; + player.time_off_board_1368 = 0.0; + } + output.time_off_board_2836 = player.time_off_board_1368; + Ok(()) +} + +fn update_ground_history( + player: &mut PlayerInputState, + captured_category: u32, + output: &ProcessedPhysicsInput, +) { + if captured_category != 200 && captured_category != 600 && player.ground_history_frames_1304 > 0 + { + let previous = player.previous_ground_position_1200; + player.previous_ground_position_1200 = player.current_ground_position_1216; + if output.frames_since_teleport_2584 > 1 { + let result = super::motion_math::ground_history(GroundHistoryRequest { + previous_position: previous, + current_position: player.current_ground_position_1216, + previous_filtered_delta: player.damped_ground_delta_1248, + timestep_bits: output.timestep_2604.to_bits(), + }); + player.ground_delta_1232 = result.delta; + player.damped_ground_delta_1248 = result.filtered_delta; + } + } +} + +fn publish_ground_history(player: &PlayerInputState, output: &mut ProcessedPhysicsInput) { + output.vectors_464_480_496_512_528[1] = player.previous_ground_position_1200; + output.vectors_464_480_496_512_528[2] = player.current_ground_position_1216; + output.vectors_464_480_496_512_528[3] = player.damped_ground_delta_1248; +} + +fn publish_post_skeleton( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, +) { + if output.flags_2472 & (1 << 21) != 0 { + replace_byte(&mut output.flags_2472, 21, physical.state.flag_66); + } + let skitch_or_ground_window = player.skitch_timer_1376 > 0.0 + || (output.flags_2476 & (1 << 22) != 0 && physical.ground.scalar_276 >= 0.0); + replace_bool(&mut output.flags_2476, 3, skitch_or_ground_window); + player.time_since_last_input_1348 = if output.flags_2472 & (1 << 23) != 0 { + player.time_since_last_input_1348 + output.timestep_2604 + } else { + 0.0 + }; + output.time_since_last_input_2748 = player.time_since_last_input_1348; + + if output.flags_2468 & (1 << 21) != 0 { + player.prepared_jump_velocity_1184 = output.vectors_400_416[1]; + } else { + player.prepared_jump_velocity_1184 = + super::motion_math::prepared_jump_velocity(PrepareJumpRequest { + skateboard_vector: output.vectors_400_416[1], + previous_velocity: player.prepared_jump_velocity_1184, + }); + } + + player.spin_same_direction_frames_1324 = + if output.spin_input_2672 * player.previous_spin_input_1360 <= 0.0 { + 0 + } else { + player.spin_same_direction_frames_1324.wrapping_add(1) + }; + player.previous_spin_input_1360 = output.spin_input_2672; + output.spin_same_direction_frames_2580 = player.spin_same_direction_frames_1324; + output.prepared_jump_704 = player.prepared_jump_velocity_1184; + output.crouch_delta_2780 = output.crouch_2776 - player.previous_crouch_1364; + player.previous_crouch_1364 = output.crouch_2776; +} + +fn native_nonnegative_decrement(value: u32) -> u32 { + let decremented = value.wrapping_sub(1); + if decremented.overflowing_add(0x8000_0000).1 { + 0 + } else { + decremented + } +} + +fn xenon_fsel(test: f32, nonnegative: f32, negative: f32) -> f32 { + if test >= -0.0 { nonnegative } else { negative } +} + diff --git a/crates/skate-core/src/player/input_phase/tests.rs b/crates/skate-core/src/player/input_phase/tests.rs new file mode 100644 index 00000000..9e7b9e48 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/tests.rs @@ -0,0 +1,460 @@ +use crate::animation::output::actor_packet::ExternalPhysicsInput; +use crate::input::animation_packet::AnimationPacketFields; + +use super::publication::{ + effective_animation_transform, publish_external_physics, publish_physical_outputs, + select_surface, +}; +use super::*; + +fn external(flags: u32) -> ExternalPhysicsInput { + ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags, + } +} + +fn animation_publication() -> AnimationPacketFields { + AnimationPacketFields { + stance_byte: 0, + timestep: 0.25, + scalar_10388: 0.0, + flags_10375_10496_10784: [0; 3], + vector_10480: [0; 4], + matrix_10704: [[0; 4]; 4], + byte_10768: 0, + truck_tightness: 0.0, + scalar_10792: 0.0, + flag_10371: 0, + } +} + +fn packet<'a>( + publication: &'a AnimationPacketFields, + external_physics: &'a ExternalPhysicsInput, +) -> AnimationInputPacket<'a> { + AnimationInputPacket { + publication, + external_physics_10512: external_physics, + flag_10369: 0, + flag_10370: 0, + flag_10373: 0, + suppress_transition_10376: 0, + use_external_physics_10688: 0, + external_physics_flag_10689: 0, + flag_10786: 0, + flag_10787: 0, + force_braking_10796: 0, + vector_10816: [0; 4], + vector_10832: [0; 4], + vector_10848: [0; 4], + vector_10864: [0; 4], + vector_10880: [0; 4], + vector_10896: [0; 4], + scalar_10912: 0.0, + scalar_10916: 0.0, + state_variant_10928: 0, + flags_10932: 0, + } +} + +#[derive(Default)] +struct Services { + calls: Vec<&'static str>, + manager_state_change: Option<(u32, u32)>, + actor_input_available: bool, +} + +impl InputPhaseServices for Services { + type Error = (); + + fn update_pre_input_manager_82d81610( + &mut self, + _player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error> { + self.calls.push("manager"); + if let Some((state, category)) = self.manager_state_change { + physical.state.state_16 = state; + physical.state.category_12 = category; + } + Ok(()) + } + + fn reset_processed_input_82bf9ef0( + &mut self, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("reset"); + *output = ProcessedPhysicsInput::default(); + Ok(()) + } + + fn actor_query_slot_56(&mut self) -> Result { + self.calls.push("actor56"); + Ok(56) + } + + fn actor_query_slot_44(&mut self) -> Result { + self.calls.push("actor44"); + Ok(44) + } + + fn reset_player_probe_82d7a330( + &mut self, + player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error> { + self.calls.push("probe_reset"); + player.probe.byte_104 = 0; + Ok(()) + } + + fn check_teleport_82db88c8( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + _output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("teleport"); + Ok(()) + } + + fn actor_input_available_slot_4(&mut self) -> Result { + self.calls.push("actor_input"); + Ok(self.actor_input_available) + } + + fn transition_action_825903c8(&mut self) -> Result { + self.calls.push("transition"); + Ok(2.0) + } + + fn calculate_ground_position_82c02840( + &mut self, + _physical: &PhysicalPlayerInput, + ) -> Result { + self.calls.push("ground_position"); + Ok([2.0f32, 4.0, 6.0, 0.0].map(f32::to_bits)) + } + + fn prepare_board_toolkit_82c013f0( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + _output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("board_toolkit"); + Ok(()) + } + + fn process_skeleton_82bd8918( + &mut self, + _packet: &AnimationInputPacket<'_>, + _physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("skeleton"); + output.flags_2472 |= (1 << 21) | (1 << 23); + output.flags_2476 |= 1 << 22; + output.spin_input_2672 = 2.0; + output.crouch_2776 = 3.0; + Ok(()) + } + + fn update_grind_manager_82d8a828( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + _output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("grind"); + Ok(()) + } +} + +#[test] +fn complete_phase_keeps_native_service_order_and_persistent_counters() { + let publication = animation_publication(); + let external_physics = external(0); + let packet = packet(&publication, &external_physics); + let mut player = PlayerInputState { + manager_1856_counter_320: 2, + manager_1852_flag_256: 9, + grounded_frames_1300: 6, + update_count_1316: 42, + frames_since_teleport_1328: 2, + ground_history_frames_1304: 1, + time_since_last_input_1348: 1.0, + time_on_ground_1352: 1.0, + previous_spin_input_1360: 1.0, + previous_crouch_1364: 1.0, + previous_ground_position_1200: [1.0f32, 2.0, 3.0, 0.0].map(f32::to_bits), + damped_ground_delta_1248: [0; 4], + probe: ProbeFields { + byte_104: 7, + ..ProbeFields::default() + }, + left_line_test_1536: LineTestFields { + surface: 123, + valid: 2, + ..LineTestFields::default() + }, + ..PlayerInputState::default() + }; + let mut physical = PhysicalPlayerInput::default(); + physical.state.category_12 = 100; + physical.state.state_16 = 104; + physical.state.skitch_value_40 = 77; + physical.state.signed_ground_step_84 = 1; + physical.surface_default_mode = 1; + physical.ground.scalar_276 = 0.0; + physical.ground.scalar_292 = 0.5; + physical.ground.scalar_296 = 0.75; + let mut output = ProcessedPhysicsInput::default(); + let mut services = Services { + actor_input_available: true, + ..Services::default() + }; + + process_input( + &mut player, + &mut physical, + &packet, + &mut output, + &mut services, + ) + .unwrap(); + + assert_eq!( + services.calls, + [ + "manager", + "reset", + "actor56", + "actor44", + "probe_reset", + "teleport", + "actor_input", + "transition", + "ground_position", + "board_toolkit", + "skeleton", + "grind", + ] + ); + assert_eq!(player.manager_1856_counter_320, 1); + assert_eq!(player.manager_1852_flag_256, 0); + assert_eq!(output.actor_query_2948, 56); + assert_eq!(output.actor_query_2952, 44); + assert_eq!(output.probe_1792.byte_104, 7); + assert_eq!(player.probe.byte_104, 0); + assert_eq!(player.grounded_frames_1300, 7); + assert_eq!(player.update_count_1316, 42); + assert_eq!(output.update_count_2568, 42); + assert_eq!(output.time_on_ground_2752, 1.25); + assert_ne!(output.flags_2468 & (1 << 13), 0); + assert_eq!(output.transition_2636, 1.0); + assert_eq!(player.skitch_timer_1376, 1.75); + assert_eq!(output.skitch_value_2592, 77); + assert_eq!(output.ground_timer_2848, 0.25); + assert_eq!(output.secondary_ground_timer_2852, 0.5); + assert_eq!( + output.vectors_464_480_496_512_528[1].map(f32::from_bits), + [2.0, 4.0, 6.0, 0.0] + ); + assert_eq!( + output.vectors_464_480_496_512_528[2].map(f32::from_bits), + [2.0, 4.0, 6.0, 0.0] + ); + for (actual, expected) in output.vectors_464_480_496_512_528[3] + .map(f32::from_bits) + .into_iter() + .zip([0.2, 0.4, 0.6, 0.0]) + { + assert!((actual - expected).abs() < 1e-6); + } + assert_eq!( + player.ground_delta_1232.map(f32::from_bits), + [1.0, 2.0, 3.0, 0.0] + ); + assert_eq!(output.time_since_last_input_2748, 1.25); + assert_eq!(output.prepared_jump_704.map(f32::from_bits), [0.0; 4]); + assert_eq!(output.spin_same_direction_frames_2580, 1); + assert_eq!(output.crouch_delta_2780, 2.0); + assert_eq!(output.left_surface_2596, 123); +} + +#[test] +fn manager_call_precedes_category_capture_but_state_number_is_already_captured() { + let publication = animation_publication(); + let external_physics = external(0); + let packet = packet(&publication, &external_physics); + let mut player = PlayerInputState::default(); + let mut physical = PhysicalPlayerInput::default(); + physical.state.state_16 = 11; + physical.state.category_12 = 100; + physical.surface_default_mode = 1; + physical.skeleton.anim_to_world_11920[3] = [7, 8, 9, 10]; + let mut output = ProcessedPhysicsInput::default(); + let mut services = Services { + manager_state_change: Some((22, 500)), + ..Services::default() + }; + + process_input( + &mut player, + &mut physical, + &packet, + &mut output, + &mut services, + ) + .unwrap(); + + assert_eq!(output.state_2504, 11); + assert_eq!(output.state_2508, 11); + assert_eq!(output.category_2512, 500); + assert_eq!(output.category_2516, 500); + assert_eq!(player.current_ground_position_1216, [7, 8, 9, 10]); + assert!(!services.calls.contains(&"ground_position")); + assert_eq!(player.time_off_board_1368.to_bits(), 0x3c88_8889); +} + +#[test] +fn physical_publication_uses_low_flag_bits_and_stance_wheel_permutation() { + let publication = animation_publication(); + let external_physics = external(0); + let mut packet = packet(&publication, &external_physics); + packet.flags_10932 = (1 << 31) | (1 << 29) | (1 << 22); + packet.flag_10369 = 2; + let mut physical = PhysicalPlayerInput::default(); + physical.ground.flag_317 = 3; + physical.air.flag_446 = 2; + physical.off_board.scalar_32 = f32::NAN; + physical.collision.wheel_contact_3296_3299 = [1, 0, 0, 0]; + let mut output = ProcessedPhysicsInput::default(); + output.flags_2468 &= !(1 << 20); + + publish_physical_outputs(&physical, &packet, &mut output); + assert_ne!(output.flags_2476 & (1 << 6), 0); + assert_eq!(output.flags_2476 & (1 << 5), 0); + assert_ne!(output.flags_2476 & (1 << 4), 0); + assert_ne!(output.flags_2488 & (1 << 24), 0); + assert_eq!(output.flags_2480 & 1, 0); + assert_ne!(output.flags_2476 & (1 << 27), 0); + assert_eq!(output.flags_2476 & 1, 0); + assert_eq!(output.flags_2476 & (1 << 17), 0); + assert_eq!(output.flags_2472 & (1 << 27), 0); + assert_ne!(output.flags_2472 & (1 << 26), 0); + assert_ne!(output.flags_2472 & (1 << 25), 0); + assert_eq!(output.flags_2472 & (1 << 24), 0); + + output.flags_2468 |= 1 << 20; + publish_physical_outputs(&physical, &packet, &mut output); + assert_ne!(output.flags_2472 & (1 << 27), 0); + assert_eq!(output.flags_2472 & (1 << 26), 0); + assert_eq!(output.flags_2472 & (1 << 25), 0); + assert_ne!(output.flags_2472 & (1 << 24), 0); +} + +#[test] +fn external_physics_fallback_uses_cached_trajectory_and_preserves_packet_high_bits() { + let publication = animation_publication(); + let mut incoming = external((1 << 31) | (1 << 25)); + incoming.vectors = [[0x100; 4]; 10]; + let mut packet = packet(&publication, &incoming); + packet.use_external_physics_10688 = 1; + packet.external_physics_flag_10689 = 3; + let mut player = PlayerInputState::default(); + for index in 5..10 { + player.external_physics_cache_1008.vectors[index] = [index as u32; 4]; + } + player.external_physics_cache_1008.flags = 1 << 27; + let mut output = ProcessedPhysicsInput::default(); + output.external_physics_1616.flags = 0x1234; + + publish_external_physics(&mut player, &packet, &mut output); + + assert_ne!(output.flags_2472 & (1 << 29), 0); + assert_ne!(output.flags_2472 & (1 << 28), 0); + assert_eq!(output.external_physics_1616.vectors[4], [0x100; 4]); + assert_eq!(output.external_physics_1616.vectors[5], [5; 4]); + assert_eq!(output.external_physics_1616.vectors[9], [9; 4]); + assert_ne!(output.external_physics_1616.flags & (1 << 31), 0); + assert_ne!(output.external_physics_1616.flags & (1 << 27), 0); + assert_ne!(output.external_physics_1616.flags & (1 << 26), 0); + assert_eq!(output.external_physics_1616.flags & 0x01ff_ffff, 0x1234); +} + +#[test] +fn surface_selector_and_effective_transform_match_recovered_switches() { + let publication = animation_publication(); + let external_physics = external(0); + let mut packet = packet(&publication, &external_physics); + let mut player = PlayerInputState::default(); + player.state_variants_1408[2].surface_override_enabled_60 = 1; + packet.state_variant_10928 = 2; + let mut output = ProcessedPhysicsInput::default(); + + select_surface(&player, &packet, 13, &mut output).unwrap(); + assert_eq!(output.surface_mode_2540, 2); + assert_eq!(output.state_variant_ref_2548.unwrap().offset, 0x5a0); + assert_eq!(output.surface_primary_ref_2544.unwrap().offset, 40); + assert_eq!(output.surface_secondary_ref_2552.unwrap().offset, 116); + + player.state_variants_1408[2].surface_override_enabled_60 = 0; + select_surface(&player, &packet, 6, &mut output).unwrap(); + assert_eq!(output.surface_mode_2540, 3); + packet.state_variant_10928 = 5; + assert_eq!(select_surface(&player, &packet, 1, &mut output), Err(5)); + + let matrix = [ + [ + 1.0f32.to_bits(), + (-2.0f32).to_bits(), + 0.0f32.to_bits(), + 4.0f32.to_bits(), + ], + [5.0f32.to_bits(); 4], + [ + 6.0f32.to_bits(), + 7.0f32.to_bits(), + 8.0f32.to_bits(), + 9.0f32.to_bits(), + ], + [10.0f32.to_bits(); 4], + ]; + let flipped = effective_animation_transform(matrix, 1 << 2); + assert_eq!(flipped[0][0], (-1.0f32).to_bits()); + assert_eq!(flipped[0][1], 2.0f32.to_bits()); + assert_eq!(flipped[1], matrix[1]); + assert_eq!(flipped[2][0], (-6.0f32).to_bits()); + assert_eq!(flipped[3], matrix[3]); +} + +#[test] +fn physical_vector_publication_uses_the_restored_native_offsets() { + let publication = animation_publication(); + let external = external(0); + let packet = packet(&publication, &external); + let mut physical = PhysicalPlayerInput::default(); + physical.board_reckoning_side_176 = [176; 4]; + physical.reckoning.vector_16 = [16; 4]; + physical.reckoning.vector_64 = [64; 4]; + physical.reckoning.vector_96 = [96; 4]; + physical.physics.vector_16 = [116; 4]; + physical.off_board.vector_64 = [164; 4]; + physical.air.vector_160 = [160; 4]; + let mut output = ProcessedPhysicsInput::default(); + publish_physical_outputs(&physical, &packet, &mut output); + assert_eq!( + output.vectors_544_560_592_608, + [[96; 4], [176; 4], [64; 4], [16; 4]] + ); + assert_eq!(output.vectors_720_784_800_816_832_864[3], [116; 4]); + assert_eq!(output.vectors_880_896_912_928_944[2], [164; 4]); + physical.air.use_air_reckoning_452 = 1; + publish_physical_outputs(&physical, &packet, &mut output); + assert_eq!(output.vectors_544_560_592_608[3], [160; 4]); +} + diff --git a/crates/skate-core/src/player/input_phase/types.rs b/crates/skate-core/src/player/input_phase/types.rs new file mode 100644 index 00000000..06e9bd99 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/types.rs @@ -0,0 +1,473 @@ +use super::air_output::AirOutputFields; +use super::pose_output::{AnimationOutputFields, ScoringOutputFields, SkeletonOutputFields}; +use crate::animation::output::actor_packet::ExternalPhysicsInput; +use crate::input::animation_packet::AnimationPacketFields; + +pub type RawVector = [u32; 4]; +pub type RawMatrix = [[u32; 4]; 4]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ProbeFields { + pub vectors_16_32_48: [RawVector; 3], + pub words_64_68: [u32; 2], + pub bytes_72_73: [u8; 2], + pub vector_80: RawVector, + pub words_96_100: [u32; 2], + pub byte_104: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LineTestFields { + pub position: RawVector, + pub normal: RawVector, + pub surface: u32, + /// The native byte is copied in full; conditionals test it for nonzero. + pub valid: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StateVariantFields { + /// Byte +60 of the object referenced by the variant block's +4 pointer. + pub surface_override_enabled_60: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +/// Reset82DE53F0 copies the template initialized by82DE3BE8: +/// every represented word and byte in this subset initially equals zero. +pub struct CurrentStateFields { + pub identifier_8: u32, + pub category_12: u32, + pub state_16: u32, + pub surface_height_32: f32, + pub skitch_value_40: u32, + pub flag_61: u8, + pub flag_66: u8, + pub flag_69: u8, + pub flag_74: u8, + pub signed_ground_step_84: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SkateboardMotionFields { + pub vector_64: RawVector, + pub vector_80: RawVector, + pub vector_96: RawVector, + pub vector_128: RawVector, + pub vector_144: RawVector, + pub scalar_160: f32, + pub scalar_164: f32, + pub scalar_168: f32, + pub scalar_172: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SystemReckoningFields { + /// Physical COM velocity from Skeleton16176 (82BE1D84/94). + /// Restored r28 at82DB49B0 is16, not128. + pub vector_16: RawVector, + pub vector_64: RawVector, + pub vector_96: RawVector, + pub vector_144: RawVector, + pub flag_164: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GroundOutputFields { + pub vector_64: RawVector, + pub vector_96: RawVector, + pub vector_128: RawVector, + pub scalar_276: f32, + pub scalar_292: f32, + pub scalar_296: f32, + pub flag_317: u8, + pub flag_318: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CollisionOutputFields { + pub wheel_count_0: u32, + pub scalar_28: f32, + pub predicted_position_64: RawVector, + pub flag_215: u8, + pub flag_216: u8, + pub wheel_contact_3296_3299: [u8; 4], + pub flag_3472: u8, + pub flag_3475: u8, + pub flag_3477: u8, + pub flag_3478: u8, + pub flag_3479: u8, + pub flag_3480: u8, + pub flag_3481: u8, + pub flag_3483: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PhysicsOutputFields { + pub flag_32: u8, + /// Physics+16. This native record is only48bytes long. + pub vector_16: RawVector, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GrindOutputFields { + pub words_136_140: [u32; 2], + pub flag_318: u8, + pub flag_322: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct OffBoardOutputFields { + pub scalar_32: f32, + pub flag_304: u8, + pub flag_311: u8, + pub flag_314: u8, + pub flag_315: u8, + pub flag_316: u8, + /// OffBoard reset82DE4138 clears this. LandingOnDeckManager Fill82D79420 + /// publishes manager260 && !manager258; only Biped/LandingOnDeck state + /// outputs call that producer. Ordinary Ground retains the reset value. + pub hippy_hurdling_317: u8, + pub flag_318: u8, + pub dropping_board_322: u8, + pub flag_319: u8, + pub retrieving_board_323: u8, + pub flag_328: u8, + pub vector_64: RawVector, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PhysicalPlayerInput { + /// SkateboardReckoning+176; restored r29 at82DB4994 is176. + pub board_reckoning_side_176: RawVector, + pub skateboard: SkateboardMotionFields, + pub air: AirOutputFields, + pub physics: PhysicsOutputFields, + pub grinds: GrindOutputFields, + pub skeleton: SkeletonOutputFields, + pub animation: AnimationOutputFields, + pub scoring: ScoringOutputFields, + /// Present only when Teleporting702 publishes a ready reset target. + pub teleport_output: Option, + pub collision: CollisionOutputFields, + pub state: CurrentStateFields, + pub ground: GroundOutputFields, + pub reckoning: SystemReckoningFields, + pub filtered_state_0: u32, + pub off_board: OffBoardOutputFields, + /// `player+1896 -> +0 -> +24 -> +16`, before the variant override. + pub surface_default_mode: u32, + /// Player component +1832, word +1876; source bit 28 is consumed. + pub component_1832_word_1876: u32, +} + +pub struct AnimationInputPacket<'a> { + pub publication: &'a AnimationPacketFields, + pub external_physics_10512: &'a ExternalPhysicsInput, + pub flag_10369: u8, + pub flag_10370: u8, + pub flag_10373: u8, + pub suppress_transition_10376: u8, + pub use_external_physics_10688: u8, + pub external_physics_flag_10689: u8, + pub flag_10786: u8, + pub flag_10787: u8, + pub force_braking_10796: u8, + pub vector_10816: RawVector, + pub vector_10832: RawVector, + pub vector_10848: RawVector, + pub vector_10864: RawVector, + pub vector_10880: RawVector, + pub vector_10896: RawVector, + pub scalar_10912: f32, + pub scalar_10916: f32, + pub state_variant_10928: u32, + pub flags_10932: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NativeReferenceBase { + Player, + SurfaceSelector, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NativeRelativeReference { + pub base: NativeReferenceBase, + pub offset: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlayerInputState { + pub manager_1856_counter_320: u32, + pub manager_1852_flag_256: u8, + pub manager_1852_vector_176: RawVector, + pub flags_1296: u32, + pub grounded_frames_1300: u32, + pub state_count_1312: u32, + pub update_count_1316: u32, + pub spin_same_direction_frames_1324: u32, + pub frames_since_teleport_1328: u32, + /// ProcessOutput82DB78A8 keeps a dismount request alive for three outputs. + pub dismount_request_frames_1332: u32, + pub state_value_1336: u32, + pub state_timer_1344: f32, + pub time_since_last_input_1348: f32, + pub time_on_ground_1352: f32, + pub signed_ground_time_1356: f32, + pub previous_spin_input_1360: f32, + pub previous_crouch_1364: f32, + pub time_off_board_1368: f32, + pub skitch_value_1372: u32, + pub skitch_timer_1376: f32, + pub ground_timer_1380: f32, + pub secondary_ground_timer_1384: f32, + pub probe: ProbeFields, + pub queued_vector_1280: RawVector, + pub external_physics_cache_1008: ExternalPhysicsInput, + pub prepared_jump_velocity_1184: RawVector, + pub previous_ground_position_1200: RawVector, + pub current_ground_position_1216: RawVector, + pub ground_delta_1232: RawVector, + pub damped_ground_delta_1248: RawVector, + pub state_variants_1408: [StateVariantFields; 5], + pub hips_line_test_1488: LineTestFields, + pub left_line_test_1536: LineTestFields, + pub right_line_test_1584: LineTestFields, + /// Number at player +1304 used to gate the ground-history filter. + pub ground_history_frames_1304: i32, +} + +impl Default for PlayerInputState { + fn default() -> Self { + Self { + manager_1856_counter_320: 0, + manager_1852_flag_256: 0, + manager_1852_vector_176: [0; 4], + flags_1296: 0, + grounded_frames_1300: 0, + state_count_1312: 0, + update_count_1316: 0, + spin_same_direction_frames_1324: 0, + frames_since_teleport_1328: 0, + dismount_request_frames_1332: 0, + state_value_1336: 0, + state_timer_1344: 0.0, + time_since_last_input_1348: 0.0, + time_on_ground_1352: 0.0, + signed_ground_time_1356: 0.0, + previous_spin_input_1360: 0.0, + previous_crouch_1364: 0.0, + time_off_board_1368: 0.0, + skitch_value_1372: 0, + skitch_timer_1376: 0.0, + ground_timer_1380: 0.0, + secondary_ground_timer_1384: 0.0, + probe: ProbeFields::default(), + queued_vector_1280: [0; 4], + external_physics_cache_1008: empty_external_physics(), + prepared_jump_velocity_1184: [0; 4], + previous_ground_position_1200: [0; 4], + current_ground_position_1216: [0; 4], + ground_delta_1232: [0; 4], + damped_ground_delta_1248: [0; 4], + state_variants_1408: [StateVariantFields::default(); 5], + hips_line_test_1488: LineTestFields::default(), + left_line_test_1536: LineTestFields::default(), + right_line_test_1584: LineTestFields::default(), + ground_history_frames_1304: 0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProcessedPhysicsInput { + pub effective_anim_transform_192: RawMatrix, + pub vectors_400_416: [RawVector; 2], + pub vectors_464_480_496_512_528: [RawVector; 5], + pub vectors_544_560_592_608: [RawVector; 4], + pub vectors_624_640_656_672: [RawVector; 4], + pub prepared_jump_704: RawVector, + ///Skeleton::ProcessData82BD8E70..8EA8, after ordered animation attributes. + pub collision_pose_error_736: RawVector, + pub animation_com_to_deck_752: RawVector, + pub animation_com_to_deck_delta_768: RawVector, + pub vectors_720_784_800_816_832_864: [RawVector; 6], + pub vectors_880_896_912_928_944: [RawVector; 5], + pub line_tests_960_1008_1056: [LineTestFields; 3], + pub vector_1520: RawVector, + pub matrix_1536: RawMatrix, + pub byte_1600: u8, + pub external_physics_1616: ExternalPhysicsInput, + pub probe_1792: ProbeFields, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub flags_2488: u32, + pub state_identifier_2496: u32, + pub state_2504: u32, + pub state_2508: u32, + pub category_2512: u32, + pub category_2516: u32, + pub player_state_value_2520: u32, + pub filtered_state_2524: u32, + pub state_variant_index_2528: u32, + pub grind_words_2532_2536: [u32; 2], + pub surface_mode_2540: u32, + pub surface_primary_ref_2544: Option, + pub state_variant_ref_2548: Option, + pub surface_secondary_ref_2552: Option, + pub wheel_count_2556: u32, + pub state_count_2564: u32, + pub update_count_2568: u32, + pub spin_same_direction_frames_2580: u32, + pub frames_since_teleport_2584: u32, + pub skitch_value_2592: u32, + pub left_surface_2596: u32, + pub right_surface_2600: u32, + pub timestep_2604: f32, + pub scalar_2612: f32, + pub transition_2636: f32, + ///Skeleton82BDD008: BodySpin plus the actual grind-air adjustment result. + ///Read only with flags2484bit15; Reset82BFA394 clears it each input tick. + pub grind_adjusted_body_spin_2644: f32, + ///Reset82BFA2D0 writes original literal822F8B40, -9.8. + pub gravity_2648: f32, + pub scalar_2652: f32, + pub scalar_2656: f32, + pub state_timer_2664: f32, + pub scalar_2668: f32, + /// Written by Skeleton::ProcessData before the post-skeleton suffix. + pub spin_input_2672: f32, + pub scalar_2696: f32, + pub scalar_2700: f32, + pub scalar_2736: f32, + pub time_since_last_input_2748: f32, + pub time_on_ground_2752: f32, + pub signed_ground_time_2756: f32, + pub truck_tightness_2760: f32, + pub scalar_2764: f32, + ///Current minus preceding authored board forward-axis Y,82BD8E20. + pub board_at_y_delta_2768: f32, + pub air_scalar_2772: f32, + /// Written by Skeleton::ProcessData before crouch-delta publication. + pub crouch_2776: f32, + pub crouch_delta_2780: f32, + pub off_board_scalar_2832: f32, + pub time_off_board_2836: f32, + pub ground_timer_2848: f32, + pub secondary_ground_timer_2852: f32, + pub collision_scalar_2924: f32, + pub actor_query_2948: u32, + pub actor_query_2952: u32, +} + +impl Default for ProcessedPhysicsInput { + fn default() -> Self { + Self { + effective_anim_transform_192: [[0; 4]; 4], + vectors_400_416: [[0; 4]; 2], + vectors_464_480_496_512_528: [[0; 4]; 5], + vectors_544_560_592_608: [[0; 4]; 4], + vectors_624_640_656_672: [[0; 4]; 4], + prepared_jump_704: [0; 4], + collision_pose_error_736: [0; 4], + animation_com_to_deck_752: [0; 4], + animation_com_to_deck_delta_768: [0; 4], + vectors_720_784_800_816_832_864: [[0; 4]; 6], + vectors_880_896_912_928_944: [[0; 4]; 5], + line_tests_960_1008_1056: [LineTestFields::default(); 3], + vector_1520: [0; 4], + matrix_1536: [[0; 4]; 4], + byte_1600: 0, + external_physics_1616: empty_external_physics(), + probe_1792: ProbeFields::default(), + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + flags_2488: 0, + state_identifier_2496: 0, + state_2504: 0, + state_2508: 0, + category_2512: 0, + category_2516: 0, + player_state_value_2520: 0, + filtered_state_2524: 0, + state_variant_index_2528: 0, + grind_words_2532_2536: [0; 2], + surface_mode_2540: 0, + surface_primary_ref_2544: None, + state_variant_ref_2548: None, + surface_secondary_ref_2552: None, + wheel_count_2556: 0, + state_count_2564: 0, + update_count_2568: 0, + spin_same_direction_frames_2580: 0, + frames_since_teleport_2584: 0, + skitch_value_2592: 0, + left_surface_2596: 0, + right_surface_2600: 0, + timestep_2604: 0.0, + scalar_2612: 0.0, + transition_2636: 0.0, + grind_adjusted_body_spin_2644: 0.0, + gravity_2648: 0.0, + scalar_2652: 0.0, + scalar_2656: 0.0, + state_timer_2664: 0.0, + scalar_2668: 0.0, + spin_input_2672: 0.0, + scalar_2696: 0.0, + scalar_2700: 0.0, + scalar_2736: 0.0, + time_since_last_input_2748: 0.0, + time_on_ground_2752: 0.0, + signed_ground_time_2756: 0.0, + truck_tightness_2760: 0.0, + scalar_2764: 0.0, + board_at_y_delta_2768: 0.0, + air_scalar_2772: 0.0, + crouch_2776: 0.0, + crouch_delta_2780: 0.0, + off_board_scalar_2832: 0.0, + time_off_board_2836: 0.0, + ground_timer_2848: 0.0, + secondary_ground_timer_2852: 0.0, + collision_scalar_2924: 0.0, + actor_query_2948: 0, + actor_query_2952: 0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundHistoryRequest { + pub previous_position: RawVector, + pub current_position: RawVector, + pub previous_filtered_delta: RawVector, + /// Processed +2604. The native block also consumes constants at + /// `0x82072818` and `0x8231A844`; the service owns those exact values. + pub timestep_bits: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundHistoryResult { + pub delta: RawVector, + pub filtered_delta: RawVector, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PrepareJumpRequest { + pub skateboard_vector: RawVector, + pub previous_velocity: RawVector, +} + +pub(crate) fn empty_external_physics() -> ExternalPhysicsInput { + ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags: 0, + } +} diff --git a/crates/skate-core/src/player/lifecycle.rs b/crates/skate-core/src/player/lifecycle.rs new file mode 100644 index 00000000..05033b17 --- /dev/null +++ b/crates/skate-core/src/player/lifecycle.rs @@ -0,0 +1,229 @@ +//! Physical-player state changes recovered from TU3 `0x82DB8540`. +//! +//! The selected state objects remain owned by `PhysicalPlayer`; changing state +//! selects one of those objects, rather than constructing a replacement. + +use super::state::{PhysicalStateId, UnknownPhysicalState}; + +/// The state object selected from one of `PhysicalPlayer`'s owned pointers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StateBinding { + pub state: PhysicalStateId, + pub owner_offset: u32, +} + +impl StateBinding { + pub const fn new(state: PhysicalStateId) -> Self { + Self { + state, + owner_offset: state.native_owner_offset(), + } + } +} + +/// State-change fields owned directly by `PhysicalPlayer`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlayerStateChangeFields { + /// Player+1312. `SetPhysicsState` clears it before publishing state history. + pub word_1312: u32, + /// Player+1336. Updated to the previous category only across a category edge. + pub previous_category_latch_1336: u32, + /// Player+1344. `SetPhysicsState` clears it on every accepted transition. + pub scalar_1344: f32, +} + +/// The exact `ProcessedPhysIn` fields read or written by `0x82DB8540`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProcessedStateChangeFields { + /// +2480; bits 7 and 8 choose free-board controller mode on off-board entry. + pub flags_2480: u32, + /// +2500; requested state. + pub requested_state_2500: u32, + /// +2504; type reported by the old state's vtable slot +28. + pub previous_state_2504: u32, + /// +2508; current concrete state published before old-state Exit. + pub current_state_2508: u32, + /// +2512; category of `current_state_2508`. + pub current_category_2512: u32, + /// +2516; category of `previous_state_2504`. + pub previous_category_2516: u32, + /// +2520; copy of Player+1336 after its conditional update. + pub previous_category_latch_2520: u32, + /// +2564; reset before the old state's Exit call. + pub word_2564: u32, + /// +2664; reset before the old state's Exit call. + pub scalar_2664: f32, +} + +/// Fields at `PhysicalPlayer+1844`'s `SkateboardController`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SkateboardControllerFields { + /// Controller+444. Its wider meaning is unresolved; every mode change here + /// resets it with a 32-bit zero. + pub word_444: u32, + /// Controller+448. Values used by this path are stopped=0, held=1, free=2. + pub state_448: u32, + /// Controller+452, identified by `StopController` as the system-on byte. + pub system_on_452: bool, +} + +/// Complete mutable data touched by the state-change function itself. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StateChangeData { + pub player: PlayerStateChangeFields, + pub processed: ProcessedStateChangeFields, + pub skateboard_controller: SkateboardControllerFields, +} + +/// Board and drive operations called by the controller branch. Their complete +/// implementations are `HoldSkateboard` `0x82D75370` and +/// `LetGoOfSkateboard` `0x82D75440`; this lifecycle never replaces them. +pub trait SkateboardControllerActions { + fn hold_skateboard(&mut self); + fn let_go_of_skateboard(&mut self); +} + +/// Context supplied to an owned state's Enter or Exit implementation. +/// `active` makes the pointer-switch boundary observable without exposing a +/// native pointer in engine-independent code. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StateCall { + pub state: StateBinding, + pub active: StateBinding, +} + +/// Calls corresponding to the recovered state vtable slots used here. +pub trait PhysicalStateCalls { + /// Vtable +28 on the currently selected state. + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId; + /// Vtable +20 on the old state. + fn exit(&mut self, call: StateCall); + /// Vtable +16 on the newly selected state. + fn enter(&mut self, call: StateCall); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PhysicalPlayerStateLifecycle { + active: StateBinding, +} + +impl PhysicalPlayerStateLifecycle { + pub const fn new(initial_state: PhysicalStateId) -> Self { + Self { + active: StateBinding::new(initial_state), + } + } + + pub const fn active(&self) -> StateBinding { + self.active + } + + /// Port of `PhysicalPlayerHiLOD::SetPhysicsState` `0x82DB8540`. + /// + /// Unknown numeric IDs are rejected before any callbacks or field writes. + /// The native default branches exit and re-enter the existing pointer for + /// such values, but no recovered caller contract establishes them as valid + /// states; making that case an error prevents a silent false transition. + pub fn set_physics_state( + &mut self, + requested_raw: u32, + data: &mut StateChangeData, + states: &mut S, + controller_actions: &mut C, + ) -> Result + where + S: PhysicalStateCalls, + C: SkateboardControllerActions, + { + let requested = PhysicalStateId::try_from(requested_raw)?; + + prepare_skateboard_controller( + requested, + &mut data.skateboard_controller, + data.processed.flags_2480, + controller_actions, + ); + + data.player.word_1312 = 0; + data.player.scalar_1344 = 0.0; + data.processed.scalar_2664 = 0.0; + data.processed.word_2564 = 0; + + let previous = states.get_type(self.active); + let previous_category = previous.category(); + let current_category = requested.category(); + data.processed.previous_state_2504 = previous as u32; + data.processed.previous_category_2516 = previous_category; + data.processed.requested_state_2500 = requested as u32; + data.processed.current_state_2508 = requested as u32; + data.processed.current_category_2512 = current_category; + if current_category != previous_category { + data.player.previous_category_latch_1336 = previous_category; + } + data.processed.previous_category_latch_2520 = data.player.previous_category_latch_1336; + + states.exit(StateCall { + state: self.active, + active: self.active, + }); + self.active = StateBinding::new(requested); + states.enter(StateCall { + state: self.active, + active: self.active, + }); + Ok(self.active) + } +} + +fn prepare_skateboard_controller( + requested: PhysicalStateId, + controller: &mut SkateboardControllerFields, + processed_flags_2480: u32, + actions: &mut C, +) { + if !matches!( + requested, + PhysicalStateId::BipedGround | PhysicalStateId::BipedAir | PhysicalStateId::OffBoardPushing + ) { + stop_skateboard_controller(controller, actions); + return; + } + + if controller.system_on_452 { + return; + } + + controller.word_444 = 0; + if processed_flags_2480 & (0x80 | 0x100) != 0 { + if controller.state_448 != 2 { + actions.let_go_of_skateboard(); + controller.state_448 = 2; + } + } else if controller.state_448 != 1 { + actions.hold_skateboard(); + controller.state_448 = 1; + } + controller.system_on_452 = true; +} + +/// `StopController` `0x82D75EA0`, called directly by `SetPhysicsState` for +/// every requested state outside 500..=502. +fn stop_skateboard_controller( + controller: &mut SkateboardControllerFields, + actions: &mut C, +) { + if !controller.system_on_452 { + return; + } + let previous_state = controller.state_448; + controller.word_444 = 0; + if previous_state != 0 { + actions.let_go_of_skateboard(); + controller.state_448 = 0; + } + controller.system_on_452 = false; +} + +#[cfg(test)] +#[path = "tests/lifecycle.rs"] +mod tests; diff --git a/crates/skate-core/src/player/logic.rs b/crates/skate-core/src/player/logic.rs new file mode 100644 index 00000000..cfd0a829 --- /dev/null +++ b/crates/skate-core/src/player/logic.rs @@ -0,0 +1,96 @@ +//! TU3 PhysicalPlayer Logic phase (`0x82DB5FA8`). + +use super::{ + lifecycle::{ + PhysicalPlayerStateLifecycle, PhysicalStateCalls, SkateboardControllerActions, + StateBinding, StateChangeData, + }, + selector::{StateSelectionInput, StateSelector}, + state::UnknownPhysicalState, +}; + +/// Player+1876 staging data cleared at the end of every Logic phase. +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicsOutputStaging { + /// Bytes +20 through +53. + pub bytes_20_through_53: [u8; 34], + /// Floats +56 through +188. + pub scalars_56_through_188: [f32; 34], + /// Word +200. + pub word_200: u32, +} + +impl PhysicsOutputStaging { + pub fn clear_like_82db6000(&mut self) { + self.word_200 = 0; + self.bytes_20_through_53.fill(0); + self.scalars_56_through_188.fill(0.0); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LogicResult { + pub previous: StateBinding, + pub selected: StateBinding, + pub changed: bool, +} + +/// State owned by the exact transition portion of PhysicalPlayer's Logic phase. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhysicalPlayerLogic { + pub selector: StateSelector, + pub lifecycle: PhysicalPlayerStateLifecycle, + /// PhysicalPlayer+1304; native writes 100 only when the concrete state changes. + pub frames_since_jump_1304: u32, +} + +impl PhysicalPlayerLogic { + pub fn new(initial_state: super::state::PhysicalStateId) -> Self { + Self { + selector: StateSelector::default(), + lifecycle: PhysicalPlayerStateLifecycle::new(initial_state), + frames_since_jump_1304: 0, + } + } + + /// Calls current-state GetType, calculates the suggested state, performs + /// SetPhysicsState only on an edge, then clears the output staging block. + pub fn run( + &mut self, + selection_input: &StateSelectionInput, + state_change_data: &mut StateChangeData, + output_staging: &mut PhysicsOutputStaging, + states: &mut S, + controller_actions: &mut C, + ) -> Result + where + S: PhysicalStateCalls, + C: SkateboardControllerActions, + { + let previous = self.lifecycle.active(); + let previous_type = states.get_type(previous); + let suggested = self.selector.calculate(previous_type, selection_input); + let changed = suggested != previous_type; + let selected = if changed { + self.frames_since_jump_1304 = 100; + self.lifecycle.set_physics_state( + suggested as u32, + state_change_data, + states, + controller_actions, + )? + } else { + previous + }; + output_staging.clear_like_82db6000(); + Ok(LogicResult { + previous, + selected, + changed, + }) + } +} + +#[cfg(test)] +#[path = "tests/logic.rs"] +mod tests; diff --git a/crates/skate-core/src/player/mod.rs b/crates/skate-core/src/player/mod.rs new file mode 100644 index 00000000..b6e3c738 --- /dev/null +++ b/crates/skate-core/src/player/mod.rs @@ -0,0 +1,24 @@ +//! Playable-skater orchestration recovered from the TU3 player and world loops. +//! +//! State behavior lives in the riding, air, animation and physics modules. This +//! module owns the one ordering contract that connects those systems. + +pub mod adjust; +pub mod frame; +pub mod input_phase; +pub mod lifecycle; +pub mod logic; +pub mod post_input; +pub mod post_output; +pub mod post_state; +pub mod pre_state; +pub mod selector; +pub mod slide_state; +pub mod state; +pub mod state_phase; +pub mod wipeout; +pub mod wipeout_state; +pub mod offboard; + +pub mod conditioner_capabilities; +pub mod teleport_state; diff --git a/crates/skate-core/src/player/offboard/board_possession.rs b/crates/skate-core/src/player/offboard/board_possession.rs new file mode 100644 index 00000000..14b245ad --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession.rs @@ -0,0 +1,59 @@ +//! Original TU3 SkateboardController. Lifecycle state448 remains in the existing +//! SkateboardControllerFields; this module owns only its additional data. +pub mod manager; +pub mod math; +pub mod lifecycle; +pub mod motion; +pub type Vector = [f32;4]; +pub type Frame = [Vector;4]; +pub const IDENTITY: Frame = [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[0.;4]]; +use crate::player::lifecycle::SkateboardControllerFields; + +#[derive(Clone, Copy, Debug)] +pub struct Retrieval { + pub initial_0: Frame, pub target_64: Frame, pub current_128: Frame, + pub elapsed_192: f32, pub duration_196: f32, pub progress_200: f32, pub weight_204: f32, +} +impl Default for Retrieval { + fn default()->Self {Self {initial_0:IDENTITY,target_64:IDENTITY,current_128:IDENTITY, + elapsed_192:0.,duration_196:0.,progress_200:0.,weight_204:0.}} +} +#[derive(Clone, Copy, Debug)] +pub struct HandDrive { + /// Native child frame224/288, parent frame256/320. + pub child: Frame, pub parent: Frame, + /// Linear then angular spring/maxvelocity,damping,strength,type. + pub dynamics: [[u32;4];2], +} +impl Default for HandDrive { + fn default()->Self {Self {child:IDENTITY,parent:IDENTITY,dynamics:[[0,0,0,2];2]}} +} +#[derive(Clone, Debug)] +pub struct State {pub retrieval:Retrieval, pub hands:[HandDrive;2], pub selected_hand_424:u32} +impl Default for State { + fn default()->Self {Self {retrieval:Retrieval::default(),hands:[HandDrive::default();2],selected_hand_424:2}} +} +#[derive(Clone, Copy, Debug)] +pub struct Processed { + pub board_frame_64:Frame, pub player_frame_192:Frame, + pub position_592:Vector, pub velocity_912:Vector, pub direction_400:Vector, + pub hide_direction_464:Vector, pub flags_2476:u32,pub flags_2480:u32,pub flags_2488:u32, +} +#[derive(Clone, Copy, Debug)] +pub struct Fill { + pub angle_36:f32,pub angle_40:f32, + pub held_311:bool,pub free_312:bool,pub returning_313:bool, + pub hiding_321:bool,pub flag_322:bool,pub flag_323:bool,pub flag_324:bool, +} +/// Complete82D76D20 numerical/byte publication, given actual82BE3170 bone11. +pub fn fill(fields:&SkateboardControllerFields,state:&State,p:&Processed,bone11:Frame)->Fill { + let frame=p.player_frame_192; + let on_axis=math::madd(frame[1],math::dot(math::sub(bone11[3],frame[3]),frame[1]),frame[3]); + let position=if fields.state_448==3 {state.retrieval.initial_0[3]}else{p.board_frame_64[3]}; + let delta=math::sub(position,on_axis); + let angle=math::quadrant_angle(math::dot(delta,frame[0]),math::dot(delta,frame[2])); + let s=fields.state_448;let bits=p.flags_2476; + Fill {angle_36:angle,angle_40:0.,held_311:s==1,free_312:matches!(s,2|3|4), + returning_313:s==4,hiding_321:s==3,flag_322:bits&0x200!=0, + flag_323:bits&0x400!=0||s==4,flag_324:bits&0x400!=0||(bits&0x800!=0&&s==4)} +} diff --git a/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs b/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs new file mode 100644 index 00000000..d3aed35f --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs @@ -0,0 +1,132 @@ +use super::{*,math::*}; +use crate::point_graph::PointGraph; +pub struct Settings { + pub hide_distance:f32,pub hide_offset:f32,pub return_distance:f32,pub mounted_return_distance:f32, + pub mounting_time:f32,pub retrieval_time:PointGraph<8>,pub retrieval_weight:PointGraph<8>, + pub throw_pitch:f32,pub throw_velocity:PointGraph<8>, + pub throw_target_pitch:f32,pub throw_pitch_scalar:f32,pub throw_roll_scalar:f32,pub throw_yaw_scalar:f32, +} +/// Actual completed physical and evaluated skeleton observations. +pub struct Observation { + pub processed:Processed,pub board_collision_flags_872:u32,pub board_state_840:u32, + pub hand_contacts:[bool;2],pub physical_hand_positions:[Vector;2], + pub animation_board_frame_12624:Frame,pub animation_hand_frames:[Frame;2], + pub attachment_frame_0:Frame, +} +#[derive(Clone,Copy,Debug)] +pub struct Alignment {pub first_1008:Vector,pub second_1024:Vector,pub factor_1040:f32,pub flag_1044:bool} +/// Each call mutates the actual board/solver owner. No observation defaults. +pub trait Effects { + fn enable_animation_soft(&mut self); + fn enable_animation_angular_only(&mut self); + fn disable_animation(&mut self); + fn disable_linear_drive(&mut self); + ///82C03AF8 clears8384 bit7, executes091F8/090C0, enables selected volumes. + fn standard_board(&mut self); + ///7549C..54D0 sets8384 bit7, deck drag literal416FFFFF,09160, enables volumes. + fn released_board(&mut self); + fn collision_volumes(&mut self,enabled:bool); + fn clear_alignment(&mut self); + fn alignment(&mut self,value:Alignment); + fn velocity(&mut self,value:Vector); + fn position(&mut self,value:Vector); + fn hook_frame(&mut self,value:Frame); + fn target_position_velocity(&mut self,value:Vector); + fn torque(&mut self,value:Vector); +} +impl State { + ///75370 writes held state BEFORE selecting/arming the hand and drive frames. + pub fn hold(&mut self,fields:&mut SkateboardControllerFields,o:&Observation,e:&mut impl Effects){ + e.enable_animation_soft();e.standard_board();e.collision_volumes(true); + fields.state_448=1; + self.selected_hand_424=(!o.processed.flags_2476>>2)&1; + self.hands[self.selected_hand_424 as usize].dynamics= + [[0x4395ffff,0,0x468c9fff,2];2]; + self.update_drive_frames(o); + } + pub fn disable_hand(&mut self){ + if self.selected_hand_424!=2{ + self.hands[self.selected_hand_424 as usize].dynamics=[[0,0,0,2];2]; + self.selected_hand_424=2; + } + } + ///75440 does not itself change448. Caller transitions after all actions. + pub fn let_go(&mut self,fields:&mut SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + e.disable_animation();e.released_board();e.collision_volumes(true);e.clear_alignment(); + self.disable_hand(); + if fields.state_448==4{e.velocity([0.;4]);} + fields.word_444=0; + if o.processed.flags_2476&0x1000!=0{ + fields.word_444=8;e.velocity(motion::throw_velocity(&o.processed,s)); + } + } + pub fn hide(&mut self,o:&Observation,e:&mut impl Effects){ + e.collision_volumes(false);e.disable_linear_drive();e.clear_alignment();self.disable_hand(); + self.retrieval=Retrieval::default();self.retrieval.initial_0=o.processed.board_frame_64; + self.retrieval.elapsed_192=0.;e.enable_animation_angular_only(); + } + pub fn retrieve(&mut self,fields:&SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + e.collision_volumes(false);e.clear_alignment();e.enable_animation_angular_only(); + let old=self.retrieval.initial_0[3];self.retrieval=Retrieval::default(); + self.retrieval.initial_0=o.processed.board_frame_64; + if fields.state_448==3{self.retrieval.initial_0[3]=old;} + self.retrieval.target_64=o.attachment_frame_0; + let mut delta=sub(self.retrieval.initial_0[3],self.retrieval.target_64[3]); + let mut distance=length(delta);let tiny=f32::from_bits(0x3a83126f); + if !(distance>=tiny){distance=tiny;delta=scale(o.processed.player_frame_192[2],tiny);} + let mounting=o.processed.flags_2480&0x80000!=0; + let maximum=if mounting{s.mounted_return_distance}else{s.return_distance}; + if distance>maximum||fields.state_448==3{ + self.retrieval.initial_0[3]=madd(scale(delta,maximum),reciprocal(distance,2),self.retrieval.target_64[3]); + e.position(self.retrieval.initial_0[3]);distance=maximum; + } + self.retrieval.duration_196=s.retrieval_time.evaluate(distance);self.retrieval.elapsed_192=0.; + if mounting{ + let d=self.retrieval.duration_196; + self.retrieval.duration_196=if s.mounting_time-d>=0.{d}else{s.mounting_time}; + } + } + ///75F00. Fields are borrowed from the single existing controller owner. + pub fn update_state(&mut self,f:&mut SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + let p=&o.processed; + match f.state_448{ + 1=>{ + let free_allowed=p.flags_2480&0x80000==0&&p.flags_2488&0x8000000==0; + if p.flags_2476&0x2000!=0||(free_allowed&&o.board_collision_flags_872&0x4000000!=0){ + f.word_444=0;self.let_go(f,o,s,e);f.state_448=2; + }else{ + let hand=if self.selected_hand_424==0{0}else{1}; + e.alignment(Alignment { + first_1008:unit(sub(o.physical_hand_positions[hand],p.board_frame_64[3]),[0.;4]), + second_1024:unit(sub(p.position_592,p.board_frame_64[3]),[0.;4]), + factor_1040:f32::from_bits(0x3f4ccccd),flag_1044:true}); + } + } + 2=>{ + let delta=sub(p.board_frame_64[3],p.position_592); + let distant=dot(delta,delta)>s.hide_distance*s.hide_distance; + if p.flags_2480&0x80000!=0||p.flags_2476&0x800!=0{ + f.word_444=0;self.retrieve(f,o,s,e);f.state_448=4; + }else if distant||o.board_state_840==6{ + f.word_444=0;self.hide(o,e);f.state_448=3; + } + } + 3=>if p.flags_2480&0x80000!=0||p.flags_2476&0x800!=0{ + f.word_444=0;self.retrieve(f,o,s,e);f.state_448=4; + }, + 4=>{ + let hand=((!p.flags_2476>>2)&1)as usize; + if p.flags_2480&0x80000==0&&o.hand_contacts[hand]{ + f.word_444=0;self.let_go(f,o,s,e);f.state_448=2; + }else if !(self.retrieval.progress_200<1.){ + e.velocity([0.;4]);f.word_444=0;self.hold(f,o,e);f.state_448=1; + } + } + 5=>if o.board_collision_flags_872&0x4000000!=0{ + f.word_444=0;self.let_go(f,o,s,e);f.state_448=2; + }else{e.alignment(Alignment{first_1008:p.board_frame_64[2],second_1024:p.board_frame_64[2], + factor_1040:f32::from_bits(0x3f733333),flag_1044:false});}, + _=>{} + } + } +} diff --git a/crates/skate-core/src/player/offboard/board_possession/manager.rs b/crates/skate-core/src/player/offboard/board_possession/manager.rs new file mode 100644 index 00000000..f5b0a331 --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/manager.rs @@ -0,0 +1,38 @@ +//! Ground state56's hand manager, distinct from SkateboardController448. +use super::Vector; +#[derive(Clone, Copy, Debug)] +pub struct Hand { + pub vectors_0_to_64:[Vector;5],pub word_80:u32,pub flag_84:bool, + pub scalars_96_100:[f32;2],pub flags_104_to_107:[bool;4], +} +impl Default for Hand { + fn default()->Self {Self {vectors_0_to_64:[[0.;4],[0.;4],[0.,1.,0.,0.],[0.;4],[0.;4]], + word_80:0,flag_84:false,scalars_96_100:[0.;2],flags_104_to_107:[false;4]}} +} +#[derive(Clone, Debug)] +pub struct State { + pub hands:[Hand;2],pub vectors_224_to_272:[Vector;4],pub scalars_288_to_296:[f32;3], + pub word_300:u32,pub flags_304_to_307:[bool;4],pub words_308_to_316:[u32;3], +} +impl Default for State { + fn default()->Self {Self {hands:[Hand::default();2],vectors_224_to_272:[[0.;4];4], + scalars_288_to_296:[0.;3],word_300:2,flags_304_to_307:[false;4],words_308_to_316:[0;3]}} +} +/// Actual skeleton16432 object's reset fields; all other fields survive. +pub struct SkeletonReset<'a> { + pub enabled_464:&'a mut bool,pub values_468:&'a mut [f32;4], + pub values_484:&'a mut [f32;4],pub words_500:&'a mut [u32;4], +} +impl State { + ///82D77168 writes only the represented fields, not pointer fields320 onward + ///or unwritten gaps88..95 in each hand record. + pub fn reset(&mut self) {*self=Self::default();} + ///82D770D8 preserves hand state across500/501/502 and always publishes304. + pub fn enter(&mut self,previous_2504:u32,current_2508:u32,skeleton:SkeletonReset<'_>) { + if !matches!(previous_2504,500|501|502) { + self.reset();*skeleton.values_468=[0.;4];*skeleton.values_484=[0.;4]; + *skeleton.words_500=[0;4];*skeleton.enabled_464=false; + } + self.flags_304_to_307[0]=current_2508==502; + } +} diff --git a/crates/skate-core/src/player/offboard/board_possession/math.rs b/crates/skate-core/src/player/offboard/board_possession/math.rs new file mode 100644 index 00000000..30730732 --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/math.rs @@ -0,0 +1,46 @@ +//! VMX arithmetic shared by the possession stages. Four stored lanes, xyz dot. +use super::{Vector,Frame}; +pub fn dot(a:Vector,b:Vector)->f32{a[0]*b[0]+a[1]*b[1]+a[2]*b[2]} +pub fn sub(a:Vector,b:Vector)->Vector{std::array::from_fn(|i|a[i]-b[i])} +pub fn scale(a:Vector,s:f32)->Vector{a.map(|x|x*s)} +pub fn madd(a:Vector,s:f32,b:Vector)->Vector{std::array::from_fn(|i|a[i].mul_add(s,b[i]))} +pub fn cross(a:Vector,b:Vector)->Vector{[ + (-a[2]).mul_add(b[1],a[1]*b[2]),(-a[0]).mul_add(b[2],a[2]*b[0]), + (-a[1]).mul_add(b[0],a[0]*b[1]),(-a[3]).mul_add(b[3],a[3]*b[3])]} +fn inverse_sqrt(s:f32)->f32{ + let mut r=crate::physics::reciprocal_sqrt::estimate(s); + for _ in 0..2 {r=(r*0.5).mul_add((-s).mul_add(r*r,1.),r);}r +} +pub fn length(a:Vector)->f32{let s=dot(a,a);let n=s*inverse_sqrt(s);if s==0.{0.}else{n}} +pub fn unit(a:Vector,fallback:Vector)->Vector{ + let s=dot(a,a);let r=inverse_sqrt(s);let n=if s==0.{0.}else{s*r}; + if n>f32::from_bits(0x358637bd){scale(a,r)}else{fallback} +} +pub fn reciprocal(x:f32,steps:usize)->f32{ + let mut r=crate::physics::native_arithmetic::reciprocal_estimate(x); + for _ in 0..steps{r=r.mul_add((-r).mul_add(x,1.),r);}r +} +///76D20 differs from atan2 at zero, including its signed-zero half-pi result. +pub fn quadrant_angle(y:f32,x:f32)->f32{ + let basic=crate::input::angle::atan(y.mul_add(reciprocal(x,1),0.)); + let sign=y.to_bits()&0x80000000; + let result=if 0.>x{f32::from_bits(0x40490fdb|sign)+basic}else{basic}; + if x==0.{f32::from_bits(0x3fc90fdb|sign)}else{result} +} +pub fn transform_direction(frame:Frame,v:Vector)->Vector{ + madd(frame[2],v[2],madd(frame[1],v[1],scale(frame[0],v[0]))) +} +pub fn projected_angle(a:Vector,b:Vector,axis:Vector)->f32{ + if !(dot(a,a)*dot(b,b)>f32::from_bits(0x37800000)){return 0.;} + let a=sub(a,scale(axis,dot(axis,a)));let b=sub(b,scale(axis,dot(axis,b))); + let aa=dot(a,a);let bb=dot(b,b);let gate=f32::from_bits(0x38d1b717); + if !(aa>gate&&bb>gate){return 0.;} + let one=|x:f32|{let r=crate::physics::reciprocal_sqrt::estimate(x);(r*0.5).mul_add((-x).mul_add(r*r,1.),r)}; + let a=scale(a,one(aa));let b=scale(b,one(bb)); + let angle=crate::trigonometry::acos(dot(a,b).max(-1.).min(1.)); + if dot(cross(a,b),axis)<0.{f32::from_bits(0x40c90fdb)-angle}else{angle} +} +pub fn wrap(angle:f32)->f32{ + let t=angle*f32::from_bits(0x3e22f983);let fraction=t-t.floor(); + (fraction-if fraction>0.5{1.}else{0.})*f32::from_bits(0x40c90fdb) +} diff --git a/crates/skate-core/src/player/offboard/board_possession/motion.rs b/crates/skate-core/src/player/offboard/board_possession/motion.rs new file mode 100644 index 00000000..af0badaf --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/motion.rs @@ -0,0 +1,79 @@ +use super::{*,math::*,lifecycle::*}; +const DT:f32=f32::from_bits(0x3c888889); +const RADIANS:f32=f32::from_bits(0x3c8efa35); +impl State { + ///751D8: inverse board rotation and inverse translation are evaluated + ///separately before composing the selected hand. Preserve z/y/x order. + pub fn update_drive_frames(&mut self,o:&Observation){ + let hand=if self.selected_hand_424==0{0}else{1}; + let a=o.animation_board_frame_12624;let b=o.animation_hand_frames[hand]; + let inverse:Frame=std::array::from_fn(|column|if column<3 { + [a[0][column],a[1][column],a[2][column],0.] + }else{[0.;4]}); + let negative=sub([0.;4],a[3]); + let translation=madd(inverse[0],negative[0], + madd(inverse[1],negative[1],scale(inverse[2],negative[2]))); + let mut parent=IDENTITY; + for i in 0..3{parent[i]=transform_direction(inverse,b[i]);} + parent[3]=madd(inverse[2],b[3][2],madd(inverse[1],b[3][1],madd(inverse[0],b[3][0],translation))); + self.hands[self.selected_hand_424 as usize].child=IDENTITY; + self.hands[self.selected_hand_424 as usize].parent=parent; + } + /// Controller block82DB6150..61B0, after physical state's update callback. + pub fn update(&mut self,f:&mut SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + if !f.system_on_452{return;} + self.update_state(f,o,s,e); + match f.state_448{ + 1=>self.update_drive_frames(o), + 2=>if (f.word_444 as i32)>0{ + f.word_444=f.word_444.wrapping_sub(1); + for torque in throw_torques(&o.processed,s){e.torque(torque);} + }, + 3=>{ + self.retrieval.elapsed_192+=DT;self.retrieval.progress_200=0.5;self.retrieval.weight_204=0.5; + self.retrieval.target_64=self.retrieval.initial_0; + self.retrieval.target_64[3]=madd(o.processed.hide_direction_464,s.hide_offset,o.processed.player_frame_192[3]); + self.retrieval.current_128=self.retrieval.target_64; + e.hook_frame(self.retrieval.current_128);e.position(self.retrieval.current_128[3]);e.velocity([0.;4]); + } + 4=>{ + self.retrieval.elapsed_192+=DT; + let fraction=self.retrieval.elapsed_192/self.retrieval.duration_196; + self.retrieval.progress_200=if 1.-fraction>=0.{fraction}else{1.}; + self.retrieval.weight_204=s.retrieval_weight.evaluate(self.retrieval.progress_200); + self.retrieval.target_64=o.attachment_frame_0; + self.retrieval.current_128=crate::animation::foot_ik::interpolate_affine( + &self.retrieval.initial_0,&self.retrieval.target_64,self.retrieval.weight_204); + e.hook_frame(self.retrieval.current_128);e.target_position_velocity(self.retrieval.current_128[3]); + } + _=>{} + } + } +} +///764F0 retains even the initial zero-yaw construction and all fused products. +pub fn throw_velocity(p:&Processed,s:&Settings)->Vector{ + let (sin,cos)=crate::trigonometry::sin_cos(0.*RADIANS); + let yaw=[[cos,0.,-sin,cos],[0.,1.,0.,0.],[sin,0.,cos,sin],[0.;4]]; + let (sin,cos)=crate::trigonometry::sin_cos(-s.throw_pitch*RADIANS); + let local=transform_direction(yaw,[0.,-sin,cos,0.]); + let direction=transform_direction(p.player_frame_192,local); + let speed=length(p.velocity_912); + scale(direction,speed+s.throw_velocity.evaluate(speed)) +} +///767D0: yaw, pitch, roll torque outputs in source append order. +pub fn throw_torques(p:&Processed,s:&Settings)->[Vector;3]{ + let mut flat_board=p.board_frame_64[2];flat_board[1]=0.; + let mut flat_direction=p.direction_400;flat_direction[1]=0.; + let heading=unit(flat_direction,unit(flat_board,[0.,0.,1.,0.])); + let up=[0.,1.,0.,0.];let right=cross(up,heading); + let basis=[right,up,heading,[0.;4]]; + let (sin,cos)=crate::trigonometry::sin_cos(-s.throw_target_pitch*RADIANS); + let target_right=transform_direction(basis,[1.,0.,0.,1.]); + let target_up=transform_direction(basis,[0.,cos,sin,0.]); + let target_forward=transform_direction(basis,[0.,-sin,cos,0.]); + let mut forward=p.board_frame_64[2];let mut right=p.board_frame_64[0]; + if 0.>dot(forward,target_forward){forward=forward.map(|v|-v);right=right.map(|v|-v);} + [scale(scale(target_up,wrap(projected_angle(forward,target_forward,target_up))),s.throw_yaw_scalar), + scale(scale(target_right,wrap(projected_angle(forward,target_forward,target_right))),s.throw_pitch_scalar), + scale(scale(target_forward,wrap(projected_angle(right,target_right,target_forward))),s.throw_roll_scalar)] +} diff --git a/crates/skate-core/src/player/offboard/cadence.rs b/crates/skate-core/src/player/offboard/cadence.rs new file mode 100644 index 00000000..146b391f --- /dev/null +++ b/crates/skate-core/src/player/offboard/cadence.rs @@ -0,0 +1,303 @@ +//! Biped cadence producer82D80720 and phase helpers82D7AA18..82D7AFD0. +//! Original TU3, SHA256431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! Embed this state once in Biped; callers supply actual movement/animation input. +//! PC floating-point arithmetic is not proven bit-exact Xenon arithmetic. + +const DT: f32 = f32::from_bits(0x3c888889); +const EPSILON: f32 = f32::from_bits(0x3a83126f); +type Vector = [f32; 3]; + +/// Retained Biped720..736; duration is unwritten by constructor82D7AFD8. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BipedPhase { + pub phase: f32, + pub rate: f32, + pub target: f32, + pub duration: Option, + pub forward_target: bool, +} + +impl Default for BipedPhase { + fn default() -> Self { + Self { + phase: 0.0, + rate: 0.0, + target: -1.0, + duration: None, + forward_target: false, + } + } +} + +/// This is part of the physical Biped owner, not a MotionGraph phase clock. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BipedCadence { + pub phase: BipedPhase, + /// Original enum716. Values above every threshold retain the previous value. + pub locomotion_index: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CadenceThresholds(pub [f32; 4]); + +impl CadenceThresholds { + ///82D7AFD8: metrics come from actual walk/run/sprint AnimTransZ attributes. + pub fn from_clip_speeds(walk: f32, run: f32, sprint: f32) -> Self { + Self([ + f32::from_bits(0x3c23d70a), + (run + walk) * 0.5, + (sprint + run) * 0.5, + f32::from_bits(0x501502f9), + ]) + } +} + +/// Offsets identify original Biped or job-input fields until their upstream +/// physical names are recovered. No field may be filled from a guessed proxy. +#[derive(Clone, Copy, Debug)] +pub struct CadenceInput { + pub motion_512: Vector, + pub motion_reference_272: Vector, + pub reject_axis_400: Vector, + pub reject_enabled_708: bool, + pub up_144: Vector, + pub frame_rows_0_16_32: [Vector; 3], + pub frame_position_48: Vector, + pub animation_motion_224: Vector, + pub requested_duration_288: f32, + pub requested_phase_296: f32, + pub suppress_adjustment_353: bool, + pub contact_flags_176: u32, + pub contact_point_96: Vector, +} + +impl BipedCadence { + ///82D80720. Called after movement stage82D80548, before common publication. + pub fn update(&mut self, input: &CadenceInput, thresholds: CadenceThresholds) { + let mut motion = subtract(input.motion_512, input.motion_reference_272); + if input.reject_enabled_708 { + motion = reject(motion, input.reject_axis_400); + } + let mut magnitude = length(reject(motion, input.up_144)); + for (index, threshold) in thresholds.0.into_iter().enumerate() { + if magnitude <= threshold { + self.locomotion_index = index as u32; + break; + } + } + if !(input.requested_phase_296 < 0.0) { + self.phase + .request(input.requested_phase_296, input.requested_duration_288); + } else if self.locomotion_index == 0 { + if !(self.phase.target >= 0.0) { + self.phase.stop(); + } + } else { + let animation = input.animation_motion_224; + let horizontal = [animation[0], 0.0, animation[2]]; + let horizontal_length = length(horizontal); + if horizontal_length > EPSILON { + //82D80A58..AA8 transposes rows, then accumulates x, y, z. + let local = input.frame_rows_0_16_32.map(|row| { + row[2].mul_add(motion[2], row[1].mul_add(motion[1], row[0] * motion[0])) + }); + let projection = (1.0 / horizontal_length) * dot(local, horizontal); + magnitude = square_root(local[1].mul_add(local[1], projection * projection)); + } + let animation_length = length(animation); + self.phase.rate = if animation_length < EPSILON { + 0.0 + } else { + magnitude / animation_length + }; + self.phase.target = -1.0; + if !input.reject_enabled_708 + && !input.suppress_adjustment_353 + && input.contact_flags_176 & 0x180 != 0 + { + let time = (length(subtract(input.contact_point_96, input.frame_position_48)) + - 0.2) + / magnitude; + let lower = self.phase.rate * 0.4; + let upper = self.phase.rate * 2.5; + if time > f32::from_bits(0x3c088889) { + if input.contact_flags_176 & 0x100 != 0 { + let target = if self.phase.phase > 0.37 && self.phase.phase <= 0.87 { + 0.9 + } else { + 0.4 + }; + self.phase.adjust_targets(target, target, time, upper); + } else { + self.phase.adjust_contact(time, lower, upper); + } + } + } + } + self.phase.advance(); + } +} + +impl BipedPhase { + fn request(&mut self, target: f32, mut duration: f32) { + if duration > 0.1 { + duration -= 0.1; + } + if self.target == target && self.duration == Some(duration) { + return; + } + let delta = target - self.phase; + self.target = target; + self.duration = Some(duration); + self.forward_target = true; + self.rate = if delta < 0.0 { + (delta + 1.0) / duration + } else { + delta / duration + }; + } + + fn stop(&mut self) { + let mut target = 0.0; + let mut best = f32::from_bits(0x7149f2ca); + for candidate in [0.0, 0.5] { + let raw = candidate - self.phase; + let wrapped = wrap(0.0, raw + 1.0, 1.0); + let distance = if raw.abs() < wrapped.abs() { + raw.abs() + } else { + wrapped.abs() + }; + if distance < best { + target = candidate; + best = distance; + } + } + self.forward_target = false; + self.duration = Some(0.2); + self.target = target; + let distance = (target - self.phase).abs(); + self.rate = select(distance - (1.0 - distance), 1.0 - distance, distance) * 5.0; + } + + ///82D7AD90: this fixed retail phase step is also used by Biped Air. + pub fn advance(&mut self) { + if self.target < 0.0 { + let next = self.rate.mul_add(DT, self.phase); + self.phase = next - next.floor(); + } else if self.forward_target { + if self.phase == self.target { + return; + } + let goal = if self.target < self.phase { + self.target + 1.0 + } else { + self.target + }; + let old = self.phase; + let next = self.rate.mul_add(DT, old); + self.phase = if old < goal && next >= goal { + self.target + } else { + next - next.floor() + }; + } else { + let old = self.phase; + let mut next = old + wrap(-0.5, self.target - old, 0.5); + let step = self.rate * DT; + if old > next + step { + next = old - step; + } else if old < next - step { + next = old + step; + } + self.phase = wrap(0.0, next, 1.0); + } + } + + ///82D7AB48, with82D7AA18's two candidate rates when neither wraps to1. + fn adjust_targets(&mut self, a: f32, b: f32, time: f32, upper: f32) { + let da = wrap(0.0, a - self.phase, 1.0); + let db = wrap(0.0, b - self.phase, 1.0); + self.rate = if da >= 1.0 { + clamp(db / time, 0.0, upper) + } else if db >= 1.0 { + clamp(da / time, 0.0, upper) + } else { + let inverse = 1.0 / time; + let a = da * inverse; + let b = db * inverse; + select_rate(a, b, 0.0, upper, select(a - b, b, a)) + }; + } + + ///82D7AC30 selects the legal contact target closest to the current rate. + fn adjust_contact(&mut self, time: f32, lower: f32, upper: f32) { + let inverse = 1.0 / time; + let a = wrap(0.0, 0.45 - self.phase, 1.0) * inverse; + let b = wrap(0.0, 0.95 - self.phase, 1.0) * inverse; + let preferred = if (self.rate - a).abs() < (self.rate - b).abs() { + a + } else { + b + }; + self.rate = select_rate(a, b, lower, upper, preferred); + } +} + +fn select_rate(a: f32, b: f32, lower: f32, upper: f32, both: f32) -> f32 { + if a >= lower && a <= upper { + if b >= lower && b <= upper { both } else { a } + } else if b >= lower && b <= upper { + b + } else { + let da = if a < lower { lower - a } else { a - upper }; + let db = if b < lower { lower - b } else { b - upper }; + clamp(if da < db { a } else { b }, lower, upper) + } +} + +///82D7AF18: closed endpoints and signed floor count, not rem_euclid. +fn wrap(lower: f32, value: f32, upper: f32) -> f32 { + let count = if value < lower { + ((upper - value) / (upper - lower)).floor() as i32 + } else if value > upper { + (((value - lower) / (upper - lower)).floor() as i32).wrapping_neg() + } else { + 0 + }; + clamp((upper - lower).mul_add(count as f32, value), lower, upper) +} + +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +fn clamp(value: f32, lower: f32, upper: f32) -> f32 { + let value = select(lower - value, lower, value); + select(upper - value, value, upper) +} +fn subtract(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn dot(a: Vector, b: Vector) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn reject(vector: Vector, axis: Vector) -> Vector { + let projection = dot(axis, vector); + std::array::from_fn(|i| vector[i] - axis[i] * projection) +} +fn length(vector: Vector) -> f32 { + square_root(dot(vector, vector)) +} +fn square_root(squared: f32) -> f32 { + // Independent PC seed. Preserve both original refinement steps and zero select. + let mut inverse = squared.sqrt().recip(); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + let result = squared * inverse; + if squared == 0.0 { 0.0 } else { result } +} + +#[cfg(test)] +#[path = "cadence/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/player/offboard/cadence/tests.rs b/crates/skate-core/src/player/offboard/cadence/tests.rs new file mode 100644 index 00000000..41f9222b --- /dev/null +++ b/crates/skate-core/src/player/offboard/cadence/tests.rs @@ -0,0 +1,180 @@ +use super::*; + +fn close(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 2.0e-6, "{actual} != {expected}"); +} +fn input() -> CadenceInput { + CadenceInput { + motion_512: [0.0, 0.0, 1.0], + motion_reference_272: [0.0; 3], + reject_axis_400: [0.0, 0.0, 1.0], + reject_enabled_708: false, + up_144: [0.0, 1.0, 0.0], + frame_rows_0_16_32: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + frame_position_48: [0.0; 3], + animation_motion_224: [0.0, 0.0, 1.0], + requested_duration_288: 0.0, + requested_phase_296: -1.0, + suppress_adjustment_353: false, + contact_flags_176: 0, + contact_point_96: [0.0, 0.0, 1.2], + } +} +fn thresholds() -> CadenceThresholds { + CadenceThresholds::from_clip_speeds(2.0, 4.0, 6.0) +} + +#[test] +fn stock_initialization_and_clip_thresholds() { + let c = BipedCadence::default(); + assert_eq!(c.phase.duration, None); + assert_eq!(c.phase.target, -1.0); + assert_eq!(thresholds().0, [0.01, 3.0, 5.0, 1.0e10]); +} + +#[test] +fn moving_rate_uses_physical_motion_relative_to_animation() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.motion_512[2] = 4.0; + i.motion_reference_272[2] = 1.0; + i.animation_motion_224[2] = 2.0; + c.update(&i, thresholds()); + assert_eq!(c.locomotion_index, 1); // Exact threshold chooses lower bucket. + close(c.phase.rate, 1.5); + close(c.phase.phase, 0.025); +} + +#[test] +fn movement_beyond_every_threshold_retains_previous_locomotion() { + let mut c = BipedCadence { + locomotion_index: 3, + ..Default::default() + }; + c.update(&input(), CadenceThresholds([0.0; 4])); + assert_eq!(c.locomotion_index, 3); +} + +#[test] +fn local_motion_keeps_vertical_component_for_cadence() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.motion_512 = [2.0, 3.0, 0.0]; + i.frame_rows_0_16_32 = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]; + c.update(&i, thresholds()); + close(c.phase.rate, 13.0f32.sqrt()); + assert_eq!(c.locomotion_index, 1); +} + +#[test] +fn contact_rejection_can_stop_locomotion_without_overwriting_animation() { + let mut c = BipedCadence::default(); + c.phase.phase = 0.25; + let mut i = input(); + i.reject_enabled_708 = true; + c.update(&i, thresholds()); + assert_eq!(c.locomotion_index, 0); + assert_eq!(c.phase.target, 0.0); // Equidistant stop targets prefer first. + assert_eq!(c.phase.duration, Some(0.2)); + close(c.phase.rate, 1.25); + close(c.phase.phase, 0.25 - 1.25 / 60.0); +} + +#[test] +fn repeated_explicit_target_does_not_recompute_rate_each_tick() { + let mut c = BipedCadence::default(); + c.phase.phase = 0.9; + let mut i = input(); + i.requested_phase_296 = 0.1; + i.requested_duration_288 = 0.3; + c.update(&i, thresholds()); + let rate = c.phase.rate; + close(rate, 1.0); + c.update(&i, thresholds()); + assert_eq!(c.phase.rate, rate); + close(c.phase.phase, 0.9 + 2.0 / 60.0); +} + +#[test] +fn forward_snap_preserves_one_endpoint_and_free_motion_wraps() { + let mut phase = BipedPhase { + phase: 0.99, + rate: 2.0, + target: 1.0, + forward_target: true, + ..Default::default() + }; + phase.advance(); + assert_eq!(phase.phase, 1.0); + phase.advance(); + assert_eq!(phase.phase, 1.0); + phase.target = -1.0; + phase.advance(); + close(phase.phase, 2.0 / 60.0); +} + +#[test] +fn closed_wrap_endpoints_are_not_euclidean_remainder() { + assert_eq!(wrap(0.0, 1.0, 1.0), 1.0); + assert_eq!(wrap(0.0, -1.0, 1.0), 1.0); + assert_eq!(wrap(0.0, 2.0, 1.0), 0.0); + assert_eq!(wrap(-0.5, 0.5, 0.5), 0.5); + assert_eq!(wrap(-0.5, -0.5, 0.5), -0.5); +} + +#[test] +fn nearest_target_can_advance_backwards_across_zero() { + let mut phase = BipedPhase { + phase: 0.01, + rate: 0.3, + target: 0.99, + ..Default::default() + }; + phase.advance(); + close(phase.phase, 0.005); +} + +#[test] +fn obstacle_target_interval_preserves_authored_boundary_ties() { + for (phase, expected_rate) in [(0.37, 0.03), (0.4, 0.5), (0.87, 0.03), (0.88, 0.52)] { + let mut c = BipedCadence::default(); + c.phase.phase = phase; + let mut i = input(); + i.contact_flags_176 = 0x100; + c.update(&i, thresholds()); + close(c.phase.rate, expected_rate); + assert_eq!(c.phase.target, -1.0); + } +} + +#[test] +fn obstacle_contact_chooses_rate_near_current_and_suppression_skips_it() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.contact_flags_176 = 0x80; + i.contact_point_96[2] = 0.7; // Half second to contact after0.2 clearance. + c.update(&i, thresholds()); + close(c.phase.rate, 0.9); + i.suppress_adjustment_353 = true; + c.update(&i, thresholds()); + close(c.phase.rate, 1.0); +} + +#[test] +fn rate_selection_uses_inclusive_limits_and_clamps_nearest_candidate() { + assert_eq!(select_rate(1.0, 2.0, 1.0, 2.0, 2.0), 2.0); + assert_eq!(select_rate(0.0, 2.0, 1.0, 2.0, 0.0), 2.0); + assert_eq!(select_rate(0.0, 4.0, 1.0, 3.0, 0.0), 3.0); // Equal distances prefer B. + assert_eq!(select_rate(0.5, 5.0, 1.0, 3.0, 0.0), 1.0); +} + +#[test] +fn no_animation_motion_gives_zero_rate_without_fabricated_clip_speed() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.animation_motion_224 = [0.0; 3]; + c.update(&i, thresholds()); + assert_eq!(c.phase.rate, 0.0); + assert_eq!(c.phase.phase, 0.0); + assert_eq!(c.locomotion_index, 1); +} diff --git a/crates/skate-core/src/player/offboard/contact_correction.rs b/crates/skate-core/src/player/offboard/contact_correction.rs new file mode 100644 index 00000000..4a35fcd9 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_correction.rs @@ -0,0 +1,91 @@ +//! Biped contact correction82D7C9E0, called before slide/movement/cadence. +//! Input displacement vectors come from actual skeleton collision output. +use crate::physics::{native_arithmetic, reciprocal_sqrt::estimate}; +type Vector = [f32; 4]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactCorrection { + /// Biped708, copied by common ProcessOutput into OffBoard333. + pub active: bool, + /// Biped400, copied into OffBoard288 even when inactive; retain old value. + pub direction: Vector, + /// Biped384, recomputed every ground update. + pub displacement: Vector, +} + +#[derive(Clone, Copy, Debug)] +pub struct Input { + /// Ground job192/208 from skeleton16288/16304. + pub collision_displacements: [Vector; 2], + pub projection_axis_416: Vector, + pub up_axis_16: Vector, +} + +impl ContactCorrection { + pub fn update(&mut self, input: Input) { + self.displacement = [0.0; 4]; + let [a, b] = input.collision_displacements; + let square_a = dot(a, a); + let square_b = dot(b, b); + //The second vector wins equality and unordered comparison. + let selected = if square_a > square_b { a } else { b }; + let maximum = select(square_a - square_b, square_a, square_b); + self.active = false; + //82D7CA7C ble skips the branch only for ordered <=. + if !(maximum <= f32::from_bits(0x38d1_b717)) { + let inverse = inverse_length(dot(selected, selected)); + self.direction = selected.map(|value| value * inverse); + self.active = true; + let length = magnitude(maximum); + let excess = length - f32::from_bits(0x3d4c_cccd); + let excess = select(excess, excess, 0.0); + let displacement = self.direction.map(|value| (value * excess) * 0.5); + self.displacement = clamp_length( + reject(displacement, input.projection_axis_416), + f32::from_bits(0x3dcc_cccd), + ); + } + self.displacement = reject(self.displacement, input.up_axis_16); + } +} + +fn dot(a: Vector, b: Vector) -> f32 { + native_arithmetic::dot3(a, b) +} +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +fn reject(vector: Vector, axis: Vector) -> Vector { + let projection = dot(axis, vector); + std::array::from_fn(|i| vector[i] - axis[i] * projection) +} +pub(super) fn inverse_length(squared: f32) -> f32 { + let mut inverse = estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + inverse +} +pub(super) fn magnitude(squared: f32) -> f32 { + let length = squared * inverse_length(squared); + if squared == 0.0 { 0.0 } else { length } +} + +/// Original82BD3D90, preserving four lanes and two reciprocal refinements. +pub(super) fn clamp_length(vector: Vector, maximum: f32) -> Vector { + let length = magnitude(dot(vector, vector)); + if !(length >= f32::from_bits(0x3780_0000)) { + return vector; + } + let bounded = select(maximum - length, length, maximum); + let mut inverse = native_arithmetic::reciprocal_estimate(length); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse); + } + vector.map(|value| (value * bounded) * inverse) +} + +#[cfg(test)] +#[path = "contact_correction/tests.rs"] +mod tests; + diff --git a/crates/skate-core/src/player/offboard/contact_correction/tests.rs b/crates/skate-core/src/player/offboard/contact_correction/tests.rs new file mode 100644 index 00000000..c2b402a8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_correction/tests.rs @@ -0,0 +1,51 @@ +use super::*; +fn state() -> ContactCorrection { + //Explicit synthetic prior state; constructor/reset ownership is separate. + ContactCorrection { active: true, direction: [0.0, 0.0, 1.0, 0.0], displacement: [1.0; 4] } +} +fn input(a: Vector, b: Vector) -> Input { + Input { collision_displacements: [a, b], projection_axis_416: [0.0; 4], up_axis_16: [0.0, 1.0, 0.0, 0.0] } +} +fn close(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}"); +} + +#[test] +fn inactive_contact_clears_displacement_but_retains_previous_direction() { + let mut state = state(); + state.update(input([0.0; 4], [0.001, 0.0, 0.0, 0.0])); + assert!(!state.active); + assert_eq!(state.direction, [0.0, 0.0, 1.0, 0.0]); + assert_eq!(state.displacement, [0.0; 4]); +} + +#[test] +fn equal_contacts_choose_second_and_apply_clearance_before_half_correction() { + let mut state = state(); + state.update(input([0.15, 0.0, 0.0, 0.0], [0.0, 0.0, -0.15, 0.0])); + assert!(state.active); + close(state.direction[2], -1.0); + close(state.displacement[2], -0.05); + close(state.displacement[0], 0.0); +} + +#[test] +fn large_correction_is_limited_then_projected_off_current_up_axis() { + let mut state = state(); + state.update(input([3.0, 4.0, 0.0, 0.0], [0.0; 4])); + close(state.direction[0], 0.6); + close(state.direction[1], 0.8); + close(state.displacement[0], 0.06); + close(state.displacement[1], 0.0); +} + +#[test] +fn projection_axis_removes_response_without_disabling_contact_observation() { + let mut state = state(); + let mut i = input([0.0, 0.0, 0.2, 0.0], [0.0; 4]); + i.projection_axis_416 = [0.0, 0.0, 1.0, 0.0]; + state.update(i); + assert!(state.active); + close(state.direction[2], 1.0); + assert_eq!(state.displacement, [0.0; 4]); +} diff --git a/crates/skate-core/src/player/offboard/controller.rs b/crates/skate-core/src/player/offboard/controller.rs new file mode 100644 index 00000000..a6ce1c3c --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller.rs @@ -0,0 +1,87 @@ +//! Canonical Biped owner and original ground dispatcher82D7C818. +//! Constructor82D7AFD8/reset82D7B1C0; S3 SHA256 +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +mod output; +mod placement; +mod state; +mod step; +pub(super) use output::build_frame; +pub use placement::PlacementInput; +#[cfg(test)] +mod tests; +use super::{movement_intent, movement_velocity}; +use crate::point_graph::PointGraph; +pub use output::GroundResult; +pub use state::{ClipMetric, State}; +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; + +/// Validated stock physics_biped curves, supplied by the data boundary. +pub struct Settings { + pub movement_intent: movement_intent::Settings, + pub movement_velocity: movement_velocity::Settings, + pub slide_vs_slope: PointGraph<8>, + pub slide_vs_speed: PointGraph<8>, +} + +/// Actual448-byte ground-job content as typed values. Ground-state geometry/ +/// processed controls and delayed scene query consumption happen before this job. +/// No default implementation: a caller must supply its actual observations. +#[derive(Clone, Copy, Debug)] +pub struct GroundJob { + pub contact_position: Vector, //0 + pub contact_normal: Vector, //16 + pub support_frame: Frame, //32..80 + pub target_position: Vector, //96 + pub target_normal: Vector, //112 + pub edge_position: Vector, //128 + pub edge_normal: Vector, //144 + pub flags: u32, //176 + pub support_id: u32, //180 + pub collision_displacements: [Vector; 2], //192/208 + pub animation_motion: Vector, //224 + pub animation_velocity: Vector, //240 + pub desired_direction: Vector, //256 + pub animation_position: Vector, //272 + pub requested_duration: f32, //288 + pub mirrored: bool, //292 + pub requested_phase: f32, //296 + pub override_duration: f32, //300 + pub animation_directed: bool, //304 + pub movement: f32, //308 + pub steering: f32, //312 + pub sprint_pressed: bool, //316 + pub suppress_lean: bool, //317 + pub suppress_minimum: bool, //318 + pub target_frame_present: bool, //352 + pub edge_active: bool, //353 + pub target_frame: Frame, //368..416 + pub ignore_obstacle: bool, //432 +} + +pub struct Controller { + pub state: State, + pub settings: Settings, +} +impl Controller { + /// Metrics are actual OffBoard walk/run/sprint AnimTransZ query results. + /// None means the original query returned absent, whose native speed is0. + pub fn new(settings: Settings, metrics: [Option; 3]) -> Self { + Self { + state: State::new(metrics), + settings, + } + } + /// Original Reset preserves cadence and positional-correction vectors. + /// Physical Enter placement is a separate lifecycle operation. + pub fn reset(&mut self) { + self.state.reset(); + } + pub fn place(&mut self, input: PlacementInput) { + self.state.place(input); + } + pub fn step_ground(&mut self, job: &GroundJob) -> GroundResult { + step::update(&mut self.state, &self.settings, job); + output::export(&self.state) + } +} diff --git a/crates/skate-core/src/player/offboard/controller/output.rs b/crates/skate-core/src/player/offboard/controller/output.rs new file mode 100644 index 00000000..54f31e6b --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/output.rs @@ -0,0 +1,66 @@ +//! Original result exporter82D80F48 and frame helper82D2CDC0. +use super::state::{IDENTITY, ZERO}; +use super::{Frame, State, Vector}; +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundResult { + pub physical_frame: Frame, //result0 <- Biped64 + pub animation_frame: Frame, //64 <-128 + pub surface_frame: Frame, //128 <-82D2CDC0(axis416,forward32) + pub velocity: Vector, //192 <-512 + pub position: Vector, //208 <-368 + pub angular_velocity: f32, //224 <-688 + pub alternate: bool, //228 <-709 + pub sliding: bool, //229 <-710 +} +pub(super) fn export(s: &State) -> GroundResult { + GroundResult { + physical_frame: s.motion.published_frame_64, + animation_frame: s.frame_output.frame, + surface_frame: build_frame(s.surface.spring_normal, s.motion.frame_0[2]), + velocity: s.frame_output.velocity, + position: s.position_368, + angular_velocity: s.motion.angular_velocity_688, + alternate: s.alternate_709, + sliding: s.sliding.active_710, + } +} +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn invsqrt(q: f32) -> f32 { + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + r +} +fn normalize(v: Vector) -> Vector { + let r = invsqrt(dot(v, v)); + v.map(|x| x * r) +} +pub(crate) fn build_frame(up: Vector, forward: Vector) -> Frame { + let right = cross(up, forward); + let q = dot(right, right); + let raw_length = q * invsqrt(q); + let length = if q == 0.0 { 0.0 } else { raw_length }; + //830BD300 initialized82F82690 from821647E0, bits37800000. + if !(length > f32::from_bits(0x3780_0000)) { + return IDENTITY; + } + let mut inverse = 1.0 / length; + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse); + } + let right = right.map(|x| x * inverse); + let up = normalize(up); + let forward = normalize(cross(right, up)); + [right, up, forward, ZERO] +} diff --git a/crates/skate-core/src/player/offboard/controller/placement.rs b/crates/skate-core/src/player/offboard/controller/placement.rs new file mode 100644 index 00000000..e8b62ad4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/placement.rs @@ -0,0 +1,61 @@ +//! Original82D7B7A0, called with real GroundEnter frame/velocity/body position. +use super::state::ZERO; +use super::{Frame, State, Vector}; +#[derive(Clone, Copy, Debug)] +pub struct PlacementInput { + pub frame: Frame, //entry0..48 + pub velocity: Vector, //entry64 + pub body_position: Vector, //entry80, actual skeleton body position + pub current_state: u32, //processed2508 + pub previous_state: u32, //processed2504 + pub previous_frame: Frame, //processed192..240 +} +impl State { + pub fn place(&mut self, i: PlacementInput) { + if i.current_state == 500 { + self.reset(); + let delta = sub(self.correction_target_592, i.previous_frame[3]); + let eligible = i.previous_state == 501 + && !(dot(i.frame[2], delta) <= 0.0) + && !(dot(i.frame[2], i.previous_frame[2]) <= f32::from_bits(0x3f66_6666)); + if eligible { + let difference = sub(i.previous_frame[3], self.correction_target_592); + let amount = dot(i.previous_frame[2], difference); + self.motion.correction_576 = i.previous_frame[2].map(|v| v * amount); + self.motion.correction_enabled_711 = true; + } else { + self.clear_entry_correction(); + } + } else if i.current_state == 501 { + self.clear_entry_correction(); + } + self.motion.frame_0 = i.frame; + self.motion.published_frame_64 = i.frame; + self.frame_output.frame = i.frame; + self.position_368 = i.body_position; + self.motion.velocity_480 = i.velocity; + self.motion.speed_704 = length(i.velocity); + self.surface.spring_normal = i.frame[1]; + self.surface.spring_delta = ZERO; + } + fn clear_entry_correction(&mut self) { + self.motion.correction_576 = ZERO; + self.correction_target_592 = ZERO; + self.motion.correction_enabled_711 = false; + } +} +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +fn sub(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn length(v: Vector) -> f32 { + let q = dot(v, v); + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + let n = q * r; + if q == 0.0 { 0.0 } else { n } +} diff --git a/crates/skate-core/src/player/offboard/controller/state.rs b/crates/skate-core/src/player/offboard/controller/state.rs new file mode 100644 index 00000000..c009e5fe --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/state.rs @@ -0,0 +1,132 @@ +use super::super::{ + cadence::{BipedCadence, CadenceThresholds}, + contact_correction::ContactCorrection, + ground_motion::GroundMotionState, + movement_intent, + position_output::FrameOutput, + slide::{Sliding, SpecialMode}, + surface_frame, +}; +use super::{Frame, Vector}; +pub(super) const ZERO: Vector = [0.0; 4]; +pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +pub(super) const IDENTITY: Frame = [[1.0, 0.0, 0.0, 0.0], UP, [0.0, 0.0, 1.0, 0.0], ZERO]; +#[derive(Clone, Copy, Debug)] +pub struct ClipMetric { + pub translation_z: f32, + pub end_time: f32, +} +impl ClipMetric { + fn speed(self) -> f32 { + (-1.0 / self.end_time) * self.translation_z + } +} + +/// Each persistent original field has one owner. Per-stage packets in step.rs +/// are temporary and copy updated shared fields back before the next producer. +#[derive(Clone, Debug)] +pub struct State { + pub motion: GroundMotionState, + pub intent: movement_intent::State, + pub surface: surface_frame::State, + pub contact: ContactCorrection, + pub special: SpecialMode, + pub sliding: Sliding, + pub frame_output: FrameOutput, + pub cadence: BipedCadence, + pub thresholds: CadenceThresholds, + pub position_368: Vector, + pub correction_target_592: Vector, + pub forward_delta_692: f32, + pub right_delta_696: f32, + pub velocity_override_remaining_772: f32, + pub alternate_709: bool, +} +impl State { + pub fn new(metrics: [Option; 3]) -> Self { + let speeds = metrics.map(|m| m.map(ClipMetric::speed).unwrap_or(0.0)); + Self { + motion: reset_motion(ZERO), + intent: movement_intent::State::default(), + surface: surface_frame::State::default(), + contact: ContactCorrection { + active: false, + direction: ZERO, + displacement: ZERO, + }, + special: SpecialMode { + enabled_714: false, + elapsed_788: 0.0, + }, + sliding: Sliding { + velocity_528: ZERO, + active_710: false, + }, + frame_output: FrameOutput { + frame: IDENTITY, + velocity: ZERO, + }, + cadence: BipedCadence::default(), + thresholds: CadenceThresholds::from_clip_speeds(speeds[0], speeds[1], speeds[2]), + position_368: ZERO, + correction_target_592: ZERO, + forward_delta_692: 0.0, + right_delta_696: 0.0, + velocity_override_remaining_772: -1.0, + alternate_709: false, + } + } + ///82D7B1C0 deliberately leaves phase716..736 and vectors576/592 alone. + pub fn reset(&mut self) { + self.motion = reset_motion(self.motion.correction_576); + self.intent = movement_intent::State::default(); + self.surface = surface_frame::State::default(); + self.contact = ContactCorrection { + active: false, + direction: ZERO, + displacement: ZERO, + }; + self.special = SpecialMode { + enabled_714: false, + elapsed_788: 0.0, + }; + self.sliding = Sliding { + velocity_528: ZERO, + active_710: false, + }; + self.frame_output = FrameOutput { + frame: IDENTITY, + velocity: ZERO, + }; + self.position_368 = ZERO; + self.forward_delta_692 = 0.0; + self.right_delta_696 = 0.0; + self.velocity_override_remaining_772 = -1.0; + self.alternate_709 = false; + } +} +fn reset_motion(correction_576: Vector) -> GroundMotionState { + GroundMotionState { + frame_0: IDENTITY, + published_frame_64: IDENTITY, + previous_support_frame_192: IDENTITY, + support_velocity_256: ZERO, + predicted_support_velocity_272: ZERO, + previous_support_velocity_288: ZERO, + support_acceleration_304: ZERO, + filtered_local_acceleration_320: ZERO, + support_yaw_336: 0.0, + previous_support_yaw_340: 0.0, + predicted_support_yaw_344: 0.0, + support_speed_348: 0.0, + support_id_352: 0, + velocity_480: ZERO, + correction_576, + target_frame_608: IDENTITY, + angular_velocity_688: 0.0, + speed_704: 0.0, + correction_enabled_711: false, + support_velocity_removed_715: false, + target_scale_784: -1.0, + } +} diff --git a/crates/skate-core/src/player/offboard/controller/step.rs b/crates/skate-core/src/player/offboard/controller/step.rs new file mode 100644 index 00000000..04ff06e3 --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/step.rs @@ -0,0 +1,194 @@ +//!82D7C818 ordering. Every shared writeback precedes its next reader. +use super::super::{ + cadence, contact_correction, ground_branch, ground_motion, movement_intent, movement_velocity, + position_output, slide, surface_frame, +}; +use super::{GroundJob, Settings, State, Vector}; +fn xyz(v: Vector) -> [f32; 3] { + [v[0], v[1], v[2]] +} +pub(super) fn update(s: &mut State, c: &Settings, j: &GroundJob) { + s.intent.speed = 0.0; + s.intent.steering = 0.0; + s.special + .update(j.movement, j.flags, s.motion.frame_0[2][1]); + s.contact.update(contact_correction::Input { + collision_displacements: j.collision_displacements, + projection_axis_416: s.surface.spring_normal, + up_axis_16: s.motion.frame_0[1], + }); + s.sliding.update( + slide::Input { + special_mode_714: s.special.enabled_714, + surface_normal_560: s.surface.source_normal, + movement_velocity_480: s.motion.velocity_480, + contact_direction_400: s.contact.active.then_some(s.contact.direction), + }, + &c.slide_vs_slope, + &c.slide_vs_speed, + ); + s.intent.update( + &c.movement_intent, + &movement_intent::Input { + flags: j.flags, + suppress_minimum: j.suppress_minimum, + magnitude: j.movement, + steering: j.steering, + sprint_pressed: j.sprint_pressed, + edge_active: j.edge_active, + ignore_obstacle: j.ignore_obstacle, + direction: j.desired_direction, + edge_tangent: j.target_frame[2], + edge_point: j.target_frame[3], + right: s.motion.frame_0[0], + up: s.motion.frame_0[1], + forward: s.motion.frame_0[2], + position: s.motion.frame_0[3], + obstacle: s.contact.active, + obstacle_normal: s.contact.direction, + sliding: s.sliding.active_710, + slide_velocity: s.sliding.velocity_528, + }, + ); + s.alternate_709 = ground_branch::select_alternate(ground_branch::BranchInput { + movement_velocity_480: s.motion.velocity_480, + output_velocity_512: s.frame_output.velocity, + right_0: s.motion.frame_0[0], + position_48: s.motion.frame_0[3], + contact_point_96: j.target_position, + contact_flags_176: j.flags, + suppressed_317: j.suppress_lean, + }); + if s.alternate_709 { + let forward = s.motion.frame_0[2]; + ground_branch::integrate_alternate( + &mut s.motion.velocity_480, + &mut s.motion.speed_704, + &mut s.motion.frame_0[3], + &mut s.frame_output, + forward, + s.surface.final_up, + s.surface.spring_normal, + ); + return; + } + velocity(s, c, j); + s.surface.update_surface(&surface_frame::SurfaceInput { + flags: j.flags, + normal: j.contact_normal, + origin: j.contact_position, + edge_point: j.edge_position, + edge_normal: j.edge_normal, + }); + s.surface.update_spring(&surface_frame::SpringInput { + velocity: s.motion.velocity_480, + up: s.motion.frame_0[1], + right: s.motion.frame_0[0], + forward: s.motion.frame_0[2], + spring_right: s.frame_output.frame[0], + spring_forward: s.frame_output.frame[2], + right_delta: s.right_delta_696, + forward_delta: s.forward_delta_692, + suppress_lean: j.suppress_lean, + }); + ground_motion::update( + &mut s.motion, + &ground_motion::GroundMotionInput { + contact_position_0: j.contact_position, + contact_frame_32: j.support_frame, + contact_target_96: j.target_position, + contact_normal_112: j.target_normal, + contact_flags_176: j.flags, + contact_id_180: j.support_id, + animation_motion_224: j.animation_motion, + animation_motion_240: j.animation_velocity, + requested_duration_288: j.requested_duration, + mirrored_292: j.mirrored, + animation_directed_304: j.animation_directed, + target_frame_present_352: j.target_frame_present, + target_frame_368: j.target_frame, + reference_frame_128: s.frame_output.frame, + contact_displacement_384: s.contact.displacement, + velocity_addition_528: s.sliding.velocity_528, + desired_up_544: s.surface.surface_normal, + correction_target_592: s.correction_target_592, + obstacle_target_672: s.intent.edge_target, + obstacle_enabled_713: s.intent.edge_aligned, + }, + ); + s.frame_output.update( + s.motion.frame_0[2], + s.surface.final_up, + s.surface.spring_normal, + s.motion.frame_0[3], + ); + position_output::update_position( + &mut s.position_368, + position_output::PositionInput { + previous_origin_112: s.motion.published_frame_64[3], + override_origin_592: s.correction_target_592, + use_override_711: s.motion.correction_enabled_711, + projection_axis_416: s.surface.spring_normal, + contact_flags_176: j.flags, + contact_origin_0: j.contact_position, + contact_origin_96: j.target_position, + frame_position_176: s.frame_output.frame[3], + animation_position_272: j.animation_position, + }, + ); + s.cadence.update( + &cadence::CadenceInput { + motion_512: xyz(s.frame_output.velocity), + motion_reference_272: xyz(s.motion.predicted_support_velocity_272), + reject_axis_400: xyz(s.contact.direction), + reject_enabled_708: s.contact.active, + up_144: xyz(s.frame_output.frame[1]), + frame_rows_0_16_32: [ + xyz(s.motion.frame_0[0]), + xyz(s.motion.frame_0[1]), + xyz(s.motion.frame_0[2]), + ], + frame_position_48: xyz(s.motion.frame_0[3]), + animation_motion_224: xyz(j.animation_motion), + requested_duration_288: j.requested_duration, + requested_phase_296: j.requested_phase, + suppress_adjustment_353: j.edge_active, + contact_flags_176: j.flags, + contact_point_96: xyz(j.target_position), + }, + s.thresholds, + ); +} +fn velocity(s: &mut State, c: &Settings, j: &GroundJob) { + let mut state = movement_velocity::State { + velocity: s.motion.velocity_480, + turn: s.motion.angular_velocity_688, + forward_delta: s.forward_delta_692, + right_delta: s.right_delta_696, + speed: s.motion.speed_704, + override_remaining: s.velocity_override_remaining_772, + }; + state.update( + &c.movement_velocity, + &movement_velocity::Input { + forward: s.motion.frame_0[2], + right: s.motion.frame_0[0], + plane_normal: s.surface.surface_normal, + desired_speed: s.intent.speed, + steering: s.intent.steering, + slope_mode: s.special.enabled_714, + obstacle: s.contact.active, + obstacle_normal: s.contact.direction, + override_gate: j.requested_phase, + override_duration: j.override_duration, + override_velocity: j.animation_velocity, + flags: j.flags, + }, + ); + s.motion.velocity_480 = state.velocity; + s.motion.angular_velocity_688 = state.turn; + s.motion.speed_704 = state.speed; + s.forward_delta_692 = state.forward_delta; + s.right_delta_696 = state.right_delta; + s.velocity_override_remaining_772 = state.override_remaining; +} diff --git a/crates/skate-core/src/player/offboard/controller/tests.rs b/crates/skate-core/src/player/offboard/controller/tests.rs new file mode 100644 index 00000000..58c7f9fe --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/tests.rs @@ -0,0 +1,190 @@ +use super::state::{IDENTITY, UP, ZERO}; +use super::*; +fn metrics() -> [Option; 3] { + [ + Some(ClipMetric { + translation_z: -2.0, + end_time: 1.0, + }), + Some(ClipMetric { + translation_z: -4.0, + end_time: 1.0, + }), + Some(ClipMetric { + translation_z: -8.0, + end_time: 1.0, + }), + ] +} +fn graph(value: f32) -> PointGraph { + PointGraph { + x: std::array::from_fn(|i| i as f32), + y: [value; N], + } +} +fn settings() -> Settings { + Settings { + movement_intent: movement_intent::Settings { + sprint_speed: graph(2.0), + normal_speed: graph(1.0), + sprint_blend: graph(0.0), + sprint_time_cap: 1.0, + slide_steering: graph(0.0), + }, + movement_velocity: movement_velocity::Settings { + slope_speed_scalar: graph(1.0), + slope_mode_speed: graph(1.0), + turn_vs_speed: graph(0.0), + turn_delta_vs_speed: graph(1.0), + }, + slide_vs_slope: graph(0.0), + slide_vs_speed: graph(0.0), + } +} +fn job() -> GroundJob { + GroundJob { + contact_position: ZERO, + contact_normal: UP, + support_frame: IDENTITY, + target_position: ZERO, + target_normal: UP, + edge_position: ZERO, + edge_normal: UP, + flags: 1, + support_id: 0, + collision_displacements: [ZERO; 2], + animation_motion: [0.0, 0.0, 1.0, 0.0], + animation_velocity: ZERO, + desired_direction: [0.0, 0.0, 1.0, 0.0], + animation_position: ZERO, + requested_duration: 1.0, + mirrored: false, + requested_phase: -1.0, + override_duration: 1.0, + animation_directed: false, + movement: 1.0, + steering: 0.0, + sprint_pressed: false, + suppress_lean: false, + suppress_minimum: false, + target_frame_present: false, + edge_active: false, + target_frame: IDENTITY, + ignore_obstacle: false, + } +} +#[test] +fn constructor_uses_clip_metrics_and_original_initial_fields() { + let s = State::new(metrics()); + assert_eq!( + s.thresholds.0, + [0.01, 3.0, 6.0, f32::from_bits(0x5015_02f9)] + ); + assert_eq!(s.motion.frame_0, IDENTITY); + assert_eq!(s.surface.surface_normal, UP); + assert_eq!(s.velocity_override_remaining_772, -1.0); + assert_eq!(s.motion.target_scale_784, -1.0); + assert_eq!(s.cadence.phase.duration, None); +} +#[test] +fn absent_clip_queries_have_original_zero_speed() { + let s = State::new([None; 3]); + assert_eq!(s.thresholds.0[1], 0.0); + assert_eq!(s.thresholds.0[2], 0.0); +} +#[test] +fn reset_preserves_phase_and_correction_vectors_only() { + let mut s = State::new(metrics()); + s.cadence.phase.phase = 0.75; + s.cadence.phase.duration = Some(2.0); + s.motion.correction_576 = [1.0; 4]; + s.correction_target_592 = [2.0; 4]; + s.motion.correction_enabled_711 = true; + s.motion.velocity_480 = [3.0; 4]; + s.intent.speed = 9.0; + s.reset(); + assert_eq!(s.cadence.phase.phase, 0.75); + assert_eq!(s.cadence.phase.duration, Some(2.0)); + assert_eq!(s.motion.correction_576, [1.0; 4]); + assert_eq!(s.correction_target_592, [2.0; 4]); + assert!(!s.motion.correction_enabled_711); + assert_eq!(s.motion.velocity_480, ZERO); + assert_eq!(s.intent.speed, 0.0); +} +fn placement(current: u32, previous: u32) -> PlacementInput { + PlacementInput { + frame: IDENTITY, + velocity: [3.0, 0.0, 4.0, 0.0], + body_position: [1.0, 2.0, 3.0, 0.0], + current_state: current, + previous_state: previous, + previous_frame: IDENTITY, + } +} +#[test] +fn landing_preserves_eligible_correction_from_air_target() { + let mut s = State::new(metrics()); + s.correction_target_592 = [0.0, 0.0, 2.0, 0.0]; + s.place(placement(500, 501)); + assert!(s.motion.correction_enabled_711); + assert_eq!(s.motion.correction_576, [0.0, 0.0, -2.0, 0.0]); + assert_eq!(s.position_368, [1.0, 2.0, 3.0, 0.0]); + assert_eq!(s.motion.speed_704, 5.0); +} +#[test] +fn air_placement_clears_correction_without_resetting_movement_timers() { + let mut s = State::new(metrics()); + s.intent.sprint_time = 4.0; + s.correction_target_592 = [1.0; 4]; + s.motion.correction_enabled_711 = true; + s.place(placement(501, 500)); + assert_eq!(s.intent.sprint_time, 4.0); + assert_eq!(s.correction_target_592, ZERO); + assert!(!s.motion.correction_enabled_711); +} +#[test] +fn other_placement_keeps_existing_correction() { + let mut s = State::new(metrics()); + s.motion.correction_576 = [1.0; 4]; + s.motion.correction_enabled_711 = true; + s.place(placement(600, 500)); + assert_eq!(s.motion.correction_576, [1.0; 4]); + assert!(s.motion.correction_enabled_711); +} +#[test] +fn full_normal_dispatch_moves_canonical_velocity_and_publishes() { + let mut c = Controller::new(settings(), metrics()); + let result = c.step_ground(&job()); + assert!(!result.alternate); + assert!(c.state.motion.velocity_480[2] > 0.0); + assert!(result.physical_frame[3][2] > 0.0); + assert_eq!(result.velocity, c.state.frame_output.velocity); + assert_eq!(result.position, c.state.position_368); +} +#[test] +fn alternate_dispatch_skips_cadence_and_position_but_exports() { + let mut c = Controller::new(settings(), metrics()); + let mut j = job(); + j.flags = 2; + j.target_position = [0.0, 0.0, 1.0, 0.0]; + c.state.motion.velocity_480 = [0.0, 5.0, 0.0, 0.0]; + c.state.frame_output.velocity = [0.0, 5.0, 0.0, 0.0]; + c.state.position_368 = [9.0; 4]; + c.state.cadence.phase.phase = 0.75; + let r = c.step_ground(&j); + assert!(r.alternate); + assert_eq!(c.state.position_368, [9.0; 4]); + assert_eq!(c.state.cadence.phase.phase, 0.75); + assert!(c.state.motion.velocity_480[1] < 5.0); +} +#[test] +fn exporter_degenerate_frame_is_identity() { + assert_eq!(build_frame(UP, UP), IDENTITY); + let f = build_frame([0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 3.0, 0.0]); + for axis in 0..3 { + for lane in 0..3 { + assert!((f[axis][lane] - IDENTITY[axis][lane]).abs() < 1e-6); + } + } + assert_eq!(f[3], ZERO); +} diff --git a/crates/skate-core/src/player/offboard/ground_branch.rs b/crates/skate-core/src/player/offboard/ground_branch.rs new file mode 100644 index 00000000..66d84860 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_branch.rs @@ -0,0 +1,61 @@ +//! Original82D7D868 branch selector and82D7E1A8 alternate integration. +use super::{contact_correction::{inverse_length, magnitude}, position_output::FrameOutput}; +use crate::physics::native_arithmetic::dot3; +type Vector = [f32; 4]; +const DT: f32 = f32::from_bits(0x3c88_8889); + +pub struct BranchInput { + pub movement_velocity_480: Vector, + pub output_velocity_512: Vector, + pub right_0: Vector, + pub position_48: Vector, + pub contact_point_96: Vector, + pub contact_flags_176: u32, + pub suppressed_317: bool, +} +/// Recomputed Biped709. This selects a ground-controller branch, not a physical state. +pub fn select_alternate(input: BranchInput) -> bool { + let velocity = if input.movement_velocity_480[1] > input.output_velocity_512[1] { + input.output_velocity_512 + } else { input.movement_velocity_480 }; + if input.suppressed_317 || input.contact_flags_176 & 2 == 0 { return false; } + let delta: Vector = std::array::from_fn(|i| input.contact_point_96[i] - input.position_48[i]); + if dot3(delta, delta) <= f32::from_bits(0x3b23_d70b) { return false; } + // Native permuted multiply/subtract computes delta cross right. + let right = input.right_0; + let normal = [ + (-delta[2]).mul_add(right[1], delta[1] * right[2]), + (-delta[0]).mul_add(right[2], delta[2] * right[0]), + (-delta[1]).mul_add(right[0], delta[0] * right[1]), + (-delta[3]).mul_add(right[3], delta[3] * right[3]), + ]; + let square = dot3(normal, normal); + let inverse = inverse_length(square); + let unit = if magnitude(square) > f32::from_bits(0x3586_37bd) { + normal.map(|v| v * inverse) + } else { [0.0; 4] }; + dot3(velocity, unit) > 4.0 +} + +///82D7E1A8 writes480/704/48 and then calls82D80360. The caller skips +///82D80548 and cadence on this branch, but still publishes82D80F48 afterward. +pub fn integrate_alternate( + velocity_480: &mut Vector, + speed_704: &mut f32, + position_48: &mut Vector, + output: &mut FrameOutput, + forward_32: Vector, + frame_up_448: Vector, + projection_axis_416: Vector, +) { + // Original830BD330 initializer82F826B0 stores [0,-9.8,0,0], using + //82165A10 zero and822F8B40 C11CCCCD. It is not the splatted830BD400. + let gravity: Vector = [0.0, f32::from_bits(0xc11c_cccd), 0.0, 0.0]; + *velocity_480 = std::array::from_fn(|i| gravity[i].mul_add(DT, velocity_480[i])); + *speed_704 = magnitude(dot3(*velocity_480, *velocity_480)); + *position_48 = std::array::from_fn(|i| velocity_480[i].mul_add(DT, position_48[i])); + output.update(forward_32, frame_up_448, projection_axis_416, *position_48); +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_branch/tests.rs b/crates/skate-core/src/player/offboard/ground_branch/tests.rs new file mode 100644 index 00000000..1d2ef33b --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_branch/tests.rs @@ -0,0 +1,44 @@ +use super::*; +fn input() -> BranchInput { + BranchInput { movement_velocity_480:[0.0,5.0,0.0,0.0], output_velocity_512:[0.0,6.0,0.0,0.0], + right_0:[1.0,0.0,0.0,0.0], position_48:[0.0;4],contact_point_96:[0.0,0.0,1.0,0.0], + contact_flags_176:2,suppressed_317:false } +} +#[test] +fn both_velocities_must_clear_projected_threshold() { + assert!(select_alternate(input())); + let mut i=input(); i.output_velocity_512[1]=4.0; + assert!(!select_alternate(i)); + let mut i=input(); i.movement_velocity_480[1]=4.0; + assert!(!select_alternate(i)); +} +#[test] +fn contact_distance_suppression_and_contact_bit_gate_branch() { + let mut i=input(); i.contact_point_96=[0.0,0.0,0.04,0.0]; + assert!(!select_alternate(i)); + let mut i=input(); i.suppressed_317=true; + assert!(!select_alternate(i)); + let mut i=input(); i.contact_flags_176=1; + assert!(!select_alternate(i)); + let mut i=input(); i.contact_point_96=[1.0,0.0,0.0,0.0]; + assert!(!select_alternate(i)); +} +#[test] +fn reversed_contact_direction_reverses_velocity_requirement() { + let mut i=input(); i.contact_point_96[2]=-1.0; + assert!(!select_alternate(i)); + let mut i=input(); i.contact_point_96[2]=-1.0; + i.movement_velocity_480[1]=-5.0; i.output_velocity_512[1]=-6.0; + assert!(select_alternate(i)); +} +#[test] +fn alternate_integrates_gravity_before_position_and_frame_output() { + let mut velocity=[1.0,0.0,0.0,0.0]; let mut speed=0.0; let mut position=[0.0;4]; + let right=[1.0,0.0,0.0,0.0]; let up=[0.0,1.0,0.0,0.0]; let forward=[0.0,0.0,1.0,0.0]; + let mut output=FrameOutput {frame:[right,up,forward,position],velocity:[0.0;4]}; + integrate_alternate(&mut velocity,&mut speed,&mut position,&mut output,forward,up,up); + assert_eq!(velocity[1],f32::from_bits(0xc11c_cccd)*DT); + assert_eq!(position[1],velocity[1]*DT); + assert!(speed>1.0); + for i in 0..3 { assert!((output.frame[3][i]-position[i]).abs()<1e-6); } +} diff --git a/crates/skate-core/src/player/offboard/ground_entry.rs b/crates/skate-core/src/player/offboard/ground_entry.rs new file mode 100644 index 00000000..94e40b48 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_entry.rs @@ -0,0 +1,86 @@ +//! Numerical BipedGround Enter82D30808 and local reset fields82D30BD0. +//! The game lifecycle also resets toolkit/contact data, initializes the controller, +//! enters board manager and skeleton mode4, and resets trajectory helpers in order. +mod math; +use math::*; +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; + +/// Ground physical-state fields, separate from the Biped controller. +#[derive(Clone, Debug)] +pub struct State { + pub frame_80: Frame, + pub flags_144_to_150: [bool; 7], + pub counter_152: u32, + pub counter_156: u32, + pub elapsed_160: f32, + pub distance_164: f32, + pub elapsed_168: f32, + pub angle_172: f32, + pub angular_velocity_176: f32, + pub duration_180: f32, +} +impl Default for State { + fn default() -> Self { + Self { frame_80:IDENTITY,flags_144_to_150:[false;7],counter_152:0,counter_156:0, + elapsed_160:0.0,distance_164:f32::from_bits(0x5015_02f9),elapsed_168:0.0, + angle_172:0.0,angular_velocity_176:0.0,duration_180:0.0 } + } +} +pub struct Input { + /// Actual82BE3650 animation-to-world frame, before changing skeleton mode. + pub animation_frame: Frame, + pub processed_velocity_608: Vector, + pub processed_flags_2484: u32, + pub processed_flags_2476: u32, + pub requested_angle_2936: f32, + pub requested_duration_2896: f32, + pub previous_state_2504: u32, + pub previous_frame_up_208: Vector, + /// Actual skeleton15872; feeds Biped initialization input80. + pub body_position_15872: Vector, +} +pub struct Placement { + pub frame: Frame, + pub planar_velocity: Vector, + pub body_position: Vector, +} +impl State { + /// Call following toolkit/contact reset; pass returned fields to82D7B7A0. + pub fn enter(&mut self, input: &Input) -> Placement { + *self=Self::default(); + self.flags_144_to_150[4]=input.previous_state_2504==502; + let up=input.animation_frame[1]; + let velocity=input.processed_velocity_608; + let planar=sub(velocity,scale(up,dot(up,velocity))); + let mut frame=input.animation_frame; + if input.processed_flags_2484 & 0x4000 != 0 { + self.flags_144_to_150[6]=true; + self.duration_180=input.requested_duration_2896; + let mut angle=input.requested_angle_2936; + if input.processed_flags_2476 & 4 != 0 { angle *= -1.0; } + let direction=if length(planar)=0.0) { + original += if original>0.0 { -TAU } else { TAU }; + } + self.angular_velocity_176=(self.angle_172+original)/self.duration_180; + } + self.frame_80=frame; + // Source sets149 after board-manager entry and before skeleton mode4. + self.flags_144_to_150[5]=true; + if input.previous_state_2504==501 { + self.flags_144_to_150[1]=f32::from_bits(0x3f35_c28f)>input.previous_frame_up_208[1]; + } + Placement {frame,planar_velocity:planar,body_position:input.body_position_15872} + } +} +#[cfg(test)] +mod tests; + diff --git a/crates/skate-core/src/player/offboard/ground_entry/math.rs b/crates/skate-core/src/player/offboard/ground_entry/math.rs new file mode 100644 index 00000000..ee05ce92 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_entry/math.rs @@ -0,0 +1,40 @@ +//! Original82C1E220 quaternion vector rotation and8296EC98 signed angle. +use super::{Frame,Vector}; +pub(super) const TAU:f32=f32::from_bits(0x40c9_0fdb); +pub(super) const IDENTITY:Frame=[[1.0,0.0,0.0,0.0],[0.0,1.0,0.0,0.0],[0.0,0.0,1.0,0.0],[0.0;4]]; +pub(super) fn dot(a:Vector,b:Vector)->f32 { crate::physics::native_arithmetic::dot3(a,b) } +pub(super) fn scale(v:Vector,s:f32)->Vector { v.map(|x|x*s) } +pub(super) fn sub(a:Vector,b:Vector)->Vector { std::array::from_fn(|i|a[i]-b[i]) } +fn cross(a:Vector,b:Vector)->Vector { + [(-a[2]).mul_add(b[1],a[1]*b[2]),(-a[0]).mul_add(b[2],a[2]*b[0]), + (-a[1]).mul_add(b[0],a[0]*b[1]),(-a[3]).mul_add(b[3],a[3]*b[3])] +} +fn inverse_length(q:f32,refinements:usize)->f32 { + let mut r=crate::physics::reciprocal_sqrt::estimate(q); + for _ in 0..refinements { r=(r*0.5).mul_add((-q).mul_add(r*r,1.0),r); } + r +} +pub(super) fn length(v:Vector)->f32 { + let q=dot(v,v);let l=q*inverse_length(q,2);if q==0.0 {0.0} else {l} +} +pub(super) fn wrap(angle:f32)->f32 { + let turns=angle*f32::from_bits(0x3e22_f983); + let fraction=turns-turns.floor(); + (fraction-if fraction>0.5 {1.0} else {0.0})*TAU +} +pub(super) fn signed_angle(a:Vector,b:Vector,axis:Vector)->f32 { + let qa=dot(a,a);let qb=dot(b,b); + if !(qa>f32::from_bits(0x38d1_b717)) || !(qb>f32::from_bits(0x38d1_b717)) { return 0.0; } + let a=scale(a,inverse_length(qa,1));let b=scale(b,inverse_length(qb,1)); + let cosine=dot(a,b).max(-1.0).min(1.0); + let angle=crate::trigonometry::acos(cosine); + if dot(cross(a,b),axis)<0.0 {TAU-angle} else {angle} +} +pub(super) fn rotate(v:Vector,axis:Vector,angle:f32)->Vector { + let (s,c)=crate::trigonometry::sin_cos(angle*0.5); + let mut q=scale(axis,s);q[3]=c; + let first=cross(q,v); + let intermediate=std::array::from_fn(|i|v[i].mul_add(c,first[i])); + let second=cross(q,intermediate); + std::array::from_fn(|i|second[i].mul_add(2.0,v[i])) +} diff --git a/crates/skate-core/src/player/offboard/ground_entry/tests.rs b/crates/skate-core/src/player/offboard/ground_entry/tests.rs new file mode 100644 index 00000000..814363b9 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_entry/tests.rs @@ -0,0 +1,42 @@ +use super::*; +fn input()->Input { + Input {animation_frame:IDENTITY,processed_velocity_608:[1.0,2.0,3.0,0.0],processed_flags_2484:0, + processed_flags_2476:0,requested_angle_2936:0.0,requested_duration_2896:1.0,previous_state_2504:0, + previous_frame_up_208:[0.0,1.0,0.0,0.0],body_position_15872:[0.0,1.0,0.0,0.0]} +} +#[test] +fn ordinary_entry_uses_actual_animation_frame_and_rejects_vertical_velocity() { + let mut state=State::default();let mut i=input();i.animation_frame[3]=[7.0,8.0,9.0,0.0]; + let placement=state.enter(&i); + assert_eq!(placement.frame,i.animation_frame); + assert_eq!(placement.planar_velocity,[1.0,0.0,3.0,0.0]); + assert_eq!(placement.body_position,i.body_position_15872); + assert!(state.flags_144_to_150[5]); + assert!(!state.flags_144_to_150[6]); +} +#[test] +fn entry_resets_previous_flags_and_records_only_matching_previous_state() { + let mut state=State::default();let mut i=input();i.previous_state_2504=502; + state.enter(&i);assert!(state.flags_144_to_150[4]); + i.previous_state_2504=501;i.previous_frame_up_208[1]=0.7; + state.enter(&i);assert!(state.flags_144_to_150[1]);assert!(!state.flags_144_to_150[4]); + i.previous_frame_up_208[1]=f32::from_bits(0x3f35_c28f); + state.enter(&i);assert!(!state.flags_144_to_150[1]); +} +#[test] +fn directed_entry_aligns_frame_to_velocity_and_preserves_translation() { + let mut state=State::default();let mut i=input();i.processed_flags_2484=0x4000; + i.processed_velocity_608=[3.0,2.0,0.0,0.0];i.animation_frame[3]=[7.0,8.0,9.0,0.0]; + let p=state.enter(&i); + assert!(state.flags_144_to_150[6]);assert!((p.frame[2][0]-1.0).abs()<1e-6); + assert_eq!(p.frame[3],i.animation_frame[3]); +} +#[test] +fn low_speed_uses_animation_forward_and_mirroring_reverses_requested_turn() { + let mut state=State::default();let mut i=input();i.processed_flags_2484=0x4000; + i.processed_velocity_608=[0.0;4];i.requested_angle_2936=0.5; + state.enter(&i);let normal=state.angular_velocity_176; + i.processed_flags_2476=4;state.enter(&i); + assert!((state.angular_velocity_176+normal).abs()<1e-5); + assert!(normal>0.0); +} diff --git a/crates/skate-core/src/player/offboard/ground_input.rs b/crates/skate-core/src/player/offboard/ground_input.rs new file mode 100644 index 00000000..797b602f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_input.rs @@ -0,0 +1,71 @@ +//! Original TU3 ground control input82D310F8; S3 SHA256 +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! Pure calculation: the existing BipedGround STATE owns the inputs and outputs. +//! Stock curves must be present and validated at the data boundary. + +mod math; +#[cfg(test)] +mod tests; + +use crate::point_graph::PointGraph; +use math::{cross, dot, inverse_frame, length, normalize, signed_angle}; + +#[derive(Clone, Copy, Debug)] +pub struct GroundInput { + pub processed_flags_2472: u32, + pub processed_direct_2684: f32, + pub processed_direct_2680: f32, + pub processed_stick_2692: f32, + pub processed_stick_2688: f32, + pub processed_scale_2912: f32, + pub processed_scale_2908: f32, + /// Forward row of the actual Biped frame, state112. + pub frame_forward_112: [f32; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +/// Ground STATE offsets:708/712 become job-input308/312 (job base400). +/// These are not Biped controller708, which is a separate correction boolean. +pub struct GroundInputOutput { + pub state_708: f32, + pub state_712: f32, + /// Original untransformed input, NOT its coordinates in the flattened frame. + pub state_656: [f32; 4], +} + +///82D310F8. Curve A key DF759B46440F16E9; curve B key2DD95B399BAE313E +/// (TurnVsStickAngle). Leading PointNegGraphData8 bounds are not inputs here. +pub fn calculate( + input: &GroundInput, + curve_a: &PointGraph<8>, + turn_vs_stick_angle: &PointGraph<8>, +) -> GroundInputOutput { + if input.processed_flags_2472 & 0x1000_0000 != 0 { + return GroundInputOutput { + state_708: input.processed_direct_2684, + state_712: input.processed_direct_2680, + state_656: [0.0; 4], + }; + } + let raw = [input.processed_stick_2692, 0.0, input.processed_stick_2688]; + let up = [0.0, 1.0, 0.0]; + let mut right = [1.0, 0.0, 0.0]; + let mut forward = [0.0, 0.0, 1.0]; + // Signed comparison: negative vertical does not receive an identity fallback. + if dot(input.frame_forward_112, up) < f32::from_bits(0x3f7f_be77) { + right = normalize(cross(up, input.frame_forward_112)); + forward = normalize(cross(right, up)); + } + let local = inverse_frame(right, up, forward, raw); + let angle = signed_angle(local[0], -local[2]) * f32::from_bits(0x3ea2_f983); + // Scalar fsel accepts either signed zero; unordered input selects -1. + let sign = if angle >= 0.0 { 1.0 } else { -1.0 }; + let absolute_angle = angle * sign; + let magnitude = length(local); + GroundInputOutput { + state_708: (curve_a.evaluate(absolute_angle) * magnitude) * input.processed_scale_2912, + state_712: ((turn_vs_stick_angle.evaluate(absolute_angle) * magnitude) * sign) + * input.processed_scale_2908, + state_656: [raw[0], raw[1], raw[2], 0.0], + } +} diff --git a/crates/skate-core/src/player/offboard/ground_input/math.rs b/crates/skate-core/src/player/offboard/ground_input/math.rs new file mode 100644 index 00000000..732797bc --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_input/math.rs @@ -0,0 +1,133 @@ +//! Independently derived from original82D310F8 and82473B98. +//! Ordinary PC reciprocal/sqrt seeds retain original refinements; they are not +//! claimed to reproduce Xenon estimate instructions bit for bit. + +type Vector = [f32; 3]; +const HALF_PI: f32 = f32::from_bits(0x3fc9_0fdb); +const PI: f32 = f32::from_bits(0x4049_0fdb); + +pub(super) fn dot(a: Vector, b: Vector) -> f32 { + // PC dot3, without a hardware VMX sum-rounding parity claim. + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} + +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + ] +} + +fn inverse_sqrt(q: f32) -> f32 { + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + let square = r * r; + let half = r * 0.5; + let error = (-q).mul_add(square, 1.0); + r = half.mul_add(error, r); + } + r +} + +pub(super) fn normalize(v: Vector) -> Vector { + let r = inverse_sqrt(dot(v, v)); + v.map(|value| value * r) +} + +pub(super) fn length(v: Vector) -> f32 { + let q = dot(v, v); + let result = q * inverse_sqrt(q); + if q == 0.0 { 0.0 } else { result } +} + +pub(super) fn inverse_frame(right: Vector, up: Vector, forward: Vector, v: Vector) -> Vector { + let c0 = cross(up, forward); + let c1 = cross(forward, right); + let c2 = cross(right, up); + let determinant = dot(right, c0); + let mut reciprocal = 1.0 / determinant; + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-determinant).mul_add(reciprocal, 1.0), reciprocal); + } + // Scale inverse columns before multiplying and accumulating input components. + let c = [c0, c1, c2]; + std::array::from_fn(|i| { + let x = c[i][0] * reciprocal; + let y = c[i][1] * reciprocal; + let z = c[i][2] * reciprocal; + z.mul_add(v[2], y.mul_add(v[1], x * v[0])) + }) +} + +pub(super) fn signed_angle(x: f32, y: f32) -> f32 { + let seed = 1.0 / y; + let reciprocal = seed.mul_add((-y).mul_add(seed, 1.0), seed); + let mut angle = rational_atan(x.mul_add(reciprocal, 0.0)); + if y < 0.0 { + angle += PI.copysign(x); + } + if y == 0.0 { + angle = HALF_PI.copysign(x); + } + angle +} + +fn reciprocal_two(d: f32) -> f32 { + let seed = 1.0 / d; + let first = seed.mul_add((-d).mul_add(seed, 1.0), seed); + let second = first.mul_add((-d).mul_add(first, 1.0), first); + // Original compares the FIRST refinement before selecting the second. + if first.is_nan() { seed } else { second } +} + +///82473B98: range reduction, rational polynomial, strict thresholds and signs. +pub(super) fn rational_atan(original: f32) -> f32 { + let absolute = original.abs(); + let invert = absolute > 1.0; + let reciprocal = reciprocal_two(absolute); + let x = if invert { reciprocal } else { absolute }; + let base_offset = if invert { HALF_PI } else { 0.0 }; + let reduced_offset = if invert { + f32::from_bits(0x3f86_0a92) + } else { + f32::from_bits(0x3f06_0a92) + }; + let denominator = x + f32::from_bits(0x3fdd_b3d7); + let numerator = x.mul_add(f32::from_bits(0x3f3b_67af), x) - 1.0; + let transformed = numerator * reciprocal_two(denominator); + let reduce = x > f32::from_bits(0x3e89_30a3); + let t = if reduce { transformed } else { x }; + let offset = if reduce { reduced_offset } else { base_offset }; + let q = t * t; + let mut p = q.mul_add(f32::from_bits(0xbf56_6bd7), f32::from_bits(0xc107_e9fb)); + p = q.mul_add(p, f32::from_bits(0xc1a4_0bfe)); + p = q.mul_add(p, f32::from_bits(0xc15b_0533)); + p *= q; + let mut d = q + f32::from_bits(0x4170_624f); + d = q.mul_add(d, f32::from_bits(0x426e_5052)); + d = q.mul_add(d, f32::from_bits(0x42ac_5090)); + d = q.mul_add(d, f32::from_bits(0x4224_43e6)); + let correction = p * reciprocal_two(d); + let polynomial = t.mul_add(correction, t); + let mut result = if t.abs() < f32::from_bits(0x3980_0000) { + t + } else { + polynomial + }; + if invert { + result = -result; + } + result += offset; + if original < 0.0 { + result = -result; + } + let huge = f32::from_bits(0x7e80_0000); + if original > huge { + result = HALF_PI; + } + if original < -huge { + result = -HALF_PI; + } + result +} diff --git a/crates/skate-core/src/player/offboard/ground_input/tests.rs b/crates/skate-core/src/player/offboard/ground_input/tests.rs new file mode 100644 index 00000000..16f83113 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_input/tests.rs @@ -0,0 +1,185 @@ +use super::*; + +fn input() -> GroundInput { + GroundInput { + processed_flags_2472: 0, + processed_direct_2684: 9.0, + processed_direct_2680: -7.0, + processed_stick_2692: 0.0, + processed_stick_2688: 1.0, + processed_scale_2912: 1.0, + processed_scale_2908: 1.0, + frame_forward_112: [0.0, 0.0, 1.0], + } +} + +fn constant(value: f32) -> PointGraph<8> { + PointGraph { + x: [0.0, 0.1, 0.2, 0.3, 0.4, 0.6, 0.8, 1.0], + y: [value; 8], + } +} + +fn angle_curve() -> PointGraph<8> { + let x = [0.0, 0.1, 0.2, 0.3, 0.4, 0.6, 0.8, 1.0]; + PointGraph { x, y: x } +} + +fn near(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 2.0e-6, "{actual} != {expected}"); +} + +#[test] +fn direct_flag_bypasses_invalid_geometry_and_scaling() { + let mut i = input(); + i.processed_flags_2472 = 0x1000_0000; + i.frame_forward_112 = [f32::NAN; 3]; + i.processed_scale_2912 = f32::NAN; + i.processed_stick_2692 = f32::NAN; + let output = calculate(&i, &constant(f32::NAN), &constant(f32::NAN)); + assert_eq!( + output, + GroundInputOutput { + state_708: 9.0, + state_712: -7.0, + state_656: [0.0; 4] + } + ); +} + +#[test] +fn other_flag_does_not_select_direct_input() { + let mut i = input(); + i.processed_flags_2472 = 0x0800_0000; + near(calculate(&i, &constant(2.0), &constant(1.0)).state_708, 2.0); +} + +#[test] +fn camera_relative_quadrants_and_output_raw_vector() { + let mut i = input(); + i.processed_stick_2692 = 0.6; + i.processed_stick_2688 = 0.8; + let right = calculate(&i, &constant(1.0), &constant(2.0)); + near(right.state_708, 1.0); + near(right.state_712, 2.0); + assert_eq!(right.state_656, [0.6, 0.0, 0.8, 0.0]); + i.processed_stick_2692 = -0.6; + near( + calculate(&i, &constant(1.0), &constant(2.0)).state_712, + -2.0, + ); +} + +#[test] +fn slope_is_flattened_before_input_transform() { + let base = calculate(&input(), &angle_curve(), &constant(1.0)); + let mut i = input(); + i.frame_forward_112 = [0.0, 0.6, 0.8]; + let slope = calculate(&i, &angle_curve(), &constant(1.0)); + near(slope.state_708, base.state_708); + near(slope.state_712, base.state_712); +} + +#[test] +fn rotated_frame_changes_angle_but_not_stored_input() { + let mut i = input(); + i.frame_forward_112 = [1.0, 0.0, 0.0]; + let result = calculate(&i, &angle_curve(), &constant(1.0)); + near(result.state_708, 0.5); + near(result.state_712, -1.0); + assert_eq!(result.state_656, [0.0, 0.0, 1.0, 0.0]); +} + +#[test] +fn threshold_equality_uses_identity_and_below_uses_frame() { + let mut i = input(); + i.frame_forward_112 = [0.1, f32::from_bits(0x3f7f_be77), 0.0]; + near(calculate(&i, &angle_curve(), &constant(1.0)).state_708, 1.0); + i.frame_forward_112[1] = f32::from_bits(0x3f7f_be76); + near(calculate(&i, &angle_curve(), &constant(1.0)).state_708, 0.5); +} + +#[test] +fn positive_vertical_identity_but_negative_vertical_has_no_invented_guard() { + let mut i = input(); + i.frame_forward_112 = [0.0, 1.0, 0.0]; + near(calculate(&i, &constant(1.0), &constant(1.0)).state_708, 1.0); + i.frame_forward_112[1] = -1.0; + assert!( + calculate(&i, &constant(1.0), &constant(1.0)) + .state_708 + .is_nan() + ); +} + +#[test] +fn zero_stick_is_zero_despite_intermediate_reciprocal_nan() { + let mut i = input(); + i.processed_stick_2688 = 0.0; + let result = calculate(&i, &constant(3.0), &constant(4.0)); + assert_eq!(result.state_708, 0.0); + assert_eq!(result.state_712, 0.0); +} + +#[test] +fn magnitude_curve_and_processed_scales_all_contribute() { + let mut i = input(); + i.processed_stick_2692 = -3.0; + i.processed_stick_2688 = 4.0; + i.processed_scale_2912 = 2.0; + i.processed_scale_2908 = 3.0; + let result = calculate(&i, &constant(0.5), &constant(2.0)); + near(result.state_708, 5.0); + near(result.state_712, -30.0); +} + +#[test] +fn shared_curve_exact_endpoints_knots_and_unordered_input() { + let curve = PointGraph { + x: [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0], + y: [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 18.0], + }; + for index in 0..8 { + assert_eq!(curve.evaluate(curve.x[index]), curve.y[index]); + } + assert_eq!(curve.evaluate(-1.0), 2.0); + assert_eq!(curve.evaluate(2.0), 18.0); + assert_eq!(curve.evaluate(f32::NAN), 18.0); + assert_eq!(curve.evaluate(0.0625), 3.0); +} + +#[test] +fn original_angle_tracks_finite_atan_across_reduction_boundaries() { + for boundary in [ + f32::from_bits(0x3980_0000), + f32::from_bits(0x3e89_30a3), + 1.0, + ] { + for bits in [ + boundary.to_bits() - 1, + boundary.to_bits(), + boundary.to_bits() + 1, + ] { + let value = f32::from_bits(bits); + near(math::rational_atan(value), value.atan()); + near(math::rational_atan(-value), (-value).atan()); + } + } + for value in [0.0_f32, 0.1, 0.5, 2.0, 10.0, 1.0e30] { + near(math::rational_atan(value), value.atan()); + } +} + +#[test] +fn axis_override_preserves_x_sign_even_when_both_inputs_are_zero() { + assert_eq!(math::signed_angle(0.0, 0.0).to_bits(), 0x3fc9_0fdb); + assert_eq!(math::signed_angle(-0.0, -0.0).to_bits(), 0xbfc9_0fdb); + near( + math::signed_angle(1.0, -1.0), + 3.0 * std::f32::consts::FRAC_PI_4, + ); + near( + math::signed_angle(-1.0, -1.0), + -3.0 * std::f32::consts::FRAC_PI_4, + ); +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle.rs b/crates/skate-core/src/player/offboard/ground_lifecycle.rs new file mode 100644 index 00000000..f560198f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle.rs @@ -0,0 +1,24 @@ +//! BipedGround Exit82D30CC0, PostPhysics82D32AA0 and FillPhysOut82D32D38. +//! Borrows canonical Ground state; source writes only the returned packet fields. +mod collision; +mod exit; +mod post; +mod publication; +#[cfg(test)] +mod tests; +use super::ground_entry::Vector; +pub use collision::{CollisionInput, CollisionSettings, check_collision}; +pub use exit::{ExitServices, exit}; +pub use post::{PostInput, post_physics}; +pub use publication::{Publication, PublicationInput, publish}; +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +fn length(v: Vector) -> f32 { + let q = dot(v, v); + let mut r = crate::physics::reciprocal_sqrt::estimate(q); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + if q == 0.0 { 0.0 } else { q * r } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/collision.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/collision.rs new file mode 100644 index 00000000..89355697 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/collision.rs @@ -0,0 +1,66 @@ +//! Complete82D90148, borrowing the existing shared Wipeout request owner. +use super::dot; +use crate::player::{ + offboard::ground_entry::Vector, + wipeout::{self, Requests}, +}; +pub struct CollisionSettings { + ///physics_wipeout/default layout96..120. + pub vehicle_scalar: f32, + pub vehicle_contact: f32, + pub maximum_displacement: f32, + pub maximum_arm_contact: f32, + pub maximum_body_contact: f32, + pub minimum_speed: f32, + pub maximum_squash: f32, + ///Actual attribute472174920C68FBE3. Required when processed2480bit7. + pub special_scalar: f32, +} +pub struct CollisionInput<'a> { + pub shared: &'a wipeout::Frame, + pub skeleton_velocity_16336: Vector, + pub contact_flag_4072: bool, + pub contact_force_4056: f32, +} +pub fn check_collision(requests: &mut Requests, s: &CollisionSettings, i: &CollisionInput<'_>) { + requests.mode = 4; + if dot(i.skeleton_velocity_16336, i.skeleton_velocity_16336) + <= s.minimum_speed * s.minimum_speed + { + return; + } + let requested = if i.shared.flags_2480 & 0x80 != 0 { + s.special_scalar + } else { + 1.0 + }; + //The source loads vehicle_scalar whenever the contact flag is present, + //even when vehicle_contact comparison does not emit reason7. + let vehicle = if i.contact_flag_4072 { + if i.contact_force_4056 > s.vehicle_contact { + requests.request(7, 0.0); + } + s.vehicle_scalar + } else { + 1.0 + }; + let scalar = if requested - vehicle >= 0.0 { + vehicle + } else { + requested + }; + if i.shared.maximum_pose_error > scalar * s.maximum_squash { + requests.request(18, 0.0); + } else { + let limit = s.maximum_displacement * scalar; + if dot(i.shared.pose_error, i.shared.pose_error) > limit * limit { + requests.request(1, 0.0); + } else if wipeout::regional_force( + i.shared, + s.maximum_body_contact * scalar, + s.maximum_arm_contact * vehicle, + ) { + requests.request(0, 0.0); + } + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/exit.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/exit.rs new file mode 100644 index 00000000..96150099 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/exit.rs @@ -0,0 +1,20 @@ +//!82D30CC0 lifecycle ordering. Toolkit refresh is a real prerequisite service. +pub trait ExitServices { + ///Run complete original82D81610 against the actual toolkit/contact owner. + ///Its active-contact branch must not be replaced by a reset/no-op. + fn refresh_toolkit_contacts(&mut self); + ///After refresh, zero actual toolkit320 and30452/30456/30460/30464/30468. + fn clear_toolkit_exit_fields(&mut self); + fn ground_geometry_active(&self) -> bool; + ///Actual Groundstate76 geometry service virtual12 release. + fn release_ground_geometry(&mut self); + fn clear_ground_geometry_active(&mut self); +} +pub fn exit(services: &mut impl ExitServices) { + services.refresh_toolkit_contacts(); + services.clear_toolkit_exit_fields(); + if services.ground_geometry_active() { + services.release_ground_geometry(); + services.clear_ground_geometry_active(); + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/post.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/post.rs new file mode 100644 index 00000000..1b51e1d5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/post.rs @@ -0,0 +1,54 @@ +use super::{CollisionInput, CollisionSettings, check_collision, dot, length}; +use crate::player::{ + offboard::ground_entry::{State, Vector}, + wipeout::Requests, +}; +pub struct PostInput<'a> { + pub collision: CollisionInput<'a>, + pub processed_flags_2484: u32, + pub processed_velocity_608: Vector, + pub skeleton_displacement_16288: Vector, + pub skeleton_displacement_16304: Vector, + pub ground_kind_356: u32, +} +pub fn post_physics( + state: &mut State, + requests: &mut Requests, + settings: &CollisionSettings, + input: &PostInput<'_>, +) { + check_collision(requests, settings, &input.collision); + if input.processed_flags_2484 & 0x400 != 0 { + if state.flags_144_to_150[6] && state.flags_144_to_150[2] { + requests.request(31, 0.0); + } + state.elapsed_168 = 0.0; + if state.flags_144_to_150[2] + || state.angle_172 > f32::from_bits(0x3fc9_0fdb) + || matches!(input.ground_kind_356, 2 | 4) + || 0.5 > state.frame_80[1][1] + { + requests.request(31, 0.0); + } else if !(dot( + input.processed_velocity_608, + input.skeleton_displacement_16304, + ) >= length(input.processed_velocity_608) * f32::from_bits(0xbca3_d70a)) + { + requests.request(32, 0.0); + } + } else { + let a = dot(input.skeleton_displacement_16304, state.frame_80[1]).abs(); + let b = dot(input.skeleton_displacement_16288, state.frame_80[1]).abs(); + if a > f32::from_bits(0x3eb3_3333) || b > f32::from_bits(0x3e19_999a) { + state.elapsed_168 += f32::from_bits(0x3c88_8889); + } else { + state.elapsed_168 = 0.0; + } + if !(state.elapsed_168 < f32::from_bits(0x3d4c_ccce)) { + requests.request(32, 0.0); + } + } + if state.flags_144_to_150[1] { + requests.request(28, 0.0); + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/publication.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/publication.rs new file mode 100644 index 00000000..a4bf751d --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/publication.rs @@ -0,0 +1,65 @@ +use super::length; +use crate::player::offboard::ground_entry::{State, Vector}; +pub struct PublicationInput { + pub ground_flags_752_to_754: [bool; 3], + pub contact_flags_368: u32, + pub contact_position_192: Vector, + pub query_position_816: Vector, + pub processed_flags_2476: u32, + pub ground_kind_356: u32, + pub ground_scalar_360: f32, + pub motion_vector_1040: Vector, + pub motion_up_864: Vector, + ///Actual hand manager flags105/106/107 and217/218/219. + pub hand_flags: [[bool; 3]; 2], +} +///Only fields written by82D32D38/82D785F8; caller preserves all other output. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Publication { + pub physics_counter_36: u32, + pub physics_counter_40: u32, + pub physics_flag_86: bool, + pub offboard_flag_304: bool, + pub offboard_kind_88: u32, + pub offboard_scalar_112: f32, + pub offboard_flag_329: bool, + pub offboard_flag_330: bool, + pub offboard_distance_116: f32, + pub offboard_flag_334: bool, + pub offboard_scalar_32: f32, + pub offboard_flag_328: bool, + pub animation_vector_144: Vector, + pub animation_flag_164: bool, + pub offboard_hand_flags_306_307: [bool; 2], +} +pub fn publish(state: &State, i: &PublicationInput) -> Publication { + let near = i.ground_flags_752_to_754[1] || !(state.distance_164 >= f32::from_bits(0x3daa_aaab)); + let blocked = state.flags_144_to_150[3] || i.contact_flags_368 & 8 != 0; + let distance = if i.ground_flags_752_to_754[0] && i.contact_flags_368 & 1 != 0 { + length(std::array::from_fn(|n| { + i.contact_position_192[n] - i.query_position_816[n] + })) + } else { + f32::from_bits(0x5015_02f9) + }; + let projection = super::dot(i.motion_up_864, i.motion_vector_1040); + Publication { + physics_counter_36: state.counter_152, + physics_counter_40: state.counter_156, + physics_flag_86: i.processed_flags_2476 & 0x0040_0000 != 0 && !state.flags_144_to_150[0], + offboard_flag_304: state.flags_144_to_150[0], + offboard_kind_88: i.ground_kind_356, + offboard_scalar_112: i.ground_scalar_360, + offboard_flag_329: blocked, + offboard_flag_330: near && !blocked, + offboard_distance_116: distance, + offboard_flag_334: i.ground_flags_752_to_754[2], + offboard_scalar_32: if state.flags_144_to_150[2] { 1.0 } else { 0.0 }, + offboard_flag_328: state.flags_144_to_150[2], + animation_vector_144: std::array::from_fn(|n| { + i.motion_vector_1040[n] - i.motion_up_864[n] * projection + }), + animation_flag_164: true, + offboard_hand_flags_306_307: i.hand_flags.map(|f| (f[0] && !f[1]) || f[2]), + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/tests.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/tests.rs new file mode 100644 index 00000000..61fcd5d4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/tests.rs @@ -0,0 +1,215 @@ +use super::*; +use crate::physics::skeleton_animation_record::IDENTITY; +use crate::player::{ + offboard::ground_entry::State, + wipeout::{Frame, Requests}, +}; +fn input() -> Frame { + Frame { + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + category: 100, + timestep: 1.0 / 60.0, + time_on_ground: 1.0, + speed: 0.0, + animation_up: [0.0, 1.0, 0.0, 0.0], + landing_angle: 0.0, + deck_velocity: [0.0; 4], + com_velocity: [0.0; 4], + jump_fix_frames: 1000, + deck: IDENTITY, + input_board: IDENTITY, + world_to_animation: IDENTITY, + closing_velocity: [0.0; 4], + board_material_flags: 0, + board_contact: true, + wheel_contact: true, + board_contact_normal: [0.0, 1.0, 0.0, 0.0], + opposing_contact: 0.0, + regions_force: [0.0; 8], + maximum_skater_force: 0.0, + vehicle_force: 0.0, + group_8: false, + conflicting: false, + compliant: false, + highest_normal: [0.0, 1.0, 0.0, 0.0], + pose_error: [0.0; 4], + maximum_pose_error: 0.0, + flip_active: false, + flip_requested_speed: 0.0, + system_up_y: 1.0, + grind_selected: false, + grind_normal_valid: false, + grind_normal: [0.0, 1.0, 0.0, 0.0], + } +} +fn settings() -> CollisionSettings { + CollisionSettings { + vehicle_scalar: 0.35, + vehicle_contact: 6.0, + maximum_displacement: 0.3, + maximum_arm_contact: 20.0, + maximum_body_contact: 20.0, + minimum_speed: 0.0, + maximum_squash: 0.6, + special_scalar: 1.0, + } +} +fn collision(f: &Frame) -> CollisionInput<'_> { + CollisionInput { + shared: f, + skeleton_velocity_16336: [1.0, 0.0, 0.0, 0.0], + contact_flag_4072: false, + contact_force_4056: 0.0, + } +} +fn post(f: &Frame) -> PostInput<'_> { + PostInput { + collision: collision(f), + processed_flags_2484: 0, + processed_velocity_608: [0.0; 4], + skeleton_displacement_16288: [0.0; 4], + skeleton_displacement_16304: [0.0; 4], + ground_kind_356: 0, + } +} +#[test] +fn duplicate_ground_triggers_increment_count_and_keep_shared_requests() { + let f = input(); + let mut p = post(&f); + p.processed_flags_2484 = 0x400; + let mut s = State::default(); + s.flags_144_to_150[6] = true; + s.flags_144_to_150[2] = true; + s.flags_144_to_150[1] = true; + let mut q = Requests::new(); + q.request(3, 2.0); + post_physics(&mut s, &mut q, &settings(), &p); + assert_eq!(q.count, 4); + assert!(q.reasons[31] && q.reasons[28] && q.reasons[3]); + assert_eq!(q.values[3], 2.0); + assert_eq!(q.mode, 4); +} +#[test] +fn displacement_requires_persistent_ticks_then_clears_when_contact_settles() { + let f = input(); + let mut p = post(&f); + p.skeleton_displacement_16304 = [0.0, 0.5, 0.0, 0.0]; + let mut s = State::default(); + let mut q = Requests::new(); + for _ in 0..2 { + post_physics(&mut s, &mut q, &settings(), &p); + } + assert_eq!(q.count, 0); + post_physics(&mut s, &mut q, &settings(), &p); + assert!(q.reasons[32]); + p.skeleton_displacement_16304 = [0.0; 4]; + post_physics(&mut s, &mut q, &settings(), &p); + assert_eq!(s.elapsed_168, 0.0); +} +#[test] +fn opposite_displacement_triggers_only_beyond_speed_scaled_threshold() { + let f = input(); + let mut p = post(&f); + p.processed_flags_2484 = 0x400; + p.processed_velocity_608 = [2.0, 0.0, 0.0, 0.0]; + let mut s = State::default(); + let mut q = Requests::new(); + p.skeleton_displacement_16304 = [-0.02, 0.0, 0.0, 0.0]; + post_physics(&mut s, &mut q, &settings(), &p); + assert_eq!(q.count, 0); + p.skeleton_displacement_16304[0] = -0.03; + post_physics(&mut s, &mut q, &settings(), &p); + assert!(q.reasons[32]); +} +#[test] +fn vehicle_scalar_applies_even_below_vehicle_request_force() { + let mut f = input(); + f.maximum_pose_error = 0.3; + let mut i = collision(&f); + i.contact_flag_4072 = true; + i.contact_force_4056 = 0.0; + let mut q = Requests::new(); + check_collision(&mut q, &settings(), &i); + assert!(q.reasons[18]); + assert!(!q.reasons[7]); +} +#[test] +fn collision_priority_selects_squash_before_displacement_and_force() { + let mut f = input(); + f.maximum_pose_error = 1.0; + f.pose_error = [1.0, 0.0, 0.0, 0.0]; + f.regions_force = [100.0; 8]; + let mut q = Requests::new(); + check_collision(&mut q, &settings(), &collision(&f)); + assert!(q.reasons[18]); + assert!(!q.reasons[1] && !q.reasons[0]); +} +fn publication() -> PublicationInput { + PublicationInput { + ground_flags_752_to_754: [false; 3], + contact_flags_368: 0, + contact_position_192: [3.0, 0.0, 4.0, 0.0], + query_position_816: [0.0; 4], + processed_flags_2476: 0, + ground_kind_356: 4, + ground_scalar_360: 0.7, + motion_vector_1040: [2.0, 3.0, 4.0, 5.0], + motion_up_864: [0.0, 1.0, 0.0, 2.0], + hand_flags: [[true, false, false], [true, true, false]], + } +} +#[test] +fn output_gates_distance_and_preserves_projection_w_lane() { + let mut s = State::default(); + s.distance_164 = 0.0; + let mut i = publication(); + let o = publish(&s, &i); + assert_eq!(o.offboard_distance_116, 1e10); + assert!(o.offboard_flag_330); + assert_eq!(o.animation_vector_144, [2.0, 0.0, 4.0, -1.0]); + assert_eq!(o.offboard_hand_flags_306_307, [true, false]); + i.ground_flags_752_to_754[0] = true; + i.contact_flags_368 = 9; + let o = publish(&s, &i); + assert_eq!(o.offboard_distance_116, 5.0); + assert!(o.offboard_flag_329); + assert!(!o.offboard_flag_330); +} +#[test] +fn exit_calls_real_refresh_before_clearing_and_releasing() { + struct Service { + calls: Vec, + active: bool, + } + impl ExitServices for Service { + fn refresh_toolkit_contacts(&mut self) { + self.calls.push(1) + } + fn clear_toolkit_exit_fields(&mut self) { + self.calls.push(2) + } + fn ground_geometry_active(&self) -> bool { + self.active + } + fn release_ground_geometry(&mut self) { + self.calls.push(3) + } + fn clear_ground_geometry_active(&mut self) { + self.calls.push(4); + self.active = false + } + } + let mut service = Service { + calls: vec![], + active: true, + }; + exit(&mut service); + assert_eq!(service.calls, [1, 2, 3, 4]); + service.calls.clear(); + exit(&mut service); + assert_eq!(service.calls, [1, 2]); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion.rs b/crates/skate-core/src/player/offboard/ground_motion.rs new file mode 100644 index 00000000..94028070 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion.rs @@ -0,0 +1,90 @@ +//! Original TU3 Biped movement82D7EDC8 and leaves800C0/80EB0/80CE0. +//! S3 SHA256431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! The controller owns these fields once; this stage makes no collision queries. + +mod approach; +mod math; +mod publication; +mod support; +#[cfg(test)] +mod tests; + +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; +use math::*; + +/// Only fields written by this stage. Integrate these into the one Biped owner; +/// velocity480, correction576/711 and frame0 must not become stale shadow copies. +#[derive(Clone, Debug)] +pub struct GroundMotionState { + pub frame_0: Frame, + pub published_frame_64: Frame, + pub previous_support_frame_192: Frame, + pub support_velocity_256: Vector, + pub predicted_support_velocity_272: Vector, + pub previous_support_velocity_288: Vector, + pub support_acceleration_304: Vector, + pub filtered_local_acceleration_320: Vector, + pub support_yaw_336: f32, + pub previous_support_yaw_340: f32, + pub predicted_support_yaw_344: f32, + pub support_speed_348: f32, + pub support_id_352: u32, + pub velocity_480: Vector, + pub correction_576: Vector, + pub target_frame_608: Frame, + pub angular_velocity_688: f32, + pub speed_704: f32, + pub correction_enabled_711: bool, + pub support_velocity_removed_715: bool, + pub target_scale_784: f32, +} + +/// Actual job values and external-controller fields, never query placeholders. +#[derive(Clone, Copy, Debug)] +pub struct GroundMotionInput { + pub contact_position_0: Vector, + pub contact_frame_32: Frame, + pub contact_target_96: Vector, + pub contact_normal_112: Vector, + pub contact_flags_176: u32, + pub contact_id_180: u32, + pub animation_motion_224: Vector, + pub animation_motion_240: Vector, + pub requested_duration_288: f32, + pub mirrored_292: bool, + pub animation_directed_304: bool, + pub target_frame_present_352: bool, + pub target_frame_368: Frame, + /// Externally produced Biped frame128..176; read by800C0 and angle limiting. + pub reference_frame_128: Frame, + /// Contact correction producer384 (not the job's frame384). + pub contact_displacement_384: Vector, + pub velocity_addition_528: Vector, + pub desired_up_544: Vector, + pub correction_target_592: Vector, + pub obstacle_target_672: Vector, + pub obstacle_enabled_713: bool, +} + +///82D7EDC8. Order: support history, approach, displacement/heading, publication. +pub fn update(state: &mut GroundMotionState, input: &GroundMotionInput) { + let support_delta = support::update(state, input); + let old_position = state.frame_0[3]; + state.frame_0[3] = ZERO; + let (step, height) = approach::calculate(state, input, old_position, support_delta); + let displacement = add(ZERO, step); + let displacement = add(displacement, height); + let displacement = add(displacement, input.contact_displacement_384); + let displacement = madd(input.velocity_addition_528, DT, displacement); + let displacement = madd(state.predicted_support_velocity_272, DT, displacement); + let angle = (state.angular_velocity_688 + state.predicted_support_yaw_344) * DT; + let axis = state.frame_0[1]; + state.frame_0 = state.frame_0.map(|row| rotate(row, axis, angle)); + state.frame_0[0] = normalize(cross(input.desired_up_544, state.frame_0[2])); + state.frame_0[1] = input.desired_up_544; + state.frame_0[2] = normalize(cross(state.frame_0[0], input.desired_up_544)); + state.frame_0 = crate::physics::skeleton_root::orthonormalize(state.frame_0); + state.frame_0[3] = add(old_position, displacement); + publication::update(state, displacement); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/approach.rs b/crates/skate-core/src/player/offboard/ground_motion/approach.rs new file mode 100644 index 00000000..ee36f67d --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/approach.rs @@ -0,0 +1,120 @@ +//!82D7F458..FDD0 animation-directed movement and ground/obstacle approach. +use super::math::*; +use super::{Frame, GroundMotionInput, GroundMotionState, Vector}; +use crate::trigonometry; + +///82D80EB0. No state change when the job has no target frame. +fn refresh_target(s: &mut GroundMotionState, i: &GroundMotionInput) { + if i.target_frame_present_352 { + s.target_frame_608 = i.target_frame_368; + s.target_frame_608[3] = madd( + i.target_frame_368[0], + f32::from_bits(0x3e4c_cccd), + i.target_frame_368[3], + ); + } +} + +pub(super) fn calculate( + s: &mut GroundMotionState, + i: &GroundMotionInput, + position: Vector, + delta: Frame, +) -> (Vector, Vector) { + let mut step = scale(s.velocity_480, DT); + let mut height = ZERO; + if i.animation_directed_304 { + let animation_speed = length(i.animation_motion_224); + let stationary = animation_speed < EPSILON; + let mut difference = sub(s.target_frame_608[3], position); + let mut distance = length(difference); + if !(s.target_scale_784 >= 0.0) { + refresh_target(s, i); + difference = sub(s.target_frame_608[3], position); + distance = length(difference); + s.target_scale_784 = distance + / if stationary { + i.requested_duration_288 + } else { + animation_speed + }; + } + step = ZERO; + if !(distance <= EPSILON) { + let mut step_length = s.target_scale_784 * DT; + if !stationary { + step_length = length(i.animation_motion_240) * step_length; + } + step = scale(scale(difference, reciprocal(distance)), step_length); + } + let sine = clamp_signed(dot( + cross(s.target_frame_608[0], s.frame_0[2]), + s.frame_0[1], + )); + let angle = trigonometry::asin(sine); + let denominator = if stationary { + i.requested_duration_288 * s.target_scale_784 + } else { + s.target_scale_784 * animation_speed + }; + s.angular_velocity_688 = 0.0; + if !(denominator <= EPSILON) { + s.angular_velocity_688 = ((1.0 - distance / denominator) * angle) * RATE; + } + s.target_frame_608 = compose(s.target_frame_608, delta); + return (step, height); + } + s.target_scale_784 = -1.0; + refresh_target(s, i); + if i.contact_flags_176 & 2 == 0 && !i.obstacle_enabled_713 { + if i.contact_flags_176 & 1 != 0 { + height = height_adjustment(s, i, position); + } + return (step, height); + } + let mut target = if i.obstacle_enabled_713 { + i.obstacle_target_672 + } else { + let side = normalize_safe(cross(UP, s.frame_0[2]), s.frame_0[0]); + let point = if s.correction_enabled_711 { + i.correction_target_592 + } else { + i.contact_target_96 + }; + sub(point, scale(side, dot(sub(point, position), side))) + }; + let difference = sub(target, position); + if dot(difference, difference) < f32::from_bits(0x3780_0000) + || !(dot(difference, s.velocity_480) >= -0.5) + { + let predicted = madd(scale(s.velocity_480, DT), 2.0, position); + target = [predicted[0], target[1], predicted[2], predicted[3]]; + } + let difference = sub(target, position); + let budget = length(step); + let distance = length(difference); + if !(distance <= budget) { + step = scale(normalize_safe(difference, ZERO), budget); + } else { + let normal = if i.obstacle_enabled_713 { + i.target_frame_368[1] + } else { + i.contact_normal_112 + }; + let angle = f32::from_bits(0x4234_0000) * f32::from_bits(0x3c8e_fa35); + let limited = limit_angle(normal, i.reference_frame_128[1], angle); + let normal = normalize_safe(limited, ZERO); + let tangent = sub(s.velocity_480, scale(normal, dot(s.velocity_480, normal))); + step = madd(normalize_safe(tangent, ZERO), budget - distance, difference); + } + if !(budget >= f32::from_bits(0x3c23_d70a)) { + height = height_adjustment(s, i, position); + } + (step, height) +} +fn height_adjustment(s: &GroundMotionState, i: &GroundMotionInput, position: Vector) -> Vector { + scale( + s.frame_0[1], + dot(sub(i.contact_position_0, position), s.frame_0[1]) * f32::from_bits(0x3d4c_cccd), + ) +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/math.rs b/crates/skate-core/src/player/offboard/ground_motion/math.rs new file mode 100644 index 00000000..45417ef4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/math.rs @@ -0,0 +1,130 @@ +//! Original VMX operation trees expressed with ordinary PC floating-point math. +//! PC seeds and dot sums are not a bit-exact Xenon emulation claim. +use super::{Frame, Vector}; +use crate::trigonometry; +pub(super) const ZERO: Vector = [0.0; 4]; +pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +pub(super) const IDENTITY: Frame = [[1.0, 0.0, 0.0, 0.0], UP, [0.0, 0.0, 1.0, 0.0], ZERO]; +pub(super) const DT: f32 = f32::from_bits(0x3c88_8889); +pub(super) const RATE: f32 = f32::from_bits(0x426f_ffff); +pub(super) const EPSILON: f32 = f32::from_bits(0x3a83_126f); +const NORMAL_EPSILON: f32 = f32::from_bits(0x3586_37bd); +pub(super) fn add(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn sub(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn scale(a: Vector, b: f32) -> Vector { + a.map(|v| v * b) +} +pub(super) fn madd(a: Vector, b: f32, c: Vector) -> Vector { + std::array::from_fn(|i| a[i].mul_add(b, c[i])) +} +pub(super) fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn inverse_sqrt(q: f32) -> f32 { + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + let square = r * r; + let half = r * 0.5; + r = half.mul_add((-q).mul_add(square, 1.0), r); + } + r +} +pub(super) fn reciprocal(d: f32) -> f32 { + let mut r = 1.0 / d; + for _ in 0..2 { + r = r.mul_add((-d).mul_add(r, 1.0), r); + } + r +} +pub(super) fn length(v: Vector) -> f32 { + let q = dot(v, v); + let x = q * inverse_sqrt(q); + if q == 0.0 { 0.0 } else { x } +} +pub(super) fn normalize(v: Vector) -> Vector { + scale(v, inverse_sqrt(dot(v, v))) +} +pub(super) fn normalize_safe(v: Vector, fallback: Vector) -> Vector { + let q = dot(v, v); + let r = inverse_sqrt(q); + let n = scale(v, r); + let len = if q == 0.0 { 0.0 } else { q * r }; + if len > NORMAL_EPSILON { n } else { fallback } +} +pub(super) fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +pub(super) fn clamp_signed(v: f32) -> f32 { + let low = select(-1.0 - v, -1.0, v); + select(1.0 - low, low, 1.0) +} +pub(super) fn transform_vector(v: Vector, f: Frame) -> Vector { + madd(f[2], v[2], madd(f[1], v[1], scale(f[0], v[0]))) +} +pub(super) fn transform_point(v: Vector, f: Frame) -> Vector { + madd(f[2], v[2], madd(f[1], v[1], madd(f[0], v[0], f[3]))) +} +pub(super) fn compose(a: Frame, b: Frame) -> Frame { + [ + transform_vector(a[0], b), + transform_vector(a[1], b), + transform_vector(a[2], b), + transform_point(a[3], b), + ] +} +pub(super) fn inverse_rigid(f: Frame) -> Frame { + crate::physics::skeleton_root::inverse_rigid(&f) +} + +///82D7FEE0..FFCC Rodrigues columns, including original repeated X in W. +pub(super) fn rotate(v: Vector, axis: Vector, angle: f32) -> Vector { + let [x, y, z, _] = axis; + let (s, c) = trigonometry::sin_cos(angle); + let t = 1.0 - c; + let (tx, ty, tz) = (t * x, t * y, t * z); + let (sx, sy, sz) = (s * x, s * y, s * z); + let xx = tx.mul_add(x, c); + let yx = ty * x - sz; + let zx = tz.mul_add(x, sy); + let columns = [ + [xx, tx.mul_add(y, sz), tx * z - sy, xx], + [yx, ty.mul_add(y, c), ty.mul_add(z, sx), yx], + [zx, tz * y - sx, tz.mul_add(z, c), zx], + ]; + transform_vector(v, [columns[0], columns[1], columns[2], ZERO]) +} + +/// Original82BD3E78, independently checked against its full raw body. +pub(super) fn limit_angle(target: Vector, from: Vector, maximum: f32) -> Vector { + let a = normalize_safe(target, ZERO); + let b = normalize_safe(from, ZERO); + let axis = cross(a, b); + let cosine = dot(a, b).max(-1.0).min(1.0); + let angle = trigonometry::acos(cosine); + let tau = f32::from_bits(0x40c9_0fdb); + let turns = angle.mul_add(f32::from_bits(0x3e22_f983), 0.5).floor(); + let closest = (-turns).mul_add(tau, angle); + if closest.abs() < maximum || dot(axis, axis) < f32::from_bits(0x3780_0000) { + return target; + } + let axis = normalize(axis); + let (s, c) = trigonometry::sin_cos(-maximum * 0.5); + let mut q = scale(axis, s); + q[3] = c; //82BD4130 vrlimi inserts cosine in the quaternion W lane. + let first = cross(q, from); + let intermediate = madd(from, c, first); + let second = cross(q, intermediate); + scale(madd(second, 2.0, from), length(target)) +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/publication.rs b/crates/skate-core/src/player/offboard/ground_motion/publication.rs new file mode 100644 index 00000000..3b3bb0c5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/publication.rs @@ -0,0 +1,28 @@ +//!82D80CE0: published frame copy and retained positional correction decay. +use super::math::*; +use super::{GroundMotionState, Vector}; +pub(super) fn update(s: &mut GroundMotionState, displacement: Vector) { + s.published_frame_64 = s.frame_0; + if !s.correction_enabled_711 { + return; + } + let magnitude = length(s.correction_576); + if magnitude <= f32::from_bits(0x3c23_d70a) { + s.correction_576 = ZERO; + s.correction_enabled_711 = false; + } else { + let unit = scale(s.correction_576, 1.0 / magnitude); + let proportional = magnitude * f32::from_bits(0xbd4c_cccd); + let minimum = f32::from_bits(0xbba3_d70a); + let decay = select(minimum - proportional, proportional, minimum); + let projected = dot(displacement, unit); + let amount = select(projected - decay, decay, projected); + if amount > -magnitude { + s.correction_576 = madd(unit, amount, s.correction_576); + } else { + s.correction_576 = ZERO; + s.correction_enabled_711 = false; + } + } + s.published_frame_64[3] = sub(s.published_frame_64[3], s.correction_576); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/support.rs b/crates/skate-core/src/player/offboard/ground_motion/support.rs new file mode 100644 index 00000000..11a8da82 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/support.rs @@ -0,0 +1,101 @@ +//!82D7EEAC..F454 moving support history and800C0 output filtering. +use super::math::*; +use super::{Frame, GroundMotionInput, GroundMotionState}; +use crate::trigonometry; + +pub(super) fn update(s: &mut GroundMotionState, i: &GroundMotionInput) -> Frame { + let mut delta = IDENTITY; + if i.contact_flags_176 & 1 == 0 { + return delta; + } + if s.support_id_352 != i.contact_id_180 { + if s.support_velocity_removed_715 { + s.velocity_480 = add(s.velocity_480, s.predicted_support_velocity_272); + s.speed_704 = length(s.velocity_480); + } + s.support_yaw_336 = 0.0; + s.previous_support_yaw_340 = 0.0; + s.predicted_support_yaw_344 = 0.0; + s.support_acceleration_304 = ZERO; + s.predicted_support_velocity_272 = ZERO; + s.support_velocity_256 = ZERO; + s.support_id_352 = i.contact_id_180; + s.support_velocity_removed_715 = false; + } else { + delta = compose( + inverse_rigid(s.previous_support_frame_192), + i.contact_frame_32, + ); + let moved = transform_point(s.frame_0[3], delta); + let velocity = scale(sub(moved, s.frame_0[3]), reciprocal(DT)); + let acceleration = madd( + sub(velocity, s.support_velocity_256), + reciprocal(DT), + s.support_acceleration_304, + ); + s.support_acceleration_304 = scale(acceleration, f32::from_bits(0x3f73_3333)); + s.support_velocity_256 = velocity; + s.predicted_support_velocity_272 = + add(velocity, sub(velocity, s.previous_support_velocity_288)); + s.previous_support_velocity_288 = velocity; + let rotated = transform_vector(s.frame_0[2], delta); + let flat = sub(rotated, scale(UP, dot(rotated, UP))); + let direction = normalize_safe(flat, ZERO); + let sine = clamp_signed(dot(cross(s.frame_0[2], direction), UP)); + let yaw = trigonometry::asin(sine) * RATE; + s.support_yaw_336 = yaw; + s.predicted_support_yaw_344 = yaw.mul_add(2.0, -s.previous_support_yaw_340); + s.previous_support_yaw_340 = yaw; + if dot( + s.predicted_support_velocity_272, + s.predicted_support_velocity_272, + ) > EPSILON + && !s.support_velocity_removed_715 + { + s.velocity_480 = add(s.velocity_480, s.predicted_support_velocity_272.map(|v| -v)); + s.support_velocity_removed_715 = true; + s.speed_704 = length(s.velocity_480); + } + } + filter(s, i); + s.previous_support_frame_192 = i.contact_frame_32; + delta +} + +///82D800C0: reference-frame local acceleration, mirrored X/Z, cap then filter. +fn filter(s: &mut GroundMotionState, i: &GroundMotionInput) { + let f = i.reference_frame_128; + let columns = [ + [f[0][0], f[1][0], f[2][0], 0.0], + [f[0][1], f[1][1], f[2][1], 0.0], + [f[0][2], f[1][2], f[2][2], 0.0], + ZERO, + ]; + let mut local = transform_vector(s.support_acceleration_304, columns); + local[1] = 0.0; + if i.mirrored_292 { + local[0] *= -1.0; + local[2] *= -1.0; + } + let mut request = scale(local, f32::from_bits(0x3ccc_cccd)); + let magnitude = length(request); + if !(magnitude <= 1.0) { + request = scale(request, 1.0 / magnitude); + } + s.filtered_local_acceleration_320 = madd( + request, + f32::from_bits(0x3d23_d70a), + scale( + s.filtered_local_acceleration_320, + f32::from_bits(0x3f75_c28f), + ), + ); + let speed = length([ + s.support_velocity_256[0], + 0.0, + s.support_velocity_256[2], + s.support_velocity_256[0], + ]) * f32::from_bits(0x3d4c_cccd); + let nonnegative = select(-speed, 0.0, speed); + s.support_speed_348 = select(1.0 - nonnegative, nonnegative, 1.0); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/tests.rs b/crates/skate-core/src/player/offboard/ground_motion/tests.rs new file mode 100644 index 00000000..db597f57 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/tests.rs @@ -0,0 +1,270 @@ +use super::math::{DT, IDENTITY, UP, ZERO}; +use super::*; +fn state() -> GroundMotionState { + GroundMotionState { + frame_0: IDENTITY, + published_frame_64: IDENTITY, + previous_support_frame_192: IDENTITY, + support_velocity_256: ZERO, + predicted_support_velocity_272: ZERO, + previous_support_velocity_288: ZERO, + support_acceleration_304: ZERO, + filtered_local_acceleration_320: ZERO, + support_yaw_336: 0.0, + previous_support_yaw_340: 0.0, + predicted_support_yaw_344: 0.0, + support_speed_348: 0.0, + support_id_352: 1, + velocity_480: ZERO, + correction_576: ZERO, + target_frame_608: IDENTITY, + angular_velocity_688: 0.0, + speed_704: 0.0, + correction_enabled_711: false, + support_velocity_removed_715: false, + target_scale_784: -1.0, + } +} +fn input() -> GroundMotionInput { + GroundMotionInput { + contact_position_0: ZERO, + contact_frame_32: IDENTITY, + contact_target_96: ZERO, + contact_normal_112: UP, + contact_flags_176: 0, + contact_id_180: 1, + animation_motion_224: ZERO, + animation_motion_240: ZERO, + requested_duration_288: 1.0, + mirrored_292: false, + animation_directed_304: false, + target_frame_present_352: false, + target_frame_368: IDENTITY, + reference_frame_128: IDENTITY, + contact_displacement_384: ZERO, + velocity_addition_528: ZERO, + desired_up_544: UP, + correction_target_592: ZERO, + obstacle_target_672: ZERO, + obstacle_enabled_713: false, + } +} +fn near(a: f32, b: f32) { + assert!((a - b).abs() < 1.0e-5, "{a} != {b}"); +} +#[test] +fn free_step_combines_all_actual_displacements() { + let mut s = state(); + let mut i = input(); + s.frame_0[3] = [10.0, 2.0, 3.0, 0.0]; + s.velocity_480 = [6.0, 0.0, 0.0, 0.0]; + s.predicted_support_velocity_272 = [0.0, 0.0, 3.0, 0.0]; + i.velocity_addition_528 = [0.0, 12.0, 0.0, 0.0]; + i.contact_displacement_384 = [0.5, 0.0, 0.0, 0.0]; + update(&mut s, &i); + near(s.frame_0[3][0], 10.6); + near(s.frame_0[3][1], 2.2); + near(s.frame_0[3][2], 3.05); + assert_eq!(s.frame_0, s.published_frame_64); +} +#[test] +fn absent_contact_preserves_support_history() { + let mut s = state(); + let i = input(); + s.support_velocity_256 = [1.0; 4]; + s.support_id_352 = 9; + support::update(&mut s, &i); + assert_eq!(s.support_velocity_256, [1.0; 4]); + assert_eq!(s.support_id_352, 9); +} +#[test] +fn new_support_restores_removed_velocity_and_resets_derivatives() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_id_180 = 2; + s.support_velocity_removed_715 = true; + s.predicted_support_velocity_272 = [3.0, 0.0, 0.0, 0.0]; + s.velocity_480 = [1.0, 0.0, 0.0, 0.0]; + s.predicted_support_yaw_344 = 5.0; + support::update(&mut s, &i); + assert_eq!(s.support_id_352, 2); + near(s.velocity_480[0], 4.0); + near(s.speed_704, 4.0); + assert!(!s.support_velocity_removed_715); + assert_eq!(s.predicted_support_velocity_272, ZERO); + assert_eq!(s.predicted_support_yaw_344, 0.0); +} +#[test] +fn translated_support_predicts_velocity_and_removes_it_once() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_frame_32[3][0] = DT; + s.velocity_480 = [10.0, 0.0, 0.0, 0.0]; + support::update(&mut s, &i); + near(s.support_velocity_256[0], 1.0); + near(s.predicted_support_velocity_272[0], 2.0); + near(s.velocity_480[0], 8.0); + assert!(s.support_velocity_removed_715); + i.contact_frame_32[3][0] = 2.0 * DT; + support::update(&mut s, &i); + near(s.predicted_support_velocity_272[0], 1.0); + near(s.velocity_480[0], 8.0); +} +#[test] +fn rotated_support_produces_signed_yaw_history() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + let angle = 0.1_f32; + let (sin, cos) = angle.sin_cos(); + i.contact_frame_32[0] = [cos, 0.0, -sin, 0.0]; + i.contact_frame_32[2] = [sin, 0.0, cos, 0.0]; + support::update(&mut s, &i); + near(s.support_yaw_336, angle * math::RATE); + near(s.predicted_support_yaw_344, 2.0 * s.support_yaw_336); +} +#[test] +fn mirrored_support_acceleration_reverses_x_and_z_filter() { + let mut a = state(); + let mut b = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_frame_32[3] = [DT, 0.0, DT, 0.0]; + support::update(&mut a, &i); + i.mirrored_292 = true; + support::update(&mut b, &i); + near( + a.filtered_local_acceleration_320[0], + -b.filtered_local_acceleration_320[0], + ); + near( + a.filtered_local_acceleration_320[2], + -b.filtered_local_acceleration_320[2], + ); + assert_eq!(a.filtered_local_acceleration_320[1], 0.0); +} +#[test] +fn stationary_animation_reaches_target_at_requested_duration() { + let mut s = state(); + let mut i = input(); + i.animation_directed_304 = true; + i.requested_duration_288 = 2.0; + s.target_frame_608[3] = [0.0, 0.0, 10.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(s.target_scale_784, 5.0); + near(step[2], 5.0 * DT); +} +#[test] +fn moving_animation_uses_current_motion_length_and_retains_scale() { + let mut s = state(); + let mut i = input(); + i.animation_directed_304 = true; + i.animation_motion_224 = [0.0, 0.0, 2.0, 0.0]; + i.animation_motion_240 = [0.0, 0.0, 3.0, 0.0]; + s.target_frame_608[3] = [0.0, 0.0, 10.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(s.target_scale_784, 5.0); + near(step[2], 15.0 * DT); + i.animation_motion_224[2] = 4.0; + approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(s.target_scale_784, 5.0); +} +#[test] +fn target_refresh_uses_right_offset_and_applies_support_delta_after_step() { + let mut s = state(); + let mut i = input(); + i.animation_directed_304 = true; + i.target_frame_present_352 = true; + i.target_frame_368[3] = [0.0, 0.0, 3.0, 0.0]; + let mut delta = IDENTITY; + delta[3] = [2.0, 0.0, 0.0, 0.0]; + approach::calculate(&mut s, &i, ZERO, delta); + near(s.target_frame_608[3][0], 2.2); + near(s.target_frame_608[3][2], 3.0); +} +#[test] +fn contact_height_adjustment_is_five_percent_along_current_up() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_position_0 = [0.0, 2.0, 0.0, 0.0]; + let (_, height) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(height[1], 0.1); +} +#[test] +fn obstacle_approach_caps_step_to_velocity_budget() { + let mut s = state(); + let mut i = input(); + s.velocity_480 = [0.0, 0.0, 6.0, 0.0]; + i.obstacle_enabled_713 = true; + i.obstacle_target_672 = [0.0, 0.0, 10.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(step[2], 0.1); +} +#[test] +fn nearby_target_uses_remaining_tangent_budget() { + let mut s = state(); + let mut i = input(); + s.velocity_480 = [0.0, 0.0, 6.0, 0.0]; + i.obstacle_enabled_713 = true; + i.obstacle_target_672 = [0.0, 0.0, 0.05, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(step[2], 0.1); +} +#[test] +fn contact_target_lateral_component_is_removed() { + let mut s = state(); + let mut i = input(); + s.velocity_480 = [0.0, 0.0, 6.0, 0.0]; + i.contact_flags_176 = 2; + i.contact_target_96 = [9.0, 0.0, 1.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(step[0], 0.0); + near(step[2], 0.1); +} +#[test] +fn publication_decays_correction_without_moving_physical_frame() { + let mut s = state(); + s.correction_enabled_711 = true; + s.correction_576 = [1.0, 0.0, 0.0, 0.0]; + publication::update(&mut s, ZERO); + near(s.correction_576[0], 0.95); + near(s.published_frame_64[3][0], -0.95); + near(s.frame_0[3][0], 0.0); + assert!(s.correction_enabled_711); +} +#[test] +fn publication_threshold_and_large_opposing_step_clear_correction() { + let mut s = state(); + s.correction_enabled_711 = true; + s.correction_576 = [0.01, 0.0, 0.0, 0.0]; + publication::update(&mut s, ZERO); + assert!(!s.correction_enabled_711); + s.correction_enabled_711 = true; + s.correction_576 = [1.0, 0.0, 0.0, 0.0]; + publication::update(&mut s, [-2.0, 0.0, 0.0, 0.0]); + assert_eq!(s.correction_576, ZERO); + assert!(!s.correction_enabled_711); +} +#[test] +fn limiter_preserves_parallel_target_and_caps_oblique_normal() { + assert_eq!(math::limit_angle(UP, UP, 0.5), UP); + let v = math::limit_angle([1.0, 0.0, 0.0, 0.0], UP, std::f32::consts::FRAC_PI_4); + near(v[0], std::f32::consts::FRAC_1_SQRT_2); + near(v[1], std::f32::consts::FRAC_1_SQRT_2); +} + +#[test] +fn limiter_keeps_quaternion_w_lane_fused_residuals() { + let limit = 0.73; + let from = [0.0, 1.0, 0.0, 0.37]; + let got = math::limit_angle([1.0, 0.0, 0.0, 0.0], from, limit); + let (_, c) = crate::trigonometry::sin_cos(-limit * 0.5); + let first_w = (-c).mul_add(from[3], c * from[3]); + let middle_w = from[3].mul_add(c, first_w); + let second_w = (-c).mul_add(middle_w, c * middle_w); + let expected = second_w.mul_add(2.0, from[3]); + assert_eq!(got[3].to_bits(), expected.to_bits()); +} diff --git a/crates/skate-core/src/player/offboard/ground_query.rs b/crates/skate-core/src/player/offboard/ground_query.rs new file mode 100644 index 00000000..75846bfb --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query.rs @@ -0,0 +1,118 @@ +//! Original TU3 Biped ground geometry:82D32808,82C20728,82C20C08,82D31620. +//! Scene enumeration/collision is supplied explicitly by the canonical host. +//! The host must consume the previous Sync query during the next PreUpdate. +mod consume; +mod geometry; +mod math; +mod packet; +use crate::math::Vector3; +pub use consume::{consume_geometry, interpret_hits}; +pub use geometry::{closest_point, edge_search, select_edge}; +pub use packet::prepare_packet; +#[derive(Clone, Copy, Debug)] +pub struct Frame { + pub right: Vector3, + pub up: Vector3, + pub forward: Vector3, + pub position: Vector3, +} +impl Frame { + pub const IDENTITY: Self = Self { + right: Vector3::new(1., 0., 0.), + up: Vector3::new(0., 1., 0.), + forward: Vector3::new(0., 0., 1.), + position: Vector3::ZERO, + }; +} +#[derive(Clone, Copy, Debug)] +pub struct QueryContext { + pub selection_flags_2948: u32, + pub matching_id_2952: i32, +} +#[derive(Clone, Copy, Debug)] +pub struct Edge { + pub start: Vector3, + pub end: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct EdgeSearch { + pub min: Vector3, + pub max: Vector3, + pub frame: Frame, + pub context: QueryContext, + pub narrow_forward: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct EdgeSelection { + pub edge: Edge, + pub closest: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct Line { + pub start: Vector3, + pub end: Vector3, + pub radius: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct GroundQueryPacket { + pub context: QueryContext, + pub center: Vector3, + pub up: Vector3, + pub tangent: Vector3, + pub lines: [Line; 7], +} +impl GroundQueryPacket { + ///82764AB0: Ground pool,21888 pool, and21892 only on island.flags==3. + pub const SOURCE_POOL_MASK: u8 = 7; + ///8276D510 adds no facing rejection; triangle winding rules still apply. + pub const FACING_FLAGS: u8 = 3; + pub const MESH_EXCLUSION_MASK: u32 = 0; + pub const MATERIAL_EXCLUSION_MASK: [u32; 4] = [0; 4]; +} +#[derive(Clone, Copy, Debug)] +pub struct LineHit { + pub position: Vector3, + pub face_normal: Vector3, + pub fraction: f32, + pub packed_surface: u16, +} +///Complete fields read by82D31620; not the unrelated general grind classifier. +#[derive(Clone, Copy, Debug)] +pub struct GroundGeometry { + pub frame: Frame, + pub kind: u32, + pub flag26: bool, + pub flag27: bool, + pub flag28: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct ConsumeInput { + pub frame_80: Frame, + pub contact_position_192: Vector3, + pub contact_flags_368: u32, + pub reach_364: f32, + pub previous_input_up_416: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct GroundAdjustment { + pub state_752: bool, + pub state_753: bool, + pub state_754: bool, + pub frame_768: Frame, + pub input_up_416: Vector3, +} +///The runtime implements actual authored edge/scene-body queries, never cached +///observations. Provider ordering, cap40, transforms and context filtering are +///specified in OFFBOARD_BIPED_GROUND_QUERY_HANDOFF.md. +pub trait GroundQueryScene { + type Error; + fn edge_candidates(&mut self, search: &EdgeSearch) -> Result, Self::Error>; + ///Return actual seven query results. Preserve source pool ordering, group + ///matching, fatness0, decoded face normals and strict nearest-hit ties. + fn query_lines( + &mut self, + packet: &GroundQueryPacket, + ) -> Result<[Option; 7], Self::Error>; +} +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_query/consume.rs b/crates/skate-core/src/player/offboard/ground_query/consume.rs new file mode 100644 index 00000000..87c31c9f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/consume.rs @@ -0,0 +1,129 @@ +use super::{ + ConsumeInput, Frame, GroundAdjustment, GroundGeometry, GroundQueryPacket, LineHit, math::*, +}; +use crate::math::Vector3; +///82C20C08 fields directly consumed by82D31620; calls82C21558/82C1E170. +pub fn interpret_hits(packet: &GroundQueryPacket, hits: [Option; 7]) -> GroundGeometry { + let h = |i: usize| hits[i].is_some(); + let mut side = scale( + cross(Vector3::new(0., 1., 0.), packet.tangent), + inverse_root(dot( + cross(Vector3::new(0., 1., 0.), packet.tangent), + cross(Vector3::new(0., 1., 0.), packet.tangent), + )), + ); + let kind = if (h(0) && h(1)) || h(4) { + 3 + } else if h(0) { + if hits[2].is_some_and(|v| !(v.fraction >= 0.65)) { + 2 + } else { + 1 + } + } else if h(1) { + side = scale(side, -1.); + if hits[3].is_some_and(|v| !(v.fraction >= 0.65)) { + 2 + } else { + 1 + } + } else { + 0 + }; + let flag28 = hits[0].is_some_and(|v| v.packed_surface & 0x0f80 == 0x0400) + || hits[1].is_some_and(|v| v.packed_surface & 0x0f80 == 0x0400); + let (flag26, flag27) = if let Some(hit) = hits[6] { + let delta = sub(hit.position, packet.center); + let d = dot(hit.face_normal, safe_unit(packet.tangent, Vector3::ZERO)); + let rejected = if d > 0. { + sub(hit.face_normal, scale(packet.tangent, d)) + } else { + hit.face_normal + }; + let normal = safe_unit(rejected, Vector3::ZERO); + ( + !(delta.y > -1.), + 0.9 > normal.y && dot(horizontal(delta), normal) > 0., + ) + } else { + (true, false) + }; + GroundGeometry { + frame: Frame { + right: side, + up: packet.up, + forward: packet.tangent, + position: packet.center, + }, + kind, + flag26, + flag27, + flag28, + } +} +///82D31620 including its PreUpdate resets82D30EAC..82D30F0C. None means the +///previous query was not pending; contact fallback still executes. +pub fn consume_geometry(input: ConsumeInput, geometry: Option) -> GroundAdjustment { + let mut out = GroundAdjustment { + state_752: false, + state_753: false, + state_754: false, + frame_768: Frame::IDENTITY, + input_up_416: input.previous_input_up_416, + }; + let mut nearby = false; + if let Some(g) = geometry { + let delta = sub(g.frame.position, input.frame_80.position); + let dh = horizontal(delta); + nearby = 0.3 > delta.y.abs() + && f32::from_bits(0x3d23_d70b) > dot(dh, dh) + && dot(g.frame.forward, input.frame_80.forward).abs() > 0.9; + if !g.flag28 && input.reach_364 > length(dh) + 0.7 { + out.state_752 = g.kind == 2 && g.frame.up.y > 0.98; + out.state_754 = g.flag26 || g.flag27; + } + if g.kind == 0 || g.kind == 1 { + out.state_753 = !(dot(delta, g.frame.right).abs() > 0.06) + && !(input.contact_flags_368 & 0x10 != 0 && input.contact_flags_368 & 0x20 != 0); + } + if out.state_752 || out.state_753 { + out.frame_768 = g.frame; + } + } + if !out.state_753 && input.contact_flags_368 & 1 != 0 && input.contact_flags_368 & 0x38 == 0 { + let h = dot( + sub(input.frame_80.position, input.contact_position_192), + input.frame_80.up, + ); + // Native bge/ble leave unordered values on the accepting path. + if !(h >= 0.06) && !(h <= -0.02) { + out.state_753 = true; + out.frame_768 = input.frame_80; + out.frame_768.position = input.contact_position_192; + } + } + if nearby || out.state_753 { + let x = horizontal(out.frame_768.right); + let x_length = length(x); + if !(x_length <= 0.001) { + let x = scale(x, reciprocal(x_length)); + let z = sub( + out.frame_768.forward, + scale(x, dot(out.frame_768.forward, x)), + ); + let z_length = length(z); + if !(z_length <= 0.001) { + let z = scale(z, reciprocal(z_length)); + out.frame_768.right = x; + out.frame_768.forward = z; + out.frame_768.up = cross(z, x); + } + } + if 0. > out.frame_768.up.y { + out.frame_768.up = scale(out.frame_768.up, -1.); + out.frame_768.right = scale(out.frame_768.right, -1.); + } + out.input_up_416 = out.frame_768.up; + } + out +} diff --git a/crates/skate-core/src/player/offboard/ground_query/geometry.rs b/crates/skate-core/src/player/offboard/ground_query/geometry.rs new file mode 100644 index 00000000..ffca39ae --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/geometry.rs @@ -0,0 +1,62 @@ +use super::{Edge, EdgeSearch, EdgeSelection, Frame, QueryContext, math::*}; +use crate::math::Vector3; +/// 82D32168..321C0 and82D32808. Inputs are the live Biped frame and state1040. +pub fn edge_search( + frame: Frame, + context: QueryContext, + state_vector_1040: Vector3, + processed_2488: u32, +) -> EdgeSearch { + let narrow_forward = + processed_2488 & 0x0800_0000 != 0 || !(dot(state_vector_1040, state_vector_1040) >= 1.); + let center = if narrow_forward { + madd(frame.forward, 0.75, frame.position) + } else { + frame.position + }; + let extent = madd( + abs(frame.forward), + 0.75, + madd(abs(frame.up), 0.2, scale(abs(frame.right), 0.5)), + ); + EdgeSearch { + min: sub(center, extent), + max: add(center, extent), + frame, + context, + narrow_forward, + } +} +///82E09D28: finite-segment closest point, including the tiny-edge retain branch. +pub fn closest_point(point: Vector3, edge: Edge) -> Vector3 { + let mut d = sub(edge.end, edge.start); + let l = length(d); + if l > f32::from_bits(0x3780_0000) { + d = scale(d, reciprocal(l)); + } + let t = dot(d, sub(point, edge.start)).min(l).max(0.); + madd(d, t, edge.start) +} +///82D32808: input candidates MUST retain provider order and its shared cap40. +///Both distances must improve; this is not a nearest-distance sort. +pub fn select_edge(search: EdgeSearch, candidates: &[Edge]) -> Option { + let mut best_vertical = 0.2; + let mut best_horizontal = 1.5; + let mut selected = None; + for &edge in candidates.iter().take(40) { + let closest = closest_point(search.frame.position, edge); + let delta = sub(closest, search.frame.position); + if search.narrow_forward && 0. > dot(search.frame.forward, delta) { + continue; + } + let vertical = dot(search.frame.up, delta); + let horizontal_distance = length(sub(delta, scale(search.frame.up, vertical))); + // Native scalar bge rejects ordered >=; unordered does not take bge. + if !(vertical.abs() >= best_vertical) && !(horizontal_distance >= best_horizontal) { + best_vertical = vertical.abs(); + best_horizontal = horizontal_distance; + selected = Some(EdgeSelection { edge, closest }); + } + } + selected +} diff --git a/crates/skate-core/src/player/offboard/ground_query/math.rs b/crates/skate-core/src/player/offboard/ground_query/math.rs new file mode 100644 index 00000000..3513f39b --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/math.rs @@ -0,0 +1,63 @@ +use crate::{math::Vector3, physics::native_arithmetic}; +pub(super) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +pub(super) fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn scale(a: Vector3, k: f32) -> Vector3 { + Vector3::new(a.x * k, a.y * k, a.z * k) +} +pub(super) fn madd(a: Vector3, k: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(k, b.x), + a.y.mul_add(k, b.y), + a.z.mul_add(k, b.z), + ) +} +pub(super) fn dot(a: Vector3, b: Vector3) -> f32 { + a.x * b.x + a.y * b.y + a.z * b.z +} +pub(super) fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +pub(super) fn horizontal(a: Vector3) -> Vector3 { + Vector3::new(a.x, 0., a.z) +} +pub(super) fn abs(a: Vector3) -> Vector3 { + Vector3::new(a.x.abs(), a.y.abs(), a.z.abs()) +} +// Independent PC estimate, retaining the two TU3 refinement steps; not bit-exact Xenon. +pub(super) fn inverse_root(q: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_square_root_estimate(q); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.), r); + } + r +} +pub(super) fn length(a: Vector3) -> f32 { + let q = dot(a, a); + let l = q * inverse_root(q); + if q == 0. { 0. } else { l } +} +pub(super) fn reciprocal(x: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_estimate(x); + for _ in 0..2 { + r = r.mul_add((-x).mul_add(r, 1.), r); + } + r +} +pub(super) fn safe_unit(a: Vector3, fallback: Vector3) -> Vector3 { + let q = dot(a, a); + let r = inverse_root(q); + let l = if q == 0. { 0. } else { q * r }; + if l > f32::from_bits(0x358637bd) { + scale(a, r) + } else { + fallback + } +} diff --git a/crates/skate-core/src/player/offboard/ground_query/packet.rs b/crates/skate-core/src/player/offboard/ground_query/packet.rs new file mode 100644 index 00000000..21922db5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/packet.rs @@ -0,0 +1,56 @@ +use super::{EdgeSelection, Frame, GroundQueryPacket, Line, QueryContext, math::*}; +use crate::math::Vector3; +///82D321D0..32320 then82C20728: all seven Biped lines, in source order. +pub fn prepare_packet( + frame: Frame, + context: QueryContext, + selected: EdgeSelection, + deck_half_wheelbase: f32, +) -> Option { + let edge = selected.edge; + let closest = selected.closest; + let reverse = safe_unit(sub(edge.start, edge.end), frame.right); + let forward = safe_unit( + sub(frame.forward, scale(reverse, dot(reverse, frame.forward))), + frame.forward, + ); + let seventh_start = madd(forward, 0.2, closest); + let delta = sub(edge.end, edge.start); + let raw_up = cross(delta, cross(Vector3::new(0., 1., 0.), delta)); + let up = safe_unit(raw_up, raw_up); + let tangent = safe_unit(delta, delta); + if f32::from_bits(0x3727_c5ac) > length(raw_up) { + return None; + } + let projection = madd(tangent, dot(sub(closest, edge.start), tangent), edge.start); + let center = sub(closest, sub(closest, projection)); + let side = cross(up, tangent); + let short_up = scale(up, 0.04); + let near = scale(side, 0.09); + let far = scale(side, deck_half_wheelbase); + let far_up = scale(up, deck_half_wheelbase * f32::from_bits(0x3f87_ae14)); + let line = |c: Vector3, d: Vector3, radius| Line { + start: add(c, d), + end: sub(c, d), + radius, + }; + Some(GroundQueryPacket { + context, + center, + up, + tangent, + lines: [ + line(add(center, near), short_up, 0.), + line(sub(center, near), short_up, 0.), + line(add(center, far), far_up, 0.), + line(sub(center, far), far_up, 0.), + line(center, short_up, 0.), + line(add(center, scale(up, 0.09)), near, 0.001), + Line { + start: seventh_start, + end: add(seventh_start, Vector3::new(0., -6., 0.)), + radius: 0., + }, + ], + }) +} diff --git a/crates/skate-core/src/player/offboard/ground_query/tests.rs b/crates/skate-core/src/player/offboard/ground_query/tests.rs new file mode 100644 index 00000000..badea94e --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/tests.rs @@ -0,0 +1,138 @@ +use super::*; +fn v(x: f32, y: f32, z: f32) -> Vector3 { + Vector3::new(x, y, z) +} +fn context() -> QueryContext { + QueryContext { + selection_flags_2948: 0, + matching_id_2952: -1, + } +} +fn input() -> ConsumeInput { + ConsumeInput { + frame_80: Frame::IDENTITY, + contact_position_192: Vector3::ZERO, + contact_flags_368: 0, + reach_364: 1., + previous_input_up_416: v(0.1, 0.9, 0.), + } +} +fn hit(position: Vector3) -> LineHit { + LineHit { + position, + face_normal: v(0., 1., 0.), + fraction: 0.5, + packed_surface: 0, + } +} +#[test] +fn edge_selection_requires_both_distances_to_improve_in_order() { + let search = edge_search(Frame::IDENTITY, context(), v(0., 0., 2.), 0); + let edges = + [v(0.5, 0.1, 0.), v(0.1, 0.15, 0.), v(0.6, 0.05, 0.)].map(|p| Edge { start: p, end: p }); + let selected = select_edge(search, &edges).unwrap(); + assert_eq!(selected.closest, edges[0].start); + let reversed = select_edge(search, &[edges[1], edges[0]]).unwrap(); + assert_eq!(reversed.closest, edges[1].start); +} +#[test] +fn biped_packet_has_seventh_downward_line_and_rounded_sixth_line() { + let selected = EdgeSelection { + edge: Edge { + start: v(0., 0., -1.), + end: v(0., 0., 1.), + }, + closest: Vector3::ZERO, + }; + let packet = prepare_packet(Frame::IDENTITY, context(), selected, 0.25).unwrap(); + assert_eq!(packet.lines[0].start, v(0.09, 0.04, 0.)); + assert_eq!(packet.lines[5].radius, 0.001); + assert_eq!(packet.lines[6].start, v(0., 0., 0.2)); + assert_eq!(packet.lines[6].end, v(0., -6., 0.2)); + assert_eq!(GroundQueryPacket::SOURCE_POOL_MASK, 7); +} +#[test] +fn no_query_preserves_existing_contact_up_but_contact_fallback_runs() { + let i = input(); + let out = consume_geometry(i, None); + assert!(!out.state_753); + assert_eq!(out.input_up_416, i.previous_input_up_416); + let out = consume_geometry( + ConsumeInput { + contact_flags_368: 1, + ..i + }, + None, + ); + assert!(out.state_753); + assert_eq!(out.input_up_416, v(0., 1., 0.)); + let out = consume_geometry( + ConsumeInput { + contact_flags_368: 9, + ..i + }, + None, + ); + assert!(!out.state_753); +} +#[test] +fn reach_and_material_flag_gate_step_without_gating_side_contact() { + let g = GroundGeometry { + frame: Frame::IDENTITY, + kind: 2, + flag26: true, + flag27: false, + flag28: false, + }; + let out = consume_geometry( + ConsumeInput { + reach_364: 0.7, + ..input() + }, + Some(g), + ); + assert!(!out.state_752); + assert!(!out.state_754); + let out = consume_geometry(input(), Some(g)); + assert!(out.state_752 && out.state_754); + let out = consume_geometry(input(), Some(GroundGeometry { flag28: true, ..g })); + assert!(!out.state_752); + let out = consume_geometry( + input(), + Some(GroundGeometry { + kind: 1, + flag28: true, + ..g + }), + ); + assert!(out.state_753); +} +#[test] +fn seventh_hit_height_and_first_hit_material_reach_consumed_flags() { + let packet = prepare_packet( + Frame::IDENTITY, + context(), + EdgeSelection { + edge: Edge { + start: v(0., 0., -1.), + end: v(0., 0., 1.), + }, + closest: Vector3::ZERO, + }, + 0.25, + ) + .unwrap(); + let mut hits = [None; 7]; + hits[0] = Some(LineHit { + packed_surface: 0x400, + ..hit(Vector3::ZERO) + }); + hits[2] = Some(hit(Vector3::ZERO)); + hits[6] = Some(hit(v(0., -1., 0.))); + let g = interpret_hits(&packet, hits); + assert_eq!(g.kind, 2); + assert!(g.flag28 && g.flag26); + assert!(!g.flag27); + hits[6] = Some(hit(v(0., -0.9, 0.))); + assert!(!interpret_hits(&packet, hits).flag26); +} diff --git a/crates/skate-core/src/player/offboard/ground_reckoning.rs b/crates/skate-core/src/player/offboard/ground_reckoning.rs new file mode 100644 index 00000000..d5c34b60 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_reckoning.rs @@ -0,0 +1,101 @@ +//! Original Reckoning::UpdateBiped82D8E3E0, using the existing shared owners. +//! 82D8E458 explicitly clears velocity; riding acceleration/damping is absent. +//! This adapter follows the shared XYZ orientation representation; all input +//! frame/filter math uses four lanes and returns complete computed vectors. +use crate::{ + air::{ + body_spin::{self, BodySpinState}, + reckoning::AirState, + }, + math::Vector3, + point_graph::PointGraph, + riding::{ground_orientation::GroundOrientation, reckoning_frames::ReckoningFrames}, +}; +type Vector = [f32; 4]; +pub struct Settings<'a> { + pub ground_normal_smoothing: [f32; 4], + pub tilt_vs_rotation: &'a PointGraph<8>, + pub tilt_vs_slope: &'a PointGraph<8>, +} +pub struct Input { + /// Actual current reckoning1152, supplied as all four original lanes. + pub previous_up: Vector, + pub requested_up: Vector, + pub requested_forward: Vector, + pub blend: f32, + pub reverse_stance: bool, + pub enable_body_spin_input: bool, + pub physical_body_spin_2812: f32, +} +/// Four-lane publications let the canonical owner retain every computed lane. +/// No separate persistent orientation state is introduced here. +pub struct Output { + pub dynamic_up_1136: Vector, + pub up_1152: Vector, + pub target_1168: Vector, + pub velocity_1184: Vector, + pub ground_normal_1216: Vector, +} +pub fn update( + orientation: &mut GroundOrientation, + frames: &mut ReckoningFrames, + body_spin: &mut BodySpinState, + air: &mut AirState, + settings: Settings<'_>, + input: Input, +) -> Output { + orientation.dynamic_up = xyz(input.previous_up); + frames.target_lean_angle = 0.0; + air.secondary_lean_angle = 0.0; + orientation.up_velocity = Vector3::ZERO; + let ground = orientation + .ground_filter + .update(settings.ground_normal_smoothing, input.requested_up); + orientation.ground_normal = xyz(ground); + frames.heading = input.requested_forward; + let retained = 1.0 - input.blend; + let candidate: Vector = std::array::from_fn(|i| { + input.previous_up[i].mul_add(input.blend, input.requested_up[i] * retained) + }); + let square = crate::physics::native_arithmetic::dot3(candidate, candidate); + let inverse = super::contact_correction::inverse_length(square); + let length = if square == 0.0 { 0.0 } else { square * inverse }; + let up = if length > f32::from_bits(0x3586_37bd) { + candidate.map(|v| v * inverse) + } else { + input.previous_up + }; + orientation.up = xyz(up); + orientation.target = xyz(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, ground); + frames.calculate_tilt( + input.reverse_stance, + settings.tilt_vs_rotation, + settings.tilt_vs_slope, + ); + body_spin::update_ground( + body_spin, + if input.enable_body_spin_input { + input.physical_body_spin_2812 + } else { + 0.0 + }, + ); + Output { + dynamic_up_1136: input.previous_up, + up_1152: up, + target_1168: up, + velocity_1184: [0.0; 4], + ground_normal_1216: ground, + } +} +fn xyz(v: Vector) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_reckoning/tests.rs b/crates/skate-core/src/player/offboard/ground_reckoning/tests.rs new file mode 100644 index 00000000..3caf5425 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_reckoning/tests.rs @@ -0,0 +1,98 @@ +use super::*; +use crate::air::ground_normal::GroundNormalFilter; + +fn orientation() -> GroundOrientation { + let up = [0., 1., 0., 0.]; + GroundOrientation { + dynamic_up: xyz(up), + up: xyz(up), + target: xyz(up), + up_velocity: Vector3::new(7., 8., 9.), + ground_normal: xyz(up), + ground_blend: 0.75, + ground_filter: GroundNormalFilter::initialized([1., 0., 0., 1.], up), + slow_filter: GroundNormalFilter::initialized([0.2, 0.1, 0.05, 0.3], up), + fast_filter: GroundNormalFilter::initialized([0.7, 0.2, 0.1, 0.8], up), + } +} + +fn run(orientation: &mut GroundOrientation, input: Input) -> (Output, BodySpinState, AirState) { + let curve = PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [0.; 8], + }; + let mut frames = ReckoningFrames::new(); + let mut spin = BodySpinState::new(); + let mut air = AirState::new(); + air.spin_angle = 2.; + air.secondary_lean_angle = 3.; + let output = update( + orientation, + &mut frames, + &mut spin, + &mut air, + Settings { + ground_normal_smoothing: [1., 0., 0., 1.], + tilt_vs_rotation: &curve, + tilt_vs_slope: &curve, + }, + input, + ); + (output, spin, air) +} + +fn input() -> Input { + Input { + previous_up: [0., 1., 0., 2.], + requested_up: [0., 0., 1., 4.], + requested_forward: [1., 0., 0., 0.], + blend: 0.5, + reverse_stance: false, + enable_body_spin_input: true, + physical_body_spin_2812: 0.25, + } +} + +#[test] +fn blend_preserves_fourth_lane_and_updates_history_without_resetting_other_state() { + let mut state = orientation(); + let controls = state.slow_filter.words()[..4].to_vec(); + let (out, spin, air) = run(&mut state, input()); + assert!((out.up_1152[1] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.00001); + assert!((out.up_1152[3] - 3. * std::f32::consts::SQRT_2).abs() < 0.00001); + assert_eq!(out.dynamic_up_1136, [0., 1., 0., 2.]); + assert_eq!(out.velocity_1184, [0.; 4]); + assert_eq!(state.up_velocity, Vector3::ZERO); + assert_eq!(state.ground_blend, 0.75); + assert_eq!(&state.slow_filter.words()[..4], controls.as_slice()); + assert_eq!( + &state.slow_filter.words()[4..8], + &out.up_1152.map(f32::to_bits) + ); + assert_eq!( + &state.fast_filter.words()[8..12], + &out.up_1152.map(f32::to_bits) + ); + assert!( + state.slow_filter.words()[12..16] + .iter() + .any(|&word| word != 0) + ); + assert_eq!(f32::from_bits(spin.words()[30]), 0.25); + assert_eq!(air.secondary_lean_angle, 0.); + assert_eq!(air.spin_angle, 2.); +} + +#[test] +fn cancellation_retains_previous_up_and_disabled_spin_still_advances_history() { + let mut state = orientation(); + let mut input = input(); + input.requested_up = [0., -1., 0., -2.]; + input.enable_body_spin_input = false; + let previous = input.previous_up; + let (out, spin, _) = run(&mut state, input); + assert_eq!(out.up_1152, previous); + assert_eq!(out.target_1168, previous); + assert_eq!(f32::from_bits(spin.words()[30]), 0.); + assert_eq!(spin.words()[42], 1); +} diff --git a/crates/skate-core/src/player/offboard/ground_sync.rs b/crates/skate-core/src/player/offboard/ground_sync.rs new file mode 100644 index 00000000..557048f3 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_sync.rs @@ -0,0 +1,217 @@ +//! TU3 BipedGround Sync82D31DC0, air decision82D32338 and board update82D324B0. +//! Call only after the Biped job completes. Geometry submitted here is consumed +//! by the next PreUpdate; this function never fabricates collision observations. +mod math; +use math::*; +use super::{ground_entry::{Frame, State, Vector}, ground_query}; + +#[derive(Clone, Copy, Debug)] +pub struct CompletedMotion { + pub contact_position_192: Vector, + pub contact_flags_368: u32, + pub physical_frame_848: Frame, + pub animation_frame_912: Frame, + pub vector_976: Vector, + pub vector_992: Vector, + pub vector_1008: Vector, + pub vector_1040: Vector, + pub vector_1056: Vector, + pub launch_1076: bool, + pub flag_1077: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct Processed { + pub frame_192: Frame, + pub position_592: Vector, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub flags_2488: u32, + pub elapsed_2664: f32, + pub value_2852: f32, + pub query_context: ground_query::QueryContext, +} +/// Actual state40 settings. Values are loaded attributes, with no inferred defaults. +#[derive(Clone, Copy, Debug)] +pub struct BoardSettings { + pub extent_0: Vector, + pub extent_16: Vector, + pub offset_32: Vector, + pub angle_436: f32, + pub angle_440: f32, + pub margin_444: f32, + pub angle_452: f32, + pub angle_456: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Bounds { pub frame: Frame, pub extents: Vector } +#[derive(Clone, Copy, Debug)] +pub struct BoardLimits { pub margin: f32, pub angle_a: f32, pub angle_b: f32 } +///82D2DCC0's seven vectors are all overwritten by Sync before82D811C8. +#[derive(Clone, Copy, Debug)] +pub struct ToolkitInput(pub [Vector; 7]); + +/// Canonical physical owners implement these operations against live state. +/// Associated values carry the actual Biped launch packet and queried candidate; +/// they are not boolean observations supplied in advance of their producers. +pub trait Services: ground_query::GroundQueryScene { + type Launch; + type Candidate; + fn processed(&self) -> Processed; + ///82D2DB98 initialization then82D7BA78 with final argument false. + fn prepare_biped_launch(&mut self) -> Self::Launch; + fn skeleton_point_10960(&self) -> Vector; + /// Store point at launch32, then execute82D6CA58 against BipedAir. + fn launch_air(&mut self, launch: Self::Launch, point: Vector); + fn update_skeleton(&mut self, frame: Frame, vector_1056: Vector); + fn board_settings(&self) -> BoardSettings; + fn board_flags_12836(&self) -> u8; + ///82D2CF68 initialized candidate,82D4D150 mode0 and initially zero key. + fn probe_board(&mut self, position: Vector) -> Option; + fn candidate_key_188(&self, candidate: &Self::Candidate) -> [u32; 2]; + /// Actual82BE3220 bone23 transform's position48. + fn bone_23_position(&mut self) -> Vector; + ///82E08DB8, including candidate projection and82E08EE8 geometry tests. + fn classify_board(&mut self, candidate: &Self::Candidate, bone: Vector, + bounds: Bounds, limits: BoardLimits) -> bool; + ///82D74200 mode4. This runs even when probe/classification fails. + fn query_board(&mut self, position: Vector, bounds: Bounds, limits: BoardLimits); + fn bind_board(&mut self, key: [u32; 2]); + /// Board action virtual44, with live manager12828 after binding. + fn commit_bound_board(&mut self); + /// Board action virtual24 with original processed frame and live manager fields. + fn commit_free_board(&mut self, frame: Frame); + ///82D749D0 followed by clearing manager12780. + fn reset_board(&mut self); + fn update_toolkit(&mut self, input: ToolkitInput); + fn deck_half_wheelbase(&self) -> f32; + /// Submit all seven real collision lines; retain until next PreUpdate consumes. + fn submit_ground_query(&mut self, packet: ground_query::GroundQueryPacket); +} + +///82D32338: special contact/input flags suppress only the explicit jump request. +/// Loss of contact and the controller's launch byte still trigger an air launch. +pub fn wants_air(state: &State, motion: &CompletedMotion, p: &Processed) -> bool { + let mut launch = motion.contact_flags_368 & 1 == 0 + && p.elapsed_2664 > f32::from_bits(0x3d4c_cccd); + launch |= motion.launch_1076; + if motion.contact_flags_368 & 8 == 0 && p.flags_2480 & 0x0002_0180 == 0 + && !state.flags_144_to_150[6] { + launch |= p.flags_2476 & 0x0008_0000 != 0; + } + launch +} + +///82D2E250 with argument7=false: retain forward.w while flattening only y. +pub fn board_bounds(frame: Frame, offset: Vector, extents: Vector) -> Bounds { + let mut forward = frame[2]; forward[1] = 0.0; + let forward = normalize_or(forward, [0.0,0.0,1.0,0.0]); + let up = [0.0,1.0,0.0,0.0]; + let right = cross(up, forward); + let position = madd(up, offset[1], madd(forward, offset[2]+extents[2], frame[3])); + Bounds { frame: [right, up, forward, position], extents } +} + +///82D324B0. The caller clears144; a failed probe deliberately preserves that byte. +pub fn update_board(state: &mut State, services: &mut S) { + let settings = services.board_settings(); + let original_frame = services.processed().frame_192; + let bounds = board_bounds(original_frame, settings.offset_32, settings.extent_16); + let position = services.processed().position_592; + if services.board_flags_12836() & 0x40 != 0 { + if let Some(candidate) = services.probe_board(position) { + // The probe may change live processed/settings state. + let settings = services.board_settings(); + let frame = services.processed().frame_192; + let inner = board_bounds(frame, settings.offset_32, + scale(settings.extent_0, f32::from_bits(0x3f73_3333))); + let settings = services.board_settings(); + let limits = limits(settings.margin_444, settings.angle_452, settings.angle_436); + let bone = services.bone_23_position(); + state.flags_144_to_150[0] = services.classify_board(&candidate, bone, inner, limits); + if state.flags_144_to_150[0] { + let key = services.candidate_key_188(&candidate); + state.counter_152 = key[0]; state.counter_156 = key[1]; + } + } + } + let settings = services.board_settings(); + services.query_board(position, bounds, + limits(settings.margin_444, settings.angle_456, settings.angle_440)); + if state.flags_144_to_150[0] { + services.bind_board([state.counter_152, state.counter_156]); + services.commit_bound_board(); + } else { services.commit_free_board(original_frame); } +} +fn limits(margin: f32, a: f32, b: f32) -> BoardLimits { + let radians = f32::from_bits(0x3c8e_fa35); + BoardLimits {margin, angle_a:a*radians, angle_b:b*radians} +} + +/// Full Sync order; returns whether a new geometry packet was submitted. +/// Scene failure is propagated after the already completed physical updates. +pub fn sync(state: &mut State, motion: &CompletedMotion, + services: &mut S) -> Result { + state.flags_144_to_150[2] = wants_air(state, motion, &services.processed()); + if state.flags_144_to_150[2] { + let launch = services.prepare_biped_launch(); + let point = services.skeleton_point_10960(); + let frame = motion.animation_frame_912; + let point = madd(frame[2], point[2], madd(frame[1], point[1], madd(frame[0], point[0], frame[3]))); + services.launch_air(launch, point); + } + state.flags_144_to_150[0] = false; + state.flags_144_to_150[3] = motion.flag_1077; + state.frame_80 = motion.physical_frame_848; + if motion.contact_flags_368 & 1 != 0 { + let up = state.frame_80[1]; + let distance = dot(sub(motion.contact_position_192, state.frame_80[3]), up); + if !(distance <= 0.0) {state.frame_80[3] = madd(up, distance, state.frame_80[3]);} + } + let mut animation = motion.animation_frame_912; + if state.flags_144_to_150[6] { + state.duration_180 -= f32::from_bits(0x3c88_8889); + if !(state.duration_180 > 0.0) { + state.duration_180 = 0.0; state.flags_144_to_150[6] = false; + } + state.angle_172 *= f32::from_bits(0x3f66_6666); + let angle = (-state.angular_velocity_176).mul_add(state.duration_180, state.angle_172); + let (sin,cos) = crate::trigonometry::sin_cos(angle); + let forward = madd([sin,0.0,cos,sin], animation[2][2], + madd([0.0,1.0,0.0,0.0], animation[2][1], + scale([cos,0.0,-sin,cos], animation[2][0]))); + // Native branch has no zero fallback on either cross normalization. + let right = normalize_unchecked(cross(animation[1], forward)); + animation[0] = right; + animation[1] = normalize_unchecked(cross(forward, right)); + animation[2] = forward; + } + services.update_skeleton(animation, motion.vector_1056); + let p = services.processed(); + if p.flags_2484 & 0x2000_0000 == 0 && p.flags_2480 & 0x0004_0000 == 0 + && !(p.value_2852 > 0.0) { + if p.flags_2476 & 0x0040_0000 != 0 { + if !state.flags_144_to_150[4] { update_board(state, services); } + else if !(p.elapsed_2664 <= 0.5) {state.flags_144_to_150[4] = false;} + } else {services.reset_board();} + } + services.update_toolkit(ToolkitInput([state.frame_80[3], motion.vector_1008, + motion.vector_992, motion.vector_976, motion.vector_1040, animation[1], animation[0]])); + let p = services.processed(); + let frame = query_frame(state.frame_80); + let search = ground_query::edge_search(frame, p.query_context, xyz(motion.vector_1040), p.flags_2488); + let candidates = services.edge_candidates(&search)?; + if let Some(edge) = ground_query::select_edge(search, &candidates) { + if let Some(packet) = ground_query::prepare_packet(frame, p.query_context, edge, + services.deck_half_wheelbase()) { + services.submit_ground_query(packet); + return Ok(true); + } + } + Ok(false) +} +fn xyz(v: Vector) -> crate::math::Vector3 { crate::math::Vector3::new(v[0],v[1],v[2]) } +fn query_frame(f: Frame) -> ground_query::Frame { + ground_query::Frame {right:xyz(f[0]),up:xyz(f[1]),forward:xyz(f[2]),position:xyz(f[3])} +} +#[cfg(test)] mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_sync/math.rs b/crates/skate-core/src/player/offboard/ground_sync/math.rs new file mode 100644 index 00000000..5c260e4f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_sync/math.rs @@ -0,0 +1,21 @@ +use super::Vector; +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] } +pub(super) fn scale(v: Vector, s: f32) -> Vector { v.map(|x|x*s) } +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i|a[i]-b[i]) } +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i|a[i].mul_add(s,b[i])) } +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + [(-a[2]).mul_add(b[1],a[1]*b[2]),(-a[0]).mul_add(b[2],a[2]*b[0]), + (-a[1]).mul_add(b[0],a[0]*b[1]),(-a[3]).mul_add(b[3],a[3]*b[3])] +} +fn inverse(sq: f32) -> f32 { + // PC reciprocal-square-root seed; two native Newton refinements retained. + let mut r = crate::physics::reciprocal_sqrt::estimate(sq); + for _ in 0..2 {r = (r*0.5).mul_add((-sq).mul_add(r*r,1.0),r);} + r +} +pub(super) fn normalize_unchecked(v: Vector) -> Vector {scale(v,inverse(dot(v,v)))} +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let sq=dot(v,v); let r=inverse(sq); + let length=if sq==0.0 {0.0}else{sq*r}; + if length>f32::from_bits(0x3586_37bd) {scale(v,r)} else {fallback} +} diff --git a/crates/skate-core/src/player/offboard/ground_sync/tests.rs b/crates/skate-core/src/player/offboard/ground_sync/tests.rs new file mode 100644 index 00000000..7f608e83 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_sync/tests.rs @@ -0,0 +1,130 @@ +use super::*; +const FRAME: Frame = [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[0.,0.,0.,1.]]; +fn motion() -> CompletedMotion { + CompletedMotion {contact_position_192:[0.,2.,0.,1.],contact_flags_368:1, + physical_frame_848:FRAME,animation_frame_912:FRAME,vector_976:[1.;4], + vector_992:[2.;4],vector_1008:[3.;4],vector_1040:[0.;4],vector_1056:[4.;4], + launch_1076:false,flag_1077:true} +} +fn processed() -> Processed { + Processed {frame_192:FRAME,position_592:[0.;4],flags_2476:0,flags_2480:0, + flags_2484:0,flags_2488:0,elapsed_2664:0.,value_2852:0., + query_context:ground_query::QueryContext{selection_flags_2948:0,matching_id_2952:0}} +} +#[test] +fn explicit_jump_suppression_does_not_suppress_controller_launch() { + let mut state=State::default(); let mut m=motion(); let mut p=processed(); + p.flags_2476=0x80000; + assert!(wants_air(&state,&m,&p)); + for mask in [0x20000,0x80,0x100] { + p.flags_2480=mask; assert!(!wants_air(&state,&m,&p)); + m.launch_1076=true; assert!(wants_air(&state,&m,&p)); m.launch_1076=false; + } + p.flags_2480=0; state.flags_144_to_150[6]=true; + assert!(!wants_air(&state,&m,&p)); + m.contact_flags_368=0; p.elapsed_2664=0.05; + assert!(!wants_air(&state,&m,&p)); + p.elapsed_2664=f32::from_bits(0.05f32.to_bits()+1); + assert!(wants_air(&state,&m,&p)); +} +#[test] +fn board_bounds_flattens_y_but_retains_scaled_w() { + let mut frame=FRAME; frame[2]=[0.,9.,2.,6.]; + let b=board_bounds(frame,[99.,3.,4.,55.],[1.,2.,5.,8.]); + assert!((b.frame[2][3]-3.).abs()<1e-5); + assert!((b.frame[3][3]-28.).abs()<1e-4); + assert_eq!(b.frame[3][1],3.); + assert_eq!(b.extents,[1.,2.,5.,8.]); + frame[2]=[0.,7.,0.,8.]; + assert_eq!(board_bounds(frame,[0.;4],[0.;4]).frame[2],[0.,0.,1.,0.]); +} +struct Recorder { + p: Processed, events:Vec<&'static str>, point:Vector, bone_frame:Frame, + toolkit:Option, found:bool, accepted:bool, edge:bool, +} +impl Recorder { + fn new()->Self {Self{p:processed(),events:vec![],point:[0.;4],bone_frame:FRAME, + toolkit:None,found:false,accepted:false,edge:false}} +} +impl ground_query::GroundQueryScene for Recorder { + type Error=(); + fn edge_candidates(&mut self,s:&ground_query::EdgeSearch)->Result,()> { + self.events.push("edges"); assert!(s.narrow_forward); + Ok(if self.edge {vec![ground_query::Edge { + start:crate::math::Vector3::new(-0.2,0.,0.), + end:crate::math::Vector3::new(0.2,0.,0.)}]}else{vec![]}) + } + fn query_lines(&mut self,_:&ground_query::GroundQueryPacket)->Result<[Option;7],()> { + panic!("Sync must not consume its newly submitted queries") + } +} +impl Services for Recorder { + type Launch=(); type Candidate=[u32;2]; + fn processed(&self)->Processed{self.p} + fn prepare_biped_launch(&mut self){self.events.push("prepare");self.point=[2.,3.,4.,90.];} + fn skeleton_point_10960(&self)->Vector{self.point} + fn launch_air(&mut self,_:(),point:Vector){self.events.push("air");assert_eq!(point,[2.,3.,4.,1.]);} + fn update_skeleton(&mut self,frame:Frame,_:Vector){ + self.events.push("skeleton");self.bone_frame=frame; + // Exercises the required fresh processed read after this callback. + self.p.flags_2476=0x400000; + } + fn board_settings(&self)->BoardSettings{ + BoardSettings{extent_0:[2.;4],extent_16:[3.;4],offset_32:[0.;4], + angle_436:10.,angle_440:20.,margin_444:0.2,angle_452:30.,angle_456:40.} + } + fn board_flags_12836(&self)->u8{0x40} + fn probe_board(&mut self,_:Vector)->Option<[u32;2]>{self.events.push("probe");self.found.then_some([7,11])} + fn candidate_key_188(&self,c:&[u32;2])->[u32;2]{*c} + fn bone_23_position(&mut self)->Vector{self.events.push("bone23");[0.;4]} + fn classify_board(&mut self,_:&[u32;2],_:Vector,b:Bounds,l:BoardLimits)->bool{ + self.events.push("classify");assert_eq!(b.extents,[1.9;4]); + assert!((l.angle_a-30.*f32::from_bits(0x3c8efa35)).abs()<1e-6);self.accepted + } + fn query_board(&mut self,_:Vector,b:Bounds,l:BoardLimits){ + self.events.push("board_query");assert_eq!(b.extents,[3.;4]); + assert!((l.angle_a-40.*f32::from_bits(0x3c8efa35)).abs()<1e-6); + } + fn bind_board(&mut self,key:[u32;2]){self.events.push("bind");assert_eq!(key,[7,11]);} + fn commit_bound_board(&mut self){self.events.push("bound");} + fn commit_free_board(&mut self,_:Frame){self.events.push("free");} + fn reset_board(&mut self){self.events.push("reset");} + fn update_toolkit(&mut self,p:ToolkitInput){self.events.push("toolkit");self.toolkit=Some(p);} + fn deck_half_wheelbase(&self)->f32{0.3} + fn submit_ground_query(&mut self,_:ground_query::GroundQueryPacket){self.events.push("submit");} +} +#[test] +fn launch_then_skeleton_then_board_then_toolkit_then_edges() { + let mut s=State::default();let mut m=motion();m.launch_1076=true; + let mut r=Recorder::new();r.found=true;r.accepted=true; + assert_eq!(sync(&mut s,&m,&mut r),Ok(false)); + assert_eq!(r.events,["prepare","air","skeleton","probe","bone23","classify","board_query","bind","bound","toolkit","edges"]); + assert_eq!(s.frame_80[3],[0.,2.,0.,1.]); + assert_eq!([s.counter_152,s.counter_156],[7,11]); + assert!(s.flags_144_to_150[0]&&s.flags_144_to_150[2]&&s.flags_144_to_150[3]); + assert_eq!(r.toolkit.unwrap().0,[s.frame_80[3],[3.;4],[2.;4],[1.;4],[0.;4],FRAME[1],FRAME[0]]); +} +#[test] +fn failed_probe_still_updates_board_and_uses_free_action() { + let mut s=State::default();let mut r=Recorder::new(); + sync(&mut s,&motion(),&mut r).unwrap(); + assert_eq!(r.events,["skeleton","probe","board_query","free","toolkit","edges"]); +} +#[test] +fn last_spin_tick_still_rotates_and_landing_gate_clears_without_query() { + let mut s=State::default();s.flags_144_to_150[6]=true;s.flags_144_to_150[4]=true; + s.duration_180=0.001;s.angle_172=1.;s.angular_velocity_176=99.; + let mut r=Recorder::new();r.p.elapsed_2664=0.6; + sync(&mut s,&motion(),&mut r).unwrap(); + assert!(!s.flags_144_to_150[6]);assert!(!s.flags_144_to_150[4]); + assert_eq!(s.duration_180,0.);assert_eq!(s.angle_172,0.9); + assert!(r.bone_frame[2][0]>0.7); + assert_eq!(r.events,["skeleton","toolkit","edges"]); +} +#[test] +fn selected_edge_submits_next_frame_packet_without_consuming_it() { + let mut s=State::default();let mut r=Recorder::new();r.edge=true; + let mut m=motion();m.contact_position_192=FRAME[3]; + assert_eq!(sync(&mut s,&m,&mut r),Ok(true)); + assert_eq!(&r.events[r.events.len()-3..],["toolkit","edges","submit"]); +} diff --git a/crates/skate-core/src/player/offboard/mod.rs b/crates/skate-core/src/player/offboard/mod.rs new file mode 100644 index 00000000..0d7cf129 --- /dev/null +++ b/crates/skate-core/src/player/offboard/mod.rs @@ -0,0 +1,16 @@ +//! Recovered off-board and shared recovery behavior. +pub mod twist_lean; +pub mod cadence; +pub mod contact_correction; +pub mod ground_input; +pub mod ground_branch; +pub mod position_output; +pub mod slide; +pub mod movement_intent; +pub mod movement_velocity; +pub mod surface_frame; +pub mod ground_motion; +pub mod controller; +pub mod ground_entry; +pub mod ground_query; +pub mod pose_adjust; diff --git a/crates/skate-core/src/player/offboard/movement_intent.rs b/crates/skate-core/src/player/offboard/movement_intent.rs new file mode 100644 index 00000000..a837340c --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_intent.rs @@ -0,0 +1,134 @@ +//! Biped movement producer82D7CF50, steering merge82D7D5A0 and slide82D7D608. +//! Original TU3; state/input offsets are documented at the host boundary. +use crate::point_graph::PointGraph; +mod math; +use math::*; +pub type Vector = [f32; 4]; + +pub struct Settings { + /// Biped320 <- physics_biped350, x+16/y+32. + pub sprint_speed: PointGraph<4>, + /// Biped5D0 <- physics_biped90, x+16/y+80. + pub normal_speed: PointGraph<16>, + /// Biped3F0 <- physics_biped1C0, x+16/y+48; header+8 is cap. + pub sprint_blend: PointGraph<8>, + pub sprint_time_cap: f32, + /// Biped440 <- physics_biped300, x+16/y+48. + pub slide_steering: PointGraph<8>, +} + +/// Retained outputs and timers; native Reset82D7B1C0 initializes these to zero. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct State { + pub speed: f32, //756 + pub steering: f32, //760 + pub sprint_time: f32, //764 + pub sprint_grace: f32, //768 + pub secondary_speed: f32, //776 + pub original_steering: f32, //780 + pub obstacle_centered: bool, //712 + pub edge_aligned: bool, //713 + pub edge_target: Vector, //672 +} + +pub struct Input { + pub flags: u32, //input176 + pub suppress_minimum: bool, //input318 + pub magnitude: f32, //input308 + pub steering: f32, //input312 + pub sprint_pressed: bool, //input316 + pub edge_active: bool, //input353; also disables sprint + pub ignore_obstacle: bool, //input432 + pub direction: Vector, //input256 + pub edge_tangent: Vector, //input400 + pub edge_point: Vector, //input416 + pub right: Vector, //Biped0 + pub up: Vector, //Biped16 + pub forward: Vector, //Biped32 + pub position: Vector, //Biped48 + pub obstacle: bool, //Biped708 + pub obstacle_normal: Vector, //Biped400 + pub sliding: bool, //Biped710 + pub slide_velocity: Vector, //Biped528 +} + +impl State { + /// Once per original physical tick; source uses literal1/60, not graph dt. + pub fn update(&mut self, settings: &Settings, input: &Input) { + let minimum = if !input.suppress_minimum && input.flags & 8 != 0 { 0.5 } else { 0.0 }; + let magnitude = select(minimum - input.magnitude, minimum, input.magnitude); + let dt = f32::from_bits(0x3c88_8889); + self.sprint_grace = if input.sprint_pressed { f32::from_bits(0x3e99_999a) } else { self.sprint_grace - dt }; + let sprint = (self.sprint_grace > 0.0 || input.sprint_pressed) && !input.edge_active; + self.sprint_time = clamp(self.sprint_time + if sprint { dt } else { -dt }, 0.0, settings.sprint_time_cap); + let fast = settings.sprint_speed.evaluate(magnitude); + let normal = settings.normal_speed.evaluate(magnitude); + let blend = if sprint { settings.sprint_blend.evaluate(self.sprint_time) } else { 0.0 }; + self.speed = (1.0 - blend).mul_add(normal, blend * fast); + self.secondary_speed = 0.0; + self.steering = input.steering; + self.original_steering = input.steering; + self.edge_aligned = false; + if input.obstacle && !input.ignore_obstacle && !(self.speed <= 0.0) { + let opposite = scale(input.obstacle_normal, -1.0); + let desired = wrap_angle(signed_angle(opposite, input.direction, [0.0, 1.0, 0.0, 0.0])); + let degrees = if self.obstacle_centered { 55.0 } else { 25.0 }; + self.obstacle_centered = degrees * f32::from_bits(0x3c8e_fa35) > desired.abs(); + let facing = wrap_angle(signed_angle(opposite, input.forward, [0.0, 1.0, 0.0, 0.0])); + if !(facing.abs() >= HALF_PI) { + let correction = if self.obstacle_centered { + (facing * f32::from_bits(0xbf22_f983)) * 0.5 + } else { + (-facing).mul_add(f32::from_bits(0x3f22_f983), if desired <= 0.0 { -1.0 } else { 1.0 }) * 0.5 + }; + self.merge_steering(clamp(correction, -1.0, 1.0)); + if self.obstacle_centered { self.speed = 0.0; } + } + } else { + self.obstacle_centered = false; + if input.sliding { + self.apply_slide(settings, input); + } else if input.edge_active && !(self.speed <= 0.0) { + let direction = if input.magnitude == 0.0 { input.forward } else { input.direction }; + let tangent = if dot(input.edge_tangent, direction) >= 0.0 { input.edge_tangent } else { scale(input.edge_tangent, -1.0) }; + self.edge_target = madd(tangent, 0.5, input.edge_point); + let delta = sub(self.edge_target, input.position); + if !(dot(horizontal(direction), horizontal(delta)) <= f32::from_bits(0x3f66_6666)) { + self.edge_aligned = true; + let normalized = normalize_or(delta, input.forward); + self.steering = clamp(dot(normalized, input.right) * 0.5, -1.0, 1.0); + } + } + } + } + + ///82D7D5A0: opposite signs replace; same signs retain larger magnitude. + fn merge_steering(&mut self, correction: f32) { + if !(self.steering * correction >= 0.0) { + self.steering = correction; + } else { + let sign = if correction <= 0.0 { -1.0 } else { 1.0 }; + let old = self.steering * sign; + let new = correction * sign; + self.steering = select(old - new, old, new) * sign; + } + } + + fn apply_slide(&mut self, settings: &Settings, input: &Input) { + let angle = wrap_angle(projected_angle(input.slide_velocity, input.forward, input.up)); + let original = self.steering; + self.speed = (1.0 - original.abs()) * self.speed; + let sign = select(angle, 1.0, -1.0); + if !(original * sign <= 0.0) { + let limit = 45.0 * f32::from_bits(0x3c8e_fa35); + let weight = (-angle).mul_add(sign, limit) / limit; + self.steering = clamp(weight, 0.0, 1.0) * original; + } + let correction = settings.slide_steering.evaluate(sign * (angle * f32::from_bits(0x3ea2_f983))); + self.steering = clamp((-correction).mul_add(sign, self.steering), -1.0, 1.0); + if !(angle.abs() >= HALF_PI) { self.speed += length(input.slide_velocity); } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/movement_intent/math.rs b/crates/skate-core/src/player/offboard/movement_intent/math.rs new file mode 100644 index 00000000..d34f9c83 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_intent/math.rs @@ -0,0 +1,56 @@ +//! Address-audited scalar forms; host sqrt seed plus original refinements. +//! Ordinary PC arithmetic is not bit-exact Xenon emulation. +use super::Vector; +pub(super) const HALF_PI: f32 = f32::from_bits(0x3fc9_0fdb); +const EPSILON: f32 = f32::from_bits(0x3586_37bd); //82F826F8 ->830BD350 +pub(super) fn select(test: f32, yes: f32, no: f32) -> f32 { if test >= 0.0 { yes } else { no } } +pub(super) fn clamp(value: f32, low: f32, high: f32) -> f32 { + let lower = select(low - value, low, value); + select(high - lower, lower, high) +} +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } +pub(super) fn scale(a: Vector, s: f32) -> Vector { a.map(|v| v * s) } +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i] - b[i]) } +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s, b[i])) } +fn inverse_squared(value: f32, refinements: usize) -> f32 { + let mut r = crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..refinements { + let error = (-value).mul_add(r * r, 1.0); + r = (r * 0.5).mul_add(error, r); + } + r +} +pub(super) fn length(v: Vector) -> f32 { + let squared = dot(v, v); + let value = squared * inverse_squared(squared, 2); + if squared == 0.0 { 0.0 } else { value } +} +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let squared = dot(v, v); + let r = inverse_squared(squared, 2); + let length = if squared == 0.0 { 0.0 } else { squared * r }; + if length > EPSILON { scale(v, r) } else { fallback } +} +///8269A4F0 permute [x,zero,x,x], then vrlimi mask2 restores Z. +///W is X, not zero: preserve the native lane even though the caller dots XYZ. +pub(super) fn horizontal(v: Vector) -> Vector { normalize_or([v[0], 0.0, v[2], v[0]], [0.0; 4]) } +pub(super) fn wrap_angle(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb) +} +pub(super) fn signed_angle(a: Vector, b: Vector, axis: Vector) -> f32 { + let aa = dot(a, a); + let bb = dot(b, b); + let threshold = f32::from_bits(0x38d1_b717); + if !(aa > threshold && bb > threshold) { return 0.0; } + let a = scale(a, inverse_squared(aa, 1)); + let b = scale(b, inverse_squared(bb, 1)); + let angle = crate::trigonometry::acos(dot(a, b).max(-1.0).min(1.0)); + let cross = [(-a[2]).mul_add(b[1], a[1] * b[2]), (-a[0]).mul_add(b[2], a[2] * b[0]), (-a[1]).mul_add(b[0], a[0] * b[1]), (-a[3]).mul_add(b[3], a[3] * b[3])]; + if dot(cross, axis) < 0.0 { f32::from_bits(0x40c9_0fdb) - angle } else { angle } +} +pub(super) fn projected_angle(a: Vector, b: Vector, axis: Vector) -> f32 { + if !(dot(a, a) * dot(b, b) > f32::from_bits(0x3780_0000)) { return 0.0; } + signed_angle(sub(a, scale(axis, dot(axis, a))), sub(b, scale(axis, dot(axis, b))), axis) +} diff --git a/crates/skate-core/src/player/offboard/movement_intent/tests.rs b/crates/skate-core/src/player/offboard/movement_intent/tests.rs new file mode 100644 index 00000000..61c09ec8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_intent/tests.rs @@ -0,0 +1,95 @@ +use super::*; + +fn constant(value: f32) -> PointGraph { + PointGraph { x: std::array::from_fn(|i| i as f32), y: [value; N] } +} +fn settings() -> Settings { + Settings { sprint_speed: constant(8.0), normal_speed: constant(4.0), sprint_blend: constant(1.0), sprint_time_cap: 2.0, slide_steering: constant(0.0) } +} +fn input() -> Input { + Input { + flags: 0, suppress_minimum: false, magnitude: 1.0, steering: 0.0, + sprint_pressed: false, edge_active: false, ignore_obstacle: false, + direction: [0.0, 0.0, 1.0, 0.0], edge_tangent: [0.0, 0.0, 1.0, 0.0], edge_point: [0.0; 4], + right: [1.0, 0.0, 0.0, 0.0], up: [0.0, 1.0, 0.0, 0.0], forward: [0.0, 0.0, 1.0, 0.0], position: [0.0; 4], + obstacle: false, obstacle_normal: [0.0, 0.0, -1.0, 0.0], sliding: false, slide_velocity: [0.0; 4], + } +} +#[test] +fn sprint_grace_survives_release_but_edge_disables_blend() { + let mut state = State::default(); + let mut input = input(); + input.sprint_pressed = true; + state.update(&settings(), &input); + assert_eq!(state.speed, 8.0); + input.sprint_pressed = false; + state.update(&settings(), &input); + assert_eq!(state.speed, 8.0); + assert!(state.sprint_grace > 0.0); + input.edge_active = true; + state.update(&settings(), &input); + assert_eq!(state.speed, 4.0); + assert!(state.sprint_time > 0.0); +} +#[test] +fn centered_obstacle_stops_speed_and_has_priority_over_slide() { + let mut state = State::default(); + let mut input = input(); + input.obstacle = true; + input.sliding = true; + input.slide_velocity = [0.0, 0.0, 3.0, 0.0]; + state.update(&settings(), &input); + assert!(state.obstacle_centered); + assert_eq!(state.speed, 0.0); + input.ignore_obstacle = true; + state.update(&settings(), &input); + assert!(!state.obstacle_centered); + assert!((state.speed - 7.0).abs() < 0.00001); +} +#[test] +fn steering_merge_replaces_opposition_and_preserves_stronger_same_sign() { + let mut state = State::default(); + state.steering = 0.8; + state.merge_steering(0.2); + assert_eq!(state.steering, 0.8); + state.merge_steering(-0.1); + assert_eq!(state.steering, -0.1); + state.merge_steering(-0.6); + assert_eq!(state.steering, -0.6); +} +#[test] +fn edge_tangent_flips_before_target_and_flag_resets_next_tick() { + let mut state = State::default(); + let mut input = input(); + input.edge_active = true; + input.edge_tangent = [0.0, 0.0, -1.0, 0.0]; + input.edge_point = [0.2, 0.0, 1.0, 0.0]; + state.update(&settings(), &input); + assert_eq!(state.edge_target, [0.2, 0.0, 1.5, 0.0]); + assert!(state.edge_aligned); + assert!(state.steering > 0.0); + input.edge_active = false; + state.update(&settings(), &input); + assert!(!state.edge_aligned); + assert_eq!(state.edge_target, [0.2, 0.0, 1.5, 0.0]); +} +#[test] +fn angle_degeneracy_and_half_turn_boundary_are_retained() { + assert_eq!(signed_angle([0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]), 0.0); + assert_eq!(normalize_or([0.0; 4], [0.0, 0.0, 1.0, 0.0]), [0.0, 0.0, 1.0, 0.0]); + let pi = f32::from_bits(0x4049_0fdb); + assert!((wrap_angle(pi) - pi).abs() < 0.000001); + assert!(wrap_angle(pi + 0.1) < 0.0); +} + +#[test] +fn edge_target_preserves_native_fourth_lane() { + let mut state = State::default(); + let mut input = input(); + input.edge_active = true; + input.edge_tangent[3] = 2.0; + input.edge_point[3] = 3.0; + state.update(&settings(), &input); + assert_eq!(state.edge_target[3], 4.0); + assert_eq!(horizontal([1.0, 4.0, 0.0, 9.0]), [1.0, 0.0, 0.0, 1.0]); +} diff --git a/crates/skate-core/src/player/offboard/movement_velocity.rs b/crates/skate-core/src/player/offboard/movement_velocity.rs new file mode 100644 index 00000000..c1e03666 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_velocity.rs @@ -0,0 +1,115 @@ +//! Original Biped velocity/turn producer82D7D9B0, TU3. +//! Call after movement intent; velocity/turn limits are per physical update. +use crate::point_graph::PointGraph; +mod math; +use math::*; +pub type Vector = [f32; 4]; + +pub struct Settings { + /// Biped0x490 <- layout0x2B0 Hash_31309236050A8F09. + pub slope_speed_scalar: PointGraph<8>, + /// Biped0x4E0 <- attribute Hash_CE45C724B30F9134. + pub slope_mode_speed: PointGraph<8>, + /// Biped0x530 <- layout0x120 TurnVsSpeed. + pub turn_vs_speed: PointGraph<8>, + /// Biped0x580 <- layout0x170 TurnDeltaVsSpeed. + pub turn_delta_vs_speed: PointGraph<8>, +} + +/// Actual Biped fields, decimal offsets. Reset82D7B1C0 sets timer=-1, others0. +#[derive(Clone, Debug, PartialEq)] +pub struct State { + pub velocity: Vector, //480 + pub turn: f32, //688 + pub forward_delta: f32, //692 + pub right_delta: f32, //696 + pub speed: f32, //704 + pub override_remaining: f32, //772 +} +impl Default for State { + fn default() -> Self { + Self { velocity: [0.0; 4], turn: 0.0, forward_delta: 0.0, right_delta: 0.0, speed: 0.0, override_remaining: -1.0 } + } +} +pub struct Input { + pub forward: Vector, //Biped32 + pub right: Vector, //Biped0 + pub plane_normal: Vector, //Biped544 + pub desired_speed: f32, //Biped756 + pub steering: f32, //Biped760 + pub slope_mode: bool, //Biped714 + pub obstacle: bool, //Biped708 + pub obstacle_normal: Vector, //Biped400 + pub override_gate: f32, //input296 + pub override_duration: f32, //input300 + pub override_velocity: Vector, //input240 + pub flags: u32, //input176 +} + +impl State { + pub fn update(&mut self, settings: &Settings, input: &Input) { + let dt = f32::from_bits(0x3c88_8889); + let radians = f32::from_bits(0x3c8e_fa35); + let projected = sub(input.forward, scale(input.plane_normal, dot(input.forward, input.plane_normal))); + let direction = normalize_or(projected, input.forward); + //82D7DB08..DB94 inlines the same recovered asin polynomial. + let slope_degrees = crate::trigonometry::asin(direction[1]) * f32::from_bits(0x4265_2ee1); + let target_speed = if input.slope_mode { + settings.slope_mode_speed.evaluate(slope_degrees.abs()) + } else { + let mut target = settings.slope_speed_scalar.evaluate(slope_degrees) * input.desired_speed; + if !(input.desired_speed <= 0.5) && !(target >= self.speed) { + let gravity_delta = if self.velocity[1] > 0.0 { + (f32::from_bits(0xc11c_cccd) * dt) * select(-direction[1], 0.0, direction[1]) + } else { 0.0 }; + let delta = target - self.speed; + let limited_delta = select(delta - -2.0, delta, -2.0) * dt; + target = self.speed + select(gravity_delta - limited_delta, limited_delta, gravity_delta); + } + target + }; + let original = self.velocity; + let mut target = madd(original, 0.0, scale(direction, target_speed)); + let turn_error = (settings.turn_vs_speed.evaluate(self.speed) * input.steering).mul_add(radians, -self.turn); + let mut maximum_delta = if input.slope_mode { 0.5 } else { f32::from_bits(0x3e4c_cccd) }; + if !(input.override_gate < 0.0) && !input.slope_mode { + let first = self.override_remaining < 0.0; + if first { self.override_remaining = input.override_duration; } + let ratio = if input.override_duration < f32::from_bits(0x3a83_126f) { 0.0 } else { self.override_remaining / input.override_duration }; + let amount = ratio * 1.25; + let mut blend = select(1.0 - amount, amount, 1.0); + if !first && !(length(input.override_velocity) <= length(original) * f32::from_bits(0x3f8c_cccd)) { + blend = select(blend - 0.5, 0.5, blend); + } + target = madd(original, blend, scale(input.override_velocity, 1.0 - blend)); + maximum_delta = 100.0; + if input.flags & 8 != 0 { + let speed = length(target); + if !(speed >= f32::from_bits(0x3c23_d70a)) { + target = scale(direction, 1.0); + } else if !(speed >= 1.0) { + target = scale(target, 1.0 / speed); + } + } + let remaining = self.override_remaining - dt; + self.override_remaining = select(-remaining, 0.0, remaining); + } else { + self.override_remaining = -1.0; + } + let turn_delta = settings.turn_delta_vs_speed.evaluate(self.speed) * radians; + let mut velocity = limit_delta(target, original, maximum_delta); + self.turn += clamp(turn_error, -turn_delta, turn_delta); + if input.obstacle { + let opposite = scale(input.obstacle_normal, -1.0); + target = remove_positive(target, opposite); + velocity = remove_positive(velocity, opposite); + } + self.right_delta = dot(sub(velocity, original), input.right); + self.forward_delta = dot(sub(target, original), input.forward); + self.velocity = velocity; + self.speed = length(velocity); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/movement_velocity/math.rs b/crates/skate-core/src/player/offboard/movement_velocity/math.rs new file mode 100644 index 00000000..6be7e536 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_velocity/math.rs @@ -0,0 +1,41 @@ +//!82BD41B0 delta bound and82C1E170 one-sided projection, original raw audited. +use super::Vector; +pub(super) fn select(test: f32, yes: f32, no: f32) -> f32 { if test >= 0.0 { yes } else { no } } +pub(super) fn clamp(value: f32, low: f32, high: f32) -> f32 { + let lower = select(low - value, low, value); + select(high - lower, lower, high) +} +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } +pub(super) fn scale(a: Vector, s: f32) -> Vector { a.map(|v| v * s) } +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i] - b[i]) } +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s, b[i])) } +fn inverse_squared(value: f32) -> f32 { + //PC seed is not bit-exact Xenon; retain both original refinement stages. + let mut r = crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..2 { + let error = (-value).mul_add(r * r, 1.0); + r = (r * 0.5).mul_add(error, r); + } + r +} +pub(super) fn length(v: Vector) -> f32 { + let squared = dot(v, v); + let value = squared * inverse_squared(squared); + if squared == 0.0 { 0.0 } else { value } +} +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let squared = dot(v, v); + let r = inverse_squared(squared); + let magnitude = if squared == 0.0 { 0.0 } else { squared * r }; + if magnitude > f32::from_bits(0x3586_37bd) { scale(v, r) } else { fallback } +} +pub(super) fn limit_delta(target: Vector, current: Vector, maximum: f32) -> Vector { + let delta = sub(target, current); + let magnitude = length(delta); + if maximum >= magnitude { target } else { madd(delta, maximum / magnitude, current) } +} +pub(super) fn remove_positive(value: Vector, direction: Vector) -> Vector { + let projection = dot(value, normalize_or(direction, [0.0; 4])); + //Native subtracts original direction, NOT the normalized direction. + if projection > 0.0 { sub(value, scale(direction, projection)) } else { value } +} diff --git a/crates/skate-core/src/player/offboard/movement_velocity/tests.rs b/crates/skate-core/src/player/offboard/movement_velocity/tests.rs new file mode 100644 index 00000000..e0aa7a05 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_velocity/tests.rs @@ -0,0 +1,95 @@ +use super::*; +fn curve(value: f32) -> PointGraph<8> { PointGraph { x: std::array::from_fn(|i| i as f32), y: [value; 8] } } +fn settings() -> Settings { + Settings { slope_speed_scalar: curve(1.0), slope_mode_speed: curve(3.0), turn_vs_speed: curve(90.0), turn_delta_vs_speed: curve(15.0) } +} +fn input() -> Input { + Input { + forward: [0.0, 0.0, 1.0, 0.0], right: [1.0, 0.0, 0.0, 0.0], plane_normal: [0.0, 1.0, 0.0, 0.0], desired_speed: 5.0, steering: 1.0, + slope_mode: false, obstacle: false, obstacle_normal: [0.0, 0.0, -1.0, 0.0], override_gate: -1.0, override_duration: 1.0, + override_velocity: [0.0; 4], flags: 0, + } +} +#[test] +fn desired_forward_delta_differs_from_limited_velocity() { + let mut state = State::default(); + state.update(&settings(), &input()); + assert_eq!(state.forward_delta, 5.0); + assert!((state.velocity[2] - 0.2).abs() < 0.000001); + assert_eq!(state.right_delta, 0.0); + assert_eq!(state.turn, 15.0 * f32::from_bits(0x3c8e_fa35)); + assert_eq!(state.override_remaining, -1.0); +} +#[test] +fn slope_mode_uses_its_own_speed_and_ignores_override() { + let mut state = State::default(); + let mut input = input(); + input.slope_mode = true; + input.override_gate = 0.0; + input.override_velocity = [20.0, 0.0, 0.0, 0.0]; + state.update(&settings(), &input); + assert_eq!(state.forward_delta, 3.0); + assert!((state.speed - 0.5).abs() < 0.000001); + assert_eq!(state.override_remaining, -1.0); +} +#[test] +fn override_first_tick_retains_old_velocity_then_fast_target_reduces_blend() { + let mut state = State::default(); + let mut input = input(); + input.override_gate = 0.0; + input.override_velocity = [0.0, 0.0, 10.0, 0.0]; + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0; 4]); + assert!(state.override_remaining < 1.0); + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0, 0.0, 5.0, 0.0]); +} +#[test] +fn minimum_motion_override_uses_projected_forward() { + let mut state = State::default(); + let mut input = input(); + input.override_gate = 0.0; + input.flags = 8; + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0, 0.0, 1.0, 0.0]); +} +#[test] +fn obstacle_projects_desired_and_limited_velocity_independently() { + let mut state = State::default(); + let mut input = input(); + input.obstacle = true; + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0; 4]); + assert_eq!(state.forward_delta, 0.0); +} +#[test] +fn nonunit_projection_subtracts_original_direction_and_zero_is_safe() { + assert_eq!(remove_positive([2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0]), [-2.0, 0.0, 0.0, 0.0]); + assert_eq!(remove_positive([-2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0]), [-2.0, 0.0, 0.0, 0.0]); + assert_eq!(remove_positive([2.0, 0.0, 0.0, 0.0], [0.0; 4]), [2.0, 0.0, 0.0, 0.0]); + assert_eq!(limit_delta([0.0; 4], [0.0; 4], 0.2), [0.0; 4]); +} + +#[test] +fn upward_slowdown_retains_gravity_even_beyond_two_per_second_bound() { + let mut state = State { velocity: [0.0, 6.0, 8.0, 0.0], speed: 10.0, ..State::default() }; + let mut input = input(); + input.forward = [0.0, 0.6, 0.8, 0.0]; + input.plane_normal = [1.0, 0.0, 0.0, 0.0]; + input.desired_speed = 1.0; + state.update(&settings(), &input); + let expected = 10.0 + (f32::from_bits(0xc11c_cccd) * f32::from_bits(0x3c88_8889)) * 0.6; + assert!((state.speed - expected).abs() < 0.00001); + assert!(state.speed < 10.0 - 2.0 * f32::from_bits(0x3c88_8889)); +} + +#[test] +fn vector_delta_limits_xyz_and_preserves_scaled_w() { + let mut state = State::default(); + let mut input = input(); + input.forward[3] = 2.0; + state.update(&settings(), &input); + assert!((state.speed - 0.2).abs() < 0.000001); + assert!((state.velocity[3] - 0.4).abs() < 0.000001); + assert_eq!(remove_positive([2.0, 0.0, 0.0, 7.0], [2.0, 0.0, 0.0, 3.0]), [-2.0, 0.0, 0.0, 1.0]); +} diff --git a/crates/skate-core/src/player/offboard/pose_adjust.rs b/crates/skate-core/src/player/offboard/pose_adjust.rs new file mode 100644 index 00000000..2a1736ac --- /dev/null +++ b/crates/skate-core/src/player/offboard/pose_adjust.rs @@ -0,0 +1,40 @@ +//! Skeleton::UpdateOffboardAdjust82BD8F10 and SkeletonIK82BF1A78. +//! Both transforms are current animation globals, before physical bone offsets. +use crate::physics::skeleton_animation_record::AnimationPartTransform as Transform; +/// Flags2476 bit2 selects left when set, right when clear. +pub fn selected_hand(flags_2476: u32) -> usize { + usize::from(flags_2476 & 4 == 0) +} +/// Actual hand composed with the rigid inverse of the board-parented hand. +/// Native inverse is a transpose, not a general scale/shear matrix inverse. +pub fn adjustment(actual: &Transform, reparented: &Transform) -> Transform { + let inverse: [[f32; 4]; 3] = std::array::from_fn(|column| { + [ + reparented[0][column], + reparented[1][column], + reparented[2][column], + 0., + ] + }); + //82BF1B18/40/50: inverse translation accumulates Z, then Y, then X. + let translation: [f32; 4] = std::array::from_fn(|lane| { + let z = (0. - reparented[3][2]) * inverse[2][lane]; + let yz = (0. - reparented[3][1]).mul_add(inverse[1][lane], z); + (0. - reparented[3][0]).mul_add(inverse[0][lane], yz) + }); + std::array::from_fn(|column| { + std::array::from_fn(|lane| { + if column < 3 { + let x = inverse[column][0] * actual[0][lane]; + let y = inverse[column][1].mul_add(actual[1][lane], x); + inverse[column][2].mul_add(actual[2][lane], y) + } else { + let x = translation[0].mul_add(actual[0][lane], actual[3][lane]); + let y = translation[1].mul_add(actual[1][lane], x); + translation[2].mul_add(actual[2][lane], y) + } + }) + }) +} +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/pose_adjust/tests.rs b/crates/skate-core/src/player/offboard/pose_adjust/tests.rs new file mode 100644 index 00000000..552e5405 --- /dev/null +++ b/crates/skate-core/src/player/offboard/pose_adjust/tests.rs @@ -0,0 +1,25 @@ +use super::*; +use crate::physics::skeleton_animation_record::IDENTITY; +#[test] +fn mapped_hand_choice_and_relative_translation() { + assert_eq!(selected_hand(4), 0); + assert_eq!(selected_hand(0), 1); + let mut actual = IDENTITY; + actual[3] = [7., 8., 9., 0.]; + let mut reparented = IDENTITY; + reparented[3] = [2., 3., 4., 0.]; + let result = adjustment(&actual, &reparented); + assert_eq!(result[3], [5., 5., 5., 0.]); + assert_eq!(result[..3], IDENTITY[..3]); +} +#[test] +fn relative_rotated_hand_maps_reparented_position_to_actual_position() { + let mut target = IDENTITY; + target[0] = [0., 1., 0., 0.]; + target[1] = [-1., 0., 0., 0.]; + target[3] = [2., 3., 0., 0.]; + let result = adjustment(&IDENTITY, &target); + assert_eq!(result[0], [0., -1., 0., 0.]); + assert_eq!(result[1], [1., 0., 0., 0.]); + assert_eq!(result[3], [-3., 2., 0., 0.]); +} diff --git a/crates/skate-core/src/player/offboard/position_output.rs b/crates/skate-core/src/player/offboard/position_output.rs new file mode 100644 index 00000000..85340f56 --- /dev/null +++ b/crates/skate-core/src/player/offboard/position_output.rs @@ -0,0 +1,109 @@ +//! Biped frame/position output stages82D80360 and82D80548. +//! The retained state is part of the actual Biped owner; no inferred initialization. +use crate::physics::{native_arithmetic::dot3, reciprocal_sqrt::estimate, skeleton_root::orthonormalize}; +type Vector = [f32; 4]; +type Matrix = [Vector; 4]; +const DT: f32 = f32::from_bits(0x3c88_8889); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameOutput { + /// Biped128..176. Common cadence reads this frame's up vector144. + pub frame: Matrix, + /// Biped512. Common physical publication copies this to OffBoard64. + pub velocity: Vector, +} + +impl FrameOutput { + ///82D80360 after movement, before82D80548 and cadence82D80720. + pub fn update( + &mut self, + forward_32: Vector, + frame_up_448: Vector, + projection_axis_416: Vector, + position_48: Vector, + ) { + let right = normalize(cross(frame_up_448, forward_32)); + let forward = normalize(cross(right, projection_axis_416)); + self.frame = orthonormalize([right, frame_up_448, forward, self.frame[3]]); + let mut change: Vector = std::array::from_fn(|i| (position_48[i] - self.frame[3][i]) * 60.0 - self.velocity[i]); + change[1] = select(change[1] - -1.0, change[1], -1.0); + self.velocity = std::array::from_fn(|i| self.velocity[i] + change[i]); + self.frame[3] = std::array::from_fn(|i| self.velocity[i].mul_add(DT, self.frame[3][i])); + } +} + +#[derive(Clone, Copy, Debug)] +pub struct PositionInput { + pub previous_origin_112: Vector, + pub override_origin_592: Vector, + pub use_override_711: bool, + pub projection_axis_416: Vector, + pub contact_flags_176: u32, + pub contact_origin_0: Vector, + pub contact_origin_96: Vector, + pub frame_position_176: Vector, + pub animation_position_272: Vector, +} + +///82D80548 updates retained Biped368 using actual ground-job contact/pose data. +pub fn update_position(position_368: &mut Vector, input: PositionInput) { + let up = input.projection_axis_416; + let mut origin = input.previous_origin_112; + if input.use_override_711 { + origin = input.override_origin_592; + } else if input.contact_flags_176 & 1 != 0 { + if dot3(subtract(input.contact_origin_0, origin), up) > 0.0 { + origin = input.contact_origin_0; + } + } + if input.contact_flags_176 & 2 != 0 { + let contact_height = dot3(subtract(input.contact_origin_96, origin), up); + let frame_height = dot3(subtract(input.frame_position_176, origin), up); + let height = select(contact_height - frame_height, contact_height, frame_height); + let height = select(height - 0.3, 0.3, height); + //82D80638 ble: unordered takes the update branch. + if !(height <= 0.0) { + origin = std::array::from_fn(|i| up[i].mul_add(height, origin[i])); + } + } + let delta = subtract(input.animation_position_272, origin); + let projection = dot3(up, delta); + let planar: Vector = std::array::from_fn(|i| delta[i] - up[i] * projection); + let target: Vector = std::array::from_fn(|i| up[i].mul_add(0.95, origin[i] + planar[i])); + let delta = subtract(target, *position_368); + let height = dot3(delta, up); + let lower = select(-0.1 - height, -0.1, height); + let bounded = select(0.1 - lower, lower, 0.1); + let correction = bounded - height; + *position_368 = std::array::from_fn(|i| { + position_368[i] + up[i].mul_add(correction, delta[i]) + }); +} + +fn subtract(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn normalize(vector: Vector) -> Vector { + let squared = dot3(vector, vector); + let mut inverse = estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + vector.map(|value| value * inverse) +} + +#[cfg(test)] +#[path = "position_output/tests.rs"] +mod tests; + diff --git a/crates/skate-core/src/player/offboard/position_output/tests.rs b/crates/skate-core/src/player/offboard/position_output/tests.rs new file mode 100644 index 00000000..b3c6109f --- /dev/null +++ b/crates/skate-core/src/player/offboard/position_output/tests.rs @@ -0,0 +1,61 @@ +use super::*; +const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +const FORWARD: Vector = [0.0, 0.0, 1.0, 0.0]; +fn close(a: f32, b: f32) { assert!((a-b).abs() < 2e-6, "{a} != {b}"); } +fn frame() -> FrameOutput { + FrameOutput { frame: [[1.0,0.0,0.0,0.0], UP, FORWARD, [0.0;4]], velocity: [0.0;4] } +} +fn input() -> PositionInput { + PositionInput { previous_origin_112:[0.0;4], override_origin_592:[0.0;4], use_override_711:false, + projection_axis_416:UP, contact_flags_176:0, contact_origin_0:[0.0;4], + contact_origin_96:[0.0;4], frame_position_176:[0.0;4], animation_position_272:[2.0,0.0,3.0,0.0] } +} + +#[test] +fn frame_tracks_horizontal_motion_but_limits_downward_velocity_change() { + let mut state = frame(); + state.update(FORWARD, UP, UP, [1.0,-1.0,2.0,0.0]); + close(state.velocity[0],60.0); + close(state.velocity[1],-1.0); + close(state.velocity[2],120.0); + close(state.frame[3][0],1.0); + close(state.frame[3][1],-1.0/60.0); + state.update(FORWARD, UP, UP, [1.0,-1.0,2.0,0.0]); + close(state.velocity[1],-2.0); + close(state.frame[3][1],-3.0/60.0); +} + +#[test] +fn upward_frame_velocity_is_not_limited_and_basis_is_normalized() { + let mut state = frame(); + state.update([0.0,0.0,3.0,0.0], UP, UP, [0.0,1.0,0.0,0.0]); + close(state.velocity[1],60.0); + close(state.frame[3][1],1.0); + close(state.frame[0][0], 1.0); + close(state.frame[2][2], 1.0); +} + +#[test] +fn position_follows_planar_animation_and_limits_vertical_change_per_update() { + let mut position = [0.0;4]; + update_position(&mut position,input()); + assert_eq!(position[0],2.0); + assert_eq!(position[2],3.0); + close(position[1],0.1); + update_position(&mut position,input()); + close(position[1],0.2); +} + +#[test] +fn alternate_origin_precedes_first_contact_and_second_contact_is_capped() { + let mut position = [0.0,0.95,0.0,0.0]; + let mut i=input(); + i.contact_flags_176=3; + i.contact_origin_0=[0.0,10.0,0.0,0.0]; + i.contact_origin_96=[0.0,20.0,0.0,0.0]; + i.use_override_711=true; + i.override_origin_592=[0.0,-0.3,0.0,0.0]; + update_position(&mut position,i); + close(position[1],0.95); //override-.3 then second-contact+.3 then offset+.95. +} + diff --git a/crates/skate-core/src/player/offboard/reset_animation.rs b/crates/skate-core/src/player/offboard/reset_animation.rs new file mode 100644 index 00000000..087a4c95 --- /dev/null +++ b/crates/skate-core/src/player/offboard/reset_animation.rs @@ -0,0 +1,48 @@ +//! Original TU3 reset field writes. Borrow the canonical animation/MG owners. +///82B97308 ->82B972A8. This is the original stance enum comparison, not XOR +///of two guessed booleans; all original integer cases remain distinguishable. +pub fn given_stance( + original_stance: u32, + requested: &mut u32, + flags: &mut u32, + mirrored: &mut bool, +) { + if (original_stance != 1 || *requested != 0) && (original_stance != 0 || *requested != 1) { + *flags |= 0xc000_0000; + *mirrored = original_stance != 0; + } else { + *flags &= 0x3fff_ffff; + *mirrored = original_stance != 1; + } + *requested = 0; +} +///82B98050's two raw rlwinm masks; unrelated stance/request fields survive. +pub fn skater_flags(flags: &mut u32, mirrored: &mut bool, word_15320: &mut u32) { + *flags &= 0x0ff7_ffff; + *mirrored = false; + *word_15320 = 0; +} +///FullMG5840..5920 inclusive, borrowed as its21 published scalar/flag words. +///Only the recovered writes occur. In particular5912/5916 survive. +pub fn motion_output(words: &mut [u32; 21], gesture: &mut u32, gesture_flags: &mut [u8; 4]) { + for index in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 17] { + words[index] = 0; + } + words[11] &= 0x7fff_ffff; + words[13] &= 0x1fff_ffff; + words[15] &= 0x1fff_ffff; + words[16] &= 0x3fff_ffff; + words[20] = (words[20] & 0x01fb_ffff) | 0x0200_0000; + *gesture = 37; + gesture_flags[0] = 0; + gesture_flags[1] = 0; +} +#[cfg(test)] +mod tests; +///8258F360 writes four meaningful request words and clears last word bit31. +///The native last word's lower31 bits originate in uninitialized stack data; +///they are not assigned a fabricated deterministic payload by this API. +pub fn motion_request(words_2664_2676: &mut [u32; 4], active_2680: &mut bool) { + *words_2664_2676 = [0, 0x3e4c_cccd, 0, 0]; + *active_2680 = false; +} diff --git a/crates/skate-core/src/player/offboard/reset_animation/tests.rs b/crates/skate-core/src/player/offboard/reset_animation/tests.rs new file mode 100644 index 00000000..4d84b597 --- /dev/null +++ b/crates/skate-core/src/player/offboard/reset_animation/tests.rs @@ -0,0 +1,32 @@ +use super::*; +#[test] +fn reset_is_selective_not_full_default() { + let mut words = [u32::MAX; 21]; + let mut gesture = 8; + let mut bytes = [9; 4]; + motion_output(&mut words, &mut gesture, &mut bytes); + assert_eq!(words[18], u32::MAX); + assert_eq!(words[19], u32::MAX); + assert_eq!(words[11], 0x7fffffff); + assert_eq!(words[20], 0x03fbffff); + assert_eq!(gesture, 37); + assert_eq!(bytes, [0, 0, 9, 9]); +} +#[test] +fn given_stance_retains_lower_flags_and_consumes_request() { + for (original, request, mirror, upper) in [ + (0, 0, false, 3), + (0, 1, true, 0), + (1, 0, false, 0), + (1, 1, true, 3), + (2, 0, true, 3), + ] { + let mut request = request; + let mut flags = 0x1234; + let mut mirrored = false; + given_stance(original, &mut request, &mut flags, &mut mirrored); + assert_eq!(mirrored, mirror); + assert_eq!(flags, (upper << 30) | 0x1234); + assert_eq!(request, 0); + } +} diff --git a/crates/skate-core/src/player/offboard/slide.rs b/crates/skate-core/src/player/offboard/slide.rs new file mode 100644 index 00000000..eb2d941b --- /dev/null +++ b/crates/skate-core/src/player/offboard/slide.rs @@ -0,0 +1,92 @@ +//! Biped special-mode latch82D7C8E8 and sliding velocity82D7CC18. +use super::contact_correction::{clamp_length, inverse_length, magnitude}; +use crate::{physics::native_arithmetic::dot3, point_graph::PointGraph}; +type Vector = [f32; 4]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpecialMode { + pub enabled_714: bool, + pub elapsed_788: f32, +} +impl SpecialMode { + pub fn update(&mut self, move_input_308: f32, contact_flags_176: u32, forward_y: f32) { + if self.enabled_714 { + self.elapsed_788 += f32::from_bits(0x3c88_8889); + if !(move_input_308 >= 0.5) + || (!(self.elapsed_788 <= 0.5) && contact_flags_176 & 0x200 == 0) + { + self.enabled_714 = false; + } + } else if !(self.elapsed_788 <= 0.0) { + if f32::from_bits(0x3e19_999a) > forward_y { + self.elapsed_788 = 0.0; + } + } else if contact_flags_176 & 0x200 != 0 { + self.elapsed_788 = 0.0; + self.enabled_714 = true; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Sliding { + pub velocity_528: Vector, + pub active_710: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub special_mode_714: bool, + pub surface_normal_560: Vector, + pub movement_velocity_480: Vector, + pub contact_direction_400: Option, +} +impl Sliding { + pub fn update(&mut self, input: Input, slide_vs_slope: &PointGraph<8>, slide_vs_speed: &PointGraph<8>) { + //82D7CC44 returns without changing either retained field in this mode. + if input.special_mode_714 { return; } + let down = [0.0, -1.0, 0.0, 0.0]; + let normal = input.surface_normal_560; + let projection = dot3(normal, down); + let downhill: Vector = std::array::from_fn(|i| down[i] - normal[i] * projection); + let tilt = -downhill[1]; + let lower = select(-tilt, 0.0, tilt); + let slope = select(1.0 - lower, lower, 1.0); + let uphill = safe_unit(downhill).map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)); + let speed = dot3(input.movement_velocity_480, uphill); + let factor = -(slide_vs_slope.evaluate(slope) * slide_vs_speed.evaluate(speed)); + //82F82588 initializes830BD400 by splatting822F8B40 (-9.8). + let acceleration = clamp_length(downhill.map(|v| v * (f32::from_bits(0xc11c_cccd) * factor)), 5.0); + self.velocity_528 = std::array::from_fn(|i| { + acceleration[i].mul_add(f32::from_bits(0x3d4c_cccd), self.velocity_528[i] * 0.95) + }); + let threshold = f32::from_bits(0x3c23_d70b); + if threshold > dot3(acceleration, acceleration) { + self.velocity_528 = self.velocity_528.map(|v| v * 0.9); + } + if let Some(direction) = input.contact_direction_400 { + self.velocity_528 = reject_positive(self.velocity_528, direction.map(|v| -v)); + } + self.active_710 = dot3(self.velocity_528, self.velocity_528) > threshold; + } +} +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +fn safe_unit(vector: Vector) -> Vector { + let square = dot3(vector, vector); + let inverse = inverse_length(square); + if magnitude(square) > f32::from_bits(0x3586_37bd) { + vector.map(|v| v * inverse) + } else { [0.0; 4] } +} +///82C1E170: project against safe unit, subtract the original supplied direction. +fn reject_positive(vector: Vector, direction: Vector) -> Vector { + let projection = dot3(vector, safe_unit(direction)); + if projection > 0.0 { + std::array::from_fn(|i| vector[i] - direction[i] * projection) + } else { vector } +} + +#[cfg(test)] +#[path = "slide/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/player/offboard/slide/tests.rs b/crates/skate-core/src/player/offboard/slide/tests.rs new file mode 100644 index 00000000..806207d8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/slide/tests.rs @@ -0,0 +1,56 @@ +use super::*; +fn constant(value: f32) -> PointGraph<8> { + PointGraph { x: [0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0], y: [value;8] } +} +fn input() -> Input { + Input { special_mode_714:false, surface_normal_560:[0.0,1.0,0.0,0.0], + movement_velocity_480:[0.0;4], contact_direction_400:None } +} +fn close(a:f32,b:f32) { assert!((a-b).abs()<1e-6,"{a} != {b}"); } + +#[test] +fn special_mode_retains_elapsed_until_low_heading_allows_reentry() { + let mut mode=SpecialMode {enabled_714:false,elapsed_788:0.0}; + mode.update(1.0,0x200,0.0); + assert!(mode.enabled_714); + mode.update(0.0,0,0.5); + assert!(!mode.enabled_714); + assert!(mode.elapsed_788>0.0); + mode.update(1.0,0x200,0.5); + assert!(!mode.enabled_714); + mode.update(1.0,0x200,0.0); + assert_eq!(mode.elapsed_788,0.0); + assert!(!mode.enabled_714); + mode.update(1.0,0x200,0.0); + assert!(mode.enabled_714); +} + +#[test] +fn special_mode_skips_both_slide_fields() { + let mut slide=Sliding {velocity_528:[1.0,2.0,3.0,0.0],active_710:true}; + let prior=slide; + let mut i=input(); i.special_mode_714=true; + slide.update(i,&constant(1.0),&constant(1.0)); + assert_eq!(slide,prior); +} + +#[test] +fn flat_surface_decays_old_slide_without_downhill_force() { + let mut slide=Sliding {velocity_528:[1.0,0.0,0.0,0.0],active_710:true}; + slide.update(input(),&constant(1.0),&constant(1.0)); + close(slide.velocity_528[0],0.95*0.9); + assert!(slide.active_710); +} + +#[test] +fn slope_force_follows_surface_and_contact_removes_into_wall_component() { + let mut i=input(); i.surface_normal_560=[0.0,0.8,0.6,0.0]; + let mut slide=Sliding {velocity_528:[0.0;4],active_710:false}; + slide.update(i,&constant(1.0),&constant(1.0)); + assert!(slide.velocity_528[1]<0.0); + assert!(slide.velocity_528[2]>0.0); + assert!(slide.active_710); + i.contact_direction_400=Some([0.0,0.0,-1.0,0.0]); + slide.update(i,&constant(1.0),&constant(1.0)); + close(slide.velocity_528[2],0.0); +} diff --git a/crates/skate-core/src/player/offboard/surface_frame.rs b/crates/skate-core/src/player/offboard/surface_frame.rs new file mode 100644 index 00000000..07e908c5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/surface_frame.rs @@ -0,0 +1,85 @@ +//! Original Biped82D7E2A8 surface normal and82D7E840 spring/lean stages. +//! Only these stages' written fields are retained here; all vectors keep VMX w. +mod math; +use math::*; +pub type Vector = [f32; 4]; +const UP: Vector = [0.0, 1.0, 0.0, 0.0]; + +#[derive(Clone, Debug, PartialEq)] +pub struct State { + pub surface_normal: Vector, //544 + pub source_normal: Vector, //560 + pub spring_normal: Vector, //416 + pub spring_delta: Vector, //432 + pub final_up: Vector, //448 + pub lean: Vector, //464 +} +impl Default for State { + fn default() -> Self { + Self { surface_normal: UP, source_normal: UP, spring_normal: UP, spring_delta: [0.0; 4], final_up: UP, lean: [0.0; 4] } + } +} +pub struct SurfaceInput { + pub flags: u32, //input176 + pub normal: Vector, //input16 + pub origin: Vector, //input0 + pub edge_point: Vector, //input128 + pub edge_normal: Vector, //input144 +} +pub struct SpringInput { + pub velocity: Vector, //Biped480 + pub up: Vector, //Biped16 + pub right: Vector, //Biped0 + pub forward: Vector, //Biped32 + pub spring_right: Vector, //Biped128 + pub spring_forward: Vector, //Biped160 + pub right_delta: f32, //Biped696 + pub forward_delta: f32, //Biped692 + pub suppress_lean: bool, //input317 +} +impl State { + pub fn update_surface(&mut self, input: &SurfaceInput) { + if input.flags & 1 == 0 { return; } + self.source_normal = input.normal; + let mut target = input.normal; + if input.flags & 4 != 0 { + let delta = sub(input.edge_point, input.origin); + let right = normalize_or(cross(UP, delta), [0.0; 4]); + let candidate = normalize_or(cross(delta, right), input.normal); + let limit = 45.0 * f32::from_bits(0x3c8e_fa35); + let first = clamp_angle(candidate, input.normal, limit); + let second = clamp_angle(input.edge_normal, input.normal, limit); + let lateral = scale(right, dot(right, input.normal)); + target = normalize_or(sub(input.normal, lateral), UP); + if first[1] > target[1] { target = first; } + if second[1] > target[1] { target = second; } + let remaining = clamp(1.0 - dot(lateral, lateral), 0.0, 1.0); + target = madd(target, square_root(remaining), lateral); + } + let angle = wrap_angle(unsigned_angle(self.surface_normal, target)).abs(); + if angle < f32::from_bits(0x3f49_0fdb) || target[1] > f32::from_bits(0x3f35_c28f) { + self.surface_normal = normalize_or(madd(self.surface_normal, 0.5, scale(target, 0.5)), self.surface_normal); + } + } + + pub fn update_spring(&mut self, input: &SpringInput) { + let blend = clamp(length(input.velocity) * 0.125, 0.0, 1.0); + let candidate = madd(self.surface_normal, blend, scale(UP, 1.0 - blend)); + let limited = clamp_angle(candidate, self.surface_normal, 36.0 * f32::from_bits(0x3c8e_fa35)); + let target = normalize_or(limited, input.up); + let desired_delta = scale(sub(target, self.spring_normal), f32::from_bits(0x3e4c_cccd)); + let desired_delta = clamp_axis(desired_delta, input.spring_right, f32::from_bits(0xbccc_cccd), f32::from_bits(0x3ccc_cccd)); + let acceleration = sub(desired_delta, self.spring_delta); + let acceleration = clamp_axis(acceleration, input.spring_right, f32::from_bits(0xbbc4_9ba6), f32::from_bits(0x3bc4_9ba6)); + let acceleration = clamp_axis(acceleration, input.spring_forward, f32::from_bits(0xbd4c_cccd), f32::from_bits(0x3d4c_cccd)); + self.spring_delta = add(self.spring_delta, acceleration); + self.spring_normal = normalize_or(add(self.spring_normal, self.spring_delta), self.spring_normal); + let lean_target = if input.suppress_lean { [0.0; 4] } else { + madd(input.forward, input.forward_delta * f32::from_bits(0x3d23_d70a), scale(input.right, input.right_delta * f32::from_bits(0x3f33_3333))) + }; + self.lean = madd(lean_target, f32::from_bits(0x3d4c_cccd), scale(self.lean, f32::from_bits(0x3f73_3333))); + self.final_up = normalize_or(add(self.lean, self.spring_normal), self.spring_normal); + } +} +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/surface_frame/math.rs b/crates/skate-core/src/player/offboard/surface_frame/math.rs new file mode 100644 index 00000000..c0c15c7c --- /dev/null +++ b/crates/skate-core/src/player/offboard/surface_frame/math.rs @@ -0,0 +1,54 @@ +//! Original82BD3E78/8296EBB0 and inlined VMX arithmetic. +use super::Vector; +pub(super) fn select(test: f32, yes: f32, no: f32) -> f32 { if test >= 0.0 { yes } else { no } } +pub(super) fn clamp(v: f32, low: f32, high: f32) -> f32 { let v = select(low-v, low, v); select(high-v, v, high) } +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] } +pub(super) fn scale(v: Vector, s: f32) -> Vector { v.map(|x| x*s) } +pub(super) fn add(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i]+b[i]) } +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i]-b[i]) } +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s,b[i])) } +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + //vpermwi0x63 preserves lane w; its fused subtraction is retained too. + [(-a[2]).mul_add(b[1],a[1]*b[2]),(-a[0]).mul_add(b[2],a[2]*b[0]),(-a[1]).mul_add(b[0],a[0]*b[1]),(-a[3]).mul_add(b[3],a[3]*b[3])] +} +fn inverse(value: f32, steps: usize) -> f32 { + //Ordinary host seed, not a claim of bit-exact Xenon estimate emulation. + let mut r = crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..steps { let e=(-value).mul_add(r*r,1.0); r=(r*0.5).mul_add(e,r); } + r +} +pub(super) fn square_root(value: f32) -> f32 { let result=value*inverse(value,2); if value==0.0 {0.0} else {result} } +pub(super) fn length(v: Vector) -> f32 { square_root(dot(v,v)) } +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let sq=dot(v,v); let r=inverse(sq,2); let len=if sq==0.0 {0.0} else {sq*r}; + if len>f32::from_bits(0x3586_37bd) {scale(v,r)} else {fallback} +} +pub(super) fn unsigned_angle(a: Vector, b: Vector) -> f32 { + let aa=dot(a,a); let bb=dot(b,b); let gate=f32::from_bits(0x38d1_b717); + if !(aa>gate && bb>gate) {return 0.0;} + crate::trigonometry::acos(dot(scale(a,inverse(aa,1)),scale(b,inverse(bb,1))).max(-1.0).min(1.0)) +} +pub(super) fn wrap_angle(a: f32) -> f32 { + let t=a*f32::from_bits(0x3e22_f983); let f=t-t.floor(); + (f-if f>0.5 {1.0}else{0.0})*f32::from_bits(0x40c9_0fdb) +} +pub(super) fn clamp_axis(value: Vector, axis: Vector, low: f32, high: f32) -> Vector { + let projection=dot(value,axis); + madd(axis,clamp(projection,low,high),sub(value,scale(axis,projection))) +} +///82BD3E78 returns original target when close or cross-axis degenerate. +pub(super) fn clamp_angle(target: Vector, reference: Vector, limit: f32) -> Vector { + let a=normalize_or(target,[0.0;4]); let b=normalize_or(reference,[0.0;4]); + let axis=cross(a,b); + let angle=crate::trigonometry::acos(dot(a,b).max(-1.0).min(1.0)); + let turns=angle.mul_add(f32::from_bits(0x3e22_f983),0.5).floor(); + let wrapped=(-turns).mul_add(f32::from_bits(0x40c9_0fdb),angle); + if wrapped.abs()dot(axis,axis) {return target;} + let unit=scale(axis,inverse(dot(axis,axis),2)); + let (sin,cos)=crate::trigonometry::sin_cos((-limit)*0.5); + let mut q=scale(unit,sin); q[3]=cos; + let first=cross(q,reference); + let middle=madd(reference,cos,first); + let rotated=madd(cross(q,middle),2.0,reference); + scale(rotated,length(target)) +} diff --git a/crates/skate-core/src/player/offboard/surface_frame/tests.rs b/crates/skate-core/src/player/offboard/surface_frame/tests.rs new file mode 100644 index 00000000..aa9f19cc --- /dev/null +++ b/crates/skate-core/src/player/offboard/surface_frame/tests.rs @@ -0,0 +1,42 @@ +use super::*; +fn surface() -> SurfaceInput { SurfaceInput {flags:1,normal:UP,origin:[0.0;4],edge_point:[0.0,0.0,1.0,0.0],edge_normal:UP} } +fn spring() -> SpringInput { + SpringInput {velocity:[0.0;4],up:UP,right:[1.0,0.0,0.0,0.0],forward:[0.0,0.0,1.0,0.0],spring_right:[1.0,0.0,0.0,0.0],spring_forward:[0.0,0.0,1.0,0.0],right_delta:0.0,forward_delta:0.0,suppress_lean:false} +} +#[test] +fn missing_contact_preserves_both_normals() { + let mut state=State::default(); let before=state.clone(); + let mut input=surface(); input.flags=0; input.normal=[1.0,0.0,0.0,7.0]; + state.update_surface(&input); assert_eq!(state,before); +} +#[test] +fn large_normal_jump_updates_source_but_rejects_surface() { + let mut state=State::default(); let mut input=surface(); input.normal=[1.0,0.0,0.0,2.0]; + state.update_surface(&input); assert_eq!(state.source_normal,input.normal); assert_eq!(state.surface_normal,UP); +} +#[test] +fn accepted_normal_preserves_w_through_normalization() { + let mut state=State::default(); let mut input=surface(); input.normal=[0.0,1.0,0.0,2.0]; + state.update_surface(&input); assert_eq!(state.surface_normal,[0.0,1.0,0.0,1.0]); +} +#[test] +fn edge_degeneracy_and_angle_antiparallel_return_original() { + let mut state=State::default(); let mut input=surface(); input.flags=5; input.edge_point=input.origin; + state.update_surface(&input); assert_eq!(state.surface_normal,UP); + let target=[0.0,-1.0,0.0,3.0]; assert_eq!(clamp_angle(target,UP,0.5),target); +} +#[test] +fn lean_is_separate_from_spring_and_suppression_decays_it() { + let mut state=State::default(); let mut input=spring(); input.right_delta=1.0; + state.update_spring(&input); + assert_eq!(state.spring_normal,UP); assert_eq!(state.spring_delta,[0.0;4]); + assert!((state.lean[0]-0.035).abs()<0.000001); assert!(state.final_up[0]>0.0); + let prior=state.lean; input.suppress_lean=true; state.update_spring(&input); + assert_eq!(state.lean,scale(prior,f32::from_bits(0x3f73_3333))); +} +#[test] +fn angle_limit_rotates_reference_toward_target_and_retains_target_magnitude() { + let limited=clamp_angle([2.0,0.0,0.0,0.0],UP,f32::from_bits(0x3f49_0fdb)); + assert!((length(limited)-2.0).abs()<0.00001); + assert!((limited[0]-limited[1]).abs()<0.00001); assert!(limited[0]>0.0); +} diff --git a/crates/skate-core/src/player/offboard/twist_lean.rs b/crates/skate-core/src/player/offboard/twist_lean.rs new file mode 100644 index 00000000..2d1facfe --- /dev/null +++ b/crates/skate-core/src/player/offboard/twist_lean.rs @@ -0,0 +1,50 @@ +//! MatchTwistAndLean: TU3 Begin82BC0848, Update82BC0860, read82BC0968. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Observation { + /// Actual PhysOutSkeleton+496 and+500, produced by active-state Fill. + pub hips_right_angle: f32, + pub hips_up_angle: f32, + /// Live ISkaterAnim virtual28, not a physical stance snapshot. + pub mirrored: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct State { + pub twist: f32, + pub lean: f32, +} + +impl State { + /// Instance constructor82BA8DC0 initializes both retained values to zero. + /// An absent original component makes read82BC0968 leave them unchanged. + pub fn begin(&mut self, observation: Option) { + if let Some(observation) = observation { + *self = read(observation); + } + } + + /// The default publishes captured values; exactly update="always" rereads. + /// The live branch initializes its local values to zero before the read. + pub fn update(&self, always: bool, observation: Option) -> Self { + if always { + observation.map(read).unwrap_or_default() + } else { + *self + } + } +} + +fn read(observation: Observation) -> State { + State { + twist: if observation.mirrored { + -observation.hips_up_angle + } else { + observation.hips_up_angle + }, + lean: observation.hips_right_angle, + } +} + +#[cfg(test)] +#[path = "twist_lean/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/player/offboard/twist_lean/tests.rs b/crates/skate-core/src/player/offboard/twist_lean/tests.rs new file mode 100644 index 00000000..4eceec09 --- /dev/null +++ b/crates/skate-core/src/player/offboard/twist_lean/tests.rs @@ -0,0 +1,31 @@ +use super::*; +fn observation(right: f32, up: f32, mirrored: bool) -> Option { + Some(Observation { hips_right_angle: right, hips_up_angle: up, mirrored }) +} + +#[test] +fn capture_preserves_twist_and_lean_after_physics_and_stance_change() { + let mut state = State::default(); + state.begin(observation(0.25, 0.75, true)); + assert_eq!(state.update(false, observation(1.5, 2.0, false)), State { twist: -0.75, lean: 0.25 }); + state.begin(observation(1.5, 2.0, false)); + assert_eq!(state.update(false, None), State { twist: 2.0, lean: 1.5 }); +} + +#[test] +fn always_reads_live_angles_without_changing_capture() { + let mut state = State::default(); + state.begin(observation(0.25, 0.75, false)); + assert_eq!(state.update(true, observation(1.5, 2.0, true)), State { twist: -2.0, lean: 1.5 }); + assert_eq!(state.update(false, None), State { twist: 0.75, lean: 0.25 }); +} + +#[test] +fn missing_original_components_preserve_capture_but_live_locals_start_zero() { + let mut state = State::default(); + state.begin(observation(0.25, 0.75, false)); + state.begin(None); + assert_eq!(state.update(false, None), State { twist: 0.75, lean: 0.25 }); + assert_eq!(state.update(true, None), State::default()); +} + diff --git a/crates/skate-core/src/player/post_input.rs b/crates/skate-core/src/player/post_input.rs new file mode 100644 index 00000000..23219d16 --- /dev/null +++ b/crates/skate-core/src/player/post_input.rs @@ -0,0 +1,219 @@ +//! Complete player-owned portion of TU3 `PhysicalPlayerHiLOD::PostInput` +//! (`0x82DB5588`) and its flag-latch helper (`0x82DB5CF8`). +//! +//! Grind, trajectory, the scalar calculation at `0x82DB5E10`, and candidate +//! registration remain required services because they own separate systems. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PostInputPlayerFields { + /// Player+1264, captured from PhysOut when its +442 byte is set. + pub jump_reference_1264: [u32; 4], + /// Player+1296. + pub flags_1296: u32, + /// Player+1304. + pub state_frames_1304: u32, + /// Player+1308. + pub jump_fix_frames_1308: u32, + /// Player+1320, owned by `0x82DB5CF8`. + pub latch_frames_1320: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PostInputProcessedFields { + /// ProcessedPhysIn+848. + pub jump_reference_848: [u32; 4], + pub word_2464: u32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub current_state_2508: u32, + pub state_frames_2572: u32, + pub jump_fix_frames_2576: u32, + pub scalar_2740: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PostInputPhysOutFields { + /// PhysOut+32 -> +316. + pub reset_state_frames_316: bool, + /// PhysOut+8 -> +442. + pub capture_jump_reference_442: bool, + /// PhysOut+8 -> +128. + pub jump_reference_128: [u32; 4], + /// PhysOut+76, set after every successful PostInput pass. + pub complete_76: bool, +} + +/// Player+1840 data consumed by `0x82D740F8`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CandidatePublicationFields { + pub first_object_present_196: bool, + pub first_pending_288: bool, + pub second_object_present_500: bool, + pub second_pending_592: bool, + pub staged_word_12768: u32, + pub staged_valid_12772: u32, + pub staged_latched_12776: u32, + pub staged_pending_12780: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CandidateRegistration { + /// `sub_82762AB0(ProcessedPhysIn+1888, component)`. + First1888, + /// `sub_82762AB0(ProcessedPhysIn+2176, component+304)`. + Second2176, +} + +/// Calls to independent systems, in their exact `0x82DB5588` order. +pub trait PostInputServices { + fn update_grind_manager_82d8ab08(&mut self); + fn update_trajectory_selector_82d68800(&mut self) -> u8; + fn calculate_scalar_2740_82db5e10(&mut self) -> f32; + fn register_candidate_82762ab0(&mut self, registration: CandidateRegistration); +} + +pub struct PostInputContext<'a> { + pub player: &'a mut PostInputPlayerFields, + pub processed: &'a mut PostInputProcessedFields, + pub phys_out: &'a mut PostInputPhysOutFields, + pub candidates: &'a mut CandidatePublicationFields, +} + +/// Runs the full TU3 PostInput phase for the fields owned by PhysicalPlayer. +pub fn run_post_input(context: PostInputContext<'_>, services: &mut impl PostInputServices) { + let PostInputContext { + player, + processed, + phys_out, + candidates, + } = context; + + services.update_grind_manager_82d8ab08(); + + if phys_out.reset_state_frames_316 { + player.state_frames_1304 = 0; + } + player.state_frames_1304 = player.state_frames_1304.wrapping_add(1); + + let reset_state_frames = processed.flags_2480 & 0x0000_2000 != 0 + || (processed.flags_2468 & 0x0040_0000 != 0 && player.flags_1296 & 0x4000_0000 != 0); + if reset_state_frames { + player.state_frames_1304 = 0; + player.flags_1296 &= !0x4000_0000; + } else { + replace_bit( + &mut player.flags_1296, + 30, + u32::from(processed.flags_2468 & 0x0040_0000 == 0), + ); + processed.flags_2468 &= !0x0040_0000; + } + processed.state_frames_2572 = player.state_frames_1304; + + if phys_out.capture_jump_reference_442 { + player.jump_fix_frames_1308 = 1; + player.jump_reference_1264 = phys_out.jump_reference_128; + } else { + player.jump_fix_frames_1308 = player.jump_fix_frames_1308.wrapping_add(1); + } + processed.jump_fix_frames_2576 = player.jump_fix_frames_1308; + processed.jump_reference_848 = player.jump_reference_1264; + + replace_bit( + &mut processed.flags_2468, + 10, + u32::from(services.update_trajectory_selector_82d68800() & 1), + ); + processed.scalar_2740 = services.calculate_scalar_2740_82db5e10(); + replace_bit(&mut processed.flags_2484, 25, (player.flags_1296 >> 24) & 1); + + if processed.flags_2484 & 0x0000_0800 != 0 { + player.flags_1296 |= 0x0020_0000; + } else if processed.current_state_2508 == 103 { + replace_bit(&mut processed.flags_2484, 11, (player.flags_1296 >> 21) & 1); + } else { + player.flags_1296 &= !0x0020_0000; + } + + update_flag_latches_82db5cf8(player, processed); + publish_candidates_82d740f8(candidates, processed, services); + phys_out.complete_76 = true; +} + +/// Exact scalar bit transfers and latch lifetime from TU3 `0x82DB5CF8`. +fn update_flag_latches_82db5cf8( + player: &mut PostInputPlayerFields, + processed: &mut PostInputProcessedFields, +) { + if processed.flags_2468 & 0x0020_0000 != 0 { + player.latch_frames_1320 = 0; + player.flags_1296 |= 0x0200_0000; + } + if processed.flags_2472 & 0x0000_0020 != 0 || processed.flags_2468 & 0x0040_0000 != 0 { + player.latch_frames_1320 = 0; + player.flags_1296 |= 0x0600_0000; + } + + let flags_before_expiry = player.flags_1296; + if flags_before_expiry & 0x0200_0000 != 0 { + if processed.flags_2472 & 0x0000_0010 != 0 + || (flags_before_expiry & 0x0800_0000 != 0 && processed.flags_2472 & 0x0000_8000 == 0) + { + player.flags_1296 &= 0xF9FF_FFFF; + } + let frames = player.latch_frames_1320; + if frames > 20 && player.flags_1296 & 0x0800_0000 == 0 { + player.flags_1296 &= 0xF9FF_FFFF; + } + player.latch_frames_1320 = frames.wrapping_add(1); + } + + replace_bit(&mut player.flags_1296, 28, (processed.flags_2472 >> 4) & 1); + replace_bit(&mut player.flags_1296, 27, (processed.flags_2472 >> 15) & 1); + replace_bit(&mut player.flags_1296, 17, (processed.flags_2480 >> 17) & 1); + replace_bit(&mut processed.flags_2472, 3, (player.flags_1296 >> 26) & 1); + replace_bit(&mut processed.flags_2472, 2, (player.flags_1296 >> 25) & 1); +} + +/// Complete TU3 `0x82D740F8` publication/reset behavior. +fn publish_candidates_82d740f8( + fields: &mut CandidatePublicationFields, + processed: &mut PostInputProcessedFields, + services: &mut impl PostInputServices, +) { + let first = fields.first_pending_288 && fields.first_object_present_196; + if first { + services.register_candidate_82762ab0(CandidateRegistration::First1888); + } + replace_bit(&mut processed.flags_2480, 22, u32::from(first)); + fields.first_pending_288 = false; + + let second = fields.second_pending_592 && fields.second_object_present_500; + if second { + services.register_candidate_82762ab0(CandidateRegistration::Second2176); + } + replace_bit(&mut processed.flags_2480, 21, u32::from(second)); + fields.second_pending_592 = false; + + if fields.staged_pending_12780 { + fields.staged_latched_12776 = 0; + if fields.staged_valid_12772 != 0 { + fields.staged_latched_12776 = 1; + processed.flags_2480 |= 0x0010_0000; + processed.word_2464 = fields.staged_word_12768; + } else { + processed.flags_2480 &= !0x0010_0000; + } + } + fields.staged_pending_12780 = false; +} + +fn replace_bit(word: &mut u32, bit: u32, value: u32) { + *word = (*word & !(1 << bit)) | ((value & 1) << bit); +} + +#[cfg(test)] +#[path = "tests/post_input.rs"] +mod tests; diff --git a/crates/skate-core/src/player/post_output.rs b/crates/skate-core/src/player/post_output.rs new file mode 100644 index 00000000..5a2c2f27 --- /dev/null +++ b/crates/skate-core/src/player/post_output.rs @@ -0,0 +1,33 @@ +//! TU3 `PhysicalPlayerHiLOD::PostOutput` (`0x82DB6CB0`). +//! +//! This is the final player-owned phase before the world finishes output and +//! conditioners. The native body performs exactly two calls in this order. + +/// Required services called by the complete `0x82DB6CB0` wrapper. +pub trait PostOutputServices { + /// `0x82909510(Player+1392, 0, -1)`. + fn finish_player_output_82909510(&mut self, player_field_1392: &mut PlayerOutputField1392); + + /// `0x82BE2BA8(Player->Skeleton)`. + fn finish_skeleton_output_82be2ba8(&mut self); +} + +/// Opaque Player+1392-owned object passed to `0x82909510`. +/// +/// Its internal layout is deliberately absent: PostOutput only passes its +/// address and the fixed native arguments `0` and `-1`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PlayerOutputField1392; + +/// Runs the complete TU3 PostOutput phase. +pub fn run_post_output( + player_field_1392: &mut PlayerOutputField1392, + services: &mut impl PostOutputServices, +) { + services.finish_player_output_82909510(player_field_1392); + services.finish_skeleton_output_82be2ba8(); +} + +#[cfg(test)] +#[path = "tests/post_output.rs"] +mod tests; diff --git a/crates/skate-core/src/player/post_state.rs b/crates/skate-core/src/player/post_state.rs new file mode 100644 index 00000000..420dfa78 --- /dev/null +++ b/crates/skate-core/src/player/post_state.rs @@ -0,0 +1,18 @@ +//! TU3 `PhysicalPlayerHiLOD::PostState` thunk (`0x82DB62F8`). +//! +//! Raw instructions load Player+1796 and tail-call the current physical +//! state's vtable slot `+12`. The function has no other side effects. + +/// Mandatory current-state dispatch performed by `0x82DB62F8`. +pub trait PostStateServices { + fn update_current_state_vtable_12(&mut self); +} + +/// Runs the complete PhysicalPlayer PostState phase. +pub fn run_post_state(services: &mut impl PostStateServices) { + services.update_current_state_vtable_12(); +} + +#[cfg(test)] +#[path = "tests/post_state.rs"] +mod tests; diff --git a/crates/skate-core/src/player/pre_state.rs b/crates/skate-core/src/player/pre_state.rs new file mode 100644 index 00000000..cfc5ad27 --- /dev/null +++ b/crates/skate-core/src/player/pre_state.rs @@ -0,0 +1,75 @@ +//! Complete TU3 `PhysicalPlayerHiLOD::PreState` (`0x82DB6050`). + +/// The 72-byte stack packet filled through current-state vtable slot `+24`. +/// Only the vector at byte `+48` is consumed by the player wrapper, but the +/// entire packet remains part of the state callback contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreStatePacket { + pub words: [u32; 18], +} + +impl PreStatePacket { + pub const ZERO: Self = Self { words: [0; 18] }; + + pub const fn vector_48(self) -> [u32; 4] { + [ + self.words[12], + self.words[13], + self.words[14], + self.words[15], + ] + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreStatePlayerFields { + /// PhysicalPlayer+1312. + pub frame_counter_1312: u32, + /// Whether PhysicalPlayer+1840 owns the optional pre-state component. + pub component_1840_present: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreStateSkeletonFields { + /// Skeleton+16112. + pub predicted_position_16112: [u32; 4], + /// Skeleton+16416. + pub predicted_position_set_16416: bool, + /// Skeleton+16432 -> nested object +3184. + pub nested_flag_3184: bool, +} + +/// Every call made by the wrapper, in native order. There is deliberately no +/// default implementation: state and component behavior must be supplied by +/// their recovered modules. +pub trait PreStateServices { + /// Current-state vtable slot `+24`. + fn fill_packet_vtable_24(&mut self, packet: &mut PreStatePacket); + /// `0x82D74270`, reached only when Player+1840 is present. + fn update_component_1840_82d74270(&mut self); + /// Current-state vtable slot `+4`. + fn update_before_state_vtable_4(&mut self); +} + +pub fn run_pre_state( + player: &mut PreStatePlayerFields, + skeleton: &mut PreStateSkeletonFields, + services: &mut impl PreStateServices, +) { + player.frame_counter_1312 = player.frame_counter_1312.wrapping_add(1); + + let mut packet = PreStatePacket::ZERO; + services.fill_packet_vtable_24(&mut packet); + skeleton.predicted_position_16112 = packet.vector_48(); + skeleton.predicted_position_set_16416 = true; + skeleton.nested_flag_3184 = false; + + if player.component_1840_present { + services.update_component_1840_82d74270(); + } + services.update_before_state_vtable_4(); +} + +#[cfg(test)] +#[path = "tests/pre_state.rs"] +mod tests; diff --git a/crates/skate-core/src/player/selector/air.rs b/crates/skate-core/src/player/selector/air.rs new file mode 100644 index 00000000..5ef95114 --- /dev/null +++ b/crates/skate-core/src/player/selector/air.rs @@ -0,0 +1,150 @@ +use super::{FrameFacts, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId; + +const STATE_SETTLE_SECONDS: f32 = f32::from_bits(0x3DA3_D70A); +const KNOWN_AIR_COLLISION_GRACE_SECONDS: f32 = f32::from_bits(0x3D23_D70A); +const TRAJECTORY_WIPEOUT_SECONDS: f32 = f32::from_bits(0x3D4C_CCCD); +const TRAJECTORY_EXPIRED_SECONDS: f32 = f32::from_bits(0xBE4C_CCCD); + +impl StateSelector { + pub(super) fn select_physics_air( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2476(0x80) && !p.has_2484(1) { + return PhysicalStateId::BipedAir; + } + if facts.force_known_air { + if facts.colliding || p.has_2468(0x8000) { + return PhysicalStateId::WipeoutGround; + } + return if p.has_2468(0x400) { + PhysicalStateId::KnownAir + } else { + current + }; + } + if p.state_timer_2664 > STATE_SETTLE_SECONDS { + if facts.colliding { + if p.has_2472(4) { + return PhysicalStateId::PhysicsAir; + } + return if facts.skateboard_animated { + PhysicalStateId::GroundAnimation + } else { + PhysicalStateId::PhysicsGround + }; + } + if self.post_grind_jump_counter > 10 { + if let Some(grind) = facts.grind { + return grind; + } + } + } + if p.has_2468(0x400) { + PhysicalStateId::KnownAir + } else { + current + } + } + + pub(super) fn select_known_air( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if p.has_2480(0x200_0000) { + return PhysicalStateId::FootPlant; + } + if p.trajectory_collision_time_2772 < TRAJECTORY_WIPEOUT_SECONDS + && (p.has_2476(0x80) || p.has_2476(0x8000)) + { + return PhysicalStateId::WipeoutGround; + } + if p.has_2476(0x80) && !p.has_2484(1) { + return PhysicalStateId::BipedAir; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if facts.force_known_air { + return if facts.colliding || p.has_2468(0x8000) { + PhysicalStateId::WipeoutGround + } else { + current + }; + } + if p.state_timer_2664 > STATE_SETTLE_SECONDS && self.post_grind_jump_counter > 10 { + if let Some(grind) = facts.grind { + return grind; + } + } + if !facts.colliding || p.state_timer_2664 < KNOWN_AIR_COLLISION_GRACE_SECONDS { + if p.trajectory_collision_time_2772 < TRAJECTORY_EXPIRED_SECONDS { + return if p.has_2476(0x400_0000) { + PhysicalStateId::KnownAir + } else { + PhysicalStateId::PhysicsAir + }; + } + if p.has_2468(0x8000) { + return if p.has_2476(0x400_0000) { + PhysicalStateId::WipeoutGround + } else { + PhysicalStateId::PhysicsAir + }; + } + return current; + } + if p.has_2472(4) { + return PhysicalStateId::PhysicsAir; + } + if p.has_2480(0x40000) || p.has_2480(0x20000) || p.has_2480(0x10000) { + return PhysicalStateId::WipeoutGround; + } + if !p.has_2472(0x8000) && !p.has_2472(0x4000) { + PhysicalStateId::PhysicsGround + } else { + PhysicalStateId::GroundAnimation + } + } + + pub(super) fn select_grind( + &self, + _current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2484(0x10_0000) { + return PhysicalStateId::PhysicsAirSecondary; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2468(0x100) { + return p.air_variant_from_2468(); + } + facts.grind.unwrap_or(PhysicalStateId::Nonspecific) + } +} diff --git a/crates/skate-core/src/player/selector/conditions.rs b/crates/skate-core/src/player/selector/conditions.rs new file mode 100644 index 00000000..9ed98777 --- /dev/null +++ b/crates/skate-core/src/player/selector/conditions.rs @@ -0,0 +1,57 @@ +//! Small predicates called by TU3 `CalcSuggestedState` (`0x82D8ADE8`). + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardBodyState { + /// `SkateboardBody + 856`. + pub field_856: f32, + /// `SkateboardBody + 7692`. + pub field_7692: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TwoStageThresholds { + pub field_856_primary: f32, + pub field_856_secondary: f32, + pub field_7692: f32, +} + +/// TU3 `0x82D8BBB8`. All comparisons are strict, matching `fcmpu`/`bgt`. +pub fn condition_is_off_ground_skitching( + body: BoardBodyState, + thresholds: TwoStageThresholds, +) -> bool { + body.field_856 > thresholds.field_856_primary + || (body.field_856 > thresholds.field_856_secondary + && body.field_7692 > thresholds.field_7692) +} + +/// TU3 `0x82D8BD50`. The separate settings source is intentional: the native +/// function reads these three values from the live physics-mode collection. +pub fn condition_is_off_ground(body: BoardBodyState, thresholds: TwoStageThresholds) -> bool { + body.field_856 > thresholds.field_856_primary + || (body.field_856 > thresholds.field_856_secondary + && body.field_7692 > thresholds.field_7692) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkeletonAnimationState { + /// `Skeleton + 16420`. + pub mode_16420: u32, + /// Lane one of the vector at `Skeleton + 12624 + 32`. + pub back_chain_lane_12656: f32, + /// `*(Skeleton + 24) + 800`. + pub threshold_800: f32, +} + +/// TU3 `0x82BDC6C8`. The native absolute value clears the scalar sign bit. +pub fn is_skateboard_animated(input: SkeletonAnimationState) -> bool { + if input.mode_16420 == 2 { + return true; + } + let magnitude = f32::from_bits(input.back_chain_lane_12656.to_bits() & 0x7fff_ffff); + if magnitude <= input.threshold_800 { + false + } else { + input.mode_16420 != 1 + } +} diff --git a/crates/skate-core/src/player/selector/ground.rs b/crates/skate-core/src/player/selector/ground.rs new file mode 100644 index 00000000..c38130fe --- /dev/null +++ b/crates/skate-core/src/player/selector/ground.rs @@ -0,0 +1,142 @@ +use super::{FrameFacts, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId; + +impl StateSelector { + pub(super) fn select_ground_family( + &mut self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + match current { + PhysicalStateId::PhysicsGround => { + if p.has_2480(0x1000) && (!p.has_2480(0x400) || !p.has_2480(0x200)) { + // TU3 calls `sk83_na_f_01a4` at 0x82D8B23C here. The + // target is 0x82B61BB8 and consists solely of `blr`. + return PhysicalStateId::LandingOnDeck; + } + let handplant_contact = + p.has_2480(0x4000_0000) && input.skateboard_contact_count_869 <= 2; + if p.has_2476(1) && (handplant_contact || input.skateboard_contact_count_869 == 0) { + return PhysicalStateId::HandPlant; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.field_2572 == 1 { + return p.air_variant_from_2468(); + } + if p.field_2744 != 0.0 { + return PhysicalStateId::RevertGround; + } + if let Some(grind) = facts.grind { + return grind; + } + if !facts.colliding && facts.off_ground && self.teleport_countdown == 0 { + return PhysicalStateId::PhysicsAir; + } + if facts.skateboard_animated && self.teleport_countdown == 0 { + return PhysicalStateId::GroundAnimation; + } + if p.has_2476(0x20_0000) && p.has_2480(0x40_0000) && self.skitch_exit_countdown == 0 + { + return PhysicalStateId::Skitching; + } + if p.field_2732 != 0.0 { + PhysicalStateId::SlideGround + } else { + current + } + } + PhysicalStateId::SlideGround => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.field_2744 != 0.0 { + return PhysicalStateId::RevertGround; + } + if let Some(grind) = facts.grind { + return grind; + } + if !facts.colliding && facts.off_ground { + return PhysicalStateId::PhysicsAir; + } + if p.field_2732 == 0.0 { + PhysicalStateId::PhysicsGround + } else { + current + } + } + PhysicalStateId::RevertGround => { + if !facts.colliding && facts.off_ground { + return PhysicalStateId::PhysicsAir; + } + if p.has_2468(0x1_0000) || p.has_2472(0x2_0000) { + return PhysicalStateId::PhysicsGround; + } + if !p.has_2472(0x20_0000) { + self.revert_exited_normally = true; + return PhysicalStateId::PhysicsGround; + } + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else { + current + } + } + PhysicalStateId::GroundAnimation => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2480(0x4000) { + return PhysicalStateId::Boneless; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if p.field_2572 == 1 { + return p.air_variant_from_2468(); + } + if !facts.colliding && facts.off_ground { + return PhysicalStateId::PhysicsAir; + } + if facts.skateboard_animated { + current + } else { + PhysicalStateId::PhysicsGround + } + } + PhysicalStateId::Skitching => { + if !p.has_2476(0x20_0000) { + return PhysicalStateId::PhysicsGround; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if facts.off_ground_skitching && self.teleport_countdown == 0 { + PhysicalStateId::PhysicsGround + } else { + current + } + } + PhysicalStateId::FollowPath => { + if p.field_1776 as u32 & 0x200_0000 != 0 { + PhysicalStateId::PhysicsGround + } else { + current + } + } + _ => unreachable!("ground-family dispatch accepts only states 100..105"), + } + } +} diff --git a/crates/skate-core/src/player/selector/input.rs b/crates/skate-core/src/player/selector/input.rs new file mode 100644 index 00000000..41514d7e --- /dev/null +++ b/crates/skate-core/src/player/selector/input.rs @@ -0,0 +1,73 @@ +use super::conditions::{BoardBodyState, SkeletonAnimationState, TwoStageThresholds}; +use crate::player::state::PhysicalStateId; + +/// Values read by TU3 `PhysicalPlayerStateChanger::CalcSuggestedState`. +/// Offset-bearing names are retained where the producer's semantic name has +/// not yet been proved in Skate 3. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProcessedStateInput { + pub grind_type_1248: u32, + pub grind_candidate_1488: bool, + pub field_1776: i32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub flags_2488: u32, + pub category_2512: u32, + pub wheel_contact_count_2556: i32, + pub field_2572: u32, + pub state_timer_2664: f32, + pub field_2732: f32, + pub field_2744: f32, + pub trajectory_collision_time_2772: f32, + pub grind_investigation_flags_1516: u32, +} + +impl ProcessedStateInput { + pub(crate) const fn has_2468(self, mask: u32) -> bool { + self.flags_2468 & mask != 0 + } + + pub(crate) const fn has_2472(self, mask: u32) -> bool { + self.flags_2472 & mask != 0 + } + + pub(crate) const fn has_2476(self, mask: u32) -> bool { + self.flags_2476 & mask != 0 + } + + pub(crate) const fn has_2480(self, mask: u32) -> bool { + self.flags_2480 & mask != 0 + } + + pub(crate) const fn has_2484(self, mask: u32) -> bool { + self.flags_2484 & mask != 0 + } + + pub(crate) const fn has_2488(self, mask: u32) -> bool { + self.flags_2488 & mask != 0 + } + + pub(crate) const fn air_variant_from_2468(self) -> PhysicalStateId { + if self.has_2468(0x400) { + PhysicalStateId::KnownAir + } else { + PhysicalStateId::PhysicsAir + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StateSelectionInput { + pub processed: ProcessedStateInput, + /// Byte at `SkateboardBody + 869`. + pub skateboard_contact_count_869: u8, + pub board_body: BoardBodyState, + pub skeleton: SkeletonAnimationState, + /// Three collection values consumed by TU3 `0x82D8BBB8`. + pub skitching_off_ground: TwoStageThresholds, + /// Three physics-mode values consumed by TU3 `0x82D8BD50`. + pub normal_off_ground: TwoStageThresholds, +} diff --git a/crates/skate-core/src/player/selector/mod.rs b/crates/skate-core/src/player/selector/mod.rs new file mode 100644 index 00000000..41e2e820 --- /dev/null +++ b/crates/skate-core/src/player/selector/mod.rs @@ -0,0 +1,250 @@ +//! Readable port of TU3 `PhysicalPlayerStateChanger::CalcSuggestedState` +//! (`0x82D8ADE8`) and its directly-called predicates. + +mod air; +pub mod conditions; +mod ground; +pub mod input; +mod offboard; + +use conditions::{ + condition_is_off_ground, condition_is_off_ground_skitching, is_skateboard_animated, +}; +pub use input::{ProcessedStateInput, StateSelectionInput}; + +use crate::player::state::PhysicalStateId; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StateSelector { + pub current_state: Option, + pub nonspecific_collision_free_frames: i32, + pub nonspecific_collision_frames: i32, + pub something_colliding_frames: i32, + pub two_wheel_counter: i32, + pub three_wheel_counter: i32, + pub post_grind_jump_counter: i32, + pub air_frames: i32, + pub teleport_countdown: i32, + pub skitch_exit_countdown: i32, + pub revert_exited_normally: bool, + pub request_teleport: bool, +} + +#[derive(Clone, Copy)] +pub(super) struct FrameFacts { + colliding: bool, + wipeout: bool, + force_known_air: bool, + skateboard_animated: bool, + off_ground: bool, + off_ground_skitching: bool, + grind: Option, +} + +impl StateSelector { + /// Executes the complete state decision tree and all state-changer-local + /// counter/flag writes performed by TU3 `0x82D8ADE8`. + pub fn calculate( + &mut self, + current: PhysicalStateId, + input: &StateSelectionInput, + ) -> PhysicalStateId { + let p = input.processed; + if self.teleport_countdown > 0 { + self.teleport_countdown = self.teleport_countdown.wrapping_sub(1); + } + self.current_state = Some(current); + self.revert_exited_normally = false; + self.request_teleport = false; + + let colliding = + p.wheel_contact_count_2556 > 0 || p.has_2468(0x1_0000) || p.has_2468(0x2_0000); + self.nonspecific_collision_free_frames = + if current == PhysicalStateId::Nonspecific && !colliding { + self.nonspecific_collision_free_frames.wrapping_add(1) + } else { + 0 + }; + self.nonspecific_collision_frames = if current == PhysicalStateId::Nonspecific && colliding + { + self.nonspecific_collision_frames.wrapping_add(1) + } else { + 0 + }; + self.two_wheel_counter = if input.skateboard_contact_count_869 > 1 { + self.two_wheel_counter.wrapping_add(1) + } else { + 0 + }; + self.three_wheel_counter = if input.skateboard_contact_count_869 > 2 { + self.three_wheel_counter.wrapping_add(1) + } else { + 0 + }; + self.something_colliding_frames = if colliding { + self.something_colliding_frames.wrapping_add(1) + } else { + 0 + }; + let special_grind = p.category_2512 == 400 && p.has_2468(0x40_0000); + self.post_grind_jump_counter = if special_grind { + 0 + } else { + self.post_grind_jump_counter.wrapping_add(1) + }; + self.air_frames = if p.category_2512 == 200 { + if p.has_2468(8) { + self.air_frames + } else { + self.air_frames.wrapping_add(1) + } + } else { + 0 + }; + self.skitch_exit_countdown = if current == PhysicalStateId::Skitching { + 10 + } else { + if self.skitch_exit_countdown > 0 { + self.skitch_exit_countdown.wrapping_sub(1) + } else { + 0 + } + }; + + if p.has_2468(2) || p.has_2472(0x4_0000) { + return PhysicalStateId::Teleporting; + } + if self.air_frames > 300 { + self.request_teleport = true; + return current; + } + if p.field_1776 < 0 + && (p.field_1776 as u32 & 0x200_0000) == 0 + && self.teleport_countdown == 0 + { + return PhysicalStateId::FollowPath; + } + + let facts = FrameFacts { + colliding, + wipeout: p.has_2468(0x4_0000), + force_known_air: p.has_2472(0x40), + skateboard_animated: is_skateboard_animated(input.skeleton), + off_ground: condition_is_off_ground(input.board_body, input.normal_off_ground), + off_ground_skitching: condition_is_off_ground_skitching( + input.board_body, + input.skitching_off_ground, + ), + grind: check_for_grind(p), + }; + + match current { + PhysicalStateId::PhysicsGround + | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround + | PhysicalStateId::GroundAnimation + | PhysicalStateId::Skitching + | PhysicalStateId::FollowPath => self.select_ground_family(current, input, facts), + PhysicalStateId::PhysicsAir => self.select_physics_air(current, input, facts), + PhysicalStateId::KnownAir => self.select_known_air(current, input, facts), + PhysicalStateId::PhysicsAirSecondary => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if !p.has_2484(0x10_0000) { + PhysicalStateId::PhysicsAir + } else { + current + } + } + PhysicalStateId::WipeoutGround + | PhysicalStateId::Sleeping + | PhysicalStateId::Teleporting => current, + PhysicalStateId::GrindBoardslide + | PhysicalStateId::GrindFiftyFifty + | PhysicalStateId::GrindTipslide + | PhysicalStateId::GrindFiveO + | PhysicalStateId::GrindBackslash + | PhysicalStateId::GrindDarkslide => self.select_grind(current, input, facts), + PhysicalStateId::BipedGround + | PhysicalStateId::BipedAir + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::LandingOnDeck + | PhysicalStateId::HandPlant + | PhysicalStateId::FootPlant + | PhysicalStateId::Boneless => self.select_biped_or_plant(current, input, facts), + PhysicalStateId::Nonspecific => self.select_nonspecific(current, input, facts), + } + } + + fn select_nonspecific( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if p.has_2484(0x10_0000) { + return PhysicalStateId::PhysicsAirSecondary; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2468(0x100) { + return p.air_variant_from_2468(); + } + if p.has_2468(0x40_0000) { + return current; + } + if p.has_2472(8) { + return if p.has_2484(0x20_0000) { + PhysicalStateId::WipeoutGround + } else { + PhysicalStateId::PhysicsAir + }; + } + if p.has_2472(4) { + return current; + } + if let Some(grind) = facts.grind { + return grind; + } + if p.grind_investigation_flags_1516 & 0x800_0000 != 0 { + if self.three_wheel_counter > 10 { + return PhysicalStateId::PhysicsGround; + } + } else if self.two_wheel_counter > 2 { + return if facts.skateboard_animated { + PhysicalStateId::GroundAnimation + } else { + PhysicalStateId::PhysicsGround + }; + } + if self.nonspecific_collision_free_frames > 2 { + return PhysicalStateId::PhysicsAir; + } + let collision_exit_frames = if p.has_2484(0x20_0000) { 30 } else { 60 }; + if self.nonspecific_collision_frames > collision_exit_frames { + PhysicalStateId::PhysicsGround + } else { + current + } + } +} + +fn check_for_grind(input: ProcessedStateInput) -> Option { + if !input.grind_candidate_1488 || input.has_2480(0x2_0000) || input.has_2480(0x400_0000) { + return None; + } + match input.grind_type_1248 { + 0 => Some(PhysicalStateId::GrindFiftyFifty), + 1 => Some(PhysicalStateId::GrindBoardslide), + 2 => Some(PhysicalStateId::GrindTipslide), + 3 => Some(PhysicalStateId::GrindFiveO), + 4 => Some(PhysicalStateId::GrindBackslash), + 5 => Some(PhysicalStateId::GrindDarkslide), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/selector/offboard.rs b/crates/skate-core/src/player/selector/offboard.rs new file mode 100644 index 00000000..b0972921 --- /dev/null +++ b/crates/skate-core/src/player/selector/offboard.rs @@ -0,0 +1,110 @@ +use super::{FrameFacts, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId; + +const HANDPLANT_GROUND_DELAY_SECONDS: f32 = f32::from_bits(0x3E4C_CCCD); + +impl StateSelector { + pub(super) fn select_biped_or_plant( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + match current { + PhysicalStateId::BipedGround => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2484(0x1_0000) { + return PhysicalStateId::BipedAir; + } + if p.has_2476(0x20_0000) { + return PhysicalStateId::OffBoardPushing; + } + if p.has_2476(0x8000) || p.has_2480(0x40000) { + return current; + } + if !p.has_2484(1) && !p.has_2488(0x1000_0000) && !p.has_2488(0x8000_0000) { + return PhysicalStateId::PhysicsAir; + } + PhysicalStateId::PhysicsGround + } + PhysicalStateId::BipedAir => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if !p.has_2480(0x10) { + if p.has_2484(1) { + if p.has_2476(0x8000) || p.has_2476(0x80) { + current + } else { + PhysicalStateId::PhysicsAir + } + } else { + PhysicalStateId::BipedGround + } + } else { + PhysicalStateId::LandingOnDeck + } + } + PhysicalStateId::OffBoardPushing => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if !p.has_2476(0x20_0000) { + PhysicalStateId::BipedGround + } else { + current + } + } + PhysicalStateId::LandingOnDeck => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if p.has_2476(0x1_0000) { + PhysicalStateId::PhysicsGround + } else if p.has_2476(0x80) { + PhysicalStateId::BipedAir + } else { + current + } + } + PhysicalStateId::HandPlant => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if (facts.colliding && p.state_timer_2664 > HANDPLANT_GROUND_DELAY_SECONDS) + || (p.flags_2480 as i32) >= 0 + { + PhysicalStateId::PhysicsGround + } else { + current + } + } + PhysicalStateId::FootPlant => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if !p.has_2480(1) { + p.air_variant_from_2468() + } else { + current + } + } + PhysicalStateId::Boneless => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2468(0x400) { + return PhysicalStateId::KnownAir; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if !p.has_2480(0x4000) && !p.has_2480(0x2000) { + PhysicalStateId::PhysicsAir + } else { + current + } + } + _ => unreachable!("off-board/plant dispatch received another state"), + } + } +} diff --git a/crates/skate-core/src/player/selector/tests.rs b/crates/skate-core/src/player/selector/tests.rs new file mode 100644 index 00000000..5c476ad4 --- /dev/null +++ b/crates/skate-core/src/player/selector/tests.rs @@ -0,0 +1,270 @@ +use super::conditions::{BoardBodyState, SkeletonAnimationState, TwoStageThresholds}; +use super::{ProcessedStateInput, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId as State; + +fn input(category: u32) -> StateSelectionInput { + StateSelectionInput { + processed: ProcessedStateInput { + grind_type_1248: 6, + grind_candidate_1488: false, + field_1776: 0, + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + flags_2488: 0, + category_2512: category, + wheel_contact_count_2556: 0, + field_2572: 0, + state_timer_2664: 0.0, + field_2732: 0.0, + field_2744: 0.0, + trajectory_collision_time_2772: 0.0, + grind_investigation_flags_1516: 0, + }, + skateboard_contact_count_869: 0, + board_body: BoardBodyState { + field_856: 0.0, + field_7692: 0.0, + }, + skeleton: SkeletonAnimationState { + mode_16420: 1, + back_chain_lane_12656: 0.0, + threshold_800: 1.0, + }, + skitching_off_ground: thresholds(), + normal_off_ground: thresholds(), + } +} + +const fn thresholds() -> TwoStageThresholds { + TwoStageThresholds { + field_856_primary: 1.0, + field_856_secondary: 1.0, + field_7692: 1.0, + } +} + +fn calculate(selector: &mut StateSelector, state: State, input: &StateSelectionInput) -> State { + selector.calculate(state, input) +} + +#[test] +fn teleport_request_has_priority_and_fallthrough_sets_the_native_request_byte() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.flags_2472 = 0x4_0000; + assert_eq!( + calculate(&mut selector, State::PhysicsGround, &frame), + State::Teleporting + ); + + frame.processed.flags_2472 = 0; + frame.processed.category_2512 = 200; + selector.air_frames = 300; + assert_eq!( + calculate(&mut selector, State::PhysicsAir, &frame), + State::PhysicsAir + ); + assert!(selector.request_teleport); +} + +#[test] +fn all_six_grind_types_use_the_exact_native_state_mapping() { + let expected = [ + State::GrindFiftyFifty, + State::GrindBoardslide, + State::GrindTipslide, + State::GrindFiveO, + State::GrindBackslash, + State::GrindDarkslide, + ]; + for (grind_type, expected) in expected.into_iter().enumerate() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.grind_candidate_1488 = true; + frame.processed.grind_type_1248 = grind_type as u32; + assert_eq!( + calculate(&mut selector, State::PhysicsGround, &frame), + expected + ); + } +} + +#[test] +fn nonspecific_uses_exact_two_three_and_collision_counter_thresholds() { + let mut free = StateSelector::default(); + let frame = input(700); + assert_eq!( + calculate(&mut free, State::Nonspecific, &frame), + State::Nonspecific + ); + assert_eq!( + calculate(&mut free, State::Nonspecific, &frame), + State::Nonspecific + ); + assert_eq!( + calculate(&mut free, State::Nonspecific, &frame), + State::PhysicsAir + ); + + let mut two_wheels = StateSelector::default(); + let mut frame = input(700); + frame.skateboard_contact_count_869 = 2; + for _ in 0..2 { + assert_eq!( + calculate(&mut two_wheels, State::Nonspecific, &frame), + State::Nonspecific + ); + } + assert_eq!( + calculate(&mut two_wheels, State::Nonspecific, &frame), + State::PhysicsGround + ); + + let mut collision = StateSelector::default(); + let mut frame = input(700); + frame.processed.flags_2468 = 0x1_0000; + for _ in 0..60 { + assert_eq!( + calculate(&mut collision, State::Nonspecific, &frame), + State::Nonspecific + ); + } + assert_eq!( + calculate(&mut collision, State::Nonspecific, &frame), + State::PhysicsGround + ); +} + +#[test] +fn nonspecific_short_collision_threshold_comes_from_flags_2484_bit_21() { + let mut selector = StateSelector::default(); + let mut frame = input(700); + frame.processed.flags_2468 = 0x1_0000; + frame.processed.flags_2484 = 0x20_0000; + for _ in 0..30 { + assert_eq!( + calculate(&mut selector, State::Nonspecific, &frame), + State::Nonspecific + ); + } + assert_eq!( + calculate(&mut selector, State::Nonspecific, &frame), + State::PhysicsGround + ); +} + +#[test] +fn ground_to_landing_on_deck_takes_the_native_noop_call_branch() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.flags_2480 = 0x1000; + let state = selector.calculate(State::PhysicsGround, &frame); + assert_eq!(state, State::LandingOnDeck); +} + +#[test] +fn revert_normal_exit_sets_the_recovered_state_changer_byte() { + let mut selector = StateSelector::default(); + let frame = input(100); + assert_eq!( + calculate(&mut selector, State::RevertGround, &frame), + State::PhysicsGround + ); + assert!(selector.revert_exited_normally); +} + +#[test] +fn known_air_transition_order_matches_the_tu3_tree() { + let mut selector = StateSelector::default(); + let mut frame = input(200); + frame.processed.flags_2480 = 0x200_0000; + assert_eq!( + calculate(&mut selector, State::KnownAir, &frame), + State::FootPlant + ); + + frame.processed.flags_2480 = 0; + frame.processed.flags_2476 = 0x80; + frame.processed.trajectory_collision_time_2772 = 0.04; + assert_eq!( + calculate(&mut selector, State::KnownAir, &frame), + State::WipeoutGround + ); +} + +#[test] +fn physics_air_collision_resolves_before_the_grind_candidate_path() { + let mut selector = StateSelector { + post_grind_jump_counter: 11, + ..StateSelector::default() + }; + let mut frame = input(200); + frame.processed.state_timer_2664 = 0.1; + frame.processed.flags_2468 = 0x1_0000; + frame.processed.grind_candidate_1488 = true; + frame.processed.grind_type_1248 = 0; + assert_eq!( + calculate(&mut selector, State::PhysicsAir, &frame), + State::PhysicsGround + ); +} + +#[test] +fn skitch_exit_countdown_is_loaded_to_ten_then_saturates() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.flags_2476 = 0x20_0000; + assert_eq!( + calculate(&mut selector, State::Skitching, &frame), + State::Skitching + ); + assert_eq!(selector.skitch_exit_countdown, 10); + + frame.processed.flags_2476 = 0; + let _ = calculate(&mut selector, State::PhysicsGround, &frame); + assert_eq!(selector.skitch_exit_countdown, 9); +} + +#[test] +fn check_for_grind_rejects_both_native_flags() { + for rejected in [0x2_0000, 0x400_0000] { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.grind_candidate_1488 = true; + frame.processed.grind_type_1248 = 0; + frame.processed.flags_2480 = rejected; + assert_eq!( + calculate(&mut selector, State::PhysicsGround, &frame), + State::PhysicsGround + ); + } +} + +#[test] +fn skeleton_animation_predicate_uses_mode_and_sign_cleared_lane() { + use super::conditions::is_skateboard_animated; + + assert!(is_skateboard_animated(SkeletonAnimationState { + mode_16420: 2, + back_chain_lane_12656: 0.0, + threshold_800: 100.0, + })); + assert!(is_skateboard_animated(SkeletonAnimationState { + mode_16420: 0, + back_chain_lane_12656: -2.0, + threshold_800: 1.0, + })); + assert!(!is_skateboard_animated(SkeletonAnimationState { + mode_16420: 1, + back_chain_lane_12656: -2.0, + threshold_800: 1.0, + })); + assert!(is_skateboard_animated(SkeletonAnimationState { + mode_16420: 0, + back_chain_lane_12656: f32::NAN, + threshold_800: 1.0, + })); +} diff --git a/crates/skate-core/src/player/slide_state/forces.rs b/crates/skate-core/src/player/slide_state/forces.rs new file mode 100644 index 00000000..406534ec --- /dev/null +++ b/crates/skate-core/src/player/slide_state/forces.rs @@ -0,0 +1,249 @@ +//! Full non-launch arithmetic82D3AB14..AE4C and point force82D925E0. +//! Uses independent PC arithmetic with the original refinement/operation order. +use crate::{ + math::Vector3, + physics::{board_motion_output::inverse_length_squared, force_queue::QueuedPointForce}, + point_graph::PointGraph, + riding::collision_response::signed_angle, +}; +type V = [f32; 4]; +#[derive(Clone, Debug)] +pub struct SlideSettings { + pub input_remap: PointGraph<8>, + pub remap_vs_speed: PointGraph<8>, + pub force_vs_angle: PointGraph<8>, + pub force_vs_speed: PointGraph<8>, + pub softest_wheel_force: f32, + pub softest_wheel_spin: f32, + pub angular_force: f32, + pub force_y_offset: f32, +} +#[derive(Clone, Debug)] +pub struct SlideSurface { + pub speed_to_force: PointGraph<8>, + pub yaw_strength: f32, + pub yaw_damping: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct SlideInput { + pub velocity: V, + pub normal: V, + pub side: V, + pub effective_forward: V, + pub reference_forward: V, + pub angular_velocity: V, + pub absolute_speed: f32, + pub surface_speed: f32, + pub slide: f32, + pub elapsed: f32, + pub wheel_hardness: f32, +} +/// The source does not add a zero-velocity normalization fallback here. +/// Retain that branch behavior rather than manufacturing a desired direction. +pub fn angular_correction(s: &SlideSettings, surface: &SlideSurface, p: SlideInput) -> V { + let remap = s.input_remap.evaluate(p.slide.abs()); + let age = p.elapsed - 0.5; + let age = fsel(-age, 0.0, age); + let age = fsel(1.0 - age, age, 1.0); + let weight = s.remap_vs_speed.evaluate(p.surface_speed) * age; + let pi = f32::from_bits(0x4049_0fdb); + let signed = fsel(p.slide, remap, -remap); + let angle = (p.slide * pi).mul_add(1.0 - weight, (signed * pi) * weight); + let velocity = normalize(p.velocity); + let side = normalize(cross(p.normal, velocity)); + let desired = madd( + velocity, + crate::trigonometry::cos(angle), + scale(side, crate::trigonometry::sin(angle)), + ); + let mut error = cross(p.reference_forward, desired); + if dot(p.reference_forward, desired) < 0.0 { + error = if error[1] < 0.0 { + p.normal.map(|v| -v) + } else { + p.normal + }; + } + let strength = (1.0 - s.softest_wheel_spin).mul_add(p.wheel_hardness, s.softest_wheel_spin) + * surface.yaw_strength; + let displacement = scale( + madd( + error, + strength, + scale(p.angular_velocity, surface.yaw_damping), + ), + s.angular_force, + ); + scale(p.normal, dot(displacement, p.normal)) +} +///82D925E0 rejects normal velocity, measures the authored slip angle and adds +/// its signed sideways response.82E0A4E8 normalizes before its rejection again. +pub fn sliding_force(s: &SlideSettings, surface: &SlideSurface, p: SlideInput) -> QueuedPointForce { + let tangent = reject(p.velocity, p.normal); + let lateral = scale(p.side, dot(tangent, p.side)); + let response = surface.speed_to_force.evaluate(p.absolute_speed); + // Original ble is !GT, so unordered follows the <= branch too. + let hardness = if response > 0.0 { + p.wheel_hardness + } else { + 1.0 + }; + let strength = + (1.0 - s.softest_wheel_force).mul_add(hardness, s.softest_wheel_force) * response; + let side = normalize_safe(cross(p.normal, p.velocity)); + let longitudinal = reject(scale(lateral, strength), normalize_safe(side)); + let angle = if dot(p.velocity, p.velocity) * dot(p.effective_forward, p.effective_forward) + > f32::from_bits(0x3780_0000) + { + signed_angle( + xyz(reject(p.velocity, p.normal)), + xyz(reject(p.effective_forward, p.normal)), + xyz(p.normal), + ) + } else { + 0.0 + }; + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let wrapped = (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb); + let degrees = wrapped * f32::from_bits(0x4265_2ee1); + let sign = if degrees > 0.0 { 1.0 } else { -1.0 }; + let angular = scale( + scale(side, s.force_vs_angle.evaluate(degrees.abs())), + s.force_vs_speed.evaluate(p.surface_speed), + ); + let force = madd(angular, sign, longitudinal); + // bge skips negation for unordered as well as nonnegative. + let y = if dot(force, p.velocity) < 0.0 { + -s.force_y_offset + } else { + s.force_y_offset + }; + QueuedPointForce { + tag: 9, + force_world: xyz(force), + point_body: Vector3::new(0.0, y, 0.0), + } +} +fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} +fn scale(v: V, s: f32) -> V { + v.map(|v| v * s) +} +fn madd(v: V, s: f32, b: V) -> V { + std::array::from_fn(|i| v[i].mul_add(s, b[i])) +} +fn reject(v: V, n: V) -> V { + let d = dot(v, n); + std::array::from_fn(|i| v[i] - n[i] * d) +} +fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn normalize(v: V) -> V { + scale(v, inverse_length_squared(dot(v, v), 2)) +} +fn normalize_safe(v: V) -> V { + let d = dot(v, v); + let inv = inverse_length_squared(d, 2); + let length = if d == 0.0 { 0.0 } else { d * inv }; + if length > f32::from_bits(0x3586_37bd) { + scale(v, inv) + } else { + [0.0; 4] + } +} +fn fsel(v: f32, a: f32, b: f32) -> f32 { + if v >= -0.0 { a } else { b } +} +fn xyz(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + fn curve(value: f32) -> PointGraph<8> { + PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [value; 8], + } + } + fn settings() -> SlideSettings { + SlideSettings { + input_remap: curve(0.0), + remap_vs_speed: curve(0.0), + force_vs_angle: curve(0.0), + force_vs_speed: curve(0.0), + softest_wheel_force: 0.25, + softest_wheel_spin: 1.0, + angular_force: 0.1, + force_y_offset: -0.12, + } + } + fn input() -> SlideInput { + SlideInput { + velocity: [4., 0., 3., 0.], + normal: [0., 1., 0., 0.], + side: [1., 0., 0., 0.], + effective_forward: [0., 0., 1., 0.], + reference_forward: [0., 0., 1., 0.], + angular_velocity: [0.; 4], + absolute_speed: 5., + surface_speed: 5., + slide: 0., + elapsed: 0., + wheel_hardness: 0.5, + } + } + #[test] + fn negative_slide_force_bypasses_wheel_softness_and_reverses_application_height() { + // Analytic projection of a world-X force onto velocity(4,0,3). + // Source82D926A4 gates hardness only for a positive curve result; + //82D92924 chooses the point height from signed work, not force X. + let s = settings(); + let p = input(); + let mut surface = SlideSurface { + speed_to_force: curve(2.), + yaw_strength: 0., + yaw_damping: 0., + }; + let positive = sliding_force(&s, &surface, p); + assert!((positive.force_world.x - 3.2).abs() < 0.00001); + assert!((positive.force_world.z - 2.4).abs() < 0.00001); + assert_eq!(positive.point_body.y, -0.12); + surface.speed_to_force = curve(-2.); + let negative = sliding_force(&s, &surface, p); + assert!((negative.force_world.x + 5.12).abs() < 0.00001); + assert!((negative.force_world.z + 3.84).abs() < 0.00001); + assert_eq!(negative.point_body.y, 0.12); + } + #[test] + fn slide_yaw_past_ninety_degrees_uses_signed_ground_axis() { + // Opposing desired headings replace the cross error with +/-normal + // at82D3ACCC..AD38; damping still uses actual angular velocity. + let s = settings(); + let mut p = input(); + p.velocity = [0., 0., 4., 0.]; + p.angular_velocity = [9., -2., 5., 0.]; + p.slide = 0.75; + let surface = SlideSurface { + speed_to_force: curve(0.), + yaw_strength: 2., + yaw_damping: 0.3, + }; + let positive = angular_correction(&s, &surface, p); + assert!((positive[1] - 0.14).abs() < 0.00001); + assert_eq!(positive[0], 0.); + assert_eq!(positive[2], 0.); + p.slide = -0.75; + let negative = angular_correction(&s, &surface, p); + assert!((negative[1] + 0.26).abs() < 0.00001); + } +} diff --git a/crates/skate-core/src/player/slide_state/mod.rs b/crates/skate-core/src/player/slide_state/mod.rs new file mode 100644 index 00000000..8effc72c --- /dev/null +++ b/crates/skate-core/src/player/slide_state/mod.rs @@ -0,0 +1,34 @@ +//! PhysicsSlideGround101: Enter82D3A700, Exit82D3A828 and board82D3A900. +mod forces; +pub use forces::{SlideInput, SlideSettings, SlideSurface, angular_correction, sliding_force}; + +#[derive(Clone, Copy, Debug)] +pub struct SlideState { + pub start_speed: f32, + pub steering_push: f32, + pub damped_turn: f32, + pub flag48: bool, + pub wall_riding: bool, +} +impl SlideState { + /// Host storage; Enter defines every represented native field before use. + pub fn new() -> Self { + Self { + start_speed: 0.0, + steering_push: 1.0, + damped_turn: 0.0, + flag48: false, + wall_riding: false, + } + } + pub fn enter(&mut self, speed: f32) { + self.start_speed = speed; + self.exit(); + } + pub fn exit(&mut self) { + self.flag48 = false; + self.wall_riding = false; + self.steering_push = 1.0; + self.damped_turn = 0.0; + } +} diff --git a/crates/skate-core/src/player/state.rs b/crates/skate-core/src/player/state.rs new file mode 100644 index 00000000..e3e03c8d --- /dev/null +++ b/crates/skate-core/src/player/state.rs @@ -0,0 +1,185 @@ +//! Physical-player state identities and native object ownership from +//! `PhysicalPlayer` construction and `SetPhysicsState` (`0x82DB8540`). + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum PhysicalStateId { + PhysicsGround = 100, + SlideGround = 101, + RevertGround = 102, + GroundAnimation = 103, + Skitching = 104, + FollowPath = 105, + PhysicsAir = 200, + KnownAir = 201, + PhysicsAirSecondary = 202, + WipeoutGround = 300, + GrindBoardslide = 400, + GrindFiftyFifty = 401, + GrindTipslide = 402, + GrindFiveO = 403, + GrindBackslash = 404, + GrindDarkslide = 405, + BipedGround = 500, + BipedAir = 501, + OffBoardPushing = 502, + LandingOnDeck = 503, + HandPlant = 600, + FootPlant = 601, + Boneless = 602, + Sleeping = 700, + Nonspecific = 701, + Teleporting = 702, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnknownPhysicalState(pub u32); + +impl TryFrom for PhysicalStateId { + type Error = UnknownPhysicalState; + + fn try_from(value: u32) -> Result { + let state = match value { + 100 => Self::PhysicsGround, + 101 => Self::SlideGround, + 102 => Self::RevertGround, + 103 => Self::GroundAnimation, + 104 => Self::Skitching, + 105 => Self::FollowPath, + 200 => Self::PhysicsAir, + 201 => Self::KnownAir, + 202 => Self::PhysicsAirSecondary, + 300 => Self::WipeoutGround, + 400 => Self::GrindBoardslide, + 401 => Self::GrindFiftyFifty, + 402 => Self::GrindTipslide, + 403 => Self::GrindFiveO, + 404 => Self::GrindBackslash, + 405 => Self::GrindDarkslide, + 500 => Self::BipedGround, + 501 => Self::BipedAir, + 502 => Self::OffBoardPushing, + 503 => Self::LandingOnDeck, + 600 => Self::HandPlant, + 601 => Self::FootPlant, + 602 => Self::Boneless, + 700 => Self::Sleeping, + 701 => Self::Nonspecific, + 702 => Self::Teleporting, + other => return Err(UnknownPhysicalState(other)), + }; + Ok(state) + } +} + +impl PhysicalStateId { + pub const ALL: [Self; 26] = [ + Self::PhysicsGround, + Self::SlideGround, + Self::RevertGround, + Self::GroundAnimation, + Self::Skitching, + Self::FollowPath, + Self::PhysicsAir, + Self::KnownAir, + Self::PhysicsAirSecondary, + Self::WipeoutGround, + Self::GrindBoardslide, + Self::GrindFiftyFifty, + Self::GrindTipslide, + Self::GrindFiveO, + Self::GrindBackslash, + Self::GrindDarkslide, + Self::BipedGround, + Self::BipedAir, + Self::OffBoardPushing, + Self::LandingOnDeck, + Self::HandPlant, + Self::FootPlant, + Self::Boneless, + Self::Sleeping, + Self::Nonspecific, + Self::Teleporting, + ]; + + /// Offset of the owning state pointer inside `PhysicalPlayer`. + pub const fn native_owner_offset(self) -> u32 { + match self { + Self::Sleeping => 1692, + Self::PhysicsGround => 1696, + Self::SlideGround => 1700, + Self::RevertGround => 1704, + Self::GroundAnimation => 1708, + Self::PhysicsAir => 1712, + Self::PhysicsAirSecondary => 1716, + Self::KnownAir => 1720, + Self::GrindDarkslide => 1724, + Self::GrindBoardslide => 1728, + Self::GrindFiftyFifty => 1732, + Self::GrindTipslide => 1736, + Self::GrindFiveO => 1740, + Self::GrindBackslash => 1744, + Self::WipeoutGround => 1748, + Self::Nonspecific => 1752, + Self::Teleporting => 1756, + Self::FollowPath => 1760, + Self::BipedGround => 1764, + Self::BipedAir => 1768, + Self::Skitching => 1772, + Self::OffBoardPushing => 1776, + Self::HandPlant => 1780, + Self::FootPlant => 1784, + Self::Boneless => 1788, + Self::LandingOnDeck => 1792, + } + } + + /// Category written by native `GetStateCategory` into ProcessedPhysIn. + pub const fn category(self) -> u32 { + (self as u32 / 100) * 100 + } + + pub const fn is_grind(self) -> bool { + matches!( + self, + Self::GrindBoardslide + | Self::GrindFiftyFifty + | Self::GrindTipslide + | Self::GrindFiveO + | Self::GrindBackslash + | Self::GrindDarkslide + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_native_state_round_trips_and_has_a_unique_owner_slot() { + let mut slots = PhysicalStateId::ALL.map(PhysicalStateId::native_owner_offset); + slots.sort_unstable(); + assert!(slots.windows(2).all(|pair| pair[0] != pair[1])); + for state in PhysicalStateId::ALL { + assert_eq!(PhysicalStateId::try_from(state as u32), Ok(state)); + assert_eq!(state.category(), (state as u32 / 100) * 100); + } + } + + #[test] + fn only_the_six_native_grind_states_form_the_grind_range() { + for state in PhysicalStateId::ALL { + assert_eq!(state.is_grind(), (400..=405).contains(&(state as u32))); + } + } + + #[test] + fn unknown_state_ids_are_rejected_without_a_fallback() { + assert_eq!(PhysicalStateId::try_from(0), Err(UnknownPhysicalState(0))); + assert_eq!( + PhysicalStateId::try_from(203), + Err(UnknownPhysicalState(203)) + ); + } +} diff --git a/crates/skate-core/src/player/state_phase.rs b/crates/skate-core/src/player/state_phase.rs new file mode 100644 index 00000000..b47a4dc4 --- /dev/null +++ b/crates/skate-core/src/player/state_phase.rs @@ -0,0 +1,62 @@ +//! TU3 `PhysicalPlayerHiLOD::State` wrapper (`0x82DB6120`). +//! +//! State bodies, skateboard-controller modes, the board force queue, and the +//! board's fixed-step cache are separate recovered systems. This module owns +//! their exact wrapper order and Player+1344 accumulation. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StatePhaseFields { + /// PhysicalPlayer+1344. + pub elapsed_1344: f32, + /// ProcessedPhysIn+2604. + pub timestep_2604: f32, + /// SkateboardController+448. + pub controller_state_448: u32, + /// SkateboardController+452. + pub controller_system_on_452: bool, +} + +/// Calls made by `0x82DB6120`. Implementors must connect each method to the +/// corresponding recovered system; no fallback behavior is defined here. +pub trait StatePhaseServices { + /// Current-state vtable slot `+8`. + fn update_current_state_vtable_8(&mut self); + + fn update_controller_82d75f00(&mut self); + fn update_controller_mode_1_82d751d8(&mut self); + fn update_controller_mode_2_82d750f0(&mut self); + fn update_controller_mode_3_82d75bb8(&mut self); + fn update_controller_mode_4_82d75d58(&mut self); + + /// First Player vtable `+116` lookup at `0x82DB61C4`. + fn touch_skateboard_vtable_116(&mut self); + /// Second Player vtable `+116` lookup followed by `0x82C03718`. + fn apply_skateboard_force_queue_82c03718(&mut self); + /// Third Player vtable `+116` lookup and the fixed-`1/60` VMX cache update + /// at `0x82DB61F0..0x82DB62D0`. + fn update_skateboard_fixed_step_cache_82db61f0(&mut self); +} + +pub fn run_state_phase(fields: &mut StatePhaseFields, services: &mut impl StatePhaseServices) { + services.update_current_state_vtable_8(); + + if fields.controller_system_on_452 { + services.update_controller_82d75f00(); + match fields.controller_state_448 { + 1 => services.update_controller_mode_1_82d751d8(), + 2 => services.update_controller_mode_2_82d750f0(), + 3 => services.update_controller_mode_3_82d75bb8(), + 4 => services.update_controller_mode_4_82d75d58(), + _ => {} + } + } + + services.touch_skateboard_vtable_116(); + services.apply_skateboard_force_queue_82c03718(); + services.update_skateboard_fixed_step_cache_82db61f0(); + fields.elapsed_1344 += fields.timestep_2604; +} + +#[cfg(test)] +#[path = "tests/state_phase.rs"] +mod tests; diff --git a/crates/skate-core/src/player/teleport_state.rs b/crates/skate-core/src/player/teleport_state.rs new file mode 100644 index 00000000..5a238547 --- /dev/null +++ b/crates/skate-core/src/player/teleport_state.rs @@ -0,0 +1,71 @@ +//! Teleporting702 from original TU3 VT823272FC. +//! Enter82D431D8, Update82D431F0 and FillPhysOut82D43280. +use super::input_phase::RawMatrix; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Target { + pub transform: RawMatrix, + pub on_board: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Update { + /// Skeleton16552 virtual36: ask the actor for a reset target. + RequestCheckpoint, + Captured, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Output { + pub transform: RawMatrix, + pub next_state: u32, + pub state_61: u8, + pub board_272: u8, +} + +#[derive(Clone, Debug)] +pub struct TeleportState { + target: RawMatrix, //48 + ready: bool, //116 bit31 + received: bool, //116 bit30; native serialization retains this bit. + on_board: bool, //116 bit29 +} +impl Default for TeleportState { + fn default() -> Self { + Self { + target: [[0; 4]; 4], + ready: false, + received: false, + on_board: true, + } + } +} +impl TeleportState { + /// Clears only the three lifecycle flags; the retained matrix is unread + /// until Update captures a new target. Native Exit is an empty leaf. + pub fn enter(&mut self) { + self.ready = false; + self.received = false; + self.on_board = true; + } + pub fn update(&mut self, flags_2468: u32, matrix_1536: RawMatrix, byte_1600: u8) -> Update { + if flags_2468 & 2 == 0 { + return Update::RequestCheckpoint; + } + self.received = true; + self.target = matrix_1536; + self.ready = true; + //rlwimi82D43254 inserts only byte1600 bit0, not a nonzero test. + self.on_board = byte_1600 & 1 != 0; + Update::Captured + } + /// This publisher does not clear the output when no target is ready. + pub fn output(&self) -> Option { + self.ready.then_some(Output { + transform: self.target, + next_state: if self.on_board { 100 } else { 500 }, + state_61: 1, + board_272: 1, + }) + } +} diff --git a/crates/skate-core/src/player/tests/adjust.rs b/crates/skate-core/src/player/tests/adjust.rs new file mode 100644 index 00000000..b096b63c --- /dev/null +++ b/crates/skate-core/src/player/tests/adjust.rs @@ -0,0 +1,117 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Event { + Condition, + BoardFlag, + Adjust(bool, u32), + State, + Selector, + Finish(u32), +} + +struct Services { + condition: u32, + board_flag: bool, + selector: u32, + events: Vec, +} + +impl AdjustServices for Services { + fn board_condition_82c02138(&mut self) -> u32 { + self.events.push(Event::Condition); + self.condition + } + + fn controller_mode_one_board_flag_868(&mut self) -> bool { + self.events.push(Event::BoardFlag); + self.board_flag + } + + fn adjust_skeleton_82bd80d8(&mut self, board_flag: bool, timestep: f32) { + self.events + .push(Event::Adjust(board_flag, timestep.to_bits())); + } + + fn update_current_state_post_physics_vtable_40(&mut self) { + self.events.push(Event::State); + } + + fn player_output_selector_82db9100(&mut self) -> u32 { + self.events.push(Event::Selector); + self.selector + } + + fn finish_skeleton_adjustment_82bd83e0(&mut self, selector: u32) { + self.events.push(Event::Finish(selector)); + } +} + +#[test] +fn publishes_board_condition_and_preserves_post_solver_call_order() { + let mut fields = AdjustFields { + processed_word_2512: 100, + timestep_2604: f32::from_bits(0x3c88_8889), + controller_state_448: 1, + output_flag_42: false, + output_scalar_144: 7.0, + output_counter_200: u32::MAX, + }; + let mut services = Services { + condition: 1, + board_flag: true, + selector: 19, + events: Vec::new(), + }; + + run_adjust(&mut fields, &mut services); + + assert!(fields.output_flag_42); + assert_eq!(fields.output_scalar_144.to_bits(), 0); + assert_eq!(fields.output_counter_200, 0); + assert_eq!( + services.events, + [ + Event::Condition, + Event::BoardFlag, + Event::Adjust(true, 0x3c88_8889), + Event::State, + Event::Selector, + Event::Finish(19), + ] + ); +} + +#[test] +fn state_500_and_other_controller_modes_skip_only_the_native_branches() { + let mut fields = AdjustFields { + processed_word_2512: 500, + timestep_2604: 0.25, + controller_state_448: 4, + output_flag_42: false, + output_scalar_144: 7.0, + output_counter_200: 8, + }; + let mut services = Services { + condition: 1, + board_flag: true, + selector: 3, + events: Vec::new(), + }; + + run_adjust(&mut fields, &mut services); + + assert!(!fields.output_flag_42); + assert_eq!(fields.output_scalar_144, 7.0); + assert_eq!(fields.output_counter_200, 8); + assert_eq!( + services.events, + [ + Event::Condition, + Event::Adjust(false, 0.25_f32.to_bits()), + Event::State, + Event::Selector, + Event::Finish(3), + ] + ); +} diff --git a/crates/skate-core/src/player/tests/frame.rs b/crates/skate-core/src/player/tests/frame.rs new file mode 100644 index 00000000..c98b9080 --- /dev/null +++ b/crates/skate-core/src/player/tests/frame.rs @@ -0,0 +1,205 @@ +use super::*; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone)] +struct Recorder { + calls: Rc>>, + fail: Option, +} + +impl Recorder { + fn call(&mut self, stage: FrameStage) -> Result<(), FrameStage> { + self.calls.borrow_mut().push(stage); + if self.fail == Some(stage) { + Err(stage) + } else { + Ok(()) + } + } +} + +impl AnimationPhases for Recorder { + type Error = FrameStage; + fn phase_one(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::AnimationPhaseOne) + } + fn phase_two(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::AnimationPhaseTwo) + } + fn phase_three(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::AnimationPhaseThree) + } + fn publish_physics_input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::ActorPhysicsPublication) + } +} + +impl WorldPhases for Recorder { + type Error = FrameStage; + fn start_frame(&mut self, _: f32) -> Result<(), Self::Error> { + self.call(FrameStage::StartFrame) + } + fn pre_frame(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldPreFrame) + } + fn prepare(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldPrepare) + } + fn precursor(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldPrecursor) + } + fn after_input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldAfterInput) + } + fn collect_solver_inputs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::CollectSolverInputs) + } + fn finish_outputs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::FinishOutputs) + } +} + +impl PhysicalPhases for Recorder { + type Error = FrameStage; + fn post_zero_jobs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostZeroJobs) + } + fn input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Input) + } + fn post_input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostInput) + } + fn logic(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Logic) + } + fn pre_state(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PreState) + } + fn state(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::State) + } + fn post_state(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostState) + } + fn adjust(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Adjust) + } + fn post_adjust(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostAdjust) + } + fn output(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Output) + } + fn post_output(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostOutput) + } +} + +impl SolverPhases for Recorder { + type Error = FrameStage; + fn prepare_jobs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PrepareSolverJobs) + } + fn submit(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::SubmitSolver) + } + fn wait(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WaitSolver) + } +} + +fn recorders(fail: Option) -> (Rc>>, [Recorder; 4]) { + let calls = Rc::new(RefCell::new(Vec::new())); + let recorder = Recorder { + calls: calls.clone(), + fail, + }; + (calls, core::array::from_fn(|_| recorder.clone())) +} + +#[test] +fn runs_the_complete_native_order_across_the_solver_fence() { + let (calls, [mut animation, mut world, mut physical, mut solver]) = recorders(None); + let mut scheduler = FrameScheduler::default(); + scheduler + .run_before_solver( + 1.0 / 60.0, + &mut animation, + &mut world, + &mut physical, + &mut solver, + ) + .unwrap(); + assert_eq!(scheduler.state(), SchedulerState::SolverPending); + scheduler + .run_after_solver(&mut world, &mut physical, &mut solver) + .unwrap(); + assert_eq!(scheduler.state(), SchedulerState::Ready); + assert_eq!( + *calls.borrow(), + [ + FrameStage::StartFrame, + FrameStage::WorldPreFrame, + FrameStage::AnimationPhaseOne, + FrameStage::AnimationPhaseTwo, + FrameStage::WorldPrepare, + FrameStage::AnimationPhaseThree, + FrameStage::WorldPrecursor, + FrameStage::ActorPhysicsPublication, + FrameStage::PostZeroJobs, + FrameStage::Input, + FrameStage::WorldAfterInput, + FrameStage::PostInput, + FrameStage::Logic, + FrameStage::PreState, + FrameStage::State, + FrameStage::PostState, + FrameStage::CollectSolverInputs, + FrameStage::PrepareSolverJobs, + FrameStage::SubmitSolver, + FrameStage::WaitSolver, + FrameStage::Adjust, + FrameStage::PostAdjust, + FrameStage::Output, + FrameStage::PostOutput, + FrameStage::FinishOutputs, + ] + ); +} + +#[test] +fn post_solver_work_cannot_run_before_submission() { + let (_, [_, mut world, mut physical, mut solver]) = recorders(None); + let mut scheduler = FrameScheduler::default(); + assert_eq!( + scheduler.run_after_solver(&mut world, &mut physical, &mut solver), + Err(ScheduleError::WrongState { + expected: SchedulerState::SolverPending, + actual: SchedulerState::Ready, + }) + ); +} + +#[test] +fn a_failed_phase_stops_the_frame_and_cannot_publish_later_output() { + let (calls, [mut animation, mut world, mut physical, mut solver]) = + recorders(Some(FrameStage::Logic)); + let mut scheduler = FrameScheduler::default(); + assert_eq!( + scheduler.run_before_solver( + 1.0 / 60.0, + &mut animation, + &mut world, + &mut physical, + &mut solver + ), + Err(ScheduleError::Runtime { + stage: FrameStage::Logic, + source: FrameStage::Logic + }) + ); + assert_eq!(scheduler.state(), SchedulerState::Faulted); + assert_eq!(calls.borrow().last(), Some(&FrameStage::Logic)); + assert!(!calls.borrow().contains(&FrameStage::SubmitSolver)); +} diff --git a/crates/skate-core/src/player/tests/lifecycle.rs b/crates/skate-core/src/player/tests/lifecycle.rs new file mode 100644 index 00000000..0c05560f --- /dev/null +++ b/crates/skate-core/src/player/tests/lifecycle.rs @@ -0,0 +1,378 @@ +use super::*; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + GetType(StateBinding), + Exit(StateCall), + Enter(StateCall), + Hold, + LetGo, +} + +struct Recorder { + reported_type: PhysicalStateId, + events: Vec, +} + +#[derive(Clone)] +struct SharedRecorder { + reported_type: PhysicalStateId, + events: Rc>>, +} + +impl PhysicalStateCalls for SharedRecorder { + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId { + self.events.borrow_mut().push(Event::GetType(state)); + self.reported_type + } + + fn exit(&mut self, call: StateCall) { + self.events.borrow_mut().push(Event::Exit(call)); + } + + fn enter(&mut self, call: StateCall) { + self.events.borrow_mut().push(Event::Enter(call)); + } +} + +impl SkateboardControllerActions for SharedRecorder { + fn hold_skateboard(&mut self) { + self.events.borrow_mut().push(Event::Hold); + } + + fn let_go_of_skateboard(&mut self) { + self.events.borrow_mut().push(Event::LetGo); + } +} + +impl PhysicalStateCalls for Recorder { + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId { + self.events.push(Event::GetType(state)); + self.reported_type + } + + fn exit(&mut self, call: StateCall) { + self.events.push(Event::Exit(call)); + } + + fn enter(&mut self, call: StateCall) { + self.events.push(Event::Enter(call)); + } +} + +impl SkateboardControllerActions for Recorder { + fn hold_skateboard(&mut self) { + self.events.push(Event::Hold); + } + + fn let_go_of_skateboard(&mut self) { + self.events.push(Event::LetGo); + } +} + +fn seeded_data() -> StateChangeData { + StateChangeData { + player: PlayerStateChangeFields { + word_1312: 0x1312_1312, + previous_category_latch_1336: 400, + scalar_1344: 13.44, + }, + processed: ProcessedStateChangeFields { + flags_2480: 0, + requested_state_2500: 0x2500_2500, + previous_state_2504: 0x2504_2504, + current_state_2508: 0x2508_2508, + current_category_2512: 0x2512_2512, + previous_category_2516: 0x2516_2516, + previous_category_latch_2520: 0x2520_2520, + word_2564: 0x2564_2564, + scalar_2664: 26.64, + }, + skateboard_controller: SkateboardControllerFields { + word_444: 444, + state_448: 0, + system_on_452: false, + }, + } +} + +fn recorder(reported_type: PhysicalStateId) -> Recorder { + Recorder { + reported_type, + events: Vec::new(), + } +} + +#[test] +fn exits_old_pointer_before_switching_and_enters_new_pointer_afterward() { + let old = StateBinding::new(PhysicalStateId::PhysicsGround); + let new = StateBinding::new(PhysicalStateId::PhysicsAir); + let mut lifecycle = PhysicalPlayerStateLifecycle::new(old.state); + let mut data = seeded_data(); + let mut states = recorder(old.state); + let mut controller = recorder(old.state); + + assert_eq!( + lifecycle + .set_physics_state(new.state as u32, &mut data, &mut states, &mut controller) + .unwrap(), + new + ); + assert_eq!( + states.events, + [ + Event::GetType(old), + Event::Exit(StateCall { + state: old, + active: old, + }), + Event::Enter(StateCall { + state: new, + active: new, + }), + ] + ); + assert_eq!(lifecycle.active(), new); +} + +#[test] +fn publishes_exact_state_history_and_resets_transition_fields() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let mut data = seeded_data(); + let mut states = recorder(PhysicalStateId::PhysicsGround); + let mut controller = recorder(PhysicalStateId::PhysicsGround); + + lifecycle + .set_physics_state( + PhysicalStateId::PhysicsAir as u32, + &mut data, + &mut states, + &mut controller, + ) + .unwrap(); + + assert_eq!(data.player.word_1312, 0); + assert_eq!(data.player.scalar_1344.to_bits(), 0.0f32.to_bits()); + assert_eq!(data.processed.scalar_2664.to_bits(), 0.0f32.to_bits()); + assert_eq!(data.processed.word_2564, 0); + assert_eq!(data.processed.previous_state_2504, 100); + assert_eq!(data.processed.previous_category_2516, 100); + assert_eq!(data.processed.requested_state_2500, 200); + assert_eq!(data.processed.current_state_2508, 200); + assert_eq!(data.processed.current_category_2512, 200); + assert_eq!(data.player.previous_category_latch_1336, 100); + assert_eq!(data.processed.previous_category_latch_2520, 100); + + states.reported_type = PhysicalStateId::PhysicsAir; + data.player.previous_category_latch_1336 = 700; + lifecycle + .set_physics_state( + PhysicalStateId::KnownAir as u32, + &mut data, + &mut states, + &mut controller, + ) + .unwrap(); + assert_eq!(data.processed.previous_state_2504, 200); + assert_eq!(data.processed.previous_category_2516, 200); + assert_eq!(data.processed.current_state_2508, 201); + assert_eq!(data.processed.current_category_2512, 200); + assert_eq!(data.player.previous_category_latch_1336, 700); + assert_eq!(data.processed.previous_category_latch_2520, 700); +} + +#[test] +fn every_native_state_selects_its_exact_owned_pointer_slot() { + let expected = [ + (PhysicalStateId::PhysicsGround, 1696), + (PhysicalStateId::SlideGround, 1700), + (PhysicalStateId::RevertGround, 1704), + (PhysicalStateId::GroundAnimation, 1708), + (PhysicalStateId::Skitching, 1772), + (PhysicalStateId::FollowPath, 1760), + (PhysicalStateId::PhysicsAir, 1712), + (PhysicalStateId::KnownAir, 1720), + (PhysicalStateId::PhysicsAirSecondary, 1716), + (PhysicalStateId::WipeoutGround, 1748), + (PhysicalStateId::GrindBoardslide, 1728), + (PhysicalStateId::GrindFiftyFifty, 1732), + (PhysicalStateId::GrindTipslide, 1736), + (PhysicalStateId::GrindFiveO, 1740), + (PhysicalStateId::GrindBackslash, 1744), + (PhysicalStateId::GrindDarkslide, 1724), + (PhysicalStateId::BipedGround, 1764), + (PhysicalStateId::BipedAir, 1768), + (PhysicalStateId::OffBoardPushing, 1776), + (PhysicalStateId::LandingOnDeck, 1792), + (PhysicalStateId::HandPlant, 1780), + (PhysicalStateId::FootPlant, 1784), + (PhysicalStateId::Boneless, 1788), + (PhysicalStateId::Sleeping, 1692), + (PhysicalStateId::Nonspecific, 1752), + (PhysicalStateId::Teleporting, 1756), + ]; + + for (requested, owner_offset) in expected { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::Sleeping); + let mut data = seeded_data(); + let mut states = recorder(PhysicalStateId::Sleeping); + let mut controller = recorder(PhysicalStateId::Sleeping); + let selected = lifecycle + .set_physics_state(requested as u32, &mut data, &mut states, &mut controller) + .unwrap(); + assert_eq!( + selected, + StateBinding { + state: requested, + owner_offset + } + ); + assert_eq!( + states.events.last(), + Some(&Event::Enter(StateCall { + state: selected, + active: selected, + })) + ); + } +} + +#[test] +fn invalid_ids_fail_before_callbacks_writes_or_pointer_changes() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let active_before = lifecycle.active(); + let mut data = seeded_data(); + let data_before = data; + let mut states = recorder(PhysicalStateId::PhysicsGround); + let mut controller = recorder(PhysicalStateId::PhysicsGround); + + assert_eq!( + lifecycle.set_physics_state(203, &mut data, &mut states, &mut controller), + Err(UnknownPhysicalState(203)) + ); + assert_eq!(lifecycle.active(), active_before); + assert_eq!(data, data_before); + assert!(states.events.is_empty()); + assert!(controller.events.is_empty()); +} + +#[test] +fn offboard_entry_chooses_held_or_free_mode_from_native_flag_bits() { + let mut held_lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let mut held_data = seeded_data(); + held_data.skateboard_controller.state_448 = 4; + let mut held_states = recorder(PhysicalStateId::PhysicsGround); + let mut held_actions = recorder(PhysicalStateId::PhysicsGround); + held_lifecycle + .set_physics_state( + PhysicalStateId::BipedGround as u32, + &mut held_data, + &mut held_states, + &mut held_actions, + ) + .unwrap(); + assert_eq!(held_actions.events, [Event::Hold]); + assert_eq!(held_data.skateboard_controller.word_444, 0); + assert_eq!(held_data.skateboard_controller.state_448, 1); + assert!(held_data.skateboard_controller.system_on_452); + + let mut free_lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let mut free_data = seeded_data(); + free_data.processed.flags_2480 = 0x80; + free_data.skateboard_controller.state_448 = 1; + let mut free_states = recorder(PhysicalStateId::PhysicsGround); + let mut free_actions = recorder(PhysicalStateId::PhysicsGround); + free_lifecycle + .set_physics_state( + PhysicalStateId::OffBoardPushing as u32, + &mut free_data, + &mut free_states, + &mut free_actions, + ) + .unwrap(); + assert_eq!(free_actions.events, [Event::LetGo]); + assert_eq!(free_data.skateboard_controller.word_444, 0); + assert_eq!(free_data.skateboard_controller.state_448, 2); + assert!(free_data.skateboard_controller.system_on_452); +} + +#[test] +fn onboard_entry_runs_stop_controller_before_state_callbacks() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::BipedGround); + let mut data = seeded_data(); + data.skateboard_controller = SkateboardControllerFields { + word_444: 99, + state_448: 1, + system_on_452: true, + }; + let events = Rc::new(RefCell::new(Vec::new())); + let mut states = SharedRecorder { + reported_type: PhysicalStateId::BipedGround, + events: events.clone(), + }; + let mut actions = states.clone(); + + lifecycle + .set_physics_state( + PhysicalStateId::PhysicsGround as u32, + &mut data, + &mut states, + &mut actions, + ) + .unwrap(); + + assert_eq!(data.skateboard_controller.word_444, 0); + assert_eq!(data.skateboard_controller.state_448, 0); + assert!(!data.skateboard_controller.system_on_452); + let old = StateBinding::new(PhysicalStateId::BipedGround); + let new = StateBinding::new(PhysicalStateId::PhysicsGround); + assert_eq!( + *events.borrow(), + [ + Event::LetGo, + Event::GetType(old), + Event::Exit(StateCall { + state: old, + active: old, + }), + Event::Enter(StateCall { + state: new, + active: new, + }), + ] + ); +} + +#[test] +fn an_already_running_offboard_controller_is_left_untouched() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::BipedGround); + let mut data = seeded_data(); + data.processed.flags_2480 = 0x180; + data.skateboard_controller = SkateboardControllerFields { + word_444: 99, + state_448: 1, + system_on_452: true, + }; + let mut states = recorder(PhysicalStateId::BipedGround); + let mut actions = recorder(PhysicalStateId::BipedGround); + + lifecycle + .set_physics_state( + PhysicalStateId::BipedAir as u32, + &mut data, + &mut states, + &mut actions, + ) + .unwrap(); + + assert!(actions.events.is_empty()); + assert_eq!( + data.skateboard_controller, + SkateboardControllerFields { + word_444: 99, + state_448: 1, + system_on_452: true, + } + ); +} diff --git a/crates/skate-core/src/player/tests/logic.rs b/crates/skate-core/src/player/tests/logic.rs new file mode 100644 index 00000000..3929ed5d --- /dev/null +++ b/crates/skate-core/src/player/tests/logic.rs @@ -0,0 +1,180 @@ +use super::*; +use crate::player::{ + lifecycle::{ + PhysicalStateCalls, PlayerStateChangeFields, ProcessedStateChangeFields, + SkateboardControllerActions, SkateboardControllerFields, StateCall, StateChangeData, + }, + selector::{ + ProcessedStateInput, + conditions::{BoardBodyState, SkeletonAnimationState, TwoStageThresholds}, + }, + state::PhysicalStateId as State, +}; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + GetType(State), + Exit(State), + Enter(State), +} + +struct States { + events: Rc>>, +} + +impl PhysicalStateCalls for States { + fn get_type(&mut self, state: StateBinding) -> State { + self.events.borrow_mut().push(Event::GetType(state.state)); + state.state + } + + fn exit(&mut self, call: StateCall) { + self.events.borrow_mut().push(Event::Exit(call.state.state)); + } + + fn enter(&mut self, call: StateCall) { + self.events + .borrow_mut() + .push(Event::Enter(call.state.state)); + } +} + +struct Controller; + +impl SkateboardControllerActions for Controller { + fn hold_skateboard(&mut self) {} + fn let_go_of_skateboard(&mut self) {} +} + +fn input() -> StateSelectionInput { + let thresholds = TwoStageThresholds { + field_856_primary: 1.0, + field_856_secondary: 1.0, + field_7692: 1.0, + }; + StateSelectionInput { + processed: ProcessedStateInput { + grind_type_1248: 6, + grind_candidate_1488: false, + field_1776: 0, + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + flags_2488: 0, + category_2512: 100, + wheel_contact_count_2556: 0, + field_2572: 0, + state_timer_2664: 0.0, + field_2732: 0.0, + field_2744: 0.0, + trajectory_collision_time_2772: 0.0, + grind_investigation_flags_1516: 0, + }, + skateboard_contact_count_869: 0, + board_body: BoardBodyState { + field_856: 0.0, + field_7692: 0.0, + }, + skeleton: SkeletonAnimationState { + mode_16420: 1, + back_chain_lane_12656: 0.0, + threshold_800: 1.0, + }, + skitching_off_ground: thresholds, + normal_off_ground: thresholds, + } +} + +fn change_data() -> StateChangeData { + StateChangeData { + player: PlayerStateChangeFields { + word_1312: 1, + previous_category_latch_1336: 0, + scalar_1344: 1.0, + }, + processed: ProcessedStateChangeFields { + flags_2480: 0, + requested_state_2500: 0, + previous_state_2504: 0, + current_state_2508: 100, + current_category_2512: 100, + previous_category_2516: 0, + previous_category_latch_2520: 0, + word_2564: 1, + scalar_2664: 1.0, + }, + skateboard_controller: SkateboardControllerFields { + word_444: 0, + state_448: 0, + system_on_452: false, + }, + } +} + +fn staging() -> PhysicsOutputStaging { + PhysicsOutputStaging { + bytes_20_through_53: [0xaa; 34], + scalars_56_through_188: [1.0; 34], + word_200: 7, + } +} + +#[test] +fn unchanged_state_calls_get_type_once_and_still_clears_output_staging() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut logic = PhysicalPlayerLogic::new(State::PhysicsGround); + let mut data = change_data(); + let mut output = staging(); + let mut states = States { + events: events.clone(), + }; + let result = logic + .run( + &input(), + &mut data, + &mut output, + &mut states, + &mut Controller, + ) + .unwrap(); + + assert!(!result.changed); + assert_eq!(*events.borrow(), [Event::GetType(State::PhysicsGround)]); + assert_eq!(logic.frames_since_jump_1304, 0); + assert_eq!(output.bytes_20_through_53, [0; 34]); + assert_eq!(output.scalars_56_through_188, [0.0; 34]); + assert_eq!(output.word_200, 0); +} + +#[test] +fn state_edge_runs_native_transition_and_sets_100() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut logic = PhysicalPlayerLogic::new(State::PhysicsGround); + let mut frame = input(); + frame.processed.flags_2480 = 0x1000; + let mut data = change_data(); + let mut output = staging(); + let mut states = States { + events: events.clone(), + }; + let result = logic + .run(&frame, &mut data, &mut output, &mut states, &mut Controller) + .unwrap(); + + assert!(result.changed); + assert_eq!(result.selected.state, State::LandingOnDeck); + assert_eq!(logic.frames_since_jump_1304, 100); + assert_eq!( + *events.borrow(), + [ + Event::GetType(State::PhysicsGround), + Event::GetType(State::PhysicsGround), + Event::Exit(State::PhysicsGround), + Event::Enter(State::LandingOnDeck), + ] + ); + assert_eq!(data.processed.current_state_2508, 503); +} diff --git a/crates/skate-core/src/player/tests/post_input.rs b/crates/skate-core/src/player/tests/post_input.rs new file mode 100644 index 00000000..1ac13529 --- /dev/null +++ b/crates/skate-core/src/player/tests/post_input.rs @@ -0,0 +1,191 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + Grind, + Trajectory, + Scalar, + Register(CandidateRegistration), +} + +struct Services { + events: Vec, + trajectory: u8, + scalar: f32, +} + +impl PostInputServices for Services { + fn update_grind_manager_82d8ab08(&mut self) { + self.events.push(Event::Grind); + } + + fn update_trajectory_selector_82d68800(&mut self) -> u8 { + self.events.push(Event::Trajectory); + self.trajectory + } + + fn calculate_scalar_2740_82db5e10(&mut self) -> f32 { + self.events.push(Event::Scalar); + self.scalar + } + + fn register_candidate_82762ab0(&mut self, registration: CandidateRegistration) { + self.events.push(Event::Register(registration)); + } +} + +fn player() -> PostInputPlayerFields { + PostInputPlayerFields { + jump_reference_1264: [1, 2, 3, 4], + flags_1296: 0, + state_frames_1304: 4, + jump_fix_frames_1308: 8, + latch_frames_1320: 0, + } +} + +fn processed() -> PostInputProcessedFields { + PostInputProcessedFields { + jump_reference_848: [0; 4], + word_2464: 0, + flags_2468: 0, + flags_2472: 0, + flags_2480: 0, + flags_2484: 0, + current_state_2508: 100, + state_frames_2572: 0, + jump_fix_frames_2576: 0, + scalar_2740: 0.0, + } +} + +fn phys_out() -> PostInputPhysOutFields { + PostInputPhysOutFields { + reset_state_frames_316: false, + capture_jump_reference_442: false, + jump_reference_128: [10, 11, 12, 13], + complete_76: false, + } +} + +fn candidates() -> CandidatePublicationFields { + CandidatePublicationFields { + first_object_present_196: false, + first_pending_288: false, + second_object_present_500: false, + second_pending_592: false, + staged_word_12768: 0, + staged_valid_12772: 0, + staged_latched_12776: 7, + staged_pending_12780: false, + } +} + +#[test] +fn phase_preserves_service_order_and_native_counter_publication() { + let mut player = player(); + let mut processed = processed(); + let mut output = phys_out(); + output.reset_state_frames_316 = true; + output.capture_jump_reference_442 = true; + let mut candidates = candidates(); + candidates.first_object_present_196 = true; + candidates.first_pending_288 = true; + candidates.second_object_present_500 = true; + candidates.second_pending_592 = true; + let mut services = Services { + events: Vec::new(), + trajectory: 3, + scalar: f32::from_bits(0x3E80_0000), + }; + + run_post_input( + PostInputContext { + player: &mut player, + processed: &mut processed, + phys_out: &mut output, + candidates: &mut candidates, + }, + &mut services, + ); + + assert_eq!( + services.events, + [ + Event::Grind, + Event::Trajectory, + Event::Scalar, + Event::Register(CandidateRegistration::First1888), + Event::Register(CandidateRegistration::Second2176), + ] + ); + assert_eq!(player.state_frames_1304, 1); + assert_eq!(processed.state_frames_2572, 1); + assert_eq!(player.jump_fix_frames_1308, 1); + assert_eq!(processed.jump_fix_frames_2576, 1); + assert_eq!(player.jump_reference_1264, output.jump_reference_128); + assert_eq!(processed.jump_reference_848, output.jump_reference_128); + assert_eq!(processed.flags_2468 & 0x400, 0x400); + assert_eq!(processed.scalar_2740.to_bits(), 0x3E80_0000); + assert_eq!(processed.flags_2480 & 0x0060_0000, 0x0060_0000); + assert!(!candidates.first_pending_288); + assert!(!candidates.second_pending_592); + assert!(output.complete_76); +} + +#[test] +fn reset_branch_clears_state_latch_and_staged_candidate_follows_validity() { + let mut player = player(); + player.flags_1296 = 0x4000_0000; + let mut processed = processed(); + processed.flags_2468 = 0x0040_0000; + let mut output = phys_out(); + let mut candidates = candidates(); + candidates.staged_word_12768 = 0x1122_3344; + candidates.staged_valid_12772 = 9; + candidates.staged_pending_12780 = true; + let mut services = Services { + events: Vec::new(), + trajectory: 0, + scalar: 0.0, + }; + + run_post_input( + PostInputContext { + player: &mut player, + processed: &mut processed, + phys_out: &mut output, + candidates: &mut candidates, + }, + &mut services, + ); + + assert_eq!(player.state_frames_1304, 0); + assert_eq!(processed.state_frames_2572, 0); + assert_eq!(player.flags_1296 & 0x4000_0000, 0); + assert_eq!(candidates.staged_latched_12776, 1); + assert_eq!(processed.flags_2480 & 0x0010_0000, 0x0010_0000); + assert_eq!(processed.word_2464, 0x1122_3344); + assert!(!candidates.staged_pending_12780); +} + +#[test] +fn latch_helper_preserves_the_twenty_frame_strict_expiry_and_mirrors_bits() { + let mut player = player(); + player.flags_1296 = 0x0600_0000; + player.latch_frames_1320 = 20; + let mut processed = processed(); + + update_flag_latches_82db5cf8(&mut player, &mut processed); + assert_eq!(player.latch_frames_1320, 21); + assert_eq!(player.flags_1296 & 0x0600_0000, 0x0600_0000); + + update_flag_latches_82db5cf8(&mut player, &mut processed); + assert_eq!(player.flags_1296 & 0x0600_0000, 0); + assert_eq!(processed.flags_2472 & 0x0000_000C, 0); + + processed.flags_2472 = 0x0000_8010; + processed.flags_2480 = 0x0002_0000; + update_flag_latches_82db5cf8(&mut player, &mut processed); + assert_eq!(player.flags_1296 & 0x1802_0000, 0x1802_0000); +} diff --git a/crates/skate-core/src/player/tests/post_output.rs b/crates/skate-core/src/player/tests/post_output.rs new file mode 100644 index 00000000..25de0179 --- /dev/null +++ b/crates/skate-core/src/player/tests/post_output.rs @@ -0,0 +1,30 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + Player, + Skeleton, +} + +#[derive(Default)] +struct Services(Vec); + +impl PostOutputServices for Services { + fn finish_player_output_82909510(&mut self, _: &mut PlayerOutputField1392) { + self.0.push(Event::Player); + } + + fn finish_skeleton_output_82be2ba8(&mut self) { + self.0.push(Event::Skeleton); + } +} + +#[test] +fn preserves_the_complete_native_call_order() { + let mut field = PlayerOutputField1392; + let mut services = Services::default(); + + run_post_output(&mut field, &mut services); + + assert_eq!(services.0, [Event::Player, Event::Skeleton]); +} diff --git a/crates/skate-core/src/player/tests/post_state.rs b/crates/skate-core/src/player/tests/post_state.rs new file mode 100644 index 00000000..128c74cd --- /dev/null +++ b/crates/skate-core/src/player/tests/post_state.rs @@ -0,0 +1,21 @@ +use super::*; + +#[derive(Default)] +struct Services { + calls: u32, +} + +impl PostStateServices for Services { + fn update_current_state_vtable_12(&mut self) { + self.calls += 1; + } +} + +#[test] +fn dispatches_the_current_state_post_state_slot_once() { + let mut services = Services::default(); + + run_post_state(&mut services); + + assert_eq!(services.calls, 1); +} diff --git a/crates/skate-core/src/player/tests/pre_state.rs b/crates/skate-core/src/player/tests/pre_state.rs new file mode 100644 index 00000000..f1c920aa --- /dev/null +++ b/crates/skate-core/src/player/tests/pre_state.rs @@ -0,0 +1,80 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + Fill, + Component, + State, +} + +struct Services { + events: Vec, + vector: [u32; 4], +} + +impl PreStateServices for Services { + fn fill_packet_vtable_24(&mut self, packet: &mut PreStatePacket) { + self.events.push(Event::Fill); + packet.words[12..16].copy_from_slice(&self.vector); + } + + fn update_component_1840_82d74270(&mut self) { + self.events.push(Event::Component); + } + + fn update_before_state_vtable_4(&mut self) { + self.events.push(Event::State); + } +} + +#[test] +fn publishes_packet_vector_and_preserves_full_native_call_order() { + let mut player = PreStatePlayerFields { + frame_counter_1312: 9, + component_1840_present: true, + }; + let mut skeleton = PreStateSkeletonFields { + predicted_position_16112: [0; 4], + predicted_position_set_16416: false, + nested_flag_3184: true, + }; + let vector = [0x3F80_0000, 0xC000_0000, 0x4040_0000, 0]; + let mut services = Services { + events: Vec::new(), + vector, + }; + + run_pre_state(&mut player, &mut skeleton, &mut services); + + assert_eq!(player.frame_counter_1312, 10); + assert_eq!(skeleton.predicted_position_16112, vector); + assert!(skeleton.predicted_position_set_16416); + assert!(!skeleton.nested_flag_3184); + assert_eq!( + services.events, + [Event::Fill, Event::Component, Event::State] + ); +} + +#[test] +fn absent_component_skips_only_82d74270_and_counter_wraps() { + let mut player = PreStatePlayerFields { + frame_counter_1312: u32::MAX, + component_1840_present: false, + }; + let mut skeleton = PreStateSkeletonFields { + predicted_position_16112: [7; 4], + predicted_position_set_16416: false, + nested_flag_3184: true, + }; + let mut services = Services { + events: Vec::new(), + vector: [1, 2, 3, 4], + }; + + run_pre_state(&mut player, &mut skeleton, &mut services); + + assert_eq!(player.frame_counter_1312, 0); + assert_eq!(services.events, [Event::Fill, Event::State]); + assert_eq!(skeleton.predicted_position_16112, [1, 2, 3, 4]); +} diff --git a/crates/skate-core/src/player/tests/state_phase.rs b/crates/skate-core/src/player/tests/state_phase.rs new file mode 100644 index 00000000..1d88ea7e --- /dev/null +++ b/crates/skate-core/src/player/tests/state_phase.rs @@ -0,0 +1,117 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + State, + Controller, + Mode(u32), + BoardTouch, + Forces, + FixedStep, +} + +#[derive(Default)] +struct Services(Vec); + +impl StatePhaseServices for Services { + fn update_current_state_vtable_8(&mut self) { + self.0.push(Event::State); + } + + fn update_controller_82d75f00(&mut self) { + self.0.push(Event::Controller); + } + + fn update_controller_mode_1_82d751d8(&mut self) { + self.0.push(Event::Mode(1)); + } + + fn update_controller_mode_2_82d750f0(&mut self) { + self.0.push(Event::Mode(2)); + } + + fn update_controller_mode_3_82d75bb8(&mut self) { + self.0.push(Event::Mode(3)); + } + + fn update_controller_mode_4_82d75d58(&mut self) { + self.0.push(Event::Mode(4)); + } + + fn touch_skateboard_vtable_116(&mut self) { + self.0.push(Event::BoardTouch); + } + + fn apply_skateboard_force_queue_82c03718(&mut self) { + self.0.push(Event::Forces); + } + + fn update_skateboard_fixed_step_cache_82db61f0(&mut self) { + self.0.push(Event::FixedStep); + } +} + +#[test] +fn active_controller_mode_runs_between_state_and_board_work() { + for mode in 1..=4 { + let mut fields = StatePhaseFields { + elapsed_1344: 1.0, + timestep_2604: f32::from_bits(0x3C88_8889), + controller_state_448: mode, + controller_system_on_452: true, + }; + let mut services = Services::default(); + + run_state_phase(&mut fields, &mut services); + + assert_eq!( + services.0, + [ + Event::State, + Event::Controller, + Event::Mode(mode), + Event::BoardTouch, + Event::Forces, + Event::FixedStep, + ] + ); + assert_eq!( + fields.elapsed_1344.to_bits(), + (1.0f32 + f32::from_bits(0x3C88_8889)).to_bits() + ); + } +} + +#[test] +fn inactive_or_unknown_controller_skips_only_controller_calls() { + for (system_on, mode) in [(false, 2), (true, 0), (true, 5)] { + let mut fields = StatePhaseFields { + elapsed_1344: -0.0, + timestep_2604: 0.0, + controller_state_448: mode, + controller_system_on_452: system_on, + }; + let mut services = Services::default(); + + run_state_phase(&mut fields, &mut services); + + let expected = if system_on { + vec![ + Event::State, + Event::Controller, + Event::BoardTouch, + Event::Forces, + Event::FixedStep, + ] + } else { + vec![ + Event::State, + Event::BoardTouch, + Event::Forces, + Event::FixedStep, + ] + }; + assert_eq!(services.0, expected); + assert_eq!(fields.elapsed_1344.to_bits(), 0); + } +} diff --git a/crates/skate-core/src/player/wipeout/air.rs b/crates/skate-core/src/player/wipeout/air.rs new file mode 100644 index 00000000..b564f66c --- /dev/null +++ b/crates/skate-core/src/player/wipeout/air.rs @@ -0,0 +1,95 @@ +//! Complete Air checks82D90358 including bad landing82D90D08. +use super::{common, Frame, Mode, Requests, Settings}; +use crate::physics::native_arithmetic::dot3; +pub fn check(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame, use_com: bool) { + if state.mode != 2 { + state.mode = 2; + state.contact_frames = 0; + } + if f.board_contact { + state.contact_frames = 4; + } + if state.contact_frames > 0 { + state.contact_frames -= 1; + } + if state.cooldown > 0.0 { + state.cooldown -= f.timestep; + return; + } + common::vehicle(state, s, f); + let a = &s.air; + let body_flip = f.flags_2476 & (1 << 26) != 0; + let scalar = if body_flip { a.body_flip_scalar } else { 1.0 }; + let deck_scalar = if body_flip { + a.body_flip_acc_scalar + } else { + 1.0 + }; + let (mut xz, y) = if f.flags_2472 & (1 << 15) != 0 && f.jump_fix_frames > a.ignore_danger_frames + { + (a.xz_trick, a.y_trick) + } else if f.wheel_contact { + (mode.ground_xz * deck_scalar, s.ground.y_acceleration) + } else { + (a.xz_acceleration * deck_scalar, a.y_acceleration) + }; + if f.board_material_flags & (1 << 29) != 0 { + xz *= a.light_dmo_scalar; + } + common::closing(state, f, xz, y); + let displacement = a.max_displacement * scalar; + if mode.check_squash && f.maximum_pose_error > a.max_squash * scalar { + state.request(18, 0.0); + } else if dot3(f.pose_error, f.pose_error) > displacement * displacement { + state.request(1, 0.0); + } else if common::force(f, a.max_contact * scalar, a.max_arm_contact) { + state.request(0, 0.0); + } + if f.flip_active && f.flip_requested_speed == 0.0 && f.system_up_y < 0.0 { + state.request(4, 0.0); + } + bad_landing(state, s, mode, f, use_com); + common::leaning(state, s, f); + //Two independent sensitive conditions can request21 twice. + if f.flags_2476 & (1 << 26) != 0 && f.compliant { + state.request(21, 0.0); + } + if f.flags_2472 & (1 << 6) != 0 && (f.compliant || f.board_contact) { + state.request(21, 0.0); + } +} +fn bad_landing(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame, use_com: bool) { + if !mode.check_bad_landing || !f.board_contact { + return; + } + let velocity = if use_com { + f.com_velocity + } else { + f.deck_velocity + }; + let mut normal = f.board_contact_normal; + if f.grind_selected { + if f.grind_normal_valid { + normal = f.grind_normal; + } + if -dot3(normal, velocity) > s.air.max_grind_speed * mode.bad_landing_scale { + state.request(8, 0.0); + } + } else { + let into = -dot3(normal, velocity); + let tangent = std::array::from_fn(|i| normal[i].mul_add(into, velocity[i])); + let limit = + s.air.max_landing_angle.evaluate(common::length(tangent)) * mode.bad_landing_scale; + if f.landing_angle.abs() > limit { + state.request(5, 0.0); + } + let limit = if f.board_material_flags & (1 << 31) != 0 { + s.air.max_stairs_speed + } else { + s.air.max_landing_speed + }; + if into > limit * mode.bad_landing_scale { + state.request(6, into); + } + } +} diff --git a/crates/skate-core/src/player/wipeout/common.rs b/crates/skate-core/src/player/wipeout/common.rs new file mode 100644 index 00000000..9b8883f3 --- /dev/null +++ b/crates/skate-core/src/player/wipeout/common.rs @@ -0,0 +1,60 @@ +use super::{Frame, Requests, Settings}; +use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic::dot3}; +pub(super) fn length(v: [f32; 4]) -> f32 { + let sq = dot3(v, v); + let inv = inverse_length_squared(sq, 2); + if sq == 0.0 { + 0.0 + } else { + sq * inv + } +} +fn selected_max(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { + a + } else { + b + } +} +///82BD88A0 reads unweighted regional forces in this exact pair order. +pub(super) fn force(frame: &Frame, body: f32, arms: f32) -> bool { + let f = frame.regions_force; + let a = selected_max(f[0], f[1]); + let b = selected_max(f[5], f[4]); + let c = selected_max(f[7], f[6]); + selected_max(selected_max(a, b), c) > body || selected_max(f[2], f[3]) > arms +} +pub(super) fn vehicle(state: &mut Requests, s: &Settings, f: &Frame) { + let limit = if f.flags_2476 & 8 != 0 { + s.ground.skitch_contact + } else { + s.ground.vehicle_contact + }; + if f.vehicle_force > limit { + state.request(7, 0.0); + } +} +///82D90AB8 rotates closing velocity656 by the actual WorldToAnim basis. +pub(super) fn closing(state: &mut Requests, f: &Frame, xz: f32, y: f32) { + if !f.board_contact { + return; + } + let v = f.closing_velocity; + let m = f.world_to_animation; + let local: [f32; 4] = + std::array::from_fn(|i| m[2][i].mul_add(v[2], m[1][i].mul_add(v[1], m[0][i] * v[0]))); + if length([local[0], 0.0, local[2], local[3]]) > xz || local[1].abs() > y { + state.request(2, 0.0); + if force(f, 1.0, 20.0) { + state.request(0, 0.0); + } + } +} +pub(super) fn leaning(state: &mut Requests, s: &Settings, f: &Frame) { + if f.animation_up[1] < s.lean_contact_y + && f.compliant + && f.highest_normal[1] > f32::from_bits(0x3f34_fdf4) + { + state.request(19, 0.0); + } +} diff --git a/crates/skate-core/src/player/wipeout/data.rs b/crates/skate-core/src/player/wipeout/data.rs new file mode 100644 index 00000000..df34b69b --- /dev/null +++ b/crates/skate-core/src/player/wipeout/data.rs @@ -0,0 +1,109 @@ +//! Actual observation values consumed by Wipeout82D8F9E0/82D90358. +use crate::{physics::skeleton_animation_record::AnimationPartTransform, point_graph::PointGraph}; +pub type V = [f32; 4]; +#[derive(Clone, Copy, Debug)] +pub struct Frame { + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub category: u32, + pub timestep: f32, + pub time_on_ground: f32, + pub speed: f32, + pub animation_up: V, + pub landing_angle: f32, + pub deck_velocity: V, + pub com_velocity: V, + pub jump_fix_frames: i32, + ///Actual solver deck pose and the retained Processed0/16/32 basis. + pub deck: AnimationPartTransform, + pub input_board: AnimationPartTransform, + pub world_to_animation: AnimationPartTransform, + pub closing_velocity: V, + pub board_material_flags: u32, + pub board_contact: bool, + pub wheel_contact: bool, + pub board_contact_normal: V, + pub opposing_contact: f32, + pub regions_force: [f32; 8], + pub maximum_skater_force: f32, + pub vehicle_force: f32, + pub group_8: bool, + pub conflicting: bool, + pub compliant: bool, + pub highest_normal: V, + pub pose_error: V, + pub maximum_pose_error: f32, + pub flip_active: bool, + pub flip_requested_speed: f32, + pub system_up_y: f32, + pub grind_selected: bool, + pub grind_normal_valid: bool, + pub grind_normal: V, +} +#[derive(Clone, Copy)] +pub struct Mode { + pub check_squash: bool, + pub check_bad_landing: bool, + pub ground_xz: f32, + pub bad_landing_scale: f32, +} +pub struct GroundSettings { + pub vehicle_scalar: f32, + pub vehicle_contact: f32, + pub skitch_contact: f32, + pub skitch_scalar: f32, + pub skitch_arms_scalar: f32, + pub skater_scalar: f32, + pub max_squash: f32, + pub max_squash_coffin: f32, + pub max_displacement: f32, + pub max_contact: f32, + pub max_arm_contact: f32, + pub max_deck_error: f32, + pub opposing_contact: f32, + pub y_acceleration: f32, + pub light_dmo_scalar: f32, + pub player_scalar: f32, + pub ai_scalar: f32, + pub skitch_acc_scalar: f32, + pub balance_total: f32, + pub balance_min_speed: f32, + pub balance_base: f32, +} +pub struct AirSettings { + pub xz_trick: f32, + pub y_trick: f32, + pub xz_acceleration: f32, + pub y_acceleration: f32, + pub max_squash: f32, + pub max_displacement: f32, + pub max_contact: f32, + pub max_arm_contact: f32, + pub body_flip_scalar: f32, + pub body_flip_acc_scalar: f32, + pub light_dmo_scalar: f32, + pub ignore_danger_frames: i32, + pub max_landing_speed: f32, + pub max_stairs_speed: f32, + pub max_grind_speed: f32, + ///Layout0 PointNegGraph; original directly reads X16/Y48. + pub max_landing_angle: PointGraph<8>, +} +pub struct Settings { + pub ground: GroundSettings, + pub air: AirSettings, + pub lean_contact_y: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct RequestInput { + pub flags_2468: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub animation_up_y: f32, + pub category: u32, +} diff --git a/crates/skate-core/src/player/wipeout/ground.rs b/crates/skate-core/src/player/wipeout/ground.rs new file mode 100644 index 00000000..c762b1ed --- /dev/null +++ b/crates/skate-core/src/player/wipeout/ground.rs @@ -0,0 +1,128 @@ +//! Complete original Ground checks82D8F9E0 and all its nonexternal leaves. +use super::{common, Frame, Mode, Requests, Settings}; +use crate::{ + physics::native_arithmetic::{dot3, vector_max, vector_min}, + trigonometry, +}; +pub fn check(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame) { + state.enter_ground(); + if state.cooldown > 0.0 { + state.cooldown -= f.timestep; + return; + } + let g = &s.ground; + let vehicle = if f.group_8 { g.vehicle_scalar } else { 1.0 }; + let trick = if f.flags_2472 & (1 << 15) != 0 { + f32::from_bits(0x3e99_999a) + } else { + 1.0 + }; + let skitch = if f.flags_2476 & 8 != 0 { + g.skitch_scalar + } else { + 1.0 + }; + let arms = if f.flags_2476 & 8 != 0 { + g.skitch_arms_scalar + } else { + 1.0 + }; + let skater = if f.flags_2472 & (1 << 28) == 0 && f.maximum_skater_force > 0.0 { + g.skater_scalar + } else { + 1.0 + }; + let scale = ((vehicle * trick) * skitch) * skater; + let squash = if f.flags_2476 & (1 << 30) != 0 { + g.max_squash_coffin + } else { + g.max_squash + }; + let displacement = g.max_displacement * scale; + if (mode.check_squash || f.time_on_ground > f32::from_bits(0x3d4c_cccd)) + && f.maximum_pose_error > vehicle * squash + { + state.request(18, 0.0); + } else if dot3(f.pose_error, f.pose_error) > displacement * displacement { + state.request(1, 0.0); + } else if common::force(f, g.max_contact * scale, g.max_arm_contact * arms) { + state.request(0, 0.0); + } + common::vehicle(state, s, f); + if f.flags_2484 & (1 << 21) == 0 { + let cosine = vector_min( + vector_min( + dot3(f.deck[0], f.input_board[0]), + dot3(f.deck[1], f.input_board[1]), + ), + dot3(f.deck[2], f.input_board[2]), + ); + if trigonometry::acos(vector_min(vector_max(cosine, -1.0), 1.0)) > g.max_deck_error { + state.request(3, 0.0); + } + } + let (xz, y) = if f.flags_2472 & (1 << 15) != 0 { + (s.air.xz_trick, s.air.y_trick) + } else { + let (mut xz, mut y) = (mode.ground_xz, g.y_acceleration); + if f.board_material_flags & (1 << 30) != 0 { + let scalar = if f.flags_2472 & (1 << 28) != 0 { + g.ai_scalar + } else { + g.player_scalar + }; + xz *= scalar; + y *= scalar; + } + if f.board_material_flags & (1 << 29) != 0 { + xz *= g.light_dmo_scalar; + } + if f.flags_2476 & 8 != 0 { + xz *= g.skitch_acc_scalar; + y *= g.skitch_acc_scalar; + } + if f.flags_2484 & (1 << 21) != 0 { + xz = 0.0; + y = 0.0; + } + (xz, y) + }; + common::closing(state, f, xz, y); + let skitch_gate = if f.flags_2476 & (1 << 23) != 0 { + 0.0 + } else { + 1.0 + }; + let slide_gate = if f.flags_2468 & (1 << 5) != 0 { + 0.0 + } else { + 1.0 + }; + let speed = (g.balance_min_speed - f.speed) / g.balance_min_speed; + let amount = (speed * (1.0 - f.animation_up[1])) * slide_gate; + let delta = amount.mul_add(skitch_gate, -g.balance_base); + state.balance = if delta > 0.0 { + state.balance + delta + } else { + 0.0 + }; + if state.balance > g.balance_total { + state.request(11, 0.0); + } + if f.conflicting { + state.request(16, 0.0); + } + common::leaning(state, s, f); +} + +///GroundAnimation82D8F918. These additional checks run even when the shared +///ground check returns during its cooldown. Caller82D34140 supplies scale1. +pub fn check_animation(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame, scale: f32) { + check(state, s, mode, f); + if super::common::force(f, s.air.max_contact, s.air.max_arm_contact) { + state.request(0, 0.0); + } + if f.opposing_contact > s.ground.opposing_contact * scale { + state.request(20, 0.0); + } +} diff --git a/crates/skate-core/src/player/wipeout/mod.rs b/crates/skate-core/src/player/wipeout/mod.rs new file mode 100644 index 00000000..95a1dfdb --- /dev/null +++ b/crates/skate-core/src/player/wipeout/mod.rs @@ -0,0 +1,14 @@ +//! Original TU3 shared Wipeout request calculation and persistent history. +//! Physical bail/recovery state response is scheduled by the game coordinator. +mod air; +mod common; +mod data; +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 requests::Requests; + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/wipeout/requests.rs b/crates/skate-core/src/player/wipeout/requests.rs new file mode 100644 index 00000000..6326f92b --- /dev/null +++ b/crates/skate-core/src/player/wipeout/requests.rs @@ -0,0 +1,71 @@ +//! Original request arrays20/56,count200,mode208 and timers192/196/204. +#[derive(Clone, Debug, PartialEq)] +pub struct Requests { + pub reasons: [bool; 34], + pub values: [f32; 34], + pub count: u32, + pub cooldown: f32, + pub contact_frames: i32, + pub balance: f32, + pub mode: u32, +} +impl Requests { + ///Player ctor82DB20B8..D8 and first ClearRequests initialize this storage. + pub fn new() -> Self { + Self { + reasons: [false; 34], + values: [0.0; 34], + count: 0, + cooldown: 0.0, + contact_frames: 0, + balance: 0.0, + mode: 0, + } + } + ///Player initialization82DB3024 supplies the short initial grace period. + pub fn initialize_player(&mut self) { + self.cooldown = f32::from_bits(0x3d23_d70a); + } + ///Teleport82DB8DB4 overwrites only the cooldown; ResetSystems is separate. + pub fn teleport(&mut self) { + self.cooldown = f32::from_bits(0x3ecc_cccd); + } + ///Ground Enter82D378E0..F4 and CheckForGroundWipeout share this state. + pub fn enter_ground(&mut self) { + if self.mode != 1 { + self.balance = 0.0; + self.mode = 1; + } + } + ///A repeated request increments the original counter even if its bit was set. + pub fn request(&mut self, index: usize, value: f32) { + self.reasons[index] = true; + self.values[index] = value; + self.count = self.count.wrapping_add(1); + } + ///SystemLogic82DB6000 after state selection: preserve cooldown and histories. + pub fn clear_after_selection(&mut self) { + self.reasons.fill(false); + self.values.fill(0.0); + self.count = 0; + } + ///ResetSystems82DB9354..9360 clears mode and count only. + pub fn reset_systems(&mut self) { + self.mode = 0; + self.count = 0; + } + ///82DB9188: count reflects individual triggers, not unique reasons. + pub fn requests_runout(&self, frame: &super::RequestInput) -> bool { + (self.count == 1 + && (self.reasons[2] || self.reasons[24] || self.reasons[10]) + && frame.flags_2468 & (1 << 5) == 0 + && frame.flags_2476 & (1 << 26) == 0 + && frame.animation_up_y.abs() > f32::from_bits(0x3f4f_5c29)) + || (frame.flags_2484 & (1 << 12) != 0 && frame.category == 400) + } + ///82DB9100 first excludes runout, then combines reason OR with2480bit3. + pub fn requests_wipeout(&self, frame: &super::RequestInput) -> bool { + !self.requests_runout(frame) + && (self.reasons.iter().any(|&v| v) || frame.flags_2480 & (1 << 3) != 0) + } +} diff --git a/crates/skate-core/src/player/wipeout/tests.rs b/crates/skate-core/src/player/wipeout/tests.rs new file mode 100644 index 00000000..5c5d4041 --- /dev/null +++ b/crates/skate-core/src/player/wipeout/tests.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::physics::skeleton_animation_record::IDENTITY; +use crate::point_graph::PointGraph; +fn input() -> Frame { + Frame { + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + category: 100, + timestep: 1.0 / 60.0, + time_on_ground: 1.0, + speed: 0.0, + animation_up: [0.0, 1.0, 0.0, 0.0], + landing_angle: 0.0, + deck_velocity: [0.0; 4], + com_velocity: [0.0; 4], + jump_fix_frames: 1000, + deck: IDENTITY, + input_board: IDENTITY, + world_to_animation: IDENTITY, + closing_velocity: [0.0; 4], + board_material_flags: 0, + board_contact: true, + wheel_contact: true, + board_contact_normal: [0.0, 1.0, 0.0, 0.0], + opposing_contact: 0.0, + regions_force: [0.0; 8], + maximum_skater_force: 0.0, + vehicle_force: 0.0, + group_8: false, + conflicting: false, + compliant: false, + highest_normal: [0.0, 1.0, 0.0, 0.0], + pose_error: [0.0; 4], + maximum_pose_error: 0.0, + flip_active: false, + flip_requested_speed: 0.0, + system_up_y: 1.0, + grind_selected: false, + grind_normal_valid: false, + grind_normal: [0.0, 1.0, 0.0, 0.0], + } +} +fn mode() -> Mode { + Mode { + check_squash: true, + check_bad_landing: true, + ground_xz: 10.0, + bad_landing_scale: 1.0, + } +} +fn settings() -> Settings { + Settings { + ground: GroundSettings { + vehicle_scalar: 1.0, + vehicle_contact: 100.0, + skitch_contact: 100.0, + skitch_scalar: 1.0, + skitch_arms_scalar: 1.0, + skater_scalar: 1.0, + max_squash: 100.0, + max_squash_coffin: 100.0, + max_displacement: 100.0, + max_contact: 100.0, + max_arm_contact: 100.0, + max_deck_error: 1.0, + opposing_contact: 1.0, + y_acceleration: 10.0, + light_dmo_scalar: 1.0, + player_scalar: 1.0, + ai_scalar: 1.0, + skitch_acc_scalar: 1.0, + balance_total: 100.0, + balance_min_speed: 1.0, + balance_base: 1.0, + }, + air: AirSettings { + xz_trick: 10.0, + y_trick: 10.0, + xz_acceleration: 10.0, + y_acceleration: 10.0, + max_squash: 100.0, + max_displacement: 100.0, + max_contact: 100.0, + max_arm_contact: 100.0, + body_flip_scalar: 1.0, + body_flip_acc_scalar: 1.0, + light_dmo_scalar: 1.0, + ignore_danger_frames: 10, + max_landing_speed: 6.0, + max_stairs_speed: 8.0, + max_grind_speed: 5.0, + max_landing_angle: PointGraph { + x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], + y: [1.0; 8], + }, + }, + lean_contact_y: -1.0, + } +} +fn request_input() -> RequestInput { + RequestInput { + flags_2468: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + animation_up_y: 1.0, + category: 100, + } +} +#[test] +fn wipeout_ground_priority_and_repeated_force_change_runout_selection() { + let mut state = Requests::new(); + let mut f = input(); + let s = settings(); + f.closing_velocity = [11.0, 0.0, 0.0, 0.0]; + check_ground(&mut state, &s, &mode(), &f); + assert_eq!(state.count, 1); + assert!(state.reasons[2]); + assert!(state.requests_runout(&request_input())); + assert!(!state.requests_wipeout(&request_input())); + state.clear_after_selection(); + f.maximum_pose_error = 101.0; + f.pose_error = [101.0, 0.0, 0.0, 0.0]; + f.regions_force[0] = 101.0; + check_ground(&mut state, &s, &mode(), &f); + assert_eq!(state.count, 3); + assert!(state.reasons[18] && state.reasons[2] && state.reasons[0]); + assert!(!state.reasons[1]); //The initial squash/displacement/force chain is exclusive. + assert!(!state.requests_runout(&request_input())); + assert!(state.requests_wipeout(&request_input())); + state.clear_after_selection(); + f.maximum_pose_error = 0.0; + f.pose_error = [0.0; 4]; + check_ground(&mut state, &s, &mode(), &f); + //Force is requested once by the main check and again after excessive closing speed. + assert_eq!(state.count, 3); + assert_eq!(state.reasons.iter().filter(|v| **v).count(), 2); +} +#[test] +fn wipeout_air_landing_uses_selected_velocity_and_preserves_impact_value() { + let mut state = Requests::new(); + let mut f = input(); + let s = settings(); + f.deck_velocity = [0.0, -2.0, 0.0, 0.0]; + f.com_velocity = [0.0, -7.0, 0.0, 0.0]; + check_air(&mut state, &s, &mode(), &f, false); + assert_eq!(state.count, 0); + check_air(&mut state, &s, &mode(), &f, true); + assert_eq!(state.count, 1); + assert!(state.reasons[6]); + assert_eq!(state.values[6], 7.0); + state.clear_after_selection(); + f.grind_selected = true; + f.grind_normal_valid = true; + f.grind_normal = [1.0, 0.0, 0.0, 0.0]; + check_air(&mut state, &s, &mode(), &f, true); + assert_eq!(state.count, 0); //The selector's actual normal replaces the board normal. + f.grind_normal_valid = false; + check_air(&mut state, &s, &mode(), &f, true); + assert_eq!(state.count, 1); + assert!(state.reasons[8]); + assert!(!state.reasons[6]); +} +#[test] +fn wipeout_sensitive_conditions_count_both_triggers_and_clear_only_requests() { + let mut state = Requests::new(); + let mut f = input(); + f.flags_2476 = 1 << 26; + f.flags_2472 = 1 << 6; + f.compliant = true; + check_air(&mut state, &settings(), &mode(), &f, false); + assert_eq!(state.count, 2); + assert!(state.reasons[21]); + assert_eq!(state.reasons.iter().filter(|v| **v).count(), 1); + state.balance = 3.0; + state.cooldown = 0.2; + state.clear_after_selection(); + assert_eq!(state.count, 0); + assert_eq!(state.balance, 3.0); + assert_eq!(state.cooldown, 0.2); + assert_eq!(state.mode, 2); + assert_eq!(state.contact_frames, 3); + state.reset_systems(); + assert_eq!(state.mode, 0); + assert_eq!(state.balance, 3.0); + assert_eq!(state.cooldown, 0.2); +} +#[test] +fn wipeout_cooldown_has_native_initial_and_teleport_lifetimes() { + let mut state = Requests::new(); + state.initialize_player(); + assert_eq!(state.cooldown.to_bits(), 0x3d23d70a); + let mut f = input(); + f.maximum_pose_error = 101.0; + check_air(&mut state, &settings(), &mode(), &f, false); + assert_eq!(state.count, 0); + assert_eq!(state.contact_frames, 3); + assert!(state.cooldown > 0.0); + state.teleport(); + assert_eq!(state.cooldown.to_bits(), 0x3ecccccd); + state.reset_systems(); + assert_eq!(state.cooldown.to_bits(), 0x3ecccccd); + state.cooldown = 0.001; + check_ground(&mut state, &settings(), &mode(), &f); + assert!(state.cooldown < 0.0); + assert_eq!(state.count, 0); + check_ground(&mut state, &settings(), &mode(), &f); + assert_eq!(state.count, 1); + assert!(state.reasons[18]); +} + +#[test] +fn wipeout_ground_animation_keeps_additional_checks_during_cooldown() { + let mut state = Requests::new(); + state.teleport(); + let mut f = input(); + f.regions_force[0] = 101.0; + f.opposing_contact = 1.5; + check_ground_animation(&mut state, &settings(), &mode(), &f, 1.0); + assert_eq!(state.count, 2); + assert!(state.reasons[0] && state.reasons[20]); + state.clear_after_selection(); + check_ground_animation(&mut state, &settings(), &mode(), &f, 2.0); + assert_eq!(state.count, 1); + assert!(state.reasons[0]); + assert!(!state.reasons[20]); +} diff --git a/crates/skate-core/src/player/wipeout_state/body.rs b/crates/skate-core/src/player/wipeout_state/body.rs new file mode 100644 index 00000000..1cba387d --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/body.rs @@ -0,0 +1,73 @@ +//! Original body adjustments used by WipeoutGround300. +use super::math::{self,V}; +use crate::{math::Vector3,physics::skeleton_body::SkeletonBody}; +pub fn set_velocity(body:&mut SkeletonBody,velocity:V) { + for part in &mut body.bodies_mut()[1..24] { + part.rates.linear_velocity=Vector3::new(velocity[0],velocity[1],velocity[2]); + } +} +pub fn add_velocity(body:&mut SkeletonBody,velocity:V) { + for part in &mut body.bodies_mut()[1..24] { + part.rates.linear_velocity.x+=velocity[0]; + part.rates.linear_velocity.y+=velocity[1]; + part.rates.linear_velocity.z+=velocity[2]; + } +} +///82BE74C8. Upper parts retain a tenth of the normal component; lower parts zero it. +pub fn remove_normal_velocity(body:&mut SkeletonBody,normal:V) { + for i in 1..24 { + let v=body.bodies()[i].rates.linear_velocity; + let v=[v.x,v.y,v.z,0.0]; + let projected=math::scale(normal,math::dot(normal,v)); + let next=math::madd(projected,if i<15 {0.1}else{0.0},math::sub(v,projected)); + let v=&mut body.bodies_mut()[i].rates.linear_velocity; + *v=Vector3::new(next[0],next[1],next[2]); + } +} +///82BE75B0 then82BD41B0: average with the requested velocity and limit distance2. +pub fn blend_velocity(body:&mut SkeletonBody,target:V) { + for part in &mut body.bodies_mut()[1..24] { + let v=part.rates.linear_velocity; + let average=math::madd(target,0.5,[v.x*0.5,v.y*0.5,v.z*0.5,0.0]); + let delta=math::sub(average,target); + let length=math::length(delta); + let next=if length<=2.0 {average} else {math::madd(delta,2.0/length,target)}; + part.rates.linear_velocity=Vector3::new(next[0],next[1],next[2]); + } +} +///82BE76B0: length and reciprocal each use the original two refinements. +pub fn limit_velocity(body:&mut SkeletonBody,maximum:f32) { + for part in &mut body.bodies_mut()[1..24] { + let v=part.rates.linear_velocity; + let v=[v.x,v.y,v.z,0.0]; + if math::dot(v,v)>maximum*maximum { + let next=math::scale(v,math::reciprocal(math::length(v))*maximum); + part.rates.linear_velocity=Vector3::new(next[0],next[1],next[2]); + } + } +} +///82D9CD60 and82D9CE58 publish per-second drag into the same live inertia. +pub fn set_drag(body:&mut SkeletonBody,linear:f32,angular:f32) { + for part in body.bodies_mut() { + part.inertia.linear_drag=linear*f32::from_bits(0x426F_FFFF); + part.inertia.angular_drag=angular*f32::from_bits(0x426F_FFFF); + } +} +///82BE32D0. +pub fn set_wipeout_drag(body:&mut SkeletonBody,drag:f32) { + set_drag(body,math::clamp(drag,0.0,1.0),math::clamp((drag+1.0)*0.5,0.0,1.0)); +} +///82BE3998. Source uses cached physical-record positions before the live writes. +pub fn special_surface(body:&mut SkeletonBody,height:f32) { + const WEIGHTS:[f32;23]=[1.7,1.5,1.2,1.0,1.0,1.0,1.2,1.0,1.0,1.0, + 1.2,1.0,1.0,1.0,0.5,0.5,0.5,0.6,0.5,0.5,0.5,0.6,1.0]; + for i in 1..24 { + let depth=(height+0.1)-body.record.pose[i][3][1]; + let drag=if depth>0.0 { + body.apply_part_displacement(i,[0.0,depth.mul_add(0.6,0.1)*WEIGHTS[i-1],0.0,0.0]); + 0.1 + } else {0.005}; + body.bodies_mut()[i].inertia.angular_drag=drag*f32::from_bits(0x426F_FFFF); + body.bodies_mut()[i].inertia.linear_drag=drag*f32::from_bits(0x426F_FFFF); + } +} diff --git a/crates/skate-core/src/player/wipeout_state/contact_response.rs b/crates/skate-core/src/player/wipeout_state/contact_response.rs new file mode 100644 index 00000000..01586de4 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/contact_response.rs @@ -0,0 +1,85 @@ +//! Wipeout82D3EC08's two persistent material responses, 82D91708..82D92440. +//! Each correction writes the actual 23 skater bodies' linear velocities. +mod material10; +mod material11; +use super::math::{V, dot, normalize_or, scale, sub}; +use crate::physics::skeleton_body::SkeletonBody; + +#[derive(Clone, Copy)] +pub struct ContactResponseInput { + /// Original Processed608 snapshot, reused by both helpers before physics. + pub com_velocity_608: V, + /// SkeletonCollision4079/1168; absence preserves the retained normal. + pub material10_normal: Option, + /// SkeletonCollision4080/1184; the helper owns its separate contact latch. + pub material11_normal: Option, + /// Original Processed224 (effective animation transform's third axis). + pub effective_axis_224: V, + /// Original Processed2824/2828, the stock Wipeout control attributes. + pub wipeout_control_2824_2828: [f32; 2], +} +#[derive(Clone, Copy, Debug)] +pub struct ContactResponseOutput { + pub material10_phase: u32, + /// One-update pulse at first helper2->3, copied to Wipeout572. + pub material10_finished_77: bool, + /// Second helper's retained byte73; selects caller's collision mode9. + pub material11_active_73: bool, +} +pub struct ContactResponse { + material10: material10::Response, + material11: material11::Response, +} +impl ContactResponse { + pub fn new() -> Self { + Self { + material10: material10::Response::new(), + material11: material11::Response::new(), + } + } + /// Wipeout Reset82D3B3A8 invokes both resets; byte73 survives the second. + pub fn reset(&mut self) { + self.material10.reset(); + self.material11.reset(); + } + pub fn update( + &mut self, + body: &mut SkeletonBody, + input: ContactResponseInput, + ) -> ContactResponseOutput { + //82D3EC58..5C: state selection precedes the first response's update. + self.material10 + .update(body, input.com_velocity_608, input.material10_normal); + self.material11.update(body, input); + ContactResponseOutput { + material10_phase: self.material10.phase, + material10_finished_77: self.material10.finished, + material11_active_73: self.material11.active, + } + } +} + +/// 82E0A4E8: reject the component along the safely normalized direction. +fn reject(value: V, direction: V) -> V { + let normal = normalize_or(direction, [0.0; 4]); + sub(value, scale(normal, dot(value, normal))) +} +/// 82C1E170 differs: positive component test uses the normalized direction, +/// but its subtraction multiplies the ORIGINAL direction, even if nonunit. +fn reject_positive(value: V, direction: V) -> V { + let projection = dot(value, normalize_or(direction, [0.0; 4])); + if projection > 0.0 { + sub(value, scale(direction, projection)) + } else { + value + } +} +fn apply_velocity_delta(body: &mut SkeletonBody, delta: V) { + //All original loops start at part1 and execute23 stores; root0/extras24/25 + //and angular velocities remain untouched. The host stores XYZ explicitly. + for part in &mut body.bodies_mut()[1..24] { + part.rates.linear_velocity.x += delta[0]; + part.rates.linear_velocity.y += delta[1]; + part.rates.linear_velocity.z += delta[2]; + } +} diff --git a/crates/skate-core/src/player/wipeout_state/contact_response/material10.rs b/crates/skate-core/src/player/wipeout_state/contact_response/material10.rs new file mode 100644 index 00000000..c8c66062 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/contact_response/material10.rs @@ -0,0 +1,105 @@ +//! First helper's Reset91708, Select917A0, Update91908 and response kernels. +use super::{apply_velocity_delta, reject_positive}; +use crate::{physics::skeleton_body::SkeletonBody, player::wipeout_state::math::*}; + +const STEP: f32 = f32::from_bits(0x3c88_8889); //820849C8 +const PHASE_DURATION: f32 = f32::from_bits(0x3d07_2b02); //8208F5C0:0.033 + +pub(super) struct Response { + previous_velocity: V, //16 + normal: V, //32 + target_velocity: V, //48 + time: f32, //64 + pub phase: u32, //72 + pub finished: bool, //77, cleared by caller before Select +} +impl Response { + pub fn new() -> Self { + Self { + previous_velocity: [0.0; 4], + normal: [0.0, 1.0, 0.0, 0.0], + target_velocity: [0.0; 4], + time: 0.0, + phase: 0, + finished: false, + } + } + pub fn reset(&mut self) { + *self = Self::new(); + } + pub fn update(&mut self, body: &mut SkeletonBody, velocity: V, contact: Option) { + if let Some(normal) = contact { + self.normal = normal; + } + self.finished = false; + self.select(contact.is_some()); + match self.phase { + 0 => { + let difference = sub(velocity, self.previous_velocity); + //Both original branches require the ordered greater-than bit. + self.previous_velocity = if self.time > f32::from_bits(0x3c75_c28f) + && dot(difference, difference) > 1.0 + { + madd( + velocity, + f32::from_bits(0x3d4c_cccd), + scale(self.previous_velocity, f32::from_bits(0x3f73_3333)), + ) + } else { + velocity + }; + } + 1 => { + //91A40: half-opposite COM velocity, conditional plane rejection, + //then an additional -normal term. This is a velocity delta. + let rejected = reject_positive(scale(velocity, -0.5), self.normal); + apply_velocity_delta(body, madd(self.normal, -1.0, rejected)); + } + 2 => { + //91B28 preserves the Processed608 snapshot used by phase1. + apply_velocity_delta(body, scale(sub(self.target_velocity, velocity), 1.0)); + //Global830BD330 initializer82F826B0 loads -9.8 from822F8B40. + self.target_velocity = madd( + [0.0, f32::from_bits(0xc11c_cccd), 0.0, 0.0], + STEP, + self.target_velocity, + ); + } + _ => {} + } + self.time += STEP; + } + fn select(&mut self, contact: bool) { + let next = match self.phase { + 0 if contact => { + if -dot(self.previous_velocity, self.normal) > 2.0 { + 1 + } else { + 4 + } + } + 1 if self.time > PHASE_DURATION => { + let twice_normal = scale(self.normal, 2.0); + self.target_velocity = scale( + sub( + self.previous_velocity, + scale(twice_normal, dot(self.previous_velocity, self.normal)), + ), + f32::from_bits(0x3f66_6666), + ); + 2 + } + 2 if self.time > PHASE_DURATION => { + self.finished = true; + 3 + } + 3 if !contact => 0, + //Native unsigned phase>3 bypasses transitions, including phase4. + phase => phase, + }; + if next != self.phase { + self.phase = next; + self.time = 0.0; + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/contact_response/material11.rs b/crates/skate-core/src/player/wipeout_state/contact_response/material11.rs new file mode 100644 index 00000000..65e3eb2f --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/contact_response/material11.rs @@ -0,0 +1,113 @@ +//! Second helper's Reset91BF8, Update91C98, response91DF8 and controls92160. +use super::{ContactResponseInput, apply_velocity_delta, reject}; +use crate::{ + physics::{native_arithmetic, skeleton_body::SkeletonBody}, + player::wipeout_state::math::*, +}; + +pub(super) struct Response { + normal: V, //16 + previous_velocity: V, //32 + velocity: V, //48 + frames_since_contact: i32, //64 + active_frames: i32, //68 + contact_latched: bool, //72, reset-owned, not cleared each update + pub active: bool, //73, explicitly preserved by native Reset +} +impl Response { + pub fn new() -> Self { + //The original constructors/reset omit initialization of byte73. Host + //storage begins false; subsequent native resets preserve its value. + Self { + normal: [0.0, 1.0, 0.0, 0.0], + previous_velocity: [0.0; 4], + velocity: [0.0; 4], + frames_since_contact: 100, + active_frames: 0, + contact_latched: false, + active: false, + } + } + pub fn reset(&mut self) { + let active = self.active; + *self = Self::new(); + self.active = active; + } + pub fn update(&mut self, body: &mut SkeletonBody, input: ContactResponseInput) { + self.velocity = input.com_velocity_608; + if dot(self.velocity, self.velocity) > 0.25 { + if let Some(normal) = input.material11_normal { + self.frames_since_contact = 0; + self.contact_latched = true; + self.normal = normal; + if !self.active { + self.previous_velocity = reject(self.previous_velocity, self.normal); + } + } + } + self.active = self.contact_latched || self.frames_since_contact < 3; + if self.active { + self.apply_response(body); + self.apply_control( + body, + input.effective_axis_224, + input.wipeout_control_2824_2828, + ); + self.active_frames = self.active_frames.wrapping_add(1); + } else { + self.previous_velocity = self.velocity; + self.active_frames = 0; + } + //Native signed comparison intentionally alternates100->101->100. + self.frames_since_contact = if self.frames_since_contact <= 100 { + self.frames_since_contact.wrapping_add(1) + } else { + 100 + }; + } + fn apply_response(&self, body: &mut SkeletonBody) { + let mut retained_delta = [0.0; 4]; + if self.active_frames < 3 { + let difference = reject(sub(self.previous_velocity, self.velocity), self.normal); + if dot(difference, self.velocity) > 0.0 { + retained_delta = scale(difference, f32::from_bits(0x3f66_6666)); + } + } + let friction = reject( + scale(self.velocity, f32::from_bits(0xbb03_126f)), + self.normal, + ); + let mut slope_delta = [0.0; 4]; + //91F9C bge skips the slope term at/above original normal.y limit0.97. + if self.normal[1] < f32::from_bits(0x3f78_51ec) { + let across = cross([0.0, 1.0, 0.0, 0.0], self.normal); + let mut direction = normalize_or(cross(across, self.normal), [0.0; 4]); + if direction[1] > 0.0 { + direction = scale(direction, -1.0); + } + //92058 loads retained NORMAL; only its Y is zeroed before length. + let mut horizontal_normal = self.normal; + horizontal_normal[1] = 0.0; + let candidate = scale( + direction, + length(horizontal_normal) * f32::from_bits(0x3dcc_cccd), + ); + if dot(candidate, self.velocity) > 0.0 { + slope_delta = candidate; + } + } + apply_velocity_delta(body, add(add(friction, retained_delta), slope_delta)); + } + fn apply_control(&self, body: &mut SkeletonBody, effective_axis: V, control: [f32; 2]) { + let planar_velocity = reject(self.velocity, self.normal); + let planar_direction = normalize_or(planar_velocity, [0.0; 4]); + if f32::from_bits(0x3f7f_be77) > dot([0.0, 1.0, 0.0, 0.0], effective_axis) { + let fraction = + reciprocal(10.0) * native_arithmetic::vector_min(10.0, length(planar_velocity)); + let input = [control[0], 0.0, control[1], 0.0]; + let tangent = reject(input, self.normal); + let lateral = reject(tangent, planar_direction); + apply_velocity_delta(body, scale(lateral, fraction * f32::from_bits(0x3d4c_cccd))); + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/control_air.rs b/crates/skate-core/src/player/wipeout_state/control_air.rs new file mode 100644 index 00000000..1e9be880 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/control_air.rs @@ -0,0 +1,39 @@ +//! Original alternate air-control82D3DD40, including its contact early return. +use super::{State, math::{self,V}, torque}; +use crate::physics::skeleton_body::SkeletonBody; +const UP:V=[0.0,1.0,0.0,0.0]; +pub fn update(state:&mut State,body:&mut SkeletonBody,physical_com:V, + input:[f32;2],contact:bool)->bool { + let spine=math::normalize_or( + math::sub(body.record.pose[11][3],body.record.pose[23][3]),[0.0;4]); + state.angular_velocity=torque::angular_velocity( + &body.record,&body.definition.animation_masses.fractional); + if contact {return false;} + let direction=math::normalize_or(state.velocity,[0.0;4]); + let mut sideways=math::normalize_or(math::cross(UP,spine),[0.0;4]); + sideways[1]=0.0; + sideways=math::normalize_or(sideways,[0.0;4]); + let velocity_side=math::cross(UP,direction); + if math::dot(velocity_side,sideways)<0.0 {sideways=math::scale(sideways,-1.0);} + let plane_normal=math::normalize_or(velocity_side,[0.0;4]); + let projected=math::sub(spine,math::scale(plane_normal,math::dot(plane_normal,spine))); + let projected=math::normalize_or(projected,[0.0;4]); + let mut automatic=math::scale(math::cross(spine,projected),0.5); + let hips_up=body.record.pose[23][1]; + if input[0].abs()<0.5 && hips_up[1]<0.0 { + let projected=math::sub(hips_up, + math::scale(plane_normal,math::dot(plane_normal,hips_up))); + let projected=math::normalize_or(projected,[0.0;4]); + automatic=math::madd(math::cross(hips_up,projected),0.5,automatic); + } + //Both original scalar limits820993E8/EC are8 degrees. + let limit=8.0*f32::from_bits(0x3C8E_FA35); + let side=math::scale(sideways,limit*input[1]); + let negative_spine=spine.map(|v|f32::from_bits(v.to_bits()^0x8000_0000)); + let target=math::add(math::madd(negative_spine,limit*input[0],side),automatic); + let target=math::scale(target,f32::from_bits(0x426F_FFFF)); + let delta=math::scale(math::scale(math::sub(target,state.angular_velocity), + f32::from_bits(0x4019_999A)),f32::from_bits(0x3C88_8889)); + torque::apply(body,physical_com,math::limit_length(delta,0.5)); + true +} diff --git a/crates/skate-core/src/player/wipeout_state/control_ground.rs b/crates/skate-core/src/player/wipeout_state/control_ground.rs new file mode 100644 index 00000000..4c5d3a03 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/control_ground.rs @@ -0,0 +1,43 @@ +//! Original Wipeout contact control82D3D660, using the world-space animation hands. +use super::{State,math::{self,V},profiles::Profile,torque}; +use crate::{physics::{skeleton_animation_record::AnimationPartTransform, + skeleton_body::SkeletonBody},trigonometry}; +pub fn update(state:&mut State,body:&mut SkeletonBody,physical_com:V, + effective:&AnimationPartTransform,ground_axis_464:V,profile:&Profile, + animation_to_world:&AnimationPartTransform,animation_pose:&[AnimationPartTransform;24]) { + state.angular_velocity=torque::angular_velocity( + &body.record,&body.definition.animation_masses.fractional); + let speed=math::length(state.velocity); + let mut rolling_axis=math::normalize_or(std::array::from_fn(|i| { + let x=effective[0][i]*profile.roll_axis[0]; + let y=effective[1][i].mul_add(profile.roll_axis[1],x); + effective[2][i].mul_add(profile.roll_axis[2],y) + }),[0.0;4]); + let hands=[3,7].map(|i| crate::physics::skeleton_animation_record::compose_affine( + animation_to_world,&animation_pose[i])); + let hands_close=math::length(math::sub(hands[0][3],hands[1][3]))<=0.6; + let mut alignment=[0.0;4]; + if profile.align_ground_with_velocity { + let axis=math::normalize_or(math::cross(ground_axis_464,state.velocity),[0.0;4]); + if math::dot(rolling_axis,axis)<0.0 {rolling_axis=math::scale(rolling_axis,-1.0);} + let perpendicular=math::cross(rolling_axis,axis); + let sine=math::clamp(math::length(perpendicular),-0.9999,0.9999); + let angle=math::wrap_angle(trigonometry::asin(sine)); + let magnitude=math::clamp(angle*1.2,0.0,1.0); + if speed>2.0 { + let axis=math::normalize_or(perpendicular,[0.0;4]); + let current=math::normalize_or(state.angular_velocity,[0.0;4]); + alignment=math::scale(math::sub(math::scale(axis,magnitude), + math::scale(axis,math::dot(current,axis))),profile.ground_align_torque); + } + } + let mut rolling=[0.0;4]; + if hands_close && profile.roll_on_ground && speed>2.0 { + let current=math::normalize_or(state.angular_velocity,[0.0;4]); + rolling=math::scale(math::sub( + math::scale(math::scale(rolling_axis,1.5),state.forward_input), + math::scale(rolling_axis,math::dot(current,rolling_axis))), + profile.ground_roll_torque); + } + torque::apply(body,physical_com,math::add(alignment,rolling)); +} diff --git a/crates/skate-core/src/player/wipeout_state/control_profile_air.rs b/crates/skate-core/src/player/wipeout_state/control_profile_air.rs new file mode 100644 index 00000000..1006ab8b --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/control_profile_air.rs @@ -0,0 +1,155 @@ +//! Original profile-driven Wipeout air rotation82D3CB50. +use super::{ + State, + math::{self, V}, + orientation, + profiles::Profile, + torque, +}; +use crate::{ + physics::{skeleton_animation_record::AnimationPartTransform, skeleton_body::SkeletonBody}, + trigonometry::sin_cos, +}; +const UP: V = [0.0, 1.0, 0.0, 0.0]; + +pub fn update( + state: &mut State, + body: &mut SkeletonBody, + physical_com: V, + effective: &AnimationPartTransform, + input: [f32; 2], + profile: &Profile, +) { + state.angular_velocity = + torque::angular_velocity(&body.record, &body.definition.animation_masses.fractional); + let radians = f32::from_bits(0x3C8E_FA35); + let tilt_input = [ + (input[1] * profile.tilt_degrees) * radians, + 0.0, + (input[0] * profile.tilt_degrees) * -radians, + 0.0, + ]; + state.retained_tilt = math::madd(tilt_input, 0.05, math::scale(state.retained_tilt, 0.95)); + let tilt_length = math::length(state.retained_tilt); + let mut tilt_rotation = [0.0, 0.0, 0.0, 1.0]; + if tilt_length > 0.01 { + let (sin, cos) = sin_cos(tilt_length * 0.5); + tilt_rotation = math::scale( + math::scale(state.retained_tilt, math::reciprocal(tilt_length)), + sin, + ); + tilt_rotation[3] = cos; + } + let desired = transform( + effective, + orientation::alignment_up(profile.align_euler[0], profile.align_euler[1]), + ); + let side = math::normalize_or(state.right, [0.0; 4]); + let projected_velocity = math::sub( + state.velocity, + math::scale(side, math::dot(state.velocity, side)), + ); + let direction = if math::dot(state.forward, projected_velocity) <= 0.0 { + math::normalize(math::add(desired, [0.0, -100.0, 0.0, 0.0])) + } else { + math::normalize_or(projected_velocity, desired) + }; + let tilted_direction = orientation::rotate(tilt_rotation, direction); + let axis = math::normalize_or(math::cross(tilted_direction, desired), desired); + let angle = math::wrap_angle(orientation::signed_angle(tilted_direction, desired, axis)); + let distance_torque = if profile.align_with_velocity { + math::scale(math::scale(axis, angle), profile.torque_distance) + } else { + [0.0; 4] + }; + + let input_side_axis = orientation::rotate(tilt_rotation, desired); + let input_forward_axis = orientation::rotate( + tilt_rotation, + math::normalize_or(math::cross(direction, UP), [0.0; 4]), + ); + state.retained_sideways_input = retain_input( + state.sideways_input, + state.retained_sideways_input, + profile.spin_inertia, + ); + state.retained_forward_input = retain_input( + state.forward_input, + state.retained_forward_input, + profile.spin_inertia, + ); + let side_spin = profile.sideways_spin * state.retained_sideways_input; + let forward_spin = profile.forward_spin * state.retained_forward_input; + let spin = math::madd( + input_forward_axis, + forward_spin, + math::madd(input_side_axis, side_spin, [0.0; 4]), + ); + if math::dot(spin, state.angular_velocity) <= 0.0 { + state.control_time = 0.0; + } + let spin = math::scale(spin, profile.spin_vs_time.evaluate(state.control_time)); + let horizontal = if side_spin.abs() <= 0.1 { + horizontal_alignment(profile, effective, direction, tilt_rotation) + } else { + [0.0; 4] + }; + let control = math::add( + math::madd( + math::sub(spin, state.angular_velocity), + profile.torque_velocity, + distance_torque, + ), + horizontal, + ); + torque::apply(body, physical_com, control); +} + +fn retain_input(input: f32, previous: f32, inertia: f32) -> f32 { + if input.abs() >= previous.abs() { + input + } else { + (1.0 - inertia) * input + previous * inertia + } +} + +fn horizontal_alignment( + profile: &Profile, + effective: &AnimationPartTransform, + direction: V, + tilt: V, +) -> V { + let local = math::normalize_or(profile.horizontal_axis, [1.0, 0.0, 0.0, 0.0]); + let world = transform(effective, local); + let mut target = orientation::rotate( + tilt, + math::normalize_or(math::cross(direction, UP), [0.0; 4]), + ); + if !profile.horizontal_directed { + target = math::scale( + target, + if math::dot(world, target) > 0.0 { + 1.0 + } else { + -1.0 + }, + ); + } + //82D3D4F0 uses the normalized authored axis here, before world composition; + //the angle query itself receives world/target. Preserve that distinction. + let axis = math::normalize_or(math::cross(local, target), [0.0; 4]); + if !(math::dot(axis, axis) > 0.9) { + return [0.0; 4]; + } + let angle = math::wrap_angle(orientation::projected_angle(world, target, axis)); + let limit = f32::from_bits(0x3F49_0FDB); + math::scale(axis, math::clamp(angle, -limit, limit) * 0.8) +} + +fn transform(frame: &AnimationPartTransform, vector: V) -> V { + math::madd( + frame[2], + vector[2], + math::madd(frame[1], vector[1], math::scale(frame[0], vector[0])), + ) +} diff --git a/crates/skate-core/src/player/wipeout_state/data.rs b/crates/skate-core/src/player/wipeout_state/data.rs new file mode 100644 index 00000000..e0e5defa --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/data.rs @@ -0,0 +1,90 @@ +//! Original WipeoutGround Reset82D3B3A8. Native offsets document ownership. +#[derive(Clone, Debug)] +pub struct State { + pub time: f32, //304 + pub settled_time: f32, //308 + pub time_until_teleport: f32, //312 + pub impaled_time: f32, //316 + pub no_support_time: f32, //320 + pub response_time: f32, //324 + pub extra_weight_zero_time: f32, //328 + pub slow_time: f32, //332 + pub surface_height: f32, //336 + pub board_offset: [f32; 4], //352 + pub forward: [f32; 4], //368 + pub right: [f32; 4], //384 + pub angular_velocity: [f32; 4], //400 + pub retained_tilt: [f32; 4], //416 + pub velocity: [f32; 4], //432 + pub retained_velocity: [f32; 4], //448 + pub predicted_position: [f32; 4], //464 + pub over: bool, //480 + pub slow: bool, //481 + pub material_eleven_response: bool, //482 + pub air_collision_mode: bool, //483 + pub request_teleport: bool, //484 + pub move_board: bool, //485 + pub special_surface: bool, //486 + pub below_surface: bool, //487 + pub retained_velocity_active: bool, //488 + pub sideways_input: f32, //492 + pub forward_input: f32, //496 + pub orientation: f32, //500 + pub collision_weight: f32, //504 + pub controlled_weight: f32, //508 + pub control_time: f32, //512 + pub retained_sideways_input: f32, //516 + pub retained_forward_input: f32, //520 + pub response_scalar: f32, //524 + pub extra_weight: f32, //528 + pub maximum_speed: f32, //532 + pub response_start_speed: f32, //536 + pub response_change: f32, //540 + pub board_move_frames: u32, //544, initialized by Enter + pub response_count: u32, //548 + pub response_frames: i32, //552 + pub airborne_frames: i32, //556 + pub teleport_countdown: i32, //560 + pub prevent_manual: bool, //564 + pub ignore_reset: bool, //565 + pub reset_ever: bool, //566 + pub ever_settled: bool, //567 + pub recovery_eligible: bool, //568 + pub teleport_pending: bool, //569 + pub ever_impaled: bool, //570 + pub direction_initialized: bool, //571 + pub material_ten_response: bool, //572 + pub imminent_surface_twelve: bool, //573 + pub allow_retained_velocity: bool, //574 + pub response_finished: bool, //575 + pub surface_query: bool, //576 + pub profile: usize, //580 + pub predicted_time: f32, //832 +} +impl Default for State { + fn default() -> Self { + Self { + time: 0.0, settled_time: 0.0, time_until_teleport: 0.0, + impaled_time: 0.0, no_support_time: 0.0, response_time: 0.0, + extra_weight_zero_time: 0.0, slow_time: 0.0, surface_height: 0.0, + board_offset: [0.0;4], forward: [0.0;4], right: [0.0;4], + angular_velocity: [0.0;4], retained_tilt: [0.0;4], velocity: [0.0;4], + retained_velocity: [0.0;4], predicted_position: [0.0;4], + over: false, slow: false, material_eleven_response: false, + air_collision_mode: false, request_teleport: false, move_board: false, + special_surface: false, below_surface: false, retained_velocity_active: false, + sideways_input: 0.0, forward_input: 0.0, orientation: 0.0, + collision_weight: 0.0, controlled_weight: 0.0, control_time: 0.0, + retained_sideways_input: 0.0, retained_forward_input: 0.0, + response_scalar: 0.0, extra_weight: 1.0, maximum_speed: 10.0, + response_start_speed: 0.0, response_change: 0.0, board_move_frames: 0, + response_count: 0, response_frames: -1, airborne_frames: -1, + teleport_countdown: -1, prevent_manual: false, ignore_reset: false, + reset_ever: false, ever_settled: false, recovery_eligible: false, + teleport_pending: false, ever_impaled: false, direction_initialized: false, + material_ten_response: false, imminent_surface_twelve: false, + allow_retained_velocity: true, response_finished: false, + surface_query: false, profile: 0, predicted_time: f32::MAX, + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/drives.rs b/crates/skate-core/src/player/wipeout_state/drives.rs new file mode 100644 index 00000000..65682d49 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/drives.rs @@ -0,0 +1,108 @@ +//! Original SkeletonDrives::UpdateRagdoll82BEB0C8 and root softness82BEB790. +use crate::{ + animation::foot_ik::inverse_affine, + physics::{ + board_motion_output::inverse_length_squared, + drive_parameters::{RetailDriveParams, RetailDriveType}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + skeleton_body::{SkeletonDrives, bone_drive_frames}, + }, +}; +const FREQUENCY_SQUARED: f32 = f32::from_bits(0x4560_FFFE); +#[derive(Clone, Copy, Debug)] +pub struct Settings { + ///Original SkeletonBody748+36*part: start, normal, controlled, end, extra. + pub bone: [[f32; 5]; 24], + ///The four full64 root_drive_* stock scalars, in the same first-four order. + pub root: [f32; 4], + ///animation/default: layout424 DriveStrengthLocal,420 DriveStrengthRootLocal. + pub strength: [f32; 2], + ///physics_animation/default HookSoftDsp888, HookSoftStr884, HookSoftDmp892. + pub hook_spring: f32, + pub hook_strength: f32, + pub hook_damping: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Weights { + pub start: f32, + pub end: f32, + pub controlled: f32, + ///Processed2484 bits5 and4 select the two independently supplied weights. + pub upper_extra: f32, + pub lower_extra: f32, +} +///Returns original retained residual16. It is not clamped or renormalized. +pub fn update( + drives: &mut SkeletonDrives, + pose: &[AnimationPartTransform; 24], + settings: &Settings, + weights: Weights, +) -> f32 { + let mut inverses = [IDENTITY; 24]; + for part in 1..24 { inverses[part] = inverse_affine(&pose[part]); } + let residual = ((1.0 - weights.start) - weights.end) - weights.controlled; + for part in 1..23 { + let bone = drives.bones[part].as_mut().expect("Original22 bone drives exist"); + let coefficients = settings.bone[part]; + let upper = (3..=10).contains(&part); + let lower = (15..=22).contains(&part); + let strengths = if upper && weights.upper_extra > 0.0 + || lower && weights.lower_extra > 0.0 + { + bone.dynamics.mode = 5; + let extra = if upper { weights.upper_extra } else { weights.lower_extra }; + let value = extra * coefficients[4]; + [value * settings.strength[0], value * settings.strength[1]] + } else { + bone.dynamics.mode = 4; + let [start, normal, controlled, end, _] = coefficients; + //82BEB424..444 preserves two distinct FMA accumulation orders. + let local = controlled.mul_add(weights.controlled, + start.mul_add(weights.start, normal.mul_add(residual, end * weights.end))); + let root_start = settings.root[0] * start; + let root_normal = settings.root[1] * normal; + let root_controlled = settings.root[2] * controlled; + let root_end = settings.root[3] * end; + let root = root_controlled.mul_add(weights.controlled, + root_start.mul_add(weights.start, root_end.mul_add(weights.end, + root_normal * residual))); + [local * settings.strength[0], root * settings.strength[1]] + }; + bone.dynamics.strengths = strengths; + for channel in 0..2 { + if !bone.active[channel] { continue; } + bone.frames[channel] = bone_drive_frames( + &pose[part], &inverses[part], &inverses[bone.parent[channel]]); + bone.dynamics.enable(channel, strengths[channel], drives.settings.bone); + } + } + residual +} +///82BEB790 changes only target0's linear drive; damping uses the unscaled spring. +pub fn set_linear_root(drives: &mut SkeletonDrives, s: &Settings, weight: f32) { + let weight = clamp(weight); + let spring = s.hook_spring; + let root = if spring == 0.0 { 0.0 } + else { spring * inverse_length_squared(spring, 2) }; + drives.targets.dynamics[0].linear = RetailDriveParams { + spring_or_max_velocity: spring * weight, + damping: root * 2.0 - spring * f32::from_bits(0x3C83_126F), + max_strength: (s.hook_strength * weight) * FREQUENCY_SQUARED, + drive_type: RetailDriveType::SoftDrive, + }; +} +///82D3BE98..BF24 uses the same target0 angular fields set during Enter. +///Enter uses spring*0.5 and strength*1799.9998; Update uses the supplied weight. +pub fn set_angular_root(drives: &mut SkeletonDrives, s: &Settings, weight: f32) { + let weight = clamp(weight); + drives.targets.dynamics[0].angular = RetailDriveParams { + spring_or_max_velocity: s.hook_spring * weight, + damping: s.hook_damping, + max_strength: (s.hook_strength * weight) * FREQUENCY_SQUARED, + drive_type: RetailDriveType::SoftDrive, + }; +} +fn clamp(v: f32) -> f32 { + let lower = if -v >= 0.0 { 0.0 } else { v }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/player/wipeout_state/math.rs b/crates/skate-core/src/player/wipeout_state/math.rs new file mode 100644 index 00000000..2c38db04 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/math.rs @@ -0,0 +1,47 @@ +//! Scalar/vector expressions recovered from original Wipeout control kernels. +use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic}; +pub type V = [f32;4]; +pub fn add(a:V,b:V)->V { std::array::from_fn(|i|a[i]+b[i]) } +pub fn sub(a:V,b:V)->V { std::array::from_fn(|i|a[i]-b[i]) } +pub fn scale(a:V,b:f32)->V { a.map(|v|v*b) } +pub fn madd(a:V,b:f32,c:V)->V { std::array::from_fn(|i|a[i].mul_add(b,c[i])) } +pub fn dot(a:V,b:V)->f32 { native_arithmetic::dot3(a,b) } +pub fn cross(a:V,b:V)->V { + [(-a[2]).mul_add(b[1],a[1]*b[2]), + (-a[0]).mul_add(b[2],a[2]*b[0]), + (-a[1]).mul_add(b[0],a[0]*b[1]), + (-a[3]).mul_add(b[3],a[3]*b[3])] +} +pub fn length(a:V)->f32 { + let square=dot(a,a); + if square==0.0 {0.0} else {square*inverse_length_squared(square,2)} +} +pub fn normalize(a:V)->V {scale(a,inverse_length_squared(dot(a,a),2))} +///Original830BD350 is initialized to splatted358637BD, not a fitted tolerance. +pub fn normalize_or(a:V,fallback:V)->V { + if length(a)>f32::from_bits(0x3586_37BD) {normalize(a)} else {fallback} +} +pub fn reciprocal(v:f32)->f32 { + let mut inv=native_arithmetic::reciprocal_estimate(v); + for _ in 0..2 {inv=inv.mul_add((-inv).mul_add(v,1.0),inv);} + inv +} +pub fn clamp(v:f32,low:f32,high:f32)->f32 { + let v=if low-v>=0.0 {low}else{v}; + if high-v>=0.0 {v}else{high} +} + +///Original82BD3D90: short vectors retain all four lanes. +pub fn limit_length(v:V,maximum:f32)->V { + let len=length(v); + if !(len>=f32::from_bits(0x3780_0000)) {return v;} + let cap=if maximum-len>=0.0 {len}else{maximum}; + scale(scale(v,cap),reciprocal(len)) +} + +pub fn wrap_angle(angle:f32)->f32 { + let turns=angle*f32::from_bits(0x3E22_F983); + let fraction=turns-turns.floor(); + let centered=if fraction>0.5 {fraction-1.0}else{fraction}; + centered*f32::from_bits(0x40C9_0FDB) +} diff --git a/crates/skate-core/src/player/wipeout_state/mod.rs b/crates/skate-core/src/player/wipeout_state/mod.rs new file mode 100644 index 00000000..e5b2b40c --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/mod.rs @@ -0,0 +1,18 @@ +//! Original physical WipeoutGround300 and its internal air control. +mod data; +pub mod drives; +pub mod skeleton; +pub use data::State; +pub mod body; +pub mod contact_response; +pub mod control_air; +pub mod control_ground; +pub mod control_profile_air; +pub mod math; +pub mod orientation; +pub mod output; +pub mod profiles; +pub mod recovery; +pub mod response; +pub mod torque; +pub mod weights; diff --git a/crates/skate-core/src/player/wipeout_state/orientation.rs b/crates/skate-core/src/player/wipeout_state/orientation.rs new file mode 100644 index 00000000..0e194a5a --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/orientation.rs @@ -0,0 +1,59 @@ +//! Original Wipeout alignment82D3C770: Euler X/Y degrees -> rotated up axis. +use super::math::{self, V}; +use crate::trigonometry::sin_cos; + +pub fn alignment_up(x_degrees: f32, y_degrees: f32) -> V { + let radians = f32::from_bits(0x3C8E_FA35); + let (sx, cx) = sin_cos((x_degrees * radians) * 0.5); + let (sy, cy) = sin_cos((y_degrees * radians) * 0.5); + let (sz, cz) = sin_cos(0.0); + let cxcz = cx * cz; + let sxcz = sx * cz; + let sxsz = sx * sz; + let cxsz = cx * sz; + //82D3C8B0 word114D4AAE: D10=A13*C10+B9, not A13*B9+C10. + let qy = cy.mul_add(sxsz, sy * cxcz); + //82D3C8B4 word11AD42EE: D13=A13*C11+B8. + let qw = cy.mul_add(cxcz, sy * sxsz); + let qz = cy * cxsz - sy * sxcz; + let qx = cy * sxcz - sy * cxsz; + rotate([qx, qy, qz, qw], [0.0, 1.0, 0.0, 0.0]) +} + +///82D3C8CC..C8F8 and82D3CF84..CFFC retain the two fused cross stages. +pub fn rotate(quaternion: V, vector: V) -> V { + let first = math::cross(quaternion, vector); + let middle = math::madd(vector, quaternion[3], first); + math::madd(math::cross(quaternion, middle), 2.0, vector) +} + +///8296EC98: both vectors require squared length >0.0001, then one +///reciprocal-square-root refinement. The negative half returns 2pi-angle. +pub fn signed_angle(left: V, right: V, axis: V) -> f32 { + use crate::physics::board_motion_output::inverse_length_squared; + let a = math::dot(left, left); + let b = math::dot(right, right); + if !(a > f32::from_bits(0x38D1_B717) && b > f32::from_bits(0x38D1_B717)) { + return 0.0; + } + let left = math::scale(left, inverse_length_squared(a, 1)); + let right = math::scale(right, inverse_length_squared(b, 1)); + let angle = crate::trigonometry::acos(math::clamp(math::dot(left, right), -1.0, 1.0)); + if math::dot(math::cross(left, right), axis) < 0.0 { + f32::from_bits(0x40C9_0FDB) - angle + } else { + angle + } +} + +///8286CD88 checks the original lengths before projecting onto the axis plane. +pub fn projected_angle(left: V, right: V, axis: V) -> f32 { + if !(math::dot(left, left) * math::dot(right, right) > f32::from_bits(0x3780_0000)) { + return 0.0; + } + signed_angle( + math::sub(left, math::scale(axis, math::dot(axis, left))), + math::sub(right, math::scale(axis, math::dot(axis, right))), + axis, + ) +} diff --git a/crates/skate-core/src/player/wipeout_state/output.rs b/crates/skate-core/src/player/wipeout_state/output.rs new file mode 100644 index 00000000..08968121 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/output.rs @@ -0,0 +1,69 @@ +//! Physical output stores82D3EEE8. Optional fields are conditional writes, +//! not instructions to clear the corresponding shared output on false paths. +use super::{State, math, orientation}; +use crate::physics::skeleton_animation_record::AnimationPartTransform; + +pub struct Output { + pub over_599: bool, + pub scalar_544: f32, + pub no_support_time_548: f32, + pub can_leave_72: bool, + pub special_surface_81: bool, + pub below_surface_82: bool, + pub surface_height_32: f32, + pub surface_height_valid_83: bool, + pub collision_time_144: f32, + pub profile_148: u32, + pub response_strength_580: f32, + pub extra_weight_584: f32, + pub time_until_teleport_576: f32, + pub teleport_pending_604: bool, + pub material_ten_3479: bool, + pub material_eleven_3480: bool, + pub imminent_surface_twelve_3483: bool, + pub predicted_position_64: [f32; 4], + pub retained_air_velocity_160: Option<[f32; 4]>, + pub response_change_588: Option, + pub teleport_countdown_68: bool, + pub request_teleport_69: bool, + pub hips_right_angle_496: f32, + pub hips_up_angle_500: f32, +} +impl State { + pub fn output(&self, hips: &AnimationPartTransform, normal: [f32; 4]) -> Output { + let right = hips[0]; + let projected = math::sub(normal, math::scale(right, math::dot(normal, right))); + let right_angle = orientation::signed_angle(right, normal, [0.0; 4]); + let up_angle = math::wrap_angle( + orientation::signed_angle(projected, hips[1], right) + f32::from_bits(0x4049_0FDB), + ); + Output { + over_599: self.over, + scalar_544: self.orientation, + no_support_time_548: self.no_support_time, + can_leave_72: self.recovery_eligible && !self.prevent_manual && !self.ignore_reset, + special_surface_81: self.special_surface, + below_surface_82: self.below_surface, + surface_height_32: self.surface_height, + surface_height_valid_83: true, + collision_time_144: self.predicted_time, + profile_148: self.profile as u32, + response_strength_580: self.response_scalar, + extra_weight_584: self.extra_weight, + time_until_teleport_576: self.time_until_teleport, + teleport_pending_604: self.teleport_pending, + material_ten_3479: self.material_ten_response, + material_eleven_3480: self.material_eleven_response, + imminent_surface_twelve_3483: self.imminent_surface_twelve, + predicted_position_64: self.predicted_position, + retained_air_velocity_160: self + .retained_velocity_active + .then_some(self.retained_velocity), + response_change_588: self.response_finished.then_some(self.response_change), + teleport_countdown_68: self.teleport_countdown >= 0, + request_teleport_69: self.request_teleport, + hips_right_angle_496: right_angle, + hips_up_angle_500: up_angle, + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/profiles.rs b/crates/skate-core/src/player/wipeout_state/profiles.rs new file mode 100644 index 00000000..540dba9e --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/profiles.rs @@ -0,0 +1,70 @@ +//! The five authored physics_wipeout_control profiles and original82D3C3F8. +use super::{State,math::{self,V}}; +use crate::{point_graph::PointGraph,physics::skeleton_body::SkeletonBody}; +#[derive(Clone,Debug)] +pub struct Profile { + pub roll_axis:V, //0 + pub horizontal_axis:V, //16 + pub align_euler:V, //32 + pub spin_vs_time:PointGraph<8>, //48, source uses X64/Y96 + pub direction_follow:f32, //128 + pub torque_velocity:f32, //132 + pub torque_distance:f32, //136 + pub spin_inertia:f32, //140 + pub roll_on_ground:bool, //144 + pub ground_roll_torque:f32, //148 + pub sideways_spin:f32, //152 + pub forward_spin:f32, //156 + pub horizontal_directed:bool, //160 + pub drift_maximum_speed:f32, //164 + pub drift_forward:f32, //168 + pub drift_sideways:f32, //172 + pub align_with_velocity:bool, //176 + pub align_ground_with_velocity:bool,//177 + pub ground_align_torque:f32, //180 + pub tilt_degrees:f32, //184 + pub drift_drag:f32, //188 +} +pub fn prepare(state:&mut State,profiles:&[Profile;5], + gesture:[f32;2],input:[f32;2]) { + let [x,z]=gesture; + let selected=if x.abs()<0.2 && z.abs()<0.2 {0} + else if x.abs()<=z.abs() {if z<=0.0 {3}else{1}} + else if x<=0.0 {4}else{2}; + state.control_time+=f32::from_bits(0x3C88_8889); + if selected!=state.profile { + state.control_time=0.0;state.profile=selected; + state.retained_sideways_input=0.0;state.retained_forward_input=0.0; + } + let mut flat_velocity=state.velocity;flat_velocity[1]=0.0; + let normalized=math::normalize_or(flat_velocity,[0.0;4]); + state.forward=if state.direction_initialized { + let follow=profiles[selected].direction_follow; + math::normalize_or(math::madd(normalized,follow, + math::scale(state.forward,1.0-follow)),[0.0;4]) + } else { + state.direction_initialized=true;normalized + }; + state.right=math::cross([0.0,1.0,0.0,0.0],state.forward); + let input=[input[0],0.0,input[1],0.0]; + state.forward_input=math::dot(input,state.forward); + state.sideways_input=math::dot(input,state.right); +} +///Original82D3C928, after air rotational control; preserves body velocity W. +pub fn drift(state:&State,body:&mut SkeletonBody,p:&Profile) { + if state.retained_velocity_active {return;} + let desired=math::madd(state.right,p.drift_sideways*state.sideways_input, + math::scale(state.forward,p.drift_forward*state.forward_input)); + let mut change=state.velocity.map(|v| + f32::from_bits(v.to_bits()^0x8000_0000)*p.drift_drag); + change[0]*=0.02;change[2]*=0.02; + if math::dot(state.velocity,math::normalize_or(desired,[0.0;4])) f32 { + let speed = self.maximum_speed * 0.1; + let count = (4u32.wrapping_sub(self.response_count) as i32) as f32; + let remaining = (count + 1.0) * 0.5; + let result = if speed - remaining >= 0.0 { speed } else { remaining }; + if self.slow_time > 1.0 || result <= 0.2 || self.special_surface || self.below_surface { + 0.0 + } else { result } + } + + pub fn manage_recovery(&mut self, flags2468: u32, flags2472: u32, + flags2484: u32, timestep: f32, settings: &Settings) { + self.prevent_manual |= flags2468 & 1 != 0; + self.ignore_reset |= flags2472 & 0x8000_0000 != 0; + self.reset_ever |= flags2472 & 0x0010_0000 != 0; + self.ever_settled |= self.over; + self.recovery_eligible |= self.ever_settled || self.time > 1.6; + self.settled_time = if self.over { self.settled_time + DT } else { 0.0 }; + if self.teleport_countdown < 0 + && self.should_teleport(flags2472, flags2484, timestep, settings) { + self.teleport_countdown = 2; + } + if self.teleport_countdown == 0 { self.request_teleport = true; } + else if self.teleport_countdown > 0 { self.teleport_countdown -= 1; } + } + + fn should_teleport(&mut self, flags2472: u32, flags2484: u32, + timestep: f32, settings: &Settings) -> bool { + if self.ever_impaled { self.impaled_time += DT; } + if flags2484 & 0x0040_0000 != 0 { + self.ever_impaled = true; + if self.impaled_time > 0.5 { return true; } + } + if self.prevent_manual { return false; } + if self.recovery_eligible && self.reset_ever && !self.ignore_reset { return true; } + if self.teleport_pending { + self.time_until_teleport -= timestep; + return self.time_until_teleport <= 0.0; + } + let automatic = if flags2472 & 0x1000_0000 != 0 { + self.time > 8.0 + } else { + (self.time > settings.maximum_time && self.response_time > settings.maximum_time) + || (self.time > settings.minimum_time + && self.settled_time > settings.minimum_settled + && self.response_time > settings.minimum_settled) + }; + if automatic { + self.teleport_pending = true; + self.time_until_teleport = settings.fade_time; + } + false + } + + pub fn post_physics(&mut self, hips_velocity: [f32;4], neck_velocity: [f32;4], + support_contact: bool, settings: &Settings) { + let bypass_neck = self.extra_weight_zero_time > 0.5; + let speed = if bypass_neck { settings.over_speed * 2.0 } else { settings.over_speed }; + let minimum_time = self.time > settings.over_minimum_time; + let hips_squared = dot3(hips_velocity, hips_velocity); + let neck_squared = dot3(neck_velocity, neck_velocity); + self.over = hips_squared < speed * speed + && (neck_squared < speed * speed || bypass_neck) + && minimum_time && self.response_strength() == 0.0; + self.slow = hips_squared < 1.0 && minimum_time; + self.no_support_time = if support_contact { 0.0 } else { self.no_support_time + DT }; + } +} diff --git a/crates/skate-core/src/player/wipeout_state/response.rs b/crates/skate-core/src/player/wipeout_state/response.rs new file mode 100644 index 00000000..daed8703 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/response.rs @@ -0,0 +1,74 @@ +//! Original Wipeout response82D3E678 and retained-velocity82D3E348/82D3E8C0. +use super::{State,body,drives,math::{self,V}}; +use crate::physics::skeleton_body::{SkeletonBody,SkeletonDrives}; +///82D3C2B0: only upward collision/prediction normals are accepted. +pub fn response_normal(support:Option,prediction:Option)->V { + if let Some(normal)=support.filter(|n| n[1]>0.0) {return normal;} + prediction.filter(|n|n[1]>0.0).unwrap_or([0.0,1.0,0.0,0.0]) +} +pub fn trigger(state:&mut State,body:&mut SkeletonBody,normal:V,input:[f32;2]) { + state.response_frames=0; + state.response_start_speed=math::length(state.velocity); + let strength=state.response_strength(); + if state.maximum_speed*0.1<1.0 { + state.response_count=state.response_count.wrapping_add(1); + } else {state.response_count=0;} + state.maximum_speed=0.0; + state.extra_weight=0.0;state.response_scalar=0.0; + if strength==0.0 {return;} + state.response_time=0.0; + let strength=if strength-1.0>=0.0 {1.0}else{strength}; + state.response_scalar=strength;state.extra_weight=strength; + let mut control=[input[0],0.0,input[1],0.0]; + let projection=math::dot(normal,control); + if projection<0.0 {control=math::sub(control,math::scale(normal,projection));} + //82D3E868: raw vmadd A=normal,C=zero,B=control*1.25. + let velocity=math::scale(math::madd(normal,0.0,math::scale(control,1.25)),strength); + body::add_velocity(body,velocity); +} +pub fn update_counter(state:&mut State,contact:bool) { + state.response_finished=false; + if state.response_frames<0 {return;} + state.response_frames=state.response_frames.wrapping_add(1); + if state.response_frames==3 { + let factor=if state.response_start_speed<1.0 { + 1.0-state.response_start_speed + } else {state.response_start_speed*0.2}; + state.response_change=(math::length(state.velocity)-state.response_start_speed) + *(factor+1.0); + } + if state.response_frames==8 { + state.response_frames=-1;state.response_finished=true; + if !contact {state.response_change=state.response_change.abs()*2.0;} + state.response_change=math::clamp((state.response_change-0.1)*0.2,0.0,1.0); + } +} +pub fn retained_velocity(state:&mut State,body:&mut SkeletonBody, + targets:&mut SkeletonDrives,settings:&drives::Settings, + contact:bool,flags2468:u32,com_velocity:V) { + let requested=flags2468&8!=0; + if state.retained_velocity_active || requested { + if contact { + state.allow_retained_velocity=false; + release(state,body,targets,settings); + } else if !requested { + release(state,body,targets,settings); + } else if !state.retained_velocity_active + &&state.allow_retained_velocity&&com_velocity[1]<2.0 { + drives::set_linear_root(targets,settings,1.0); + state.retained_velocity_active=true; + state.retained_velocity=state.velocity; + } + } + if state.retained_velocity_active { + state.velocity=state.retained_velocity; + state.time-=f32::from_bits(0x3C88_8889); + } +} +fn release(state:&mut State,body:&mut SkeletonBody,targets:&mut SkeletonDrives, + settings:&drives::Settings) { + if !state.retained_velocity_active {return;} + drives::set_linear_root(targets,settings,0.0); + body::set_velocity(body,state.retained_velocity); + state.retained_velocity_active=false;state.velocity=[0.0;4]; +} diff --git a/crates/skate-core/src/player/wipeout_state/skeleton.rs b/crates/skate-core/src/player/wipeout_state/skeleton.rs new file mode 100644 index 00000000..ec4814ac --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/skeleton.rs @@ -0,0 +1,36 @@ +//! Original Skeleton::UpdateRootTransformsWipeout82BDFBE8. +use crate::physics::{ + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + skeleton_root::{SkeletonRootFrames, inverse_rigid}, +}; +const DT: f32 = f32::from_bits(0x3C88_8889); + +///Reconstruct the root from the cached physical hips and animated hips, +///then blend its predicted position toward the actual weighted physical COM. +///This updates animation roots; it does not overwrite any solved body rates. +pub fn update_roots( + roots: &mut SkeletonRootFrames, + animation_hips: &Transform, + physical_hips: &Transform, + hips_velocity: [f32; 4], + physical_com: [f32; 4], + com_velocity: [f32; 4], + state_time: f32, +) { + let inverse_hips = inverse_rigid(animation_hips); + let mut predicted_hips = *physical_hips; + predicted_hips[3] = std::array::from_fn(|i| + hips_velocity[i].mul_add(DT, physical_hips[3][i])); + let mut root = compose_affine(&predicted_hips, &inverse_hips); + let weight = state_time + DT; + let weight = if -weight >= 0.0 { 0.0 } else { weight }; + let weight = if 1.0 - weight >= 0.0 { weight } else { 1.0 }; + let mut desired = physical_com; + desired[1] += f32::from_bits(0xBF4C_CCCD); + root[3] = std::array::from_fn(|i| { + let predicted = com_velocity[i].mul_add(DT, root[3][i]); + desired[i].mul_add(weight, predicted * (1.0 - weight)) + }); + roots.animation_to_world = root; + roots.world_to_animation = inverse_rigid(&root); +} diff --git a/crates/skate-core/src/player/wipeout_state/torque.rs b/crates/skate-core/src/player/wipeout_state/torque.rs new file mode 100644 index 00000000..4f7825b3 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/torque.rs @@ -0,0 +1,32 @@ +//! Original SkeletonState angular estimate82BEC0E8 and torque82BE3B58. +use super::math::{self,V}; +use crate::physics::skeleton_body::{SkeletonBody,SkeletonPhysicalRecord}; + +///4656 contains the same fractional masses used by physical COM publication: +///original Init82BEBAA8 divides each4560 mass by total5216 at82BEBB80. +pub fn angular_velocity(record:&SkeletonPhysicalRecord,fractional:&[f32;24])->V { + let mut result=[0.0;4]; + for part in 1..24 { + let radial=math::sub(record.positions[part],record.centre_of_mass); + let velocity=math::sub(record.velocities[part],record.centre_of_mass_velocity); + let squared=math::dot(radial,radial); + if squared>f32::from_bits(0x38D1_B717) { + let term=math::scale(math::cross(radial,velocity),math::reciprocal(squared)); + result=math::madd(term,fractional[part],result); + } + } + result +} + +///82BE3B58 converts the control magnitude and direction into original +///per-part displacement accumulators. Actual solved velocities remain intact. +pub fn apply(body:&mut SkeletonBody,physical_com:V,control:V) { + let magnitude=math::length(control); + for part in 1..24 { + let radial=math::sub(body.record.pose[part][3],physical_com); + if math::dot(radial,radial)>0.001 { + let tangent=math::normalize_or(math::cross(control,radial),[0.0;4]); + body.apply_part_displacement(part,math::scale(tangent,magnitude)); + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/weights.rs b/crates/skate-core/src/player/wipeout_state/weights.rs new file mode 100644 index 00000000..a6dcd3b5 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/weights.rs @@ -0,0 +1,65 @@ +//! Wipeout Update82D3BE04..82D3BFE8, after the selected physical control. +use super::{State, math}; + +#[derive(Clone, Copy)] +pub struct Settings { + pub remove_target_time: f32, + pub remove_drives_time: f32, + /// Native352 is a per-update increment, not a per-second velocity. + pub collision_step: f32, + /// Native348 is also a per-update increment. + pub controlled_step: f32, +} + +#[derive(Clone, Copy)] +pub struct Output { + pub target_weight: f32, + pub start: f32, + pub end: f32, + pub controlled: f32, + pub extra: f32, +} + +pub fn update( + state: &mut State, + settings: Settings, + contact: bool, + control_applied: bool, + flags_2472: u32, + gesture: [f32; 2], +) -> Output { + let target_weight = fade(settings.remove_target_time, state.time); + if state.special_surface || (gesture[0].abs() < 0.2 && gesture[1].abs() < 0.2) { + let step = if contact { + settings.collision_step + } else { + -settings.collision_step + }; + state.collision_weight = math::clamp(state.collision_weight + step, 0.0, 1.0); + } else { + state.collision_weight = 0.0; + } + let drive_fade = fade(settings.remove_drives_time, state.time); + let drive_fade_squared = drive_fade * drive_fade; + let enabled = (control_applied || flags_2472 & 0x0008_0000 != 0) && !state.special_surface; + let step = if enabled { + settings.controlled_step + } else { + -settings.controlled_step + }; + state.controlled_weight = math::clamp(state.controlled_weight + step, 0.0, 1.0); + let available = 1.0 - state.collision_weight; + let start = available * drive_fade_squared; + Output { + target_weight, + start, + end: state.collision_weight, + controlled: (available - start) * state.controlled_weight, + extra: state.extra_weight * 0.03, + } +} + +fn fade(duration: f32, time: f32) -> f32 { + let divisor = if duration == 0.0 { 0.01 } else { duration }; + math::clamp((duration - time) / divisor, 0.0, 1.0) +} diff --git a/crates/skate-core/src/point_graph.rs b/crates/skate-core/src/point_graph.rs new file mode 100644 index 00000000..77108ada --- /dev/null +++ b/crates/skate-core/src/point_graph.rs @@ -0,0 +1,34 @@ +//! TU3 `PointGraph` scalar evaluator, `0x82481E10`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointGraph { + pub x: [f32; N], + pub y: [f32; N], +} + +impl PointGraph { + /// Native endpoint handling and slope-first binary32 interpolation. + pub fn evaluate(&self, input: f32) -> f32 { + assert!(N > 0); + if input < self.x[0] { + return self.y[0]; + } + // The native branch enters the scan only on an ordered less-than. + // Unordered input or a NaN final key therefore selects the last value. + if !(input < self.x[N - 1]) { + return self.y[N - 1]; + } + for upper in 1..N { + if !(input < self.x[upper]) { + continue; + } + let lower = upper - 1; + let width = self.x[upper] - self.x[lower]; + if width <= 0.0 { + return self.y[upper]; + } + let slope = (self.y[upper] - self.y[lower]) / width; + return slope.mul_add(input - self.x[lower], self.y[lower]); + } + self.y[0] + } +} diff --git a/crates/skate-core/src/riding/anti_flip.rs b/crates/skate-core/src/riding/anti_flip.rs new file mode 100644 index 00000000..708124a4 --- /dev/null +++ b/crates/skate-core/src/riding/anti_flip.rs @@ -0,0 +1,49 @@ +//! Complete TU3 Toolkit_AntiFlipTorque 82D94190, distinct from heading/manual. +use super::vector::{cross, dot3, normalize}; +use crate::{point_graph::PointGraph, trigonometry::acos}; +pub struct AntiFlipSettings { + pub axis_96_response: PointGraph<8>, // +1832/+1864 + pub axis_64_response: PointGraph<8>, // +1912/+1944 + pub normal_threshold: [f32; 4], // live 830BD350 +} +pub struct AntiFlipInput { + pub flags_2468: u32, + pub balance: f32, // +2720 + pub axis_96: [f32; 4], + pub axis_64: [f32; 4], + pub projection_axis_544: [f32; 4], +} +pub fn calculate(s: &AntiFlipSettings, i: &AntiFlipInput) -> [f32; 4] { + if i.flags_2468 & 0x2000_0000 != 0 { + return [0.0; 4]; + } + let project = |axis: [f32; 4]| { + let dot = dot3(i.projection_axis_544, axis); + normalize( + core::array::from_fn(|n| axis[n] - i.projection_axis_544[n] * dot), + s.normal_threshold, + ) + }; + let a = project(i.axis_96); + let b = project(i.axis_64); + let angle = |axis, projected| { + let d = dot3(axis, projected); + let d = if 0.0 > d { 0.0 } else { d }; + let d = if 1.0 < d { 1.0 } else { d }; + acos(d) * f32::from_bits(0x3f22_f983) + }; + let sign_a = if dot3(i.axis_64, cross(i.axis_96, a)) >= 0.0 { + 1.0 + } else { + -1.0 + }; + let sign_b = if dot3(i.axis_96, cross(i.axis_64, b)) >= 0.0 { + 1.0 + } else { + -1.0 + }; + let b_strength = s.axis_64_response.evaluate(angle(i.axis_64, b)) * sign_b; + let a_strength = (s.axis_96_response.evaluate(angle(i.axis_96, a)) * sign_a) + * if i.balance == 0.0 { 1.0 } else { 0.0 }; + core::array::from_fn(|n| i.axis_64[n].mul_add(a_strength, i.axis_96[n] * b_strength)) +} diff --git a/crates/skate-core/src/riding/braking.rs b/crates/skate-core/src/riding/braking.rs new file mode 100644 index 00000000..b1a16ef7 --- /dev/null +++ b/crates/skate-core/src/riding/braking.rs @@ -0,0 +1,96 @@ +//! TU3 ground braking and linear drag, using the existing ground-force +//! direct82D94AE0/82D94A20 exports and SKATE3_MAIN_SKATER_FUNCTIONS.md. +//! Cached ground settings are explicit inputs; no replacement tuning defaults. +use crate::{math::Vector3, physics::force_queue::QueuedPointForce}; + +#[derive(Clone, Copy, Debug)] +pub struct BrakeSettings { + /// Cached settings +20, multiplied by processed input +2728. + pub input_force: f32, + /// Cached settings +24. The second request overrides the first. + pub override_force: f32, + /// Cached settings +28, compared against processed absolute speed +2616. + pub minimum_speed: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct BrakeInput { + pub flags_2468: u32, + pub input_2728: f32, + pub signed_speed: f32, + pub absolute_body_speed: f32, + /// Selected surface collection's layout +264. + pub surface_factor: f32, + /// Processed vector +368, used without normalization. + pub direction: Vector3, +} + +/// Toolkit_CalcBraking 82D94AE0. GroundUpdate appends even a zero result +/// with tag 2 at 82D391A0. This result is force, not a velocity assignment. +pub fn calculate_braking(input: BrakeInput, settings: BrakeSettings) -> QueuedPointForce { + let mut amount = if input.flags_2468 & 0x4000_0000 != 0 { + input.input_2728 * settings.input_force + } else { + 0.0 + }; + if input.flags_2468 & 0x2000_0000 != 0 { + amount = settings.override_force; + } + let scaled = input.surface_factor * amount; + let sign = if input.signed_speed < 0.0 { 1.0 } else { -1.0 }; + amount = sign * scaled; + //82D94B78 bge retains the amount when the comparison is unordered. + if input.absolute_body_speed < settings.minimum_speed { + amount = 0.0; + } + QueuedPointForce { + tag: 2, + force_world: Vector3::new( + input.direction.x * amount, + input.direction.y * amount, + input.direction.z * amount, + ), + point_body: Vector3::ZERO, + } +} + +#[derive(Clone, Copy, Debug)] +pub struct LinearDragSettings { + /// Cached +1244; multiplied by native constant 2.0 at 82060C50. + pub brake_speed: f32, + /// Cached +1248. + pub balance_speed: f32, + /// Cached +1252. Original semantic name remains unresolved. + pub comparison_threshold: f32, + /// Cached +1256. + pub balance_drag: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct LinearDragInput { + pub flags_2468: u32, + pub absolute_body_speed: f32, + pub balance_2720: f32, + pub scalar_2724: f32, + /// Floating argument f1 supplied by GroundUpdate, distinct from speed. + pub comparison_scalar: f32, +} + +/// Toolkit_CalcLinearDrag 82D94A20, including its low-speed brake override. +/// Applying the result to inertia is a separate caller operation. +pub fn calculate_linear_drag(input: LinearDragInput, settings: LinearDragSettings) -> f32 { + let brake_override = input.absolute_body_speed < settings.brake_speed * 2.0 + && input.flags_2468 & 0x6000_0000 != 0 + && !(input.scalar_2724 > 0.0) + && input.balance_2720 == 0.0; + let balance_drag = input.balance_2720 != 0.0 + && input.absolute_body_speed < settings.balance_speed + && input.comparison_scalar > settings.comparison_threshold; + if brake_override { + 1.0 + } else if balance_drag { + settings.balance_drag + } else { + 0.0 + } +} diff --git a/crates/skate-core/src/riding/collision_response.rs b/crates/skate-core/src/riding/collision_response.rs new file mode 100644 index 00000000..9db6a2af --- /dev/null +++ b/crates/skate-core/src/riding/collision_response.rs @@ -0,0 +1,189 @@ +//! Complete collision response calculation, TU3 82D944E8 and its angle helpers. +use crate::{ + math::Vector3, + physics::{ + board_ground::angle_between, + board_motion_output::{dot, inverse_length_squared, length}, + native_arithmetic::reciprocal_estimate, + }, + point_graph::PointGraph, +}; +#[derive(Clone, Debug)] +pub struct CollisionResponseSettings { + pub maximum_velocity_delta: f32, + pub force_y_offset: f32, + pub force_scalar: f32, + pub target_displacement_velocity: f32, + pub torque_vs_angle: PointGraph<8>, +} +#[derive(Clone, Copy, Debug)] +pub struct CollisionResponsePhysical { + pub flags_2472: u32, + /// Processed736,416,448,544 and Ground1216 respectively. + pub collision_displacement: [f32; 4], + pub velocity: [f32; 4], + pub forward: [f32; 4], + pub up: [f32; 4], + pub ground_normal: [f32; 4], + /// Processed2604 and2660. + pub time_step: f32, + pub mass: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CollisionResponse { + /// Native return value. False after the delta threshold still writes all + /// three outputs, including the target velocity and angular correction. + pub applied: bool, + pub force: [f32; 4], + pub point: [f32; 4], + pub angular_displacement: [f32; 4], + pub target_velocity: [f32; 4], +} +/// None represents the two early returns which write no outputs. The native +/// late false return must remain distinguishable from them. +pub fn collision_response( + settings: &CollisionResponseSettings, + input: CollisionResponsePhysical, +) -> Option { + if input.flags_2472 & 0x20000 == 0 { + return None; + } + let projection = dot(xyz(input.collision_displacement), xyz(input.ground_normal)); + let displaced: [f32; 4] = std::array::from_fn(|i| { + input.collision_displacement[i] - input.ground_normal[i] * projection + }); + let distance = length(xyz(displaced)); + if distance < f32::from_bits(0x3780_0000) { + return None; + } + let inverse_distance = 1.0 / distance; //scalar fdivs, not vector estimate. + let direction: [f32; 4] = displaced.map(|v| v * inverse_distance); + let speed_toward = dot(xyz(input.velocity), xyz(direction)); + let tangent: [f32; 4] = + std::array::from_fn(|i| input.velocity[i] - direction[i] * speed_toward); + let target_velocity = std::array::from_fn(|i| { + direction[i].mul_add(settings.target_displacement_velocity, tangent[i]) + }); + let mut delta = + std::array::from_fn(|i| (target_velocity[i] - input.velocity[i]) * settings.force_scalar); + if speed_toward > settings.target_displacement_velocity { + delta = [0.; 4]; + } + let magnitude = length(xyz(delta)); + let inverse_dt = reciprocal_refined(input.time_step); + let mut force = delta.map(|v| (v * input.mass) * inverse_dt); + let applied = magnitude >= 0.01; + if applied { + let clamp = if magnitude <= settings.maximum_velocity_delta { + 1.0 + } else { + settings.maximum_velocity_delta / magnitude + }; + force = force.map(|v| v * clamp); + } else { + force = [0.; 4]; + } + let angle = wrap_angle(signed_angle( + xyz(input.forward), + xyz(direction), + xyz(input.up), + )); + let magnitude = settings + .torque_vs_angle + .evaluate(angle.abs() * f32::from_bits(0x3ea2_f983)); + let signed = if angle >= 0.0 { magnitude } else { -magnitude }; + Some(CollisionResponse { + applied, + force, + point: [0.0, settings.force_y_offset, 0.0, 0.0], + angular_displacement: input.up.map(|v| v * signed), + target_velocity, + }) +} +///8296EC98, one-refinement normalization and signed cross about supplied up. +pub fn signed_angle(a: Vector3, b: Vector3, up: Vector3) -> f32 { + let a_squared = dot(a, a); + let b_squared = dot(b, b); + if !(a_squared > 0.0001 && b_squared > 0.0001) { + return 0.0; + } + let angle = angle_between(a, b); + let a = scale(a, inverse_length_squared(a_squared, 1)); + let b = scale(b, inverse_length_squared(b_squared, 1)); + let cross = Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ); + if dot(cross, up) < 0.0 { + f32::from_bits(0x40c9_0fdb) - angle + } else { + angle + } +} +///8258DB98 scalar truncation, fused wrap subtraction, then single range repair. +fn wrap_angle(mut angle: f32) -> f32 { + let pi = f32::from_bits(0x4049_0fdb); + let tau = f32::from_bits(0x40c9_0fdb); + if angle < -pi || !(angle < pi) { + let turns = (angle * f32::from_bits(0x3e22_f983)) as i32; + angle = (-(turns as f32)).mul_add(tau, angle); + if !(angle < pi) { + angle -= tau; + } else if angle < -pi { + angle += tau; + } + } + angle +} +fn reciprocal_refined(value: f32) -> f32 { + let estimate = reciprocal_estimate(value); + let first = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + first.mul_add((-first).mul_add(value, 1.0), first) +} +fn scale(v: Vector3, k: f32) -> Vector3 { + Vector3::new(v.x * k, v.y * k, v.z * k) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn late_false_still_publishes_collision_target_and_angle() { + let settings = CollisionResponseSettings { + maximum_velocity_delta: 8., + force_y_offset: -0.05, + force_scalar: 1., + target_displacement_velocity: 1., + torque_vs_angle: PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [0.03; 8], + }, + }; + let mut input = CollisionResponsePhysical { + flags_2472: 0x20000, + collision_displacement: [1., 0., 0., 0.], + velocity: [2., 0., 4., 0.], + forward: [0., 0., 1., 0.], + up: [0., 1., 0., 0.], + ground_normal: [0., 1., 0., 0.], + time_step: 1. / 60., + mass: 8., + }; + let late = collision_response(&settings, input).unwrap(); + assert!(!late.applied); + assert_eq!(late.force, [0.; 4]); + assert_eq!(late.target_velocity, [1., 0., 4., 0.]); + assert!(late.angular_displacement[1] > 0.); + input.velocity = [-20., 0., 4., 0.]; + let clamped = collision_response(&settings, input).unwrap(); + assert!(clamped.applied); + assert!((clamped.force[0] - 3840.).abs() < 0.001); + assert_eq!(clamped.point, [0., -0.05, 0., 0.]); + input.collision_displacement = input.ground_normal; + assert!(collision_response(&settings, input).is_none()); + } +} + diff --git a/crates/skate-core/src/riding/ground_contact_response.rs b/crates/skate-core/src/riding/ground_contact_response.rs new file mode 100644 index 00000000..caeeb9a7 --- /dev/null +++ b/crates/skate-core/src/riding/ground_contact_response.rs @@ -0,0 +1,148 @@ +//! Wall-ride contact response, TU3 82D93DF0. Inputs are the native published +//! board contact record and processed physical observations, not ray guesses. +use crate::{ + math::Vector3, + physics::{board_motion_output::dot, force_queue::QueuedPointForce}, + point_graph::PointGraph, + riding::grounded::state::board_types::{GroundContactFrame, GroundContactResponse}, +}; + +#[derive(Clone, Debug)] +pub struct WallRideSettings { + pub anti_gravity_vs_time: PointGraph<8>, + pub max_dot_floor_wall: f32, + pub foot_force_time: f32, + pub auto_jump_height: f32, + pub max_time: f32, + pub velocity_time_to_consider: f32, + pub auto_jump_y_down_scalar: f32, + pub auto_jump_force: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct WallRidePhysical { + /// Processed464,544 and400 respectively. + pub board_normal: [f32; 4], + pub up: [f32; 4], + pub velocity: [f32; 4], + /// Processed2660,2648,2652,2556. + pub board_mass: f32, + pub gravity: f32, + pub speed: f32, + pub contact_count: i32, +} + +pub fn wall_ride_response( + settings: &WallRideSettings, + physical: WallRidePhysical, + frame: GroundContactFrame, + previous_velocity: [f32; 4], +) -> GroundContactResponse { + let mut response = GroundContactResponse { + active_2731: false, + tag_16_force: QueuedPointForce { + tag: 16, + ..Default::default() + }, + vector_2688: previous_velocity, + scalar_2704: 0.0, + animated_board_2708: false, + }; + //82D94C10 installs literal .5 and .71 in the two first gates. + if !frame.flag_8084 + || !(physical.board_normal[1].abs() < 0.5) + || !(dot(xyz(physical.up), xyz(physical.board_normal)) > 0.71) + { + return response; + } + response.active_2731 = true; + let acceleration = settings.anti_gravity_vs_time.evaluate(frame.scalar_2752); + response.tag_16_force.force_world = Vector3::new( + 0.0, + -((acceleration * physical.board_mass) * physical.gravity), + 0.0, + ); + let time = frame.scalar_2756; + let consider = (time < 0.0 && time > -1.0) || (physical.speed < 3.0 && time > 0.1); + if !(consider + || (frame.scalar_2752 > 0.01 && physical.contact_count > 0) + || frame.scalar_2752 > settings.foot_force_time) + { + return response; + } + //The dot is loaded BEFORE the temporary slot is overwritten by the + //height/velocity expression at82D94044. Older decompiles alias the two. + let floor_wall = dot(xyz(frame.vector_8064), xyz(physical.board_normal)); + let height = (frame.vector_8048[1] - frame.vector_8032[1]).abs(); + let predicted_height = physical.velocity[1].mul_add(settings.velocity_time_to_consider, height); + if !(floor_wall < settings.max_dot_floor_wall + && (consider || predicted_height < settings.auto_jump_height || time > settings.max_time)) + { + return response; + } + response.animated_board_2708 = true; + let into_normal = dot(xyz(physical.board_normal), xyz(physical.velocity)); + let mut tangent: [f32;4] = + std::array::from_fn(|i| physical.velocity[i] - (physical.board_normal[i] * into_normal)); + if tangent[1] < 0.0 && tangent[1] > -6.0 { + tangent[1] *= settings.auto_jump_y_down_scalar; + } + response.vector_2688 = std::array::from_fn(|i| { + physical.board_normal[i].mul_add(settings.auto_jump_force, tangent[i]) + }); + response +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn contact_force_and_auto_jump_have_distinct_gates_and_keep_old_velocity() { + let settings = WallRideSettings { + anti_gravity_vs_time: PointGraph { x: [0., 1., 2., 3., 4., 5., 6., 7.], y: [0.1; 8] }, + max_dot_floor_wall: 0.5, + foot_force_time: 0.09, + auto_jump_height: 0.6, + max_time: 1., + velocity_time_to_consider: 0.25, + auto_jump_y_down_scalar: 0.66, + auto_jump_force: 3., + }; + let physical = WallRidePhysical { + board_normal: [1., 0., 0., 0.], + up: [1., 0., 0., 0.], + velocity: [2., -2., 4., 0.], + board_mass: 8., + gravity: 9.81, + speed: 5., + contact_count: 0, + }; + let mut frame = GroundContactFrame { + vector_8032: [0.; 4], + vector_8048: [0., 0.2, 0., 0.], + vector_8064: [0., 1., 0., 0.], + word_8080: 0, + flag_8084: true, + scalar_2752: 0., + scalar_2756: 0., + }; + let old = [9.; 4]; + let force = wall_ride_response(&settings, physical, frame, old); + assert!(force.active_2731); + assert!(!force.animated_board_2708); + assert_eq!(force.vector_2688, old); + assert!((force.tag_16_force.force_world.y + 7.848).abs() < 0.00001); + frame.scalar_2752 = 0.1; + let launch = wall_ride_response(&settings, physical, frame, old); + assert!(launch.animated_board_2708); + assert_eq!(launch.vector_2688, [3., -1.32, 4., 0.]); + frame.vector_8064 = physical.board_normal; + assert!(!wall_ride_response(&settings, physical, frame, old).animated_board_2708); + frame.flag_8084 = false; + let no_contact = wall_ride_response(&settings, physical, frame, old); + assert!(!no_contact.active_2731); + assert_eq!(no_contact.tag_16_force.force_world, Vector3::ZERO); + } +} diff --git a/crates/skate-core/src/riding/ground_correction_math.rs b/crates/skate-core/src/riding/ground_correction_math.rs new file mode 100644 index 00000000..690dc3f1 --- /dev/null +++ b/crates/skate-core/src/riding/ground_correction_math.rs @@ -0,0 +1,184 @@ +//! Remaining numerical ground corrections from TU3 82D38800 and its callees. +//! These operate on actual physical vectors; phase/counter gates stay in the +//! grounded state owner and all body writes target the shared board solver. +use crate::{ + math::Vector3, + physics::{ + board_motion_output::{dot, inverse_length_squared, length}, + native_arithmetic::reciprocal_estimate, + }, +}; + +///82D38838..AC: length of Skeleton11008, with two reciprocal-square-root refinements. +pub fn center_of_mass_height(com_to_deck_world: [f32; 4]) -> f32 { + length(xyz(com_to_deck_world)) +} + +pub fn dot_product(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} + +/// 82D38E18..82D38EA8: normalize the force with two refinements, reject +/// lengths no greater than the live native epsilon, then dot with velocity. +/// Velocity deliberately remains unnormalized. +pub fn collision_force_projection(force: [f32; 4], velocity: [f32; 4]) -> f32 { + let squared = dot(xyz(force), xyz(force)); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let direction = if length > f32::from_bits(0x3586_37bd) { + force.map(|v| v * inverse) + } else { + [0.0; 4] + }; + dot(xyz(direction), xyz(velocity)) +} + +/// 82D37048: edge direction is divided by its computed length. The native +/// code uses two reciprocal refinements after its two square-root refinements; +/// it does not replace that sequence with direct inverse-length scaling. +pub fn edge_direction(start: [f32; 4], end: [f32; 4]) -> [f32; 4] { + let delta = subtract(end, start); + let inverse = reciprocal_refined(length(xyz(delta))); + delta.map(|v| v * inverse) +} + +/// 82D370F8: positive-Y perpendicular to an edge. The degenerate result is +/// native world X (82139A10), not world Y or an arbitrary orthogonal vector. +pub fn edge_up(direction: [f32; 4]) -> [f32; 4] { + let first = cross([0., 1., 0., 0.], direction); + let perpendicular = cross(first, direction); + let magnitude = length(xyz(perpendicular)); + if magnitude <= 0.0 { + return [1., 0., 0., 0.]; + } + let signed_length = if perpendicular[1] < 0.0 { + -magnitude + } else { + magnitude + }; + let inverse = reciprocal_refined(signed_length); + perpendicular.map(|v| v * inverse) +} + +/// 82D395AC..82D396AC: push away from the obstructing edge and upward. +/// Inputs are Processed1312,1328 and the actual deck position112. +pub fn hang_force(edge_start: [f32; 4], edge_end: [f32; 4], deck: [f32; 4]) -> [f32; 4] { + let direction = edge_direction(edge_start, edge_end); + let up = edge_up(direction); + let side = cross(up, direction); + let side = if dot(xyz(subtract(deck, edge_start)), xyz(side)) < 0.0 { + side.map(|v| v * -1.0) + } else { + side + }; + let lift = [0., 160., 0., 0.]; + std::array::from_fn(|i| side[i].mul_add(50.0, lift[i])) +} + +/// 82D39950..82D399FC: signed deck-Z cross deck-Y, scaled by the native +/// displacement0.007. The caller already checked halfpipe/contact geometry. +pub fn wheel_catch_displacement(deck_y: [f32; 4], deck_z: [f32; 4]) -> [f32; 4] { + let signed_z = if deck_z[1] > 0.0 { + deck_z + } else { + deck_z.map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)) + }; + cross(signed_z, deck_y).map(|v| v * f32::from_bits(0x3be5_6042)) +} + +/// 82D39B04..82D39B8C: only captured X/Z are restored. Current position Y +/// appears in both terms, so no vertical pinning correction is introduced. +pub fn pinning_velocity(position: [f32; 4], captured_x: f32, captured_z: f32, dt: f32) -> [f32; 4] { + let target = [captured_x, position[1], captured_z, 0.0]; + let inverse_dt = reciprocal_refined(dt); + subtract(target, position).map(|v| v * inverse_dt) +} + +pub fn scale_to_magnitude(vector: [f32; 4], squared: f32, magnitude: f32) -> [f32; 4] { + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + // 82D394D8 divides50 by the computed length with scalar fdivs. + let factor = magnitude / length; + vector.map(|v| v * factor) +} + +fn reciprocal_refined(value: f32) -> f32 { + let estimate = reciprocal_estimate(value); + let first = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + first.mul_add((-first).mul_add(value, 1.0), first) +} +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn subtract(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + std::array::from_fn(|i| a[i] - b[i]) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn corrections_preserve_native_axes_and_units() { + let force = hang_force([0.; 4], [0., 0., 2., 0.], [1., 0., 0., 0.]); + assert!((force[0] - 50.).abs() < 0.0001); + assert_eq!(force[1], 160.); + assert_eq!(edge_up([0., 1., 0., 0.]), [1., 0., 0., 0.]); + assert_eq!( + collision_force_projection([0., 0., 4., 0.], [0., 0., -3., 0.]), + -3. + ); + assert_eq!(collision_force_projection([0.; 4], [1., 2., 3., 0.]), 0.); + let pin = pinning_velocity([4., 9., 8., 0.], 3., 10., 1. / 60.); + assert!((pin[0] + 60.).abs() < 0.00001); + assert_eq!(pin[1], 0.); + assert!((pin[2] - 120.).abs() < 0.00002); + assert!(wheel_catch_displacement([0., 0., 1., 0.], [0., 1., 0., 0.])[0] > 0.); + } +} + +/// ApplyWorldSpaceForceToDeck82C03F98. Its moment arm is measured from the +/// physical deck part origin returned by82585CB0, not the body's COM origin. +pub fn apply_world_force( + body: &mut crate::physics::assembly::BodySnapshot, + deck_part_position: Vector3, + force: Vector3, + point: Vector3, +) { + let arm = Vector3::new( + point.x - deck_part_position.x, + point.y - deck_part_position.y, + point.z - deck_part_position.z, + ); + let torque = cross([arm.x, arm.y, arm.z, 0.], [force.x, force.y, force.z, 0.]); + let inverse = body.inertia.inverse_mass; + body.rates.force_acceleration.x += force.x * inverse; + body.rates.force_acceleration.y += force.y * inverse; + body.rates.force_acceleration.z += force.z * inverse; + let columns = body.rates.world_inverse_inertia.columns; + let angular = |i: usize| { + columns[2][i].mul_add( + torque[2], + columns[1][i].mul_add(torque[1], columns[0][i] * torque[0]), + ) + }; + body.rates.torque_acceleration.x += angular(0); + body.rates.torque_acceleration.y += angular(1); + body.rates.torque_acceleration.z += angular(2); + body.rates.cool_down = 0; +} diff --git a/crates/skate-core/src/riding/ground_force.rs b/crates/skate-core/src/riding/ground_force.rs new file mode 100644 index 00000000..ab6ea08c --- /dev/null +++ b/crates/skate-core/src/riding/ground_force.rs @@ -0,0 +1,43 @@ +//! Complete TU3 82D931E8, called twice by Ground Update for tags 4 and 5. +//! The research index has not recovered its original name. Keep its four +//! caller-supplied scalar arguments explicit instead of inventing units. +use super::vector::{clamp, dot3, normalize}; +pub struct GroundForceSettings { + pub range_1220: f32, + pub speed_scale_1224: f32, + pub scale_1228: f32, + pub normal_threshold: [f32; 4], +} +pub struct GroundForceInput { + pub argument_1: f32, + pub application_z: f32, + pub argument_3: f32, + pub argument_4: f32, + pub balance: f32, // Processed +2720 + pub surface_speed: f32, // +2656 + pub axis_384: [f32; 4], + pub velocity_400: [f32; 4], + pub axis_544: [f32; 4], +} +pub fn calculate(s: &GroundForceSettings, i: &GroundForceInput) -> [f32; 8] { + let fraction = clamp(i.argument_1 / s.range_1220, 0.0, 1.0); + // Native double literals at822F8700/+8 are0 and1, narrowed at zero arg1. + let zero_scalar = if i.argument_1 != 0.0 || i.balance != 0.0 { + 1.0 + } else { + 0.0 + }; + let speed = (i.surface_speed * 0.125) * s.speed_scale_1224; + let remainder = ((1.0 - fraction) * s.scale_1228) * zero_scalar; + let magnitude = -(speed.mul_add(fraction, remainder) * i.argument_4); + let force = + core::array::from_fn(|n| i.axis_544[n].mul_add(magnitude, (-i.axis_384[n]) * i.argument_3)); + let direction = normalize(i.velocity_400, s.normal_threshold); + let projected = dot3(direction, force); + let mut result = [0.0; 8]; + for n in 0..4 { + result[n] = force[n] - direction[n] * projected; + } + result[6] = i.application_z; + result +} diff --git a/crates/skate-core/src/riding/ground_orientation.rs b/crates/skate-core/src/riding/ground_orientation.rs new file mode 100644 index 00000000..d38c6e91 --- /dev/null +++ b/crates/skate-core/src/riding/ground_orientation.rs @@ -0,0 +1,190 @@ +//! Riding up-vector and ground-normal calculation in TU3 Reckoning82D8C8F0. +//! Inputs are assembled by82D8E5C0; this module owns the three persistent +//! filters, target, acceleration and damping. Transform/tilt publication is a +//! separate downstream calculation. Numerical primitives remain unverified +//! against independent hardware captures. +use crate::{air::ground_normal::GroundNormalFilter, math::Vector3, point_graph::PointGraph}; +use crate::physics::{board_ground::angle_between, board_motion_output::{add, subtract, scale, dot, inverse_length_squared, length}, native_arithmetic}; + +const UP: Vector3 = Vector3::new(0.0, 1.0, 0.0); +const EPSILON: f32 = f32::from_bits(0x3586_37BD); //82F826F8 installs82181A88 at830BD350. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOrientationSettings { + pub ground_normal_smoothing: [f32; 4], + pub up_vector_smoothing_slow: [f32; 4], + pub up_vector_smoothing_fast: [f32; 4], + pub dynamic_up_vs_ground_y: PointGraph<8>, + pub ground_vector_blend: PointGraph<8>, + /// The eight x/y pairs inside PointNegGraphData8 at layout256+16. + ///82D8CF10 calls ordinary PointGraphEval directly, ignoring its sign mode. + pub deck_angle_usage_vs_speed: PointGraph<8>, + pub up_vector_smoothing_vs_speed: PointGraph<8>, + pub up_vector_max_delta_vs_speed: PointGraph<8>, + pub ground_blend_max_delta: f32, + pub up_vector_max_acceleration: f32, + pub anti_wobble_damping: f32, + pub extra_side_damping: f32, + pub minimum_wheels_for_ground_blend: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOrientationInput { + /// Processed752, copied from Skeleton11008 in82BD8918. + pub com_to_deck: Vector3, + /// Processed464 <- Ground96 (retained wheel normal). + pub ground_normal: Vector3, + /// Processed528 <- Ground64 (overall normal). + pub dynamic_up: Vector3, + /// Processed2652 <- SkateboardMotion160 (raw deck speed). + pub speed: f32, + pub wheel_contact_count: i32, + /// Processed2720; the native deck-angle gate tests exact zero. + pub animation_balance: f32, + /// Processed2616 input to DeckAngleUsageVsSpeed. + pub deck_angle_curve_input: f32, + /// Processed80/96: current board transform Y/Z. + pub board_up: Vector3, + pub board_forward: Vector3, + /// Processed160: effective board transform Z. + pub effective_board_forward: Vector3, + /// The previous complete Reckoning transform X (native816), including flip. + pub previous_reckoning_right: Vector3, + /// Processed2476 bit30, the coffin restriction. + pub prevent_up_behind_board: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroundOrientation { + pub dynamic_up: Vector3, + pub up: Vector3, + pub target: Vector3, + pub up_velocity: Vector3, + pub ground_normal: Vector3, + pub ground_blend: f32, + pub(crate) ground_filter: GroundNormalFilter, + pub(crate) slow_filter: GroundNormalFilter, + pub(crate) fast_filter: GroundNormalFilter, +} +impl GroundOrientation { + /// Native82D8DA68 filter history and82D8C3A8/82D8BE38 vector seeds. + pub fn new(settings: &GroundOrientationSettings) -> Self { + Self { + dynamic_up: UP, up: UP, target: UP, up_velocity: Vector3::ZERO, + ground_normal: UP, ground_blend: 0.0, + ground_filter: GroundNormalFilter::initialized(settings.ground_normal_smoothing, lanes(UP)), + slow_filter: GroundNormalFilter::initialized(settings.up_vector_smoothing_slow, lanes(UP)), + fast_filter: GroundNormalFilter::initialized(settings.up_vector_smoothing_fast, lanes(UP)), + } + } + + /// Reset82D8C3A8 resets these vectors/scalars but preserves filter history. + pub fn reset(&mut self) { + self.dynamic_up = UP; + self.up = UP; + self.target = UP; + self.up_velocity = Vector3::ZERO; + self.ground_normal = UP; + self.ground_blend = 0.0; + } + /// Complete up/normal branch82D8C934..82D8D650, before CalculateTransform, + /// CalculateDynamicLean and CalculateTilt. BodySpin runs before this stage. + pub fn update(&mut self, settings: &GroundOrientationSettings, input: GroundOrientationInput) { + let normalized_com = normalize_safe(input.com_to_deck, Vector3::ZERO); + let amount = settings.dynamic_up_vs_ground_y.evaluate(input.ground_normal.y); + let prediction = normalize_safe(blend( + self.ground_normal, add(UP, subtract(input.dynamic_up, normalized_com)), amount, + ), Vector3::ZERO); + self.dynamic_up = input.dynamic_up; + self.ground_normal = vector(self.ground_filter.update( + settings.ground_normal_smoothing, lanes(input.ground_normal), + )); + let horizontal_axis = cross(UP, self.ground_normal); + if dot(horizontal_axis, horizontal_axis) > f32::from_bits(0x3A83_126F) { + let axis = scale(horizontal_axis, inverse_length_squared(dot(horizontal_axis, horizontal_axis), 2)); + let projected = subtract(prediction, scale(axis, dot(axis, prediction))); + let prediction_to_ground = angle_between(projected, self.ground_normal); + let prediction_to_up = angle_between(projected, UP); + let ground_to_up = angle_between(self.ground_normal, UP); + let old_up_to_up = angle_between(self.up, UP); + let mut candidate = if prediction_to_ground < prediction_to_up { self.ground_normal } else { UP }; + if prediction_to_ground < ground_to_up && prediction_to_up < ground_to_up { candidate = projected; } + let alpha = (input.speed + 1.0) * f32::from_bits(0x3A83_126F); + if !(old_up_to_up > ground_to_up || prediction_to_up > old_up_to_up) { + candidate = madd(self.up, 1.0 - alpha, scale(candidate, alpha)); + } + let slope = unit_saturate(ground_to_up * f32::from_bits(0x3EA2_F983)); + let mut target_blend = settings.ground_vector_blend.evaluate(slope); + if input.wheel_contact_count <= settings.minimum_wheels_for_ground_blend { target_blend = 0.0; } + let delta = target_blend - self.ground_blend; + let lower = fsel(-settings.ground_blend_max_delta - delta, -settings.ground_blend_max_delta, delta); + self.ground_blend += fsel(settings.ground_blend_max_delta - lower, lower, settings.ground_blend_max_delta); + self.target = normalize_safe(blend(candidate, self.ground_normal, self.ground_blend), Vector3::ZERO); + } else { + self.target = self.ground_normal; + } + if input.wheel_contact_count >= 2 && input.animation_balance == 0.0 { + let usage = settings.deck_angle_usage_vs_speed.evaluate(input.deck_angle_curve_input); + let axis = normalize_safe(cross(input.board_forward, self.ground_normal), Vector3::ZERO); + let deck_up = normalize_safe(subtract(input.board_up, scale(axis, dot(input.board_up, axis))), Vector3::ZERO); + self.target = normalize_safe(blend(self.target, deck_up, usage), Vector3::ZERO); + } + let slow = normalize_safe(vector(self.slow_filter.filter_raw(settings.up_vector_smoothing_slow, lanes(self.target))), Vector3::ZERO); + let fast = normalize_safe(vector(self.fast_filter.filter_raw(settings.up_vector_smoothing_fast, lanes(self.target))), Vector3::ZERO); + let speed_fraction = input.speed * f32::from_bits(0x3DCC_CCCD); + let mix = settings.up_vector_smoothing_vs_speed.evaluate(speed_fraction); + let filtered = normalize_safe(blend(slow, fast, mix), Vector3::ZERO); + let max_delta = settings.up_vector_max_delta_vs_speed.evaluate(speed_fraction) * f32::from_bits(0x3E4C_CCCD); + let difference = subtract(filtered, self.up); + let magnitude = length(difference); + let bounded = fsel(magnitude - max_delta, max_delta, magnitude); + let stepped = normalize_safe(madd(normalize_safe(difference, Vector3::ZERO), bounded, self.up), Vector3::ZERO); + let desired_velocity = subtract(stepped, self.up); + let acceleration = clamp_length(subtract(desired_velocity, self.up_velocity), + settings.up_vector_max_acceleration * f32::from_bits(0x3C88_8889)); + self.up_velocity = add(self.up_velocity, acceleration); + let side = scale(input.previous_reckoning_right, dot(self.up_velocity, input.previous_reckoning_right)); + self.up_velocity = madd(side, settings.extra_side_damping, subtract(self.up_velocity, side)); + let unnormalized = add(self.up, self.up_velocity); + self.up = normalize_safe(unnormalized, unnormalized); + if 0.0 > dot(self.up_velocity, desired_velocity) { + self.up_velocity = scale(self.up_velocity, settings.anti_wobble_damping); + } + if input.prevent_up_behind_board && dot(self.up, input.effective_board_forward) < 0.0 { + let side = cross(self.up, input.effective_board_forward); + self.up = normalize_safe(cross(input.effective_board_forward, side), self.up); + self.up_velocity = scale(self.up_velocity, 0.5); + } + self.slow_filter.publish_current(lanes(self.up)); + self.fast_filter.publish_current(lanes(self.up)); + } +} + +fn lanes(v: Vector3) -> [f32; 4] { [v.x, v.y, v.z, 0.0] } +fn vector(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } +fn fsel(test: f32, positive: f32, negative: f32) -> f32 { if test >= -0.0 { positive } else { negative } } +fn unit_saturate(value: f32) -> f32 { let value = fsel(-value, 0.0, value); fsel(1.0-value, value, 1.0) } +fn madd(v: Vector3, factor: f32, offset: Vector3) -> Vector3 { + Vector3::new(v.x.mul_add(factor,offset.x),v.y.mul_add(factor,offset.y),v.z.mul_add(factor,offset.z)) +} +fn blend(from: Vector3, to: Vector3, amount: f32) -> Vector3 { madd(to,amount,scale(from,1.0-amount)) } +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new((-a.z).mul_add(b.y,a.y*b.z),(-a.x).mul_add(b.z,a.z*b.x),(-a.y).mul_add(b.x,a.x*b.y)) +} +fn normalize_safe(value: Vector3, fallback: Vector3) -> Vector3 { + let squared=dot(value,value); let inverse=inverse_length_squared(squared,2); + let magnitude=if squared==0.0 {0.0} else {squared*inverse}; + if magnitude > EPSILON { scale(value,inverse) } else { fallback } +} +/// Complete ClampVectorWithinMaxLength82BD3D90. +pub(crate) fn clamp_length(value: Vector3, maximum: f32) -> Vector3 { + let magnitude=length(value); + if magnitude < f32::from_bits(0x3780_0000) { return value; } + let bounded=fsel(maximum-magnitude,magnitude,maximum); + let mut inverse=native_arithmetic::reciprocal_estimate(magnitude); + for _ in 0..2 { let error=(-inverse).mul_add(magnitude,1.0);inverse=inverse.mul_add(error,inverse); } + scale(scale(value,bounded),inverse) +} +#[cfg(test)] +#[path = "tests/ground_orientation.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/grounded/drag.rs b/crates/skate-core/src/riding/grounded/drag.rs new file mode 100644 index 00000000..7cf34ec2 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/drag.rs @@ -0,0 +1,103 @@ +//! Ground drag calculation82D39100/104 and inertia publication82D392A8..B4. +//! Body::SetLinearDrag82D9CD60 writes inertia fields, not current velocities. +use crate::{ + physics::rigid_body::RetailInertiaDynamics, + riding::braking::{LinearDragInput, LinearDragSettings, calculate_linear_drag}, +}; + +/// Literal at822F860C, verified in the original TU3 image. It is not replaced +/// by60 or recomputed from the current simulation timestep. +pub const DRAG_FREQUENCY: f32 = f32::from_bits(0x426f_ffff); + +#[derive(Clone, Copy, Debug)] +pub struct GroundDragInput { + pub flags_2468: u32, + pub absolute_body_speed_2616: f32, + pub balance_2720: f32, + pub scalar_2724: f32, + /// Ground context+1216 vector Y, passed in f1 at82D39100. This is not + /// Processed speed, a manual output, or a timestep. + pub ground_normal_y: f32, +} + +impl GroundDragInput { + /// Calculate before the manual-output80 branch at82D39110. The returned + /// drag must be retained across force submission, then applied afterward. + pub fn calculate(self, settings: LinearDragSettings) -> f32 { + calculate_linear_drag( + LinearDragInput { + flags_2468: self.flags_2468, + absolute_body_speed: self.absolute_body_speed_2616, + balance_2720: self.balance_2720, + scalar_2724: self.scalar_2724, + comparison_scalar: self.ground_normal_y, + }, + settings, + ) + } +} + +/// Native body+24 assembly contains part count+28 and definitions+8; each +///96-byte part resolves body+76 -> inertia+92. Shared inertias remain shared: +/// this binding references the live dynamics objects, not copies per part. +pub struct BodyInertias<'a> { + pub part_inertia_indices: &'a [usize], + pub inertias: &'a mut [RetailInertiaDynamics], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DragSelection { + /// Native r5=-1, used by the inspected Ground update and exit callsites. + AllParts, + Part(usize), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DragBindingError { + PartOutsideAssembly, + InertiaOutsideStorage, +} + +impl BodyInertias<'_> { + /// Exact valid-binding setter82D9CD60. Invalid host bindings are rejected + /// before writes; native would address invalid storage rather than recover. + /// All-parts order is0..count, with four resolved handles before each group + /// of four writes and a remaining single-part tail. Only inertia+32 changes. + pub fn set_linear_drag( + &mut self, + drag: f32, + selection: DragSelection, + ) -> Result<(), DragBindingError> { + let selected = match selection { + DragSelection::AllParts => self.part_inertia_indices, + DragSelection::Part(part) => self + .part_inertia_indices + .get(part..part.saturating_add(1)) + .filter(|parts| parts.len() == 1) + .ok_or(DragBindingError::PartOutsideAssembly)?, + }; + if selected.iter().any(|&index| index >= self.inertias.len()) { + return Err(DragBindingError::InertiaOutsideStorage); + } + let mut groups = selected.chunks_exact(4); + for group in &mut groups { + let [first, second, third, fourth] = [group[0], group[1], group[2], group[3]]; + let coefficient = drag * DRAG_FREQUENCY; + self.inertias[first].linear_drag = coefficient; + self.inertias[second].linear_drag = coefficient; + self.inertias[third].linear_drag = coefficient; + self.inertias[fourth].linear_drag = coefficient; + } + for &index in groups.remainder() { + self.inertias[index].linear_drag = drag * DRAG_FREQUENCY; + } + Ok(()) + } + + /// Ground82D392B4: after either ordinary tag16 or alternate tag7 submission, + /// apply the drag retained from82D39104 to all parts. The caller owns the + /// remaining force-strength flag and ground-correction helpers afterward. + pub fn apply_ground_drag(&mut self, calculated_drag: f32) -> Result<(), DragBindingError> { + self.set_linear_drag(calculated_drag, DragSelection::AllParts) + } +} diff --git a/crates/skate-core/src/riding/grounded/manual_entry.rs b/crates/skate-core/src/riding/grounded/manual_entry.rs new file mode 100644 index 00000000..1fe3f48e --- /dev/null +++ b/crates/skate-core/src/riding/grounded/manual_entry.rs @@ -0,0 +1,111 @@ +//! Ground's manual entry slice82D376F4..7750 and velocity helper82D37960. +//! The complete Ground Enter function and native projection are not supplied. +use crate::physics::manual::state::{ManualEntryContinuation, ManualState}; + +#[derive(Clone, Copy, Debug)] +pub struct ManualGroundInput { + /// ProcessedPhysIn+2720; either signed zero bypasses every body operation. + pub balance: f32, + /// ProcessedPhysIn+464, used directly without normalization. + pub ground_normal: [f32; 4], + /// ProcessedPhysIn+2468. Bit20 swaps the two contact-controlled groups. + pub flags_2468: u32, + /// ProcessedPhysIn+2472. Bits26/27 select the two groups. + pub flags_2472: u32, +} + +/// Direct part-body velocity access, not a force queue or delayed write list. +/// Resolve the live board assembly part on each call: native reads follow every +/// earlier write, including when multiple parts happen to refer to one body. +pub trait ManualGroundBodies { + /// Part indices0..6 resolve body pointers at assembly+76+96*part; velocity + /// is the four-component body field+32. No angular velocity is touched. + fn linear_velocity(&mut self, part: usize) -> [f32; 4]; + fn set_linear_velocity(&mut self, part: usize, velocity: [f32; 4]); +} + +/// Required numerical dependency with no production or fallback implementation. +pub trait ManualGroundProjection { + type Error; + /// Native three-component projection at82D3799C (and six repeated sites). + /// W does not contribute. Preserve native accumulation/rounding when this + /// operation is recovered; generic host dot arithmetic is not a substitute. + fn normal_speed( + &mut self, + ground_normal: [f32; 4], + linear_velocity: [f32; 4], + ) -> Result; +} + +/// Performs the actual manual-state reset/scaling, then its required velocity +/// continuation before returning to Ground Enter's subsequent category200 work. +/// An error retains the executed state/body prefix and must abort the tick. +pub fn enter_ground( + state: &mut ManualState, + previous_category: u32, + powerslide_exit_scale: f32, + input: ManualGroundInput, + bodies: &mut B, + projection: &mut P, +) -> Result<(), P::Error> { + if state.enter_ground(previous_category, powerslide_exit_scale) + == ManualEntryContinuation::RemoveVelocityIntoGround + { + remove_velocity_into_ground(input, bodies, projection)?; + } + Ok(()) +} + +/// Full branch/write order of82D37960, conditional on the required projection. +/// Despite the recovered name, both signs of normal motion are removed. There +/// is no clamp, normal-length division, timestep or balance-sign selection. +pub fn remove_velocity_into_ground( + input: ManualGroundInput, + bodies: &mut B, + projection: &mut P, +) -> Result<(), P::Error> { + if input.balance == 0.0 { + return Ok(()); + } + remove_part(6, input.ground_normal, bodies, projection)?; + + // Native flags are read after the deck write82D379B4..B8. + let reversed = input.flags_2468 & (1 << 20) != 0; + let contact26 = input.flags_2472 & (1 << 26) != 0; + let contact27 = input.flags_2472 & (1 << 27) != 0; + let (first_group, second_group) = if reversed { + (contact26, contact27) + } else { + (contact27, contact26) + }; + if first_group { + for part in [0, 1, 4] { + remove_part(part, input.ground_normal, bodies, projection)?; + } + } + if second_group { + for part in [2, 3, 5] { + remove_part(part, input.ground_normal, bodies, projection)?; + } + } + Ok(()) +} + +fn remove_part( + part: usize, + ground_normal: [f32; 4], + bodies: &mut B, + projection: &mut P, +) -> Result<(), P::Error> { + let velocity = bodies.linear_velocity(part); + let normal_speed = projection.normal_speed(ground_normal, velocity)?; + let mut remaining = velocity; + for axis in 0..3 { + // Separate multiply then subtract82D379A4..A8, not a fused operation. + let component = ground_normal[axis] * normal_speed; + remaining[axis] = velocity[axis] - component; + } + // W is retained bit-for-bit by the native insert before each store. + bodies.set_linear_velocity(part, remaining); + Ok(()) +} diff --git a/crates/skate-core/src/riding/grounded/mod.rs b/crates/skate-core/src/riding/grounded/mod.rs new file mode 100644 index 00000000..52efc3cf --- /dev/null +++ b/crates/skate-core/src/riding/grounded/mod.rs @@ -0,0 +1,6 @@ +//! Recovered grounded producer units; the complete ground state is not ported. +pub mod drag; +pub mod manual_entry; +pub mod mode; +pub mod propulsion; +pub mod state; diff --git a/crates/skate-core/src/riding/grounded/mode.rs b/crates/skate-core/src/riding/grounded/mode.rs new file mode 100644 index 00000000..63f7172d --- /dev/null +++ b/crates/skate-core/src/riding/grounded/mode.rs @@ -0,0 +1,24 @@ +//! Mode binding producer, TU3 ProcessInput 82DB4420..447C. +//! Numeric mode identities are preserved without assigning guessed names. + +/// The two fields are deliberately separate: an unsupported request is still +/// published, while the previously selected attribute binding remains active. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProcessedMode { + /// ProcessedPhysIn+2528, copied from skeleton+10928 on every update. + pub requested_mode: u32, + /// ProcessedPhysIn+2548. Supply actual resource bindings, not host addresses. + pub selected_attributes: T, +} + +impl ProcessedMode { + /// `attributes` correspond in order to player+1408/1424/1440/1456/1472. + /// Their constructor/asset loading is a separate dependency. There is no + /// guessed initial binding and no default-mode fallback for invalid input. + pub fn update(&mut self, requested_mode: u32, attributes: &[T; 5]) { + if requested_mode <= 4 { + self.selected_attributes = attributes[requested_mode as usize]; + } + self.requested_mode = requested_mode; + } +} diff --git a/crates/skate-core/src/riding/grounded/propulsion.rs b/crates/skate-core/src/riding/grounded/propulsion.rs new file mode 100644 index 00000000..adc2a70a --- /dev/null +++ b/crates/skate-core/src/riding/grounded/propulsion.rs @@ -0,0 +1,136 @@ +//! Brake/push production82D38EB4..8F08 and submission82D39194..91B0. +//! These are two distinct slices of Ground UpdateSkateboard, not an entire +//! ground update: intervening native force/manual producers must execute. +use crate::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::controller::ManualEffect, + }, + riding::{ + braking::{BrakeInput, BrakeSettings, calculate_braking}, + push::{PushAcceleration, PushInput, PushLimits, calculate_acceleration}, + }, +}; + +/// Actual ProcessedPhysIn fields consumed by these two native calculations. +/// No raw button mapping, recomputed mass, normalized axis or host timestep. +#[derive(Clone, Copy, Debug)] +pub struct GroundPropulsionInput { + pub flags_2468: u32, + pub flags_2472: u32, + ///+2608, produced by Skeleton's push attribute path82BDAC24/2C. + pub target_speed: f32, + ///+2612, copied by ProcessInput82DB4878 from player1800->4 field168. + pub signed_speed: f32, + ///+2616, separate from the signed projection above. + pub absolute_body_speed: f32, + ///+2660, consumed directly. Existing mass-producer attribution82C0140C + ///still needs a fresh containing-body audit; do not recompute here. + pub scalar_2660: f32, + ///+2604, unlike Ground's fixed pumping timestep. + pub timestep: f32, + ///+2728, Skeleton attribute scalar consumer82BDB0E8. + pub brake_input: f32, + ///+352 and+368 are distinct effective axes, used verbatim. + pub push_direction: Vector3, + pub brake_direction: Vector3, + /// Processed+2544 -> collection+4 -> layout+264. + pub surface_braking_factor: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct GroundPropulsionSettings { + /// Ground+32 toolkit offsets20/24/28, cached by82D94D08/1C/34. + pub braking: BrakeSettings, + /// Toolkit+16 from physics_push layout+8 at82D94CF4. + pub maximum_pushable_speed: f32, + /// Processed+2548 -> selected collection+4 -> layout+48/+52. + /// These are separate from the pumping fields at that mode binding. + pub mode_speed_changes: [f32; 2], +} + +/// Native stack results retained until the later force-submission phase. +#[derive(Clone, Copy, Debug)] +pub struct GroundPropulsion { + pub braking: QueuedPointForce, + pub push: PushAcceleration, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropulsionSubmission { + BrakingAndPush([bool; 2]), + ManualCorrection(bool), +} + +/// Run only after the earlier velocity/collision branches select82D38EB4. +/// The force helpers run even if the later manual branch suppresses submission. +/// Publishes Ground+2730 after push calculation, including clearing old state. +pub fn calculate( + input: GroundPropulsionInput, + settings: GroundPropulsionSettings, + push_suppressed_2730: &mut u8, +) -> GroundPropulsion { + let braking = calculate_braking( + BrakeInput { + flags_2468: input.flags_2468, + input_2728: input.brake_input, + signed_speed: input.signed_speed, + absolute_body_speed: input.absolute_body_speed, + surface_factor: input.surface_braking_factor, + direction: input.brake_direction, + }, + settings.braking, + ); + let push = calculate_acceleration( + PushInput { + flags_2468: input.flags_2468, + flags_2472: input.flags_2472, + target_speed: input.target_speed, + current_speed: input.signed_speed, + absolute_body_speed: input.absolute_body_speed, + scale: input.scalar_2660, + delta_seconds: input.timestep, + direction: input.push_direction, + }, + PushLimits { + maximum_pushable_speed: settings.maximum_pushable_speed, + low_speed_change: settings.mode_speed_changes[0], + high_speed_change: settings.mode_speed_changes[1], + }, + ); + *push_suppressed_2730 = u8::from(push.suppressed); + GroundPropulsion { braking, push } +} + +impl GroundPropulsion { + /// Call at82D39194 after native correction82D39338 and any tag4/5 writes, + /// before angular changes/tag6/1/8/16. Queues both records, even zero forces. + /// A true actual CalcManualEffect output80 selects the alternate tag7 path + /// and queues its corrective force+48/point+64 instead. Output81 is not + /// this selector.82D94A20 writes + /// no r9, preserving the output80 byte through the intervening drag call. + /// Returns each append result; the native21-entry limit may drop a record. + pub fn submit( + self, + manual: &ManualEffect, + queue: &mut BoardForceQueue, + ) -> PropulsionSubmission { + if manual.correction_active { + let force = manual.corrective_force_world; + let point = manual.corrective_point_body; + return PropulsionSubmission::ManualCorrection(queue.append(QueuedPointForce { + tag: 7, + force_world: Vector3::new(force[0], force[1], force[2]), + point_body: Vector3::new(point[0], point[1], point[2]), + })); + } + let brake = queue.append(self.braking); + let push = queue.append(QueuedPointForce { + tag: 3, + force_world: self.push.vector, + point_body: self.push.local_point, + }); + PropulsionSubmission::BrakingAndPush([brake, push]) + } +} diff --git a/crates/skate-core/src/riding/grounded/state/board.rs b/crates/skate-core/src/riding/grounded/state/board.rs new file mode 100644 index 00000000..7ee6e93d --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/board.rs @@ -0,0 +1,160 @@ +//! Branch composition of `PhysicsGround::UpdateSkateboard` (`82D38800`). + +use crate::riding::{speed_wobble, steering}; + +use super::{ + board_types::{ + GroundBoardComponents, GroundBoardError, GroundBoardInput, GroundBoardServices, + GroundBoardSettings, GroundBoardStage, + }, + data::PhysicsGroundState, + ordinary, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundBoardOutcome { + Animated, + Collision { tag_15_queued: bool }, + Ordinary(ordinary::OrdinaryGroundResult), +} + +/// Runs the native steering/contact branch prefix, then exactly one of the +/// animated, collision-response, or ordinary grounded paths. +pub fn update( + state: &mut PhysicsGroundState, + components: GroundBoardComponents<'_, '_>, + settings: GroundBoardSettings<'_>, + mut input: GroundBoardInput, + services: &mut S, +) -> Result> { + let tilt = steering::calculate_tilt( + settings.steering, + input.steering, + Some(&mut state.steering_push_scalar_2640), + Some(&mut state.steering_damped_turn_2644), + ); + input.speed_wobble.tilt = tilt; + input.speed_wobble.center_of_mass_height = service( + GroundBoardStage::CenterOfMassHeight, + services.center_of_mass_height_82d38838(), + )?; + let tilt = speed_wobble::calculate( + components.speed_wobble, + settings.speed_wobble, + input.speed_wobble, + ); + components.truck_steering.update( + tilt, + settings.steering.tilt_blending, + input.truck_flags_2468, + input.truck_flags_2472, + ); + + service( + GroundBoardStage::SetWheelMaterials, + services.set_contact_wheel_materials(), + )?; + let contact = service( + GroundBoardStage::ContactResponse, + services.contact_response_82d93df0(input.contact, state.vector_2688), + )?; + state.flag_2731 = contact.active_2731; + state.vector_2688 = contact.vector_2688; + state.scalar_2704 = contact.scalar_2704; + state.flag_2708 = contact.animated_board_2708; + state.flag_2720 = false; + + service( + GroundBoardStage::UpdateBodyAccumulator, + services.update_body_accumulator_82d389dc(), + )?; + if state.flag_2708 { + return animated(state, components, services); + } + + let collision = service( + GroundBoardStage::CollisionForce, + services.collision_force_82d944e8(input.ground_vector_1216), + )?; + if let Some(collision) = collision { + state.collision_force_2528 = collision.force_2528; + state.collision_point_2544 = collision.point_2544; + state.vector_2592 = collision.vector_2592; + state.collision_countdown_2652 = settings.collision_response_duration; + service( + GroundBoardStage::ApplyCollisionVector, + services.apply_vector_82c07000(collision.vector_2592), + )?; + components.speed_model.flags_1360 |= 0x8000_0000; + let queued = components.force_queue.append(collision.tagged_force()); + let projection = service( + GroundBoardStage::CollisionProjection, + services.collision_force_dot_velocity_82d38e18( + collision.force_2528, + input.ground_force.velocity_400, + ), + )?; + state.flag_2722 = projection < -0.75; + return Ok(GroundBoardOutcome::Collision { + tag_15_queued: queued, + }); + } + + let result = ordinary::update( + state, + components, + settings, + input, + contact.tag_16_force, + services, + )?; + Ok(GroundBoardOutcome::Ordinary(result)) +} + +fn animated( + state: &mut PhysicsGroundState, + components: GroundBoardComponents<'_, '_>, + services: &mut S, +) -> Result> { + service( + GroundBoardStage::SetAnimatedVelocity, + services.set_animated_velocity_82c04168(state.vector_2688), + )?; + service( + GroundBoardStage::WriteProcessedVelocity, + services.write_processed_velocity_400(state.vector_2688), + )?; + components + .inertias + .set_linear_drag(0.0, crate::riding::grounded::drag::DragSelection::AllParts) + .map_err(GroundBoardError::Drag)?; + let pose = service( + GroundBoardStage::BuildAnimatedPose, + services.build_animated_pose_82d33448(), + )?; + service( + GroundBoardStage::PublishAnimatedPose, + services.publish_animated_pose_82be33d0(&pose), + )?; + service( + GroundBoardStage::UpdateExternalPlayer, + services.update_external_player_82d67848(&pose, state.vector_2688), + )?; + service( + GroundBoardStage::CommitExternalPlayer, + services.commit_external_player_82d68800(), + )?; + service( + GroundBoardStage::FinalizeAnimatedBoard, + services.finalize_animated_board_sk83_na_f_01a4(), + )?; + state.flag_2720 = true; + Ok(GroundBoardOutcome::Animated) +} + +pub(super) fn service( + stage: GroundBoardStage, + result: Result, +) -> Result> { + result.map_err(|source| GroundBoardError::Service { stage, source }) +} diff --git a/crates/skate-core/src/riding/grounded/state/board_types.rs b/crates/skate-core/src/riding/grounded/state/board_types.rs new file mode 100644 index 00000000..b5891379 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/board_types.rs @@ -0,0 +1,246 @@ +//! Typed boundaries for `PhysicsGround::UpdateSkateboard` (`82D38800`). + +use crate::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::{ + controller::{ManualAngleMeasurement, ManualInput}, + settings::{ManualMode, ManualSettings}, + state::ManualState, + }, + }, + riding::{ + anti_flip::{AntiFlipInput, AntiFlipSettings}, + braking::LinearDragSettings, + ground_force::GroundForceSettings, + grounded::{ + drag::{BodyInertias, GroundDragInput}, + propulsion::{GroundPropulsionInput, GroundPropulsionSettings}, + }, + heading::{HeadingInput, HeadingSettings}, + pumping::PumpForceInput, + slide_friction::{SlideFrictionInput, SlideFrictionSettings}, + speed_model::{SpeedModelInput, SpeedModelSettings, SpeedModelState}, + speed_wobble::{SpeedWobbleInput, SpeedWobbleSettings, SpeedWobbleState}, + steering::{SteeringInput, SteeringSettings, TruckSteeringState}, + straighten::{StraightenInput, StraightenSettings}, + }, +}; + +use super::corrections::{ + AntiFlipNudgeInput, AntiFlipNudgeMath, HalfpipeWheelCatchInput, HalfpipeWheelCatchServices, + HangUpInput, HangUpServices, PinningInput, PinningServices, +}; + +pub struct GroundBoardSettings<'a> { + pub steering: &'a SteeringSettings, + pub speed_wobble: &'a SpeedWobbleSettings, + pub propulsion: GroundPropulsionSettings, + pub ground_force: &'a GroundForceSettings, + pub speed_model: &'a SpeedModelSettings, + pub slide_friction: &'a SlideFrictionSettings, + pub straighten: &'a StraightenSettings, + pub heading: &'a HeadingSettings, + pub anti_flip: &'a AntiFlipSettings, + pub manual: &'a ManualSettings, + pub manual_mode: ManualMode, + pub linear_drag: LinearDragSettings, + /// Global selected collision layout +172. + pub collision_response_duration: f32, + /// Global selected collision layout +176. + pub collision_response_scale: f32, + /// Global selected ground layout +272. + pub ground_force_contact_time_limit: f32, + /// Live scalar `flt_82098D0C`. + pub speed_model_reset_force_squared: f32, +} + +/// Values used to construct both `82D931E8` calls in their native caller. +pub struct GroundForceFrame { + pub argument_1_2752: f32, + pub processed_2776: f32, + pub processed_2780: f32, + pub previous_state_scalar_56: f32, + pub ground_scalar_1216: f32, + pub ground_scalar_1232: f32, + pub ground_scalar_1236: f32, + pub ground_scalar_1240: f32, + pub balance_2720: f32, + pub surface_speed_2656: f32, + pub axis_384: [f32; 4], + pub velocity_400: [f32; 4], + pub axis_544: [f32; 4], +} + +/// Contiguous local record copied from board-body +8032 through +8084 before +/// the `82D93DF0` call. The names deliberately retain offsets where semantics +/// have not been independently recovered. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundContactFrame { + pub vector_8032: [f32; 4], + pub vector_8048: [f32; 4], + pub vector_8064: [f32; 4], + pub word_8080: u32, + pub flag_8084: bool, + pub scalar_2752: f32, + pub scalar_2756: f32, +} + +pub struct GroundBoardInput { + pub steering: SteeringInput, + pub speed_wobble: SpeedWobbleInput, + pub truck_flags_2468: u32, + pub truck_flags_2472: u32, + pub contact: GroundContactFrame, + /// Ground context +1216, copied verbatim for `82D944E8`. + pub ground_vector_1216: [f32; 4], + pub propulsion: GroundPropulsionInput, + pub ground_force: GroundForceFrame, + pub speed_model: SpeedModelInput, + pub slide_friction: SlideFrictionInput, + pub pump_force: PumpForceInput, + pub straighten: StraightenInput, + pub heading: HeadingInput, + pub anti_flip: AntiFlipInput, + pub manual: ManualInput, + pub ground_drag: GroundDragInput, + pub contact_time_2756: f32, + pub trajectory_state_1776_bits: u32, + pub anti_flip_nudge: AntiFlipNudgeInput, + pub hang_up: HangUpInput, + pub halfpipe_wheel_catch: HalfpipeWheelCatchInput, + pub pinning: PinningInput, +} + +pub struct GroundBoardComponents<'a, 'body> { + pub speed_wobble: &'a mut SpeedWobbleState, + pub truck_steering: &'a mut TruckSteeringState, + pub speed_model: &'a mut SpeedModelState, + pub manual: &'a mut ManualState, + /// Board+284, separate from the five-field manual controller state. + pub heading_previous: &'a mut f32, + pub force_queue: &'a mut BoardForceQueue, + pub inertias: &'a mut BodyInertias<'body>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundContactResponse { + pub active_2731: bool, + pub tag_16_force: QueuedPointForce, + pub vector_2688: [f32; 4], + pub scalar_2704: f32, + pub animated_board_2708: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CollisionForceResponse { + pub force_2528: [f32; 4], + pub point_2544: [f32; 4], + pub vector_2592: [f32; 4], +} + +impl CollisionForceResponse { + pub fn tagged_force(self) -> QueuedPointForce { + QueuedPointForce { + tag: 15, + force_world: xyz(self.force_2528), + point_body: xyz(self.point_2544), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundBoardStage { + CenterOfMassHeight, + SetWheelMaterials, + ContactResponse, + UpdateBodyAccumulator, + SetAnimatedVelocity, + WriteProcessedVelocity, + BuildAnimatedPose, + PublishAnimatedPose, + UpdateExternalPlayer, + CommitExternalPlayer, + FinalizeAnimatedBoard, + CollisionForce, + ApplyCollisionVector, + CollisionProjection, + ManualEffect, + PushForceSquared, + AntiFlipNudge, + ApplyCollisionDecay, + ApplyStraighten, + ApplyHeading, + ApplyAntiFlip, + ApplyManual, + HangUps, + HalfpipeWheelCatch, + Pinning, + StrongForceSquared, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GroundBoardError { + Service { stage: GroundBoardStage, source: E }, + Manual(crate::physics::manual::controller::ManualError), + Drag(crate::riding::grounded::drag::DragBindingError), +} + +/// Engine/numerical operations whose bodies do not yet exist in readable core +/// code. Every operation is mandatory on the branch where native code calls it. +pub trait GroundBoardServices: + ManualAngleMeasurement + + AntiFlipNudgeMath + + HangUpServices + + HalfpipeWheelCatchServices + + PinningServices +{ + type BoardError; + type AnimatedPose; + + fn center_of_mass_height_82d38838(&mut self) -> Result; + fn set_contact_wheel_materials(&mut self) -> Result<(), Self::BoardError>; + fn contact_response_82d93df0( + &mut self, + frame: GroundContactFrame, + previous_vector_2688: [f32; 4], + ) -> Result; + fn update_body_accumulator_82d389dc(&mut self) -> Result<(), Self::BoardError>; + fn set_animated_velocity_82c04168( + &mut self, + velocity: [f32; 4], + ) -> Result<(), Self::BoardError>; + fn write_processed_velocity_400(&mut self, velocity: [f32; 4]) -> Result<(), Self::BoardError>; + fn build_animated_pose_82d33448(&mut self) -> Result; + fn publish_animated_pose_82be33d0( + &mut self, + pose: &Self::AnimatedPose, + ) -> Result<(), Self::BoardError>; + fn update_external_player_82d67848( + &mut self, + pose: &Self::AnimatedPose, + velocity: [f32; 4], + ) -> Result<(), Self::BoardError>; + fn commit_external_player_82d68800(&mut self) -> Result<(), Self::BoardError>; + fn finalize_animated_board_sk83_na_f_01a4(&mut self) -> Result<(), Self::BoardError>; + fn collision_force_82d944e8( + &mut self, + ground_vector_1216: [f32; 4], + ) -> Result, Self::BoardError>; + /// Exact normalize/threshold/dot sequence at `82D38E18..82D38EA8`. + fn collision_force_dot_velocity_82d38e18( + &mut self, + force: [f32; 4], + velocity_400: [f32; 4], + ) -> Result; + fn apply_vector_82c07000(&mut self, vector: [f32; 4]) -> Result<(), Self::BoardError>; + fn apply_angular_displacement_82c075b8( + &mut self, + vector: [f32; 4], + ) -> Result<(), Self::BoardError>; +} + +fn xyz(value: [f32; 4]) -> Vector3 { + Vector3::new(value[0], value[1], value[2]) +} diff --git a/crates/skate-core/src/riding/grounded/state/contact_state.rs b/crates/skate-core/src/riding/grounded/state/contact_state.rs new file mode 100644 index 00000000..eefcbe57 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/contact_state.rs @@ -0,0 +1,210 @@ +//! Ground contact identity/update leaf (`82D38430`). + +use super::data::GroundContactHistory; + +pub type Vector4 = [f32; 4]; + +/// Values copied into the native contact publication record. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundContactStateInput { + pub flags_2476: u32, + pub flags_2480: u32, + pub velocity_400: Vector4, + pub axis_464: Vector4, + pub contact_position_560: Vector4, + pub trajectory_position_592: Vector4, + pub reckoning_vector_1200: Vector4, + /// Selected global collection +396 -> layout+4 -> +560. + pub differing_contact_frame_limit: i32, +} + +/// Required owner/collision operations around state-owned counter logic. +pub trait GroundContactServices { + type Error; + type Handle: Eq; + + /// `82D62F20`, called first on the inactive branch. + fn stop_contact_tracking_82d62f20(&mut self) -> Result<(), Self::Error>; + /// Exact owner+3024 reset at `82D38468..82D3848C`. + fn clear_inactive_contact_output_3024(&mut self) -> Result<(), Self::Error>; + /// Owner+1920, read only when the retained handle is empty. + fn initial_contact_handle_1920(&mut self) -> Result, Self::Error>; + /// `82D63C30` over the first three vectors of the publication record. + fn identify_contact_82d63c30( + &mut self, + frame: &GroundContactStateInput, + ) -> Result, Self::Error>; + /// `82D61268`, with all five vectors and the retained handle. + fn publish_contact_82d61268( + &mut self, + frame: &GroundContactStateInput, + retained: Option<&Self::Handle>, + ) -> Result<(), Self::Error>; +} + +/// Runs the full TU3 state/call order. Counter updates use wrapping integer +/// arithmetic because the native body uses ordinary `addi` instructions. +pub fn update( + history: &mut GroundContactHistory, + input: GroundContactStateInput, + services: &mut S, +) -> Result<(), S::Error> { + if input.flags_2476 & 0x0040_0000 == 0 { + services.stop_contact_tracking_82d62f20()?; + services.clear_inactive_contact_output_3024()?; + return Ok(()); + } + + if input.flags_2480 & 0x2000_0000 != 0 || input.flags_2480 & 0x1000_0000 == 0 { + history.tracked_contact_2760 = None; + history.differing_contact_frames_2756 = 0; + } + + if input.flags_2480 & 0x1000_0000 != 0 { + if input.axis_464[1] < f32::from_bits(0x3f73_3333) { + if history.tracked_contact_2760.is_none() { + history.tracked_contact_2760 = services.initial_contact_handle_1920()?; + } + let current = services.identify_contact_82d63c30(&input)?; + history.differing_contact_frames_2756 = if current == history.tracked_contact_2760 { + 0 + } else { + history.differing_contact_frames_2756.wrapping_add(1) + }; + if history.differing_contact_frames_2756 > input.differing_contact_frame_limit { + history.tracked_contact_2760 = None; + } + } else { + // Native label clears only the retained handle here. + history.tracked_contact_2760 = None; + } + } + + services.publish_contact_82d61268(&input, history.tracked_contact_2760.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct Services { + events: Vec<&'static str>, + initial: Option, + identified: Option, + published: Option>, + } + + impl GroundContactServices for Services { + type Error = (); + type Handle = u32; + + fn stop_contact_tracking_82d62f20(&mut self) -> Result<(), Self::Error> { + self.events.push("stop"); + Ok(()) + } + + fn clear_inactive_contact_output_3024(&mut self) -> Result<(), Self::Error> { + self.events.push("clear"); + Ok(()) + } + + fn initial_contact_handle_1920(&mut self) -> Result, Self::Error> { + self.events.push("initial"); + Ok(self.initial) + } + + fn identify_contact_82d63c30( + &mut self, + _frame: &GroundContactStateInput, + ) -> Result, Self::Error> { + self.events.push("identify"); + Ok(self.identified) + } + + fn publish_contact_82d61268( + &mut self, + _frame: &GroundContactStateInput, + retained: Option<&Self::Handle>, + ) -> Result<(), Self::Error> { + self.events.push("publish"); + self.published = Some(retained.copied()); + Ok(()) + } + } + + fn input() -> GroundContactStateInput { + GroundContactStateInput { + flags_2476: 0x0040_0000, + flags_2480: 0x1000_0000, + velocity_400: [1.0; 4], + axis_464: [0.0, 0.5, 0.0, 0.0], + contact_position_560: [2.0; 4], + trajectory_position_592: [3.0; 4], + reckoning_vector_1200: [4.0; 4], + differing_contact_frame_limit: 5, + } + } + + #[test] + fn inactive_branch_preserves_history_and_runs_both_owner_calls() { + let mut history = GroundContactHistory { + differing_contact_frames_2756: 7, + tracked_contact_2760: Some(9), + }; + let mut frame = input(); + frame.flags_2476 = 0; + let mut services = Services::default(); + + update(&mut history, frame, &mut services).unwrap(); + + assert_eq!(services.events, ["stop", "clear"]); + assert_eq!(history.differing_contact_frames_2756, 7); + assert_eq!(history.tracked_contact_2760, Some(9)); + } + + #[test] + fn candidate_latches_initial_handle_then_counts_identity_changes() { + let mut history = GroundContactHistory::empty(); + let mut services = Services { + initial: Some(10), + identified: Some(11), + ..Default::default() + }; + + update(&mut history, input(), &mut services).unwrap(); + + assert_eq!(services.events, ["initial", "identify", "publish"]); + assert_eq!(history.differing_contact_frames_2756, 1); + assert_eq!(history.tracked_contact_2760, Some(10)); + assert_eq!(services.published, Some(Some(10))); + } + + #[test] + fn limit_and_axis_rejection_clear_only_the_native_fields() { + let mut history = GroundContactHistory { + differing_contact_frames_2756: 5, + tracked_contact_2760: Some(10), + }; + let mut frame = input(); + frame.differing_contact_frame_limit = 5; + let mut services = Services { + identified: Some(11), + ..Default::default() + }; + update(&mut history, frame, &mut services).unwrap(); + assert_eq!(history.differing_contact_frames_2756, 6); + assert_eq!(history.tracked_contact_2760, None); + assert_eq!(services.published, Some(None)); + + history.differing_contact_frames_2756 = 13; + history.tracked_contact_2760 = Some(12); + frame.axis_464[1] = 0.95; + services.events.clear(); + services.published = None; + update(&mut history, frame, &mut services).unwrap(); + assert_eq!(history.differing_contact_frames_2756, 13); + assert_eq!(history.tracked_contact_2760, None); + assert_eq!(services.events, ["publish"]); + } +} diff --git a/crates/skate-core/src/riding/grounded/state/corrections.rs b/crates/skate-core/src/riding/grounded/state/corrections.rs new file mode 100644 index 00000000..a7a3b188 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/corrections.rs @@ -0,0 +1,392 @@ +//! Ground correction paths called by `82D38800`. + +use crate::{ + math::Vector3, + physics::force_queue::{BoardForceQueue, QueuedPointForce}, +}; + +use super::data::PhysicsGroundState; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AntiFlipNudgeInput { + pub deck_speed_2652: f32, + pub deck_axis_96: [f32; 4], +} + +pub trait AntiFlipNudgeMath { + type Error; + + /// Exact `vmsum3fp128` operation. Generic host dot arithmetic is not a + /// substitute for the recovered Xenon operation. + fn dot3(&mut self, left: [f32; 4], right: [f32; 4]) -> Result; + /// Exact `vrsqrtefp128` plus two refinements and final scale at + /// `82D39498..82D394EC`. + fn scale_to_magnitude( + &mut self, + vector: [f32; 4], + squared_length: f32, + magnitude: f32, + ) -> Result<[f32; 4], Self::Error>; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AntiFlipNudgeResult { + pub attempted: bool, + pub queued: bool, +} + +/// Complete state/branch/queue contract of `UpdateAntiFlipNudge` `82D39338`. +pub fn update_anti_flip_nudge( + state: &mut PhysicsGroundState, + input: AntiFlipNudgeInput, + queue: &mut BoardForceQueue, + math: &mut M, +) -> Result { + let anti_flip_squared = math.dot3(state.anti_flip_torque_2624, state.anti_flip_torque_2624)?; + state.anti_flip_nudge_frames_2752 = if anti_flip_squared > 0.0 { + state.anti_flip_nudge_frames_2752.wrapping_add(1) + } else { + // `ble` after `fcmpu` also takes the unordered case. + 0 + }; + if state.anti_flip_nudge_frames_2752 <= 12 + || !(input.deck_speed_2652 < f32::from_bits(0x3f8c_cccd)) + { + return Ok(AntiFlipNudgeResult { + attempted: false, + queued: false, + }); + } + + let mut direction = input.deck_axis_96; + if direction[1] > 0.0 { + direction = direction.map(flip_sign_bit); + } + direction[1] = 0.0; + let squared = math.dot3(direction, direction)?; + if !(squared > f32::from_bits(0x3a83_126f)) { + return Ok(AntiFlipNudgeResult { + attempted: false, + queued: false, + }); + } + let force = math.scale_to_magnitude(direction, squared, 50.0)?; + let queued = queue.append(QueuedPointForce { + tag: 17, + force_world: xyz(force), + point_body: Vector3::ZERO, + }); + // The native byte is set after the append call regardless of capacity. + state.anti_flip_nudge_applied_2723 = true; + Ok(AntiFlipNudgeResult { + attempted: true, + queued, + }) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HangUpInput { + pub flags_1516: u32, + pub deck_speed_2652: f32, + pub scalar_84: f32, + /// Exact `vmsum3fp128` comparison at `82D39744..82D39764`. + pub geometry_axis_dot_positive: bool, +} + +pub trait HangUpServices { + type Error; + + /// `82D37048`, `82D370F8` and the direction/up-vector construction at + /// `82D395AC..82D396AC`, including its signed side test. + fn build_hang_force(&mut self) -> Result<[f32; 4], Self::Error>; + /// Direct `ApplyWorldSpaceForceToDeck` call at `82D396DC`. + fn apply_hang_force(&mut self, force: [f32; 4]) -> Result<(), Self::Error>; + /// Exact `82C20530/5D8/728/C08/6C0` geometry-object lifetime and result. + fn detect_hung_up_geometry(&mut self) -> Result; + fn request_wipeout(&mut self) -> Result<(), Self::Error>; +} + +/// Complete counter and effect order of `ManageHangUps` `82D39510`. +pub fn manage_hang_ups( + state: &mut PhysicsGroundState, + input: HangUpInput, + services: &mut S, +) -> Result<(), S::Error> { + let investigating = input.flags_1516 & 0x0800_0000 != 0; + if investigating { + if input.deck_speed_2652 < f32::from_bits(0x3f8c_cccd) + && input.scalar_84 < f32::from_bits(0x3f66_6666) + && input.scalar_84 > 0.0 + { + state.hang_detection_frames_2740 = state.hang_detection_frames_2740.wrapping_add(1); + } + } else { + state.hang_detection_frames_2740 = 0; + } + + if state.hang_detection_frames_2740 == 20 { + state.hang_force_frames_2744 = 10; + state.vector_2608 = services.build_hang_force()?; + } + if state.hang_force_frames_2744 > 0 { + if investigating { + services.apply_hang_force(state.vector_2608)?; + } + state.hang_force_frames_2744 = state.hang_force_frames_2744.wrapping_sub(1); + if state.hang_force_frames_2744 == 0 { + state.hang_detection_frames_2740 = 0; + } + } + + let geometry_candidate = input.deck_speed_2652 < f32::from_bits(0x3f8c_cccd) + && input.flags_1516 & 0x0c00_0000 == 0x0c00_0000 + && input.geometry_axis_dot_positive; + let hung = if geometry_candidate { + services.detect_hung_up_geometry()? + } else { + false + }; + state.hung_wipeout_frames_2748 = if hung { + state.hung_wipeout_frames_2748.wrapping_add(1) + } else { + 0 + }; + if state.hung_wipeout_frames_2748 > 20 { + services.request_wipeout()?; + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HalfpipeWheelCatchInput { + pub flags_1516: u32, + pub deck_speed_2652: f32, + pub deck_x_axis_y: f32, + pub deck_y_axis_y: f32, + pub signed_deck_distance: f32, +} + +pub trait HalfpipeWheelCatchServices { + type Error; + + /// Exact signed axis/cross-product construction at `82D39950..82D399E8`. + fn angular_displacement(&mut self) -> Result<[f32; 4], Self::Error>; + fn apply_angular_displacement(&mut self, value: [f32; 4]) -> Result<(), Self::Error>; +} + +/// Gating and effect order of `ManageHalfpipeWheelCatches` `82D39868`. +pub fn manage_halfpipe_wheel_catches( + input: HalfpipeWheelCatchInput, + services: &mut S, +) -> Result { + let candidate = input.flags_1516 & 0x0800_0000 != 0 + && input.deck_speed_2652 < 3.0 + && input.deck_x_axis_y.abs() < f32::from_bits(0x3f11_eb85) + && input.deck_y_axis_y.abs() < f32::from_bits(0x3df5_c28f) + && input.signed_deck_distance < f32::from_bits(0x3de1_47ae) + && input.signed_deck_distance > 0.0; + if !candidate { + return Ok(false); + } + let displacement = services.angular_displacement()?; + services.apply_angular_displacement(displacement)?; + Ok(true) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PinningInput { + pub flags_2488: u32, + pub frames_since_teleport_2584: i32, + pub flags_2472: u32, +} + +pub trait PinningServices { + type Error; + + /// Exact reciprocal-refinement velocity and `82C04168` application at + /// `82D39B04..82D39B8C` using captured X/Z, live Y, position and timestep. + fn pin_to_captured_position( + &mut self, + captured_x: f32, + captured_z: f32, + ) -> Result<(), Self::Error>; +} + +/// Complete state gating of `ConsiderPinning` `82D39A18`. +pub fn consider_pinning( + state: &mut PhysicsGroundState, + input: PinningInput, + services: &mut S, +) -> Result<(), S::Error> { + if input.flags_2488 & 1 != 0 { + state.pinning_2727 = false; + state.was_pinning_2728 = false; + return Ok(()); + } + if state.human_player_2724 && input.frames_since_teleport_2584 < 90 { + let eligible = state.was_pinning_2728 || input.frames_since_teleport_2584 < 30; + let controls_seen = + input.frames_since_teleport_2584 > 0 && input.flags_2472 & 0x0080_0000 == 0; + state.controls_latched_2725 |= controls_seen; + if eligible && state.captured_position_valid_2726 && !state.controls_latched_2725 { + services.pin_to_captured_position( + state.captured_position_x_2656, + state.captured_position_z_2660, + )?; + state.pinning_2727 = true; + } + } + state.was_pinning_2728 = state.pinning_2727; + Ok(()) +} + +fn flip_sign_bit(value: f32) -> f32 { + f32::from_bits(value.to_bits() ^ 0x8000_0000) +} + +fn xyz(value: [f32; 4]) -> Vector3 { + Vector3::new(value[0], value[1], value[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::force_queue::FORCE_CAPACITY; + + struct NudgeMath { + dots: [f32; 2], + dot_count: usize, + scale_count: usize, + } + + impl AntiFlipNudgeMath for NudgeMath { + type Error = (); + + fn dot3(&mut self, _left: [f32; 4], _right: [f32; 4]) -> Result { + let value = self.dots[self.dot_count]; + self.dot_count += 1; + Ok(value) + } + + fn scale_to_magnitude( + &mut self, + vector: [f32; 4], + _squared_length: f32, + magnitude: f32, + ) -> Result<[f32; 4], Self::Error> { + self.scale_count += 1; + Ok(vector.map(|lane| lane * magnitude)) + } + } + + fn state() -> PhysicsGroundState { + PhysicsGroundState { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0, + word_2564: 0, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [1.0, 0.0, 0.0, 0.0], + steering_push_scalar_2640: 1.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.0, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 0.0, + scalar_2668: 0.0, + straighten_scale_2672: 1.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: false, + flag_2721: false, + flag_2722: false, + anti_flip_nudge_applied_2723: false, + human_player_2724: false, + controls_latched_2725: false, + captured_position_valid_2726: false, + pinning_2727: false, + was_pinning_2728: false, + flag_2729: false, + push_suppressed_2730: false, + flag_2731: false, + manual_correction_2732: false, + manual_opposition_2733: false, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 13, + } + } + + #[test] + fn nudge_unordered_comparisons_take_the_native_rejection_paths() { + let mut state = state(); + let mut queue = BoardForceQueue::default(); + let mut math = NudgeMath { + dots: [f32::NAN, 1.0], + dot_count: 0, + scale_count: 0, + }; + let result = update_anti_flip_nudge( + &mut state, + AntiFlipNudgeInput { + deck_speed_2652: 0.0, + deck_axis_96: [1.0, 0.0, 0.0, 0.0], + }, + &mut queue, + &mut math, + ) + .unwrap(); + assert_eq!(state.anti_flip_nudge_frames_2752, 0); + assert!(!result.attempted); + + state.anti_flip_nudge_frames_2752 = 13; + math.dots = [1.0, f32::NAN]; + math.dot_count = 0; + let result = update_anti_flip_nudge( + &mut state, + AntiFlipNudgeInput { + deck_speed_2652: 0.0, + deck_axis_96: [1.0, 0.0, 0.0, 0.0], + }, + &mut queue, + &mut math, + ) + .unwrap(); + assert!(!result.attempted); + assert_eq!(math.scale_count, 0); + } + + #[test] + fn nudge_latches_presence_even_when_the_native_queue_is_full() { + let mut state = state(); + let mut queue = BoardForceQueue::default(); + for _ in 0..FORCE_CAPACITY { + assert!(queue.append(QueuedPointForce::default())); + } + let mut math = NudgeMath { + dots: [1.0, 1.0], + dot_count: 0, + scale_count: 0, + }; + + let result = update_anti_flip_nudge( + &mut state, + AntiFlipNudgeInput { + deck_speed_2652: 0.0, + deck_axis_96: [1.0, 0.0, 0.0, 0.0], + }, + &mut queue, + &mut math, + ) + .unwrap(); + + assert!(result.attempted); + assert!(!result.queued); + assert!(state.anti_flip_nudge_applied_2723); + assert_eq!(queue.entries().len(), FORCE_CAPACITY); + } +} diff --git a/crates/skate-core/src/riding/grounded/state/data.rs b/crates/skate-core/src/riding/grounded/state/data.rs new file mode 100644 index 00000000..ba10e8fa --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/data.rs @@ -0,0 +1,207 @@ +//! Persistent fields owned by TU3 `PhysState_PhysicsGround`. +//! +//! Offsets remain in names when the original semantic name has not been +//! recovered. This prevents an adapter from silently assigning an invented +//! meaning to a stock field. + +use super::contact_state::GroundContactStateInput; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsGroundState { + /// Collision-force record retained by the successful `82D944E8` branch. + pub collision_force_2528: [f32; 4], + pub collision_point_2544: [f32; 4], + pub word_2560: u32, + pub word_2564: u32, + /// Collision response retained and decayed by `82D38800`. + pub vector_2592: [f32; 4], + /// Direct hang-up force assembled by `82D39510`. + pub vector_2608: [f32; 4], + /// Output of `Toolkit_AntiFlipTorque` (`82D94190`). + pub anti_flip_torque_2624: [f32; 4], + /// Optional steering history arguments passed to `82D92440`. + pub steering_push_scalar_2640: f32, + pub steering_damped_turn_2644: f32, + /// Accumulated by ProcessedPhysIn+2604 once per Ground Update. + pub elapsed_2648: f32, + /// Collision-response countdown set from the selected global settings. + pub collision_countdown_2652: f32, + /// First contacted position captured once for spawn pinning. + pub captured_position_x_2656: f32, + pub captured_position_z_2660: f32, + /// Time remaining before the skater may enter the skitch path. + pub scalar_2664: f32, + /// Height of the selected skitch spline. + pub scalar_2668: f32, + /// Multiplies the straighten-out torque before heading calculation. + pub straighten_scale_2672: f32, + /// Animated-board velocity/displacement state used by Enter/Exit and Update. + pub vector_2688: [f32; 4], + /// Explicitly zeroed by `82D93DF0` through its Ground+2688 output record. + pub scalar_2704: f32, + /// Selects the animated-board branch of `82D38800`. + pub flag_2708: bool, + pub flag_2720: bool, + pub flag_2721: bool, + pub flag_2722: bool, + pub anti_flip_nudge_applied_2723: bool, + /// Pinning is restricted to the human-player path. + pub human_player_2724: bool, + pub controls_latched_2725: bool, + pub captured_position_valid_2726: bool, + pub pinning_2727: bool, + pub was_pinning_2728: bool, + pub flag_2729: bool, + pub push_suppressed_2730: bool, + pub flag_2731: bool, + pub manual_correction_2732: bool, + pub manual_opposition_2733: bool, + pub hang_detection_frames_2740: i32, + pub hang_force_frames_2744: i32, + pub hung_wipeout_frames_2748: i32, + pub anti_flip_nudge_frames_2752: i32, +} + +impl PhysicsGroundState { + /// Ground constructor82D37358..37534. Fields whose first native write is + /// Enter are safe host zero storage until Enter replaces them; this method + /// does not imply that entering Ground has already occurred. + pub fn before_first_enter(human_player: bool) -> Self { + Self { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0, + word_2564: 0, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [0.0; 4], + steering_push_scalar_2640: 0.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.0, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 0.0, + scalar_2668: 0.0, + straighten_scale_2672: 0.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: false, + flag_2721: false, + flag_2722: false, + anti_flip_nudge_applied_2723: false, + human_player_2724: human_player, + controls_latched_2725: false, + captured_position_valid_2726: false, + pinning_2727: false, + was_pinning_2728: false, + flag_2729: false, + push_suppressed_2730: false, + flag_2731: false, + manual_correction_2732: false, + manual_opposition_2733: false, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 0, + } + } + + /// State-owned prefix82D3758C..5C0 after the physical collision mode change. + pub fn begin_entry(&mut self) { + self.word_2560 = 0; + self.word_2564 = 0; + self.flag_2708 = false; + self.controls_latched_2725 = false; + self.captured_position_valid_2726 = false; + self.was_pinning_2728 = false; + self.flag_2731 = false; + self.manual_correction_2732 = false; + self.manual_opposition_2733 = false; + } + /// State-owned tail82D378AC..7914, after pumping and controller-mode reset. + pub fn finish_entry(&mut self, previous_state: u32) { + self.straighten_scale_2672 = if previous_state == 101 { + f32::from_bits(0x3e23_d70a) + } else { + 1.0 + }; + self.steering_push_scalar_2640 = 1.0; + self.steering_damped_turn_2644 = 0.0; + self.elapsed_2648 = 0.0; + self.collision_countdown_2652 = 0.0; + self.vector_2592 = [0.0; 4]; + self.vector_2608 = [0.0; 4]; + self.anti_flip_torque_2624 = [0.0; 4]; + self.hang_detection_frames_2740 = 0; + self.hang_force_frames_2744 = 0; + self.hung_wipeout_frames_2748 = 0; + self.anti_flip_nudge_frames_2752 = 0; + self.flag_2721 = false; + self.anti_flip_nudge_applied_2723 = false; + self.push_suppressed_2730 = false; + } + + /// Prefix of `Update` `82D37CB4..82D37D28`, before contact-state work. + pub fn begin_update(&mut self, input: GroundUpdateInput) -> GroundUpdateSignals { + self.begin_update_at(input.wheel_contact_count_2556, input.position_112) + } + + pub fn begin_update_at( + &mut self, + wheel_contacts: i32, + position: [f32; 4], + ) -> GroundUpdateSignals { + if !self.captured_position_valid_2726 && wheel_contacts > 0 { + self.captured_position_x_2656 = position[0]; + self.captured_position_z_2660 = position[2]; + self.captured_position_valid_2726 = true; + } + self.anti_flip_torque_2624 = [0.0; 4]; + self.flag_2722 = false; + self.anti_flip_nudge_applied_2723 = false; + self.pinning_2727 = false; + self.scalar_2664 = -1.0; + self.flag_2729 = false; + GroundUpdateSignals { + set_skeleton_flag_16505: self.elapsed_2648 > f32::from_bits(0x3d4c_cccd), + } + } + + /// Tail of `Update` `82D37EBC..82D37EEC` after prediction succeeds. + pub fn finish_update(&mut self, timestep_2604: f32) { + self.elapsed_2648 += timestep_2604; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundUpdateInput { + pub wheel_contact_count_2556: i32, + pub position_112: [f32; 4], + pub timestep_2604: f32, + pub contact_state: GroundContactStateInput, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundUpdateSignals { + pub set_skeleton_flag_16505: bool, +} + +/// Contact identity history updated by `82D38430`. The concrete collision +/// object handle is supplied by the world adapter rather than stored as a host +/// pointer in the engine-independent core. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroundContactHistory { + pub differing_contact_frames_2756: i32, + pub tracked_contact_2760: Option, +} + +impl GroundContactHistory { + pub const fn empty() -> Self { + Self { + differing_contact_frames_2756: 0, + tracked_contact_2760: None, + } + } +} diff --git a/crates/skate-core/src/riding/grounded/state/lifecycle.rs b/crates/skate-core/src/riding/grounded/state/lifecycle.rs new file mode 100644 index 00000000..f1b50251 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/lifecycle.rs @@ -0,0 +1,243 @@ +//! Ground Enter `82D37538` and Exit `82D37B20` lifecycle order. + +use crate::{ + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::state::ManualState, + }, + riding::{ + grounded::{ + drag::{BodyInertias, DragBindingError, DragSelection}, + manual_entry::{ + ManualGroundBodies, ManualGroundInput, ManualGroundProjection, enter_ground, + }, + }, + pumping::state::PumpingState, + speed_wobble::SpeedWobbleState, + }, +}; + +use super::data::PhysicsGroundState; + +#[derive(Clone, Copy, Debug)] +pub struct GroundEnterInput { + pub previous_category_2516: u32, + pub previous_state_2504: u32, + pub powerslide_exit_scale: f32, + pub manual_ground: ManualGroundInput, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundEnterStage { + SetBoardStandard, + InitializeSkeleton, + SetCollisionState, + ResetExternalObjects, + InitializeBoardSpeed, + EnterManual, + EnterFromAir, + BuildLandingOnDeckForce, + SetControllerMode, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GroundEnterError { + Service { stage: GroundEnterStage, source: S }, + ManualProjection { stage: GroundEnterStage, source: P }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundEnterResult { + pub landing_force_attempted: bool, + pub landing_force_queued: bool, +} + +/// Required object operations surrounding the state-owned resets. None have a +/// default because their complete side effects are part of Ground entry. +pub trait GroundEnterServices { + type Error; + + fn set_board_standard_82c03af8(&mut self) -> Result<(), Self::Error>; + fn initialize_ground_skeleton(&mut self) -> Result<(), Self::Error>; + fn set_collision_state_82d911a8(&mut self, state: u32) -> Result<(), Self::Error>; + fn reset_ground_external_objects(&mut self) -> Result<(), Self::Error>; + /// Full `82D375D0..82D376CC` board transform/velocity projection and target + /// speed initialization, preserving native VMX arithmetic. + fn initialize_board_speed_82d375d0(&mut self) -> Result<(), Self::Error>; + fn enter_from_air_82bf2f18(&mut self) -> Result<(), Self::Error>; + /// Full tag-19 producer `82D377AC..82D378A8`. + fn landing_on_deck_force(&mut self) -> Result; + fn set_controller_mode_one(&mut self) -> Result<(), Self::Error>; +} + +pub struct GroundEntryComponents<'a> { + pub pumping: &'a mut PumpingState, + pub speed_wobble: &'a mut SpeedWobbleState, + pub manual: &'a mut ManualState, + pub force_queue: &'a mut BoardForceQueue, +} + +/// Runs every verified Ground entry operation in native order. The service +/// boundaries are unresolved engine/numerical functions, not optional hooks. +pub fn enter( + state: &mut PhysicsGroundState, + components: GroundEntryComponents<'_>, + input: GroundEnterInput, + bodies: &mut B, + projection: &mut P, + services: &mut S, +) -> Result> +where + S: GroundEnterServices, + B: ManualGroundBodies, + P: ManualGroundProjection, +{ + enter_call( + GroundEnterStage::SetBoardStandard, + services.set_board_standard_82c03af8(), + )?; + enter_call( + GroundEnterStage::InitializeSkeleton, + services.initialize_ground_skeleton(), + )?; + enter_call( + GroundEnterStage::SetCollisionState, + services.set_collision_state_82d911a8(6), + )?; + + state.word_2560 = 0; + state.word_2564 = 0; + state.flag_2708 = false; + state.controls_latched_2725 = false; + state.captured_position_valid_2726 = false; + state.was_pinning_2728 = false; + state.flag_2731 = false; + state.manual_correction_2732 = false; + state.manual_opposition_2733 = false; + + enter_call( + GroundEnterStage::ResetExternalObjects, + services.reset_ground_external_objects(), + )?; + enter_call( + GroundEnterStage::InitializeBoardSpeed, + services.initialize_board_speed_82d375d0(), + )?; + components.speed_wobble.reset(); + enter_ground( + components.manual, + input.previous_category_2516, + input.powerslide_exit_scale, + input.manual_ground, + bodies, + projection, + ) + .map_err(|source| GroundEnterError::ManualProjection { + stage: GroundEnterStage::EnterManual, + source, + })?; + + if input.previous_category_2516 == 200 { + enter_call( + GroundEnterStage::EnterFromAir, + services.enter_from_air_82bf2f18(), + )?; + } + let mut result = GroundEnterResult { + landing_force_attempted: false, + landing_force_queued: false, + }; + if input.previous_state_2504 == 503 { + let mut force = enter_call( + GroundEnterStage::BuildLandingOnDeckForce, + services.landing_on_deck_force(), + )?; + force.tag = 19; + result.landing_force_attempted = true; + result.landing_force_queued = components.force_queue.append(force); + } + + state.straighten_scale_2672 = if input.previous_state_2504 == 101 { + f32::from_bits(0x3e23_d70a) + } else { + 1.0 + }; + components.pumping.reset(); + enter_call( + GroundEnterStage::SetControllerMode, + services.set_controller_mode_one(), + )?; + + state.steering_push_scalar_2640 = 1.0; + state.steering_damped_turn_2644 = 0.0; + state.elapsed_2648 = 0.0; + state.collision_countdown_2652 = 0.0; + state.vector_2592 = [0.0; 4]; + state.vector_2608 = [0.0; 4]; + state.anti_flip_torque_2624 = [0.0; 4]; + state.hang_detection_frames_2740 = 0; + state.hang_force_frames_2744 = 0; + state.hung_wipeout_frames_2748 = 0; + state.anti_flip_nudge_frames_2752 = 0; + state.flag_2721 = false; + state.anti_flip_nudge_applied_2723 = false; + state.push_suppressed_2730 = false; + Ok(result) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundExitStage { + SetWheelMaterials, + RestoreAnimatedBoardVelocity, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GroundExitError { + Drag(DragBindingError), + Service { stage: GroundExitStage, source: E }, +} + +pub trait GroundExitServices { + type Error; + + fn set_default_wheel_materials(&mut self) -> Result<(), Self::Error>; + /// Full `82D37BAC..82D37C30` jump-velocity clamp and board velocity write. + fn restore_animated_board_velocity(&mut self, vector_2688: [f32; 4]) + -> Result<(), Self::Error>; +} + +pub fn exit( + state: &mut PhysicsGroundState, + pumping: &mut PumpingState, + inertias: &mut BodyInertias<'_>, + services: &mut S, +) -> Result<(), GroundExitError> { + state.steering_damped_turn_2644 = 0.0; + state.steering_push_scalar_2640 = 1.0; + pumping.reset(); + inertias + .set_linear_drag(0.0, DragSelection::AllParts) + .map_err(GroundExitError::Drag)?; + services + .set_default_wheel_materials() + .map_err(|source| GroundExitError::Service { + stage: GroundExitStage::SetWheelMaterials, + source, + })?; + if state.flag_2708 { + services + .restore_animated_board_velocity(state.vector_2688) + .map_err(|source| GroundExitError::Service { + stage: GroundExitStage::RestoreAnimatedBoardVelocity, + source, + })?; + } + Ok(()) +} + +fn enter_call( + stage: GroundEnterStage, + result: Result, +) -> Result> { + result.map_err(|source| GroundEnterError::Service { stage, source }) +} diff --git a/crates/skate-core/src/riding/grounded/state/mod.rs b/crates/skate-core/src/riding/grounded/state/mod.rs new file mode 100644 index 00000000..44a844a6 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/mod.rs @@ -0,0 +1,19 @@ +//! Readable composition of the TU3 `PhysState_PhysicsGround` lifecycle. +//! +//! This module owns native state order. Collision queries, skeleton writes and +//! other engine objects remain required interfaces; no branch has a no-op +//! implementation. + +pub mod board; +pub mod board_types; +pub mod contact_state; +pub mod corrections; +pub mod data; +pub mod lifecycle; +pub mod motion; +mod ordinary; +pub mod output; +pub mod post_physics; +pub mod update; + +pub use data::{GroundContactHistory, GroundUpdateInput, PhysicsGroundState}; diff --git a/crates/skate-core/src/riding/grounded/state/motion.rs b/crates/skate-core/src/riding/grounded/state/motion.rs new file mode 100644 index 00000000..6243f5dc --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/motion.rs @@ -0,0 +1,89 @@ +//! Physical Ground entry82D37604..82D378A8 and future-deck82D38630 math. +//! These operate on actual board rates/force records. Skeleton publication is +//! separate because prediction moves its animation target, not the board. +use crate::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + native_arithmetic::reciprocal_estimate, + }, + riding::ground_correction_math::dot_product, +}; + +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn words(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn reciprocal(value: f32) -> f32 { + let mut r = reciprocal_estimate(value); + for _ in 0..2 { + r = r.mul_add((-value).mul_add(r, 1.0), r); + } + r +} +/// Projection is applied directly to deck angular velocity, before speed seed. +pub fn entry_angular_velocity(normal: [f32; 4], angular: [f32; 4]) -> Vector3 { + let speed = dot_product(normal, angular); + xyz(normal.map(|v| v * speed)) +} +/// Board+260 is the smaller absolute longitudinal speed before/after normal +/// rejection. The source fsel retains the raw value on an unordered compare. +pub fn entry_target_speed(velocity: Vector3, normal: [f32; 4], forward: [f32; 4]) -> f32 { + let v = words(velocity); + let along_normal = dot_product(v, normal); + let tangent = core::array::from_fn(|i| v[i] - normal[i] * along_normal); + let raw = dot_product(v, forward).abs(); + let planar = dot_product(tangent, forward).abs(); + if raw - planar >= 0.0 { planar } else { raw } +} +/// Exact tag19 producer. Gravity and normal velocity are not added here. +pub fn landing_on_deck_force( + physical_velocity: [f32; 4], + animation_velocity: [f32; 4], + axis: [f32; 4], + mass: f32, + strength: f32, + point_y: f32, +) -> QueuedPointForce { + let delta = core::array::from_fn(|i| animation_velocity[i] - physical_velocity[i]); + let projected = dot_product(delta, axis); + let step_inverse = reciprocal(f32::from_bits(0x3c88_8889)); + let force = core::array::from_fn(|i| { + ((delta[i] - axis[i] * projected) * strength) * mass * step_inverse + }); + QueuedPointForce { + tag: 19, + force_world: xyz(force), + point_body: Vector3::new(0.0, point_y, 0.0), + } +} +/// Complete displacement calculation in PredictFutureDeck. Force summation +/// preserves queue order; normal rejection occurs after the first dt/mass. +/// Returns None when the source deliberately preserves both skeleton targets. +pub fn future_deck_displacement( + forces: &BoardForceQueue, + mass: f32, + dt: f32, + normal: [f32; 4], + pushing: bool, + manual_correction: bool, +) -> Option { + if pushing || manual_correction { + return None; + } + let mut sum = [0.0; 4]; + for force in forces.entries() { + let v = words(force.force_world); + for i in 0..4 { + sum[i] += v[i]; + } + } + let scale = reciprocal(mass) * dt; + let delta = sum.map(|v| v * scale); + let projection = dot_product(delta, normal); + Some(xyz(core::array::from_fn(|i| { + (delta[i] - normal[i] * projection) * dt + }))) +} diff --git a/crates/skate-core/src/riding/grounded/state/ordinary.rs b/crates/skate-core/src/riding/grounded/state/ordinary.rs new file mode 100644 index 00000000..d20e2f92 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/ordinary.rs @@ -0,0 +1,293 @@ +//! Ordinary (non-animated, non-collision-response) `82D38800` path. + +use crate::{ + math::Vector3, + physics::{force_queue::QueuedPointForce, manual::controller}, + riding::{ + anti_flip, ground_force, + grounded::propulsion::{self, PropulsionSubmission}, + heading, pumping, slide_friction, speed_model, straighten, + }, +}; + +use super::{ + board::service, + board_types::{ + GroundBoardComponents, GroundBoardError, GroundBoardInput, GroundBoardServices, + GroundBoardSettings, GroundBoardStage, GroundForceFrame, + }, + corrections, + data::PhysicsGroundState, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OrdinaryGroundResult { + pub manual_correction: bool, + pub terminal_force_tag: u32, + pub terminal_force_queued: bool, + pub speed_model_reset: bool, +} + +pub(super) fn update( + state: &mut PhysicsGroundState, + components: GroundBoardComponents<'_, '_>, + settings: GroundBoardSettings<'_>, + mut input: GroundBoardInput, + mut contact_force: QueuedPointForce, + services: &mut S, +) -> Result> { + let mut suppressed = 0_u8; + let propulsion = propulsion::calculate(input.propulsion, settings.propulsion, &mut suppressed); + state.push_suppressed_2730 = suppressed != 0; + + let (ground_a, ground_b) = ground_forces(settings.ground_force, &input.ground_force); + input.speed_model.manual_state_276 = components.manual.angular_correction; + let speed_force = speed_model::update( + components.speed_model, + settings.speed_model, + &input.speed_model, + ); + input.slide_friction.heading_time = state.elapsed_2648; + // Both helpers read wheel hardness from ProcessedPhysIn+2764 + // (82D92AAC / 82D92B80), supplied by the animation profile. + let slide_force = slide_friction::calculate(settings.slide_friction, &input.slide_friction); + let pump_force = pumping::calculate(&input.pump_force); + input.straighten.heading_time = state.elapsed_2648; + let straighten = straighten::calculate(settings.straighten, &input.straighten); + let scaled_straighten = straighten.map(|lane| lane * state.straighten_scale_2672); + let heading = heading::calculate( + settings.heading, + &input.heading, + components.heading_previous, + ); + let anti_flip = anti_flip::calculate(settings.anti_flip, &input.anti_flip); + state.anti_flip_torque_2624 = anti_flip; + + input.manual.powersliding = false; + let manual = controller::calculate( + components.manual, + settings.manual, + settings.manual_mode, + &input.manual, + services, + ) + .map_err(GroundBoardError::Manual)?; + state.manual_correction_2732 = manual.correction_active; + state.manual_opposition_2733 = manual.opposing_motion_without_correction; + let drag = input.ground_drag.calculate(settings.linear_drag); + + let terminal_queued = if manual.correction_active { + match propulsion.submit(&manual, components.force_queue) { + PropulsionSubmission::ManualCorrection(queued) => queued, + PropulsionSubmission::BrakingAndPush(_) => unreachable!(), + } + } else { + let push = vector4(propulsion.push.vector); + let push_squared = service( + GroundBoardStage::PushForceSquared, + services.dot3(push, push), + )?; + if push_squared > 1.0 { + state.flag_2721 = true; + } + corrections::update_anti_flip_nudge( + state, + input.anti_flip_nudge, + components.force_queue, + services, + ) + .map_err(|source| GroundBoardError::Service { + stage: GroundBoardStage::AntiFlipNudge, + source, + })?; + if input.contact_time_2756 < settings.ground_force_contact_time_limit { + components.force_queue.append(force_record(4, ground_a)); + components.force_queue.append(force_record(5, ground_b)); + } + match propulsion.submit(&manual, components.force_queue) { + PropulsionSubmission::BrakingAndPush(_) => {} + PropulsionSubmission::ManualCorrection(_) => unreachable!(), + } + apply_body_vectors( + state, + settings.collision_response_scale, + input.propulsion.timestep, + scaled_straighten, + heading, + anti_flip, + manual.angular_displacement, + services, + )?; + if input.trajectory_state_1776_bits & 0x8000_0000 == 0 { + components.force_queue.append(force_record(6, speed_force)); + } + components.force_queue.append(force_record(1, slide_force)); + components.force_queue.append(force_record(8, pump_force)); + contact_force.tag = 16; + components.force_queue.append(contact_force) + }; + + components + .inertias + .apply_ground_drag(drag) + .map_err(GroundBoardError::Drag)?; + let reset = update_speed_model_reset( + components.speed_model, + settings.speed_model_reset_force_squared, + propulsion.braking.force_world, + propulsion.push.vector, + pump_force, + services, + )?; + corrections::manage_hang_ups(state, input.hang_up, services).map_err(|source| { + GroundBoardError::Service { + stage: GroundBoardStage::HangUps, + source, + } + })?; + corrections::manage_halfpipe_wheel_catches(input.halfpipe_wheel_catch, services).map_err( + |source| GroundBoardError::Service { + stage: GroundBoardStage::HalfpipeWheelCatch, + source, + }, + )?; + corrections::consider_pinning(state, input.pinning, services).map_err(|source| { + GroundBoardError::Service { + stage: GroundBoardStage::Pinning, + source, + } + })?; + Ok(OrdinaryGroundResult { + manual_correction: manual.correction_active, + terminal_force_tag: if manual.correction_active { 7 } else { 16 }, + terminal_force_queued: terminal_queued, + speed_model_reset: reset, + }) +} + +fn ground_forces( + settings: &ground_force::GroundForceSettings, + frame: &GroundForceFrame, +) -> ([f32; 8], [f32; 8]) { + let mut side_scalar = 0.0; + if frame.processed_2776 == 0.0 || frame.processed_2780 < 0.0 { + let value = frame.ground_scalar_1232 * frame.previous_state_scalar_56; + side_scalar = if -value >= 0.0 { 0.0 } else { value }; + } + let common = |application_z, argument_3, argument_4| ground_force::GroundForceInput { + argument_1: frame.argument_1_2752, + application_z, + argument_3, + argument_4, + balance: frame.balance_2720, + surface_speed: frame.surface_speed_2656, + axis_384: frame.axis_384, + velocity_400: frame.velocity_400, + axis_544: frame.axis_544, + }; + let first = ground_force::calculate( + settings, + &common( + frame.ground_scalar_1216, + side_scalar, + first_balance_selector(frame.balance_2720), + ), + ); + let second = ground_force::calculate( + settings, + &common( + -frame.ground_scalar_1240, + frame.ground_scalar_1236 * side_scalar, + second_balance_selector(frame.balance_2720), + ), + ); + (first, second) +} + +fn apply_body_vectors( + state: &mut PhysicsGroundState, + collision_scale: f32, + timestep: f32, + straighten: [f32; 4], + heading: [f32; 4], + anti_flip: [f32; 4], + manual: [f32; 4], + services: &mut S, +) -> Result<(), GroundBoardError> { + if state.collision_countdown_2652 > 0.0 { + state.collision_countdown_2652 -= timestep; + state.vector_2592 = state.vector_2592.map(|lane| lane * collision_scale); + service( + GroundBoardStage::ApplyCollisionDecay, + services.apply_vector_82c07000(state.vector_2592), + )?; + } + service( + GroundBoardStage::ApplyStraighten, + services.apply_vector_82c07000(straighten), + )?; + service( + GroundBoardStage::ApplyHeading, + services.apply_vector_82c07000(heading), + )?; + service( + GroundBoardStage::ApplyAntiFlip, + services.apply_angular_displacement_82c075b8(anti_flip), + )?; + service( + GroundBoardStage::ApplyManual, + services.apply_angular_displacement_82c075b8(manual), + )?; + Ok(()) +} + +fn update_speed_model_reset( + speed_model: &mut crate::riding::speed_model::SpeedModelState, + threshold: f32, + brake: Vector3, + push: Vector3, + pump: [f32; 8], + services: &mut S, +) -> Result> { + let combined = [ + (push.x + brake.x) + pump[0], + (push.y + brake.y) + pump[1], + (push.z + brake.z) + pump[2], + pump[3], + ]; + let squared = service( + GroundBoardStage::StrongForceSquared, + services.dot3(combined, combined), + )?; + if squared > threshold { + speed_model.flags_1360 |= 0x8000_0000; + Ok(true) + } else { + Ok(false) + } +} + +fn first_balance_selector(value: f32) -> f32 { + if value <= 0.0 { + if value >= -0.0 { 1.0 } else { 2.0 } + } else { + 0.0 + } +} + +fn second_balance_selector(value: f32) -> f32 { + let nonnegative = if value <= 0.0 { 1.0 } else { 2.0 }; + if value >= -0.0 { nonnegative } else { 0.0 } +} + +fn force_record(tag: u32, value: [f32; 8]) -> QueuedPointForce { + QueuedPointForce { + tag, + force_world: Vector3::new(value[0], value[1], value[2]), + point_body: Vector3::new(value[4], value[5], value[6]), + } +} + +fn vector4(value: Vector3) -> [f32; 4] { + [value.x, value.y, value.z, 0.0] +} diff --git a/crates/skate-core/src/riding/grounded/state/output.rs b/crates/skate-core/src/riding/grounded/state/output.rs new file mode 100644 index 00000000..c88d7542 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/output.rs @@ -0,0 +1,321 @@ +//! `PhysState_PhysicsGround::FillPhysOut` (`82D3A388`). +//! +//! The TU3 body is a leaf: it reads Ground and ProcessedPhysIn fields and +//! writes distinct PhysOut records. Names recovered from Skate 2 are used only +//! where the TU3 offsets and operations agree. Remaining fields retain their +//! destination offsets. Xenon vector arithmetic uses the project's isolated +//! accepted host approximation; no generated runtime code is included. + +use crate::physics::native_arithmetic; + +use super::data::PhysicsGroundState; + +pub type Vector4 = [f32; 4]; + +/// ProcessedPhysIn values consumed by the TU3 output leaf. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOutputFrame { + pub axis_464: Vector4, + pub velocity_608: Vector4, + pub absolute_body_speed_2616: f32, + pub deck_speed_2652: f32, + pub state_timer_2664: f32, + pub scalar_2720: f32, + pub flags_2476: u32, + pub flags_2484: u32, + /// ProcessedPhysIn+2548 -> selected collection+4 -> byte+109. + pub selected_mode_flag_109: bool, +} + +/// Live collection values loaded through the global collection singleton. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOutputSettings { + /// Selected ground-layout +4 and +8, added before the pushable-speed test. + pub pushable_speed_terms_4_8: [f32; 2], + /// Selected ground-layout +0, used by the conditional output+52/+58 test. + pub mode_speed_threshold_0: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SkateboardMotionOutput { + /// PhysOut pointer+4, byte+274. + pub is_push_accelerating: bool, + /// PhysOut pointer+4, byte+276. + pub is_at_pushable_speed: bool, +} + +/// Conditional writes to the record reached through PhysOut pointer+36. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundVelocityProjectionOutput { + /// Destination +144. + pub velocity_without_axis_component: Vector4, + /// Destination +164. The native branch only ever writes true. + pub active: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundRecordOutput { + /// PhysOut pointer+32, bytes +316/+317/+318. + pub wall_ride_exit: bool, + pub anti_flip_nudge_present: bool, + pub is_pinning: bool, + /// PhysOut pointer+32, vector+128. + pub anti_flip_torque: Vector4, + /// PhysOut pointer+32, floats +276/+280. + pub time_to_skitch: f32, + pub skitch_spline_height: f32, + /// PhysOut pointer+32, float+304. + pub processed_scalar_2720: f32, + /// PhysOut pointer+32, byte+325. + pub processed_flag_2484_bit_13: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StateRecordOutput { + /// PhysOut pointer+28, words +36/+40. + pub grab_spline_type: u32, + pub grab_spline_object_id: u32, + /// PhysOut pointer+28, byte+84. Stock name is not yet proven. + pub flag_84: bool, + /// PhysOut pointer+28, byte+86. + pub has_world_grab_intent_without_object: bool, + /// PhysOut pointer+28, byte+78 is untouched when this is `None`. + pub manual_correction_write_78: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IntentRecordOutput { + /// PhysOut pointer+52, byte+55. + pub has_world_grab_intent: bool, + /// PhysOut pointer+52, byte+58. Stock name is not yet proven. + pub selected_mode_below_speed_threshold_58: bool, +} + +/// All writes made by TU3 `82D3A388`. Field grouping follows the pointer table +/// reached through the PhysOut argument. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsGroundOutput { + pub skateboard_motion_4: SkateboardMotionOutput, + /// `None` means the native `ProcessedPhysIn+2664 == 0.0` branch did not + /// touch pointer+36 vector+144 or byte+164. + pub velocity_projection_36: Option, + pub ground_32: GroundRecordOutput, + pub state_28: StateRecordOutput, + pub intents_52: IntentRecordOutput, + /// PhysOut pointer+72, byte+304. + pub is_grabbing_object_72_304: bool, + /// PhysOut pointer+56, byte+168. + pub manual_opposition_56_168: bool, + /// PhysOut pointer+20, byte+596. + pub push_suppressed_20_596: bool, +} + +/// Complete TU3 output leaf. Every comparison uses the same strict relation as +/// its `fcmpu`/branch pair, so unordered values do not select either true path. +pub fn fill_physics_output( + state: &PhysicsGroundState, + frame: GroundOutputFrame, + settings: GroundOutputSettings, +) -> PhysicsGroundOutput { + let has_world_grab_intent = frame.flags_2476 & 0x0040_0000 != 0; + let velocity_projection_36 = + (frame.state_timer_2664 == 0.0).then(|| GroundVelocityProjectionOutput { + velocity_without_axis_component: reject_axis_component( + frame.velocity_608, + frame.axis_464, + ), + active: true, + }); + + PhysicsGroundOutput { + skateboard_motion_4: SkateboardMotionOutput { + is_push_accelerating: state.flag_2721, + is_at_pushable_speed: frame.absolute_body_speed_2616 + < settings.pushable_speed_terms_4_8[0] + settings.pushable_speed_terms_4_8[1], + }, + velocity_projection_36, + ground_32: GroundRecordOutput { + wall_ride_exit: state.flag_2720, + anti_flip_nudge_present: state.anti_flip_nudge_applied_2723, + is_pinning: state.pinning_2727, + anti_flip_torque: state.anti_flip_torque_2624, + time_to_skitch: state.scalar_2664, + skitch_spline_height: state.scalar_2668, + processed_scalar_2720: frame.scalar_2720, + processed_flag_2484_bit_13: frame.flags_2484 & 0x0000_2000 != 0, + }, + state_28: StateRecordOutput { + grab_spline_type: state.word_2560, + grab_spline_object_id: state.word_2564, + flag_84: state.flag_2731, + has_world_grab_intent_without_object: has_world_grab_intent && !state.flag_2729, + manual_correction_write_78: state.manual_correction_2732.then_some(true), + }, + intents_52: IntentRecordOutput { + has_world_grab_intent, + selected_mode_below_speed_threshold_58: frame.selected_mode_flag_109 + && frame.deck_speed_2652 < settings.mode_speed_threshold_0, + }, + is_grabbing_object_72_304: state.flag_2729, + manual_opposition_56_168: state.manual_opposition_2733, + push_suppressed_20_596: state.push_suppressed_2730, + } +} + +fn reject_axis_component(velocity: Vector4, axis: Vector4) -> Vector4 { + // Direct TU3 sequence: vmsum3fp128, vmulfp128, vsubfp128. `dot3` is the + // isolated, explicitly documented host approximation for the Xenon sum. + let projection = native_arithmetic::dot3(velocity, axis); + core::array::from_fn(|lane| { + let scaled = flush_subnormal(axis[lane] * projection); + flush_subnormal(velocity[lane] - scaled) + }) +} + +fn flush_subnormal(value: f32) -> f32 { + let bits = value.to_bits(); + if bits & 0x7f80_0000 == 0 && bits & 0x007f_ffff != 0 { + f32::from_bits(bits & 0x8000_0000) + } else { + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state() -> PhysicsGroundState { + PhysicsGroundState { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0x1122_3344, + word_2564: 0x5566_7788, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [1.0, 2.0, 3.0, 4.0], + steering_push_scalar_2640: 1.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.0, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 6.0, + scalar_2668: 7.0, + straighten_scale_2672: 1.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: true, + flag_2721: true, + flag_2722: false, + anti_flip_nudge_applied_2723: true, + human_player_2724: true, + controls_latched_2725: false, + captured_position_valid_2726: true, + pinning_2727: true, + was_pinning_2728: false, + flag_2729: false, + push_suppressed_2730: true, + flag_2731: true, + manual_correction_2732: true, + manual_opposition_2733: true, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 0, + } + } + + fn frame() -> GroundOutputFrame { + GroundOutputFrame { + axis_464: [1.0, 0.0, 0.0, 0.0], + velocity_608: [5.0, 2.0, 3.0, 4.0], + absolute_body_speed_2616: 4.0, + deck_speed_2652: 2.0, + state_timer_2664: 0.0, + scalar_2720: 8.0, + flags_2476: 0x0040_0000, + flags_2484: 0x0000_2000, + selected_mode_flag_109: true, + } + } + + fn settings() -> GroundOutputSettings { + GroundOutputSettings { + pushable_speed_terms_4_8: [2.0, 3.0], + mode_speed_threshold_0: 3.0, + } + } + + #[test] + fn fill_maps_every_unconditional_field_and_projection_branch() { + let output = fill_physics_output(&state(), frame(), settings()); + + assert_eq!( + output.skateboard_motion_4, + SkateboardMotionOutput { + is_push_accelerating: true, + is_at_pushable_speed: true, + } + ); + assert_eq!( + output.velocity_projection_36, + Some(GroundVelocityProjectionOutput { + velocity_without_axis_component: [0.0, 2.0, 3.0, 4.0], + active: true, + }) + ); + assert!(output.ground_32.wall_ride_exit); + assert!(output.ground_32.anti_flip_nudge_present); + assert!(output.ground_32.is_pinning); + assert_eq!(output.ground_32.anti_flip_torque, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(output.ground_32.time_to_skitch, 6.0); + assert_eq!(output.ground_32.skitch_spline_height, 7.0); + assert_eq!(output.ground_32.processed_scalar_2720, 8.0); + assert!(output.ground_32.processed_flag_2484_bit_13); + assert_eq!(output.state_28.grab_spline_type, 0x1122_3344); + assert_eq!(output.state_28.grab_spline_object_id, 0x5566_7788); + assert!(output.state_28.flag_84); + assert!(output.state_28.has_world_grab_intent_without_object); + assert_eq!(output.state_28.manual_correction_write_78, Some(true)); + assert!(output.intents_52.has_world_grab_intent); + assert!(output.intents_52.selected_mode_below_speed_threshold_58); + assert!(!output.is_grabbing_object_72_304); + assert!(output.manual_opposition_56_168); + assert!(output.push_suppressed_20_596); + } + + #[test] + fn fill_preserves_native_conditional_writes_and_unordered_comparisons() { + let mut state = state(); + state.flag_2729 = true; + state.manual_correction_2732 = false; + let mut frame = frame(); + frame.state_timer_2664 = f32::NAN; + frame.absolute_body_speed_2616 = f32::NAN; + frame.deck_speed_2652 = f32::NAN; + + let output = fill_physics_output(&state, frame, settings()); + + assert_eq!(output.velocity_projection_36, None); + assert!(!output.skateboard_motion_4.is_at_pushable_speed); + assert!(!output.intents_52.selected_mode_below_speed_threshold_58); + assert!(!output.state_28.has_world_grab_intent_without_object); + assert_eq!(output.state_28.manual_correction_write_78, None); + } + + #[test] + fn fill_extracts_only_the_two_recovered_processed_bits() { + let mut frame = frame(); + frame.flags_2476 = 0x0020_0000; + frame.flags_2484 = 0x0000_1000; + frame.state_timer_2664 = -0.0; + + let output = fill_physics_output(&state(), frame, settings()); + + assert!(!output.intents_52.has_world_grab_intent); + assert!(!output.ground_32.processed_flag_2484_bit_13); + assert!(output.velocity_projection_36.is_some()); + } +} diff --git a/crates/skate-core/src/riding/grounded/state/post_physics.rs b/crates/skate-core/src/riding/grounded/state/post_physics.rs new file mode 100644 index 00000000..4af2084a --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/post_physics.rs @@ -0,0 +1,43 @@ +//! `PhysState_PhysicsGround::UpdatePostPhysics` (`82D387A8`). + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundPostPhysicsStage { + CheckForGroundWipeout, + UpdateBoardPath, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroundPostPhysicsError { + pub stage: GroundPostPhysicsStage, + pub source: E, +} + +/// Wipeout::CheckForGroundWipeout82D8F9E0 and board path82C05EC0. +/// There is deliberately no empty implementation. +pub trait GroundPostPhysicsServices { + type Error; + + fn check_for_ground_wipeout_82d8f9e0(&mut self) -> Result<(), Self::Error>; + fn update_board_path_82c05ec0(&mut self) -> Result<(), Self::Error>; +} + +pub fn update_post_physics( + offboard_trajectory_state_1776: i32, + services: &mut S, +) -> Result<(), GroundPostPhysicsError> { + services + .check_for_ground_wipeout_82d8f9e0() + .map_err(|source| GroundPostPhysicsError { + stage: GroundPostPhysicsStage::CheckForGroundWipeout, + source, + })?; + if offboard_trajectory_state_1776 < 0 { + services + .update_board_path_82c05ec0() + .map_err(|source| GroundPostPhysicsError { + stage: GroundPostPhysicsStage::UpdateBoardPath, + source, + })?; + } + Ok(()) +} diff --git a/crates/skate-core/src/riding/grounded/state/update.rs b/crates/skate-core/src/riding/grounded/state/update.rs new file mode 100644 index 00000000..0ef76bde --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/update.rs @@ -0,0 +1,428 @@ +//! `PhysState_PhysicsGround::Update` (`82D37C88`) call composition. + +use crate::riding::pumping::{ + controller::{self, PumpingGeometry, PumpingSample}, + settings::{PumpingMode, PumpingSettings}, + state::PumpingState, +}; + +use super::{ + contact_state::{self, GroundContactServices}, + data::{GroundContactHistory, GroundUpdateInput, PhysicsGroundState}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundUpdateStage { + SetElapsedSkeletonFlag, + UpdateContactState, + LoadPumpingFrame, + UpdatePumping, + UpdateSkateboard, + PredictFutureDeck, + SetCollisionSkeletonFlag, + SetGroundSkeletonFlag, + UpdateSpecialState, + UpdateTrajectory, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroundUpdateError { + pub stage: GroundUpdateStage, + pub source: E, +} + +/// Required engine-facing operations in the exact `82D37C88` order. This +/// trait has no default implementation because each call mutates stock-owned +/// objects that are outside `skate-core`. +pub trait GroundUpdateServices: + PumpingGeometry + + GroundContactServices +{ + type UpdateError; + type ContactHandle; + + fn set_elapsed_skeleton_flag(&mut self) -> Result<(), Self::UpdateError>; + /// Supplies the exact deck-angle result produced at `82D37D34..82D37E54` + /// together with the other direct Pumping inputs. Generic host angle math + /// is not an allowed implementation of this producer. + fn pumping_frame( + &mut self, + ) -> Result<(PumpingSettings, PumpingMode, PumpingSample), Self::UpdateError>; + fn update_skateboard( + &mut self, + state: &mut PhysicsGroundState, + ) -> Result<(), Self::UpdateError>; + fn predict_future_deck(&mut self) -> Result<(), Self::UpdateError>; + fn set_collision_skeleton_flag(&mut self) -> Result<(), Self::UpdateError>; + fn set_ground_skeleton_flag(&mut self) -> Result<(), Self::UpdateError>; + fn update_special_state(&mut self) -> Result<(), Self::UpdateError>; + fn update_trajectory(&mut self) -> Result<(), Self::UpdateError>; +} + +/// Runs the full recovered outer Ground update. State writes before a failing +/// service remain visible, matching the native non-transactional execution. +pub fn update( + state: &mut PhysicsGroundState, + pumping: &mut PumpingState, + contacts: &mut GroundContactHistory, + input: GroundUpdateInput, + services: &mut S, +) -> Result<(), GroundUpdateError> { + let signals = state.begin_update(input); + if signals.set_skeleton_flag_16505 { + call( + GroundUpdateStage::SetElapsedSkeletonFlag, + services.set_elapsed_skeleton_flag(), + )?; + } + call( + GroundUpdateStage::UpdateContactState, + contact_state::update(contacts, input.contact_state, services), + )?; + let (settings, mode, sample) = call( + GroundUpdateStage::LoadPumpingFrame, + services.pumping_frame(), + )?; + call( + GroundUpdateStage::UpdatePumping, + controller::update_ground(pumping, &settings, mode, sample, services), + )?; + call( + GroundUpdateStage::UpdateSkateboard, + services.update_skateboard(state), + )?; + call( + GroundUpdateStage::PredictFutureDeck, + services.predict_future_deck(), + )?; + if state.flag_2722 { + call( + GroundUpdateStage::SetCollisionSkeletonFlag, + services.set_collision_skeleton_flag(), + )?; + } + call( + GroundUpdateStage::SetGroundSkeletonFlag, + services.set_ground_skeleton_flag(), + )?; + state.finish_update(input.timestep_2604); + if input.contact_state.flags_2476 & 0x0040_0000 != 0 { + call( + GroundUpdateStage::UpdateSpecialState, + services.update_special_state(), + ) + } else { + call( + GroundUpdateStage::UpdateTrajectory, + services.update_trajectory(), + ) + } +} + +fn call(stage: GroundUpdateStage, result: Result) -> Result> { + result.map_err(|source| GroundUpdateError { stage, source }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::point_graph::PointGraph; + use crate::riding::grounded::state::contact_state::GroundContactStateInput; + + #[derive(Default)] + struct Services { + events: Vec<&'static str>, + collision_signal: bool, + } + + impl PumpingGeometry for Services { + type Error = &'static str; + + fn height( + &mut self, + _normal: [f32; 4], + _com_to_deck: [f32; 4], + ) -> Result { + self.events.push("pumping-height"); + Ok(2.0) + } + + fn speed( + &mut self, + _previous: [f32; 4], + _current: [f32; 4], + _dt: f32, + ) -> Result { + Err("unexpected speed") + } + + fn angular_speed( + &mut self, + _previous_position: [f32; 4], + _previous_normal: [f32; 4], + _sample: &PumpingSample, + _dt: f32, + ) -> Result { + Err("unexpected angular speed") + } + + fn inclination_radians(&mut self, _normal_y: f32) -> Result { + Err("unexpected inclination") + } + } + + impl GroundContactServices for Services { + type Error = &'static str; + type Handle = u32; + + fn stop_contact_tracking_82d62f20(&mut self) -> Result<(), Self::Error> { + self.events.push("contact-stop"); + Ok(()) + } + + fn clear_inactive_contact_output_3024(&mut self) -> Result<(), Self::Error> { + self.events.push("contact-clear"); + Ok(()) + } + + fn initial_contact_handle_1920(&mut self) -> Result, Self::Error> { + self.events.push("contact-initial"); + Ok(None) + } + + fn identify_contact_82d63c30( + &mut self, + _frame: &GroundContactStateInput, + ) -> Result, Self::Error> { + self.events.push("contact-identify"); + Ok(None) + } + + fn publish_contact_82d61268( + &mut self, + _frame: &GroundContactStateInput, + _retained: Option<&Self::Handle>, + ) -> Result<(), Self::Error> { + self.events.push("contact-publish"); + Ok(()) + } + } + + impl GroundUpdateServices for Services { + type UpdateError = &'static str; + type ContactHandle = u32; + + fn set_elapsed_skeleton_flag(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("elapsed-flag"); + Ok(()) + } + + fn pumping_frame( + &mut self, + ) -> Result<(PumpingSettings, PumpingMode, PumpingSample), Self::UpdateError> { + self.events.push("pumping-frame"); + let graph = PointGraph { + x: [0.0; 8], + y: [0.0; 8], + }; + Ok(( + PumpingSettings { + pump_vs_speed: graph, + pump_vs_time: graph, + min_crouch_vs_ground_angle: graph, + compression_vs_ground_angle: graph, + compression_vs_deck_angle: graph, + height_change_damping: 0.0, + minimum_height_change: 0.0, + maximum_height_change: 0.0, + ground_compression_scale: 0.0, + deck_compression_scale: 0.0, + angular_speed_damping: 0.0, + }, + PumpingMode { + maximum_absorption_per_second: 0.0, + maximum_acceleration_per_second: 0.0, + absorption_factor: 0.0, + acceleration_factor: 0.0, + }, + PumpingSample { + position: [0.0; 4], + normal: [0.0, 1.0, 0.0, 0.0], + com_to_deck_world: [0.0; 4], + deck_angle: 0.0, + intentional_pumping: 0, + }, + )) + } + + fn update_skateboard( + &mut self, + state: &mut PhysicsGroundState, + ) -> Result<(), Self::UpdateError> { + self.events.push("skateboard"); + state.flag_2722 = self.collision_signal; + Ok(()) + } + + fn predict_future_deck(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("predict"); + Ok(()) + } + + fn set_collision_skeleton_flag(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("collision-flag"); + Ok(()) + } + + fn set_ground_skeleton_flag(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("ground-flag"); + Ok(()) + } + + fn update_special_state(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("special"); + Ok(()) + } + + fn update_trajectory(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("trajectory"); + Ok(()) + } + } + + fn state() -> PhysicsGroundState { + PhysicsGroundState { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0, + word_2564: 0, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [9.0; 4], + steering_push_scalar_2640: 1.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.1, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 8.0, + scalar_2668: 0.0, + straighten_scale_2672: 1.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: false, + flag_2721: false, + flag_2722: false, + anti_flip_nudge_applied_2723: true, + human_player_2724: false, + controls_latched_2725: false, + captured_position_valid_2726: false, + pinning_2727: true, + was_pinning_2728: false, + flag_2729: true, + push_suppressed_2730: false, + flag_2731: false, + manual_correction_2732: false, + manual_opposition_2733: false, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 0, + } + } + + fn input(flags_2476: u32) -> GroundUpdateInput { + GroundUpdateInput { + wheel_contact_count_2556: 1, + position_112: [3.0, 4.0, 5.0, 0.0], + timestep_2604: 0.25, + contact_state: GroundContactStateInput { + flags_2476, + flags_2480: 0, + velocity_400: [0.0; 4], + axis_464: [0.0; 4], + contact_position_560: [0.0; 4], + trajectory_position_592: [0.0; 4], + reckoning_vector_1200: [0.0; 4], + differing_contact_frame_limit: 0, + }, + } + } + + #[test] + fn update_preserves_complete_outer_order_and_state_prefix() { + let mut state = state(); + let mut pumping = PumpingState::reset_state(); + let mut contacts = GroundContactHistory::empty(); + let mut services = Services { + collision_signal: true, + ..Default::default() + }; + + update( + &mut state, + &mut pumping, + &mut contacts, + input(0), + &mut services, + ) + .unwrap(); + + assert_eq!( + services.events, + [ + "elapsed-flag", + "contact-stop", + "contact-clear", + "pumping-frame", + "pumping-height", + "skateboard", + "predict", + "collision-flag", + "ground-flag", + "trajectory", + ] + ); + assert_eq!(state.captured_position_x_2656, 3.0); + assert_eq!(state.captured_position_z_2660, 5.0); + assert!(state.captured_position_valid_2726); + assert_eq!(state.anti_flip_torque_2624, [0.0; 4]); + assert!(!state.anti_flip_nudge_applied_2723); + assert!(!state.pinning_2727); + assert!(!state.flag_2729); + assert_eq!(state.scalar_2664, -1.0); + assert_eq!(state.elapsed_2648, 0.35); + } + + #[test] + fn active_contact_and_special_tail_select_only_their_native_paths() { + let mut state = state(); + state.elapsed_2648 = 0.0; + let mut pumping = PumpingState::reset_state(); + let mut contacts = GroundContactHistory::empty(); + let mut services = Services::default(); + + update( + &mut state, + &mut pumping, + &mut contacts, + input(0x0040_0000), + &mut services, + ) + .unwrap(); + + assert_eq!( + services.events, + [ + "contact-publish", + "pumping-frame", + "pumping-height", + "skateboard", + "predict", + "ground-flag", + "special", + ] + ); + } +} diff --git a/crates/skate-core/src/riding/heading.rs b/crates/skate-core/src/riding/heading.rs new file mode 100644 index 00000000..4aceec8d --- /dev/null +++ b/crates/skate-core/src/riding/heading.rs @@ -0,0 +1,114 @@ +//! Complete TU3 Toolkit_CalcHeadingTorque 82D92CF8. Ground calls this before +//! AntiFlipTorque; the two are distinct. The caller owns board+284 history. +use crate::{physics::reciprocal_sqrt::estimate, point_graph::PointGraph, trigonometry::acos}; + +pub struct HeadingSettings { + pub manual_wrong_wheel_scalar: f32, // +904 + pub manual_damping: f32, // +908 + pub speed_max: f32, // +912 + pub heading_strength: f32, // +916 + pub turn_strength: f32, // +920 + pub angular_response: PointGraph<8>, // +940/+972 + pub manual_response: PointGraph<8>, // +1020/+1052 + pub inclination_response: PointGraph<8>, // +1084/+1116 + pub speed_response: PointGraph<8>, // +1148/+1180 + /// Live four-lane threshold at 830BD350, initialized by 82F826F8. + pub normal_threshold: [f32; 4], +} + +pub struct HeadingInput { + pub balance: f32, // Processed +2720 + pub manual_turn: f32, // +2672 + pub flags_2472: u32, + pub timestep: f32, // +2604 + pub signed_speed: f32, // +2612 + pub manual_curve_input: f32, // +2652 + pub turn_2712: f32, + pub scalar_2740: f32, + pub velocity: [f32; 4], // +432 + pub normal: [f32; 4], // +464 + pub angular_velocity: [f32; 4], // +720 + /// Ground caller's r6 matrix, in native column order. No host orthogonalization. + pub transform: [[f32; 4]; 4], +} + +pub fn calculate(settings: &HeadingSettings, input: &HeadingInput, previous: &mut f32) -> [f32; 4] { + if input.balance != 0.0 && input.manual_turn != 0.0 { + let wrong = (input.flags_2472 & 0x0800_0000 != 0 && input.balance < 0.0) + || (input.flags_2472 & 0x0400_0000 != 0 && input.balance > 0.0); + let turn = if wrong { + input.manual_turn * settings.manual_wrong_wheel_scalar + } else { + input.manual_turn + }; + let target = settings.manual_response.evaluate(input.manual_curve_input) + * f32::from_bits(0x3C8E_FA35); + *previous = + (1.0 - settings.manual_damping).mul_add(*previous, target * settings.manual_damping); + let scalar = (input.timestep * *previous) * turn; + return input.normal.map(|n| n * scalar); + } + *previous = 0.0; + let matrix = input.transform; + let rows: [[f32; 4]; 3] = + core::array::from_fn(|i| [matrix[0][i], matrix[1][i], matrix[2][i], 0.0]); + let cosine = clamp(rows[1][1], -1.0, 1.0); + let angle = acos(cosine) * f32::from_bits(0x3F22_F983); + let angle = if angle > 1.2 { 0.0 } else { angle }; + let mut downhill: [f32; 4] = core::array::from_fn(|i| { + let v = rows[0][i] * 0.0; + let v = rows[1][i].mul_add(-1.0, v); + rows[2][i].mul_add(0.0, v) + }); + downhill[1] = 0.0; + let squared = dot3(downhill, downhill); + let mut inverse = estimate(squared); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let inclination = settings.inclination_response.evaluate(angle); + for i in 0..4 { + downhill[i] = (if length > settings.normal_threshold[i] { + downhill[i] * inverse + } else { + 0.0 + }) * inclination; + } + let mut velocity: [f32; 4] = core::array::from_fn(|i| { + let v = rows[0][i] * input.velocity[0]; + let v = rows[1][i].mul_add(input.velocity[1], v); + rows[2][i].mul_add(input.velocity[2], v) + }); + velocity[1] = 0.0; + // Native cyclic permutations select the Y component of velocity x downhill. + let cross_y = (-velocity[0]).mul_add(downhill[2], velocity[2] * downhill[0]); + let speed = clamp(input.signed_speed.abs(), 0.0, settings.speed_max); + let heading = (cross_y * settings.heading_strength) + * settings.speed_response.evaluate(speed / settings.speed_max); + let opposing = dot3(input.angular_velocity, input.normal) * -input.turn_2712; + let opposing = if opposing >= 0.0 { opposing } else { 0.0 }; + let turn = ((settings.angular_response.evaluate(opposing) * input.scalar_2740) + * settings.turn_strength) + * input.turn_2712; + let scalar = if !(heading * turn < 0.0) && !(turn.abs() > heading.abs()) { + heading + } else { + turn + }; + input.normal.map(|n| n * scalar) +} + +fn clamp(value: f32, lower: f32, upper: f32) -> f32 { + let v = if lower - value >= 0.0 { lower } else { value }; + if upper - v >= 0.0 { v } else { upper } +} + +fn dot3(a: [f32; 4], b: [f32; 4]) -> f32 { + super::vector::dot3(a, b) +} diff --git a/crates/skate-core/src/riding/mod.rs b/crates/skate-core/src/riding/mod.rs new file mode 100644 index 00000000..8a2f5490 --- /dev/null +++ b/crates/skate-core/src/riding/mod.rs @@ -0,0 +1,23 @@ +//! Recovered riding calculations. Inputs are processed physics inputs, not +//! platform stick/button values; action-graph producers remain a separate owner. +pub mod anti_flip; +pub mod braking; +pub mod ground_force; +pub mod grounded; +pub mod heading; +pub mod pumping; +pub mod push; +pub mod push_animation; +pub mod slide_friction; +pub mod speed_model; +pub mod speed_and_slope; +pub mod ground_orientation; +pub mod speed_wobble; +pub mod steering; +pub mod straighten; +mod vector; +pub mod push_behaviors; +pub mod ground_contact_response; +pub mod collision_response; +pub mod ground_correction_math; +pub mod reckoning_frames; diff --git a/crates/skate-core/src/riding/pumping.rs b/crates/skate-core/src/riding/pumping.rs new file mode 100644 index 00000000..20a2a291 --- /dev/null +++ b/crates/skate-core/src/riding/pumping.rs @@ -0,0 +1,31 @@ +//! Pumping lifecycle, native geometry and CalcPumpForce82D933D0 publication. +//! Ground supplies the current stock mode and queues the resulting board force. +pub mod controller; +pub mod geometry; +pub mod settings; +pub mod state; + +use super::vector::normalize; +pub struct PumpForceInput { + pub flags_2476: u32, + pub mode_multiplier: f32, // selected-mode layout+8 + pub pumping_scalar: f32, // Pumping+48 argument f1 + pub input_scalar_2660: f32, + pub timestep: f32, // Processed+2604 + pub direction_432: [f32; 4], + pub normal_threshold: [f32; 4], +} +pub fn calculate(i: &PumpForceInput) -> [f32; 8] { + let pumping = if i.flags_2476 & 2 == 0 { + i.mode_multiplier * i.pumping_scalar + } else { + i.pumping_scalar + }; + let scalar = (i.input_scalar_2660 * pumping) / i.timestep; + let direction = normalize(i.direction_432, i.normal_threshold); + let mut result = [0.0; 8]; + for n in 0..4 { + result[n] = direction[n] * scalar; + } + result +} diff --git a/crates/skate-core/src/riding/pumping/controller.rs b/crates/skate-core/src/riding/pumping/controller.rs new file mode 100644 index 00000000..9f7564b2 --- /dev/null +++ b/crates/skate-core/src/riding/pumping/controller.rs @@ -0,0 +1,174 @@ +//! Recovered scalar/control flow of TU3 82D8F228 and 82D8F470. +//! +//! Production uses geometry::NativePumpingGeometry for the original vector +//! operations, refinement order and inverse-cosine kernel. The Ground adapter +//! supplies the current physical sample and the selected stock mode each tick. +use super::{settings::*, state::PumpingState}; + +/// Literal loaded by Ground Update 82D37EA0, independent of Processed+2604. +pub const GROUND_PUMPING_TIMESTEP: f32 = f32::from_bits(0x3c88_8889); + +#[derive(Clone, Copy, Debug)] +pub struct PumpingSample { + /// Ground caller: ProcessedPhysIn+112, ground context+1216, skeleton+11008. + pub position: [f32; 4], + pub normal: [f32; 4], + pub com_to_deck_world: [f32; 4], + /// Ground's signed-angle difference calculation supplies the absolute angle. + pub deck_angle: f32, + /// Ground caller extracts ProcessedPhysIn+2476 bit 1. + pub intentional_pumping: u8, +} + +/// Recovered numeric operations, implemented by geometry::NativePumpingGeometry. +/// Keeping this boundary explicit allows independent measurements to validate +/// the surrounding lifecycle without asserting numeric parity from mocks. +pub trait PumpingGeometry { + type Error; + /// 82D8F280..290: three-component COM-to-deck projection onto the normal. + fn height(&mut self, normal: [f32; 4], com_to_deck: [f32; 4]) -> Result; + /// 82D8F4A0..574: displacement divided by dt, then its length. + fn speed(&mut self, previous: [f32; 4], current: [f32; 4], dt: f32) + -> Result; + /// 82D8F590..6AC: dot(cross(previous_normal, normal)/dt, + /// safe_normalize(cross(position-previous_position, normal))). + /// Preserve the recovered fused cross-product and refinement order. + fn angular_speed( + &mut self, + previous_position: [f32; 4], + previous_normal: [f32; 4], + sample: &PumpingSample, + dt: f32, + ) -> Result; + /// 82453298 called at 82D8F6EC, with previous normal.y clamped to [0,1]. + fn inclination_radians(&mut self, clamped_normal_y: f32) -> Result; +} + +/// Executes the recovered lifecycle only when the required geometry succeeds. +/// An error leaves the already executed prefix visible, and must abort the +/// caller's simulation tick; it is never permission to publish partial forces. +pub fn update( + state: &mut PumpingState, + settings: &PumpingSettings, + mode: PumpingMode, + sample: PumpingSample, + timestep: f32, + geometry: &mut G, +) -> Result<(), G::Error> { + state.pump_acceleration = 0.0; + let height = geometry.height(sample.normal, sample.com_to_deck_world)?; + if state.record_valid { + state.intentional_pumping = sample.intentional_pumping; + let change = height - state.previous_height; + let rising_change = if change >= 0.0 { change } else { 0.0 }; + let retained = (1.0 - settings.height_change_damping) * state.smoothed_height_change; + let smoothed = rising_change.mul_add(settings.height_change_damping, retained); + state.smoothed_height_change = smoothed; + // bge after fcmpu takes the unordered path too: do not replace with >=. + let mut effect = if smoothed < settings.minimum_height_change { + 0.0 + } else { + smoothed + }; + state.pumping_time = if effect > 0.0 { + state.pumping_time + timestep + } else { + 0.0 + }; + if effect.abs() > settings.maximum_height_change { + let sign = if effect >= 0.0 { + if effect > 0.0 { 1.0 } else { 0.0 } + } else { + -1.0 + }; + effect = sign * settings.maximum_height_change; + } + effect = settings.pump_vs_time.evaluate(state.pumping_time) * effect; + let pumping = calculate(state, settings, &sample, timestep, geometry)?; + state.pumping = pumping; + let factor = if pumping * effect > 0.0 { + mode.acceleration_factor + } else { + mode.absorption_factor + }; + let raw = (pumping * factor) * effect; + let lower = -(mode.maximum_absorption_per_second * timestep); + let upper = mode.maximum_acceleration_per_second * timestep; + let lower_clamped = if lower - raw >= 0.0 { lower } else { raw }; + state.pump_acceleration = if upper - lower_clamped >= 0.0 { + lower_clamped + } else { + upper + }; + } + state.previous_position = sample.position; + state.previous_normal = sample.normal; + state.record_valid = true; + state.previous_height = height; + Ok(()) +} + +/// Pumping slice of Ground Update 82D37EA8. Force assembly must follow only +/// after this succeeds; it has its own ProcessedPhysIn timestep for force scaling. +pub fn update_ground( + state: &mut PumpingState, + settings: &PumpingSettings, + mode: PumpingMode, + sample: PumpingSample, + geometry: &mut G, +) -> Result<(), G::Error> { + update( + state, + settings, + mode, + sample, + GROUND_PUMPING_TIMESTEP, + geometry, + ) +} + +/// Scalar portion of Calculate, including output write/curve evaluation order. +/// Does not update the Update-owned cached pose, pump time or pump acceleration. +pub fn calculate( + state: &mut PumpingState, + settings: &PumpingSettings, + sample: &PumpingSample, + timestep: f32, + geometry: &mut G, +) -> Result { + let speed = geometry.speed(state.previous_position, sample.position, timestep)?; + let speed_effect = settings.pump_vs_speed.evaluate(speed); + let angular_speed = geometry.angular_speed( + state.previous_position, + state.previous_normal, + sample, + timestep, + )?; + let measured = angular_speed * settings.angular_speed_damping; + state.angular_speed = + (1.0 - settings.angular_speed_damping).mul_add(state.angular_speed, measured); + state.absorption = -(speed * state.angular_speed); + let inclination = geometry.inclination_radians(unit(state.previous_normal[1]))?; + // Stock 822F8B7C: 2/pi rounded to binary32. This is not host acos(). + let radians_to_quarter_turns = f32::from_bits(0x3f22_f983); + let ground_angle = unit(radians_to_quarter_turns * inclination); + state.ground_normal_absorption = settings.compression_vs_ground_angle.evaluate(ground_angle) + * settings.ground_compression_scale; + state.minimum_crouch = settings.min_crouch_vs_ground_angle.evaluate(ground_angle); + state.deck_angle_absorption = settings + .compression_vs_deck_angle + .evaluate(sample.deck_angle * radians_to_quarter_turns); + let deck_compression = settings.deck_compression_scale * state.deck_angle_absorption; + let pumping = state.angular_speed * speed_effect; + if deck_compression * deck_compression + > state.ground_normal_absorption * state.ground_normal_absorption + { + state.ground_normal_absorption = deck_compression; + } + Ok(pumping) +} + +fn unit(value: f32) -> f32 { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/riding/pumping/geometry.rs b/crates/skate-core/src/riding/pumping/geometry.rs new file mode 100644 index 00000000..4349a990 --- /dev/null +++ b/crates/skate-core/src/riding/pumping/geometry.rs @@ -0,0 +1,69 @@ +//! TU3 Pumping geometry82D8F280 and82D8F4A0..6AC. The reciprocal and +//! reciprocal-square-root estimate primitives retain their documented host +//! approximation boundary; source refinements, fused crosses and gates remain. +use super::controller::{PumpingGeometry, PumpingSample}; +use crate::physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::{dot3, reciprocal_estimate}, +}; +pub struct NativePumpingGeometry; +impl PumpingGeometry for NativePumpingGeometry { + type Error = core::convert::Infallible; + fn height(&mut self, normal: [f32; 4], com: [f32; 4]) -> Result { + Ok(dot3(normal, com)) + } + fn speed( + &mut self, + previous: [f32; 4], + current: [f32; 4], + dt: f32, + ) -> Result { + let inverse = reciprocal(dt); + let velocity = std::array::from_fn(|i| (current[i] - previous[i]) * inverse); + let squared = dot3(velocity, velocity); + let length = squared * inverse_length_squared(squared, 2); + Ok(if squared == 0.0 { 0.0 } else { length }) + } + fn angular_speed( + &mut self, + previous_position: [f32; 4], + previous_normal: [f32; 4], + sample: &PumpingSample, + dt: f32, + ) -> Result { + let displacement = std::array::from_fn(|i| sample.position[i] - previous_position[i]); + let axis = cross(displacement, sample.normal); + let squared = dot3(axis, axis); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let axis = if length > f32::from_bits(0x3586_37bd) { + axis.map(|v| v * inverse) + } else { + [0.0; 4] + }; + let inverse_dt = reciprocal(dt); + let angular = cross(previous_normal, sample.normal).map(|v| v * inverse_dt); + Ok(dot3(angular, axis)) + } + fn inclination_radians(&mut self, y: f32) -> Result { + Ok(crate::trigonometry::acos(y)) + } +} +fn reciprocal(value: f32) -> f32 { + let mut inverse = reciprocal_estimate(value); + for _ in 0..2 { + let error = (-inverse).mul_add(value, 1.0); + inverse = inverse.mul_add(error, inverse); + } + inverse +} +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + let cyclic = [1, 2, 0, 3]; + let product: [f32; 4] = + std::array::from_fn(|i| (-a[cyclic[i]]).mul_add(b[i], a[i] * b[cyclic[i]])); + cyclic.map(|i| product[i]) +} diff --git a/crates/skate-core/src/riding/pumping/settings.rs b/crates/skate-core/src/riding/pumping/settings.rs new file mode 100644 index 00000000..214b5200 --- /dev/null +++ b/crates/skate-core/src/riding/pumping/settings.rs @@ -0,0 +1,37 @@ +//! Stock layout bindings consumed by TU3 Pumping Update/Calculate. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct PumpingSettings { + /// physics_pumping +16/+48 (the graph's four header floats are skipped). + pub pump_vs_speed: PointGraph<8>, + /// +96/+128, also skipping the PointNegGraphData8 header. + pub pump_vs_time: PointGraph<8>, + /// +160/+192, +224/+256, +288/+320. + pub min_crouch_vs_ground_angle: PointGraph<8>, + pub compression_vs_ground_angle: PointGraph<8>, + pub compression_vs_deck_angle: PointGraph<8>, + /// +364 PumpEffectDamping; +372 MinChangeInCOMBeforePumping. + pub height_change_damping: f32, + pub minimum_height_change: f32, + /// +376 MaxDeltaHeightAllowedPerFrame. + pub maximum_height_change: f32, + /// +380 CompressionGroundScalar; +384 CompressionDeckScalar. + pub ground_compression_scale: f32, + pub deck_compression_scale: f32, + /// +392 AngularSpeedDamping. + pub angular_speed_damping: f32, +} + +/// Ground 82D37E74 passes ProcessedPhysIn+2548, then Update loads wrapper+4. +/// ProcessInput 82DB4420..446C selects that wrapper from the five player modes. +#[derive(Clone, Copy, Debug)] +pub struct PumpingMode { + /// +12 Hash_9D1AEE3D7D8A4A7. Name describes its use, not a recovered name. + pub maximum_absorption_per_second: f32, + /// +16 Hash_D77AFD320B6241C5. Multiplied by the pumping timestep. + pub maximum_acceleration_per_second: f32, + /// +20 PumpEffectFactorAbsorption; +24 PumpEffectFactor. + pub absorption_factor: f32, + pub acceleration_factor: f32, +} diff --git a/crates/skate-core/src/riding/pumping/state.rs b/crates/skate-core/src/riding/pumping/state.rs new file mode 100644 index 00000000..2186a1bd --- /dev/null +++ b/crates/skate-core/src/riding/pumping/state.rs @@ -0,0 +1,83 @@ +//! Owned pumping fields. Layout offsets are evidence, not a runtime memory map. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PumpingState { + /// +0 and +16, cached after every successful Update, including the first. + pub previous_position: [f32; 4], + pub previous_normal: [f32; 4], + /// +32/+36/+40. + pub smoothed_height_change: f32, + pub pumping_time: f32, + pub previous_height: f32, + /// +44/+48/+52. + pub pumping: f32, + pub pump_acceleration: f32, + pub angular_speed: f32, + /// +56/+60/+64/+68, also copied to physics output. + pub absorption: f32, + pub ground_normal_absorption: f32, + pub minimum_crouch: f32, + pub deck_angle_absorption: f32, + /// +72 is reset but not read or written by Update/Calculate. + pub reset_only_scalar: f32, + /// +76 and +81 are the only bytes touched by these three functions. + pub record_valid: bool, + pub intentional_pumping: u8, +} + +impl PumpingState { + /// TU3 82D8F1A0. Does not initialize bytes +77..80 or +82 onward; those + /// are outside this owned state and must be preserved by any binary adapter. + /// Ground Enter 82D378DC and Exit 82D37B58 both call this reset. + pub fn reset(&mut self) { + *self = Self::reset_state(); + } + + /// The output of the recovered reset, not a claim about the constructor. + pub const fn reset_state() -> Self { + Self { + previous_position: [0.0; 4], + previous_normal: [0.0; 4], + smoothed_height_change: 0.0, + pumping_time: 0.0, + previous_height: 0.0, + pumping: 0.0, + pump_acceleration: 0.0, + angular_speed: 0.0, + absorption: 0.0, + ground_normal_absorption: 0.0, + minimum_crouch: 0.0, + deck_angle_absorption: 0.0, + reset_only_scalar: 0.0, + record_valid: false, + intentional_pumping: 0, + } + } + + /// Inline FillPhysOut at 82DB6FA8..7000. Read after Update; force assembly + /// consumes pump_acceleration earlier, within Ground Update 82D38800. + pub fn physics_output(&self) -> PumpingOutput { + PumpingOutput { + compression: 0.0, + absorption: self.absorption, + ground_normal_absorption: self.ground_normal_absorption, + minimum_crouch: self.minimum_crouch, + deck_angle_absorption: self.deck_angle_absorption, + pump_acceleration: self.pump_acceleration, + intentional_pumping: self.intentional_pumping, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PumpingOutput { + /// Skeleton output +512/+516/+520/+528/+532. + pub compression: f32, + pub absorption: f32, + pub ground_normal_absorption: f32, + pub minimum_crouch: f32, + pub deck_angle_absorption: f32, + /// Ground output +268/+324. + pub pump_acceleration: f32, + pub intentional_pumping: u8, +} diff --git a/crates/skate-core/src/riding/push.rs b/crates/skate-core/src/riding/push.rs new file mode 100644 index 00000000..871b6256 --- /dev/null +++ b/crates/skate-core/src/riding/push.rs @@ -0,0 +1,112 @@ +//! TU3 Toolkit_CalcPushAcceleration (`0x82D948B8`). Despite the native name, +//! the returned vector includes total body mass and is a force-queue payload. +//! It is queued with tag 3; deck inverse mass is applied by `0x82C03718`. +use crate::math::Vector3; +use crate::physics::force_queue::{BoardForceQueue, QueuedPointForce, total_body_mass}; + +#[derive(Clone, Copy, Debug)] +pub struct PushInput { + pub flags_2468: u32, + pub flags_2472: u32, + pub target_speed: f32, + pub current_speed: f32, + pub absolute_body_speed: f32, + /// Input +2660, populated at 0x82C0140C by 0x82C06F58's sum of body + /// masses. This is not a tunable push strength or direct velocity scale. + pub scale: f32, + pub delta_seconds: f32, + pub direction: Vector3, +} + +#[derive(Clone, Copy, Debug)] +pub struct PushLimits { + pub maximum_pushable_speed: f32, + pub low_speed_change: f32, + pub high_speed_change: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PushAcceleration { + pub vector: Vector3, + pub local_point: Vector3, + /// Native output byte; set only when push is requested and flag 0x200 is set. + pub suppressed: bool, +} + +pub fn calculate_acceleration(input: PushInput, limits: PushLimits) -> PushAcceleration { + if input.flags_2468 & 0x0200_0000 == 0 { + return PushAcceleration::default(); + } + if input.flags_2472 & 0x200 != 0 { + return PushAcceleration { + suppressed: true, + ..Default::default() + }; + } + let gap = input.target_speed - input.current_speed; + //82D94930 bgt: unordered and both signed zeros take the zero branch. + let mut speed_change = if gap > 0.0 { gap } else { 0.0 }; + let speed_fraction = input.current_speed / limits.maximum_pushable_speed; + //82D94960/68 are ordered fsel tests, not host clamp. Unordered ratios + //select the original ratio first, then1 on the upper test. + let nonnegative = if -speed_fraction >= 0.0 { + 0.0 + } else { + speed_fraction + }; + let fraction = if 1.0 - nonnegative >= 0.0 { + nonnegative + } else { + 1.0 + }; + // Skate 3 blends two limits here. Skate 2 uses a single limit. + let limit = + (1.0 - fraction).mul_add(limits.low_speed_change, fraction * limits.high_speed_change); + let below_lower = speed_change < -limit; + if speed_change > limit { + speed_change = limit; + } + if below_lower { + speed_change = -limit; + } + if input.absolute_body_speed + speed_change > limits.maximum_pushable_speed { + let remaining = limits.maximum_pushable_speed - input.absolute_body_speed; + speed_change = if remaining >= 0.0 { remaining } else { 0.0 }; + } + let force_magnitude = (input.scale * speed_change) / input.delta_seconds; + PushAcceleration { + vector: Vector3::new( + input.direction.x * force_magnitude, + input.direction.y * force_magnitude, + input.direction.z * force_magnitude, + ), + ..Default::default() + } +} + +/// Standalone convenience for callers that explicitly need a fresh mass sum. +/// Input +2660 is populated by 0x82C0140C, and 0x82D391B0 appends the result. +/// The input scale is overwritten with the live body mass sum. No direct +/// velocity adjustment is made; the force consumer and solver own its effect. +/// Ground82D38800 instead consumes the already-produced Processed+2660 and +/// defers submission until after the manual branch; use grounded::propulsion +/// for that caller. This convenience does not reproduce Ground's scheduling. +pub fn enqueue_push( + mut input: PushInput, + limits: PushLimits, + inverse_masses: &[f32], + queue: &mut BoardForceQueue, +) -> PushAcceleration { + input.scale = total_body_mass(inverse_masses); + let output = calculate_acceleration(input, limits); + queue.append(QueuedPointForce { + tag: 3, + force_world: output.vector, + point_body: output.local_point, + }); + output +} + +#[cfg(test)] +#[path = "tests/push.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/push_animation.rs b/crates/skate-core/src/riding/push_animation.rs new file mode 100644 index 00000000..c25098bc --- /dev/null +++ b/crates/skate-core/src/riding/push_animation.rs @@ -0,0 +1,100 @@ +//! TU3 push animation calculations, upstream of the contact-driven push force. +//! `0x82BAD658` maps held time to strength; `0x82BACF50` updates the three +//! Andale parameters. These do not select an animation or synthesize root motion. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct PushAnimationCurves { + /// anim_motion/pushing, collection +640 (x +656, y +688). + pub button_time_max: PointGraph<8>, + /// Collection +560 (x +576, y +608). + pub button_time_to_dv: PointGraph<8>, + /// Collection +720 (x +736, y +768). + pub blend_speed_over_frames: PointGraph<8>, + /// Collection +800 (x +816, y +848). + pub blend_acc_over_frames: PointGraph<8>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushBlendParameters { + pub hstr_vel_b: f32, + pub lstr_vel_b: f32, + /// A negative current value is the native uninitialized sentinel. + pub vel_e: f32, +} + +impl PushAnimationCurves { + pub fn strength(&self, held_seconds: f32, speed: f32) -> f32 { + let duration = self.button_time_max.evaluate(speed); + self.button_time_to_dv + .evaluate(unit_interval(held_seconds / duration)) + } + + /// Returns the values written to shared properties +8/+12/+16 and then + /// published as HSTR_VEL_B, LSTR_VEL_B, VEL_E, in that order. + pub fn update_blend( + &self, + current: PushBlendParameters, + target: PushBlendParameters, + forward_speed: f32, + delta_seconds: f32, + ) -> PushBlendParameters { + let current = if current.vel_e < 0.0 { target } else { current }; + // This factor is a native constant at 0x820849C8, not the graph dt. + let frame_seconds = f32::from_bits(0x3c88_8889); + let velocity_rate = + 1.0 / (self.blend_speed_over_frames.evaluate(current.vel_e) * frame_seconds); + let speed = if forward_speed >= 0.0 { + forward_speed + } else { + 0.0 + }; + let strength_rate = 1.0 / (self.blend_acc_over_frames.evaluate(speed) * frame_seconds); + PushBlendParameters { + hstr_vel_b: approach( + current.hstr_vel_b, + target.hstr_vel_b, + velocity_rate, + delta_seconds, + ), + lstr_vel_b: approach( + current.lstr_vel_b, + target.lstr_vel_b, + velocity_rate, + delta_seconds, + ), + vel_e: approach(current.vel_e, target.vel_e, strength_rate, delta_seconds), + } + } +} + +fn approach(current: f32, target: f32, rate: f32, dt: f32) -> f32 { + let difference = target - current; + let result = if difference.abs() > 0.001 { + if difference > 0.0 { + let candidate = dt.mul_add(rate, current); + if candidate - target >= 0.0 { + target + } else { + candidate + } + } else { + // Native fnmsubs, followed by fsel; preserve the fused operation. + let candidate = -dt.mul_add(rate, -current); + if candidate - target >= 0.0 { + candidate + } else { + target + } + } + } else { + target + }; + unit_interval(result) +} + +fn unit_interval(value: f32) -> f32 { + // Native fsel sequence also canonicalizes negative zero to positive zero. + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/riding/push_behaviors.rs b/crates/skate-core/src/riding/push_behaviors.rs new file mode 100644 index 00000000..1454bdc3 --- /dev/null +++ b/crates/skate-core/src/riding/push_behaviors.rs @@ -0,0 +1,365 @@ +//! Push behavior state and coefficient calculations from Skate 3 TU3. +//! Animation-tree selection and graph transitions belong to the authored graph. +use super::push_animation::{PushAnimationCurves, PushBlendParameters}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushClipMetrics { + pub length: f32, + pub begin_velocity: f32, + pub end_velocity: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushAttributes { + /// Native order: low-speed/high-strength, high-speed/high-strength, + /// low-speed/low-strength, high-speed/low-strength. + pub clips: [PushClipMetrics; 4], + pub minimum_begin_velocity: f32, + pub maximum_begin_velocity: f32, + pub minimum_delta_velocity: f32, + pub maximum_delta_velocity: f32, +} + +impl PushAttributes { + /// 82B954D8, after 82B95620 obtains each tree's length and its last + /// HStr_Vel_B/LStr_Vel_B and Vel_E attributes. No fitted clip coefficients. + pub fn from_clips(clips: [PushClipMetrics; 4]) -> Self { + let begin = clips.map(|clip| clip.begin_velocity); + let delta = clips.map(|clip| clip.end_velocity - clip.begin_velocity); + Self { + clips, + minimum_begin_velocity: native_min( + native_min(begin[0], begin[1]), + native_min(begin[2], begin[3]), + ), + maximum_begin_velocity: native_max( + native_max(begin[0], begin[1]), + native_max(begin[2], begin[3]), + ), + minimum_delta_velocity: native_min( + native_min(delta[0], delta[1]), + native_min(delta[2], delta[3]), + ), + maximum_delta_velocity: native_max( + native_max(delta[0], delta[1]), + native_max(delta[2], delta[3]), + ), + } + } + + /// ComputeTargetCoefsFromSpeedAndStrength, 82BAD260. The blend accounts + /// for differing clip lengths; a linear velocity interpolation is wrong. + pub fn target(&self, forward_speed: f32, strength: f32) -> PushBlendParameters { + let speed = bounded( + nonnegative(forward_speed), + self.minimum_begin_velocity, + self.maximum_begin_velocity, + ); + let strength = bounded( + strength, + self.minimum_delta_velocity, + self.maximum_delta_velocity, + ); + let [slow_strong, fast_strong, slow_gentle, fast_gentle] = self.clips; + let strong_speed = bounded( + speed, + slow_strong.begin_velocity, + fast_strong.begin_velocity, + ); + let gentle_speed = bounded( + speed, + slow_gentle.begin_velocity, + fast_gentle.begin_velocity, + ); + let strong = velocity_blend( + strong_speed, + slow_strong.length, + slow_strong.begin_velocity, + fast_strong.length, + fast_strong.begin_velocity, + ); + let gentle = velocity_blend( + gentle_speed, + slow_gentle.length, + slow_gentle.begin_velocity, + fast_gentle.length, + fast_gentle.begin_velocity, + ); + let strong_length = (1.0 - strong).mul_add(slow_strong.length, fast_strong.length * strong); + let gentle_length = (1.0 - gentle).mul_add(slow_gentle.length, fast_gentle.length * gentle); + let strong_end = blended_end(strong, slow_strong, fast_strong); + let gentle_end = blended_end(gentle, slow_gentle, fast_gentle); + let target_end = bounded(strength + speed, gentle_end, strong_end); + let strength_blend = velocity_blend( + target_end, + gentle_length, + gentle_end, + strong_length, + strong_end, + ); + PushBlendParameters { + hstr_vel_b: unit(strong), + lstr_vel_b: unit(gentle), + vel_e: unit(strength_blend), + } + } + + /// ComputeRepushDeadline::End, 82BADA90. Despite its graph name, this + /// writes PushState.out_factor, not a time deadline or a graph transition. + pub fn out_factor( + &self, + forward_speed: f32, + strength: f32, + speed_weight: f32, + maximum_out: f32, + ) -> f32 { + let first_speed = self.minimum_begin_velocity + self.minimum_delta_velocity; + let speed = bounded( + nonnegative(forward_speed), + first_speed, + self.maximum_begin_velocity, + ); + let strength = bounded( + strength, + self.minimum_delta_velocity, + self.maximum_delta_velocity, + ); + let strength_factor = unit( + (strength - self.minimum_delta_velocity) + / (self.maximum_delta_velocity - self.minimum_delta_velocity), + ); + let speed_factor = + unit((speed - first_speed) / (self.maximum_begin_velocity - first_speed)); + let combined = strength_factor.mul_add(1.0 - speed_weight, speed_factor * speed_weight); + let lower = if -combined >= 0.0 { 0.0 } else { combined }; + native_min(maximum_out, lower) + } +} + +fn velocity_blend( + speed: f32, + low_length: f32, + low_velocity: f32, + high_length: f32, + high_velocity: f32, +) -> f32 { + let low_distance = low_length * low_velocity; + let speed_distance = speed * low_length; + let sum = high_length.mul_add(high_velocity, speed_distance); + let numerator = speed_distance - low_distance; + let denominator = -speed.mul_add(high_length, -(sum - low_distance)); + unit(numerator / denominator) +} +fn blended_end(coefficient: f32, low: PushClipMetrics, high: PushClipMetrics) -> f32 { + let coefficient = unit(coefficient); + let high_weight = coefficient * high.length; + let low_weight = (1.0 - coefficient) * low.length; + high.end_velocity + .mul_add(high_weight, low.end_velocity * low_weight) + / (high_weight + low_weight) +} + +fn native_min(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { b } else { a } +} +fn native_max(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { a } else { b } +} +fn bounded(value: f32, minimum: f32, maximum: f32) -> f32 { + native_min(maximum, native_max(minimum, value)) +} +fn nonnegative(value: f32) -> f32 { + if value >= 0.0 { value } else { 0.0 } +} +fn unit(value: f32) -> f32 { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushState { + pub out_factor: f32, + pub current_push_dv: f32, + pub current: PushBlendParameters, + pub target: PushBlendParameters, + pub continue_push: bool, +} +impl PushState { + /// InitPush::Begin, 82BAD5B8, preserves out_factor. + pub fn initialize_push(&mut self) { + self.continue_push = false; + self.current_push_dv = 0.0; + self.current = PushBlendParameters { + hstr_vel_b: -1.0, + lstr_vel_b: -1.0, + vel_e: -1.0, + }; + self.target = self.current; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FirstPushStrength { + pub simulated_held_seconds: f32, + pub push_from_teleport: bool, + pub first_push: bool, + pub first_update: bool, +} +impl FirstPushStrength { + /// Begin82BAD6E8: capture teleport eligibility once per activation. + pub fn begin(time_since_teleport: f32, teleport_window: f32) -> Self { + Self { + simulated_held_seconds: 0.0, + push_from_teleport: time_since_teleport < teleport_window, + first_push: true, + first_update: true, + } + } + /// Update82BAD7A8: the teleport path evaluates the old simulated time + /// before adding dt. A released first push cannot be resumed here. + pub fn update( + &mut self, + state: &mut PushState, + curves: &PushAnimationCurves, + pushing: Option, + forward_speed: f32, + dt: f32, + ) { + if pushing.is_none() { + self.first_push = false; + } + if self.first_push || self.push_from_teleport { + let held = if self.push_from_teleport { + let held = self.simulated_held_seconds; + self.simulated_held_seconds = dt + self.simulated_held_seconds; + held + } else { + pushing.unwrap_or(0.0) + }; + let strength = curves.strength(held, nonnegative(forward_speed)); + if strength > state.current_push_dv { + state.current_push_dv = strength; + } + } + self.first_update = false; + } +} + +/// A snapshot of the authored MG intent list, not raw controller buttons. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PushIntents { + pub pushing: Option, + pub new_push: bool, + /// Presence of the configured newPushName (RightPush or LeftPush in stock). + pub configured_foot: bool, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PushCyclePhase { + HoldingFirst, + WaitingNext, + HoldingNext, + NextReleased, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushCycle { + pub phase: PushCyclePhase, + pub next_push_dv: f32, +} +impl PushCycle { + /// Begin82BADCD0. + pub fn begin( + state: &mut PushState, + curves: &PushAnimationCurves, + intents: PushIntents, + ) -> Self { + let holding = intents.pushing.is_some() && intents.configured_foot; + state.continue_push = holding; + Self { + phase: if holding { + PushCyclePhase::HoldingFirst + } else { + PushCyclePhase::WaitingNext + }, + next_push_dv: curves.button_time_to_dv.evaluate(0.0), + } + } + /// Update82BADE38. Separate phase checks intentionally allow the release + /// and next-press path to run in one graph update, as in the recovered code. + pub fn update( + &mut self, + state: &mut PushState, + curves: &PushAnimationCurves, + intents: PushIntents, + forward_speed: f32, + maximum_holding_acceleration: f32, + ) { + let speed = nonnegative(forward_speed); + if self.phase == PushCyclePhase::HoldingFirst { + if let Some(held) = intents.pushing { + self.next_push_dv = curves.strength(held, speed); + if self.next_push_dv > maximum_holding_acceleration { + self.next_push_dv = maximum_holding_acceleration; + state.current_push_dv = maximum_holding_acceleration; + } + if state.current_push_dv < self.next_push_dv { + state.current_push_dv = self.next_push_dv; + } + } else { + self.phase = PushCyclePhase::WaitingNext; + self.next_push_dv = curves.button_time_to_dv.evaluate(0.0); + state.continue_push = false; + } + } + if self.phase == PushCyclePhase::WaitingNext && intents.new_push && intents.configured_foot + { + self.phase = PushCyclePhase::HoldingNext; + } + if self.phase == PushCyclePhase::HoldingNext && !intents.configured_foot { + self.phase = PushCyclePhase::NextReleased; + state.current_push_dv = self.next_push_dv; + } + if self.phase == PushCyclePhase::HoldingNext { + self.next_push_dv = curves.strength(intents.pushing.unwrap_or(0.0), speed); + if state.current_push_dv < self.next_push_dv { + state.current_push_dv = self.next_push_dv; + } + state.continue_push = true; + } + } +} +/// Physics outputs consumed by GetFootPos82595B28. All positions are world +/// space; deck axes come from the live reckoning transform, not a render pose. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushFootFrame { + pub left_foot: [f32; 4], + pub right_foot: [f32; 4], + pub deck_position: [f32; 4], + pub deck_y: [f32; 4], + pub deck_z: [f32; 4], + pub skateboard_flipped: bool, +} +impl PushFootFrame { + /// TU382595B28. Reuses the isolated, approximation-derived Xenon dot + /// lowering; independent hardware arithmetic validation remains pending. + pub fn out_distance(&self, right_foot: bool) -> [f32; 2] { + let foot = if right_foot { + self.right_foot + } else { + self.left_foot + }; + let relative = std::array::from_fn(|i| foot[i] - self.deck_position[i]); + let z = if self.skateboard_flipped { + self.deck_z.map(|v| -v) + } else { + self.deck_z + }; + [ + crate::physics::native_arithmetic::dot3(relative, z), + crate::physics::native_arithmetic::dot3(relative, self.deck_y), + ] + } +} + +#[cfg(test)] +#[path = "tests/push_behaviors.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/reckoning_frames.rs b/crates/skate-core/src/riding/reckoning_frames.rs new file mode 100644 index 00000000..75968fd2 --- /dev/null +++ b/crates/skate-core/src/riding/reckoning_frames.rs @@ -0,0 +1,196 @@ +//! Reckoning transform, dynamic lean and lateral tilt, TU3 +//!82D8D688/82D8D930/82D8C4F8. State scheduling and frame producers are separate. +//! Numerical primitives retain their documented hardware-validation boundary. +use crate::{ + math::Vector3, + physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, + }, + point_graph::PointGraph, + riding::collision_response::signed_angle, + trigonometry, +}; + +#[derive(Clone, Debug)] +pub struct ReckoningFrames { + ///752: ground frame, whose translation is retained by CalculateTransform. + pub ground: AnimationPartTransform, + ///816: system frame after application of the body-flip transform. + pub system: AnimationPartTransform, + ///880: same frame before body flip, used by CalculateTilt. + pub unflipped: AnimationPartTransform, + ///944: rigid inverse of the final system frame. + pub inverse_system: AnimationPartTransform, + ///1008: body-flip transform, produced by UpdateBodyFlip. + pub body_flip: AnimationPartTransform, + ///1200: heading, reprojected onto the current up plane on each calculation. + pub heading: [f32; 4], + ///1576, published to PhysOutSystemReckoning160 with the stance sign. + pub target_lean_angle: f32, + ///1248: first lane consumed by the body-tilt animation handler. + pub lateral_tilt: [f32; 4], +} + +impl ReckoningFrames { + ///82D33178 initializes each matrix independently to identity and seeds + ///heading to world X. Reset82D8C3A8 later preserves the matrices. + pub fn new() -> Self { + Self { + ground: IDENTITY, + system: IDENTITY, + unflipped: IDENTITY, + inverse_system: IDENTITY, + body_flip: IDENTITY, + heading: [1.0, 0.0, 0.0, 0.0], + target_lean_angle: 0.0, + lateral_tilt: [0.0; 4], + } + } + + ///82D8D688. Cross products use the unnormalized intermediate axis; + ///normalization has two refinements and no epsilon fallback in this leaf. + pub fn calculate_transform(&mut self, up: [f32; 4], ground_normal: [f32; 4]) { + let right = cross(up, self.heading); + let forward = cross(right, up); + self.heading = normalize(forward); + self.system[0] = normalize(right); + self.system[1] = up; + self.system[2] = self.heading; + self.unflipped = self.system; + self.system = compose_affine(&self.body_flip, &self.system); + self.inverse_system = inverse(&self.system); + + let ground_right = cross(ground_normal, self.heading); + let ground_forward = cross(ground_right, ground_normal); + self.ground[0] = normalize(ground_right); + self.ground[1] = ground_normal; + self.ground[2] = normalize(ground_forward); + } + + ///82D8D930. Signed angle is wrapped by fraction/floor, not scalar atan2. + pub fn calculate_dynamic_lean(&mut self, up: [f32; 4], dynamic_up: [f32; 4]) { + let axis = self.system[2]; + let project = |value| { + let amount = dot3(value, axis); + std::array::from_fn(|i| value[i] - axis[i] * amount) + }; + let from = project(up); + let to = project(dynamic_up); + let threshold = f32::from_bits(0x3727_c5ac); //8219B100 + self.target_lean_angle = if dot3(from, from) > threshold && dot3(to, to) > threshold { + wrap_fraction(signed_angle(xyz(from), xyz(to), xyz(axis))) + } else { + 0.0 + }; + } + + ///82D8C4F8. Degenerate projection preserves the preceding lateral tilt. + ///The stock source uses the unflipped up/forward axes and two authored + ///curves, including its absolute wrapped angle and stance sign change. + pub fn calculate_tilt( + &mut self, + reverse_stance: bool, + tilt_vs_angle: &PointGraph<8>, + tilt_vs_up: &PointGraph<8>, + ) { + let axis = self.unflipped[1]; + let world_up = [0.0, 1.0, 0.0, 0.0]; + let dot = dot3(axis, world_up); + let parallel = axis.map(|v| v * dot); + let projected = std::array::from_fn(|i| world_up[i] - parallel[i]); + let squared = dot3(projected, projected); + if squared <= f32::from_bits(0x3a03_126f) { + return; + } + let angle = if world_up == parallel { + 0.0 + } else { + signed_angle(xyz(self.unflipped[2]), xyz(normalize(projected)), xyz(axis)) + }; + let half_pi = f32::from_bits(0x3fc9_0fdb); + let scale = f32::from_bits(0x3f22_f983); + let mut normalized_angle = + ((wrap_fraction(angle - half_pi).abs() - half_pi) * -1.0) * scale; + if reverse_stance { + normalized_angle = -normalized_angle; + } + //Inline native asin polynomial82D8C704..7E0 is the same operation + //tree retained by trigonometry::asin, including one rsqrt refinement. + let normalized_up_angle = (half_pi - trigonometry::asin(axis[1])) * scale; + let angle_tilt = tilt_vs_angle.evaluate(normalized_angle.abs()); + let up_tilt = tilt_vs_up.evaluate(normalized_up_angle.abs()); + let signed = if normalized_angle >= -0.0 { + angle_tilt + } else { + -angle_tilt + }; + self.lateral_tilt = [up_tilt * signed, 0.0, 0.0, 0.0]; + } +} + +fn normalize(value: [f32; 4]) -> [f32; 4] { + let reciprocal = inverse_length_squared(dot3(value, value), 2); + value.map(|v| v * reciprocal) +} +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn inverse(frame: &AnimationPartTransform) -> AnimationPartTransform { + let axes: [[f32; 4]; 3] = std::array::from_fn(|i| [frame[0][i], frame[1][i], frame[2][i], 0.0]); + let p = frame[3].map(|v| 0.0 - v); + let position = std::array::from_fn(|i| { + let z = axes[2][i] * p[2]; + let y = axes[1][i].mul_add(p[1], z); + axes[0][i].mul_add(p[0], y) + }); + [axes[0], axes[1], axes[2], position] +} +fn wrap_fraction(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let fraction = fraction - if fraction > 0.5 { 1.0 } else { 0.0 }; + fraction * f32::from_bits(0x40c9_0fdb) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn body_flip_changes_system_but_retains_ground_and_preflip_frames() { + let mut state = ReckoningFrames::new(); + state.heading = [0.0, 0.0, 1.0, 0.0]; + state.body_flip = [ + [0., 0., -1., 0.], + [0., 1., 0., 0.], + [1., 0., 0., 0.], + [3., 0., 0., 0.], + ]; + state.calculate_transform([0., 1., 0., 0.], [0., 1., 0., 0.]); + assert_eq!(state.unflipped, IDENTITY); + assert_eq!(state.ground, IDENTITY); + assert_eq!(state.system, state.body_flip); + assert_eq!( + compose_affine(&state.system, &state.inverse_system), + IDENTITY + ); + state.calculate_dynamic_lean([0., 1., 0., 0.], [0., 1., 0., 0.]); + assert!(state.target_lean_angle.abs() < 0.001); + state.lateral_tilt = [0.3, 0., 0., 0.]; + let curve = PointGraph { + x: [0.; 8], + y: [0.; 8], + }; + state.calculate_tilt(false, &curve, &curve); + assert_eq!(state.lateral_tilt, [0.3, 0., 0., 0.]); + } +} diff --git a/crates/skate-core/src/riding/slide_friction.rs b/crates/skate-core/src/riding/slide_friction.rs new file mode 100644 index 00000000..257df578 --- /dev/null +++ b/crates/skate-core/src/riding/slide_friction.rs @@ -0,0 +1,46 @@ +//! Complete TU3 Toolkit_CalcSlideFriction 82D92970; Ground force tag 1. +use super::vector::{clamp, dot3}; +use crate::{point_graph::PointGraph, trigonometry::acos}; + +pub struct SlideFrictionSettings { + pub angle_response: PointGraph<16>, // +196/+260 + pub speed_response: PointGraph<8>, // +324/+356 + pub time_response: PointGraph<16>, // +388/+452 + pub scalar_516: f32, + pub heading_time_limit: f32, // surface +236 + pub friction: f32, // surface +240 +} +pub struct SlideFrictionInput { + pub heading_time: f32, + pub normal: [f32; 4], // Processed +464 + pub velocity: [f32; 4], // +400 + pub side_axis: [f32; 4], // +64 + pub surface_speed: f32, // +2656 + pub scalar_2764: f32, +} +pub fn calculate(s: &SlideFrictionSettings, i: &SlideFrictionInput) -> [f32; 8] { + // VMX min/max select the second argument on unordered comparisons. + let y = if -1.0 > i.normal[1] { + -1.0 + } else { + i.normal[1] + }; + let y = if 1.0 < y { 1.0 } else { y }; + let angle = clamp(acos(y) * f32::from_bits(0x3f22_f983), 0.0, 1.0); + let angle = 1.0 - s.angle_response.evaluate(angle); + let speed = 1.0 - s.speed_response.evaluate(i.surface_speed * 0.1); + let time = s + .time_response + .evaluate(clamp(i.heading_time, 0.0, s.heading_time_limit) / s.heading_time_limit); + let blend = (1.0 - s.scalar_516).mul_add(i.scalar_2764, s.scalar_516); + let response = if angle - speed >= 0.0 { angle } else { speed }; + let scalar = -(((time * blend) * response) * s.friction); + let normal_speed = dot3(i.velocity, i.normal); + let tangent = core::array::from_fn(|n| i.velocity[n] - i.normal[n] * normal_speed); + let side_speed = dot3(tangent, i.side_axis); + let mut output = [0.0; 8]; + for n in 0..4 { + output[n] = (i.side_axis[n] * side_speed) * scalar; + } + output +} diff --git a/crates/skate-core/src/riding/speed_and_slope.rs b/crates/skate-core/src/riding/speed_and_slope.rs new file mode 100644 index 00000000..8f64e079 --- /dev/null +++ b/crates/skate-core/src/riding/speed_and_slope.rs @@ -0,0 +1,62 @@ +//! Speed/slope coefficient published to the stock ActionGraph. +//! +//! TU3 PhysicalPlayerHiLOD::CalculateHeadingAdjustFactor82DB5E10 is called +//! during PostInput82DB56C0. ProcessOutput82DB7580 publishes its result to +//! SkateboardMotion+192. The source uses the *turn torque* curves, despite +//! the function name; the nearby HeadingAdjustVsSpeed/Slope curves differ. +use crate::{physics::native_arithmetic, point_graph::PointGraph, trigonometry}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpeedAndSlopeSettings { + /// physics_heading layout336/368. + pub turn_torque_vs_speed: PointGraph<8>, + /// physics_heading layout400/432. + pub turn_torque_vs_slope: PointGraph<8>, + /// physics_heading layout600. + pub heading_adjust_max_speed: f32, +} + +impl SpeedAndSlopeSettings { + /// Complete82DB5E10, including the slope cutoff and speed saturation. + /// + /// `ground_normal_y` is ground output+96.Y, copied to ProcessedPhysIn+464; + /// `forward_speed` is skateboard output+168, copied to ProcessedPhysIn+2612. + /// The reciprocal/acos primitives retain their documented numerical + /// approximation boundary until independent hardware validation. + pub fn calculate(&self, ground_normal_y: f32, forward_speed: f32) -> f32 { + let normal_y = native_arithmetic::vector_min( + 1.0, + native_arithmetic::vector_max(-1.0, ground_normal_y), + ); + let half_pi = f32::from_bits(0x3FC9_0FDB); + let mut inverse_half_pi = native_arithmetic::reciprocal_estimate(half_pi); + for _ in 0..2 { + let error = (-inverse_half_pi).mul_add(half_pi, 1.0); + inverse_half_pi = inverse_half_pi.mul_add(error, inverse_half_pi); + } + let mut slope = inverse_half_pi * trigonometry::acos(normal_y); + if slope > f32::from_bits(0x3F99_999A) { + slope = 0.0; + } + + let absolute_speed = forward_speed.abs(); + let nonnegative_speed = if -absolute_speed >= -0.0 { + 0.0 + } else { + absolute_speed + }; + let bounded_speed = if self.heading_adjust_max_speed - nonnegative_speed >= -0.0 { + nonnegative_speed + } else { + self.heading_adjust_max_speed + }; + let speed_fraction = bounded_speed / self.heading_adjust_max_speed; + let speed_factor = self.turn_torque_vs_speed.evaluate(speed_fraction); + let slope_factor = self.turn_torque_vs_slope.evaluate(slope); + speed_factor * slope_factor + } +} + +#[cfg(test)] +#[path = "tests/speed_and_slope.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/speed_model.rs b/crates/skate-core/src/riding/speed_model.rs new file mode 100644 index 00000000..c3bbd1c2 --- /dev/null +++ b/crates/skate-core/src/riding/speed_model.rs @@ -0,0 +1,188 @@ +//! TU3 Skateboard::UpdateSpeedModel 82C04F68, including its force-enable gate. +//! The caller supplies GetEffectiveTransform and GetMass outputs, not a guessed +//! direction or mass. Ground submits this result as force tag 6. +use super::vector::{clamp, dot3, normalize}; +use crate::point_graph::PointGraph; + +pub struct SpeedModelSettings { + /// physics_speedmodel layout +0,+8,+16,+20,+24. + pub negative_gain: f32, + pub maximum_gravity_acceleration: f32, + pub gravity: f32, + pub positive_gain: f32, + pub coffin_acceleration: f32, + /// Surface layout +260 and curve +96/+128. + pub speed_error_bound: f32, + pub surface_friction: PointGraph<8>, + /// physics_manual layout +180,+212,+220. + pub manual_acceleration: f32, + pub negative_manual_angle_limit: f32, + pub manual_angle_limit: f32, + /// Global +184 layout +356 and curves +144/+176,+224/+256. + pub no_input_delay: f32, + pub no_input_friction: PointGraph<8>, + pub manual_friction: PointGraph<8>, + /// Selected-mode layout +44 and +40. + pub override_enabled: bool, + pub override_speed: f32, + /// Live constants 830BD350 and 830BD380 (four native lanes each). + pub normal_threshold: [f32; 4], + pub override_direction_threshold: [f32; 4], +} + +#[derive(Clone, Copy, Debug)] +pub struct SpeedModelState { + pub target_speed: f32, // Board +260 + pub flags_1360: u32, +} + +pub struct SpeedModelInput { + pub flags_2468: u32, + pub flags_2476: u32, + pub wheel_contact_count: i32, // +2556 + pub frames_2580: i32, + pub timestep: f32, // +2604 + pub signed_speed: f32, // +2612 + pub angle_2652: f32, + pub surface_speed: f32, // +2656 + pub turn_2672: f32, + pub balance: f32, // +2720 + pub elapsed_without_input: f32, // +2748 + pub manual_state_276: f32, // Board +276 + pub vector_160: [f32; 4], + pub vector_352: [f32; 4], + pub velocity_416: [f32; 4], + pub normal_464: [f32; 4], + /// Z column of 82C01BF8's result; stance correction has already happened. + pub effective_forward: [f32; 4], + /// 82C06F58 result, whose two-accumulator summation is implemented in mass. + pub mass: f32, +} + +/// Force vector followed by zeroed local application point, all four lanes. +pub fn update( + state: &mut SpeedModelState, + settings: &SpeedModelSettings, + input: &SpeedModelInput, +) -> [f32; 8] { + let speed = input.signed_speed.abs(); + let dt = input.timestep; + let bound = settings.speed_error_bound; + let lower = speed - bound; + let lower = if -lower >= 0.0 { 0.0 } else { lower }; + let mut target = if state.flags_1360 & 0x8000_0000 != 0 { + state.flags_1360 &= 0x7fff_ffff; + speed + } else { + clamp(state.target_speed, lower, bound + speed) + }; + let coffin = if input.flags_2476 & 0x4000_0000 != 0 { + settings.coffin_acceleration * dt + } else { + 0.0 + }; + let projection = dot3(input.velocity_416, input.effective_forward); + let projected = input.effective_forward.map(|v| v * projection); + let normal_speed = dot3(projected, input.normal_464); + let tangent = normalize( + core::array::from_fn(|i| projected[i] - input.normal_464[i] * normal_speed), + settings.normal_threshold, + ); + let gravity = clamp( + -tangent[1] * settings.gravity, + -settings.maximum_gravity_acceleration, + settings.maximum_gravity_acceleration, + ) * dt; + + if input.frames_2580 < 30 { + let active = if input.manual_state_276 == 0.0 { + 0.0 + } else { + 1.0 + }; + let mut direction = if input.balance > 0.0 { 1.0 } else { -1.0 }; + if 0.0 > dot3(input.vector_352, tangent) { + direction *= -1.0; + } + let angle = input.angle_2652.abs(); + let within = if angle > settings.manual_angle_limit { + 0.0 + } else { + 1.0 + }; + let negative_within = if angle > settings.negative_manual_angle_limit { + 0.0 + } else { + 1.0 + }; + let mut acceleration = + ((((input.turn_2672 * input.turn_2672) * settings.manual_acceleration) * within) + * direction) + * active; + if acceleration < 0.0 { + acceleration *= negative_within; + } + target = acceleration + target; + } + let friction = -settings.surface_friction.evaluate(input.surface_speed) * dt; + let surface_delta = if gravity > 0.0 { + friction + gravity + } else if friction - gravity >= 0.0 { + gravity + } else { + friction + }; + target = (surface_delta + coffin) + target; + if input.elapsed_without_input > settings.no_input_delay { + target = -settings + .no_input_friction + .evaluate(input.surface_speed) + .mul_add(dt, -target); + } + if input.balance != 0.0 { + target = -settings + .manual_friction + .evaluate(input.surface_speed) + .mul_add(dt, -target); + } + state.target_speed = if target >= 0.0 { target } else { 0.0 }; + let mut error = clamp(state.target_speed - speed, -bound, bound); + if settings.override_enabled + && input.flags_2476 & 0x0040_0000 != 0 + && input.normal_464[1] > 0.65 + { + let mut goal = settings.override_speed * f32::from_bits(0x3E8E_38E4); + let along = dot3(input.velocity_416, input.vector_160); + if settings + .override_direction_threshold + .iter() + .all(|t| *t > along) + && 1.0 > input.surface_speed + { + goal *= -1.0; + } + error = clamp(goal - speed, -0.4, 0.4); + } + let enabled = input.flags_2468 & 0x6201_0000 == 0 + && input.flags_2476 & 0x0800_0000 == 0 + && input.wheel_contact_count >= 2; + let gain = if !enabled { + 0.0 + } else if error < 0.0 { + settings.negative_gain + } else { + settings.positive_gain + }; + let scalar = ((input.mass * gain) * error) / dt; + let forward = dot3(input.effective_forward, tangent) > 0.0; + let mut output = [0.0; 8]; + for i in 0..4 { + let axis = if forward { + input.effective_forward[i] + } else { + -input.effective_forward[i] + }; + output[i] = axis * scalar; + } + output +} diff --git a/crates/skate-core/src/riding/speed_wobble.rs b/crates/skate-core/src/riding/speed_wobble.rs new file mode 100644 index 00000000..ff210cee --- /dev/null +++ b/crates/skate-core/src/riding/speed_wobble.rs @@ -0,0 +1,101 @@ +//! Complete TU3 SpeedWobble::CalculateSpeedWobble (82C0DBE8). +//! UpdateSpeedWobble 82C03638 supplies the selected-mode attributes and body +//! inputs; Ground Update 82D38800 applies the returned tilt before truck targets. +use crate::{point_graph::PointGraph, trigonometry::sin}; + +#[derive(Clone, Copy, Debug)] +pub struct SpeedWobbleSettings { + /// Layout arrays +0/+32, +64/+96, +128/+160, +192/+224. + pub frequency_time: PointGraph<8>, + pub frequency_speed: PointGraph<8>, + pub amplitude_time: PointGraph<8>, + pub amplitude_speed: PointGraph<8>, + /// Layout +256 through +284, in native order. + pub tightness_threshold: f32, + pub time_range: f32, + pub speed_range: f32, + pub frequency_scale: f32, + pub amplitude_scale: f32, + pub crouch_threshold: f32, + pub height_min: f32, + pub height_max: f32, +} + +/// Fields +16..+44, including untouched +20 and opaque/reset-only +24/+28. +/// The active flag is a byte; retain its other three packed bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpeedWobbleState(pub [u32; 8]); + +impl SpeedWobbleState { + /// The same six-float/one-byte reset used in Ground Enter 82D376D0. + pub fn reset(&mut self) { + for index in [0, 2, 3, 4, 5, 6] { + self.0[index] = 0; + } + self.0[7] &= 0x00ff_ffff; + } + + fn active(&self) -> bool { + self.0[7] >> 24 != 0 + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SpeedWobbleInput { + pub tilt: f32, + /// Signed speed, ProcessedPhysIn +2612. This routine does not take abs(). + pub speed: f32, + pub center_of_mass_height: f32, + pub truck_tightness: f32, + /// Resolved mode Hash_77AFCE78FE1206CA / Hash_5B57F2CCCCEEF430. + pub activation_threshold: f32, + pub amplitude_multiplier: f32, +} + +pub fn calculate( + state: &mut SpeedWobbleState, + settings: &SpeedWobbleSettings, + input: SpeedWobbleInput, +) -> f32 { + let height = (input.center_of_mass_height - settings.height_min) + / (settings.height_max - settings.height_min); + let crouch = unit(1.0 - height); + let threshold = crouch.mul_add( + settings.crouch_threshold, + settings.tightness_threshold * input.truck_tightness, + ) + input.activation_threshold; + if !state.active() && input.speed > threshold { + state.reset(); + state.0[7] |= 0x0100_0000; + } + if !state.active() { + return input.tilt; + } + + let time = f32::from_bits(state.0[0]); + let time_fraction = unit(time / settings.time_range); + let speed_fraction = unit((input.speed - threshold) / settings.speed_range); + let frequency = (settings.frequency_time.evaluate(time_fraction) + * settings.frequency_speed.evaluate(speed_fraction)) + * settings.frequency_scale; + let amplitude = settings.amplitude_speed.evaluate(speed_fraction) + * settings.amplitude_time.evaluate(time_fraction); + let phase = frequency.mul_add(f32::from_bits(0x3DD6_7751), f32::from_bits(state.0[4])); + let scale = (settings.amplitude_scale * amplitude) * input.amplitude_multiplier; + let displacement = sin(phase) * scale; + state.0[0] = (time + f32::from_bits(0x3C88_8889)).to_bits(); + state.0[4] = phase.to_bits(); + state.0[5] = amplitude.to_bits(); + state.0[6] = displacement.to_bits(); + let result = (displacement + input.tilt) * 0.5; + // This frame still returns the computed tilt when speed drops below threshold. + if input.speed < threshold { + state.reset(); + } + result +} + +fn unit(value: f32) -> f32 { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/riding/steering.rs b/crates/skate-core/src/riding/steering.rs new file mode 100644 index 00000000..e7966f47 --- /dev/null +++ b/crates/skate-core/src/riding/steering.rs @@ -0,0 +1,130 @@ +//! TU3 steering tilt (`0x82D92440`) and truck targets (`0x82C040F0`). +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct SteeringSettings { + pub hard_turn_increase: f32, + pub damping: f32, + pub speed_graph_max_speed: f32, + pub push_scalar_increment: f32, + pub push_scalar_decrement: f32, + pub push_scalar_min: f32, + pub manual_scalar: f32, + pub general_scalar: f32, + pub tight_trucks_scalar: f32, + pub speed_graph: PointGraph<8>, + pub input_graph: PointGraph<8>, + pub tilt_blending: f32, +} + +/// Field names describe the reads from `ProcessedPhysIn`. No yaw-rate or +/// bicycle steering interpretation is applied to the returned tilt. +#[derive(Clone, Copy, Debug, Default)] +pub struct SteeringInput { + pub turn: f32, + pub hard_turn: f32, + pub absolute_body_speed: f32, + pub flipped_controls_scalar: f32, + pub balance: f32, + pub truck_tightness: f32, + /// `ProcessedPhysIn+2468`, bit 27 (distinct from push-force bit 25). + pub pushing: bool, +} + +/// Optional pointers in the native function are represented independently. +/// Updates are per physics call; the native routine has no timestep argument. +pub fn calculate_tilt( + settings: &SteeringSettings, + input: SteeringInput, + push_scalar: Option<&mut f32>, + damped_turn: Option<&mut f32>, +) -> f32 { + let hard_scalar = input + .hard_turn + .abs() + .mul_add(settings.hard_turn_increase, 1.0); + let mut turn = input.turn; + if input.hard_turn != 0.0 { + turn = if turn >= 0.0 { 1.0 } else { -1.0 }; + } + if let Some(previous) = damped_turn { + turn = (1.0 - settings.damping).mul_add(*previous, settings.damping * turn); + *previous = turn; + } + let speed_scalar = settings + .speed_graph + .evaluate((input.absolute_body_speed / settings.speed_graph_max_speed).clamp(0.0, 1.0)); + let input_scalar = settings.input_graph.evaluate(turn.abs()); + let push = if let Some(previous) = push_scalar { + let next = if input.pushing { + *previous - settings.push_scalar_decrement + } else { + settings.push_scalar_increment + *previous + }; + *previous = next.clamp(settings.push_scalar_min, 1.0); + *previous + } else { + 1.0 + }; + let manual = if input.balance != 0.0 { + settings.manual_scalar + } else { + 1.0 + }; + let tightness = settings + .tight_trucks_scalar + .mul_add(input.truck_tightness, 1.0) + - input.truck_tightness; + input.flipped_controls_scalar + * settings.general_scalar + * manual + * tightness + * push + * input_scalar + * speed_scalar + * hard_scalar + * turn +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TruckSteeringState { + pub deck_tilt: f32, + /// Native `SkateboardBody+7680/+7684` order, not stance-relative labels. + pub targets: [f32; 2], + pub activation_time: [f32; 2], +} + +impl TruckSteeringState { + /// Flags are retained in their native words to make stance-dependent + /// contact ownership explicit (`+2468` bit 20, `+2472` bits 26 and 27). + pub fn update(&mut self, target: f32, blend: f32, flags_2468: u32, flags_2472: u32) { + self.deck_tilt = (1.0 - blend).mul_add(self.deck_tilt, blend * target); + let mut active = [flags_2472 & (1 << 27) != 0, flags_2472 & (1 << 26) != 0]; + if flags_2468 & (1 << 20) != 0 { + active.swap(0, 1); + } + for (index, enabled) in active.into_iter().enumerate() { + let timer = &mut self.activation_time[index]; + let angle = &mut self.targets[index]; + if enabled { + if *timer <= 0.16500001 { + *timer += f32::from_bits(0x3C88_8889); + let fraction = (*timer * 6.060606).clamp(0.0, 1.0); + *angle = (1.0 - fraction).mul_add(*angle, fraction * self.deck_tilt); + } else { + *angle = self.deck_tilt; + } + } else { + // The first inactive frame holds the angle; subsequent frames decay. + if *timer == 0.0 { + *angle *= 0.983; + } + *timer = 0.0; + } + } + } +} + +#[cfg(test)] +#[path = "tests/steering.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/straighten.rs b/crates/skate-core/src/riding/straighten.rs new file mode 100644 index 00000000..6a4b9a55 --- /dev/null +++ b/crates/skate-core/src/riding/straighten.rs @@ -0,0 +1,37 @@ +//! Complete TU3 Toolkit_CalcStraightenOutTorque 82D92B60. +use super::vector::{clamp, cross, dot3}; +use crate::point_graph::PointGraph; +pub struct StraightenSettings { + pub time_response: PointGraph<16>, // settings +768/+832 + pub opposite_turn_limit: f32, // +896 + pub time_scalar: f32, // +900 + pub heading_time_limit: f32, // surface +236 + pub strength: f32, // surface +232 +} +pub struct StraightenInput { + pub heading_time: f32, + pub scalar_2764: f32, + pub turn: f32, // +2676 + pub forward: [f32; 4], // +352 + pub velocity: [f32; 4], // +400 + pub normal: [f32; 4], // +464 +} +pub fn calculate(s: &StraightenSettings, i: &StraightenInput) -> [f32; 4] { + let limit = (1.0 - s.time_scalar).mul_add(i.scalar_2764, s.time_scalar) * s.heading_time_limit; + let strength = s + .time_response + .evaluate(clamp(i.heading_time, 0.0, limit) / limit) + * s.strength; + let forward = if dot3(i.forward, i.velocity) < 0.0 { + i.forward.map(|v| -v) + } else { + i.forward + }; + let error = dot3(cross(forward, i.velocity), i.normal); + let mut output = i.normal.map(|v| v * (error * strength)); + if i.turn * error < 0.0 { + let scale = 1.0 - clamp(i.turn.abs(), 0.0, s.opposite_turn_limit) / s.opposite_turn_limit; + output = output.map(|v| v * scale); + } + output +} diff --git a/crates/skate-core/src/riding/tests/ground_orientation.rs b/crates/skate-core/src/riding/tests/ground_orientation.rs new file mode 100644 index 00000000..0bd4443c --- /dev/null +++ b/crates/skate-core/src/riding/tests/ground_orientation.rs @@ -0,0 +1,39 @@ +use super::*; +fn constant(value: f32) -> PointGraph<8> { + PointGraph { x: [0.0,1.0,2.0,3.0,4.0,5.0,6.0,7.0], y: [value;8] } +} +fn settings() -> GroundOrientationSettings { + GroundOrientationSettings { + ground_normal_smoothing: [1.0,0.0,0.0,1.0], + up_vector_smoothing_slow: [1.0,0.0,0.0,1.0], + up_vector_smoothing_fast: [1.0,0.0,0.0,1.0], + dynamic_up_vs_ground_y: constant(1.0), ground_vector_blend: constant(0.0), + deck_angle_usage_vs_speed: constant(0.0), up_vector_smoothing_vs_speed: constant(0.0), + up_vector_max_delta_vs_speed: constant(1.0), ground_blend_max_delta:0.1, + up_vector_max_acceleration:2.0, anti_wobble_damping:0.5, extra_side_damping:0.6, + minimum_wheels_for_ground_blend:0, + } +} +fn input(normal: Vector3) -> GroundOrientationInput { + GroundOrientationInput { com_to_deck: UP, ground_normal:normal,dynamic_up:normal, + speed:0.0,wheel_contact_count:4,animation_balance:0.0,deck_angle_curve_input:0.0, + board_up:UP,board_forward:Vector3::new(0.0,0.0,1.0), + effective_board_forward:Vector3::new(0.0,0.0,1.0), + previous_reckoning_right:Vector3::new(1.0,0.0,0.0),prevent_up_behind_board:false } +} +#[test] +fn stationary_filter_startup_has_no_false_tilt_or_acceleration() { + let s=settings();let mut state=GroundOrientation::new(&s); + for _ in 0..120 {state.update(&s,input(UP));} + assert_eq!(state.up,UP);assert_eq!(state.ground_normal,UP); + assert_eq!(state.up_velocity,Vector3::ZERO); +} +#[test] +fn changed_contact_normal_does_not_instantly_replace_the_skater_up() { + let s=settings();let mut state=GroundOrientation::new(&s); + let wall=Vector3::new(1.0,0.0,0.0);state.update(&s,input(wall)); + assert_eq!(state.ground_normal,wall); + assert_ne!(state.up,state.ground_normal); + assert!(state.up.y>0.99 && state.up.x>0.0 && state.up.x<0.04); + assert_eq!(&state.slow_filter.words()[4..8],&lanes(state.up).map(f32::to_bits)); +} \ No newline at end of file diff --git a/crates/skate-core/src/riding/tests/push.rs b/crates/skate-core/src/riding/tests/push.rs new file mode 100644 index 00000000..e0983296 --- /dev/null +++ b/crates/skate-core/src/riding/tests/push.rs @@ -0,0 +1,82 @@ +use super::*; + +fn input() -> PushInput { + PushInput { + flags_2468: 0x0200_0000, + flags_2472: 0, + target_speed: 10.0, + current_speed: 0.0, + absolute_body_speed: 0.0, + scale: 0.5, + delta_seconds: 0.125, + direction: Vector3::new(0.0, 0.0, 1.0), + } +} +fn limits() -> PushLimits { + PushLimits { + maximum_pushable_speed: 8.5, + low_speed_change: 2.0, + high_speed_change: 1.0, + } +} + +#[test] +fn inactive_and_suppressed_push_clear_both_output_vectors() { + let mut i = input(); + i.flags_2468 = 0; + i.flags_2472 = 0x200; + assert_eq!( + calculate_acceleration(i, limits()), + PushAcceleration::default() + ); + i.flags_2468 = 0x0200_0000; + assert_eq!( + calculate_acceleration(i, limits()), + PushAcceleration { + suppressed: true, + ..Default::default() + } + ); +} + +#[test] +fn tu3_blends_the_two_speed_limits_before_time_conversion() { + assert_eq!(calculate_acceleration(input(), limits()).vector.z, 8.0); + let i = PushInput { + current_speed: 4.25, + absolute_body_speed: 4.25, + ..input() + }; + assert_eq!(calculate_acceleration(i, limits()).vector.z, 6.0); +} + +#[test] +fn speed_cap_is_based_on_absolute_speed_and_never_brakes() { + let i = PushInput { + current_speed: 8.25, + absolute_body_speed: 8.25, + ..input() + }; + assert_eq!(calculate_acceleration(i, limits()).vector.z, 1.0); + let i = PushInput { + absolute_body_speed: 9.0, + ..i + }; + assert_eq!(calculate_acceleration(i, limits()).vector, Vector3::ZERO); + let i = PushInput { + target_speed: -1.0, + ..input() + }; + assert_eq!(calculate_acceleration(i, limits()).vector, Vector3::ZERO); +} + +#[test] +fn native_force_direction_keeps_vertical_component_and_zero_application_point() { + let i = PushInput { + direction: Vector3::new(0.0, 0.6, 0.8), + ..input() + }; + let result = calculate_acceleration(i, limits()); + assert_eq!(result.vector, Vector3::new(0.0, 4.8, 6.4)); + assert_eq!(result.local_point, Vector3::ZERO); +} diff --git a/crates/skate-core/src/riding/tests/push_behaviors.rs b/crates/skate-core/src/riding/tests/push_behaviors.rs new file mode 100644 index 00000000..97e00372 --- /dev/null +++ b/crates/skate-core/src/riding/tests/push_behaviors.rs @@ -0,0 +1,222 @@ +use super::*; +use crate::point_graph::PointGraph; +fn curves() -> PushAnimationCurves { + let linear = PointGraph { + x: std::array::from_fn(|i| i as f32 / 7.0), + y: std::array::from_fn(|i| i as f32), + }; + let constant = PointGraph { + x: linear.x, + y: [1.0; 8], + }; + PushAnimationCurves { + button_time_max: constant, + button_time_to_dv: linear, + blend_speed_over_frames: constant, + blend_acc_over_frames: constant, + } +} +fn state() -> PushState { + let zero = PushBlendParameters { + hstr_vel_b: 0.0, + lstr_vel_b: 0.0, + vel_e: 0.0, + }; + PushState { + out_factor: 0.375, + current_push_dv: 0.0, + current: zero, + target: zero, + continue_push: false, + } +} +fn clips() -> PushAttributes { + PushAttributes::from_clips([ + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 4.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 8.0, + }, + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 1.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 5.0, + }, + ]) +} +fn near(a: f32, b: f32) { + assert!((a - b).abs() < 0.000002, "{a} != {b}"); +} + +#[test] +fn unequal_clip_lengths_require_distance_weighted_velocity_blend() { + let target = clips().target(2.0, 2.5); + // At coefficient1/3, equal weighted durations make the begin velocity2. + // The strong/gentle end velocities are6 and3, so their midpoint is4.5. + near(target.hstr_vel_b, 1.0 / 3.0); + near(target.lstr_vel_b, 1.0 / 3.0); + near(target.vel_e, 0.5); +} +#[test] +fn authored_bounds_cap_begin_speed_and_strength_before_blending() { + let attributes = clips(); + assert_eq!(attributes.minimum_begin_velocity, 0.0); + assert_eq!(attributes.maximum_begin_velocity, 4.0); + assert_eq!(attributes.minimum_delta_velocity, 1.0); + assert_eq!(attributes.maximum_delta_velocity, 4.0); + assert_eq!( + attributes.target(-5.0, -3.0), + PushBlendParameters { + hstr_vel_b: 0.0, + lstr_vel_b: 0.0, + vel_e: 0.0 + } + ); + assert_eq!( + attributes.target(10.0, 20.0), + PushBlendParameters { + hstr_vel_b: 1.0, + lstr_vel_b: 1.0, + vel_e: 1.0 + } + ); +} +#[test] +fn out_factor_combines_normalized_speed_strength_then_caps_at_stock_limit() { + near(clips().out_factor(2.5, 2.5, 0.4, 0.659), 0.5); + assert_eq!(clips().out_factor(50.0, 50.0, 0.4, 0.659), 0.659); + assert_eq!(clips().out_factor(-20.0, -20.0, 0.4, 0.659), 0.0); +} +#[test] +fn init_push_preserves_out_factor_and_marks_all_blends_uninitialized() { + let mut state = state(); + state.current_push_dv = 4.0; + state.continue_push = true; + state.initialize_push(); + assert_eq!(state.out_factor, 0.375); + assert_eq!(state.current_push_dv, 0.0); + assert!(!state.continue_push); + assert_eq!(state.current, state.target); + assert_eq!(state.current.vel_e, -1.0); +} +#[test] +fn teleport_push_evaluates_previous_held_time_before_advancing() { + let mut first = FirstPushStrength::begin(0.1, 0.5); + let mut state = state(); + first.update(&mut state, &curves(), None, 0.0, 0.25); + assert_eq!(state.current_push_dv, 0.0); + assert_eq!(first.simulated_held_seconds, 0.25); + assert!(!first.first_push && !first.first_update); + first.update(&mut state, &curves(), None, 0.0, 0.25); + near(state.current_push_dv, 1.75); +} +#[test] +fn first_push_release_latches_until_fresh_activation() { + let mut first = FirstPushStrength::begin(0.5, 0.5); + assert!(!first.push_from_teleport); + let mut state = state(); + first.update(&mut state, &curves(), Some(0.25), 0.0, 0.1); + let strength = state.current_push_dv; + first.update(&mut state, &curves(), None, 0.0, 0.1); + first.update(&mut state, &curves(), Some(1.0), 0.0, 0.1); + assert_eq!(state.current_push_dv, strength); +} +#[test] +fn holding_first_cap_can_reduce_strength_but_next_hold_is_uncapped() { + let mut state = state(); + state.current_push_dv = 6.0; + let mut cycle = PushCycle::begin( + &mut state, + &curves(), + PushIntents { + pushing: Some(1.0), + configured_foot: true, + new_push: false, + }, + ); + cycle.update( + &mut state, + &curves(), + PushIntents { + pushing: Some(1.0), + configured_foot: true, + new_push: false, + }, + 0.0, + 4.0, + ); + assert_eq!(state.current_push_dv, 4.0); + cycle.update(&mut state, &curves(), PushIntents::default(), 0.0, 4.0); + assert!(!state.continue_push); + cycle.update( + &mut state, + &curves(), + PushIntents { + pushing: Some(1.0), + configured_foot: true, + new_push: true, + }, + 0.0, + 4.0, + ); + assert_eq!(state.current_push_dv, 7.0); + assert_eq!(cycle.phase, PushCyclePhase::HoldingNext); + state.current_push_dv = 9.0; + cycle.update(&mut state, &curves(), PushIntents::default(), 0.0, 4.0); + assert_eq!(state.current_push_dv, 7.0); + assert_eq!(cycle.phase, PushCyclePhase::NextReleased); + // Release does not clear the continuation set in the previous next-hold. + assert!(state.continue_push); +} +#[test] +fn released_first_and_new_push_checks_can_cascade_in_one_update() { + let mut state = state(); + let mut cycle = PushCycle::begin( + &mut state, + &curves(), + PushIntents { + pushing: Some(0.25), + configured_foot: true, + new_push: false, + }, + ); + cycle.update( + &mut state, + &curves(), + PushIntents { + pushing: None, + configured_foot: true, + new_push: true, + }, + 0.0, + 4.0, + ); + assert_eq!(cycle.phase, PushCyclePhase::HoldingNext); + assert!(state.continue_push); + assert_eq!(cycle.next_push_dv, 0.0); +} +#[test] +fn out_distance_uses_live_foot_and_deck_axes_with_only_z_flipped() { + let mut frame = PushFootFrame { + left_foot: [11.0, 22.0, 33.0, 0.0], + right_foot: [14.0, 25.0, 36.0, 0.0], + deck_position: [10.0, 20.0, 30.0, 0.0], + deck_y: [0.0, 1.0, 0.0, 0.0], + deck_z: [1.0, 0.0, 0.0, 0.0], + skateboard_flipped: false, + }; + assert_eq!(frame.out_distance(false), [1.0, 2.0]); + assert_eq!(frame.out_distance(true), [4.0, 5.0]); + frame.skateboard_flipped = true; + assert_eq!(frame.out_distance(true), [-4.0, 5.0]); +} diff --git a/crates/skate-core/src/riding/tests/speed_and_slope.rs b/crates/skate-core/src/riding/tests/speed_and_slope.rs new file mode 100644 index 00000000..7481700d --- /dev/null +++ b/crates/skate-core/src/riding/tests/speed_and_slope.rs @@ -0,0 +1,31 @@ +use super::*; + +fn linear_settings() -> SpeedAndSlopeSettings { + let keys = [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0]; + SpeedAndSlopeSettings { + turn_torque_vs_speed: PointGraph { x: keys, y: keys }, + turn_torque_vs_slope: PointGraph { x: keys, y: keys }, + heading_adjust_max_speed: 8.0, + } +} + +#[test] +fn coefficient_uses_absolute_forward_speed_and_saturates_its_fraction() { + let settings = linear_settings(); + let half_speed = settings.calculate(0.0, 4.0); + assert!((half_speed - 0.5).abs() < 0.00001); + assert_eq!(settings.calculate(0.0, -4.0), half_speed); + assert!((settings.calculate(0.0, 80.0) - 1.0).abs() < 0.00001); + assert_eq!(settings.calculate(0.0, 0.0), 0.0); +} + +#[test] +fn over_vertical_slope_returns_to_the_first_curve_key() { + let mut settings = linear_settings(); + settings.turn_torque_vs_slope.y[0] = 0.375; + // acos(-1)/(pi/2) exceeds1.2. The native cutoff selects zero, + // so an inverted board uses the first graph value, not its last value. + assert_eq!(settings.calculate(-1.0, 8.0), 0.375); + assert_eq!(settings.calculate(-2.0, 8.0), 0.375); + assert!((settings.calculate(0.0, 8.0) - 1.0).abs() < 0.00001); +} diff --git a/crates/skate-core/src/riding/tests/steering.rs b/crates/skate-core/src/riding/tests/steering.rs new file mode 100644 index 00000000..a6bd1021 --- /dev/null +++ b/crates/skate-core/src/riding/tests/steering.rs @@ -0,0 +1,104 @@ +use super::*; + +fn settings() -> SteeringSettings { + SteeringSettings { + hard_turn_increase: 1.0, + damping: 0.7, + speed_graph_max_speed: 50.0, + push_scalar_increment: 0.05, + push_scalar_decrement: 0.015, + push_scalar_min: 0.5, + manual_scalar: 0.25, + general_scalar: 0.6, + tight_trucks_scalar: 0.7, + tilt_blending: 0.2, + speed_graph: PointGraph { + x: [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 1.0], + y: [1.0; 8], + }, + input_graph: PointGraph { + x: [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 1.0], + y: [1.0; 8], + }, + } +} + +fn input() -> SteeringInput { + SteeringInput { + turn: 0.5, + flipped_controls_scalar: 1.0, + ..Default::default() + } +} + +#[test] +fn hard_turn_uses_turn_sign_including_zero_not_hard_turn_sign() { + let s = settings(); + let mut i = input(); + i.hard_turn = -1.0; + assert_eq!(calculate_tilt(&s, i, None, None), 1.2); + i.turn = -0.5; + assert_eq!(calculate_tilt(&s, i, None, None), -1.2); + i.turn = -0.0; + assert_eq!(calculate_tilt(&s, i, None, None), 1.2); +} + +#[test] +fn optional_state_pointers_and_pushing_recovery_preserve_native_limits() { + let s = settings(); + let mut scalar = 1.0; + let mut turn = 0.0; + let mut i = input(); + i.pushing = true; + calculate_tilt(&s, i, Some(&mut scalar), Some(&mut turn)); + assert_eq!(turn, 0.35); + assert_eq!(scalar, 0.985); + for _ in 0..100 { + calculate_tilt(&s, i, Some(&mut scalar), None); + } + assert_eq!(scalar, 0.5); + i.pushing = false; + for _ in 0..100 { + calculate_tilt(&s, i, Some(&mut scalar), None); + } + assert_eq!(scalar, 1.0); +} + +#[test] +fn manual_tightness_and_flipped_controls_multiply_the_tilt() { + let s = settings(); + let base = calculate_tilt(&s, input(), None, None); + let i = SteeringInput { + balance: 1.0, + truck_tightness: 1.0, + flipped_controls_scalar: -1.0, + ..input() + }; + assert!((calculate_tilt(&s, i, None, None) - base * -0.25 * 0.7).abs() < 1e-7); +} + +#[test] +fn truck_contact_flags_swap_with_stance_and_first_inactive_tick_holds_angle() { + let mut regular = TruckSteeringState::default(); + regular.update(0.6, 0.2, 0, 1 << 27); + assert!(regular.targets[0] > 0.0); + assert_eq!(regular.targets[1], 0.0); + let mut switch = TruckSteeringState::default(); + switch.update(0.6, 0.2, 1 << 20, 1 << 27); + assert_eq!(regular.targets, [switch.targets[1], switch.targets[0]]); + let previous = regular.targets[0]; + regular.update(0.6, 0.2, 0, 0); + assert_eq!(regular.targets[0], previous); + regular.update(0.6, 0.2, 0, 0); + assert_eq!(regular.targets[0], previous * 0.983); +} + +#[test] +fn truck_reactivation_completes_the_native_ramp() { + let mut state = TruckSteeringState::default(); + for _ in 0..11 { + state.update(0.6, 1.0, 0, 3 << 26); + } + assert_eq!(state.targets, [0.6; 2]); + assert!(state.activation_time[0] > 0.165); +} diff --git a/crates/skate-core/src/riding/vector.rs b/crates/skate-core/src/riding/vector.rs new file mode 100644 index 00000000..2198a913 --- /dev/null +++ b/crates/skate-core/src/riding/vector.rs @@ -0,0 +1,40 @@ +use crate::physics::reciprocal_sqrt::estimate; + +pub(super) fn dot3(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} + +/// Inline NormalizeSafe sequence in TU3 82C04F68 / 82D92CF8. +pub(super) fn normalize(v: [f32; 4], threshold: [f32; 4]) -> [f32; 4] { + let squared = dot3(v, v); + let mut inverse = estimate(squared); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + core::array::from_fn(|i| { + if length > threshold[i] { + v[i] * inverse + } else { + 0.0 + } + }) +} + +pub(super) fn clamp(value: f32, lower: f32, upper: f32) -> f32 { + let v = if lower - value >= 0.0 { lower } else { value }; + if upper - v >= 0.0 { v } else { upper } +} + +pub(super) fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| { + let j = if i == 3 { 3 } else { (i + 1) % 3 }; + let k = if i == 3 { 3 } else { (i + 2) % 3 }; + (-a[k]).mul_add(b[j], a[j] * b[k]) + }) +} diff --git a/crates/skate-core/src/trigonometry.rs b/crates/skate-core/src/trigonometry.rs new file mode 100644 index 00000000..232697b4 --- /dev/null +++ b/crates/skate-core/src/trigonometry.rs @@ -0,0 +1,141 @@ +//! Scalar lane of TU3 `XMVectorSinCos`, `0x82473A08`. +//! +//! Coefficients are the executable words at 0x822F97C0..0x822F9860. +//! Arithmetic follows the recovered polynomial structure. Numerical agreement +//! with the original game requires independent validation. +pub fn sin_cos(angle: f32) -> (f32, f32) { + let turns = (angle * f32::from_bits(0x3E22_F983)).round_ties_even(); + let x = (-f32::from_bits(0x40C9_0FDB)).mul_add(turns, angle); + let x2 = x * x; + let x3 = x2 * x; + let x4 = x2 * x2; + let x5 = x3 * x2; + let x6 = x3 * x3; + let x7 = x4 * x3; + let x8 = x4 * x4; + let x9 = x5 * x4; + let x10 = x5 * x5; + let x11 = x6 * x5; + let x12 = x6 * x6; + let x13 = x7 * x6; + let x15 = x8 * x7; + let x14 = x7 * x7; + let x16 = x8 * x8; + let x17 = x9 * x8; + let x19 = x10 * x9; + let x18 = x9 * x9; + let x21 = x11 * x10; + let x20 = x10 * x10; + let x23 = x12 * x11; + let x22 = x11 * x11; + + let sin = f32::from_bits(0xBE2A_AAAB).mul_add(x3, x); + let sin = f32::from_bits(0x3C08_8889).mul_add(x5, sin); + let sin = x7.mul_add(f32::from_bits(0xB950_0D01), sin); + let sin = f32::from_bits(0x3638_EF1D).mul_add(x9, sin); + let sin = f32::from_bits(0xB2D7_322B).mul_add(x11, sin); + let sin = f32::from_bits(0x2F30_9231).mul_add(x13, sin); + let sin = f32::from_bits(0xAB57_3F9F).mul_add(x15, sin); + let sin = f32::from_bits(0x274A_963C).mul_add(x17, sin); + let sin = f32::from_bits(0xA317_A4DA).mul_add(x19, sin); + let sin = f32::from_bits(0x1EB8_DC78).mul_add(x21, sin); + let sin = f32::from_bits(0x9A3B_0DA1).mul_add(x23, sin); + + let cos = (-0.5_f32).mul_add(x2, 1.0); + let cos = f32::from_bits(0x3D2A_AAAB).mul_add(x4, cos); + let cos = f32::from_bits(0xBAB6_0B61).mul_add(x6, cos); + let cos = f32::from_bits(0x37D0_0D01).mul_add(x8, cos); + let cos = f32::from_bits(0xB493_F27E).mul_add(x10, cos); + let cos = f32::from_bits(0x310F_76C8).mul_add(x12, cos); + let cos = f32::from_bits(0xAD49_CBA5).mul_add(x14, cos); + let cos = f32::from_bits(0x2957_3F9F).mul_add(x16, cos); + let cos = f32::from_bits(0xA534_13C3).mul_add(x18, cos); + let cos = f32::from_bits(0x20F2_A15D).mul_add(x20, cos); + let cos = f32::from_bits(0x9C86_71CB).mul_add(x22, cos); + (sin, cos) +} + +/// Standalone TU3 cosine82473930. The even-power tree differs from SinCos; +/// normal gameplay camera roll82BD35B8 calls this function independently. +/// This is the retained readable polynomial also audited for SetTurning. +pub fn cos(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3e22_f983)).round_ties_even(); + let x = (-f32::from_bits(0x40c9_0fdb)).mul_add(turns, angle); + let x2 = x * x; + let x4 = x2 * x2; + let x6 = x4 * x2; + let x8 = x4 * x4; + let x10 = x6 * x4; + let x12 = x6 * x6; + let x14 = x8 * x6; + let x16 = x8 * x8; + let x18 = x10 * x8; + let x22 = x12 * x10; + let x20 = x10 * x10; + let mut value = (-0.5_f32).mul_add(x2, 1.0); + for (power, coefficient) in [ + (x4, 0x3d2a_aaab), (x6, 0xbab6_0b61), (x8, 0x37d0_0d01), + (x10, 0xb493_f27e), (x12, 0x310f_76c8), (x14, 0xad49_cba5), + (x16, 0x2957_3f9f), (x18, 0xa534_13c3), (x20, 0x20f2_a15d), + (x22, 0x9c86_71cb), + ] { value = f32::from_bits(coefficient).mul_add(power, value); } + value +} + +/// TU3 vector sine 824531C8. Its sequential odd powers differ from SinCos's +/// multiplication tree, so callers of the standalone sine must use this path. +pub fn sin(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3E22_F983)).round_ties_even(); + let x = (-f32::from_bits(0x40C9_0FDB)).mul_add(turns, angle); + let x2 = x * x; + let mut power = x * x2; + let mut result = x; + for coefficient in [ + 0xBE2A_AAAB, + 0x3C08_8889, + 0xB950_0D01, + 0x3638_EF1D, + 0xB2D7_322B, + 0x2F30_9231, + 0xAB57_3F9F, + 0x274A_963C, + 0xA317_A4DA, + 0x1EB8_DC78, + 0x9A3B_0DA1, + ] { + result = f32::from_bits(coefficient).mul_add(power, result); + power *= x2; + } + result +} + +/// Scalar lane of TU3 vector acos 82453298. The native radicand constant is +/// 0x3f800001, and its reciprocal square root receives one refinement. +pub fn acos(value: f32) -> f32 { + (f32::from_bits(0x4049_0FDB) * 0.5) - asin(value) +} + +/// Shared inverse-sine polynomial, also inlined by DisablePushBrake82BA5150. +/// Keep this intermediate separate: that caller subtracts degrees from90, +/// whereas vector acos subtracts radians from half pi. +pub fn asin(value: f32) -> f32 { + let a = value.abs(); + let cube = (value * value) * a; + let c = f32::from_bits; + let p0 = c(0x400B_1889).mul_add(a, c(0xC0D1_360E)); + let p1 = c(0x3E66_3246).mul_add(a, c(0xBF98_3F2F)); + let p2 = c(0xBED6_5553).mul_add(a, c(0x4089_80BD)); + let p3 = c(0xBD6D_D42D).mul_add(a, c(0x3F1D_D7B6)); + let p0 = p0.mul_add(a, c(0x40AF_6AD8)); + let p1 = p1.mul_add(a, c(0x3FB5_8485)); + let p2 = p2.mul_add(a, c(0xC08F_6AD9)); + let p3 = p3.mul_add(a, c(0xBFAF_4418)); + let left = p1.mul_add(cube, p0); + let right = p3.mul_add(cube, p2); + let radicand = c(0x3F80_0001) - a; + let mut r = crate::physics::reciprocal_sqrt::estimate(radicand); + let correction = (-(radicand * 0.5)).mul_add(r * r, 0.5); + r = r.mul_add(correction, r); + let residual = (-a).mul_add(value, value) * right; + residual.mul_add(r, value * left) +} diff --git a/crates/skate-core/tests/animation_actor_composition.rs b/crates/skate-core/tests/animation_actor_composition.rs new file mode 100644 index 00000000..7abdcec4 --- /dev/null +++ b/crates/skate-core/tests/animation_actor_composition.rs @@ -0,0 +1,620 @@ +use skate_core::animation::{ + commands::{ + batch::{CompletedSetData, SetDataQueue}, + buffers::{BufferError, PoseBuffers}, + }, + output::{ + actor_packet::{ + self, ActorPacketFields, ActorPublicationEnvironment, ActorPublicationState, + ExternalPhysicsInput, ExternalReset, PlayerPhysicsSettingsSource, + }, + attributes::{AttributeName, MotionGraphAttribute, PacketAttributes}, + intents::{Intent, IntentCapacityExceeded, IntentMap, IntentName}, + motion_graph_packet::{GestureData, MotionGraphPacket, TrickData, TrickIdentifier}, + packet_reset::{AdditionalResetFields, RESET_POSE}, + physics_packet::{ActorPoseBuffers, PhysicsPosePacket, SkaterPublicationState}, + setup_physics::{ + self, AnimationPublication, IntentPublication, MotionGraphPublication, + PhysicsInputPacket, PublicationError, + }, + }, +}; +use std::num::NonZeroU16; + +fn intent(id: u32, value: f32) -> Intent { + Intent { + name: IntentName([id, 0, 0, 0, 0, 0]), + value, + } +} +#[test] +fn intent_assignment_reverses_collisions_and_retains_first_duplicate_value() { + let mut source = IntentMap::default(); + for entry in [ + intent(1, 1.0), + intent(62, 2.0), + intent(123, 3.0), + intent(2, 4.0), + ] { + assert_eq!(source.insert(entry), Ok(true)); + } + assert_eq!(source.insert(intent(62, 99.0)), Ok(false)); + assert_eq!( + source.entries().map(|e| e.name.0[0]).collect::>(), + [123, 62, 1, 2] + ); + let mut destination = IntentMap::default(); + destination.insert(intent(99, 99.0)).unwrap(); + destination.replace_from(&source); + assert_eq!( + destination + .entries() + .map(|e| (e.name.0[0], e.value)) + .collect::>(), + [(1, 1.0), (62, 2.0), (123, 3.0), (2, 4.0)] + ); + assert_eq!( + source.entries().map(|e| e.name.0[0]).collect::>(), + [123, 62, 1, 2] + ); +} +#[test] +fn intent_hash_wraps_all_six_words_and_capacity_allows_existing_keys() { + let mut map = IntentMap::default(); + map.insert(intent(1, 7.0)).unwrap(); + let wrapped = Intent { + name: IntentName([u32::MAX, 0, 0, 0, 0, 2]), + value: -0.0, + }; + map.insert(wrapped).unwrap(); + assert_eq!(map.entries().next().unwrap().name, wrapped.name); + assert_eq!(map.entries().next().unwrap().value.to_bits(), 0x80000000); + for id in 2..64 { + map.insert(intent(id, id as f32)).unwrap(); + } + assert_eq!(map.len(), 64); + assert_eq!(map.insert(intent(1, 8.0)), Ok(false)); + assert_eq!(map.insert(intent(1000, 8.0)), Err(IntentCapacityExceeded)); + assert_eq!(map.len(), 64); + map.clear(); + assert!(map.is_empty()); + assert_eq!(map.insert(wrapped), Ok(true)); +} + +fn graph() -> MotionGraphPacket { + MotionGraphPacket { + trick: TrickData { + identifiers: [TrickIdentifier([7; 6]); 2], + word48: 8, + scalars52_56: [9.0, 10.0], + }, + gesture: GestureData { + gesture: 11, + flag_bytes: [1, 2, 3, 255], + }, + wipeout_gesture: [12.0, 13.0], + } +} +fn packet() -> PhysicsInputPacket { + PhysicsInputPacket { + pose: PhysicsPosePacket { + bone_count: 1, + hierarchy: vec![[[77.0; 4]; 4]], + local: vec![[[88.0; 4]; 4]], + timestep: 0.0, + foot_surface_ids: [42; 2], + flags: u32::MAX, + board_flipped: false, + mirrored: false, + riding_switch: false, + riding_fakie: false, + weight_forwards: false, + regular_stance: false, + air_dismount_revert_frames: 0, + }, + reset: AdditionalResetFields { + compression: 99.0, + foot_ik_influence: [99.0; 2], + next_step_position_valid: true, + actor_flag_1904_bit23: false, + actor_flag_1908_bit2: false, + external_impulse_active: false, + external_physics_input_active: false, + externally_controlled: false, + prevent_manual_respawn: false, + ignore_respawn_reset_button: 0, + force_braking: false, + truck_tightness: 99.0, + wheel_hardness: 99.0, + auxiliary_vectors: [[99.0; 4]; 6], + requested_physics_mode: 99, + }, + motion_graph: graph(), + intents: IntentMap::default(), + attributes: PacketAttributes::default(), + actor: ActorPacketFields { + external_impulse: [0; 4], + external_physics: ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags: 0x01abcdef, + }, + external_reset: ExternalReset { + transform: [[0; 4]; 4], + byte64: 0, + }, + actor_flag_1904_bit29: false, + }, + } +} +fn actor() -> ActorPublicationState { + ActorPublicationState { + flags1904: (1 << 23) | (1 << 25) | (1 << 27) | (1 << 28) | (1 << 29) | (1 << 30) | 1, + flags1908: 4, + external_controller_present: false, + external_impulse: [0x7fc12345, 0x80000000, 1, 0xff], + external_physics: ExternalPhysicsInput { + vectors: std::array::from_fn(|i| [i as u32; 4]), + flags: 0xaa765432, + }, + external_reset: ExternalReset { + transform: [[0x80000000; 4]; 4], + byte64: 254, + }, + player_index: 3, + } +} +struct Environment { + calls: Vec<&'static str>, + scene: Option, + selector: u8, + missing_scene: bool, + missing_mode: bool, +} +struct Settings { + fixed: bool, + missing_mode: bool, +} +impl PlayerPhysicsSettingsSource for Settings { + type Error = &'static str; + fn truck_tightness(&mut self, index: u32) -> Result { + assert!(!self.fixed); + assert_eq!(index, 3); + Ok(0.2) + } + fn wheel_hardness(&mut self, index: u32) -> Result { + assert!(!self.fixed); + assert_eq!(index, 3); + Ok(0.8) + } + fn requested_physics_mode(&mut self, index: u32) -> Result { + assert_eq!(index, 3); + if self.missing_mode { + Err("mode unavailable") + } else { + Ok(4) + } + } +} +impl ActorPublicationEnvironment for Environment { + type Error = &'static str; + type Settings = Settings; + fn current_scene_mode(&mut self) -> Result, Self::Error> { + self.calls.push("scene"); + if self.missing_scene { + Err("scene service unavailable") + } else { + Ok(self.scene) + } + } + fn ignore_respawn_reset_button(&mut self) -> Result { + self.calls.push("global-byte"); + Ok(0xfe) + } + fn actor_subobject56_slot16( + &mut self, + actor: &mut ActorPublicationState, + ) -> Result { + self.calls.push("actor56"); + assert_eq!(actor.flags1904 & ((1 << 25) | (1 << 28)), 0); + Ok(self.selector) + } + fn capture_physics_settings(&mut self) -> Result { + self.calls.push("settings-base"); + Ok(Settings { + fixed: self.selector != 0, + missing_mode: self.missing_mode, + }) + } +} +fn environment() -> Environment { + Environment { + calls: vec![], + scene: Some(19), + selector: 0, + missing_scene: false, + missing_mode: false, + } +} +#[test] +fn external_input_copy_transfers_each_high_flag_and_preserves_every_low_flag() { + for bit in 0..32 { + let mut destination = ExternalPhysicsInput { + vectors: [[7; 4]; 10], + flags: 1 << bit, + }; + let source = ExternalPhysicsInput { + vectors: [[0x7fc12345; 4]; 10], + flags: 0, + }; + destination.copy_from(&source); + assert_eq!(destination.flags, if bit < 25 { 1 << bit } else { 0 }); + assert_eq!(destination.vectors, source.vectors); + let source = ExternalPhysicsInput { + flags: 1 << bit, + ..source + }; + destination.flags = 0; + destination.copy_from(&source); + assert_eq!(destination.flags, if bit >= 25 { 1 << bit } else { 0 }); + } +} +#[test] +fn actor_publication_copies_masked_payload_consumes_only_two_latches_and_repeats() { + let mut packet = packet(); + let mut actor = actor(); + let initial = actor.flags1904; + let mut environment = environment(); + actor_packet::publish( + &mut actor, + &mut packet.pose, + &mut packet.reset, + &mut packet.actor, + &mut environment, + ) + .unwrap(); + assert_eq!( + environment.calls, + ["scene", "global-byte", "settings-base", "actor56"] + ); + assert_eq!(packet.actor.external_physics.flags, 0xababcdef); + assert_eq!( + packet.actor.external_physics.vectors, + actor.external_physics.vectors + ); + assert_eq!(packet.actor.external_reset, actor.external_reset); + assert_eq!(packet.actor.external_impulse, actor.external_impulse); + assert!(packet.reset.actor_flag_1904_bit23 && packet.reset.actor_flag_1908_bit2); + assert!(packet.reset.external_impulse_active && packet.reset.external_physics_input_active); + assert!(packet.actor.actor_flag_1904_bit29 && packet.reset.prevent_manual_respawn); + assert!(!packet.reset.externally_controlled && !packet.reset.force_braking); + assert_eq!(packet.pose.flags, u32::MAX); + assert_eq!(actor.flags1904, initial & !((1 << 25) | (1 << 28))); + assert_eq!(packet.reset.ignore_respawn_reset_button, 254); + assert_eq!( + (packet.reset.truck_tightness, packet.reset.wheel_hardness), + (0.2, 0.8) + ); + assert_eq!(packet.reset.requested_physics_mode, 4); + actor_packet::publish( + &mut actor, + &mut packet.pose, + &mut packet.reset, + &mut packet.actor, + &mut environment, + ) + .unwrap(); + assert_eq!(packet.pose.flags & (1 << 24), 0); + assert!(!packet.reset.prevent_manual_respawn); + assert!(packet.reset.external_impulse_active && packet.actor.actor_flag_1904_bit29); +} +#[test] +fn scene_query_short_circuits_and_only_modes19_20_enable_flag() { + for controller in [false, true] { + for braking in [false, true] { + for mode in [None, Some(18), Some(19), Some(20), Some(21)] { + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + a.external_controller_present = controller; + if braking { + a.flags1904 |= 1 << 26; + } + e.scene = mode; + e.selector = 128; + actor_packet::publish(&mut a, &mut p.pose, &mut p.reset, &mut p.actor, &mut e) + .unwrap(); + assert_eq!(e.calls.contains(&"scene"), !controller && !braking); + assert_eq!( + p.pose.flags & (1 << 22) != 0, + !controller && !braking && matches!(mode, Some(19 | 20)) + ); + assert_eq!(p.reset.externally_controlled, controller); + assert_eq!(p.reset.force_braking, braking); + assert_eq!(p.reset.truck_tightness.to_bits(), 0x3f333333); + assert_eq!(p.reset.wheel_hardness.to_bits(), 0x3f333333); + assert_eq!(p.reset.requested_physics_mode, 4); + } + } + } +} +#[test] +fn missing_mode_retains_both_branch_scalars_without_reading_unused_settings() { + for fixed in [false, true] { + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + e.selector = u8::from(fixed); + e.missing_mode = true; + assert_eq!( + actor_packet::publish(&mut a, &mut p.pose, &mut p.reset, &mut p.actor, &mut e), + Err("mode unavailable") + ); + let expected = if fixed { (0.7, 0.7) } else { (0.2, 0.8) }; + assert_eq!((p.reset.truck_tightness, p.reset.wheel_hardness), expected); + assert_eq!(p.reset.requested_physics_mode, 99); + assert_eq!( + e.calls, + ["scene", "global-byte", "settings-base", "actor56"] + ); + } +} + +fn animation(initialized: bool) -> (CompletedSetData, ActorPoseBuffers, SkaterPublicationState) { + let mut buffers = PoseBuffers::default(); + let hierarchy = if initialized { + buffers.matrices.initialized([[[1.0; 4]; 4]]) + } else { + buffers.matrices.allocate(1) + }; + let local = buffers.matrices.initialized([[[2.0; 4]; 4]]); + let completed = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + ( + completed, + ActorPoseBuffers { + hierarchy: hierarchy.at(0), + local: local.at(0), + }, + SkaterPublicationState { + orientation_bit31: false, + mirrored: false, + riding_fakie: true, + weight_on_nose: true, + relative_stance: 1, + natural_stance: 0, + request_bit16: true, + request_bit15: true, + air_dismount_revert_requested: true, + air_dismount_revert_frames: 12, + signal: None, + }, + ) +} +#[test] +fn composition_overwrites_reset_poses_then_merges_graph_lists_and_binds_owned_packet() { + let (completed, poses, mut state) = animation(true); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mut mg = graph(); + mg.gesture.gesture = 987; + let mut intents = IntentMap::default(); + intents.insert(intent(1, 3.0)).unwrap(); + let attrs = [MotionGraphAttribute { + name: AttributeName([1; 5]), + value: 4.0, + }]; + let tree = [MotionGraphAttribute { + name: AttributeName([1; 5]), + value: 5.0, + } + .to_animation()]; + let binding = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"signup", + tree_attributes: &tree, + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::MotionGraph(&intents), + attributes: &attrs, + }, + &mut a, + &mut e, + ) + .unwrap(); + let published = binding.packet(); + assert_eq!(published.pose.hierarchy, [[[1.0; 4]; 4]]); + assert_eq!(published.pose.local, [[[2.0; 4]; 4]]); + assert_eq!(published.pose.flags, 0x19ffffff); + assert_eq!(published.reset.compression, 0.5); + assert_eq!(published.pose.timestep.to_bits(), 0x3c888889); + assert_eq!(published.motion_graph.gesture.gesture, 987); + assert_eq!( + published + .attributes + .entries() + .iter() + .map(|a| a.payload.0[0]) + .collect::>(), + [Some(4.0f32.to_bits()), Some(5.0f32.to_bits())] + ); + assert_eq!(published.intents.entries().next().unwrap().value, 3.0); + assert!(!state.request_bit16 && !state.request_bit15 && !state.air_dismount_revert_requested); + assert_eq!(mg.gesture.gesture, 987); +} +#[test] +fn composition_runtime_failure_retains_prior_phases_but_does_not_bind() { + let (completed, poses, mut state) = animation(true); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mg = graph(); + p.intents.insert(intent(1, 3.0)).unwrap(); + p.intents.insert(intent(62, 4.0)).unwrap(); + e.missing_scene = true; + let error = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"", + tree_attributes: &[], + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::RetainPacket, + attributes: &[], + }, + &mut a, + &mut e, + ) + .err() + .unwrap(); + assert_eq!( + error, + PublicationError::RuntimeService("scene service unavailable") + ); + assert_eq!(e.calls, ["scene"]); + assert_eq!(p.pose.local, [[[2.0; 4]; 4]]); + assert_eq!(p.actor.external_physics.flags, 0xababcdef); + assert_eq!( + p.intents.entries().map(|e| e.name.0[0]).collect::>(), + [62, 1] + ); + assert_ne!(a.flags1904 & ((1 << 25) | (1 << 28)), 0); + assert!(!state.request_bit16); +} +#[test] +fn missing_pose_stops_after_reset_before_graph_or_runtime_phases() { + let (completed, poses, mut state) = animation(false); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mut mg = graph(); + mg.gesture.gesture = 999; + let error = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"", + tree_attributes: &[], + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::RetainPacket, + attributes: &[], + }, + &mut a, + &mut e, + ) + .err() + .unwrap(); + assert!(matches!( + error, + PublicationError::Pose(BufferError::UninitializedBone { .. }) + )); + assert_eq!(p.pose.local, [RESET_POSE]); + assert_eq!(p.motion_graph.gesture.gesture, 11); + assert!(e.calls.is_empty()); + assert!(state.request_bit16); +} + +#[test] +fn composed_mg_and_tree_attributes_reach_concrete_skeleton_steering_consumers() { + use skate_core::animation::skeleton_input::{ + name::encode, + scalar_attributes::{self, AnimationControlOutput, ScalarAttributeInputs}, + }; + let (completed, poses, mut state) = animation(true); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mg = graph(); + let graph_attributes = [ + MotionGraphAttribute { + name: encode(b"Turn"), + value: 0.25, + }, + MotionGraphAttribute { + name: encode(b"PushContact"), + value: 0.0, + }, + ]; + let tree_attributes = [ + MotionGraphAttribute { + name: encode(b"Turn"), + value: -0.75, + } + .to_animation(), + MotionGraphAttribute { + name: encode(b"Spin"), + value: 0.5, + } + .to_animation(), + ]; + let binding = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"signup", + tree_attributes: &tree_attributes, + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::RetainPacket, + attributes: &graph_attributes, + }, + &mut a, + &mut e, + ) + .unwrap(); + let mut processed = ScalarAttributeInputs { + flags2468: 0, + flags2472: 0, + flags2476: 0, + flags2484: 0, + flags2488: 0, + board_adjust: 0, + balance: 0.0, + spin: 0.0, + body_spin: 0.0, + brake: 0.0, + turn: 0.0, + turn_scale: 1.0, + magnitude_scale: 1.0, + animation_end_com: [0.0; 4], + animation_translation: [0.0; 4], + animation_time: 0.0, + animation_physics_blend_seconds: 0.0, + cadence_end_percent: 0.0, + raw_turn: 0.0, + hard_turn: 0.0, + slide: 0.0, + }; + let mut animation_output = AnimationControlOutput { + grind_name: AttributeName([0; 5]), + flags: 0, + }; + scalar_attributes::dispatch_supported_packet(&binding, &mut processed, &mut animation_output) + .unwrap(); + assert_eq!(processed.turn, 0.75); // Later tree value wins, then native negation. + assert_eq!(processed.spin, -0.5); + assert_eq!(processed.flags2488, 1 << 29); // Scalar marker, no strength calculation. + assert_eq!(binding.packet().attributes.entries().len(), 4); + assert_eq!( + binding.packet().attributes.entries()[0].payload.0[0], + Some(0.25f32.to_bits()) + ); +} diff --git a/crates/skate-core/tests/animation_commands.rs b/crates/skate-core/tests/animation_commands.rs new file mode 100644 index 00000000..43af0ba4 --- /dev/null +++ b/crates/skate-core/tests/animation_commands.rs @@ -0,0 +1,358 @@ +use skate_core::animation::commands::{ + batch::SetDataQueue, + buffers::{BufferError, PoseBuffers}, + set_data::{CopyCommand, InternalUpdate, SetDataCommand, resolve_set_count}, +}; +use skate_core::animation::output::{NativeMatrix, Sqt}; +use std::num::NonZeroU16; + +fn matrix(value: f32) -> NativeMatrix { + [[value; 4]; 4] +} +fn queue(buffers: PoseBuffers, limit: u16) -> SetDataQueue { + SetDataQueue::new(buffers, NonZeroU16::new(limit).unwrap()) +} +#[test] +fn partial_set_exports_whole_skeleton_and_preserves_tail() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(9.0)]); + let internal = buffers + .matrices + .initialized([matrix(1.0), matrix(2.0), matrix(3.0)]); + let output = buffers.matrices.initialized([matrix(-1.0); 4]); + let mut queue = queue(buffers, 4); + queue + .enqueue( + 3, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: internal.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + assert!(queue.flush()); + assert!(!queue.flush()); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 4).unwrap(), + [matrix(9.0), matrix(2.0), matrix(3.0), matrix(-1.0)] + ); +} +#[test] +fn alias_writes_feed_later_records_and_final_external_export() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(7.0)]); + let scratch = buffers.matrices.initialized([matrix(0.0); 2]); + let output = buffers.matrices.allocate(1); + let mut queue = queue(buffers, 8); + queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: scratch.at(0), + external: Some(scratch.at(1)), + }), + &[], + ) + .unwrap(); + queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: scratch.at(1), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 1).unwrap(), + [matrix(7.0)] + ); +} +#[test] +fn uninitialized_tail_does_not_become_a_valid_zero_pose() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(8.0)]); + let internal = buffers.matrices.allocate(2); + let output = buffers.matrices.initialized([matrix(3.0); 2]); + let mut queue = queue(buffers, 8); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: internal.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 2), + Err(BufferError::UninitializedBone { bone: 1 }) + ); + assert_eq!( + done.buffers().matrices.read(output.at(0), 1).unwrap(), + [matrix(8.0)] + ); +} +#[test] +fn sqt_copy_preserves_every_float_bit_and_nonzero_bone_offset() { + let mut buffers = PoseBuffers::default(); + let value = Sqt { + scale: [f32::from_bits(0x7fc12345), -0.0, 3.0, 4.0], + rotation: [5.0; 4], + translation: [6.0; 4], + }; + let source = buffers.sqt.initialized([value]); + let output = buffers.sqt.allocate(3); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 1, + SetDataCommand::Sqt(CopyCommand { + update: InternalUpdate::Preserve, + internal: source.at(0), + external: Some(output.at(1)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let copied = done.buffers().sqt.read(output.at(1), 1).unwrap()[0]; + assert_eq!( + copied.scale.map(f32::to_bits), + value.scale.map(f32::to_bits) + ); + assert_eq!(copied.rotation, value.rotation); + assert_eq!(copied.translation, value.translation); + assert!(done.buffers().sqt.read(output.at(0), 1).is_err()); +} +#[test] +fn capacity_boundary_and_dependency_preserve_producer_before_consumer() { + let mut buffers = PoseBuffers::default(); + let input = buffers.matrices.initialized([matrix(42.0)]); + let intermediate = buffers.matrices.allocate(1); + let output = buffers.matrices.allocate(1); + let mut queue = queue(buffers, 1); + let first = queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: input.at(0), + external: Some(intermediate.at(0)), + }), + &[None], + ) + .unwrap(); + let second = queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: intermediate.at(0), + external: Some(output.at(0)), + }), + &[Some(first), None, Some(first)], + ) + .unwrap(); + assert_ne!(first, second); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 1).unwrap(), + [matrix(42.0)] + ); +} +#[test] +fn prepare_and_cancel_never_execute_copies() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(9.0)]); + let output = buffers.matrices.initialized([matrix(1.0)]); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: source.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + queue.flush(); + assert_eq!( + queue.cancel().matrices.read(output.at(0), 1).unwrap(), + [matrix(1.0)] + ); +} +#[test] +fn unresolved_partial_overlap_fails_without_undoing_prior_set() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(8.0)]); + let internal = buffers.matrices.initialized([matrix(0.0); 3]); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: internal.at(0), + external: Some(internal.at(1)), + }), + &[], + ) + .unwrap(); + let failure = match queue.submit().complete() { + Ok(_) => panic!("overlap accepted"), + Err(e) => e, + }; + assert_eq!(failure.cause, BufferError::UnsupportedPartialOverlap); + assert_eq!( + failure.buffers.matrices.read(internal.at(0), 3).unwrap(), + [matrix(8.0), matrix(0.0), matrix(0.0)] + ); +} +#[test] +fn latest_enqueued_skeleton_count_applies_to_every_command_in_batch() { + let mut buffers = PoseBuffers::default(); + let input = buffers.matrices.initialized([matrix(1.0), matrix(2.0)]); + let output = buffers.matrices.allocate(2); + let mut queue = queue(buffers, 3); + let first = queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: input.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: input.at(0), + external: None, + }), + &[Some(first)], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 2).unwrap(), + [matrix(1.0), matrix(2.0)] + ); + assert_eq!(resolve_set_count(0, 258), 2); + assert_eq!(resolve_set_count(0, 256), 0); + assert_eq!(resolve_set_count(255, 20), 255); +} + +#[test] +fn foreign_buffer_and_job_handles_are_rejected() { + let mut foreign = PoseBuffers::default(); + let foreign_pose = foreign.matrices.initialized([matrix(9.0)]); + let mut foreign_queue = queue(foreign, 2); + let foreign_job = foreign_queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: foreign_pose.at(0), + external: None, + }), + &[], + ) + .unwrap(); + let mut buffers = PoseBuffers::default(); + let local = buffers.matrices.initialized([matrix(1.0)]); + assert_eq!( + buffers.matrices.read(foreign_pose.at(0), 1), + Err(BufferError::UnknownBuffer) + ); + let mut own_queue = queue(buffers, 2); + let command = SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: local.at(0), + external: None, + }); + assert_eq!( + own_queue.enqueue(1, command, &[Some(foreign_job)]), + Err(skate_core::animation::commands::batch::QueueError::UnavailableDependency) + ); + own_queue.enqueue(1, command, &[]).unwrap(); + assert!(own_queue.submit().complete().is_ok()); +} + +#[test] +fn full_export_count_is_not_truncated_to_the_set_byte() { + let mut buffers = PoseBuffers::default(); + let internal = buffers + .matrices + .initialized((0..256).map(|i| matrix(i as f32))); + let empty_source = buffers.matrices.allocate(0); + let output = buffers.matrices.allocate(256); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 256, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: empty_source.at(usize::MAX), + bone_count: 0, + }, + internal: internal.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let values = done.buffers().matrices.read(output.at(0), 256).unwrap(); + assert_eq!(values[0], matrix(0.0)); + assert_eq!(values[255], matrix(255.0)); +} diff --git a/crates/skate-core/tests/animation_output_bounds.rs b/crates/skate-core/tests/animation_output_bounds.rs new file mode 100644 index 00000000..37aebf24 --- /dev/null +++ b/crates/skate-core/tests/animation_output_bounds.rs @@ -0,0 +1,23 @@ +//! Independent buffer-validation checks; no generated-code fixtures. +use skate_core::animation::output::{PoseBufferError, Sqt, compose_hierarchy, sqt_to_local}; + +#[test] +fn invalid_buffers_fail_without_partial_publication() { + let sentinel = [[f32::from_bits(0x3f123456); 4]; 4]; + let sqt = Sqt { + scale: [1.0; 4], + rotation: [0.0, 0.0, 0.0, 1.0], + translation: [0.0; 4], + }; + let mut destination = [sentinel; 4]; + assert_eq!( + sqt_to_local(1, 0, &[sqt], &mut destination), + Err(PoseBufferError::ShortInput) + ); + assert_eq!(destination, [sentinel; 4]); + assert_eq!( + compose_hierarchy(4, &[-1, 0, 1, 9], -1, &[sentinel; 4], &mut destination), + Err(PoseBufferError::InvalidParent { bone: 3, parent: 9 }) + ); + assert_eq!(destination, [sentinel; 4]); +} diff --git a/crates/skate-core/tests/animation_packet_merge.rs b/crates/skate-core/tests/animation_packet_merge.rs new file mode 100644 index 00000000..1a975d99 --- /dev/null +++ b/crates/skate-core/tests/animation_packet_merge.rs @@ -0,0 +1,125 @@ +use skate_core::animation::output::{ + attributes::{ + AnimationAttribute, AttributeName, AttributePayload, MotionGraphAttribute, PacketAttributes, + }, + motion_graph_packet::{self, GestureData, MotionGraphPacket, TrickData, TrickIdentifier}, +}; +fn attribute(kind: u8, seed: u32) -> AnimationAttribute { + AnimationAttribute { + payload: AttributePayload(std::array::from_fn(|i| Some(seed + i as u32))), + begin_time: 2.0, + end_time: 3.0, + name: AttributeName([seed; 5]), + status: 9, + kind, + sequence_id: 17, + } +} +#[test] +fn assignment_copies_only_kind_selected_union_lanes_and_all_metadata() { + for kind in [0, 1, 2, 3, 4, 255] { + let source = attribute(kind, 100); + let mut destination = attribute(3, 200); + destination.copy_from(&source); + let count = match kind { + 0 | 2 => 1, + 1 => 4, + 3 => 6, + _ => 0, + }; + for lane in 0..6 { + assert_eq!( + destination.payload.0[lane], + Some(if lane < count { 100 } else { 200 } + lane as u32) + ); + } + assert_eq!(destination.kind, kind); + assert_eq!(destination.name, source.name); + assert_eq!(destination.begin_time, 2.0); + assert_eq!(destination.end_time, 3.0); + assert_eq!(destination.status, 9); + assert_eq!(destination.sequence_id, 17); + } +} +#[test] +fn motion_graph_conversion_preserves_scalar_bits_without_fabricating_other_lanes() { + for bits in [0x80000000, 0x7fc12345, 0x3f800000] { + let source = MotionGraphAttribute { + name: AttributeName([10, 20, 30, 40, 50]), + value: f32::from_bits(bits), + }; + let result = source.to_animation(); + assert_eq!(result.payload.0, [Some(bits), None, None, None, None, None]); + assert_eq!(result.name, source.name); + assert_eq!((result.begin_time, result.end_time), (-1.0, -1.0)); + assert_eq!((result.status, result.kind, result.sequence_id), (6, 0, -1)); + } +} +#[test] +fn merge_resets_active_length_preserves_duplicates_and_keeps_mg_before_tree() { + let name = AttributeName([1, 2, 3, 4, 5]); + let mg = [ + MotionGraphAttribute { name, value: 0.0 }, + MotionGraphAttribute { name, value: 2.0 }, + ]; + let mut tree = [attribute(0, 7), attribute(1, 8)]; + tree[0].name = name; + let mut list = PacketAttributes::default(); + list.append(&attribute(3, 500)); + list.replace_from(&mg, &tree); + assert_eq!(list.entries().len(), 4); + assert_eq!(list.entries()[0].name, name); + assert_eq!(list.entries()[1].name, name); + assert_eq!(list.entries()[2].name, name); + assert_eq!(list.entries()[0].payload.0[0], Some(0)); + assert_eq!(list.entries()[1].payload.0[0], Some(2.0f32.to_bits())); + assert_eq!(list.entries()[2].payload.0[0], Some(7)); + assert_eq!(list.entries()[3].kind, 1); + list.replace_from(&[], &[]); + assert!(list.entries().is_empty()); + list.append(&attribute(0, 900)); + assert_eq!( + list.entries()[0].payload.0, + [ + Some(900), + Some(501), + Some(502), + Some(503), + Some(504), + Some(505) + ] + ); +} +fn motion(seed: u32) -> MotionGraphPacket { + MotionGraphPacket { + trick: TrickData { + identifiers: [TrickIdentifier([seed; 6]), TrickIdentifier([seed + 1; 6])], + word48: seed + 2, + scalars52_56: [f32::from_bits(0x80000000), f32::from_bits(0x7fc12345)], + }, + gesture: GestureData { + gesture: seed + 3, + flag_bytes: [1, 2, 0xaa, 0xbb], + }, + wipeout_gesture: [4.0, -5.0], + } +} +#[test] +fn concrete_motion_graph_publication_preserves_source_including_gesture_and_trick() { + let source = motion(10); + let mut destination = motion(90); + motion_graph_packet::publish(&source, &mut destination); + assert_eq!(destination.trick.identifiers, source.trick.identifiers); + assert_eq!(destination.trick.word48, 12); + assert_eq!( + destination.trick.scalars52_56.map(f32::to_bits), + [0x80000000, 0x7fc12345] + ); + assert_eq!(destination.gesture, source.gesture); + assert_eq!(destination.wipeout_gesture, [4.0, -5.0]); + assert_eq!(source.gesture.gesture, 13); + assert_eq!(source.wipeout_gesture, [4.0, -5.0]); + destination.gesture.gesture = 37; + motion_graph_packet::publish(&source, &mut destination); + assert_eq!(destination.gesture.gesture, 13); +} diff --git a/crates/skate-core/tests/animation_packet_reset.rs b/crates/skate-core/tests/animation_packet_reset.rs new file mode 100644 index 00000000..52127d0e --- /dev/null +++ b/crates/skate-core/tests/animation_packet_reset.rs @@ -0,0 +1,138 @@ +use skate_core::animation::{ + commands::buffers::BufferError, + output::{ + NativeMatrix, + packet_reset::{self, AdditionalResetFields}, + physics_packet::PhysicsPosePacket, + }, +}; +fn filled(value: f32) -> NativeMatrix { + [[value; 4]; 4] +} +fn packet(count: u32) -> PhysicsPosePacket { + PhysicsPosePacket { + bone_count: count, + hierarchy: vec![filled(7.0); 3], + local: vec![filled(8.0); 3], + timestep: 99.0, + foot_surface_ids: [123, 456], + flags: u32::MAX, + board_flipped: true, + mirrored: true, + riding_switch: true, + riding_fakie: true, + weight_forwards: true, + regular_stance: true, + air_dismount_revert_frames: 100, + } +} +fn fields() -> AdditionalResetFields { + AdditionalResetFields { + compression: 9.0, + foot_ik_influence: [4.0, 5.0], + next_step_position_valid: true, + actor_flag_1904_bit23: true, + actor_flag_1908_bit2: true, + external_impulse_active: true, + external_physics_input_active: true, + externally_controlled: true, + prevent_manual_respawn: true, + ignore_respawn_reset_button: 0xff, + force_braking: true, + truck_tightness: 4.0, + wheel_hardness: 5.0, + auxiliary_vectors: [[f32::NAN; 4]; 6], + requested_physics_mode: 4, + } +} +#[test] +fn reset_clears_all_owned_fields_and_uses_packed_basis_with_zero_translation_w() { + let mut packet = packet(2); + let mut fields = fields(); + let hierarchy_allocation = packet.hierarchy.as_ptr(); + let local_allocation = packet.local.as_ptr(); + packet_reset::reset(&mut packet, &mut fields).unwrap(); + let expected = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0; 4], + ]; + assert_eq!(&packet.local[..2], [expected, expected]); + assert_eq!(&packet.hierarchy[..2], [expected, expected]); + assert_eq!(packet.local[2], filled(8.0)); + assert_eq!(packet.hierarchy[2], filled(7.0)); + assert_eq!(packet.hierarchy.as_ptr(), hierarchy_allocation); + assert_eq!(packet.local.as_ptr(), local_allocation); + assert_eq!(packet.bone_count, 2); + assert_eq!(packet.timestep.to_bits(), 0x3c888889); + assert_eq!(packet.flags, 0x013fffff); + assert_eq!(packet.foot_surface_ids, [0, 0]); + assert_eq!(packet.air_dismount_revert_frames, 0); + assert!( + !packet.board_flipped + && !packet.mirrored + && !packet.riding_switch + && !packet.riding_fakie + && !packet.weight_forwards + && !packet.regular_stance + ); + assert_eq!(fields.compression, 0.5); + assert_eq!(fields.foot_ik_influence, [0.0, 0.0]); + assert_eq!(fields.truck_tightness, 0.0); + assert_eq!(fields.wheel_hardness, 0.0); + assert_eq!(fields.ignore_respawn_reset_button, 0); + assert_eq!(fields.requested_physics_mode, 1); + assert!( + !fields.next_step_position_valid + && !fields.actor_flag_1904_bit23 + && !fields.actor_flag_1908_bit2 + && !fields.external_impulse_active + && !fields.external_physics_input_active + && !fields.externally_controlled + && !fields.prevent_manual_respawn + && !fields.force_braking + ); + for vector in fields.auxiliary_vectors { + assert_eq!(vector.map(f32::to_bits), [0; 4]); + } +} +#[test] +fn reset_preserves_every_flag_outside_the_recovered_clear_mask() { + for bit in 0..32 { + let mut packet = packet(0); + let mut fields = fields(); + packet.flags = 1 << bit; + packet_reset::reset(&mut packet, &mut fields).unwrap(); + let preserved = bit <= 21 || bit == 24; + assert_eq!( + packet.flags, + if preserved { 1 << bit } else { 0 }, + "bit {bit}" + ); + } +} +#[test] +fn zero_and_signed_negative_counts_skip_matrices_but_still_reset_scalars() { + for count in [0, 0x80000000, u32::MAX] { + let mut packet = packet(count); + let mut fields = fields(); + packet_reset::reset(&mut packet, &mut fields).unwrap(); + assert_eq!(packet.bone_count, count); + assert_eq!(packet.local, vec![filled(8.0); 3]); + assert_eq!(fields.compression, 0.5); + assert_eq!(packet.flags, 0x013fffff); + } +} +#[test] +fn short_allocation_is_rejected_without_partial_reset() { + let mut packet = packet(4); + let mut fields = fields(); + assert_eq!( + packet_reset::reset(&mut packet, &mut fields), + Err(BufferError::RangeOutsideAllocation) + ); + assert_eq!(packet.flags, u32::MAX); + assert_eq!(fields.requested_physics_mode, 4); + assert_eq!(packet.local, vec![filled(8.0); 3]); +} diff --git a/crates/skate-core/tests/animation_physics_publication.rs b/crates/skate-core/tests/animation_physics_publication.rs new file mode 100644 index 00000000..ab95c171 --- /dev/null +++ b/crates/skate-core/tests/animation_physics_publication.rs @@ -0,0 +1,219 @@ +use skate_core::animation::output::physics_packet; +use skate_core::animation::output::physics_packet::{ + ActorPoseBuffers, AnimationSignal, PhysicsPosePacket, SkaterPublicationState, +}; +use skate_core::animation::{ + commands::{ + batch::SetDataQueue, + buffers::{BufferError, PoseBuffers}, + }, + output::NativeMatrix, +}; +use std::num::NonZeroU16; +fn matrix(value: f32) -> NativeMatrix { + [[value; 4]; 4] +} +fn state() -> SkaterPublicationState { + SkaterPublicationState { + orientation_bit31: false, + mirrored: false, + riding_fakie: false, + weight_on_nose: false, + relative_stance: 0, + natural_stance: 0, + request_bit16: true, + request_bit15: true, + air_dismount_revert_requested: true, + air_dismount_revert_frames: 17, + signal: Some(AnimationSignal { + name_hash: 0, + active: 255, + }), + } +} +fn packet() -> PhysicsPosePacket { + PhysicsPosePacket { + bone_count: 2, + hierarchy: vec![matrix(-1.0); 3], + local: vec![matrix(-2.0); 3], + timestep: 0.0, + foot_surface_ids: [35, 46], + flags: u32::MAX, + board_flipped: false, + mirrored: false, + riding_switch: false, + riding_fakie: false, + weight_forwards: false, + regular_stance: false, + air_dismount_revert_frames: 0, + } +} +#[test] +fn publication_copies_separate_arrays_consumes_latches_and_preserves_unrelated_flags() { + let mut buffers = PoseBuffers::default(); + let hierarchy = buffers.matrices.initialized([matrix(1.0), matrix(2.0)]); + let local = buffers.matrices.initialized([matrix(3.0), matrix(4.0)]); + let completed = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let poses = ActorPoseBuffers { + hierarchy: hierarchy.at(0), + local: local.at(0), + }; + let mut state = state(); + let mut packet = packet(); + assert_eq!( + physics_packet::publish( + &mut state, + &completed, + &poses, + &mut packet, + 1.0 / 60.0, + b"signup" + ), + Ok(17) + ); + assert_eq!(packet.hierarchy, [matrix(1.0), matrix(2.0), matrix(-1.0)]); + assert_eq!(packet.local, [matrix(3.0), matrix(4.0), matrix(-2.0)]); + assert_eq!(packet.flags, u32::MAX & !((1 << 25) | (1 << 26))); + assert_eq!(packet.foot_surface_ids, [0, 0]); + assert_eq!(packet.timestep.to_bits(), (1.0f32 / 60.0).to_bits()); + assert!(!state.request_bit16 && !state.request_bit15 && !state.air_dismount_revert_requested); + assert_eq!(state.signal.as_ref().unwrap().active, 0); + // ASCII shift/add evaluated independently: no overflow fold for six bytes. + assert_eq!(state.signal.as_ref().unwrap().name_hash, 127_919_552); + packet.hierarchy[0] = matrix(100.0); + assert_eq!( + completed + .buffers() + .matrices + .read(hierarchy.at(0), 1) + .unwrap(), + [matrix(1.0)] + ); + physics_packet::publish(&mut state, &completed, &poses, &mut packet, 0.25, b"signup").unwrap(); + assert_eq!(packet.flags & ((1 << 27) | (1 << 23) | (1 << 28)), 0); + assert_eq!(packet.air_dismount_revert_frames, 17); + assert_eq!(state.air_dismount_revert_frames, 17); +} +#[test] +fn concrete_stance_truth_table_keeps_natural_relative_and_fakie_distinct() { + let mut scratch = PoseBuffers::default(); + let empty = scratch.matrices.allocate(0); + let completed = SetDataQueue::new(scratch, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let poses = ActorPoseBuffers { + hierarchy: empty.at(0), + local: empty.at(0), + }; + for reversed in [false, true] { + for mirrored in [false, true] { + for fakie in [false, true] { + for nose in [false, true] { + let mut state = state(); + state.orientation_bit31 = reversed; + state.mirrored = mirrored; + state.riding_fakie = fakie; + state.weight_on_nose = nose; + state.relative_stance = 1; + state.natural_stance = 2; + state.signal = None; + let mut packet = packet(); + packet.bone_count = 0; + physics_packet::publish(&mut state, &completed, &poses, &mut packet, 0.0, b"") + .unwrap(); + assert_eq!(packet.board_flipped, reversed != mirrored); + assert_eq!(packet.mirrored, mirrored); + assert_eq!(packet.riding_fakie, fakie); + assert_eq!(packet.weight_forwards, fakie != nose); + assert!(packet.riding_switch); + assert!(!packet.regular_stance); + } + } + } + } +} +#[test] +fn unsafe_source_extent_is_rejected_before_consuming_requests() { + let mut buffers = PoseBuffers::default(); + let pose = buffers.matrices.allocate(2); + let completed = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let mut state = state(); + let mut packet = packet(); + let poses = ActorPoseBuffers { + hierarchy: pose.at(0), + local: pose.at(0), + }; + assert_eq!( + physics_packet::publish(&mut state, &completed, &poses, &mut packet, 0.1, b"signup"), + Err(BufferError::UninitializedBone { bone: 0 }) + ); + assert!(state.request_bit16 && state.request_bit15 && state.air_dismount_revert_requested); + assert_eq!(packet.foot_surface_ids, [35, 46]); +} +#[test] +fn signal_hash_uses_signed_bytes_nul_termination_and_high_nibble_fold() { + assert_eq!(physics_packet::signal_hash(b""), 0); + assert_eq!(physics_packet::signal_hash(b"a\0ignored"), 97); + assert_eq!(physics_packet::signal_hash(&[0xff]), 0x0fff_fe1f); + assert_eq!(physics_packet::signal_hash(&[0x80]), 0x0fff_fe60); +} + +#[test] +fn completed_setdata_exports_feed_physics_publication() { + use skate_core::animation::commands::set_data::{CopyCommand, InternalUpdate, SetDataCommand}; + let mut buffers = PoseBuffers::default(); + let hierarchy_input = buffers.matrices.initialized([matrix(12.0), matrix(13.0)]); + let local_input = buffers.matrices.initialized([matrix(22.0), matrix(23.0)]); + let hierarchy_actor = buffers.matrices.allocate(2); + let local_actor = buffers.matrices.allocate(2); + let mut queue = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: hierarchy_input.at(0), + external: Some(hierarchy_actor.at(0)), + }), + &[], + ) + .unwrap(); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: local_input.at(0), + external: Some(local_actor.at(0)), + }), + &[], + ) + .unwrap(); + let completed = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let poses = ActorPoseBuffers { + hierarchy: hierarchy_actor.at(0), + local: local_actor.at(0), + }; + let mut packet = packet(); + physics_packet::publish( + &mut state(), + &completed, + &poses, + &mut packet, + 1.0 / 60.0, + b"signup", + ) + .unwrap(); + assert_eq!(&packet.hierarchy[..2], [matrix(12.0), matrix(13.0)]); + assert_eq!(&packet.local[..2], [matrix(22.0), matrix(23.0)]); +} diff --git a/crates/skate-core/tests/animation_skeleton_attributes.rs b/crates/skate-core/tests/animation_skeleton_attributes.rs new file mode 100644 index 00000000..c01e790d --- /dev/null +++ b/crates/skate-core/tests/animation_skeleton_attributes.rs @@ -0,0 +1,237 @@ +use skate_core::animation::{ + output::attributes::{AnimationAttribute, AttributeName, AttributePayload}, + skeleton_input::{ + catalog::{self, EVENT_COMPARISONS, SCALAR_ATTRIBUTES}, + name::encode, + scalar_attributes::{self, AnimationControlOutput, DispatchError, ScalarAttributeInputs}, + }, +}; + +fn input() -> ScalarAttributeInputs { + ScalarAttributeInputs { + flags2468: 0x1000, + flags2472: 0x1000, + flags2476: 0x1000, + flags2484: 0x1000, + flags2488: 0x1000, + board_adjust: 999, + balance: 2.0, + spin: 3.0, + body_spin: 4.0, + brake: 5.0, + turn: 6.0, + turn_scale: 7.0, + magnitude_scale: 8.0, + animation_end_com: [1.0, 2.0, 3.0, -0.0], + animation_translation: [4.0, 5.0, 6.0, 9.0], + animation_time: 10.0, + animation_physics_blend_seconds: 11.0, + cadence_end_percent: 12.0, + raw_turn: 13.0, + hard_turn: 14.0, + slide: 15.0, + } +} +fn output() -> AnimationControlOutput { + AnimationControlOutput { + grind_name: AttributeName([99; 5]), + flags: 0x1000, + } +} +fn attribute(name: &[u8], value: Option) -> AnimationAttribute { + AnimationAttribute { + name: encode(name), + payload: AttributePayload([value.map(f32::to_bits), None, None, None, None, None]), + begin_time: 123.0, + end_time: -99.0, + status: 0, + kind: 0, + sequence_id: 5, + } +} +fn apply( + name: &[u8], + value: Option, + input: &mut ScalarAttributeInputs, + output: &mut AnimationControlOutput, +) { + scalar_attributes::dispatch_attribute(&attribute(name, value), input, output).unwrap(); +} + +#[test] +fn native_name_encoding_is_base38_case_insensitive_chunked_and_nul_terminated() { + assert_eq!(encode(b"A").0, [11 * 79_235_168, 0, 0, 0, 0]); + assert_eq!( + encode(b"a0_zZz").0[0], + 11 * 79_235_168 + 2_085_136 + 37 * 54_872 + 36 * 1_444 + 36 * 38 + 36 + ); + assert_eq!(encode(b"ABCDEFg").0[1], 17 * 79_235_168); + assert_eq!(encode(b"Turn"), encode(b"tUrN")); + assert_eq!(encode(b"Turn\0Brake"), encode(b"Turn")); + assert_eq!( + encode(b"123456123456123456123456123456ignored"), + encode(b"123456123456123456123456123456") + ); + assert_eq!( + encode(&[255]).0[0], + (-48i32 as u32).wrapping_mul(79_235_168) + ); + assert_ne!(encode(b"push_contact"), encode(b"PushContact")); +} + +#[test] +fn complete_dispatch_identity_catalog_retains_direct_and_event_comparisons() { + assert_eq!(SCALAR_ATTRIBUTES.len(), 151); + assert_eq!(EVENT_COMPARISONS.len(), 5); + assert!( + SCALAR_ATTRIBUTES + .windows(2) + .all(|pair| pair[0].comparison_site < pair[1].comparison_site) + ); + assert_eq!( + catalog::lookup(encode(b"Spin")).unwrap().comparison_site, + 0x82bdb004 + ); + assert_eq!( + catalog::lookup(encode(b"IsWipeoutPushOffImpulse")) + .unwrap() + .comparison_site, + 0x82bdb148 + ); + assert_eq!( + catalog::lookup(encode(b"WantsLeftAirGrab")) + .unwrap() + .comparison_site, + 0x82bdb5fc + ); + assert_eq!( + catalog::lookup(encode(b"WantsRightAirGrab")) + .unwrap() + .comparison_site, + 0x82bdb614 + ); + assert_eq!( + catalog::lookup(encode(b"jump")).unwrap().comparison_site, + 0x82bdb7e8 + ); + assert!(catalog::lookup(encode(b"push_contact")).is_none()); +} + +#[test] +fn steering_and_push_markers_write_their_distinct_consumed_fields_without_thresholds() { + let mut i = input(); + let mut o = output(); + for name in [ + b"NoInput".as_slice(), + b"NewAutoPump", + b"ManualBrake", + b"Carving", + b"PushContact", + b"PushSpeed", + ] { + apply(name, None, &mut i, &mut o); + } + apply(b"Brake", Some(-2.5), &mut i, &mut o); + apply(b"Turn", Some(0.25), &mut i, &mut o); + apply(b"Spin", Some(-0.75), &mut i, &mut o); + assert_eq!((i.brake, i.turn, i.spin), (-2.5, -0.25, 0.75)); + assert_eq!(i.flags2468, 0xa0001000); + assert_eq!(i.flags2472, 0x00801000); + assert_eq!(i.flags2488, 0x20801000); + assert_eq!(i.flags2476, 0x1000); + assert_eq!(o.flags, 0x60001000); + assert_eq!(i.balance, 2.0); + // A false/zero scalar is still a presence marker in these native branches. + apply(b"PushContact", Some(0.0), &mut i, &mut o); + assert_eq!(i.flags2488, 0x20801000); +} + +#[test] +fn repeated_attributes_preserve_list_order_and_do_not_clear_unrelated_state() { + let mut i = input(); + let mut o = output(); + apply(b"SlideBoard", None, &mut i, &mut o); + apply(b"GrindNose", None, &mut i, &mut o); + apply(b"GrindFacingForwards", None, &mut i, &mut o); + apply(b"GrindFacingBackwards", None, &mut i, &mut o); + apply(b"BoardAdjustLeft", None, &mut i, &mut o); + apply(b"BoardAdjustDown", None, &mut i, &mut o); + apply(b"Turn", Some(1.0), &mut i, &mut o); + apply(b"TURN", Some(-0.0), &mut i, &mut o); + assert_eq!(o.grind_name, encode(b"GrindNose")); + assert_eq!(o.flags, 0x1000); + assert_eq!(i.board_adjust, 4); + assert_eq!(i.turn.to_bits(), 0); + assert_eq!(i.brake, 5.0); + assert_eq!(i.flags2468, 0x1000); +} + +#[test] +fn com_translation_and_scale_handlers_preserve_other_lanes_and_float_signs() { + let mut i = input(); + let mut o = output(); + apply(b"AnimEndCOMY", Some(-7.0), &mut i, &mut o); + apply(b"AnimTransZ", Some(8.0), &mut i, &mut o); + apply(b"TurnScale", Some(0.0), &mut i, &mut o); + apply(b"MagScale", Some(-3.0), &mut i, &mut o); + apply(b"RawTurn", Some(f32::from_bits(0x7fc12345)), &mut i, &mut o); + assert_eq!(i.animation_end_com[..3], [1.0, -7.0, 3.0]); + assert_eq!(i.animation_end_com[3].to_bits(), 0x80000000); + assert_eq!(i.animation_translation, [4.0, 5.0, 8.0, 9.0]); + assert_eq!((i.turn_scale, i.magnitude_scale), (0.0, -3.0)); + assert_eq!(i.raw_turn.to_bits(), 0xffc12345); +} + +#[test] +fn scalar_kind_gate_ignores_timing_status_but_never_treats_active_events_as_scalars() { + let mut i = input(); + let mut o = output(); + let original = i; + for kind in [1, 2, 127, 128, 255] { + let mut a = attribute(b"Turn", Some(0.75)); + a.kind = kind; + a.status = 255; + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o).unwrap(); + assert_eq!(i, original); + } + for status in [0, 1, 2, 16, 128] { + let mut a = attribute(b"Turn", None); + a.kind = 3; + a.status = status; + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o).unwrap(); + assert_eq!(i, original); + } + for status in [4, 8, 12, 255] { + let mut a = attribute(b"unknown-event", None); + a.kind = 3; + a.status = status; + assert!(matches!( + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o), + Err(DispatchError::EventConsumerUnavailable { .. }) + )); + } + let mut a = attribute(b"Turn", Some(2.0)); + a.status = 255; + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o).unwrap(); + assert_eq!(i.turn, -2.0); +} + +#[test] +fn unported_known_handlers_and_uninitialized_scalars_fail_without_writes() { + let mut i = input(); + let mut o = output(); + let original = i; + let original_o = o; + assert!( + matches!(scalar_attributes::dispatch_attribute(&attribute(b"MinJump", Some(0.8)), &mut i, &mut o), + Err(DispatchError::KnownScalarUnavailable { attribute }) if attribute.comparison_site == 0x82bdb71c) + ); + assert!(matches!( + scalar_attributes::dispatch_attribute(&attribute(b"Turn", None), &mut i, &mut o), + Err(DispatchError::UninitializedScalar { .. }) + )); + scalar_attributes::dispatch_attribute(&attribute(b"unknown-name", None), &mut i, &mut o) + .unwrap(); + assert_eq!(i, original); + assert_eq!(o, original_o); +} diff --git a/crates/skate-core/tests/constraint_batch.rs b/crates/skate-core/tests/constraint_batch.rs new file mode 100644 index 00000000..509a7fd9 --- /dev/null +++ b/crates/skate-core/tests/constraint_batch.rs @@ -0,0 +1,35 @@ +use skate_core::physics::constraint_batch::compile_active; + +#[test] +fn inactive_constraints_never_reach_compilation_and_survivors_keep_registration_order() { + let records = [ + (0, [0, 8]), + (1, [4, 0]), + (2, [1, 2]), + (3, [0, 4]), + (4, [12, 4]), + ]; + let mut called = Vec::new(); + let batch = compile_active( + records, + |record| record.1, + |(id, _)| { + assert!(![0, 2].contains(&id), "inactive row reached compiler"); + called.push(id); + id * 10 + }, + ); + assert_eq!(called, [1, 3, 4]); + assert_eq!(batch, [10, 30, 40]); +} + +#[test] +fn each_batch_recomputes_eligibility_after_endpoint_state_changes() { + let mut states = [4, 0]; + assert_eq!(compile_active([7], |_| states, |id| id), [7]); + states[0] = 8; + let sleeping: Vec = compile_active([7], |_| states, |_| panic!("sleeping row compiled")); + assert!(sleeping.is_empty()); + states[1] = 4; + assert_eq!(compile_active([7], |_| states, |id| id), [7]); +} diff --git a/crates/skate-core/tests/contact_iteration.rs b/crates/skate-core/tests/contact_iteration.rs new file mode 100644 index 00000000..33498969 --- /dev/null +++ b/crates/skate-core/tests/contact_iteration.rs @@ -0,0 +1,136 @@ +//! Independent analytical cases for compiled rows. These exercise the solver +//! without pretending the still-unavailable contact builder has been validated. +use skate_core::physics::solver::packed::{self, Contact, Reaction}; + +fn row(words: &mut [u32], index: usize, value: [f32; 4]) { + for (i, value) in value.into_iter().enumerate() { + words[index * 4 + i] = value.to_bits(); + } +} +fn read(words: &[u32], index: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(words[index * 4 + i])) +} +fn contact(target: [f32; 4], inverse_mass: f32) -> Contact { + let mut words = [0; 64]; + row(&mut words, 2, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 3, [0.0, 1.0, 0.0, 0.5]); + row(&mut words, 4, [0.0, 0.0, 1.0, 0.25]); + row(&mut words, 6, target); + row(&mut words, 7, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 10, [0.0, 1.0, 0.0, 0.0]); + row(&mut words, 13, [0.0, 0.0, 1.0, 0.0]); + row(&mut words, 8, [0.0, 0.0, 0.0, inverse_mass]); + Contact { + words, + reaction_a: 0, + reaction_b: 1, + } +} +fn once(contact: &mut Contact, reactions: &mut [Reaction; 2]) { + packed::solve( + core::slice::from_mut(contact), + &mut [], + &mut [], + reactions, + 1, + ); +} + +#[test] +fn first_normal_impulse_does_not_enable_friction_in_the_same_pass() { + let mut contact = contact([2.0, 1.0, -1.0, 0.5], 1.0); + let mut reactions = [[0; 16]; 2]; + once(&mut contact, &mut reactions); + assert_eq!(read(&contact.words, 5), [2.0, 0.0, 0.0, 0.5]); + assert_eq!(&read(&reactions[0], 0)[..3], &[2.0, 0.0, 0.0]); + assert_eq!(&read(&reactions[0], 1)[..3], &[0.5, 0.0, 0.0]); + assert_eq!(reactions[1], [0; 16]); +} + +#[test] +fn static_boundary_is_inclusive_but_sliding_uses_dynamic_limit() { + for (target, expected) in [(1.0, 1.0), (-1.0, -1.0), (1.125, 0.5), (-1.125, -0.5)] { + let mut contact = contact([0.0, target, 0.0, 0.0], 0.0); + row(&mut contact.words, 5, [2.0, 0.0, 0.0, 0.0]); + once(&mut contact, &mut [[0; 16]; 2]); + assert_eq!(read(&contact.words, 5), [2.0, expected, 0.0, 0.0]); + } +} + +#[test] +fn angular_motion_at_contact_feeds_the_normal_row() { + let mut contact = contact([0.0; 4], 0.0); + row(&mut contact.words, 0, [0.0, 1.0, 0.0, 0.0]); + row(&mut contact.words, 8, [0.0, 0.0, -1.0, 0.0]); + let mut reactions = [[0; 16]; 2]; + row(&mut reactions[0], 2, [0.0, 0.0, 2.0, 0.0]); + once(&mut contact, &mut reactions); + assert_eq!(read(&contact.words, 5), [2.0, 0.0, 0.0, 0.0]); + assert_eq!(read(&reactions[0], 2), [0.0; 4]); +} + +#[test] +fn later_contact_reads_the_reaction_from_the_previous_contact() { + let mut contacts = [ + contact([2.0, 0.0, 0.0, 0.0], 1.0), + contact([3.0, 0.0, 0.0, 0.0], 1.0), + ]; + let mut reactions = [[0; 16]; 2]; + packed::solve(&mut contacts, &mut [], &mut [], &mut reactions, 1); + assert_eq!(read(&contacts[0].words, 5)[0], 2.0); + assert_eq!(read(&contacts[1].words, 5)[0], 1.0); + assert_eq!(read(&reactions[0], 0)[0], 3.0); +} + +#[test] +fn body_b_receives_opposite_linear_and_angular_changes() { + let mut contact = contact([2.0, 0.0, 0.0, 0.5], 1.0); + row(&mut contact.words, 9, [0.0, 0.0, 3.0, 0.25]); + let mut reactions = [[0; 16]; 2]; + once(&mut contact, &mut reactions); + assert_eq!(&read(&reactions[1], 0)[..3], &[-0.5, 0.0, 0.0]); + assert_eq!(&read(&reactions[1], 1)[..3], &[-0.125, 0.0, 0.0]); + assert_eq!(&read(&reactions[1], 2)[..3], &[0.0, 0.0, -6.0]); + assert_eq!(&read(&reactions[1], 3)[..3], &[0.0, 0.0, -1.5]); +} + +#[test] +fn ground_contact_reaches_joint_then_drive_in_one_iteration() { + // Three equal-mass bodies at their centers. Ground first gives body0 a + // correction of2. The joint equalizes0/1 to1; the drive then equalizes1/2 + // to0.5. Solving families from independent snapshots cannot produce this. + let mut ground = contact([2.0, 0.0, 0.0, 0.0], 1.0); + ground.reaction_b = 3; + let mut words = [0; 96]; + row(&mut words, 4, [0.5, 0.0, 0.0, 0.0]); + row(&mut words, 12, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 18, [0.0, 0.0, 0.0, 1.0]); + row(&mut words, 21, [0.0, 0.0, 0.0, 1.0]); + let mut joint = packed::Joint { + words, + reaction_a: 0, + reaction_b: 1, + }; + row(&mut words, 2, [0.0, 0.0, 0.0, 1.0]); + words[6 * 4 + 3] = 10.0f32.to_bits(); + let mut drive = packed::Drive { + words, + reaction_a: 1, + reaction_b: 2, + }; + let mut reactions = [[0; 16]; 4]; + packed::solve( + core::slice::from_mut(&mut ground), + core::slice::from_mut(&mut joint), + core::slice::from_mut(&mut drive), + &mut reactions, + 1, + ); + assert_eq!( + reactions.map(|r| f32::from_bits(r[0])), + [1.0, 0.5, 0.5, 0.0] + ); + assert_eq!(read(&ground.words, 5)[0], 2.0); + assert_eq!(read(&joint.words, 2)[0], -1.0); + assert_eq!(read(&drive.words, 2)[0], -0.5); +} diff --git a/crates/skate-core/tests/physics_rebuild_authored_transforms.rs b/crates/skate-core/tests/physics_rebuild_authored_transforms.rs new file mode 100644 index 00000000..566535de --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_authored_transforms.rs @@ -0,0 +1,128 @@ +//! Analytic geometry checks using real stock inputs and explicit alternatives. +//! No generated-code outputs or live/settled-pose fixtures are used. +use skate_core::{ + math::Vector3, + physics::drive_frames::{ + AuthoredTransformInputs, RetailAffineTransform, authored_body_pose_records, + authored_body_transforms, default_body_transforms, + }, +}; + +#[test] +fn stock_dimensions_place_all_seven_authored_parts() { + let parts = default_body_transforms(); + // Decoded XML dimensions: mid length .59, wheel offset .095, + // truck longitudinal correction -.052, vertical position -.0565. + let expected = [ + [-0.095, -0.0565, 0.243], + [0.095, -0.0565, 0.243], + [-0.095, -0.0565, -0.243], + [0.095, -0.0565, -0.243], + [0.0, -0.0565, 0.243], + [0.0, -0.0565, -0.243], + [0.0; 3], + ]; + for (part, expected) in parts.iter().zip(expected) { + for (actual, expected) in [part.translation.x, part.translation.y, part.translation.z] + .into_iter() + .zip(expected) + { + assert!((actual - expected).abs() < 0.00000004); + } + } + for part in &parts[..5] { + assert_eq!(part.basis, RetailAffineTransform::IDENTITY.basis); + } + assert_eq!(parts[6], RetailAffineTransform::IDENTITY); +} + +#[test] +fn asymmetric_inputs_keep_native_front_back_and_wheel_signs() { + let parts = authored_body_transforms(AuthoredTransformInputs { + deck_mid_length: 8.0, + wheel_x_distance: 0.5, + truck_z_position_front: -1.0, + truck_z_position_back: -2.0, + truck_y_position: -0.25, + }); + assert_eq!( + parts.map(|p| p.translation), + [ + Vector3::new(-0.5, -0.25, 3.0), + Vector3::new(0.5, -0.25, 3.0), + Vector3::new(-0.5, -0.25, -2.0), + Vector3::new(0.5, -0.25, -2.0), + Vector3::new(0.0, -0.25, 3.0), + Vector3::new(0.0, -0.25, -2.0), + Vector3::ZERO, + ] + ); +} + +#[test] +fn negative_offsets_are_not_clamped_or_reordered() { + let parts = authored_body_transforms(AuthoredTransformInputs { + deck_mid_length: 2.0, + wheel_x_distance: -0.5, + truck_z_position_front: -3.0, + truck_z_position_back: -4.0, + truck_y_position: 0.25, + }); + assert_eq!(parts[0].translation, Vector3::new(0.5, 0.25, -2.0)); + assert_eq!(parts[1].translation, Vector3::new(-0.5, 0.25, -2.0)); + assert_eq!(parts[2].translation, Vector3::new(0.5, 0.25, 3.0)); + assert_eq!(parts[3].translation, Vector3::new(-0.5, 0.25, 3.0)); +} + +#[test] +fn rear_truck_keeps_quarter_turn_polynomial_residual() { + let basis = default_body_transforms()[5].basis.columns; + // Analytic Y quarter-turn axes: -Z, +Y, +X. The literal angle is finite + // pi/2 and the recovered polynomial retains a nonzero cosine residual. + assert_eq!(basis[1], [0.0, 1.0, 0.0]); + assert!(basis[0][0].abs() < 0.000001); + assert_ne!(basis[0][0], 0.0); + assert_eq!(basis[0][0].to_bits(), basis[2][2].to_bits()); + assert_eq!(basis[0][2].to_bits(), (-basis[2][0]).to_bits()); + assert!((basis[0][2] + 1.0).abs() < 0.000001); + assert!((basis[2][0] - 1.0).abs() < 0.000001); + assert_eq!(basis[0][1], 0.0); + assert_eq!(basis[2][1], 0.0); +} + +#[test] +fn reset_records_preserve_authored_axes_and_zero_carry_lanes() { + let records = authored_body_pose_records(AuthoredTransformInputs::STOCK); + let parts = default_body_transforms(); + for (record, part) in records.into_iter().zip(parts) { + assert_eq!([record[3], record[7], record[11], record[15]], [0; 4]); + for axis in 0..3 { + assert_eq!( + [record[axis * 4], record[axis * 4 + 1], record[axis * 4 + 2]], + part.basis.columns[axis].map(f32::to_bits) + ); + } + assert_eq!( + [record[12], record[13], record[14]], + [ + part.translation.x.to_bits(), + part.translation.y.to_bits(), + part.translation.z.to_bits() + ] + ); + } +} + +#[test] +fn dimensions_do_not_change_authored_body_orientations() { + let stock = default_body_transforms(); + let alternate = authored_body_transforms(AuthoredTransformInputs { + deck_mid_length: 1.25, + wheel_x_distance: 0.25, + truck_z_position_front: 0.0, + truck_z_position_back: 0.0, + truck_y_position: -0.125, + }); + assert_eq!(stock.map(|p| p.basis), alternate.map(|p| p.basis)); + assert_ne!(stock[0].translation, alternate[0].translation); +} diff --git a/crates/skate-core/tests/physics_rebuild_constraints.rs b/crates/skate-core/tests/physics_rebuild_constraints.rs new file mode 100644 index 00000000..2cfc0ca7 --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_constraints.rs @@ -0,0 +1,436 @@ +//! Independently specified mechanics and packed-boundary checks. These rows +//! are explicit test inputs, not generated-code outputs or parity fixtures. +use skate_core::physics::solver::packed::{self, Constraint, Drive, Joint, Reaction}; + +fn column(words: &mut [u32], byte_offset: usize, values: [f32; 4]) { + for (slot, value) in words[byte_offset / 4..byte_offset / 4 + 4] + .iter_mut() + .zip(values) + { + *slot = value.to_bits(); + } +} + +fn read(words: &[u32], byte_offset: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(words[byte_offset / 4 + i])) +} + +// Equal unit masses/inertias with origin anchors have inverse effective mass +// one half along each axis. The six impulse axes are the Cartesian basis. +fn origin_constraint() -> Constraint<96> { + let mut words = [0; 96]; + for axis in 0..3 { + let mut basis = [0.0; 4]; + basis[axis] = 1.0; + column(&mut words, 192 + axis * 16, basis); + column(&mut words, 240 + axis * 16, basis); + let mut projection = basis; + projection[axis] = 0.5; + column(&mut words, 64 + axis * 32, projection); + column(&mut words, 80 + axis * 32, projection); + basis[3] = if axis == 0 { 1.0 } else { 0.0 }; + column(&mut words, 288 + axis * 16, basis); + column(&mut words, 336 + axis * 16, basis); + } + Constraint { + words, + reaction_a: 0, + reaction_b: 1, + } +} + +fn drive() -> Drive { + let mut drive = origin_constraint(); + drive.words[11] = 1.0f32.to_bits(); + drive.words[15] = 1.0f32.to_bits(); + drive_limits(&mut drive, [1000.0; 3], [1000.0; 3]); + drive +} + +fn drive_limits(drive: &mut Drive, linear: [f32; 3], angular: [f32; 3]) { + for (offset, value) in [ + (108, linear[0]), + (124, linear[1]), + (172, linear[2]), + (140, angular[0]), + (156, angular[1]), + (188, angular[2]), + ] { + drive.words[offset / 4] = value.to_bits(); + } +} + +fn reactions(linear_b: [f32; 3], angular_b: [f32; 3]) -> [Reaction; 2] { + let mut states = [[0; 16]; 2]; + column( + &mut states[1], + 0, + [linear_b[0], linear_b[1], linear_b[2], 0.0], + ); + column( + &mut states[1], + 32, + [angular_b[0], angular_b[1], angular_b[2], 0.0], + ); + states +} + +fn xyz(words: &[u32], offset: usize) -> [f32; 3] { + read(words, offset)[..3].try_into().unwrap() +} + +#[test] +fn joint_and_drive_equalize_origin_corrections_and_converge() { + for is_drive in [false, true] { + let mut joints: Vec = if is_drive { + vec![] + } else { + vec![origin_constraint()] + }; + let mut drives = if is_drive { vec![drive()] } else { vec![] }; + let mut states = reactions([4.0, -8.0, 12.0], [-6.0, 10.0, -14.0]); + packed::solve(&mut [], &mut joints, &mut drives, &mut states, 1); + for body in states { + assert_eq!(xyz(&body, 0), [2.0, -4.0, 6.0]); + assert_eq!(xyz(&body, 32), [-3.0, 5.0, -7.0]); + } + let first = states; + packed::solve(&mut [], &mut joints, &mut drives, &mut states, 5); + assert_eq!(states, first); + } +} + +#[test] +fn drive_applies_six_independent_symmetric_limits() { + let mut drive = drive(); + drive_limits(&mut drive, [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]); + column(&mut drive.words, 160, [8.0, -8.0, 0.25, 3.0]); + column(&mut drive.words, 176, [-9.0, 9.0, -0.75, 6.0]); + let mut states = reactions([0.0; 3], [0.0; 3]); + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(read(&drive.words, 32), [1.0, -2.0, 0.25, 1.0]); + assert_eq!(read(&drive.words, 48), [-4.0, 5.0, -0.75, 1.0]); + assert_eq!(xyz(&states[0], 0), [1.0, -2.0, 0.25]); + assert_eq!(xyz(&states[1], 0), [-1.0, 2.0, -0.25]); + assert_eq!(xyz(&states[0], 32), [-4.0, 5.0, -0.75]); + assert_eq!(xyz(&states[1], 32), [4.0, -5.0, 0.75]); +} + +#[test] +fn drive_softness_scales_accumulated_and_projected_response_before_target() { + let mut drive = drive(); + column(&mut drive.words, 32, [2.0, 0.0, 0.0, 0.5]); + column(&mut drive.words, 48, [4.0, 0.0, 0.0, 0.25]); + column(&mut drive.words, 160, [1.0, 0.0, 0.0, 1000.0]); + column(&mut drive.words, 176, [-1.0, 0.0, 0.0, 1000.0]); + let mut states = reactions([2.0, 0.0, 0.0], [8.0, 0.0, 0.0]); + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(read(&drive.words, 32), [2.5, 0.0, 0.0, 0.5]); + assert_eq!(read(&drive.words, 48), [1.0, 0.0, 0.0, 0.25]); + assert_eq!(states[0][0], 0.5f32.to_bits()); + assert_eq!(states[1][0], 1.5f32.to_bits()); + assert_eq!(states[0][8], (-3.0f32).to_bits()); + assert_eq!(states[1][8], 11.0f32.to_bits()); +} + +#[test] +fn joint_limits_remove_inside_interval_and_correct_both_outside_sides() { + let mut joint = origin_constraint(); + column(&mut joint.words, 160, [-1.0, -2.0, -3.0, -4.0]); + column(&mut joint.words, 176, [1.0, 2.0, 3.0, 4.0]); + for (offset, value) in [(108, -2.0f32), (124, -3.0), (140, 2.0), (156, 3.0)] { + joint.words[offset / 4] = value.to_bits(); + } + let mut states = reactions([6.0, -6.0, 0.0], [8.0, -8.0, 0.0]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [2.0, -1.0, 0.0]); + assert_eq!(xyz(&joint.words, 48), [2.0, -1.0, 0.0]); + + for direction in [-1.0, 1.0] { + let mut joint = joint.clone(); + column(&mut joint.words, 32, [0.0; 4]); + column(&mut joint.words, 48, [0.0; 4]); + let mut states = reactions( + [direction * 2.0, direction * 4.0, direction * 6.0], + [0.0; 3], + ); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [0.0; 3]); + } +} + +#[test] +fn off_center_linear_and_angular_candidates_share_incoming_reactions() { + let mut joint = origin_constraint(); + column(&mut joint.words, 0, [0.0, 1.0, 0.0, 0.0]); + column(&mut joint.words, 16, [0.0, -2.0, 0.0, 0.0]); + joint.words[64 / 4] = 0.125f32.to_bits(); + joint.words[348 / 4] = 2.0f32.to_bits(); + let mut states = reactions([0.0; 3], [0.0, 0.0, -1.0]); + states[0][10] = 1.0f32.to_bits(); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [-0.125, 0.0, 0.0]); + assert_eq!(xyz(&joint.words, 48), [0.0, 0.0, -1.0]); + assert_eq!(xyz(&states[0], 0), [-0.125, 0.0, 0.0]); + assert_eq!(xyz(&states[1], 0), [0.25, 0.0, 0.0]); + assert_eq!(xyz(&states[0], 32), [0.0, 0.0, 0.125]); + assert_eq!(xyz(&states[1], 32), [0.0, 0.0, 0.25]); +} + +#[test] +fn shared_reactions_use_full_inertia_tensors_and_separate_arms() { + let mut drive = drive(); + column(&mut drive.words, 0, [1.0, 2.0, 3.0, 0.0]); + column(&mut drive.words, 16, [-1.0, 1.0, 2.0, 0.0]); + column(&mut drive.words, 288, [2.0, 0.25, 0.5, 1.0]); + column(&mut drive.words, 304, [0.25, 3.0, 0.75, 0.0]); + column(&mut drive.words, 320, [0.5, 0.75, 4.0, 0.0]); + column(&mut drive.words, 336, [1.0, -0.5, 0.0, 1.0]); + column(&mut drive.words, 352, [-0.5, 2.0, 0.25, 0.0]); + column(&mut drive.words, 368, [0.0, 0.25, 3.0, 0.0]); + column(&mut drive.words, 160, [2.0, 3.0, 4.0, 1000.0]); + column(&mut drive.words, 176, [5.0, 6.0, 7.0, 1000.0]); + let mut states = reactions([0.0; 3], [0.0; 3]); + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(xyz(&states[0], 32), [13.0, 29.5, 32.0]); + assert_eq!(xyz(&states[1], 32), [4.0, -27.0, -9.5]); +} + +#[test] +fn packed_carry_lanes_update_but_pose_only_blocks_and_records_survive() { + let mut drive = drive(); + for (offset, carry) in [ + (204, 7.0f32), + (220, 11.0), + (236, 13.0), + (300, 2.0), + (316, 5.0), + (332, 7.0), + (348, 3.0), + (364, 13.0), + (380, 17.0), + ] { + drive.words[offset / 4] = carry.to_bits(); + } + column(&mut drive.words, 160, [2.0, 3.0, 4.0, 1000.0]); + column(&mut drive.words, 176, [5.0, 6.0, 7.0, 1000.0]); + let original = drive.words; + let mut states = reactions([0.0; 3], [0.0; 3]); + states[0][3] = 17.0f32.to_bits(); + states[1][3] = 19.0f32.to_bits(); + states[0][11] = 11.0f32.to_bits(); + states[1][11] = 23.0f32.to_bits(); + for state in &mut states { + state[4..8].copy_from_slice(&[0x12345678; 4]); + state[12..16].copy_from_slice(&[0x87654321; 4]); + } + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(states[0][3], 215.0f32.to_bits()); + assert_eq!(states[1][3], (-278.0f32).to_bits()); + assert_eq!(states[0][11], 100.0f32.to_bits()); + assert_eq!(states[1][11], (-189.0f32).to_bits()); + for state in states { + assert_eq!(&state[4..8], &[0x12345678; 4]); + assert_eq!(&state[12..16], &[0x87654321; 4]); + } + assert_eq!(&drive.words[..8], &original[..8]); + assert_eq!(&drive.words[16..], &original[16..]); +} + +#[test] +fn zero_strength_drive_and_static_body_response_are_distinct() { + let mut drive = drive(); + drive_limits(&mut drive, [0.0; 3], [0.0; 3]); + let mut states = reactions([8.0; 3], [6.0; 3]); + let before = states; + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 2, + ); + assert_eq!(states, before); + + let mut drive = origin_constraint(); + drive.words[84..96].fill(0); // Body B supplies no dynamic response. + let mut states = reactions([8.0, 0.0, 0.0], [0.0; 3]); + let fixed = states[1]; + packed::solve( + &mut [], + std::slice::from_mut(&mut drive), + &mut [], + &mut states, + 1, + ); + assert_eq!(states[1], fixed); + assert_eq!(states[0][0], 4.0f32.to_bits()); +} + +#[test] +fn family_order_and_body_indices_share_reactions_between_constraints() { + let mut joint = origin_constraint(); + let mut drive = drive(); + drive.reaction_a = 1; + drive.reaction_b = 2; + let mut states = [[0; 16]; 3]; + states[1][0] = 4.0f32.to_bits(); + states[2][0] = 8.0f32.to_bits(); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!( + [states[0][0], states[1][0], states[2][0]], + [2.0f32, 5.0, 5.0].map(f32::to_bits) + ); + assert_eq!(drive.words[8], 3.0f32.to_bits()); + + let mut reversed = origin_constraint(); + reversed.reaction_a = 1; + reversed.reaction_b = 0; + let mut states = reactions([4.0, 0.0, 0.0], [0.0; 3]); + packed::solve( + &mut [], + std::slice::from_mut(&mut reversed), + &mut [], + &mut states, + 1, + ); + assert_eq!(states[0][0], 2.0f32.to_bits()); + assert_eq!(states[1][0], 2.0f32.to_bits()); + assert_eq!(reversed.words[8], (-2.0f32).to_bits()); +} + +#[test] +fn zero_iterations_preserve_all_packed_words() { + let mut joints = [origin_constraint()]; + let mut drives = [drive()]; + let original_joint = joints[0].words; + let original_drive = drives[0].words; + let mut states = reactions([2.0, 3.0, 4.0], [5.0, 6.0, 7.0]); + let original_states = states; + packed::solve(&mut [], &mut joints, &mut drives, &mut states, 0); + assert_eq!(states, original_states); + assert_eq!(joints[0].words, original_joint); + assert_eq!(drives[0].words, original_drive); +} + +#[test] +fn nonorthogonal_angular_axes_and_rotated_linear_axes_keep_column_direction() { + let mut joint = origin_constraint(); + for (axis, values) in [ + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + ] + .into_iter() + .enumerate() + { + column(&mut joint.words, 192 + axis * 16, values); + } + column(&mut joint.words, 64, [0.0, 0.0, 0.5, 0.0]); + column(&mut joint.words, 96, [0.5, 0.0, 0.0, 0.0]); + column(&mut joint.words, 128, [0.0, 0.5, 0.0, 0.0]); + column(&mut joint.words, 240, [1.0, 0.0, 0.0, 0.0]); + column(&mut joint.words, 256, [1.0, 1.0, 0.0, 0.0]); + column(&mut joint.words, 272, [0.0, 1.0, 1.0, 0.0]); + column(&mut joint.words, 80, [0.5, 0.25, 0.0, 0.0]); + column(&mut joint.words, 112, [0.0, 0.25, 0.25, 0.0]); + column(&mut joint.words, 144, [0.0, 0.0, 0.25, 0.0]); + let mut states = reactions([6.0, 2.0, 4.0], [2.0, 4.0, 6.0]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [1.0, 2.0, 3.0]); + assert_eq!(xyz(&joint.words, 48), [1.0, 1.5, 2.5]); + assert_eq!(xyz(&states[0], 0), [3.0, 1.0, 2.0]); + assert_eq!(xyz(&states[0], 32), [2.5, 4.0, 2.5]); + assert_eq!(xyz(&states[1], 32), [-0.5, 0.0, 3.5]); +} + +#[test] +fn fused_projection_retains_exact_product_cancellation() { + let mut joint = origin_constraint(); + // (1 + 2^-23) * (1 - 2^-23) - 1 = -2^-46. A rounded multiply + // followed by addition would erase this independently specified residual. + joint.words[16] = f32::from_bits(0x3f800001).to_bits(); + joint.words[8] = (-1.0f32).to_bits(); + let mut states = reactions([f32::from_bits(0x3f7ffffe), 0.0, 0.0], [0.0; 3]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(joint.words[8], 0xa8800000); +} + +#[test] +fn joint_accumulator_carry_is_projected_instead_of_preserved_as_softness() { + let mut joint = origin_constraint(); + column(&mut joint.words, 32, [0.0, 0.0, 0.0, 2.0]); + column(&mut joint.words, 48, [0.0, 0.0, 0.0, 3.0]); + column(&mut joint.words, 160, [0.0, 0.0, 0.0, -4.0]); + column(&mut joint.words, 176, [0.0, 0.0, 0.0, 4.0]); + let mut states = reactions([0.0; 3], [0.0; 3]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(read(&joint.words, 32), [0.0; 4]); + assert_eq!(read(&joint.words, 48), [0.0; 4]); +} diff --git a/crates/skate-core/tests/physics_rebuild_contact_build.rs b/crates/skate-core/tests/physics_rebuild_contact_build.rs new file mode 100644 index 00000000..5842b77f --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_contact_build.rs @@ -0,0 +1,273 @@ +//! Independently specified geometry and branch cases. Supplied response scales +//! are algebraic test inputs, never native hardware estimates or parity fixtures. +use skate_core::math::Vector3; +use skate_core::physics::solver::contact_build::{ + ContactBuildError, ContactMassResponse, UnavailableContactMassResponse, build_with_response, + prepare_contact, +}; + +fn row(words: &mut [u32; 64], row: usize, values: [f32; 4]) { + words[row * 4..row * 4 + 4].copy_from_slice(&values.map(f32::to_bits)); +} + +fn read(words: &[u32; 64], row: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(words[row * 4 + i])) +} + +fn origin() -> [u32; 64] { + let mut words = [0; 64]; + row(&mut words, 2, [1.0, 0.0, 0.0, 0.5]); + row(&mut words, 3, [0.0, 1.0, 0.0, 0.75]); + row(&mut words, 4, [0.0, 0.0, 1.0, 0.25]); + for body in 0..2 { + row(&mut words, 8 + body, [1.0, 0.0, 0.0, 1.0]); + row(&mut words, 10 + body, [1.0, 1.0, 0.0, 0.0]); + words[(10 + body) * 4 + 3] = 4; + } + words +} + +struct ExpectedResponse { + masses: [f32; 3], + scales: [f32; 3], + calls: usize, +} +impl ContactMassResponse for ExpectedResponse { + fn inverse_effective_mass(&mut self, masses: [f32; 3]) -> Result<[f32; 3], ContactBuildError> { + assert_eq!( + masses, self.masses, + "builder must pass its actual effective masses" + ); + self.calls += 1; + Ok(self.scales) + } +} + +fn compile(words: &mut [u32; 64], dt: f32, masses: [f32; 3], scales: [f32; 3]) { + let mut response = ExpectedResponse { + masses, + scales, + calls: 0, + }; + build_with_response(words, dt, &mut response).unwrap(); + assert_eq!(response.calls, 1); +} + +#[test] +fn origin_masses_and_complete_metadata_publication() { + let mut words = origin(); + words[3] = 0xaabbccdd; + words[7] = 0x11223344; + words[27] = 0x55667788; + words[31] = 0x99aabbcc; + words[23] = 0x12345678; + words[43] = 4 | 8 | 0x80; + let prepared = prepare_contact(&words, 0.25); + assert_eq!(prepared.effective_mass, [2.0; 3]); + assert_eq!(prepared.arms, [Vector3::ZERO; 2]); + compile(&mut words, 0.25, [2.0; 3], [0.5; 3]); + let mut expected = [0; 64]; + expected[3] = 0x55667788; + expected[7] = 0x99aabbcc; + row(&mut expected, 2, [0.5, 0.0, 0.0, 0.0]); + expected[11] = 8; + row(&mut expected, 3, [0.0, 0.5, 0.0, 0.75]); + row(&mut expected, 4, [0.0, 0.0, 0.5, 0.25]); + row(&mut expected, 7, [1.0, 0.0, 0.0, 0.0]); + expected[31] = 0xaabbccdd; + row(&mut expected, 8, [0.0, 0.0, 0.0, 1.0]); + row(&mut expected, 9, [0.0, 0.0, 0.0, 1.0]); + row(&mut expected, 10, [0.0, 1.0, 0.0, 0.0]); + expected[43] = 0x11223344; + row(&mut expected, 13, [0.0, 0.0, 1.0, 0.0]); + expected[55] = 0x12345678; + assert_eq!(words, expected); +} + +#[test] +fn off_center_inertia_and_tangent_carry_are_retained() { + let mut words = origin(); + row(&mut words, 0, [0.0, 2.0, 0.0, 0.0]); + row(&mut words, 1, [0.0, 0.0, 3.0, 0.0]); + // A normal torque is -2Z; B normal torque is +3Y. + let p = prepare_contact(&words, 0.0); + assert_eq!(p.effective_mass, [15.0, 11.0, 6.0]); + assert_eq!(p.angular_response_a[0], [0.0, 0.0, -2.0, -2.0]); + assert_eq!(p.angular_response_b[0], [0.0, 3.0, 0.0, 3.0]); + compile(&mut words, 0.0, [15.0, 11.0, 6.0], [0.125, 0.25, 0.5]); + assert_eq!(read(&words, 8), [0.0, 0.0, -2.0, 1.0]); + assert_eq!(read(&words, 9), [0.0, 3.0, 0.0, 1.0]); + assert_eq!(read(&words, 12), [-3.0, 0.0, 0.0, -3.0]); + assert_eq!(read(&words, 14), [2.0, 0.0, 0.0, 2.0]); +} + +#[test] +fn full_symmetric_inertia_changes_response_and_effective_mass() { + let mut words = origin(); + row(&mut words, 0, [0.0, 2.0, 3.0, 0.0]); + row(&mut words, 8, [4.0, 1.0, 2.0, 2.0]); + row(&mut words, 10, [6.0, 5.0, 3.0, 0.0]); + words[43] = 4; + let p = prepare_contact(&words, 0.0); + // I * (0,3,-2) = (-1,9,-3), torque work 33 + masses 3. + assert_eq!(p.angular_response_a[0], [-1.0, 9.0, -3.0, 2.0]); + assert_eq!(p.effective_mass, [36.0, 39.0, 19.0]); + compile(&mut words, 0.0, [36.0, 39.0, 19.0], [0.125; 3]); +} + +#[test] +fn each_active_state_gates_mass_inertia_and_point_acceleration() { + for (a, b, masses) in [ + (false, false, [0.0; 3]), + (true, false, [5.0, 1.0, 5.0]), + (false, true, [10.0, 10.0, 1.0]), + (true, true, [15.0, 11.0, 6.0]), + ] { + let mut words = origin(); + row(&mut words, 0, [0.0, 2.0, 0.0, 0.0]); + row(&mut words, 1, [0.0, 0.0, 3.0, 0.0]); + row(&mut words, 12, [1.0, 2.0, 3.0, 0.0]); + row(&mut words, 13, [4.0, 5.0, 6.0, 0.0]); + words[43] = if a { 4 } else { 8 }; + words[47] = if b { 4 } else { 0 }; + let p = prepare_contact(&words, 0.5); + assert_eq!(p.active, [a, b]); + assert_eq!(p.effective_mass, masses); + assert_eq!( + p.point_acceleration[0], + if a { + Vector3::new(1.0, 2.0, 3.0) + } else { + Vector3::ZERO + } + ); + assert_eq!( + p.point_acceleration[1], + if b { + Vector3::new(4.0, 5.0, 6.0) + } else { + Vector3::ZERO + } + ); + compile(&mut words, 0.5, masses, [0.0; 3]); + assert_eq!(words[11], if a { 0 } else { 8 }); + } +} + +#[test] +fn force_torque_velocity_and_squared_timestep_predict_contact_separation() { + let mut words = origin(); + row(&mut words, 0, [1.0, 2.0, 3.0, 0.0]); + row(&mut words, 1, [4.0, 6.0, 8.0, 0.0]); + row(&mut words, 7, [4.0, 6.0, 8.0, 0.0]); + row(&mut words, 12, [10.0, 20.0, 30.0, 0.0]); + row(&mut words, 14, [2.0, 3.0, 5.0, 0.0]); + row(&mut words, 13, [1.0, 2.0, 3.0, 0.0]); + row(&mut words, 5, [2.0, 4.0, 6.0, 0.0]); + let p = prepare_contact(&words, 0.5); + assert_eq!( + p.point_acceleration, + // (2,3,5) cross (1,2,3) = (-1,-1,+1). + [Vector3::new(9.0, 19.0, 31.0), Vector3::new(1.0, 2.0, 3.0)] + ); + assert_eq!(p.separation_projection, [3.0, 4.0, 5.0]); + assert_eq!(p.restitution_projection, [-0.5, -1.0, -1.5]); + assert_eq!(p.predicted_separation_projection, [2.0, 1.75, 1.0]); + compile(&mut words, 0.5, [15.0, 12.0, 7.0], [0.5; 3]); + assert_eq!(read(&words, 6), [1.25, 0.875, 0.5, 1.5]); +} + +#[test] +fn normal_target_ordered_sign_branches_and_zero_boundaries() { + // (separation, velocity, forceB, restitution, expected normal target). + for (sep, vel, force, bounce, expected) in [ + (-2.0, -2.0, 8.0, 0.5_f32, 4.0), // negative separation, positive restitution + (-2.0, 4.0, 0.0, 0.5, 6.0), // both negative: subtract their sum + (2.0, 2.0, 0.0, 0.5, 5.0), // positive separation: restitution only + (2.0, -1.0, 0.0, 0.5, 0.5), + (2.0, 2.0, -5.0, 0.5, -1.0), // nonpositive prediction clears adjustment + (2.0, 2.0, -4.0, 0.5, 0.0), + (0.0, 2.0, 0.0, 0.5, 3.0), // equality takes >= separation branch + (-2.0, 3.0, 0.0, 0.0, 1.0), // zero restitution takes >= branch + ] { + let mut words = origin(); + row(&mut words, 1, [sep, 0.0, 0.0, 0.0]); + row(&mut words, 7, [sep, 0.0, 0.0, 0.0]); + row(&mut words, 5, [vel, 2.0, -3.0, 0.0]); + row(&mut words, 13, [force, 0.0, 0.0, 0.0]); + words[11] = bounce.to_bits(); + compile(&mut words, 1.0, [2.0; 3], [1.0; 3]); + assert_eq!( + read(&words, 6), + [expected, 2.0, -3.0, sep], + "sep {sep}, velocity {vel}, force {force}" + ); + } +} + +#[test] +fn axes_project_without_normalization_and_scales_apply_by_constraint() { + let mut words = origin(); + row(&mut words, 2, [2.0, 1.0, 0.0, 0.0]); + row(&mut words, 3, [0.0, 3.0, 1.0, 0.75]); + row(&mut words, 4, [1.0, 0.0, 4.0, 0.25]); + row(&mut words, 5, [1.0, 2.0, 3.0, 0.0]); + compile(&mut words, 1.0, [2.0; 3], [0.5, 0.25, 0.125]); + assert_eq!(read(&words, 2), [1.0, 0.0, 0.125, 0.0]); + assert_eq!(read(&words, 3), [0.5, 0.75, 0.0, 0.75]); + assert_eq!(read(&words, 4), [0.0, 0.25, 0.5, 0.25]); + assert_eq!(read(&words, 6), [2.0, 2.25, 1.625, 0.0]); +} + +#[test] +fn unavailable_estimate_preserves_every_input_word() { + let mut words = origin(); + words[23] = 0xfedcba98; + row(&mut words, 5, [3.0, -2.0, 1.0, 0.0]); + let before = words; + let result = build_with_response(&mut words, 0.5, &mut UnavailableContactMassResponse); + assert_eq!( + result, + Err(ContactBuildError::ReciprocalEstimateUnavailable { + effective_mass: [2.0; 3] + }) + ); + assert_eq!(words, before); +} + +#[test] +fn prepared_rotated_contact_drives_shared_solver_reactions() { + use skate_core::physics::solver::packed::{self, Contact}; + let mut words = origin(); + // Normal Y, tangents Z/X. Only A has a lever arm, +X. + row(&mut words, 0, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 1, [1.0, 2.0, 0.0, 0.0]); + row(&mut words, 7, [1.0, 2.0, 0.0, 0.0]); + row(&mut words, 2, [0.0, 1.0, 0.0, 0.0]); + row(&mut words, 3, [0.0, 0.0, 1.0, 0.75]); + row(&mut words, 4, [1.0, 0.0, 0.0, 0.25]); + row(&mut words, 5, [8.0, 0.0, 4.0, 0.0]); + // Deliberately explicit algebraic scales, not a reciprocal approximation. + compile(&mut words, 1.0, [3.0, 3.0, 2.0], [0.25, 0.5, 0.125]); + let mut contacts = [Contact { + words, + reaction_a: 0, + reaction_b: 1, + }]; + let mut reactions = [[0; 16]; 2]; + packed::solve(&mut contacts, &mut [], &mut [], &mut reactions, 2); + // Pass one gives normal .5 and no friction. On pass two the normal + // correction is -.25; friction uses old normal .5 and dynamic limit .125. + assert_eq!(read(&contacts[0].words, 5), [0.25, 0.125, 0.125, 0.625]); + let read_reaction = |body: usize, row: usize| -> [f32; 4] { + core::array::from_fn(|lane| f32::from_bits(reactions[body][row * 4 + lane])) + }; + assert_eq!(read_reaction(0, 0), [0.125, 0.25, 0.125, 0.0]); + assert_eq!(read_reaction(1, 0), [-0.125, -0.25, -0.125, 0.0]); + assert_eq!(read_reaction(0, 1), [0.0, 0.625, 0.0, 0.0]); + assert_eq!(read_reaction(1, 1), [0.0, -0.625, 0.0, 0.0]); + assert_eq!(read_reaction(0, 2), [0.0, -0.125, 0.25, 0.125]); + assert_eq!(read_reaction(1, 2), [0.0, 0.0, 0.0, -0.25]); + assert_eq!(read_reaction(0, 3), [0.0, 0.0, 0.625, 0.625]); + assert_eq!(read_reaction(1, 3), [0.0, 0.0, 0.0, -0.625]); +} diff --git a/crates/skate-core/tests/physics_rebuild_mass.rs b/crates/skate-core/tests/physics_rebuild_mass.rs new file mode 100644 index 00000000..70ca885a --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_mass.rs @@ -0,0 +1,297 @@ +//! Independent geometric/constructor checks; no generated execution fixtures. +use skate_core::{ + math::Vector3, + physics::mass::{ + MassShape, PartMassInput, TruckMassSettings, WheelMassSettings, forward_mass_properties, + primitive_mass, truck_mass_input, wheel_mass_input, + }, +}; + +fn close(actual: f32, expected: f64) { + assert!( + (f64::from(actual) - expected).abs() <= expected.abs().max(1e-25) * 3e-6, + "actual {actual:?}, expected {expected:?}" + ); +} + +#[test] +fn sphere_obeys_solid_sphere_moments_and_volume() { + let mass = primitive_mass(MassShape::Sphere { radius: 2.0 }).unwrap(); + for moment in [ + mass.moments_per_unit_mass.x, + mass.moments_per_unit_mass.y, + mass.moments_per_unit_mass.z, + ] { + close(moment, 8.0 / 5.0); + } + close(mass.volume, 32.0 * std::f64::consts::PI / 3.0); +} + +#[test] +fn capsule_uses_native_longitudinal_axis_and_dimension_floor() { + let mass = primitive_mass(MassShape::Capsule { + radius: 1.0, + half_length: 2.0, + }) + .unwrap(); + close(mass.moments_per_unit_mass.x, 271.0 / 100.0); + assert_eq!(mass.moments_per_unit_mass.x, mass.moments_per_unit_mass.y); + close(mass.moments_per_unit_mass.z, 2.0 / 5.0); + close(mass.volume, 16.0 * std::f64::consts::PI / 3.0); + + let floor = primitive_mass(MassShape::Capsule { + radius: 1.0, + half_length: 20.0, + }); + for radius in [-2.0, 0.0, 0.5, 1.0] { + assert_eq!( + primitive_mass(MassShape::Capsule { + radius, + half_length: 20.0 + }), + floor + ); + } + assert_ne!( + primitive_mass(MassShape::Capsule { + radius: 1.5, + half_length: 20.0 + }), + floor + ); +} + +#[test] +fn sphere_radius_floor_includes_negative_zero_and_unordered_input() { + let minimum = f32::from_bits(0x33d6_bf95); + let floor = primitive_mass(MassShape::Sphere { radius: minimum }); + for radius in [0.0, -0.0, -10.0, minimum * 0.5, f32::NAN, f32::NEG_INFINITY] { + assert_eq!(primitive_mass(MassShape::Sphere { radius }), floor); + } + assert_ne!( + primitive_mass(MassShape::Sphere { + radius: minimum * 2.0 + }), + floor + ); +} + +#[test] +fn box_without_rounding_has_cuboid_properties() { + let mass = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(1.0, 2.0, 3.0), + radius: 0.0, + }) + .unwrap(); + close(mass.moments_per_unit_mass.x, 13.0 / 3.0); + close(mass.moments_per_unit_mass.y, 10.0 / 3.0); + close(mass.moments_per_unit_mass.z, 5.0 / 3.0); + assert_eq!(mass.volume, 48.0); +} + +#[test] +fn rounded_box_includes_edge_and_corner_volume() { + let mass = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(1.0, 2.0, 3.0), + radius: 0.5, + }) + .unwrap(); + close(mass.volume, 92.0 + 19.0 * std::f64::consts::PI / 6.0); + close(mass.moments_per_unit_mass.x, 49.0 / 8.0); + close(mass.moments_per_unit_mass.y, 115.0 / 24.0); + close(mass.moments_per_unit_mass.z, 67.0 / 24.0); +} + +#[test] +fn box_floor_depends_on_largest_padded_dimension() { + for half_extents in [ + Vector3::new(0.0, 0.0, 20.0), + Vector3::new(0.0, 20.0, 0.0), + Vector3::new(20.0, 0.0, 0.0), + ] { + let mass = primitive_mass(MassShape::RoundedBox { + half_extents, + radius: 0.0, + }) + .unwrap(); + assert_eq!(mass.volume, 160.0); + } + let thin = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(-3.0, 0.0, 20.0), + radius: 0.5, + }); + let floored = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(0.525, 0.525, 20.0), + radius: 0.5, + }); + assert_eq!(thin, floored); +} + +#[test] +fn cylinder_padding_expands_radius_and_half_length() { + let bare = primitive_mass(MassShape::Cylinder { + radius: 3.0, + half_length: 2.0, + padding: 0.0, + }) + .unwrap(); + let padded = primitive_mass(MassShape::Cylinder { + radius: 2.5, + half_length: 1.5, + padding: 0.5, + }) + .unwrap(); + assert_eq!(bare, padded); + close(bare.moments_per_unit_mass.x, 43.0 / 12.0); + assert_eq!(bare.moments_per_unit_mass.z, 4.5); + close(bare.volume, 36.0 * std::f64::consts::PI); +} + +#[test] +fn geometric_scaling_is_quadratic_for_moments_and_cubic_for_volume() { + let shapes = [ + ( + MassShape::Sphere { radius: 0.75 }, + MassShape::Sphere { radius: 1.5 }, + ), + ( + MassShape::Capsule { + radius: 0.75, + half_length: 1.25, + }, + MassShape::Capsule { + radius: 1.5, + half_length: 2.5, + }, + ), + ( + MassShape::RoundedBox { + half_extents: Vector3::new(1.0, 2.0, 3.0), + radius: 0.25, + }, + MassShape::RoundedBox { + half_extents: Vector3::new(2.0, 4.0, 6.0), + radius: 0.5, + }, + ), + ( + MassShape::Cylinder { + radius: 0.75, + half_length: 1.25, + padding: 0.25, + }, + MassShape::Cylinder { + radius: 1.5, + half_length: 2.5, + padding: 0.5, + }, + ), + ]; + for (small, large) in shapes { + let small = primitive_mass(small).unwrap(); + let large = primitive_mass(large).unwrap(); + assert_eq!(large.volume, small.volume * 8.0); + assert_eq!( + large.moments_per_unit_mass, + Vector3::new( + small.moments_per_unit_mass.x * 4.0, + small.moments_per_unit_mass.y * 4.0, + small.moments_per_unit_mass.z * 4.0, + ) + ); + } +} + +#[test] +fn constructors_derive_geometry_and_scale_requested_mass() { + let wheel = wheel_mass_input(WheelMassSettings { + radius: 0.125, + mass: 0.25, + mass_factor: 2.0, + }); + assert_eq!( + wheel, + PartMassInput { + shape: MassShape::Sphere { radius: 0.125 }, + requested_mass: 0.5 + } + ); + let truck = truck_mass_input(TruckMassSettings { + wheel_radius: 0.125, + wheel_x_distance: 0.25, + radius_scalar: 2.0, + half_height_scalar: 3.0, + mass: 0.5, + mass_factor: 4.0, + }); + assert_eq!( + truck, + PartMassInput { + shape: MassShape::Capsule { + radius: 0.125, + half_length: 0.375 + }, + requested_mass: 2.0, + } + ); + let stock = forward_mass_properties(wheel_mass_input(WheelMassSettings::STOCK)).unwrap(); + assert_eq!(stock.mass.to_bits(), 0x3ed4_7ae1); // 0.415 + close(stock.principal_moments.x, 0.4 * 0.031_f64.powi(2) * 0.415); + let truck = truck_mass_input(TruckMassSettings::STOCK); + if let MassShape::Capsule { + radius, + half_length, + } = truck.shape + { + close(radius, 0.00833125); + close(half_length, 0.064125); + } else { + panic!("native truck producer must use capsule"); + } + assert_eq!(truck.requested_mass.to_bits(), 0x3ff9_9999); // 1.95 +} + +#[test] +fn mass_fallback_is_strictly_below_minimum_normal_and_preserves_nan() { + let shape = MassShape::Sphere { radius: 1.0 }; + let volume = primitive_mass(shape).unwrap().volume; + for requested_mass in [0.0, -1.0, f32::from_bits(1), f32::MIN_POSITIVE * 0.5] { + assert_eq!( + forward_mass_properties(PartMassInput { + shape, + requested_mass + }) + .unwrap() + .mass, + volume + ); + } + for requested_mass in [f32::MIN_POSITIVE, 2.0, f32::INFINITY] { + assert_eq!( + forward_mass_properties(PartMassInput { + shape, + requested_mass + }) + .unwrap() + .mass, + requested_mass + ); + } + assert!( + forward_mass_properties(PartMassInput { + shape, + requested_mass: f32::NAN + }) + .unwrap() + .mass + .is_nan() + ); + assert_eq!(primitive_mass(MassShape::Unsupported), None); + assert_eq!( + forward_mass_properties(PartMassInput { + shape: MassShape::Unsupported, + requested_mass: 1.0 + }), + None + ); +} diff --git a/crates/skate-core/tests/point_graph.rs b/crates/skate-core/tests/point_graph.rs new file mode 100644 index 00000000..5648feea --- /dev/null +++ b/crates/skate-core/tests/point_graph.rs @@ -0,0 +1,45 @@ +//! Expected branch outcomes are specified directly from TU3 82481E10's +//! fcmpu/blt control flow, not generated-code execution. +use skate_core::point_graph::PointGraph; + +#[test] +fn unordered_endpoint_comparison_selects_the_final_value() { + let graph = PointGraph { + x: [0.0, 1.0, 2.0], + y: [3.0, 5.0, 7.0], + }; + assert_eq!(graph.evaluate(f32::NAN), 7.0); + let last_key_nan = PointGraph { + x: [0.0, 1.0, f32::NAN], + y: graph.y, + }; + assert_eq!(last_key_nan.evaluate(0.5), 7.0); +} + +#[test] +fn unordered_interior_key_does_not_select_an_interval() { + let graph = PointGraph { + x: [0.0, f32::NAN, 1.0, 2.0], + y: [0.0, 99.0, 10.0, 20.0], + }; + assert_eq!(graph.evaluate(1.5), 15.0); +} + +#[test] +fn finite_endpoints_and_interpolation_remain_unchanged() { + let graph = PointGraph { + x: [0.0, 1.0, 2.0], + y: [0.0, 2.0, 8.0], + }; + for (input, expected) in [ + (-1.0, 0.0), + (0.0, 0.0), + (0.5, 1.0), + (1.0, 2.0), + (1.5, 5.0), + (2.0, 8.0), + (3.0, 8.0), + ] { + assert_eq!(graph.evaluate(input), expected); + } +} diff --git a/crates/skate-core/tests/riding_grounded_propulsion.rs b/crates/skate-core/tests/riding_grounded_propulsion.rs new file mode 100644 index 00000000..08250422 --- /dev/null +++ b/crates/skate-core/tests/riding_grounded_propulsion.rs @@ -0,0 +1,382 @@ +//! Independent caller/branch checks; no generated executable fixtures. +use skate_core::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::controller::ManualEffect, + rigid_body::RetailInertiaDynamics, + }, + riding::{ + braking::{BrakeSettings, LinearDragSettings}, + grounded::{ + drag::{ + BodyInertias, DRAG_FREQUENCY, DragBindingError, DragSelection, GroundDragInput, + }, + propulsion::{ + self, GroundPropulsionInput, GroundPropulsionSettings, PropulsionSubmission, + }, + }, + }, +}; + +fn input() -> GroundPropulsionInput { + GroundPropulsionInput { + flags_2468: (1 << 30) | (1 << 25), + flags_2472: 0, + target_speed: 10., + signed_speed: 4., + absolute_body_speed: 4., + scalar_2660: 2., + timestep: 0.25, + brake_input: 0.5, + push_direction: Vector3::new(0., 2., 3.), + brake_direction: Vector3::new(1., 0., 0.), + surface_braking_factor: 0.2, + } +} +fn settings() -> GroundPropulsionSettings { + GroundPropulsionSettings { + braking: BrakeSettings { + input_force: 10., + override_force: 20., + minimum_speed: 0.5, + }, + maximum_pushable_speed: 8., + mode_speed_changes: [2., 1.], + } +} +fn manual() -> ManualEffect { + ManualEffect { + angular_displacement: [0.; 4], + force_world: [0.; 4], + force_point_body: [0.; 4], + corrective_force_world: [11., 12., 13., 14.], + corrective_point_body: [21., 22., 23., 24.], + correction_active: false, + opposing_motion_without_correction: false, + } +} +fn inertia() -> RetailInertiaDynamics { + RetailInertiaDynamics { + inverse_tensor: Vector3::new(2., 3., 4.), + inverse_mass: 5., + spherical: 6., + maximum_linear_velocity: 7., + maximum_angular_velocity: 8., + linear_drag: 9., + angular_drag: 10., + } +} + +#[test] +fn live_processed_scale_time_and_distinct_axes_reach_brake_then_push_records() { + let mut suppressed = 255; + let forces = propulsion::calculate(input(), settings(), &mut suppressed); + assert_eq!(suppressed, 0); + assert_eq!(forces.braking.force_world, Vector3::new(-1., -0., -0.)); + assert_eq!(forces.push.vector, Vector3::new(0., 24., 36.)); + let mut queue = BoardForceQueue::default(); + assert_eq!( + forces.submit(&manual(), &mut queue), + PropulsionSubmission::BrakingAndPush([true, true]) + ); + assert_eq!( + queue.entries().iter().map(|f| f.tag).collect::>(), + [2, 3] + ); + assert_eq!(queue.entries()[0].point_body, Vector3::ZERO); + assert_eq!(queue.entries()[1].point_body, Vector3::ZERO); + let doubled = propulsion::calculate( + GroundPropulsionInput { + scalar_2660: 4., + ..input() + }, + settings(), + &mut suppressed, + ); + assert_eq!(doubled.push.vector, Vector3::new(0., 48., 72.)); +} + +#[test] +fn suppression_byte_is_republished_and_zero_forces_are_still_queued() { + let mut suppressed = 0; + let forces = propulsion::calculate( + GroundPropulsionInput { + flags_2472: 1 << 9, + ..input() + }, + settings(), + &mut suppressed, + ); + assert_eq!(suppressed, 1); + assert_eq!(forces.push.vector, Vector3::ZERO); + let inactive = propulsion::calculate( + GroundPropulsionInput { + flags_2468: 0, + flags_2472: 1 << 9, + ..input() + }, + settings(), + &mut suppressed, + ); + assert_eq!(suppressed, 0); + let mut queue = BoardForceQueue::default(); + inactive.submit(&manual(), &mut queue); + assert_eq!(queue.entries().len(), 2); + assert_eq!(queue.entries()[0].force_world, Vector3::ZERO); + assert_eq!(queue.entries()[1].force_world, Vector3::ZERO); +} + +#[test] +fn only_manual_output80_selects_alternate_tag7_payload() { + let forces = propulsion::calculate(input(), settings(), &mut 0); + let mut queue = BoardForceQueue::default(); + let manual = ManualEffect { + correction_active: true, + ..manual() + }; + assert_eq!( + forces.submit(&manual, &mut queue), + PropulsionSubmission::ManualCorrection(true) + ); + assert_eq!( + queue.entries(), + [QueuedPointForce { + tag: 7, + force_world: Vector3::new(11., 12., 13.), + point_body: Vector3::new(21., 22., 23.), + }] + ); + queue.clear(); + let only81 = ManualEffect { + correction_active: false, + opposing_motion_without_correction: true, + ..manual + }; + assert_eq!( + forces.submit(&only81, &mut queue), + PropulsionSubmission::BrakingAndPush([true, true]) + ); +} + +#[test] +fn capacity_drops_later_push_without_reordering_or_clearing_prior_forces() { + let mut queue = BoardForceQueue::default(); + for tag in 100..120 { + queue.append(QueuedPointForce { + tag, + ..QueuedPointForce::default() + }); + } + let forces = propulsion::calculate(input(), settings(), &mut 0); + assert_eq!( + forces.submit(&manual(), &mut queue), + PropulsionSubmission::BrakingAndPush([true, false]) + ); + assert_eq!(queue.entries().len(), 21); + assert_eq!(queue.entries()[0].tag, 100); + assert_eq!(queue.entries()[20].tag, 2); +} + +#[test] +fn braking_override_reverse_and_unordered_cutoff_follow_direct_branches() { + let mut sample = input(); + sample.flags_2468 |= 1 << 29; + sample.signed_speed = -4.; + let positive = propulsion::calculate(sample, settings(), &mut 0); + assert_eq!(positive.braking.force_world.x, 4.); + sample.absolute_body_speed = 0.25; + assert_eq!( + propulsion::calculate(sample, settings(), &mut 0) + .braking + .force_world, + Vector3::ZERO + ); + sample.absolute_body_speed = f32::NAN; + assert_eq!( + propulsion::calculate(sample, settings(), &mut 0) + .braking + .force_world + .x, + 4. + ); + sample.signed_speed = f32::NAN; + assert_eq!( + propulsion::calculate(sample, settings(), &mut 0) + .braking + .force_world + .x, + -4. + ); +} + +#[test] +fn push_unordered_ratio_selects_upper_fraction_before_both_limit_tests() { + // Negative limits are not sanitized by the source. They make the two + // independent pre-clamp comparisons visible even when the input gap is0. + let sample = GroundPropulsionInput { + signed_speed: f32::NAN, + absolute_body_speed: 0., + ..input() + }; + let settings = GroundPropulsionSettings { + mode_speed_changes: [2., -2.], + ..settings() + }; + let forces = propulsion::calculate(sample, settings, &mut 0); + assert_eq!(forces.push.vector, Vector3::new(0., 32., 48.)); +} + +fn drag_input() -> GroundDragInput { + GroundDragInput { + flags_2468: 1 << 30, + absolute_body_speed_2616: 0.5, + balance_2720: 0., + scalar_2724: 0., + ground_normal_y: 0.75, + } +} +fn drag_settings() -> LinearDragSettings { + LinearDragSettings { + brake_speed: 1., + balance_speed: 2., + comparison_threshold: 0.5, + balance_drag: 0.25, + } +} + +#[test] +fn drag_reads_normal_y_and_preserves_strict_thresholds_and_exclusions() { + let settings = drag_settings(); + assert_eq!(drag_input().calculate(settings), 1.); + assert_eq!( + GroundDragInput { + scalar_2724: 0.1, + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + absolute_body_speed_2616: 2., + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + balance_2720: 1., + ..drag_input() + } + .calculate(settings), + 0.25 + ); + assert_eq!( + GroundDragInput { + balance_2720: 1., + ground_normal_y: 0.5, + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + balance_2720: 1., + ground_normal_y: f32::NAN, + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + absolute_body_speed_2616: f32::NAN, + ..drag_input() + } + .calculate(settings), + 0. + ); +} + +#[test] +fn drag_publication_changes_only_linear_drag_of_bound_inertias() { + let mut storage = [inertia(); 8]; + let bindings = [6, 1, 1, 5, 3, 2, 0]; + let drag = GroundDragInput { + balance_2720: 1., + ..drag_input() + } + .calculate(drag_settings()); + BodyInertias { + part_inertia_indices: &bindings, + inertias: &mut storage, + } + .apply_ground_drag(drag) + .unwrap(); + for index in [0, 1, 2, 3, 5, 6] { + assert_eq!( + storage[index], + RetailInertiaDynamics { + linear_drag: f32::from_bits(0x416f_ffff), + ..inertia() + } + ); + } + assert_eq!(storage[4], inertia()); + assert_eq!(storage[7], inertia()); + assert_eq!(DRAG_FREQUENCY.to_bits(), 0x426f_ffff); +} + +#[test] +fn drag_all_part_count_boundaries_single_selection_and_signed_zero() { + for count in [0, 1, 3, 4, 7, 8] { + let mut storage = [inertia(); 8]; + let indices: Vec<_> = (0..count).collect(); + BodyInertias { + part_inertia_indices: &indices, + inertias: &mut storage, + } + .set_linear_drag(-0., DragSelection::AllParts) + .unwrap(); + for (index, value) in storage.iter().enumerate() { + assert_eq!( + value.linear_drag.to_bits(), + if index < count { + 0x8000_0000 + } else { + 9f32.to_bits() + } + ); + } + } + let mut storage = [inertia(); 2]; + BodyInertias { + part_inertia_indices: &[1, 0], + inertias: &mut storage, + } + .set_linear_drag(1., DragSelection::Part(0)) + .unwrap(); + assert_eq!(storage[0], inertia()); + assert_eq!(storage[1].linear_drag.to_bits(), 0x426f_ffff); +} + +#[test] +fn invalid_host_bindings_fail_before_mutation() { + let mut storage = [inertia(); 2]; + let mut bodies = BodyInertias { + part_inertia_indices: &[0, 3], + inertias: &mut storage, + }; + assert_eq!( + bodies.apply_ground_drag(1.), + Err(DragBindingError::InertiaOutsideStorage) + ); + assert_eq!( + bodies.set_linear_drag(1., DragSelection::Part(usize::MAX)), + Err(DragBindingError::PartOutsideAssembly) + ); + assert_eq!(storage, [inertia(); 2]); +} diff --git a/crates/skate-core/tests/riding_manual.rs b/crates/skate-core/tests/riding_manual.rs new file mode 100644 index 00000000..359e14fa --- /dev/null +++ b/crates/skate-core/tests/riding_manual.rs @@ -0,0 +1,374 @@ +//! Independent manual control-flow checks; supplied angle measurements are not +//! a replacement implementation or numerical validation of 8296EC98. +use skate_core::{ + physics::manual::{ + angle::{AngleError, normalize}, + controller::{ManualAngleMeasurement, ManualError, ManualInput, calculate}, + settings::{ManualGains, ManualMode, ManualSettings}, + state::{ManualEntryContinuation, ManualState}, + }, + point_graph::PointGraph, +}; + +fn state() -> ManualState { + ManualState { + filtered_angle_error: 0.0, + target_angle: 0.0, + measured_angle: 0.0, + angular_correction: 0.0, + elapsed: 0.0, + } +} +fn gains() -> ManualGains { + ManualGains { + proportional: 0.0, + integral: 0.0, + derivative: 0.0, + } +} +fn settings() -> ManualSettings { + ManualSettings { + noise_vs_speed: PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [1.0; 8], + }, + torque_scale_without_contact: 0.25, + torque_bleed_without_contact: 0.5, + start_torque_scale: 0.0, + procedural_noise_scale: 0.0, + procedural_noise_frequency: 1.0, + powerslide: gains(), + manual: gains(), + maximum_tilt_degrees: 0.0, + maximum_angle_error: 1.0, + derivative_limit: 0.25, + brake_tilt_degrees: 0.0, + animation_noise_scale: 0.0, + } +} +fn mode() -> ManualMode { + ManualMode { + correction_angular_speed_threshold: 4.0, + corrective_force_enabled: true, + } +} +fn input() -> ManualInput { + ManualInput { + balance: 1.0, + flipped_controls: 1.0, + procedural_noise_time: 0.0, + absolute_speed: 0.0, + animation_noise: 0.0, + timestep: 0.125, + powersliding: false, + braking: false, + positive_balance_contact: true, + negative_balance_contact: true, + reversed_point_selection: false, + reference_x: [1.0, 0.0, 0.0, 0.0], + reference_z: [0.0, 0.0, 1.0, 0.0], + deck_z: [0.0, 0.0, 1.0, 0.0], + velocity_frame_z: [0.0, 0.0, 1.0, 0.0], + angular_velocity_world: [0.0, 0.0, -5.0, 0.0], + correction_point_7888: [1.0, 2.0, 3.0, 4.0], + correction_point_7952: [5.0, 6.0, 7.0, 8.0], + } +} +struct Angle { + value: f32, + calls: usize, + fail: bool, +} +impl Angle { + fn new(value: f32) -> Self { + Self { + value, + calls: 0, + fail: false, + } + } +} +impl ManualAngleMeasurement for Angle { + type Error = &'static str; + fn angle_between( + &mut self, + _deck: [f32; 4], + _z: [f32; 4], + _x: [f32; 4], + ) -> Result { + self.calls += 1; + if self.fail { + Err("angle measurement unavailable") + } else { + Ok(self.value) + } + } +} + +#[test] +fn zero_balance_resets_five_fields_without_querying_geometry() { + let mut state = ManualState { + filtered_angle_error: 1., + target_angle: 2., + measured_angle: 3., + angular_correction: 4., + elapsed: 5., + }; + let mut input = input(); + input.balance = -0.0; + let mut angle = Angle::new(0.0); + angle.fail = true; + let effect = calculate(&mut state, &settings(), mode(), &input, &mut angle).unwrap(); + assert_eq!(state, crate::state()); + assert_eq!(angle.calls, 0); + assert_eq!(effect.angular_displacement, [0.0; 4]); + assert!(!effect.correction_active); + assert_eq!(effect.corrective_force_world, [0.0; 4]); + assert!(!effect.opposing_motion_without_correction); +} + +#[test] +fn ground_entry_preserves_only_the_native_ground_history_branch() { + let original = ManualState { + filtered_angle_error: 2., + target_angle: 3., + measured_angle: 4., + angular_correction: 6., + elapsed: 7., + }; + let mut state = original; + assert_eq!( + state.enter_ground(100, 0.25), + ManualEntryContinuation::Continue + ); + assert_eq!(state.filtered_angle_error, 0.5); + assert_eq!(state.angular_correction, 1.5); + assert_eq!(state.target_angle, 3.0); + assert_eq!(state.measured_angle, 4.0); + assert_eq!(state.elapsed, 7.0); + assert_eq!( + state.enter_ground(200, 0.25), + ManualEntryContinuation::RemoveVelocityIntoGround + ); + assert_eq!(state, crate::state()); +} + +#[test] +fn derivative_is_suppressed_only_on_first_active_tick_and_then_clamped() { + let mut state = state(); + state.measured_angle = -2.0; + let mut settings = settings(); + settings.manual.derivative = 2.0; + calculate( + &mut state, + &settings, + mode(), + &input(), + &mut Angle::new(0.5), + ) + .unwrap(); + assert_eq!(state.angular_correction, 0.0); + assert_eq!(state.elapsed, 0.125); + let result = calculate( + &mut state, + &settings, + mode(), + &input(), + &mut Angle::new(1.0), + ) + .unwrap(); + assert_eq!(state.angular_correction, 0.25); + assert_eq!(result.angular_displacement[0], -0.25); + assert_eq!(state.elapsed, 0.25); +} + +#[test] +fn wrong_contact_bleeds_state_and_separately_scales_output() { + let mut state = state(); + state.elapsed = 1.0; + state.angular_correction = 8.0; + let mut input = input(); + input.positive_balance_contact = false; + let result = calculate( + &mut state, + &settings(), + mode(), + &input, + &mut Angle::new(0.0), + ) + .unwrap(); + assert_eq!(state.angular_correction, 2.0); // bleed0.5 * one-axle factor0.5 + assert_eq!(result.angular_displacement[0], -0.25); // output0.25 * one-axle factor0.5 + input.balance = -1.0; + calculate( + &mut state, + &settings(), + mode(), + &input, + &mut Angle::new(0.0), + ) + .unwrap(); + assert_eq!(state.angular_correction, 2.0); // negative balance contact exists +} + +#[test] +fn powerslide_argument_selects_distinct_controller_gains() { + let mut settings = settings(); + settings.manual.proportional = 2.0; + settings.powerslide.proportional = 4.0; + for (powersliding, expected) in [(false, -1.0), (true, -2.0)] { + let mut input = input(); + input.powersliding = powersliding; + let mut state = state(); + calculate(&mut state, &settings, mode(), &input, &mut Angle::new(0.5)).unwrap(); + assert_eq!(state.angular_correction, expected); + assert_eq!(state.filtered_angle_error, -0.025); + } +} + +#[test] +fn brake_target_ignores_noise_but_retains_signed_target_mapping() { + let mut settings = settings(); + settings.maximum_tilt_degrees = 24.0; + settings.brake_tilt_degrees = 30.0; + settings.procedural_noise_scale = 100.0; + settings.animation_noise_scale = 100.0; + let mut input = input(); + input.braking = true; + input.balance = -0.5; + input.flipped_controls = -1.0; + input.animation_noise = 1.0; + input.procedural_noise_time = 0.25; + let mut state = state(); + calculate(&mut state, &settings, mode(), &input, &mut Angle::new(0.0)).unwrap(); + let expected = 18.75 * f32::from_bits(0x3c8efa35); // target magnitude .625 *30deg + assert_eq!(state.target_angle, expected); +} + +#[test] +fn procedural_noise_uses_processed_time_instead_of_elapsed_timer() { + let mut settings = settings(); + settings.procedural_noise_scale = 0.25; + let mut input = input(); + input.procedural_noise_time = 0.25; + let mut state = state(); + state.elapsed = 20.0; + calculate(&mut state, &settings, mode(), &input, &mut Angle::new(0.0)).unwrap(); + assert!((state.target_angle - 0.25).abs() < 0.000001); + assert_eq!(state.elapsed, 20.125); +} + +#[test] +fn correction_requires_opposing_motion_speed_and_alignment_and_selects_point() { + let mut input = input(); + input.braking = true; + for (reversed, point) in [ + (false, input.correction_point_7888), + (true, input.correction_point_7952), + ] { + input.reversed_point_selection = reversed; + let result = calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.0), + ) + .unwrap(); + assert!(result.correction_active); + assert_eq!(result.corrective_force_world, [0.0, 0.0, 2000.0, 0.0]); + assert_eq!(result.corrective_point_body, point); + assert!(!result.opposing_motion_without_correction); + } + input.angular_velocity_world[2] = -4.0; // threshold equality does not activate + assert!( + !calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.0) + ) + .unwrap() + .correction_active + ); + input.angular_velocity_world[2] = -5.0; + assert!( + !calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.1) + ) + .unwrap() + .correction_active + ); + input.angular_velocity_world[2] = 5.0; + assert!( + !calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.0) + ) + .unwrap() + .correction_active + ); +} + +#[test] +fn disabled_mode_reports_opposing_motion_before_speed_and_alignment_gates() { + let mut input = input(); + input.braking = true; + input.angular_velocity_world[2] = -0.1; + let mut mode = mode(); + mode.corrective_force_enabled = false; + let result = calculate( + &mut state(), + &settings(), + mode, + &input, + &mut Angle::new(0.5), + ) + .unwrap(); + assert!(result.opposing_motion_without_correction); + assert!(!result.correction_active); + assert_eq!(result.corrective_force_world, [0.0; 4]); +} + +#[test] +fn measurement_failure_does_not_return_partial_effect_or_advance_timer() { + let mut state = state(); + let mut angle = Angle::new(0.0); + angle.fail = true; + assert_eq!( + calculate(&mut state, &settings(), mode(), &input(), &mut angle), + Err(ManualError::Measurement("angle measurement unavailable")) + ); + assert_eq!(state.elapsed, 0.0); +} + +#[test] +fn finite_angle_wrap_has_native_interval_and_explicit_exception_boundary() { + let pi = f32::from_bits(0x40490fdb); + let tau = f32::from_bits(0x40c90fdb); + assert_eq!(normalize(pi), Ok(-pi)); + assert_eq!(normalize(-pi), Ok(-pi)); + assert_eq!(normalize(0.25), Ok(0.25)); + assert_eq!(normalize(tau).unwrap(), 0.0); + assert_eq!(normalize(tau).unwrap().to_bits(), (-0.0_f32).to_bits()); + assert_eq!( + normalize(f32::NAN), + Err(AngleError::IntegerConversionUnavailable) + ); + assert_eq!( + normalize(f32::INFINITY), + Err(AngleError::IntegerConversionUnavailable) + ); + assert_eq!( + normalize(f32::MAX), + Err(AngleError::IntegerConversionUnavailable) + ); +} diff --git a/crates/skate-core/tests/riding_manual_entry.rs b/crates/skate-core/tests/riding_manual_entry.rs new file mode 100644 index 00000000..f61c9d80 --- /dev/null +++ b/crates/skate-core/tests/riding_manual_entry.rs @@ -0,0 +1,248 @@ +//! Independent branch/order checks with supplied projection measurements. +//! These do not validate the missing native three-component dot arithmetic. +use skate_core::{ + physics::manual::state::ManualState, + riding::grounded::manual_entry::{ + ManualGroundBodies, ManualGroundInput, ManualGroundProjection, enter_ground, + remove_velocity_into_ground, + }, +}; + +struct Bodies { + velocities: [[f32; 4]; 7], + reads: Vec, + writes: Vec, + alias: bool, +} +impl Bodies { + fn new() -> Self { + Self { + velocities: [[2., 3., 4., f32::from_bits(0x7fc0_0123)]; 7], + reads: vec![], + writes: vec![], + alias: false, + } + } +} +impl ManualGroundBodies for Bodies { + fn linear_velocity(&mut self, part: usize) -> [f32; 4] { + self.reads.push(part); + self.velocities[if self.alias { 0 } else { part }] + } + fn set_linear_velocity(&mut self, part: usize, velocity: [f32; 4]) { + self.writes.push(part); + self.velocities[if self.alias { 0 } else { part }] = velocity; + } +} +struct Projection { + speed: f32, + samples: Vec<([f32; 4], [f32; 4])>, + fail_at: Option, +} +impl Projection { + fn new(speed: f32) -> Self { + Self { + speed, + samples: vec![], + fail_at: None, + } + } +} +impl ManualGroundProjection for Projection { + type Error = &'static str; + fn normal_speed(&mut self, normal: [f32; 4], velocity: [f32; 4]) -> Result { + self.samples.push((normal, velocity)); + if self.fail_at == Some(self.samples.len()) { + Err("projection unavailable") + } else { + Ok(self.speed) + } + } +} +fn input() -> ManualGroundInput { + ManualGroundInput { + balance: 1., + ground_normal: [0., 1., 0., 55.], + flags_2468: 0, + flags_2472: 0, + } +} +fn state() -> ManualState { + ManualState { + filtered_angle_error: 2., + target_angle: 3., + measured_angle: 4., + angular_correction: 5., + elapsed: 6., + } +} + +#[test] +fn signed_zero_bypasses_every_read_but_unordered_balance_enters() { + for balance in [0., -0.] { + let mut bodies = Bodies::new(); + let mut projection = Projection::new(3.); + remove_velocity_into_ground( + ManualGroundInput { balance, ..input() }, + &mut bodies, + &mut projection, + ) + .unwrap(); + assert!(bodies.reads.is_empty()); + assert!(projection.samples.is_empty()); + } + let mut bodies = Bodies::new(); + remove_velocity_into_ground( + ManualGroundInput { + balance: f32::NAN, + ..input() + }, + &mut bodies, + &mut Projection::new(3.), + ) + .unwrap(); + assert_eq!(bodies.writes, [6]); +} + +#[test] +fn contact_bits_and_reversal_select_exact_groups_in_native_order() { + for (contacts, reversed, expected) in [ + (0, false, vec![6]), + (1 << 27, false, vec![6, 0, 1, 4]), + (1 << 26, false, vec![6, 2, 3, 5]), + (1 << 27, true, vec![6, 2, 3, 5]), + (1 << 26, true, vec![6, 0, 1, 4]), + ((1 << 26) | (1 << 27), false, vec![6, 0, 1, 4, 2, 3, 5]), + ((1 << 26) | (1 << 27), true, vec![6, 0, 1, 4, 2, 3, 5]), + ] { + let mut bodies = Bodies::new(); + remove_velocity_into_ground( + ManualGroundInput { + flags_2468: if reversed { 1 << 20 } else { 0 }, + flags_2472: contacts, + ..input() + }, + &mut bodies, + &mut Projection::new(3.), + ) + .unwrap(); + assert_eq!(bodies.reads, expected); + assert_eq!(bodies.writes, expected); + } +} + +#[test] +fn projection_is_not_clamped_or_normalized_and_preserves_w_bits() { + for (speed, expected_y) in [(3., -3.), (-3., 9.)] { + let mut bodies = Bodies::new(); + let mut projection = Projection::new(speed); + let normal = [0., 2., 0., 55.]; + remove_velocity_into_ground( + ManualGroundInput { + ground_normal: normal, + ..input() + }, + &mut bodies, + &mut projection, + ) + .unwrap(); + assert_eq!(projection.samples[0].0, normal); + assert_eq!(bodies.velocities[6][..3], [2., expected_y, 4.]); + assert_eq!(bodies.velocities[6][3].to_bits(), 0x7fc0_0123); + } +} + +#[test] +fn multiply_and_subtract_have_separate_rounding() { + let mut bodies = Bodies::new(); + bodies.velocities[6][0] = 1.; + let normal_x = f32::from_bits(0x3f80_0001); + let speed = f32::from_bits(0x3f7f_fffe); + remove_velocity_into_ground( + ManualGroundInput { + ground_normal: [normal_x, 0., 0., 0.], + ..input() + }, + &mut bodies, + &mut Projection::new(speed), + ) + .unwrap(); + assert_eq!(bodies.velocities[6][0].to_bits(), 0); + assert_ne!((-normal_x).mul_add(speed, 1.).to_bits(), 0); +} + +#[test] +fn later_parts_read_prior_writes_when_bodies_alias() { + let mut bodies = Bodies::new(); + bodies.alias = true; + let mut projection = Projection::new(1.); + remove_velocity_into_ground( + ManualGroundInput { + flags_2472: 1 << 27, + ..input() + }, + &mut bodies, + &mut projection, + ) + .unwrap(); + let sampled_y: Vec<_> = projection + .samples + .iter() + .map(|sample| sample.1[1]) + .collect(); + assert_eq!(sampled_y, [3., 2., 1., 0.]); + assert_eq!(bodies.velocities[0][1], -1.); +} + +#[test] +fn category100_scales_preserved_state_and_skips_velocity_work() { + let mut state = state(); + let mut bodies = Bodies::new(); + let mut projection = Projection::new(3.); + enter_ground(&mut state, 100, 0.5, input(), &mut bodies, &mut projection).unwrap(); + assert_eq!( + state, + ManualState { + filtered_angle_error: 1., + angular_correction: 2.5, + ..crate::state() + } + ); + assert!(bodies.reads.is_empty()); + assert!(projection.samples.is_empty()); +} + +#[test] +fn other_category_resets_then_runs_velocity_continuation_and_retains_error_prefix() { + let mut state = state(); + let mut bodies = Bodies::new(); + let mut projection = Projection::new(3.); + projection.fail_at = Some(3); + let result = enter_ground( + &mut state, + 200, + 77., + ManualGroundInput { + flags_2472: 1 << 27, + ..input() + }, + &mut bodies, + &mut projection, + ); + assert_eq!(result, Err("projection unavailable")); + assert_eq!( + state, + ManualState { + filtered_angle_error: 0., + target_angle: 0., + measured_angle: 0., + angular_correction: 0., + elapsed: 0., + } + ); + assert_eq!(bodies.reads, [6, 0, 1]); + assert_eq!(bodies.writes, [6, 0]); + assert_eq!(bodies.velocities[6][1], 0.); + assert_eq!(bodies.velocities[0][1], 0.); + assert_eq!(bodies.velocities[1][1], 3.); +} diff --git a/crates/skate-core/tests/riding_mode_binding.rs b/crates/skate-core/tests/riding_mode_binding.rs new file mode 100644 index 00000000..04b53434 --- /dev/null +++ b/crates/skate-core/tests/riding_mode_binding.rs @@ -0,0 +1,19 @@ +use skate_core::riding::grounded::mode::ProcessedMode; + +#[test] +fn unsupported_mode_retains_last_valid_attributes_but_publishes_request() { + let attributes = ["zero", "one", "two", "three", "four"]; + let mut mode = ProcessedMode { + requested_mode: 99, + selected_attributes: "external initial binding", + }; + mode.update(u32::MAX, &attributes); + assert_eq!(mode.selected_attributes, "external initial binding"); + for (index, selected) in attributes.iter().enumerate() { + mode.update(index as u32, &attributes); + assert_eq!(mode.selected_attributes, *selected); + mode.update(5, &attributes); + assert_eq!(mode.requested_mode, 5); + assert_eq!(mode.selected_attributes, *selected); + } +} diff --git a/crates/skate-core/tests/riding_pumping_controller.rs b/crates/skate-core/tests/riding_pumping_controller.rs new file mode 100644 index 00000000..fc9262b9 --- /dev/null +++ b/crates/skate-core/tests/riding_pumping_controller.rs @@ -0,0 +1,338 @@ +//! Independent control-flow checks with supplied geometric measurements. +//! These do not implement or certify the missing native geometry operations. +use skate_core::{ + point_graph::PointGraph, + riding::pumping::{ + controller::{ + GROUND_PUMPING_TIMESTEP, PumpingGeometry, PumpingSample, calculate, update, + update_ground, + }, + settings::{PumpingMode, PumpingSettings}, + state::PumpingState, + }, +}; + +fn constant(value: f32) -> PointGraph<8> { + PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [value; 8], + } +} +fn settings() -> PumpingSettings { + PumpingSettings { + pump_vs_speed: constant(2.0), + pump_vs_time: constant(1.0), + min_crouch_vs_ground_angle: constant(0.25), + compression_vs_ground_angle: constant(0.5), + compression_vs_deck_angle: constant(0.75), + height_change_damping: 1.0, + minimum_height_change: 0.1, + maximum_height_change: 0.5, + ground_compression_scale: -4.0, + deck_compression_scale: -8.0, + angular_speed_damping: 1.0, + } +} +fn mode() -> PumpingMode { + PumpingMode { + maximum_absorption_per_second: 100.0, + maximum_acceleration_per_second: 100.0, + absorption_factor: 3.0, + acceleration_factor: 5.0, + } +} +fn sample() -> PumpingSample { + PumpingSample { + position: [4., 5., 6., 9.], + normal: [0., 1., 0., 7.], + com_to_deck_world: [1., 2., 3., 0.], + deck_angle: 0.0, + intentional_pumping: 1, + } +} + +struct Measurements { + height: f32, + angular: f32, + calls: Vec<&'static str>, + times: Vec, + inclination_input: Option, + fail_speed: bool, +} +impl Measurements { + fn new(height: f32, angular: f32) -> Self { + Self { + height, + angular, + calls: vec![], + times: vec![], + inclination_input: None, + fail_speed: false, + } + } +} +impl PumpingGeometry for Measurements { + type Error = &'static str; + fn height(&mut self, normal: [f32; 4], com: [f32; 4]) -> Result { + self.calls.push("height"); + assert_eq!(normal, sample().normal); + assert_eq!(com, sample().com_to_deck_world); + Ok(self.height) + } + fn speed(&mut self, _old: [f32; 4], _now: [f32; 4], dt: f32) -> Result { + self.calls.push("speed"); + self.times.push(dt); + if self.fail_speed { + Err("native speed unavailable") + } else { + Ok(4.0) + } + } + fn angular_speed( + &mut self, + _position: [f32; 4], + _normal: [f32; 4], + _sample: &PumpingSample, + dt: f32, + ) -> Result { + self.calls.push("angular"); + self.times.push(dt); + Ok(self.angular) + } + fn inclination_radians(&mut self, input: f32) -> Result { + self.calls.push("inclination"); + self.inclination_input = Some(input); + Ok(0.0) + } +} +fn seeded() -> PumpingState { + let mut state = PumpingState::reset_state(); + state.record_valid = true; + state.previous_normal = [0., 0.5, 0., 0.]; + state +} + +#[test] +fn first_update_only_records_history_and_clears_force() { + let mut state = PumpingState::reset_state(); + state.pump_acceleration = 42.0; + state.absorption = 3.0; + state.reset_only_scalar = 11.0; + state.intentional_pumping = 7; + let mut geometry = Measurements::new(2.0, 99.0); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut geometry, + ) + .unwrap(); + assert_eq!(geometry.calls, ["height"]); + assert_eq!(state.previous_position, sample().position); + assert_eq!(state.previous_normal, sample().normal); + assert_eq!(state.previous_height, 2.0); + assert!(state.record_valid); + assert_eq!(state.pump_acceleration, 0.0); + assert_eq!(state.absorption, 3.0); + assert_eq!(state.reset_only_scalar, 11.0); + assert_eq!(state.intentional_pumping, 7); +} + +#[test] +fn positive_pumping_caps_height_before_mode_scaling_and_updates_outputs() { + let mut state = seeded(); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut Measurements::new(2.0, 2.0), + ) + .unwrap(); + assert_eq!(state.smoothed_height_change, 2.0); + assert_eq!(state.pumping_time, 0.25); + assert_eq!(state.pumping, 4.0); + assert_eq!(state.pump_acceleration, 10.0); // 4 * 5 * capped 0.5 + assert_eq!(state.absorption, -8.0); + assert_eq!(state.ground_normal_absorption, -6.0); // greater squared deck magnitude + assert_eq!(state.minimum_crouch, 0.25); + assert_eq!(state.physics_output().compression, 0.0); + assert_eq!(state.physics_output().intentional_pumping, 1); +} + +#[test] +fn absorption_and_acceleration_use_distinct_factors_and_time_scaled_limits() { + let mut limits = mode(); + limits.maximum_absorption_per_second = 2.0; + limits.maximum_acceleration_per_second = 4.0; + for (angular, expected) in [(-2.0, -0.5), (2.0, 1.0)] { + let mut state = seeded(); + update( + &mut state, + &settings(), + limits, + sample(), + 0.25, + &mut Measurements::new(2.0, angular), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, expected); + } + let mut state = seeded(); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut Measurements::new(2.0, -2.0), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, -6.0); // -4 * absorption factor 3 * 0.5 +} + +#[test] +fn falling_height_does_not_pump_but_geometry_and_compression_still_update() { + let mut state = seeded(); + state.previous_height = 5.0; + state.pumping_time = 9.0; + let mut geometry = Measurements::new(2.0, 2.0); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut geometry, + ) + .unwrap(); + assert_eq!(state.pumping_time, 0.0); + assert_eq!(state.pump_acceleration, 0.0); + assert_eq!(state.absorption, -8.0); + assert_eq!( + geometry.calls, + ["height", "speed", "angular", "inclination"] + ); + assert_eq!(geometry.inclination_input, Some(0.5)); // previous normal, not this tick's normal +} + +#[test] +fn ground_pumping_uses_its_native_fixed_step() { + let mut state = seeded(); + let mut geometry = Measurements::new(0.25, 2.0); + update_ground(&mut state, &settings(), mode(), sample(), &mut geometry).unwrap(); + assert_eq!(state.pumping_time.to_bits(), 0x3c88_8889); + assert_eq!(geometry.times, [GROUND_PUMPING_TIMESTEP; 2]); +} + +#[test] +fn reset_invalidates_history_and_all_owned_outputs() { + let mut state = seeded(); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut Measurements::new(2.0, 2.0), + ) + .unwrap(); + state.reset_only_scalar = 7.0; + state.reset(); + assert_eq!(state, PumpingState::reset_state()); + let mut geometry = Measurements::new(2.0, 2.0); + update_ground(&mut state, &settings(), mode(), sample(), &mut geometry).unwrap(); + assert_eq!(geometry.calls, ["height"]); + assert_eq!(state.intentional_pumping, 0); +} + +#[test] +fn missing_geometry_aborts_before_cache_commit_or_force_publication() { + let mut state = seeded(); + state.previous_position = [8.0; 4]; + state.pump_acceleration = 12.0; + let mut geometry = Measurements::new(2.0, 2.0); + geometry.fail_speed = true; + assert_eq!( + update_ground(&mut state, &settings(), mode(), sample(), &mut geometry), + Err("native speed unavailable") + ); + assert_eq!(state.previous_position, [8.0; 4]); + assert_eq!(state.previous_height, 0.0); + assert_eq!(state.pump_acceleration, 0.0); + assert_eq!(geometry.calls, ["height", "speed"]); +} + +#[test] +fn calculate_preserves_update_owned_history_and_equal_compression_magnitude() { + let mut state = seeded(); + state.previous_normal[1] = -2.0; + state.pump_acceleration = 11.0; + state.pumping_time = 4.0; + let mut settings = settings(); + settings.deck_compression_scale = 8.0 / 3.0; + let mut geometry = Measurements::new(1.0, 2.0); + assert_eq!( + calculate(&mut state, &settings, &sample(), 0.25, &mut geometry).unwrap(), + 4.0 + ); + assert_eq!(state.ground_normal_absorption, -2.0); // tie does not select positive deck compression + assert_eq!(geometry.inclination_input, Some(0.0)); + assert_eq!(state.pump_acceleration, 11.0); + assert_eq!(state.pumping_time, 4.0); +} + +#[test] +fn threshold_equality_and_negative_history_keep_native_signed_comparisons() { + let mut config = settings(); + config.minimum_height_change = 0.25; + let mut state = seeded(); + update( + &mut state, + &config, + mode(), + sample(), + 0.25, + &mut Measurements::new(0.25, 2.0), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, 5.0); // equality is active, not discarded + config.height_change_damping = 0.0; + config.minimum_height_change = -3.0; + state.smoothed_height_change = -2.0; + update( + &mut state, + &config, + mode(), + sample(), + 0.25, + &mut Measurements::new(0.25, 2.0), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, -6.0); + assert_eq!(state.pumping_time, 0.0); +} + +#[test] +fn unordered_history_and_normal_follow_native_compare_and_select_paths() { + let mut state = seeded(); + state.smoothed_height_change = f32::NAN; + state.previous_normal[1] = f32::NAN; + let mut geometry = Measurements::new(0.25, 2.0); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut geometry, + ) + .unwrap(); + assert!(state.smoothed_height_change.is_nan()); + assert_eq!(state.pumping_time, 0.0); + assert_eq!(state.pump_acceleration, 25.0); // upper fsel selects bound for unordered difference + assert_eq!(geometry.inclination_input, Some(1.0)); +} diff --git a/crates/skate-core/tests/wheel_ground_solver.rs b/crates/skate-core/tests/wheel_ground_solver.rs new file mode 100644 index 00000000..b9c5ea80 --- /dev/null +++ b/crates/skate-core/tests/wheel_ground_solver.rs @@ -0,0 +1,190 @@ +//! Wheel/ground response through the production shared solver. These tests +//! query the production sphere/triangle path before compiling the contact. +//! Wheel mass/radius/inertia come from the recovered stock assembly. +use skate_core::{ + math::Vector3, + physics::{ + board::RETAIL_WHEEL_RADIUS, + collision::{ + Sphere, Triangle, TriangleFeature, WorldContactSettings, sphere_triangle_world_contact, + }, + contact::{RetailContactBodyState, RetailContactInput, generate_contact}, + contact_solver::{RetailContactJacobian, build_contact_jacobian}, + mass::retail_wheel_mass_properties, + rigid_body::RetailReactionCorrections, + solver::solve_constraints, + }, +}; + +const DT: f32 = 1.0 / 60.0; +const GRAVITY: f32 = 9.8; + +fn contact(speed: f32, spin: f32, force: f32, friction: (f32, f32)) -> RetailContactJacobian { + let inertia = retail_wheel_mass_properties().dynamics; + let world = RetailContactBodyState { + contact_body_id: u32::MAX, + reaction_id: 1, + state: 0, + center_of_mass: Vector3::ZERO, + inverse_inertia_full: Vector3::ZERO, + inverse_inertia_split: Vector3::ZERO, + inverse_mass: 0.0, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }; + let wheel = RetailContactBodyState { + contact_body_id: 0, + reaction_id: 0, + state: 4, + center_of_mass: Vector3::new(0.0, RETAIL_WHEEL_RADIUS, 0.0), + inverse_inertia_full: Vector3::new(inertia.inverse_tensor.x, 0.0, 0.0), + inverse_inertia_split: Vector3::new( + inertia.inverse_tensor.z, + inertia.inverse_tensor.y, + 0.0, + ), + inverse_mass: inertia.inverse_mass, + force_acceleration: Vector3::new(0.0, -GRAVITY, force * inertia.inverse_mass), + linear_velocity: Vector3::new(0.0, 0.0, speed), + angular_velocity: Vector3::new(spin, 0.0, 0.0), + ..world + }; + let pair = sphere_triangle_world_contact( + Sphere { + center: wheel.center_of_mass, + radius: RETAIL_WHEEL_RADIUS, + }, + Triangle { + vertices: [ + Vector3::new(-2.0, 0.0, -2.0), + Vector3::new(2.0, 0.0, -2.0), + Vector3::new(-2.0, 0.0, 2.0), + ], + feature: TriangleFeature { + normal: Vector3::new(0.0, 1.0, 0.0), + edges: [ + Vector3::new(0.0, 0.0, 1.0), + Vector3::new( + std::f32::consts::FRAC_1_SQRT_2, + 0.0, + -std::f32::consts::FRAC_1_SQRT_2, + ), + Vector3::new(-1.0, 0.0, 0.0), + ], + flags: 0x1F0, + edge_cosines: [1.0; 3], + }, + edge_lengths: [4.0, 4.0 * 2.0_f32.sqrt(), 4.0], + fatness: 0.0, + }, + wheel.linear_velocity, + WorldContactSettings { + volume_padding: 0.0, + maximum_separating_distance: 0.0, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + }, + ) + .expect("wheel sphere reaches the triangle"); + build_contact_jacobian( + generate_contact( + RetailContactInput { + position_on_a: pair.points.a, + position_on_b: pair.points.b, + normal: pair.normal, + static_friction: friction.0, + dynamic_friction: friction.1, + restitution: 0.0, + tag: 0, + }, + wheel, + world, + ), + DT, + ) +} + +fn solve( + speed: f32, + spin: f32, + force: f32, + friction: (f32, f32), + iterations: u32, +) -> (f32, f32, RetailContactJacobian) { + let mut contacts = [contact(speed, spin, force, friction)]; + let mut reactions = [RetailReactionCorrections::default(); 2]; + solve_constraints(&mut contacts, &mut [], &mut [], &mut reactions, iterations); + let r = reactions[0]; + let inv_mass = retail_wheel_mass_properties().dynamics.inverse_mass; + let velocity = speed + force * inv_mass * DT + r.linear_displacement.z / DT; + let angular = spin + r.angular_displacement.x / DT; + assert_eq!( + reactions[1], + RetailReactionCorrections::default(), + "static world must not move" + ); + if iterations > 0 { + assert!( + (-GRAVITY * DT + r.linear_displacement.y / DT).abs() < 1e-6, + "ground supports the wheel" + ); + } + (velocity, angular, contacts[0]) +} + +#[test] +fn ground_friction_spins_a_slipping_wheel_in_both_directions() { + for speed in [-1.0, 1.0] { + let (velocity, angular, _) = solve(speed, 0.0, 0.0, (0.8, 0.7), 25); + assert!(velocity.abs() < speed.abs()); + assert!(angular * speed > 0.0); + assert!((velocity - angular * RETAIL_WHEEL_RADIUS).abs() < speed.abs()); + let inertia = retail_wheel_mass_properties().dynamics; + let linear_impulse = (velocity - speed) / inertia.inverse_mass; + let angular_impulse = angular / inertia.inverse_tensor.x; + assert!((angular_impulse + RETAIL_WHEEL_RADIUS * linear_impulse).abs() < 1e-7); + } +} + +#[test] +fn a_rolling_wheel_does_not_receive_artificial_drag() { + for speed in [-3.0, 3.0] { + let spin = speed / RETAIL_WHEEL_RADIUS; + let (velocity, angular, _) = solve(speed, spin, 0.0, (0.8, 0.7), 25); + assert!((velocity - speed).abs() < 1e-6); + assert!((angular - spin).abs() < 1e-5); + } +} + +#[test] +fn frictionless_ground_supports_without_spinning_or_slowing_the_wheel() { + let (velocity, angular, _) = solve(2.0, 0.0, 0.0, (0.0, 0.0), 25); + assert_eq!(velocity, 2.0); + assert_eq!(angular, 0.0); +} + +#[test] +fn static_grip_transitions_to_dynamic_friction_under_larger_load() { + let (velocity, angular, _) = solve(0.0, 0.0, 0.1, (0.8, 0.7), 25); + assert!(velocity > 0.0 && angular > 0.0); + assert!((velocity - angular * RETAIL_WHEEL_RADIUS).abs() < 1e-6); + let (velocity, angular, contact) = solve(0.0, 0.0, 30.0, (0.8, 0.7), 25); + assert!(velocity - angular * RETAIL_WHEEL_RADIUS > 0.1); + let tangential = contact.accumulated_impulse()[1] + .abs() + .max(contact.accumulated_impulse()[2].abs()); + assert!((tangential - 0.7 * contact.accumulated_impulse()[0]).abs() < 1e-8); +} + +#[test] +fn first_contact_pass_uses_previous_normal_impulse_for_friction() { + let (_, first_spin, _) = solve(1.0, 0.0, 0.0, (0.8, 0.7), 1); + let (_, second_spin, _) = solve(1.0, 0.0, 0.0, (0.8, 0.7), 2); + assert_eq!(first_spin, 0.0); + assert!(second_spin > 0.0); +} diff --git a/crates/skate-data/Cargo.toml b/crates/skate-data/Cargo.toml new file mode 100644 index 00000000..c811da07 --- /dev/null +++ b/crates/skate-data/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "skate-data" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +skate-core = { path = "../skate-core" } +serde.workspace = true +serde_json.workspace = true diff --git a/crates/skate-data/src/abin/bank.rs b/crates/skate-data/src/abin/bank.rs new file mode 100644 index 00000000..0e989ff9 --- /dev/null +++ b/crates/skate-data/src/abin/bank.rs @@ -0,0 +1,316 @@ +//! Ordered bank indexing and lossless header decoding. Codec math is separate. +use super::*; +use std::{collections::BTreeMap, fs, path::Path, sync::Arc}; + +pub struct Bank { + bytes: Arc<[u8]>, + pub wrapper_bytes: usize, + pub trailer: Range, + records: Vec, + offsets: BTreeMap, + animations: BTreeMap, + clips: BTreeMap, + poses: BTreeMap, + hierarchy: Option, +} +impl Bank { + pub fn load(path: &Path) -> Result { + let bytes = + fs::read(path).map_err(|e| Error::new(0, format!("{}: {e}", path.display())))?; + Self::parse(bytes) + } + pub fn parse(bytes: Vec) -> Result { + let reader = Reader::new(&bytes); + let end = reader.u32(0)? as usize; + if reader.u32(4)? != 1 || end < 48 || end > bytes.len() { + return Err(Error::new(0, "invalid type1 bank wrapper")); + } + let mut records = Vec::new(); + let mut offsets = BTreeMap::new(); + let mut animations = BTreeMap::new(); + let mut clips = BTreeMap::new(); + let mut poses = BTreeMap::new(); + let mut hierarchy = None; + let mut at = 48; + while at < end { + let size = reader.u32(at)? as usize; + if size < 48 || size % 16 != 0 || size > end - at { + return Err(Error::new(at, "invalid record size/alignment")); + } + let r = reader.bounded(at..at + size)?; + let header = RecordHeader { + name: r.name(at + 16, 6)?, + name_words: r.words(at + 16)?, + offset: at, + size, + payload_offset: (at + 55) & !15, + type_id: r.u32(at + 4)?, + codec_word: r.u32(at + 8)?, + guid: r.u32(at + 12)?, + }; + let data = match header.type_id { + 2 => RecordData::Clip(read_clip(r, &header)?), + 3 => RecordData::Pose(Pose { + parts: read_parts( + r, + header.payload_offset, + 4, + r.u32(header.payload_offset)? as usize, + false, + )?, + }), + 4 => RecordData::Hierarchy(read_hierarchy(r, header.payload_offset)?), + 5 => { + let n = r.u32(header.payload_offset)?; + let size = (n as usize) + .checked_mul(112) + .ok_or_else(|| Error::new(at, "physics bone size overflow"))?; + RecordData::PhysicsPose(PhysicsPose { + bone_count: n, + records: r.relative(header.payload_offset, 16, size)?, + }) + } + _ => RecordData::Opaque, + }; + let index = records.len(); + // SetDBContent82D1B428..45C overwrites map entries in file order. + // Clips also participate in the animation-tree map, poses do not. + match header.type_id { + 2 => { + clips.insert(header.name.clone(), index); + animations.insert(header.name.clone(), index); + } + 3 => { + poses.insert(header.name.clone(), index); + } + 4 => { + hierarchy = Some(index); + } + 6..=11 => { + animations.insert(header.name.clone(), index); + } + _ => {} + } + offsets.insert(at, index); + records.push(Record { header, data }); + at += size; + } + let file_size = bytes.len(); + Ok(Self { + bytes: bytes.into(), + wrapper_bytes: end, + trailer: end..file_size, + records, + offsets, + animations, + clips, + poses, + hierarchy, + }) + } + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + pub fn records(&self) -> &[Record] { + &self.records + } + pub fn record_at(&self, offset: usize) -> Option<&Record> { + self.offsets.get(&offset).map(|&i| &self.records[i]) + } + pub fn animation(&self, name: &str) -> Option<&Record> { + self.animations + .get(&name.to_ascii_uppercase()) + .map(|&i| &self.records[i]) + } + pub fn clip(&self, name: &str) -> Option<(&RecordHeader, &Clip)> { + let record = &self.records[*self.clips.get(&name.to_ascii_uppercase())?]; + if let RecordData::Clip(clip) = &record.data { + Some((&record.header, clip)) + } else { + None + } + } + pub fn pose(&self, name: &str) -> Option<(&RecordHeader, &Pose)> { + let record = &self.records[*self.poses.get(&name.to_ascii_uppercase())?]; + if let RecordData::Pose(pose) = &record.data { + Some((&record.header, pose)) + } else { + None + } + } + pub fn hierarchy(&self) -> Option<&Hierarchy> { + match &self.records[self.hierarchy?].data { + RecordData::Hierarchy(v) => Some(v), + _ => None, + } + } + ///The stock OnBoard/OffBoard VBR banks carry all eight parts in hierarchy + ///order. Their encoded ids are all zero; source82D20608/82D20924 advances + ///its equal-id cursor one entry at a time, preserving this ordinal mapping. + ///Call before using positional part association; partial banks may require + ///the original named/id association and are not silently treated as full. + pub fn validate_positional_parts(&self) -> Result<()> { + let hierarchy = self + .hierarchy() + .ok_or_else(|| Error::new(0, "missing hierarchy"))?; + for record in &self.records { + let parts = match &record.data { + RecordData::Clip(v) => &v.parts, + RecordData::Pose(v) => &v.parts, + _ => continue, + }; + if parts.len() != hierarchy.parts.len() { + return Err(Error::new( + record.header.offset, + "part table differs from full hierarchy", + )); + } + for (entry, layout) in parts.iter().zip(&hierarchy.parts) { + if let Some(part) = &entry.part { + if part.channel_count as u32 != layout.bone_count { + return Err(Error::new( + part.offset, + "part channel count differs from hierarchy", + )); + } + } + } + } + Ok(()) + } +} + +fn read_clip(r: Reader<'_>, h: &RecordHeader) -> Result { + let p = h.payload_offset; + r.take(p, 56)?; + let flags = r.u32(p + 52)?; + let attribute_count = flags as u8; + let attribute_offset = r.relative(p, r.u32(p + 44)?, 0)?.start; + Ok(Clip { + fps_bits: r.u32(p + 32)?, + frame_count_bits: r.u32(p + 36)?, + base_speed_bits: r.u32(p + 40)?, + flags, + loop_translation_words: r.words(p)?, + loop_rotation_words: r.words(p + 16)?, + attribute_offset, + attribute_count, + parts: read_parts( + r, + p, + 56, + ((flags >> 8) & 31) as usize, + flags & 0x2000_0000 != 0, + )?, + }) +} +fn read_parts( + r: Reader<'_>, + base: usize, + table: usize, + count: usize, + channel: bool, +) -> Result> { + let size = count + .checked_mul(4) + .ok_or_else(|| Error::new(base, "part table size overflow"))?; + r.take(base + table, size)?; + let mut parts = Vec::with_capacity(count); + for i in 0..count { + let table_offset = base + table + 4 * i; + let word = r.u32(table_offset)?; + let relative = word & 0x00ff_ffff; + let part = if relative == 0 { + None + } else { + let offset = r.relative(base, relative, 16)?.start; + let header_word = r.u32(offset)?; + let compressed_size = r.u32(offset + 4)?; + let compression_header_relative = r.u32(offset + 8)?; + let compressed_data_relative = r.u32(offset + 12)?; + let channel_count = (((header_word >> 6) & 63) + 1) as u16; + let compression_header = r.relative( + offset, + compression_header_relative, + (header_word >> 12) as usize, + )?; + let compressed_data = + r.relative(offset, compressed_data_relative, compressed_size as usize)?; + let channel_weights = if channel { + Some(r.relative(offset, 16, 4 * channel_count as usize)?) + } else { + None + }; + Some(AnimationPart { + offset, + header_word, + compressed_size, + compression_header_relative, + compressed_data_relative, + channel_count, + compression_header, + compressed_data, + channel_weights, + }) + }; + parts.push(PartEntry { + table_index: i, + table_offset, + raw_word: word, + encoded_id: (word >> 24) as u8, + part, + }); + } + Ok(parts) +} +fn read_hierarchy(r: Reader<'_>, p: usize) -> Result { + let bone_count = r.u16(p)?; + let n = bone_count as usize; + let count = r.u32(p + 4)? as usize; + let part_bytes = count + .checked_mul(36) + .ok_or_else(|| Error::new(p, "hierarchy part count overflow"))?; + let part_base = p + 8 + 8 * n; + r.take(p + 8, 8 * n)?; + r.take(part_base, part_bytes)?; + let name_base = part_base + part_bytes; + r.take(name_base, 20 * n)?; + let mut parents = Vec::with_capacity(n); + let mut mirrors = Vec::with_capacity(n); + let mut bone_names = Vec::with_capacity(n); + for i in 0..n { + let parent = r.i32(p + 8 + 4 * i)?; + let mirror = r.i32(p + 8 + 4 * n + 4 * i)?; + if parent < -1 || parent >= i as i32 || mirror < -1 || mirror >= n as i32 { + return Err(Error::new(p + 8 + 4 * i, "invalid parent or mirror index")); + } + parents.push(parent); + mirrors.push(mirror); + bone_names.push(r.name(name_base + 20 * i, 5)?); + } + let mut parts = Vec::with_capacity(count); + for i in 0..count { + let at = part_base + i * 36; + let bone_count = r.u32(at + 24)?; + let sqt_offset = r.i32(at + 28)?; + if sqt_offset < 0 || sqt_offset as usize + bone_count as usize > n { + return Err(Error::new(at, "part extends outside skeleton output")); + } + parts.push(HierarchyPart { + name: r.name(at, 6)?, + bone_count, + sqt_offset, + flags: r.u16(at + 32)?, + encoded_id: r.u16(at + 34)?, + }); + } + Ok(Hierarchy { + offset: p, + bone_count, + has_trajectory: r.u16(p + 2)? != 0, + parents, + mirrors, + bone_names, + parts, + }) +} diff --git a/crates/skate-data/src/abin/mod.rs b/crates/skate-data/src/abin/mod.rs new file mode 100644 index 00000000..506da948 --- /dev/null +++ b/crates/skate-data/src/abin/mod.rs @@ -0,0 +1,168 @@ +//! Direct stock Andale bank storage and format descriptions. +//! +//! Source: TU3 DataBase::SetDBContent82D1B340, Clip::Init827B8AB0, +//! FetchSys::BatchFetch82D20560/82D20788 and codec identifier82D17A60. +//! This module owns bytes and bounds checks; codec arithmetic belongs to core. +mod bank; +mod reader; +pub use bank::Bank; +pub use reader::Reader; +use std::{fmt, ops::Range}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Error { + pub offset: usize, + pub message: String, +} +impl Error { + pub fn new(offset: usize, message: impl Into) -> Self { + Self { + offset, + message: message.into(), + } + } +} +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ABIN at {:#x}: {}", self.offset, self.message) + } +} +impl std::error::Error for Error {} +pub type Result = std::result::Result; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Codec { + Raw, + Vbr, + Other(u32), +} +impl Codec { + ///82D17A60 reverses the disk word and compares RD\0\0 and VBR\0. + pub fn from_word(word: u32) -> Self { + match word { + 0x0000_4452 => Self::Raw, + 0x0052_4256 => Self::Vbr, + v => Self::Other(v), + } + } +} +#[derive(Clone, Debug)] +pub struct RecordHeader { + pub name: String, + pub name_words: [u32; 6], + pub offset: usize, + pub size: usize, + pub payload_offset: usize, + pub type_id: u32, + pub codec_word: u32, + pub guid: u32, +} +impl RecordHeader { + pub fn codec(&self) -> Codec { + Codec::from_word(self.codec_word) + } + pub fn range(&self) -> Range { + self.offset..self.offset + self.size + } +} +#[derive(Clone, Debug)] +pub struct Record { + pub header: RecordHeader, + pub data: RecordData, +} +#[derive(Clone, Debug)] +pub enum RecordData { + Clip(Clip), + Pose(Pose), + Hierarchy(Hierarchy), + PhysicsPose(PhysicsPose), + Opaque, +} +#[derive(Clone, Debug)] +pub struct Clip { + pub fps_bits: u32, + pub frame_count_bits: u32, + pub base_speed_bits: u32, + pub flags: u32, + pub loop_translation_words: [u32; 4], + pub loop_rotation_words: [u32; 4], + pub attribute_offset: usize, + pub attribute_count: u8, + pub parts: Vec, +} +impl Clip { + pub fn looping(&self) -> bool { + self.flags & 0x1000_0000 != 0 + } + pub fn channel_animation(&self) -> bool { + self.flags & 0x2000_0000 != 0 + } + pub fn phase_controlled(&self) -> bool { + self.flags & 0x4000_0000 != 0 + } +} +#[derive(Clone, Debug)] +pub struct Pose { + pub parts: Vec, +} +#[derive(Clone, Debug)] +pub struct PhysicsPose { + pub bone_count: u32, + ///24 authored112-byte PhysicsParamBoneData records in the stock banks. + pub records: Range, +} +#[derive(Clone, Debug)] +pub struct PartEntry { + pub table_index: usize, + pub table_offset: usize, + pub raw_word: u32, + pub encoded_id: u8, + ///An authored zero offset remains an empty entry; no table compaction. + pub part: Option, +} +/// Common original AnimationPart header for RAW and VBR. Relative words and +/// absolute byte ranges coexist so consumers never accidentally mix bases. +#[derive(Clone, Debug)] +pub struct AnimationPart { + pub offset: usize, + pub header_word: u32, + pub compressed_size: u32, + pub compression_header_relative: u32, + pub compressed_data_relative: u32, + ///Header bits6..11 plus1, verified against all stock hierarchy parts. + pub channel_count: u16, + ///Packed header bits12..31 give the byte count at compression_header_relative. + pub compression_header: Range, + pub compressed_data: Range, + ///Original channel-animation weight words begin at part+16. + pub channel_weights: Option>, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HierarchyPart { + pub name: String, + pub bone_count: u32, + pub sqt_offset: i32, + ///Retained original words; no runtime part-id rewriting is performed. + pub flags: u16, + pub encoded_id: u16, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Hierarchy { + pub offset: usize, + pub bone_count: u16, + pub has_trajectory: bool, + pub parents: Vec, + pub mirrors: Vec, + pub bone_names: Vec, + pub parts: Vec, +} +impl Hierarchy { + pub fn compatible_with(&self, other: &Self) -> bool { + self.bone_count == other.bone_count + && self.has_trajectory == other.has_trajectory + && self.parents == other.parents + && self.mirrors == other.mirrors + && self.bone_names == other.bone_names + && self.parts == other.parts + } +} diff --git a/crates/skate-data/src/abin/reader.rs b/crates/skate-data/src/abin/reader.rs new file mode 100644 index 00000000..9d6684b2 --- /dev/null +++ b/crates/skate-data/src/abin/reader.rs @@ -0,0 +1,113 @@ +//! Checked big-endian reads with absolute offsets retained through subrecords. +use super::{Error, Result}; +use std::ops::Range; + +#[derive(Clone, Copy)] +pub struct Reader<'a> { + bytes: &'a [u8], + begin: usize, + end: usize, +} +impl<'a> Reader<'a> { + pub fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + begin: 0, + end: bytes.len(), + } + } + pub fn bounded(self, range: Range) -> Result { + self.take( + range.start, + range + .end + .checked_sub(range.start) + .ok_or_else(|| Error::new(range.start, "reversed record range"))?, + )?; + Ok(Self { + bytes: self.bytes, + begin: range.start, + end: range.end, + }) + } + pub fn take(self, offset: usize, size: usize) -> Result<&'a [u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::new(offset, "read overflow"))?; + if offset < self.begin || end > self.end { + return Err(Error::new( + offset, + format!( + "read size{size} exceeds record {:#x}..{:#x}", + self.begin, self.end + ), + )); + } + Ok(&self.bytes[offset..end]) + } + pub fn u8(self, offset: usize) -> Result { + Ok(self.take(offset, 1)?[0]) + } + pub fn u16(self, offset: usize) -> Result { + Ok(u16::from_be_bytes( + self.take(offset, 2)?.try_into().unwrap(), + )) + } + pub fn u32(self, offset: usize) -> Result { + Ok(u32::from_be_bytes( + self.take(offset, 4)?.try_into().unwrap(), + )) + } + pub fn i32(self, offset: usize) -> Result { + Ok(self.u32(offset)? as i32) + } + pub fn words(self, offset: usize) -> Result<[u32; N]> { + let raw = self.take( + offset, + N.checked_mul(4) + .ok_or_else(|| Error::new(offset, "word count overflow"))?, + )?; + Ok(core::array::from_fn(|i| { + u32::from_be_bytes(raw[4 * i..4 * i + 4].try_into().unwrap()) + })) + } + ///Five words encode FastString30, six encode FastString36. Names stay + ///uppercase as authored; padding remains word-local, matching stock names. + pub fn name(self, offset: usize, words: usize) -> Result { + let raw = self.take( + offset, + words + .checked_mul(4) + .ok_or_else(|| Error::new(offset, "name size overflow"))?, + )?; + const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_"; + let mut result = String::new(); + for raw_word in raw.chunks_exact(4) { + let mut value = u32::from_be_bytes(raw_word.try_into().unwrap()); + let mut divisor = 38u32.pow(5); + for _ in 0..6 { + let digit = value / divisor; + value %= divisor; + if digit == 0 { + if value != 0 { + return Err(Error::new(offset, "invalid FastString padding")); + } + break; + } + let character = ALPHABET + .get(digit as usize - 1) + .ok_or_else(|| Error::new(offset, "invalid FastString digit"))?; + result.push(*character as char); + divisor /= 38; + } + } + Ok(result) + } + pub(crate) fn relative(self, base: usize, offset: u32, size: usize) -> Result> { + let start = base + .checked_add(offset as usize) + .ok_or_else(|| Error::new(base, "relative offset overflow"))?; + self.take(start, size)?; + Ok(start..start + size) + } +} diff --git a/crates/skate-data/src/animation_banks.rs b/crates/skate-data/src/animation_banks.rs new file mode 100644 index 00000000..d04c4761 --- /dev/null +++ b/crates/skate-data/src/animation_banks.rs @@ -0,0 +1,46 @@ +//! Own both stock skater banks once. Clip/tree names are disjoint; poses retain +//! their bank identity because the banks share several reference-pose names. +use crate::{abin::Bank, animation_metadata::AnimationMetadata, sha256}; +use std::{path::Path, sync::Arc}; + +pub struct AnimationBanks { + pub banks: Vec>, + pub identities: Vec, +} +impl AnimationBanks { + pub fn load(asset_root: &Path) -> Result { + let mut banks = Vec::new(); + let mut identities = Vec::new(); + for name in ["OnBoard", "OffBoard"] { + let bank = Bank::load(&asset_root.join(format!("private/stock/data/anim/{name}.abin"))) + .map_err(|e| format!("{name}: {e}"))?; + bank.validate_positional_parts() + .map_err(|e| e.to_string())?; + identities.push(sha256::digest(bank.bytes())); + banks.push(Arc::new(bank)); + } + let first = banks[0] + .hierarchy() + .ok_or("OnBoard has no animation hierarchy")?; + let second = banks[1] + .hierarchy() + .ok_or("OffBoard has no animation hierarchy")?; + if !first.compatible_with(second) { + return Err("Skater animation bank hierarchies differ".into()); + } + Ok(Self { banks, identities }) + } + pub fn metadata(&self) -> Result { + let mut result = AnimationMetadata::from_bank( + &self.banks[0], + "OnBoard.abin".into(), + self.identities[0].clone(), + )?; + result.merge(AnimationMetadata::from_bank( + &self.banks[1], + "OffBoard.abin".into(), + self.identities[1].clone(), + )?)?; + Ok(result) + } +} diff --git a/crates/skate-data/src/animation_frames.rs b/crates/skate-data/src/animation_frames.rs new file mode 100644 index 00000000..0070f415 --- /dev/null +++ b/crates/skate-data/src/animation_frames.rs @@ -0,0 +1,252 @@ +//! Decoded stock samples. Playback/interpolation and reference composition are +//! separate core operations; this loader never repairs missing bone channels. +use crate::animation_metadata::AnimationMetadata; +use serde::Deserialize; +use std::{collections::BTreeMap, fs, path::Path}; +mod native; + +/// Scale xyz, quaternion xyzw, translation xyz; each component is an f32 word. +pub type SampleWords = [u32; 10]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClipFrames { + pub name: String, + pub source_offset: u64, + pub fps_bits: u32, + pub loop_translation_bits: [u32; 3], + pub loop_rotation_bits: [u32; 4], + pub channel_animation: bool, + pub channel_weights: Vec, + pub frames: Vec>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReferencePose { + pub name: String, + pub source_offset: u64, + pub samples: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + source_sha256: String, + decoder_sha256: String, + decoder_hardware_verified: bool, + parents: Vec, + bone_names: Vec, + mirror_indices: Vec, + has_trajectory: bool, + clips: Vec, + reference_poses: Vec, +} + +pub struct AnimationFrames { + pub source_sha256: String, + pub decoder_sha256: String, + pub decoder_hardware_verified: bool, + pub parents: Vec, + pub bone_names: Vec, + pub mirror_indices: Vec, + pub has_trajectory: bool, + clips: BTreeMap, + reference_poses: BTreeMap, + native: Option, +} + +impl AnimationFrames { + pub fn from_banks(banks: &crate::animation_banks::AnimationBanks) -> Result { + let hierarchy = banks.banks[0] + .hierarchy() + .ok_or("Missing animation hierarchy")?; + Ok(Self { + source_sha256: banks.identities[0].clone(), + decoder_sha256: String::new(), + decoder_hardware_verified: false, + parents: hierarchy.parents.clone(), + bone_names: hierarchy.bone_names.clone(), + mirror_indices: hierarchy.mirrors.clone(), + has_trajectory: hierarchy.has_trajectory, + clips: BTreeMap::new(), + reference_poses: BTreeMap::new(), + native: Some(native::NativeFrames::new(banks)?), + }) + } + pub fn load(path: &Path, metadata: &AnimationMetadata) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("Cannot read {}: {error}", path.display()))?; + Self::parse(&text, metadata).map_err(|error| format!("{}: {error}", path.display())) + } + + pub fn parse(text: &str, metadata: &AnimationMetadata) -> Result { + let file: File = serde_json::from_str(text).map_err(|error| error.to_string())?; + if file.version != 3 || file.source_sha256 != metadata.source_sha256 { + return Err( + "Animation sample format or stock bank identity differs from metadata".into(), + ); + } + if file.decoder_sha256.len() != 64 + || !file.decoder_sha256.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err("Animation sample decoder identity is invalid".into()); + } + let bones = file.parents.len(); + if bones == 0 || bones > 255 { + return Err("Animation sample hierarchy has invalid bone count".into()); + } + for (index, &parent) in file.parents.iter().enumerate() { + if parent < -1 || parent >= index as i32 { + return Err(format!("Animation bone{index} has invalid parent{parent}")); + } + } + if file.bone_names.len() != bones || file.mirror_indices.len() != bones { + return Err("Animation hierarchy names/mirrors are incomplete".into()); + } + let mut names = std::collections::BTreeSet::new(); + for (i, (&partner, name)) in file.mirror_indices.iter().zip(&file.bone_names).enumerate() { + if name.is_empty() + || !names.insert(name) + || partner < -1 + || partner >= bones as i32 + || (partner >= 0 && file.mirror_indices[partner as usize] != i as i32) + { + return Err(format!( + "Animation bone{i} has an invalid name/mirror mapping" + )); + } + } + let mut clips = BTreeMap::new(); + for clip in file.clips { + let source = metadata.clip(&clip.name)?; + if source.source_offset != clip.source_offset + || source.fps_bits != clip.fps_bits + || f32::from_bits(source.frames_bits) != clip.frames.len() as f32 + || clip.channel_animation != (source.flags_word & (1 << 29) != 0) + { + return Err(format!( + "{}: decoded samples disagree with stock clip header", + clip.name + )); + } + if clip.channel_weights.len() != bones + || clip + .channel_weights + .iter() + .any(|&word| !f32::from_bits(word).is_finite()) + || clip + .loop_translation_bits + .iter() + .chain(&clip.loop_rotation_bits) + .any(|&word| !f32::from_bits(word).is_finite()) + { + return Err(format!( + "{}: invalid loop transform or missing channel weights", + clip.name + )); + } + for frame in &clip.frames { + validate_samples(frame, bones)?; + } + let name = clip.name.clone(); + if clips.insert(name.clone(), clip).is_some() { + return Err(format!("Duplicate decoded clip{name}")); + } + } + let mut reference_poses = BTreeMap::new(); + for pose in file.reference_poses { + validate_samples(&pose.samples, bones)?; + if !matches!( + pose.name.as_str(), + "RIG_TPOSE" | "BOARD_BACKWARDS" | "BOARD_BACKWARDS_IK" + ) || reference_poses.insert(pose.source_offset, pose).is_some() + { + return Err("Invalid or duplicated reference-pose source record".into()); + } + } + Ok(Self { + source_sha256: file.source_sha256, + decoder_sha256: file.decoder_sha256, + decoder_hardware_verified: file.decoder_hardware_verified, + parents: file.parents, + bone_names: file.bone_names, + mirror_indices: file.mirror_indices, + has_trajectory: file.has_trajectory, + clips, + reference_poses, + native: None, + }) + } + + pub fn clip(&self, name: &str) -> Result<&ClipFrames, String> { + if let Some(native) = &self.native { + return native.clip(name); + } + self.clips + .get(&name.to_ascii_uppercase()) + .ok_or_else(|| format!("Required decoded stock clip is absent: {name}")) + } + + /// Explicit source-record lookup, useful when auditing original bank data. + pub fn reference_pose(&self, source_offset: u64) -> Result<&ReferencePose, String> { + if let Some(native) = &self.native { + return native.reference_pose(0, source_offset); + } + self.reference_poses + .get(&source_offset) + .ok_or_else(|| format!("Required reference-pose record{source_offset:#x} is absent")) + } + + /// SetDBContent82D1B45C assigns into the selected database's name map, + /// replacing earlier same-name records in that bank. + pub fn named_pose(&self, name: &str) -> Result<&ReferencePose, String> { + self.named_pose_in(0, name) + } + pub fn named_pose_in(&self, bank: usize, name: &str) -> Result<&ReferencePose, String> { + if let Some(native) = &self.native { + return native.named_pose(bank, name); + } + if bank != 0 { + return Err("Converted animation data contains only one bank".into()); + } + self.reference_poses + .values() + .rev() + .find(|pose| pose.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("Required stock reference pose is absent: {name}")) + } + + pub fn clip_count(&self) -> usize { + if let Some(native) = &self.native { + return native.clip_count(); + } + self.clips.len() + } + + pub fn clip_names(&self) -> Box + '_> { + if let Some(native) = &self.native { + return Box::new(native.clip_names()); + } + Box::new(self.clips.keys().map(String::as_str)) + } + pub fn clip_bank(&self, name: &str) -> Result { + if let Some(native) = &self.native { + return native.clip_bank(name); + } + self.clip(name).map(|_| 0) + } +} + +fn validate_samples(samples: &[SampleWords], bones: usize) -> Result<(), String> { + if samples.len() != bones + || samples + .iter() + .flatten() + .any(|&word| !f32::from_bits(word).is_finite()) + { + return Err("Decoded stock frame is incomplete or nonfinite".into()); + } + Ok(()) +} diff --git a/crates/skate-data/src/animation_frames/native.rs b/crates/skate-data/src/animation_frames/native.rs new file mode 100644 index 00000000..30db4f1d --- /dev/null +++ b/crates/skate-data/src/animation_frames/native.rs @@ -0,0 +1,231 @@ +//! Bank-backed lazy animation samples. Each requested clip is decoded once; +//! registration indexes every record without allocating every decoded frame. +use super::{ClipFrames, ReferencePose, SampleWords, validate_samples}; +use crate::{ + abin::{Bank, Codec, PartEntry, Reader, RecordData}, + animation_banks::AnimationBanks, +}; +use skate_core::animation::vbr::VbrDecoder; +use std::{ + collections::BTreeMap, + sync::{Arc, OnceLock}, +}; + +struct Entry { + bank: usize, + offset: usize, + decoded: OnceLock>, +} +impl Entry { + fn new(bank: usize, offset: usize) -> Self { + Self { + bank, + offset, + decoded: OnceLock::new(), + } + } +} +pub(super) struct NativeFrames { + banks: Vec>, + clips: BTreeMap>, + poses: BTreeMap<(usize, u64), Entry>, + pose_names: BTreeMap<(usize, String), u64>, +} +impl NativeFrames { + pub(super) fn new(banks: &AnimationBanks) -> Result { + let mut clips: BTreeMap> = BTreeMap::new(); + let mut poses = BTreeMap::new(); + let mut pose_names = BTreeMap::new(); + for (bank_index, bank) in banks.banks.iter().enumerate() { + for record in bank.records() { + let name = &record.header.name; + let at = record.header.offset; + match &record.data { + RecordData::Clip(_) => { + if clips + .get(name) + .is_some_and(|entry| entry.bank != bank_index) + { + return Err(format!( + "Ambiguous clip {name} in multiple animation banks" + )); + } + clips.insert(name.clone(), Entry::new(bank_index, at)); + } + RecordData::Pose(_) => { + poses.insert((bank_index, at as u64), Entry::new(bank_index, at)); + pose_names.insert((bank_index, name.clone()), at as u64); + } + _ => {} + } + } + } + Ok(Self { + banks: banks.banks.clone(), + clips, + poses, + pose_names, + }) + } + pub(super) fn clip_count(&self) -> usize { + self.clips.len() + } + pub(super) fn clip_names(&self) -> impl Iterator { + self.clips.keys().map(String::as_str) + } + pub(super) fn clip_bank(&self, name: &str) -> Result { + self.clips + .get(&name.to_ascii_uppercase()) + .map(|e| e.bank) + .ok_or_else(|| format!("Missing stock animation clip {name}")) + } + pub(super) fn clip(&self, name: &str) -> Result<&ClipFrames, String> { + let entry = self + .clips + .get(&name.to_ascii_uppercase()) + .ok_or_else(|| format!("Missing stock animation clip {name}"))?; + entry + .decoded + .get_or_init(|| decode_clip(&self.banks[entry.bank], entry.offset)) + .as_ref() + .map_err(Clone::clone) + } + pub(super) fn named_pose(&self, bank: usize, name: &str) -> Result<&ReferencePose, String> { + let at = self + .pose_names + .get(&(bank, name.to_ascii_uppercase())) + .ok_or_else(|| format!("Missing reference pose {name} in bank {bank}"))?; + self.reference_pose(bank, *at) + } + pub(super) fn reference_pose( + &self, + bank: usize, + offset: u64, + ) -> Result<&ReferencePose, String> { + let entry = self + .poses + .get(&(bank, offset)) + .ok_or_else(|| format!("Missing reference pose {bank}:{offset:#x}"))?; + entry + .decoded + .get_or_init(|| decode_pose(&self.banks[bank], entry.offset)) + .as_ref() + .map_err(Clone::clone) + } +} + +fn decode_clip(bank: &Bank, offset: usize) -> Result { + let record = bank.record_at(offset).ok_or("Missing clip source record")?; + let RecordData::Clip(clip) = &record.data else { + return Err("Source record is not a clip".into()); + }; + require_vbr(record.header.codec())?; + let count = f32::from_bits(clip.frame_count_bits); + if !count.is_finite() || count < 1.0 || count.fract() != 0.0 { + return Err(format!("{}: invalid frame count", record.header.name)); + } + let frames = decode_parts(bank, &clip.parts, count as usize) + .map_err(|e| format!("{}: {e}", record.header.name))?; + let bones = bank.hierarchy().ok_or("No bank hierarchy")?.bone_count as usize; + let mut weights = vec![1.0f32.to_bits(); bones]; + if clip.channel_animation() { + let reader = Reader::new(bank.bytes()); + for (entry, layout) in clip.parts.iter().zip(&bank.hierarchy().unwrap().parts) { + let part = entry + .part + .as_ref() + .ok_or("Channel animation part is absent")?; + let range = part + .channel_weights + .as_ref() + .ok_or("Channel animation weights are absent")?; + for lane in 0..layout.bone_count as usize { + let word = reader + .u32(range.start + lane * 4) + .map_err(|e| e.to_string())?; + if !f32::from_bits(word).is_finite() { + return Err("Nonfinite channel weight".into()); + } + weights[layout.sqt_offset as usize + lane] = word; + } + } + } + Ok(ClipFrames { + name: record.header.name.clone(), + source_offset: offset as u64, + fps_bits: clip.fps_bits, + loop_translation_bits: clip.loop_translation_words[..3].try_into().unwrap(), + loop_rotation_bits: clip.loop_rotation_words, + channel_animation: clip.channel_animation(), + channel_weights: weights, + frames, + }) +} +fn decode_pose(bank: &Bank, offset: usize) -> Result { + let record = bank.record_at(offset).ok_or("Missing pose source record")?; + let RecordData::Pose(pose) = &record.data else { + return Err("Source record is not a pose".into()); + }; + require_vbr(record.header.codec())?; + let mut frames = + decode_parts(bank, &pose.parts, 1).map_err(|e| format!("{}: {e}", record.header.name))?; + Ok(ReferencePose { + name: record.header.name.clone(), + source_offset: offset as u64, + samples: frames.remove(0), + }) +} +fn require_vbr(codec: Codec) -> Result<(), String> { + if codec != Codec::Vbr { + return Err(format!("Unsupported native animation codec {codec:?}")); + } + Ok(()) +} +fn decode_parts( + bank: &Bank, + parts: &[PartEntry], + count: usize, +) -> Result>, String> { + let hierarchy = bank.hierarchy().ok_or("Missing stock hierarchy")?; + let bones = hierarchy.bone_count as usize; + if parts.len() != hierarchy.parts.len() { + return Err("Animation part count differs from hierarchy".into()); + } + let mut frames = vec![vec![[0; 10]; bones]; count]; + let mut assigned = vec![false; bones]; + for (entry, layout) in parts.iter().zip(&hierarchy.parts) { + let part = entry + .part + .as_ref() + .ok_or("Required stock animation part is absent")?; + let mut decoder = VbrDecoder::new( + bank.bytes(), + part.offset, + part.compression_header_relative as usize, + part.compressed_data_relative as usize, + )?; + if decoder.frame_count() != count || decoder.channel_count() != layout.bone_count as usize { + return Err(format!( + "{}: codec frame/channel count disagrees with bank header", + layout.name + )); + } + let start = + usize::try_from(layout.sqt_offset).map_err(|_| "Negative animation part offset")?; + let end = start + layout.bone_count as usize; + if end > bones || assigned[start..end].iter().any(|b| *b) { + return Err("Overlapping animation parts".into()); + } + assigned[start..end].fill(true); + for (index, frame) in frames.iter_mut().enumerate() { + frame[start..end].copy_from_slice(&decoder.decode_frame(index)?); + } + } + if assigned.iter().any(|v| !*v) { + return Err("Incomplete animation hierarchy channels".into()); + } + for frame in &frames { + validate_samples(frame, bones)?; + } + Ok(frames) +} diff --git a/crates/skate-data/src/animation_metadata.rs b/crates/skate-data/src/animation_metadata.rs new file mode 100644 index 00000000..11aa78ba --- /dev/null +++ b/crates/skate-data/src/animation_metadata.rs @@ -0,0 +1,376 @@ +//! Runtime clip metadata read directly from the stock Andale bank. +//! Clock/attribute evaluation belongs to core/game; this loader preserves bits +//! and record order and rejects malformed or ambiguous source data. +use serde::Deserialize; +use std::{collections::BTreeMap, fs, path::Path}; +mod native; +mod selection_space; +pub use selection_space::{ + SelectionCandidateMetadata, SelectionParameterMetadata, SelectionSpaceMetadata, +}; + +/// Bank-relative record offsets are meaningful only with this source identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BankSource { + pub source_bank: String, + pub source_sha256: String, + pub source_bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClipAttribute { + pub name: String, + pub type_id: u8, + pub begin_bits: u32, + pub end_bits: u32, + /// Raw aligned payload, including opaque curve data and padding. Consumers + /// select the lanes written by Attribute::Init for this type. + pub payload_words: Vec, + pub source_offset: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClipMetadata { + pub name: String, + pub source_offset: u64, + pub fps_bits: u32, + pub frames_bits: u32, + pub base_speed_bits: u32, + pub flags_word: u32, + pub attributes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhaseBlendMetadata { + pub name: String, + pub source_offset: u64, + pub parameter: String, + pub children: Vec, +} +pub enum TreeMetadata<'a> { + Clip(&'a ClipMetadata), + PhaseBlend(&'a PhaseBlendMetadata), + Selector(&'a SelectorMetadata), + SelectionSpace(&'a SelectionSpaceMetadata), +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectorMetadata { + pub name: String, + pub source_offset: u64, + pub parameter: String, + pub default: String, + pub children: Vec, + pub values: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct UnsupportedTree { + name: String, + source_offset: u64, + type_id: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + source_bank: String, + source_sha256: String, + source_bytes: u64, + clips: Vec, + #[serde(default)] + phase_blends: Vec, + #[serde(default)] + selectors: Vec, + #[serde(default)] + selection_spaces: Vec, + unsupported_trees: Vec, +} + +#[derive(Debug)] +pub struct AnimationMetadata { + pub source_bank: String, + pub source_sha256: String, + clips: BTreeMap>, + phase_blends: BTreeMap>, + selectors: BTreeMap>, + selection_spaces: BTreeMap>, + unsupported: BTreeMap>, + sources: Vec, + origins: BTreeMap, +} + +impl AnimationMetadata { + pub fn load(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|e| format!("Cannot read animation metadata {}: {e}", path.display()))?; + Self::parse(&text).map_err(|e| format!("{}: {e}", path.display())) + } + + pub fn parse(text: &str) -> Result { + let file: File = serde_json::from_str(text).map_err(|e| e.to_string())?; + Self::from_file(file) + } + + fn from_file(file: File) -> Result { + if file.version != 1 { + return Err(format!( + "Unsupported animation metadata version {}", + file.version + )); + } + if file.source_bank.is_empty() + || file.source_sha256.len() != 64 + || !file.source_sha256.bytes().all(|c| c.is_ascii_hexdigit()) + || file.source_bytes < 48 + { + return Err("Invalid animation bank source identity".into()); + } + let mut clips: BTreeMap> = BTreeMap::new(); + for clip in file.clips { + validate_name(&clip.name, 36)?; + let fps = f32::from_bits(clip.fps_bits); + let frames = f32::from_bits(clip.frames_bits); + let base = f32::from_bits(clip.base_speed_bits); + if !fps.is_finite() + || fps <= 0.0 + || !frames.is_finite() + || frames < 1.0 + || !base.is_finite() + || base <= 0.0 + || clip.source_offset >= file.source_bytes + { + return Err(format!("{}: invalid clip header", clip.name)); + } + let mut previous = clip.source_offset; + for attribute in &clip.attributes { + validate_name(&attribute.name, 30)?; + if !f32::from_bits(attribute.begin_bits).is_finite() + || !f32::from_bits(attribute.end_bits).is_finite() + || attribute.source_offset <= previous + || attribute.source_offset >= file.source_bytes + { + return Err(format!("{}: invalid attribute record/order", clip.name)); + } + let minimum = match attribute.type_id { + 0 => 1, + 1 => 4, + 3 => 6, + _ => 0, + }; + if attribute.payload_words.len() < minimum { + return Err(format!( + "{}: truncated {} payload", + clip.name, attribute.name + )); + } + previous = attribute.source_offset; + } + clips.entry(clip.name.clone()).or_default().push(clip); + } + let mut phase_blends: BTreeMap> = BTreeMap::new(); + for tree in file.phase_blends { + validate_name(&tree.name, 36)?; + validate_name(&tree.parameter, 30)?; + if tree.children.len() < 2 { + return Err(format!("{}: PhaseBlend needs two children", tree.name)); + } + if tree.source_offset >= file.source_bytes { + return Err(format!("{}: tree offset outside bank", tree.name)); + } + for child in &tree.children { + validate_name(child, 36)?; + } + phase_blends + .entry(tree.name.clone()) + .or_default() + .push(tree); + } + let mut selectors: BTreeMap> = BTreeMap::new(); + for tree in file.selectors { + validate_name(&tree.name, 36)?; + validate_name(&tree.parameter, 30)?; + validate_name(&tree.default, 36)?; + if tree.children.len() != tree.values.len() { + return Err(format!("{}: selector key/child count mismatch", tree.name)); + } + if tree.source_offset >= file.source_bytes { + return Err(format!("{}: tree offset outside bank", tree.name)); + } + for child in &tree.children { + validate_name(child, 36)?; + } + for value in &tree.values { + validate_name(value, 30)?; + } + selectors.entry(tree.name.clone()).or_default().push(tree); + } + let mut selection_spaces: BTreeMap> = BTreeMap::new(); + for tree in file.selection_spaces { + tree.validate(file.source_bytes)?; + selection_spaces + .entry(tree.name.clone()) + .or_default() + .push(tree); + } + let mut unsupported: BTreeMap> = BTreeMap::new(); + for tree in file.unsupported_trees { + validate_name(&tree.name, 36)?; + if tree.source_offset >= file.source_bytes { + return Err(format!("{}: tree offset outside bank", tree.name)); + } + unsupported + .entry(tree.name) + .or_default() + .push((tree.source_offset, tree.type_id)); + } + let origins = clips + .keys() + .chain(phase_blends.keys()) + .chain(selectors.keys()) + .chain(selection_spaces.keys()) + .chain(unsupported.keys()) + .map(|name| (name.clone(), 0)) + .collect(); + let sources = vec![BankSource { + source_bank: file.source_bank.clone(), + source_sha256: file.source_sha256.clone(), + source_bytes: file.source_bytes, + }]; + Ok(Self { + source_bank: file.source_bank, + source_sha256: file.source_sha256, + clips, + phase_blends, + selectors, + selection_spaces, + unsupported, + sources, + origins, + }) + } + + /// Combine disjoint animation namespaces without comparing offsets from + /// different files. Same-bank duplicates still obey native last-record wins. + /// All collision checks precede mutation; the primary rig identity remains. + pub fn merge(&mut self, other: Self) -> Result<(), String> { + if let Some(name) = other + .origins + .keys() + .find(|name| self.origins.contains_key(*name)) + { + return Err(format!("Animation bank merge collides at {name}")); + } + let base = self.sources.len(); + self.origins.extend( + other + .origins + .into_iter() + .map(|(name, index)| (name, base + index)), + ); + self.sources.extend(other.sources); + self.clips.extend(other.clips); + self.phase_blends.extend(other.phase_blends); + self.selectors.extend(other.selectors); + self.selection_spaces.extend(other.selection_spaces); + self.unsupported.extend(other.unsupported); + Ok(()) + } + + pub fn sources(&self) -> &[BankSource] { + &self.sources + } + + pub fn source_for(&self, name: &str) -> Option<&BankSource> { + self.origins + .get(&name.to_ascii_uppercase()) + .map(|&index| &self.sources[index]) + } + + /// The serialized names use FastString's canonical uppercase alphabet. + /// The API accepts only that alphabet plus lowercase ASCII, so uppercase + /// lookup is equivalent to native FastString encoding for accepted inputs. + pub fn clip(&self, name: &str) -> Result<&ClipMetadata, String> { + let name = name.to_ascii_uppercase(); + validate_name(&name, 36)?; + // SetDBContent82D1B428..434 updates its separate clip map on every + // clip record; repeated keys overwrite with the later bank record. + self.clips + .get(&name) + .and_then(|clips| clips.iter().max_by_key(|c| c.source_offset)) + .ok_or_else(|| format!("Missing direct clip {name}")) + } + pub fn tree(&self, name: &str) -> Result, String> { + let name = name.to_ascii_uppercase(); + validate_name(&name, 36)?; + let clips = self.clips.get(&name).map(Vec::as_slice).unwrap_or(&[]); + let trees = self + .unsupported + .get(&name) + .map(Vec::as_slice) + .unwrap_or(&[]); + let phase_blends = self + .phase_blends + .get(&name) + .map(Vec::as_slice) + .unwrap_or(&[]); + let selectors = self.selectors.get(&name).map(Vec::as_slice).unwrap_or(&[]); + let selection_spaces = self + .selection_spaces + .get(&name) + .map(Vec::as_slice) + .unwrap_or(&[]); + // TU382D1B438..45C inserts into the tree map in file order and writes + // the record pointer even when map[]82D27320 finds an existing key. + let last = clips + .iter() + .map(|c| c.source_offset) + .chain(phase_blends.iter().map(|t| t.source_offset)) + .chain(selectors.iter().map(|t| t.source_offset)) + .chain(selection_spaces.iter().map(|t| t.source_offset)) + .chain(trees.iter().map(|t| t.0)) + .max(); + if let Some(clip) = clips.iter().find(|c| Some(c.source_offset) == last) { + return Ok(TreeMetadata::Clip(clip)); + } + if let Some(tree) = phase_blends.iter().find(|t| Some(t.source_offset) == last) { + return Ok(TreeMetadata::PhaseBlend(tree)); + } + if let Some(tree) = selectors.iter().find(|t| Some(t.source_offset) == last) { + return Ok(TreeMetadata::Selector(tree)); + } + if let Some(tree) = selection_spaces + .iter() + .find(|t| Some(t.source_offset) == last) + { + return Ok(TreeMetadata::SelectionSpace(tree)); + } + if let Some((_, kind)) = trees.iter().find(|t| Some(t.0) == last) { + return Err(format!( + "Animation {name} is tree type {kind}; tree evaluation is required" + )); + } + Err(format!("Missing animation {name} in {}", self.source_bank)) + } + + pub fn clip_count(&self) -> usize { + self.clips.values().map(Vec::len).sum() + } +} + +fn validate_name(name: &str, length: usize) -> Result<(), String> { + if name.is_empty() + || name.len() > length + || !name + .bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_') + { + return Err(format!("Invalid canonical FastString name {name:?}")); + } + Ok(()) +} diff --git a/crates/skate-data/src/animation_metadata/native.rs b/crates/skate-data/src/animation_metadata/native.rs new file mode 100644 index 00000000..cc914528 --- /dev/null +++ b/crates/skate-data/src/animation_metadata/native.rs @@ -0,0 +1,153 @@ +//! Lossless original ABIN metadata layouts. No pose codec or numerical decoder. +//! Clip header827B8AB0, attribute82D164F8, tree lookup82D1B5B8. +use super::{ + AnimationMetadata, ClipAttribute, ClipMetadata, File, PhaseBlendMetadata, SelectorMetadata, + UnsupportedTree, +}; +use crate::abin::{Bank, Clip, Error, Reader, RecordData, RecordHeader, Result}; + +impl AnimationMetadata { + /// Read the retained bytes without constructing or evaluating animation + /// trees. Caller supplies the independently calculated bank SHA-256. + pub fn from_bank( + bank: &Bank, + source_bank: String, + source_sha256: String, + ) -> std::result::Result { + let mut file = File { + version: 1, + source_bank, + source_sha256, + source_bytes: bank.bytes().len() as u64, + clips: Vec::new(), + phase_blends: Vec::new(), + selectors: Vec::new(), + selection_spaces: Vec::new(), + unsupported_trees: Vec::new(), + }; + let reader = Reader::new(bank.bytes()); + for record in bank.records() { + let h = &record.header; + let r = reader.bounded(h.range()).map_err(|e| e.to_string())?; + match &record.data { + RecordData::Clip(clip) => file + .clips + .push(read_clip(r, h, clip).map_err(|e| e.to_string())?), + RecordData::Pose(_) | RecordData::Hierarchy(_) | RecordData::PhysicsPose(_) => {} + RecordData::Opaque => match h.type_id { + 7 => file + .phase_blends + .push(read_phase_blend(r, h).map_err(|e| e.to_string())?), + 8 => file + .selectors + .push(read_selector(r, h).map_err(|e| e.to_string())?), + 11 => file + .selection_spaces + .push(super::selection_space::read(r, h).map_err(|e| e.to_string())?), + kind => file.unsupported_trees.push(UnsupportedTree { + name: h.name.clone(), + source_offset: h.offset as u64, + type_id: kind, + }), + }, + } + } + Self::from_file(file) + } +} + +fn read_clip(r: Reader<'_>, h: &RecordHeader, clip: &Clip) -> Result { + let mut at = clip.attribute_offset; + let mut attributes = Vec::with_capacity(clip.attribute_count as usize); + for _ in 0..clip.attribute_count { + // Original GetAttribute82D25CA0 advances by the record's first word. + let size = r.u32(at)? as usize; + if size < 48 { + return Err(Error::new(at, "attribute record smaller than header")); + } + let end = at + .checked_add(size) + .ok_or_else(|| Error::new(at, "attribute size overflow"))?; + let a = r.bounded(at..end)?; + let data = at + .checked_add(51) + .ok_or_else(|| Error::new(at, "attribute alignment overflow"))? + & !15; + let payload = a.take( + data, + end.checked_sub(data) + .ok_or_else(|| Error::new(at, "attribute payload outside record"))?, + )?; + if payload.len() % 4 != 0 { + return Err(Error::new(at, "unaligned attribute payload")); + } + attributes.push(ClipAttribute { + name: a.name(at + 12, 5)?, + type_id: a.u8(at + 32)?, + begin_bits: a.u32(at + 4)?, + end_bits: a.u32(at + 8)?, + payload_words: payload + .chunks_exact(4) + .map(|word| u32::from_be_bytes(word.try_into().unwrap())) + .collect(), + source_offset: at as u64, + }); + at = end; + } + Ok(ClipMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + fps_bits: clip.fps_bits, + frames_bits: clip.frame_count_bits, + base_speed_bits: clip.base_speed_bits, + flags_word: clip.flags, + attributes, + }) +} + +fn read_phase_blend(r: Reader<'_>, h: &RecordHeader) -> Result { + // Type7 construction82D1B5B8: count+8, parameter+16, child names+36. + let p = h.payload_offset; + let count = r.u32(p + 8)? as usize; + let size = count + .checked_mul(24) + .ok_or_else(|| Error::new(p, "PhaseBlend child count overflow"))?; + r.take(p + 36, size)?; + let children = (0..count) + .map(|i| r.name(p + 36 + 24 * i, 6)) + .collect::>>()?; + Ok(PhaseBlendMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + parameter: r.name(p + 16, 5)?, + children, + }) +} + +fn read_selector(r: Reader<'_>, h: &RecordHeader) -> Result { + // Type8 construction82D1B5B8 keeps the first name as authored default. + // Child/value order matters: runtime chooses the first matching value. + let p = h.payload_offset; + let count = r.u32(p + 28)? as usize; + let names_size = count + .max(1) + .checked_mul(24) + .ok_or_else(|| Error::new(p, "Selector child count overflow"))?; + let keys_size = count + .checked_mul(20) + .ok_or_else(|| Error::new(p, "Selector key count overflow"))?; + let names = r.relative(p, r.u32(p + 20)?, names_size)?.start; + let keys = r.relative(p, r.u32(p + 24)?, keys_size)?.start; + Ok(SelectorMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + parameter: r.name(p, 5)?, + default: r.name(names, 6)?, + children: (0..count) + .map(|i| r.name(names + 24 * i, 6)) + .collect::>>()?, + values: (0..count) + .map(|i| r.name(keys + 20 * i, 5)) + .collect::>>()?, + }) +} diff --git a/crates/skate-data/src/animation_metadata/selection_space.rs b/crates/skate-data/src/animation_metadata/selection_space.rs new file mode 100644 index 00000000..cdc204ad --- /dev/null +++ b/crates/skate-data/src/animation_metadata/selection_space.rs @@ -0,0 +1,111 @@ +//! Type11 layout from original 82D1BEA0, 82D26CC0 and 82D26FF8. +use super::validate_name; +use crate::abin::{Error, Reader, RecordHeader, Result}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectionParameterMetadata { + pub name: String, + pub mode: u32, + pub weight_bits: u32, + pub minimum_bits: u32, + pub maximum_bits: u32, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectionCandidateMetadata { + pub child: String, + pub value_bits: Vec, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectionSpaceMetadata { + pub name: String, + pub source_offset: u64, + pub parameters: Vec, + pub candidates: Vec, +} +impl SelectionSpaceMetadata { + pub(super) fn validate(&self, bytes: u64) -> std::result::Result<(), String> { + validate_name(&self.name, 36)?; + if self.source_offset >= bytes || self.parameters.len() > 10 || self.candidates.is_empty() { + return Err(format!("{}: invalid selection space dimensions", self.name)); + } + for p in &self.parameters { + validate_name(&p.name, 30)?; + if [p.weight_bits, p.minimum_bits, p.maximum_bits] + .into_iter() + .any(|v| !f32::from_bits(v).is_finite()) + { + return Err(format!("{}: nonfinite selection metric", self.name)); + } + } + for c in &self.candidates { + validate_name(&c.child, 36)?; + if c.value_bits.len() != self.parameters.len() + || c.value_bits.iter().any(|v| !f32::from_bits(*v).is_finite()) + { + return Err(format!("{}: invalid selection candidate", self.name)); + } + } + Ok(()) + } +} +pub(super) fn read(r: Reader<'_>, h: &RecordHeader) -> Result { + let p = h.payload_offset; + let count = r.u32(p)? as usize; + if count > 10 { + return Err(Error::new( + p, + "selection parameter array exceeds native capacity", + )); + } + let parameters_at = r.relative(p, r.u32(p + 4)?, count * 40)?.start; + let candidates_count = r.u32(p + 8)? as usize; + let candidates_at = r.relative(p, r.u32(p + 12)?, 4)?.start; + let stride = r.u32(candidates_at)? as usize; + let size = stride + .checked_mul(candidates_count) + .ok_or_else(|| Error::new(p, "selection candidate size overflow"))?; + r.take(candidates_at, size)?; + if stride < 32 { + return Err(Error::new( + candidates_at, + "selection candidate header too small", + )); + } + let parameters = (0..count) + .map(|i| { + let at = parameters_at + i * 40; + Ok(SelectionParameterMetadata { + name: r.name(at, 6)?, + mode: r.u32(at + 24)?, + weight_bits: r.u32(at + 28)?, + minimum_bits: r.u32(at + 32)?, + maximum_bits: r.u32(at + 36)?, + }) + }) + .collect::>()?; + let candidates = (0..candidates_count) + .map(|i| { + let at = candidates_at + i * stride; + let child_reader = r.bounded(at..at + stride)?; + let values = child_reader + .relative(at, child_reader.u32(at + 28)?, count * 4)? + .start; + Ok(SelectionCandidateMetadata { + child: child_reader.name(at + 4, 6)?, + value_bits: (0..count) + .map(|j| child_reader.u32(values + j * 4)) + .collect::>()?, + }) + }) + .collect::>()?; + Ok(SelectionSpaceMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + parameters, + candidates, + }) +} diff --git a/crates/skate-data/src/collections.rs b/crates/skate-data/src/collections.rs new file mode 100644 index 00000000..9d45c95c --- /dev/null +++ b/crates/skate-data/src/collections.rs @@ -0,0 +1,137 @@ +//! Typed access to converted stock skater XML values, without engine objects. +use serde::Deserialize; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Debug, Deserialize)] +pub struct Field { + #[serde(rename = "type")] + pub type_name: String, + pub data: String, +} + +#[derive(Debug, Deserialize)] +pub struct Collection { + #[serde(rename = "class")] + pub class_name: String, + pub key: String, + pub parent: String, + pub fields: BTreeMap, + pub source: String, + pub sha256: String, +} + +#[derive(Debug, Deserialize)] +pub struct Collections { + version: u32, + collections: Vec, +} + +impl Collections { + pub fn entries(&self) -> &[Collection] { + &self.collections + } + + pub fn load(asset_root: &Path) -> Result { + let path = asset_root.join("private/stock/skater-collections.json"); + let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let data: Self = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if data.version != 1 { + return Err(format!( + "Unsupported skater collections version {}", + data.version + )); + } + let mut identities = std::collections::BTreeSet::new(); + for item in &data.collections { + if !identities.insert((&item.class_name, &item.key)) { + return Err(format!( + "Duplicate collection {}/{}", + item.class_name, item.key + )); + } + } + Ok(data) + } + + pub fn field(&self, class: &str, key: &str, name: &str) -> Result<&Field, String> { + let mut current = key; + for _ in 0..=self.collections.len() { + let item = self + .collections + .iter() + .find(|c| c.class_name == class && c.key == current) + .ok_or_else(|| format!("Missing stock collection {class}/{current}"))?; + if let Some(field) = item.fields.get(name) { + return Ok(field); + } + if item.parent.is_empty() { + return Err(format!("Missing stock field {class}/{key}/{name}")); + } + current = &item.parent; + } + Err(format!("Cyclic stock collection inheritance {class}/{key}")) + } + + pub fn float(&self, class: &str, key: &str, name: &str) -> Result { + let field = self.field(class, key, name)?; + if field.type_name != "EA::Reflection::Float" { + return Err(format!("Expected float at {class}/{key}/{name}")); + } + let words = decode_words::<1>(&field.data)?; + let value = f32::from_bits(words[0]); + if !value.is_finite() { + return Err(format!("Non-finite stock float {class}/{key}/{name}")); + } + Ok(value) + } + + pub fn integer(&self, class: &str, key: &str, name: &str) -> Result { + let field = self.field(class, key, name)?; + if !matches!( + field.type_name.as_str(), + "EA::Reflection::Int32" | "EA::Reflection::UInt32" + ) { + return Err(format!("Expected integer at {class}/{key}/{name}")); + } + Ok(decode_words::<1>(&field.data)?[0]) + } + + pub fn boolean(&self, class: &str, key: &str, name: &str) -> Result { + let field = self.field(class, key, name)?; + if field.type_name != "EA::Reflection::Bool" { + return Err(format!("Expected boolean at {class}/{key}/{name}")); + } + // Attributes store the byte followed by padding; layout bools may + // contain only the byte. Reading a big-endian u32 gives the wrong bit. + match field.data.get(..2) { + Some("00") => Ok(false), + Some("01") => Ok(true), + _ => Err(format!("Invalid stock boolean {class}/{key}/{name}")), + } + } + + pub fn words( + &self, + class: &str, + key: &str, + name: &str, + ) -> Result<[u32; N], String> { + decode_words(&self.field(class, key, name)?.data) + } +} + +fn decode_words(text: &str) -> Result<[u32; N], String> { + let hex: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + if hex.len() != N * 8 || !hex.is_ascii() { + return Err(format!( + "Expected {N} big-endian words, found {} bytes of hex", + hex.len() + )); + } + let mut words = [0; N]; + for (i, word) in words.iter_mut().enumerate() { + *word = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16) + .map_err(|e| format!("Invalid collection payload: {e}"))?; + } + Ok(words) +} diff --git a/crates/skate-data/src/input_config.rs b/crates/skate-data/src/input_config.rs new file mode 100644 index 00000000..be919b2f --- /dev/null +++ b/crates/skate-data/src/input_config.rs @@ -0,0 +1,100 @@ +//! Compatibility check for the stock gameplay expressions specialized by +//! skate-core's GameplayActions. This is not the full native expression VM. +use crate::AssetError; +use std::{collections::BTreeMap, fs, path::Path}; + +pub const STOCK_INPUT_PATH: &str = "private/stock/data/config/input.cfg"; + +pub struct StockGameplayConfig; + +impl StockGameplayConfig { + pub fn load(asset_root: &Path) -> Result { + let path = asset_root.join(STOCK_INPUT_PATH); + let source = fs::read_to_string(&path) + .map_err(|error| AssetError(format!("Cannot read {}: {error}", path.display())))?; + Self::validate(&source) + .map_err(|error| AssetError(format!("Input configuration {}: {error}", path.display()))) + } + + /// Require the actual file to agree with the supported native mapping. + /// A modified expression fails explicitly instead of silently running the + /// stock specialization against different user data. + pub fn validate(source: &str) -> Result { + let source: String = source + .lines() + .map(|line| line.split("//").next().unwrap_or_default()) + .collect::>() + .join("\n"); + let mut definitions = BTreeMap::new(); + for statement in source.split(';') { + let Some((name, expression)) = statement.split_once('=') else { + continue; + }; + let name = name.trim(); + if BUTTON_NAMES.contains(&name) || GAMEPLAY.iter().any(|(key, _)| *key == name) { + let expression: String = + expression.chars().filter(|c| !c.is_whitespace()).collect(); + if definitions.insert(name, expression).is_some() { + return Err(AssetError(format!( + "Duplicate gameplay input definition {name}" + ))); + } + } + } + for (index, name) in BUTTON_NAMES.iter().enumerate() { + require(&definitions, name, &format!("Button{index}"))?; + } + for (name, expression) in GAMEPLAY { + require(&definitions, name, expression)?; + } + Ok(Self) + } +} + +fn require( + definitions: &BTreeMap<&str, String>, + name: &str, + expected: &str, +) -> Result<(), AssetError> { + match definitions.get(name) { + Some(actual) if actual == expected => Ok(()), + Some(actual) => Err(AssetError(format!( + "Unsupported {name} = {actual}; the implemented stock mapping requires {expected}" + ))), + None => Err(AssetError(format!( + "Missing gameplay input definition {name}" + ))), + } +} + +const BUTTON_NAMES: [&str; 24] = [ + "DPadU", "DPadD", "DPadL", "DPadR", "Start", "Back", "LStick", "RStick", "LBumper", "RBumper", + "LTrigger", "RTrigger", "A", "B", "X", "Y", "LStickR", "LStickL", "LStickU", "LStickD", + "RStickR", "RStickL", "RStickU", "RStickD", +]; + +// TU3 82697740 registers these eighteen actions at indices 64..81. +const GAMEPLAY: [(&str, &str); 18] = [ + ("GP_LStickX", "LStickR-LStickL"), + ("GP_LStickY", "LStickU-LStickD"), + ("GP_LStickIn", "LStick"), + ("GP_RStickX", "RStickR-RStickL"), + ("GP_RStickY", "RStickU-RStickD"), + ("GP_RStickIn", "RStick"), + ("GP_LTrigger", "LTrigger"), + ("GP_RTrigger", "RTrigger"), + ("GP_LBumper", "LBumper"), + ("GP_RBumper", "RBumper"), + ("GP_UDPad", "DPadU"), + ("GP_DDPad", "DPadD"), + ("GP_LDPad", "DPadL"), + ("GP_RDPad", "DPadR"), + ("GP_XFace", "X"), + ("GP_YFace", "Y"), + ("GP_AFace", "A"), + ("GP_BFace", "B"), +]; + +#[cfg(test)] +#[path = "tests/input_config.rs"] +mod tests; diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs new file mode 100644 index 00000000..562e5065 --- /dev/null +++ b/crates/skate-data/src/lib.rs @@ -0,0 +1,13 @@ +//! Runtime stock data, validation and bank storage. Stateless codec math is in core. +#![forbid(unsafe_code)] +mod manifest; +pub use manifest::{AssetError, GameAssets}; +pub mod abin; +pub mod animation_banks; +pub mod animation_frames; +pub mod animation_metadata; +pub mod collections; +pub mod input_config; +pub mod physics_skeleton; +mod sha256; +pub mod state_graph; diff --git a/crates/skate-data/src/manifest.rs b/crates/skate-data/src/manifest.rs new file mode 100644 index 00000000..5c0f2681 --- /dev/null +++ b/crates/skate-data/src/manifest.rs @@ -0,0 +1,79 @@ +use serde::Deserialize; +use std::{ + fmt, fs, + path::{Component, Path, PathBuf}, +}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GameAssets { + pub version: u32, + pub character_scene: String, + pub initial_animation: String, + pub action_graph: String, + pub motion_graph: String, +} + +#[derive(Debug)] +pub struct AssetError(pub String); +impl fmt::Display for AssetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for AssetError {} + +impl GameAssets { + pub fn load(root: &Path) -> Result { + let file = root.join("private/game.json"); + let json = fs::read_to_string(&file).map_err(|e| AssetError(format!("Cannot read {}: {e}. Run pipeline/Prepare-Assets.ps1 or select the correct --assets directory.", file.display())))?; + let manifest = Self::parse(&json)?; + for (kind, relative) in [ + ("Character scene", manifest.character_scene.as_str()), + ("ActionGraph", manifest.action_graph.as_str()), + ("MotionGraph", manifest.motion_graph.as_str()), + ] { + let path = root.join(relative); + if !path.is_file() { + return Err(AssetError(format!("{kind} missing: {}", path.display()))); + } + } + Ok(manifest) + } + + pub fn parse(json: &str) -> Result { + let manifest: Self = serde_json::from_str(json) + .map_err(|e| AssetError(format!("Invalid game asset manifest: {e}")))?; + if manifest.version != 1 { + return Err(AssetError(format!( + "Unsupported asset manifest version {}", + manifest.version + ))); + } + validate_path(&manifest.character_scene, "character_scene", "glb")?; + validate_path(&manifest.action_graph, "action_graph", "stategraph")?; + validate_path(&manifest.motion_graph, "motion_graph", "stategraph")?; + if manifest.initial_animation.trim().is_empty() { + return Err(AssetError( + "initial_animation must name a stock animation".into(), + )); + } + Ok(manifest) + } +} + +fn validate_path(value: &str, field: &str, extension: &str) -> Result<(), AssetError> { + let path = PathBuf::from(value); + if path.as_os_str().is_empty() + || !path.components().all(|c| matches!(c, Component::Normal(_))) + || value.contains([':', '\\', '#']) + { + return Err(AssetError(format!( + "{field} must be a relative asset path using forward slashes" + ))); + } + if path.extension().and_then(|x| x.to_str()) != Some(extension) { + return Err(AssetError(format!("{field} must name a .{extension} file"))); + } + Ok(()) +} diff --git a/crates/skate-data/src/physics_skeleton.rs b/crates/skate-data/src/physics_skeleton.rs new file mode 100644 index 00000000..7e022edc --- /dev/null +++ b/crates/skate-data/src/physics_skeleton.rs @@ -0,0 +1,64 @@ +//! Lossless stock physics-bone records. Core consumes typed values derived here. +use serde::Deserialize; +use std::{collections::BTreeSet, path::Path}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicsBone { + pub name: String, + pub source_offset: u64, + pub words: [u32; 28], +} +impl PhysicsBone { + pub fn size(&self) -> [f32; 3] { + [self.words[8], self.words[9], self.words[10]].map(f32::from_bits) + } + pub fn rotation(&self) -> [f32; 4] { + [self.words[12], self.words[13], self.words[14], self.words[15]].map(f32::from_bits) + } + pub fn translation(&self) -> [f32; 3] { + [self.words[16], self.words[17], self.words[18]].map(f32::from_bits) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicsSkeleton { + pub name: String, + pub source_offset: u64, + pub bones: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + source_sha256: String, + skeletons: Vec, +} + +impl PhysicsSkeleton { + pub fn load(path: &Path, bank_sha256: &str, name: &str) -> Result { + let bytes = std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?; + let file: File = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + if file.version != 1 || file.source_sha256 != bank_sha256 { + return Err("Physics skeleton bank identity or format differs from animation data".into()); + } + let mut identities = BTreeSet::new(); + if file.skeletons.iter().any(|s| !identities.insert(&s.name)) { + return Err("Converted physics skeleton has unresolved duplicate names".into()); + } + let selected = file.skeletons.into_iter().find(|s| s.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("Required stock physics skeleton {name} is absent"))?; + let mut names = BTreeSet::new(); + if selected.bones.is_empty() || selected.bones.len() > 255 { + return Err("Invalid stock physical bone count".into()); + } + for bone in &selected.bones { + if !names.insert(&bone.name) || bone.size().iter().chain(&bone.rotation()).chain(&bone.translation()).any(|v| !v.is_finite()) { + return Err(format!("Invalid or repeated stock physics bone {}", bone.name)); + } + } + Ok(selected) + } +} diff --git a/crates/skate-data/src/sha256.rs b/crates/skate-data/src/sha256.rs new file mode 100644 index 00000000..7ee929b9 --- /dev/null +++ b/crates/skate-data/src/sha256.rs @@ -0,0 +1,79 @@ +//! SHA-256 source identity for private bank bytes (FIPS 180-4). +//! This is file integrity bookkeeping, separate from game arithmetic. +pub(crate) fn digest(bytes: &[u8]) -> String { + let mut state = [ + 0x6a09e667u32, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, + ]; + let mut blocks = bytes.chunks_exact(64); + for block in &mut blocks { + compress(&mut state, block); + } + let remainder = blocks.remainder(); + let mut tail = [0u8; 128]; + tail[..remainder.len()].copy_from_slice(remainder); + tail[remainder.len()] = 0x80; + let end = if remainder.len() < 56 { 64 } else { 128 }; + tail[end - 8..end].copy_from_slice(&((bytes.len() as u64).wrapping_mul(8)).to_be_bytes()); + for block in tail[..end].chunks_exact(64) { + compress(&mut state, block); + } + state.iter().map(|v| format!("{v:08x}")).collect() +} +fn compress(state: &mut [u32; 8], block: &[u8]) { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + let mut w = [0u32; 64]; + for (out, bytes) in w.iter_mut().zip(block.chunks_exact(4)) { + *out = u32::from_be_bytes(bytes.try_into().unwrap()); + } + for i in 16..64 { + let x = w[i - 15]; + let y = w[i - 2]; + let s0 = x.rotate_right(7) ^ x.rotate_right(18) ^ (x >> 3); + let s1 = y.rotate_right(17) ^ y.rotate_right(19) ^ (y >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ (!e & g); + let t1 = h + .wrapping_add(s1) + .wrapping_add(choice) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let t2 = s0.wrapping_add((a & b) ^ (a & c) ^ (b & c)); + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (s, v) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *s = s.wrapping_add(v); + } +} diff --git a/crates/skate-data/src/state_graph.rs b/crates/skate-data/src/state_graph.rs new file mode 100644 index 00000000..4b250d3e --- /dev/null +++ b/crates/skate-data/src/state_graph.rs @@ -0,0 +1,143 @@ +//! Stock TU3 `.stategraph` decoding, independent of graph execution. +//! +//! ReadElement 82C15DE0 reads a NUL-terminated tag, BinaryAttributeMap +//! 82C14F38, a big-endian child count, then children in file order. +//! Each attribute stores two strings, a float word and a boolean byte. +//! Keep all three representations: parsing the string again loses stock data. +use crate::AssetError; +use std::{fs, path::Path}; + +pub mod attributes; +pub mod binding; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphAttribute { + pub name: String, + pub text: String, + pub float_bits: u32, + pub boolean_byte: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphElement { + /// Byte offset of the tag in the original file, for binding diagnostics. + pub source_offset: usize, + pub tag: String, + /// File order, including duplicate names. Native hash-map binding is a + /// separate operation; the decoder must not silently discard attributes. + pub attributes: Vec, + /// Indices into StateGraph::elements, in original child order. + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StateGraph { + /// Preorder arena. Element zero is the single file root. + pub elements: Vec, +} + +impl StateGraph { + pub fn load(path: &Path) -> Result { + let bytes = fs::read(path) + .map_err(|e| AssetError(format!("Cannot read graph {}: {e}", path.display())))?; + Self::decode(&bytes) + .map_err(|e| AssetError(format!("Invalid graph {}: {e}", path.display()))) + } + + pub fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader { bytes, offset: 0 }; + let (root, children) = reader.element()?; + let mut elements = vec![root]; + let mut parents = vec![(0_usize, children)]; + // The native reader recurses. An explicit stack preserves preorder and + // permits deeply nested input without depending on the host stack size. + while let Some((parent, remaining)) = parents.last_mut() { + if *remaining == 0 { + parents.pop(); + continue; + } + *remaining -= 1; + let (element, children) = reader.element()?; + let index = elements.len(); + elements[*parent].children.push(index); + elements.push(element); + parents.push((index, children)); + } + if reader.offset != bytes.len() { + return Err(reader.error("trailing bytes after the root element")); + } + Ok(Self { elements }) + } +} + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl Reader<'_> { + fn error(&self, message: &str) -> AssetError { + AssetError(format!("stategraph byte 0x{:X}: {message}", self.offset)) + } + + fn take(&mut self, count: usize) -> Result<&[u8], AssetError> { + if count > self.bytes.len() - self.offset { + return Err(self.error("truncated record")); + } + let begin = self.offset; + self.offset += count; + Ok(&self.bytes[begin..self.offset]) + } + + fn word(&mut self) -> Result { + Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + + fn string(&mut self) -> Result { + let length = self.bytes[self.offset..] + .iter() + .position(|b| *b == 0) + .ok_or_else(|| self.error("unterminated string"))?; + let offset = self.offset; + let string = self.take(length + 1)?; + // Native storage is bytes. Unsupported encodings fail explicitly; + // lossy conversion could change node names or binding keys. + std::str::from_utf8(&string[..length]) + .map(str::to_owned) + .map_err(|_| AssetError(format!("stategraph byte 0x{offset:X}: non-UTF-8 string"))) + } + + fn element(&mut self) -> Result<(GraphElement, u32), AssetError> { + let source_offset = self.offset; + let tag = self.string()?; + let attribute_count = self.word()?; + // Each attribute requires at least two terminators, four bytes and + // one byte. Bound allocations by actual input, not a guessed graph cap. + if attribute_count as usize > (self.bytes.len() - self.offset) / 7 { + return Err(self.error("attribute count exceeds remaining data")); + } + let mut attributes = Vec::with_capacity(attribute_count as usize); + for _ in 0..attribute_count { + attributes.push(GraphAttribute { + name: self.string()?, + text: self.string()?, + float_bits: self.word()?, + boolean_byte: self.take(1)?[0], + }); + } + let children = self.word()?; + // A minimal child is one terminator and two four-byte counts. + if children as usize > (self.bytes.len() - self.offset) / 9 { + return Err(self.error("child count exceeds remaining data")); + } + Ok(( + GraphElement { + source_offset, + tag, + attributes, + children: Vec::new(), + }, + children, + )) + } +} diff --git a/crates/skate-data/src/state_graph/attributes.rs b/crates/skate-data/src/state_graph/attributes.rs new file mode 100644 index 00000000..4223be63 --- /dev/null +++ b/crates/skate-data/src/state_graph/attributes.rs @@ -0,0 +1,84 @@ +//! BinaryAttributeMap keys (82B92B98) and lookup/insert semantics. +//! This is the graph's 32-bit hash, not Attribulator's 64-bit field hash. +use super::GraphAttribute; +use std::collections::BTreeMap; + +/// Attribute/tag callers use zero for missing or empty strings. The underlying +/// 82B92B98 byte hash has a different, nonzero result for a zero-length buffer. +pub fn key_hash(text: &str) -> u32 { + if text.is_empty() { + 0 + } else { + byte_hash(text.as_bytes()) + } +} + +/// Complete 82B92B98, including each tail length and wrapping 32-bit arithmetic. +pub fn byte_hash(bytes: &[u8]) -> u32 { + let mut a = 0x9e37_79b9_u32; + let mut b = a; + let mut c = 0xabcd_ef00_u32; + let mut chunks = bytes.chunks_exact(12); + for chunk in &mut chunks { + a = a.wrapping_add(u32::from_le_bytes(chunk[0..4].try_into().unwrap())); + b = b.wrapping_add(u32::from_le_bytes(chunk[4..8].try_into().unwrap())); + c = c.wrapping_add(u32::from_le_bytes(chunk[8..12].try_into().unwrap())); + mix(&mut a, &mut b, &mut c); + } + c = c.wrapping_add(bytes.len() as u32); + for (i, &byte) in chunks.remainder().iter().enumerate() { + match i { + 0..=3 => a = a.wrapping_add((byte as u32) << (8 * i)), + 4..=7 => b = b.wrapping_add((byte as u32) << (8 * (i - 4))), + _ => c = c.wrapping_add((byte as u32) << (8 * (i - 7))), + } + } + mix(&mut a, &mut b, &mut c); + c +} + +fn mix(a: &mut u32, b: &mut u32, c: &mut u32) { + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 13); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 8); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 13); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 12); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 16); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 5); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 3); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 10); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 15); +} + +/// Native 82C19BB0 is unique insertion by the hashed key: the first record wins +/// for duplicate keys, including collisions between differently spelled keys. +/// Original record order and all encodings remain available in GraphElement. +pub struct Attributes<'a> { + entries: BTreeMap, +} + +impl<'a> Attributes<'a> { + pub fn new(attributes: &'a [GraphAttribute]) -> Self { + let mut entries = BTreeMap::new(); + for attribute in attributes { + entries + .entry(key_hash(&attribute.name)) + .or_insert(attribute); + } + Self { entries } + } + pub fn get(&self, name: &str) -> Option<&'a GraphAttribute> { + self.entries.get(&key_hash(name)).copied() + } + /// 82C14E10. A present empty string is not the default. + pub fn text(&self, name: &str) -> Option<&'a str> { + self.get(name).map(|v| v.text.as_str()) + } + /// 82C151B0 returns the stored byte without boolean normalization. + pub fn boolean_byte(&self, name: &str, default: u8) -> u8 { + self.get(name).map_or(default, |v| v.boolean_byte) + } + /// 82C15308; callers of 82C15258 supply the native zero default. + pub fn float_bits(&self, name: &str, default: u32) -> u32 { + self.get(name).map_or(default, |v| v.float_bits) + } +} diff --git a/crates/skate-data/src/state_graph/binding.rs b/crates/skate-data/src/state_graph/binding.rs new file mode 100644 index 00000000..a22b7d99 --- /dev/null +++ b/crates/skate-data/src/state_graph/binding.rs @@ -0,0 +1,426 @@ +//! Dynamic graph object construction and name binding from TU3 82C15F50, +//! 82C11930, 82C16708, 82C169B8, 82C16D68, 82C16FF0 and 82C16C10. +//! Concrete operations are created through the native factory boundary and +//! receive each nested parameter in authored order. +use super::{ + StateGraph, + attributes::{Attributes, key_hash}, +}; +use crate::AssetError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Node { + State(usize), + Transition(usize), + Expression(usize), + Operation(usize), +} + +#[derive(Clone, Debug)] +pub struct State { + pub element: usize, + pub name: String, + pub parent: Option, + pub children: Vec, + pub behaviors: Vec, + pub transitions: Vec, + pub expression: Option, + pub enabled: u8, + pub active: u8, + pub interruptibility: u32, + pub interrupt_ancestor: Option, +} + +#[derive(Clone, Debug)] +pub struct Transition { + pub element: usize, + pub owner: usize, + pub target: Option, + pub enabled: u8, + pub priority: u32, + pub expression: Option, + pub hooks: Vec, +} + +#[derive(Clone, Debug)] +pub struct Expression { + pub element: usize, + pub enabled: u8, + pub operator: u32, + /// Ordered nested expressions and concrete condition operations. + pub children: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationKind { + Behavior, + Condition, + Hook, +} + +#[derive(Clone, Debug)] +pub struct Operation { + pub element: usize, + pub parent: Node, + pub kind: OperationKind, + pub name: String, + pub enabled: u8, + /// Condition::mMask from 82C12CB0/82C12BB0. Other operation kinds have no + /// condition mask. + pub condition_mask: Option, + /// Ordered `param` elements, consumed by the concrete virtual40 handler. + pub parameters: Vec, +} + +#[derive(Clone, Debug)] +pub struct Binding { + pub states: Vec, + pub transitions: Vec, + pub expressions: Vec, + pub operations: Vec, + pub root: usize, +} + +#[derive(Clone, Debug)] +pub struct OperationInstances { + /// Same indices as `Binding::operations`. + pub operations: Vec, +} + +/// Readable boundary for 8241BFF0 (factory create) and the parser's virtual+40 +/// parameter dispatch in 82C15F50. A missing registration is represented by +/// `Ok(None)` and is rejected by `instantiate_operations` with source context. +pub trait OperationFactory { + type Instance; + type Error: std::fmt::Display; + + fn create( + &mut self, + kind: OperationKind, + parent: Node, + attributes: &Attributes<'_>, + ) -> Result, Self::Error>; + + fn add_parameter( + &mut self, + instance: &mut Self::Instance, + attributes: &Attributes<'_>, + ) -> Result<(), Self::Error>; +} + +impl Binding { + /// Binds the compiled dynamic graph structure, not its registered gameplay + /// operations. The source graph remains the owner of raw typed attributes. + pub fn from_graph(source: &StateGraph) -> Result { + let mut result = Self { + states: Vec::new(), + transitions: Vec::new(), + expressions: Vec::new(), + operations: Vec::new(), + root: 0, + }; + let mut parents = vec![None; source.elements.len()]; + let mut nodes = vec![None; source.elements.len()]; + for (i, element) in source.elements.iter().enumerate() { + for &child in &element.children { + if child <= i || child >= parents.len() || parents[child].replace(i).is_some() { + return Err(error(source, i, "invalid preorder ownership")); + } + } + let parent = parents[i].and_then(|p| nodes[p]); + let attrs = Attributes::new(&element.attributes); + let enabled = if parent.is_some_and(|p| result.enabled(p) == 0) { + 0 + } else { + attrs.boolean_byte("enabled", 1) + }; + let name = attrs.text("name").unwrap_or("__unknown__").to_owned(); + let tag = key_hash(&element.tag); + let node = if tag == key_hash("state") { + let parent = match parent { + None if i == 0 => None, + Some(Node::State(id)) => Some(id), + _ => return Err(error(source, i, "state requires a state parent")), + }; + let interruptibility = match attrs.text("interruptable").unwrap_or("true") { + "true" => 1, + "false" => 0, + _ => 2, + }; + let id = result.states.len(); + result.states.push(State { + element: i, + name, + parent, + children: Vec::new(), + behaviors: Vec::new(), + transitions: Vec::new(), + expression: None, + enabled, + active: attrs.boolean_byte("active", 1), + interruptibility, + interrupt_ancestor: None, + }); + if let Some(parent) = parent { + result.states[parent].children.push(id); + } + Node::State(id) + } else if tag == key_hash("transition") { + let Some(Node::State(owner)) = parent else { + return Err(error(source, i, "transition requires a state parent")); + }; + let id = result.transitions.len(); + let priority = match attrs.text("priority") { + Some("med") => 1, + Some("high") => 2, + Some("urgent") => 3, + _ => 0, + }; + result.transitions.push(Transition { + element: i, + owner, + target: None, + enabled, + priority, + expression: None, + hooks: Vec::new(), + }); + result.states[owner].transitions.push(id); + Node::Transition(id) + } else if tag == key_hash("expression") { + let id = result.expressions.len(); + let operator = match attrs.text("op") { + Some("and") => 1, + Some("or") => 2, + Some("not") => 3, + _ => 0, + }; + result.expressions.push(Expression { + element: i, + enabled, + operator, + children: Vec::new(), + }); + match parent { + Some(Node::State(owner)) => result.states[owner].expression = Some(id), + Some(Node::Transition(owner)) => { + result.transitions[owner].expression = Some(id) + } + Some(Node::Expression(owner)) => result.expressions[owner] + .children + .push(Node::Expression(id)), + _ => { + return Err(error( + source, + i, + "expression requires state, transition or expression parent", + )); + } + } + Node::Expression(id) + } else if tag == key_hash("param") { + let Some(Node::Operation(owner)) = parent else { + return Err(error(source, i, "unbound parameter handler")); + }; + result.operations[owner].parameters.push(i); + if !element.children.is_empty() { + return Err(error( + source, + i, + "parameter children have no native object parent", + )); + } + continue; + } else { + let kind = if tag == key_hash("behaviour") { + OperationKind::Behavior + } else if tag == key_hash("condition") { + OperationKind::Condition + } else if tag == key_hash("hook") { + OperationKind::Hook + } else { + return Err(error(source, i, "unsupported graph element")); + }; + let Some(parent) = parent else { + return Err(error(source, i, "operation requires a parent")); + }; + let id = result.operations.len(); + match (kind, parent) { + (OperationKind::Behavior, Node::State(owner)) => { + result.states[owner].behaviors.push(id) + } + (OperationKind::Condition, Node::Expression(owner)) => { + result.expressions[owner].children.push(Node::Operation(id)) + } + (OperationKind::Hook, Node::Transition(owner)) => { + result.transitions[owner].hooks.push(id) + } + _ => return Err(error(source, i, "unsupported operation parent")), + } + result.operations.push(Operation { + element: i, + parent, + kind, + name, + enabled, + condition_mask: (kind == OperationKind::Condition) + .then(|| parse_condition_mask(attrs.text("mask"))), + parameters: Vec::new(), + }); + Node::Operation(id) + }; + nodes[i] = Some(node); + } + if result.states.is_empty() { + return Err(AssetError("Graph has no root state".into())); + } + result.process_names(source)?; + Ok(result) + } + + /// Instantiates every concrete operation from its complete attribute map, + /// then passes its `param` maps one at a time in stock child order. + pub fn instantiate_operations( + &self, + source: &StateGraph, + factory: &mut F, + ) -> Result, AssetError> { + let mut instances = Vec::with_capacity(self.operations.len()); + for operation in &self.operations { + let attributes = Attributes::new(&source.elements[operation.element].attributes); + let mut instance = factory + .create(operation.kind, operation.parent, &attributes) + .map_err(|e| operation_error(source, operation, &e.to_string()))? + .ok_or_else(|| operation_error(source, operation, "unregistered operation"))?; + for &element in &operation.parameters { + let attributes = Attributes::new(&source.elements[element].attributes); + factory + .add_parameter(&mut instance, &attributes) + .map_err(|e| operation_error(source, operation, &e.to_string()))?; + } + instances.push(instance); + } + Ok(OperationInstances { + operations: instances, + }) + } + + fn enabled(&self, node: Node) -> u8 { + match node { + Node::State(i) => self.states[i].enabled, + Node::Transition(i) => self.transitions[i].enabled, + Node::Expression(i) => self.expressions[i].enabled, + Node::Operation(i) => self.operations[i].enabled, + } + } + + fn process_names(&mut self, source: &StateGraph) -> Result<(), AssetError> { + for id in 0..self.states.len() { + let state = &self.states[id]; + if state.interruptibility == 2 { + let attrs = Attributes::new(&source.elements[state.element].attributes); + let name = attrs.text("interruptable").unwrap(); + let target = self + .find_state(id, name, true) + .ok_or_else(|| error(source, state.element, "unresolved interrupt ancestor"))?; + let mut ancestor = Some(id); + while ancestor.is_some() && ancestor != Some(target) { + ancestor = self.states[ancestor.unwrap()].parent; + } + if ancestor.is_none() { + return Err(error( + source, + state.element, + "interrupt target is not an ancestor", + )); + } + self.states[id].interrupt_ancestor = Some(target); + } + } + for id in 0..self.transitions.len() { + let transition = &self.transitions[id]; + let attrs = Attributes::new(&source.elements[transition.element].attributes); + let target = + self.find_state(transition.owner, attrs.text("target").unwrap_or(""), true); + self.transitions[id].target = target; + // 82C16FF0 disables unresolved/disabled targets; no invented fallback. + if target.is_none_or(|i| self.states[i].enabled == 0) { + self.transitions[id].enabled = 0; + } + } + Ok(()) + } + + /// Complete 82C16C10 search: first direct child, then self/ancestors if + /// allowed. Dotted paths search their first component with ascent enabled, + /// even if the caller disabled ascent, then resolve the suffix locally. + pub fn find_state(&self, start: usize, name: &str, ascend: bool) -> Option { + if let Some((first, rest)) = name.split_once('.') { + let found = self.find_state(start, first, true)?; + return self.find_state(found, rest, false); + } + let key = name_hash(name); + let mut state = start; + loop { + for &child in &self.states[state].children { + if name_hash(&self.states[child].name) == key { + return Some(child); + } + } + if !ascend { + return None; + } + if name_hash(&self.states[state].name) == key { + return Some(state); + } + state = self.states[state].parent?; + } + } +} + +/// Native interned-name comparison 82C118A0 uses FNV-1, without string equality +/// after the hash comparison. This differs from BinaryAttributeMap's key hash. +pub fn name_hash(text: &str) -> u32 { + text.bytes().fold(0x811c9dc5_u32, |hash, byte| { + hash.wrapping_mul(0x01000193) ^ u32::from(byte) + }) +} + +/// StateGraph::ParseMask 82C12BB0. Comparison is case-sensitive. TU3 returns +/// the precondition mask for a missing or unrecognized value. +pub fn parse_condition_mask(value: Option<&str>) -> u32 { + match value { + Some("sustain") => 2, + Some("always") => 3, + Some("postcond") => 4, + _ => 1, + } +} + +fn error(source: &StateGraph, element: usize, message: &str) -> AssetError { + let element = &source.elements[element]; + AssetError(format!( + "Graph {} at byte {}: {message}", + element.tag, element.source_offset + )) +} + +fn operation_error(source: &StateGraph, operation: &Operation, message: &str) -> AssetError { + error( + source, + operation.element, + &format!( + "{} `{}`: {message}", + operation_kind(operation.kind), + operation.name + ), + ) +} + +fn operation_kind(kind: OperationKind) -> &'static str { + match kind { + OperationKind::Behavior => "behaviour", + OperationKind::Condition => "condition", + OperationKind::Hook => "hook", + } +} diff --git a/crates/skate-data/src/tests/input_config.rs b/crates/skate-data/src/tests/input_config.rs new file mode 100644 index 00000000..692ba1ce --- /dev/null +++ b/crates/skate-data/src/tests/input_config.rs @@ -0,0 +1,36 @@ +use super::*; + +fn source() -> String { + let buttons = BUTTON_NAMES + .iter() + .enumerate() + .map(|(i, name)| format!("{name} = Button{i};\n")); + let gameplay = GAMEPLAY + .iter() + .map(|(name, value)| format!("{name} = {value};\n")); + buttons.chain(gameplay).collect() +} + +#[test] +fn accepts_spacing_comments_and_unrelated_menu_expressions() { + let source = format!( + "// controller mapping\n{}\nMenuKey = A.released ? 1 : -1;", + source().replace("LStickR-LStickL", "LStickR - LStickL // same expression\n") + ); + assert!(StockGameplayConfig::validate(&source).is_ok()); +} + +#[test] +fn rejects_changed_axis_or_button_instead_of_using_incompatible_specialization() { + for changed in [ + source().replace( + "GP_LStickX = LStickR-LStickL", + "GP_LStickX = LStickL-LStickR", + ), + source().replace("A = Button12", "A = Button13"), + source().replace("GP_AFace = A;", ""), + format!("{}GP_AFace = B;", source()), + ] { + assert!(StockGameplayConfig::validate(&changed).is_err()); + } +} diff --git a/crates/skate-data/tests/graph_attributes.rs b/crates/skate-data/tests/graph_attributes.rs new file mode 100644 index 00000000..becb233d --- /dev/null +++ b/crates/skate-data/tests/graph_attributes.rs @@ -0,0 +1,27 @@ +use skate_data::state_graph::GraphAttribute; +use skate_data::state_graph::attributes::Attributes; + +#[test] +fn binary_attributes_keep_first_record_and_separate_native_encodings() { + let records = vec![ + GraphAttribute { + name: "value".into(), + text: "".into(), + float_bits: 0x80000000, + boolean_byte: 255, + }, + GraphAttribute { + name: "value".into(), + text: "replacement".into(), + float_bits: 0x3f800000, + boolean_byte: 0, + }, + ]; + let attributes = Attributes::new(&records); + assert_eq!(attributes.text("value"), Some("")); + assert_eq!(attributes.float_bits("value", 0), 0x80000000); + assert_eq!(attributes.boolean_byte("value", 0), 255); + assert_eq!(attributes.text("missing"), None); + assert_eq!(attributes.float_bits("missing", 0x3f800000), 0x3f800000); + assert_eq!(attributes.boolean_byte("missing", 2), 2); +} diff --git a/crates/skate-data/tests/graph_binding.rs b/crates/skate-data/tests/graph_binding.rs new file mode 100644 index 00000000..090bd90a --- /dev/null +++ b/crates/skate-data/tests/graph_binding.rs @@ -0,0 +1,111 @@ +use skate_data::state_graph::{ + GraphAttribute, GraphElement, StateGraph, + binding::{Binding, parse_condition_mask}, +}; + +fn attr(name: &str, text: &str, byte: u8) -> GraphAttribute { + GraphAttribute { + name: name.into(), + text: text.into(), + float_bits: 0, + boolean_byte: byte, + } +} +fn element(tag: &str, attributes: Vec, children: Vec) -> GraphElement { + GraphElement { + source_offset: 0, + tag: tag.into(), + attributes, + children, + } +} +#[test] +fn constructor_flags_priorities_expressions_and_target_resolution() { + let source = StateGraph { + elements: vec![ + element("state", vec![attr("name", "Root", 0)], vec![1, 3, 4, 6]), + element( + "state", + vec![attr("name", "Off", 0), attr("enabled", "true", 0)], + vec![2], + ), + element( + "behaviour", + vec![attr("name", "Handler", 0), attr("enabled", "true", 255)], + vec![], + ), + element( + "transition", + vec![attr("target", "Off", 0), attr("priority", "urgent", 0)], + vec![], + ), + element( + "state", + vec![attr("name", "On", 0), attr("interruptable", "Root", 0)], + vec![5], + ), + element( + "transition", + vec![attr("target", "Root.On", 0), attr("priority", "med", 0)], + vec![], + ), + element("transition", vec![attr("target", "Absent", 0)], vec![7]), + element("expression", vec![attr("op", "or", 0)], vec![8]), + element("condition", vec![attr("name", "Condition", 0)], vec![]), + ], + }; + let binding = Binding::from_graph(&source).unwrap(); + assert_eq!(binding.states[0].children, vec![1, 2]); + assert_eq!(binding.states[1].enabled, 0); + assert_eq!(binding.operations[0].enabled, 0); // Inherited disable wins. + assert_eq!(binding.states[2].interruptibility, 2); + assert_eq!(binding.states[2].interrupt_ancestor, Some(0)); + assert_eq!(binding.transitions[0].target, Some(1)); + assert_eq!(binding.transitions[0].enabled, 0); // Target disables transition. + assert_eq!(binding.transitions[0].priority, 3); + assert_eq!(binding.transitions[1].target, Some(2)); + assert_eq!(binding.transitions[1].priority, 1); + assert_eq!(binding.transitions[2].target, None); + assert_eq!(binding.transitions[2].enabled, 0); + assert_eq!(binding.expressions[0].operator, 2); + assert_eq!(binding.operations[1].condition_mask, Some(1)); + // Constructor inheritance precedes Process disabling an unresolved target. + assert_eq!(binding.expressions[0].enabled, 1); +} + +#[test] +fn condition_masks_match_tu3_parse_mask_ordinals() { + assert_eq!(parse_condition_mask(None), 1); + assert_eq!(parse_condition_mask(Some("precond")), 1); + assert_eq!(parse_condition_mask(Some("sustain")), 2); + assert_eq!(parse_condition_mask(Some("always")), 3); + assert_eq!(parse_condition_mask(Some("postcond")), 4); + assert_eq!(parse_condition_mask(Some("PRECOND")), 1); + assert_eq!(parse_condition_mask(Some("unknown")), 1); +} + +#[test] +fn missing_native_operations_and_invalid_ancestry_are_explicit() { + let graph = StateGraph { + elements: vec![element("include", vec![], vec![])], + }; + assert!( + Binding::from_graph(&graph) + .unwrap_err() + .0 + .contains("unsupported graph element") + ); + let graph = StateGraph { + elements: vec![element( + "state", + vec![attr("interruptable", "Missing", 0)], + vec![], + )], + }; + assert!( + Binding::from_graph(&graph) + .unwrap_err() + .0 + .contains("unresolved interrupt ancestor") + ); +} diff --git a/crates/skate-data/tests/graph_operations.rs b/crates/skate-data/tests/graph_operations.rs new file mode 100644 index 00000000..f443607f --- /dev/null +++ b/crates/skate-data/tests/graph_operations.rs @@ -0,0 +1,122 @@ +use skate_data::state_graph::{ + GraphAttribute, GraphElement, StateGraph, + attributes::Attributes, + binding::{Binding, Node, OperationFactory, OperationKind}, +}; + +fn attr(name: &str, text: &str, bits: u32, byte: u8) -> GraphAttribute { + GraphAttribute { + name: name.into(), + text: text.into(), + float_bits: bits, + boolean_byte: byte, + } +} + +fn element(tag: &str, attributes: Vec, children: Vec) -> GraphElement { + GraphElement { + source_offset: 0x40, + tag: tag.into(), + attributes, + children, + } +} + +#[derive(Default)] +struct Factory { + events: Vec, + registered: bool, +} + +impl OperationFactory for Factory { + type Instance = String; + type Error = &'static str; + + fn create( + &mut self, + kind: OperationKind, + parent: Node, + attributes: &Attributes<'_>, + ) -> Result, Self::Error> { + let name = attributes.text("name").unwrap().to_owned(); + self.events + .push(format!("create:{kind:?}:{parent:?}:{name}")); + Ok(self.registered.then_some(name)) + } + + fn add_parameter( + &mut self, + instance: &mut Self::Instance, + attributes: &Attributes<'_>, + ) -> Result<(), Self::Error> { + self.events.push(format!( + "param:{instance}:{}:{:08X}:{}", + attributes.text("name").unwrap(), + attributes.float_bits("value", 0), + attributes.boolean_byte("value", 0) + )); + Ok(()) + } +} + +fn source() -> StateGraph { + StateGraph { + elements: vec![ + element("state", vec![attr("name", "Root", 0, 0)], vec![1]), + element("behaviour", vec![attr("name", "Drive", 0, 0)], vec![2, 3]), + element( + "param", + vec![ + attr("name", "first", 0, 0), + attr("value", "one", 0x3F80_0000, 7), + ], + vec![], + ), + element( + "param", + vec![ + attr("name", "second", 0, 0), + attr("value", "two", 0x4000_0000, 9), + ], + vec![], + ), + ], + } +} + +#[test] +fn factory_receives_complete_operation_then_ordered_typed_parameters() { + let source = source(); + let binding = Binding::from_graph(&source).unwrap(); + let mut factory = Factory { + registered: true, + ..Default::default() + }; + let instances = binding + .instantiate_operations(&source, &mut factory) + .unwrap(); + assert_eq!(instances.operations, ["Drive"]); + assert_eq!( + factory.events, + [ + "create:Behavior:State(0):Drive", + "param:Drive:first:3F800000:7", + "param:Drive:second:40000000:9", + ] + ); +} + +#[test] +fn missing_registration_is_a_source_located_error() { + let source = source(); + let binding = Binding::from_graph(&source).unwrap(); + let error = binding + .instantiate_operations(&source, &mut Factory::default()) + .unwrap_err(); + assert!(error.0.contains("byte 64")); + assert!( + error + .0 + .contains("behaviour `Drive`: unregistered operation") + ); +} diff --git a/crates/skate-data/tests/manifest.rs b/crates/skate-data/tests/manifest.rs new file mode 100644 index 00000000..07af5f3b --- /dev/null +++ b/crates/skate-data/tests/manifest.rs @@ -0,0 +1,61 @@ +use skate_data::GameAssets; + +fn manifest(path: &str, version: u32) -> String { + serde_json::json!({ + "version": version, + "character_scene": path, + "initial_animation": "R_IDLE_HCOM_000", + "action_graph": "private/stock/data/state/ActionGraph_OnBoard.stategraph", + "motion_graph": "private/stock/data/state/MotionGraph_OnBoard.stategraph" + }) + .to_string() +} +#[test] +fn validates_contract_without_loading_private_assets() { + assert_eq!( + GameAssets::parse(&manifest("private/skater.glb", 1)) + .unwrap() + .initial_animation, + "R_IDLE_HCOM_000" + ); +} +#[test] +fn rejects_incompatible_versions_and_ambiguous_paths() { + assert!(GameAssets::parse(&manifest("private/skater.glb", 2)).is_err()); + for path in [ + "../skater.glb", + "/skater.glb", + "C:/skater.glb", + "private\\skater.glb", + "private/skater.glb#Scene0", + "private/skater.json", + "", + ] { + assert!( + GameAssets::parse(&manifest(path, 1)).is_err(), + "accepted {path}" + ); + } +} + +#[test] +fn requires_relative_compiled_stock_graph_paths() { + let mut value: serde_json::Value = + serde_json::from_str(&manifest("private/skater.glb", 1)).unwrap(); + for field in ["action_graph", "motion_graph"] { + for path in ["../graph.stategraph", "/graph.stategraph", "graph.xml", ""] { + value[field] = path.into(); + assert!( + GameAssets::parse(&serde_json::to_string(&value).unwrap()).is_err(), + "accepted {field}={path}" + ); + } + value[field] = format!("private/{field}.stategraph").into(); + } +} +#[test] +fn reports_missing_data_as_an_error() { + let root = std::env::temp_dir().join(format!("skate-missing-assets-{}", std::process::id())); + let error = GameAssets::load(&root).unwrap_err().to_string(); + assert!(error.contains("private") && error.contains("Prepare-Assets")); +} diff --git a/crates/skate-data/tests/state_graph.rs b/crates/skate-data/tests/state_graph.rs new file mode 100644 index 00000000..f25dd508 --- /dev/null +++ b/crates/skate-data/tests/state_graph.rs @@ -0,0 +1,76 @@ +use skate_data::state_graph::StateGraph; + +fn element(tag: &str, attributes: &[(&str, &str, u32, u8)], children: &[Vec]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend(tag.bytes().chain([0])); + bytes.extend((attributes.len() as u32).to_be_bytes()); + for (key, text, bits, flag) in attributes { + bytes.extend(key.bytes().chain([0])); + bytes.extend(text.bytes().chain([0])); + bytes.extend(bits.to_be_bytes()); + bytes.push(*flag); + } + bytes.extend((children.len() as u32).to_be_bytes()); + for child in children { + bytes.extend(child); + } + bytes +} + +#[test] +fn preserves_tree_order_duplicate_attributes_and_encoded_values() { + let grandchild = element("condition", &[], &[]); + let first = element("expression", &[("op", "and", 0, 0)], &[grandchild]); + let second = element( + "behaviour", + &[("value", "0", 0x80000000, 2), ("value", "1", 0x7fc00001, 0)], + &[], + ); + let bytes = element("state", &[("name", "Root", 0, 0)], &[first, second]); + let graph = StateGraph::decode(&bytes).unwrap(); + assert_eq!( + graph + .elements + .iter() + .map(|e| e.tag.as_str()) + .collect::>(), + ["state", "expression", "condition", "behaviour"] + ); + assert_eq!(graph.elements[0].children, [1, 3]); + assert_eq!(graph.elements[1].children, [2]); + assert_eq!(graph.elements[3].attributes[0].float_bits, 0x80000000); + assert_eq!(graph.elements[3].attributes[0].boolean_byte, 2); + assert_eq!(graph.elements[3].attributes[1].float_bits, 0x7fc00001); + assert_eq!(graph.elements[3].attributes[1].text, "1"); + assert!( + graph + .elements + .windows(2) + .all(|e| e[0].source_offset < e[1].source_offset) + ); +} + +#[test] +fn rejects_truncation_and_trailing_data_without_partial_graph() { + let bytes = element( + "state", + &[("name", "Root", 0, 0)], + &[element("behaviour", &[], &[])], + ); + for end in 0..bytes.len() { + assert!( + StateGraph::decode(&bytes[..end]).is_err(), + "accepted truncation at {end}" + ); + } + let mut extra = bytes.clone(); + extra.push(0); + assert!( + StateGraph::decode(&extra) + .unwrap_err() + .to_string() + .contains("trailing") + ); + let excessive = b"state\0\xff\xff\xff\xff"; + assert!(StateGraph::decode(excessive).is_err()); +} diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml new file mode 100644 index 00000000..59ff6260 --- /dev/null +++ b/crates/skate-game/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "skate-game" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +bevy.workspace = true +skate-core.workspace = true +skate-data.workspace = true diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs new file mode 100644 index 00000000..7c9cc80c --- /dev/null +++ b/crates/skate-game/src/animation.rs @@ -0,0 +1,141 @@ +//! Render the same stock skeleton that animation and physics update. +//! The GLB supplies the mesh, skin weights and hierarchy; it does not play a +//! separate idle clip or own the skater's pose. +use crate::{app::FrameSet, assets::AssetStatus, physics::SkaterRuntime, world::PlayerRoot}; +use bevy::{mesh::skinning::SkinnedMesh, prelude::*}; +use skate_core::animation::output::NativeMatrix; + +#[derive(Resource, Default)] +pub(crate) struct AnimationStatus { + pub ready: bool, + bindings: Vec, +} +struct BoneBinding { + entity: Entity, + bone: usize, + parent_bone: Option, +} +pub(crate) struct AnimationPlugin; +impl Plugin for AnimationPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, (bind, present).chain().in_set(FrameSet::Animation)); + } +} +fn bind( + status: Res, + skater: Res, + mut animation: ResMut, + skins: Query<(Entity, &SkinnedMesh)>, + nodes: Query<(&Name, &Transform)>, + parents: Query<&ChildOf>, + roots: Query>, + mut exit: MessageWriter, +) { + if *status != AssetStatus::Ready || animation.ready { + return; + } + let names = &skater.animation.evaluator.frames.bone_names; + let result = (|| -> Result>, String> { + for (entity, skin) in &skins { + if !parents.iter_ancestors(entity).any(|e| roots.contains(e)) { + continue; + } + let mut binding = Vec::with_capacity(skin.joints.len()); + for &joint in &skin.joints { + let (name, _) = nodes + .get(joint) + .map_err(|_| "Skater skin joint is missing its name or transform")?; + let bone = names + .iter() + .position(|n| n.eq_ignore_ascii_case(name.as_str())) + .ok_or_else(|| { + format!("Skater skin bone {name} is absent from the stock rig") + })?; + let mut parent_bone = None; + for parent in parents.iter_ancestors(joint) { + if roots.contains(parent) { + break; + } + if let Ok((name, transform)) = nodes.get(parent) { + if let Some(index) = names + .iter() + .position(|n| n.eq_ignore_ascii_case(name.as_str())) + { + parent_bone = Some(index); + break; + } + // The supplied GLB's armature node is identity. Reject + // another import convention rather than double a pose. + if !transform.to_matrix().abs_diff_eq(Mat4::IDENTITY, 0.00001) { + return Err(format!( + "Skater armature ancestor {name} has an unsupported transform" + )); + } + } + } + binding.push(BoneBinding { + entity: joint, + bone, + parent_bone, + }); + } + // All ten imported mesh primitives share these joint entities and + // their authored inverse-bind matrices. Bind the joints once and + // retain that mesh reference: the stock initialization pose is an + // animation pose, not a replacement skin bind pose. + return Ok(Some(binding)); + } + Ok(None) + })(); + match result { + Ok(Some(binding)) => { + animation.bindings = binding; + animation.ready = true; + info!( + "GAME_CHARACTER_READY bones={} source=physical_stock_pose", + animation.bindings.len() + ); + } + Ok(None) => (), + Err(message) => { + error!("{message}"); + exit.write(AppExit::error()); + } + } +} +fn present( + skater: Res, + animation: Res, + mut nodes: Query<&mut Transform>, +) { + if !animation.ready { + return; + } + // Existing GLB was exported through Blender: its bone-local axes are + // rotated -90 degrees about X relative to the native frames. Both files' + // world positions are Y-up. This is a skin basis change, not a physics turn. + let basis = render_basis(); + for binding in &animation.bindings { + let global = native_matrix(skater.render_pose[binding.bone]) * basis; + let local = if let Some(parent) = binding.parent_bone { + (native_matrix(skater.render_pose[parent]) * basis).inverse() * global + } else { + global + }; + if let Ok(mut transform) = nodes.get_mut(binding.entity) { + *transform = Transform::from_matrix(local); + } + } +} +fn render_basis() -> Mat4 { + Mat4::from_cols(Vec4::X, -Vec4::Z, Vec4::Y, Vec4::W) +} +pub(crate) fn native_matrix(matrix: NativeMatrix) -> Mat4 { + Mat4::from_cols( + Vec3::from_array(matrix[0][..3].try_into().unwrap()).extend(0.0), + Vec3::from_array(matrix[1][..3].try_into().unwrap()).extend(0.0), + Vec3::from_array(matrix[2][..3].try_into().unwrap()).extend(0.0), + Vec3::from_array(matrix[3][..3].try_into().unwrap()).extend(1.0), + ) +} diff --git a/crates/skate-game/src/animation_pose.rs b/crates/skate-game/src/animation_pose.rs new file mode 100644 index 00000000..5faa0a84 --- /dev/null +++ b/crates/skate-game/src/animation_pose.rs @@ -0,0 +1,195 @@ +//! Production animation command evaluator. File IO and buffer ownership are +//! host services; frame selection, trajectory and pose math live in core. +use skate_core::animation::{ + output::{self, NativeMatrix, Sqt}, + playback_tree::PoseCommand, + pose_add, pose_blend, pose_mirror, + pose_sample::{sample_key_vbr, select_frames}, + pose_trajectory::{self, LoopTransform}, +}; +use skate_data::{ + animation_banks::AnimationBanks, + animation_frames::{AnimationFrames, ClipFrames, SampleWords}, +}; +use std::path::Path; + +pub(crate) struct PoseEvaluator { + pub frames: AnimationFrames, +} + +impl PoseEvaluator { + pub fn load(asset_root: &Path) -> Result { + Self::from_banks(&AnimationBanks::load(asset_root)?) + } + pub fn from_banks(banks: &AnimationBanks) -> Result { + Ok(Self { + frames: AnimationFrames::from_banks(banks)?, + }) + } + + /// Executes the ordered stock tree. This uses the native immediate ACS + /// arithmetic; the host does not recreate packed animation job commands. + pub fn evaluate(&self, commands: &[PoseCommand]) -> Result, String> { + let mut stack: Vec> = Vec::new(); + for command in commands { + match command { + PoseCommand::Pose { name } => { + // Init82B97E38 retains these references from the actor's + // initial database. AddBindPose82B98118 reuses them even + // when a later motion clip comes from another bank. + stack.push( + self.frames + .named_pose(name)? + .samples + .iter() + .copied() + .map(sqt) + .collect(), + ); + } + PoseCommand::Add { motion_is_a } => { + let other = stack + .pop() + .ok_or("Animation add has no reference subtree")?; + let motion = stack + .last_mut() + .ok_or("Animation add has no motion subtree")?; + if motion.len() != other.len() { + return Err("Animation add bone counts differ".into()); + } + for (motion, other) in motion.iter_mut().zip(other) { + *motion = if *motion_is_a { + pose_add::add(*motion, other, true) + } else { + pose_add::add(other, *motion, false) + }; + } + } + PoseCommand::Mirror { trajectory_mode } => { + let pose = stack.last_mut().ok_or("Animation mirror has no subtree")?; + pose_mirror::mirror( + pose, + &self.frames.parents, + &self.frames.mirror_indices, + *trajectory_mode, + )?; + } + PoseCommand::Clip { + name, + time, + previous_time, + loops, + } => { + let clip = self.frames.clip(name)?; + let mut pose = sample_clip(clip, *time)?; + if self.frames.has_trajectory { + let previous = sample_bone(clip, *previous_time, 0)?; + pose[0] = pose_trajectory::delta( + pose[0], + previous, + (*loops != 0).then_some(LoopTransform { + rotation: clip.loop_rotation_bits.map(f32::from_bits), + translation: clip.loop_translation_bits.map(f32::from_bits), + }), + ); + } + stack.push(pose); + } + PoseCommand::Blend { weight } | PoseCommand::ChannelBlend { weight, .. } => { + let second = stack.pop().ok_or("Animation blend has no second subtree")?; + let first = stack + .last_mut() + .ok_or("Animation blend has no first subtree")?; + if first.len() != second.len() { + return Err("Animation subtree bone counts differ".into()); + } + for (a, b) in first.iter_mut().zip(second) { + *a = match command { + PoseCommand::ChannelBlend { + use_channels_from_weights, + .. + } => pose_blend::channel_blend_sample( + *a, + b, + *weight, + *use_channels_from_weights, + ), + _ => pose_blend::blend_sample(*a, b, *weight), + }; + } + } + } + } + if stack.len() != 1 { + return Err(format!("Animation evaluation left{} poses", stack.len())); + } + Ok(stack.pop().unwrap()) + } + + /// TU3828D3800 then828D3B58. NewACS824744B8 initializes the + /// detached parent to0, so trajectory deltas do not move the local rig. + pub fn hierarchy(&self, pose: &[Sqt]) -> Result, String> { + if pose.len() != self.frames.parents.len() { + return Err("Animation pose and hierarchy bone counts differ".into()); + } + let mut globals: Vec<_> = pose.iter().copied().map(output::sqt_to_matrix).collect(); + output::compose_hierarchy_in_place( + globals.len() as i32, + &self.frames.parents, + 0, + &mut globals, + ) + .map_err(|e| format!("Invalid stock animation hierarchy: {e:?}"))?; + Ok(globals) + } +} + +fn sample_clip(clip: &ClipFrames, time: f32) -> Result, String> { + let selection = select_frames( + time, + f32::from_bits(clip.fps_bits), + clip.frames.len(), + true, + 0.0, + )?; + Ok(clip.frames[selection.first] + .iter() + .zip(&clip.frames[selection.second]) + .enumerate() + .map(|(bone, (&first, &second))| { + let mut sample = sample_key_vbr(sqt(first), sqt(second), selection); + sample.translation[3] = f32::from_bits(clip.channel_weights[bone]); + sample + }) + .collect()) +} + +fn sample_bone(clip: &ClipFrames, time: f32, bone: usize) -> Result { + let selection = select_frames( + time, + f32::from_bits(clip.fps_bits), + clip.frames.len(), + true, + 0.0, + )?; + let mut sample = sample_key_vbr( + sqt(clip.frames[selection.first][bone]), + sqt(clip.frames[selection.second][bone]), + selection, + ); + sample.translation[3] = f32::from_bits(clip.channel_weights[bone]); + Ok(sample) +} + +fn sqt(words: SampleWords) -> Sqt { + let [sx, sy, sz, x, y, z, w, tx, ty, tz] = words.map(f32::from_bits); + Sqt { + scale: [sx, sy, sz, 1.0], + rotation: [x, y, z, w], + translation: [tx, ty, tz, 1.0], + } +} + +#[cfg(test)] +#[path = "tests/animation_pose.rs"] +mod tests; diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs new file mode 100644 index 00000000..f19c2b3d --- /dev/null +++ b/crates/skate-game/src/app.rs @@ -0,0 +1,100 @@ +use crate::{ + animation, assets, camera, + config::Config, + graph_runtime::StockGraphs, + input, + physics::{GamePhysics, PhysicsPlugin, SkaterRuntime}, + verification, world, +}; +use bevy::{ + prelude::*, + render::{ + RenderPlugin, + settings::{Backends, InstanceFlags, RenderCreation, WgpuSettings}, + }, +}; +use skate_data::GameAssets; + +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub(crate) enum FrameSet { + Assets, + Physics, + Animation, + Verification, +} + +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub(crate) enum SimulationSet { + Input, + Controls, + Physics, +} + +pub(crate) fn build( + config: Config, + manifest: GameAssets, + graphs: StockGraphs, + physics: GamePhysics, + skater: SkaterRuntime, +) -> App { + let mut app = App::new(); + app.add_plugins( + DefaultPlugins + .set(AssetPlugin { + file_path: config.asset_root.to_string_lossy().into_owned(), + ..default() + }) + .set(WindowPlugin { + primary_window: Some(Window { + title: "Skate 3".into(), + resolution: (1280, 800).into(), + ..default() + }), + ..default() + }) + .set(RenderPlugin { + render_creation: RenderCreation::Automatic(WgpuSettings { + backends: Some(Backends::VULKAN), + // Existing machine's validation layer rejects wgpu atomic shaders. + // This workaround belongs only to the rendering adapter. + instance_flags: InstanceFlags::empty(), + ..default() + }), + ..default() + }), + ) + .insert_resource(config) + .insert_resource(assets::AssetManifest(manifest)) + .insert_resource(graphs) + .insert_resource(physics) + .insert_resource(skater) + .configure_sets( + FixedUpdate, + ( + SimulationSet::Input, + SimulationSet::Controls, + SimulationSet::Physics, + ) + .chain(), + ) + .configure_sets( + Update, + ( + FrameSet::Assets, + FrameSet::Physics, + FrameSet::Animation, + FrameSet::Verification, + ) + .chain(), + ) + .add_plugins(( + input::InputPlugin, + PhysicsPlugin, + assets::GameAssetsPlugin, + animation::AnimationPlugin, + world::WorldPlugin, + camera::CameraPlugin, + verification::VerificationPlugin, + )); + app +} diff --git a/crates/skate-game/src/assets.rs b/crates/skate-game/src/assets.rs new file mode 100644 index 00000000..792e9f9f --- /dev/null +++ b/crates/skate-game/src/assets.rs @@ -0,0 +1,46 @@ +use crate::app::FrameSet; +use bevy::{asset::LoadState, gltf::Gltf, prelude::*}; +use skate_data::GameAssets; + +#[derive(Resource)] +pub(crate) struct AssetManifest(pub GameAssets); +#[derive(Resource)] +pub(crate) struct CharacterAsset(pub Handle); +#[derive(Resource, Default, PartialEq, Eq)] +pub(crate) enum AssetStatus { + #[default] + Loading, + Ready, + Failed, +} + +pub(crate) struct GameAssetsPlugin; +impl Plugin for GameAssetsPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, load) + .add_systems(Update, check.in_set(FrameSet::Assets)); + } +} +fn load(mut commands: Commands, server: Res, manifest: Res) { + commands.insert_resource(CharacterAsset( + server.load(manifest.0.character_scene.clone()), + )); +} +fn check( + server: Res, + character: Res, + mut status: ResMut, + mut exit: MessageWriter, +) { + if *status != AssetStatus::Loading { + return; + } + if let Some(LoadState::Failed(error)) = server.get_load_state(character.0.id()) { + error!("Character asset failed to load: {error}"); + *status = AssetStatus::Failed; + exit.write(AppExit::error()); + } else if server.is_loaded_with_dependencies(character.0.id()) { + *status = AssetStatus::Ready; + } +} diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs new file mode 100644 index 00000000..ff5fac5b --- /dev/null +++ b/crates/skate-game/src/camera.rs @@ -0,0 +1,67 @@ +//! Normal gameplay camera rendering endpoint. Simulation owns the cadence of +//! CameraRuntime::advance after its complete physical/animation publication. +mod settings; +mod collision; +mod world_query; +mod shot_data; +mod stock_names; +mod shake_data; +mod trajectory; +mod subject; +mod graph_subject; +mod graph_conditions; +mod graph; +mod runtime; +mod publication; +pub(crate) use publication::{ + snapshot as publish_camera_subject, CameraPublicationInputs, CameraStateOutput, + CameraAnimationOutput, CameraAirOutput, CameraOffboardOutput, CameraGrindOutput, + CameraEventsOutput, CameraPreferences, +}; +pub(crate) use subject::{CameraSubjectFrame, CameraSubjectSnapshot}; +pub(crate) use graph_subject::{CameraGraphEnvironment, CameraGraphSubject}; +pub(crate) use runtime::CameraRuntime; +use bevy::prelude::*; +use crate::{app::FrameSet, config::Config}; + +#[derive(Component)] +struct GameplayCamera; + +pub(crate) struct CameraPlugin; +impl Plugin for CameraPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, spawn) + .add_systems(Update, present.after(FrameSet::Animation).before(FrameSet::Verification)); + } +} +fn spawn(mut commands: Commands, config: Res) { + let runtime = CameraRuntime::load(&config.asset_root) + .unwrap_or_else(|error| panic!("Cannot initialize normal gameplay camera: {error}")); + commands.insert_resource(runtime); + commands.spawn(( + GameplayCamera, + Camera3d::default(), + Camera { is_active: false, ..default() }, + Transform::default(), + )); +} + +fn present(mut runtime: ResMut, windows: Query<&Window>, + mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With>) { + if let Ok(window) = windows.single() { + runtime.set_aspect_ratio(window.width() / window.height()); + } + let Some(frame) = runtime.frame else { return; }; + for (mut camera, mut transform, mut projection) in &mut cameras { + let [right, up, at] = frame.basis.columns.map(Vec3::from_array); + // World rendering retains nativeXYZ. Native camera At points forward; + // Bevy camera looks along local-Z, hence the two-axis basis conversion. + transform.rotation = Quat::from_mat3(&Mat3::from_cols(-right, up, -at)); + transform.translation = Vec3::new(frame.position[0], frame.position[1], frame.position[2]); + if let Projection::Perspective(p) = &mut *projection { + p.fov = frame.field_of_view_degrees.to_radians(); + } + camera.is_active = true; + } +} diff --git a/crates/skate-game/src/camera/collision.rs b/crates/skate-game/src/camera/collision.rs new file mode 100644 index 00000000..71863c9d --- /dev/null +++ b/crates/skate-game/src/camera/collision.rs @@ -0,0 +1,66 @@ +//! Host camera query adapter. The six source fat lines are preserved, and each +//! candidate uses the shared recovered TU3 thin/fat triangle dispatcher. +use skate_core::{ + camera::{DropCollisionProvider, FatLine, FatLineResult, PositionerCollisionProvider}, + physics::board_world::BoardWorld, +}; + +pub(super) struct CameraCollision<'a> { + world: &'a BoardWorld, + results: [FatLineResult; 6], + pub error: Option, +} + +impl DropCollisionProvider for CameraCollision<'_> { + fn query( + &mut self, + lines: [FatLine; 10], + _context: u32, + ) -> Result<[FatLineResult; 10], String> { + let mut output = [no_hit(); 10]; + for (result, line) in output.iter_mut().zip(lines) { + *result = super::world_query::line(self.world, line.start, line.end, line.radius)?; + } + Ok(output) + } +} + +impl<'a> CameraCollision<'a> { + pub fn new(world: &'a BoardWorld) -> Self { + Self { + world, + results: [no_hit(); 6], + error: None, + } + } +} + +impl PositionerCollisionProvider for CameraCollision<'_> { + fn submit(&mut self, lines: [FatLine; 6], _context: u32) { + // Context excludes the subject actor from native world queries. The + // clone's BoardWorld contains world triangles only, never actor bodies. + self.results = [no_hit(); 6]; + for (result, line) in self.results.iter_mut().zip(lines) { + match super::world_query::line(self.world, line.start, line.end, line.radius) { + Ok(hit) => *result = hit, + Err(error) => { + self.error = Some(error.into()); + } + } + } + } + fn results(&mut self) -> [FatLineResult; 6] { + self.results + } +} + +// 82DF3290 initializes both vectors, fraction and surface to zero on a miss. +fn no_hit() -> FatLineResult { + FatLineResult { + position: [0.0; 4], + normal: [0.0; 4], + fraction: 0.0, + hit: 0, + surface: 0, + } +} diff --git a/crates/skate-game/src/camera/graph.rs b/crates/skate-game/src/camera/graph.rs new file mode 100644 index 00000000..55dc7ad9 --- /dev/null +++ b/crates/skate-game/src/camera/graph.rs @@ -0,0 +1,117 @@ +//! Stock default camera graph, using the same recovered dynamic controller as +//! the action and motion graphs. Shot behavior side effects occur at Begin. +use std::path::Path; +use skate_core::{camera::{CameraMan, ManagerSubject, SlowMotionController, SlowMotionSettings, + SimulationRateRequest}, graph::{activation::ConditionHost, + controller::{Controller, Frame, Host}}}; +use skate_data::state_graph::{StateGraph, attributes::Attributes, binding::Binding}; +use crate::graph_runtime::CompiledGraph; +use super::{graph_conditions::{Condition, shot_names}, graph_subject::{CameraGraphEnvironment, + CameraGraphSubject}, shot_data::StockShots}; + +pub(super) struct CameraGraph { + program: CompiledGraph, + controller: Controller, + conditions: Vec, + behaviors: Vec, + slow_motion: Vec>, + slow_motion_settings: SlowMotionSettings, +} +enum Behavior { + Choose { names: Vec, incoming: f32, outgoing: f32 }, + Print(String), + SlowMotion, +} +impl CameraGraph { + pub fn load(path: &Path, slow_motion_settings: SlowMotionSettings) -> Result { + let source = StateGraph::load(path).map_err(|e| e.to_string())?; + let binding = Binding::from_graph(&source).map_err(|e| e.to_string())?; + let program = CompiledGraph::from_binding(&binding).map_err(|e| e.to_string())?; + let conditions = program.operations.conditions.iter().map(|id| { + let node = &source.elements[binding.operations[*id].element]; + Condition::parse(&Attributes::new(&node.attributes)) + }).collect::, _>>()?; + let behaviors = program.operations.behaviors.iter().map(|id| { + let operation = &binding.operations[*id]; + let node = &source.elements[operation.element]; + let a = Attributes::new(&node.attributes); + Ok(match operation.name.as_str() { + "CameraChooseShot" => Behavior::Choose { names: shot_names(&a), + incoming: f32::from_bits(a.float_bits("transitionIn", (-1.0_f32).to_bits())), + outgoing: f32::from_bits(a.float_bits("transitionOut", (-1.0_f32).to_bits())) }, + "PrintText2D" => Behavior::Print(a.text("text").unwrap_or("").into()), + "SlowMotionController" => Behavior::SlowMotion, + name => return Err(format!("Unimplemented camera behavior {name}")), + }) + }).collect::, String>>()?; + if !program.operations.hooks.is_empty() { + return Err("Camera graph has unimplemented transition hooks".into()); + } + let controller = Controller::new(binding.states.len()); + let slow_motion = vec![None; behaviors.len()]; + Ok(Self { program, controller, conditions, behaviors, slow_motion, slow_motion_settings }) + } + + pub fn update(&mut self, dt: f32, manager: &mut CameraMan, subject: &ManagerSubject, + physical: CameraGraphSubject, world: &CameraGraphEnvironment, database: &StockShots) + -> Result, String> { + let mut host = CameraHost { manager, subject, physical, world, database, + conditions: &self.conditions, behaviors: &self.behaviors, error: None, + slow_motion: &mut self.slow_motion, slow_motion_settings: self.slow_motion_settings, + rates: Vec::new() }; + self.controller.update(&self.program.program, dt, &mut host); + if let Some(error) = host.error { Err(error) } else { Ok(host.rates) } + } +} +struct CameraHost<'a> { + manager: &'a mut CameraMan, + subject: &'a ManagerSubject, + physical: CameraGraphSubject, + world: &'a CameraGraphEnvironment, + database: &'a StockShots, + conditions: &'a [Condition], + behaviors: &'a [Behavior], + error: Option, + slow_motion: &'a mut [Option], + slow_motion_settings: SlowMotionSettings, + rates: Vec, +} +impl ConditionHost for CameraHost<'_> { + fn condition_activation(&mut self, id: usize, _: &Frame) -> u32 { + u32::from(self.conditions[id].evaluate(self.manager, self.subject, self.physical, self.world)) + } +} +impl Host for CameraHost<'_> { + fn context(&self) -> [u32; 6] { [0; 6] } + fn allocate(&mut self, _: usize, _: &Frame) -> u32 { 0 } + fn begin(&mut self, id: usize, _: [u32; 6], _: &Frame) { + match &self.behaviors[id] { + Behavior::Choose { names, incoming, outgoing } => { + if let Some(name) = names.first() { + if let Err(error) = self.manager.set_shot(name, false, self.subject, self.database) { + self.error = Some(error); return; + } + self.manager.shots.apply_graph_transition(*incoming, *outgoing); + } + } + Behavior::Print(text) => bevy::log::warn!("Stock camera graph: {text}"), + Behavior::SlowMotion => { + let (instance, request) = SlowMotionController::begin(self.slow_motion_settings); + self.slow_motion[id] = Some(instance); + self.rates.push(request); + } + } + } + // CameraChooseShot Update/End both resolve to TU382B61BB8 (return). + fn update(&mut self, id: usize, _: [u32; 6], frame: &Frame) { + if let Some(instance) = &mut self.slow_motion[id] { + self.rates.push(instance.update(frame.dt, self.physical.slow_motion_air_duration, + self.slow_motion_settings)); + } + } + fn end(&mut self, id: usize, _: [u32; 6], _: &Frame) { + if self.slow_motion[id].take().is_some() { self.rates.push(SlowMotionController::end()); } + } + fn hook(&mut self, _: usize, _: &Frame) { unreachable!("camera hooks rejected during load") } + fn release(&mut self, _: u32) {} +} diff --git a/crates/skate-game/src/camera/graph_conditions.rs b/crates/skate-game/src/camera/graph_conditions.rs new file mode 100644 index 00000000..b0d1fe10 --- /dev/null +++ b/crates/skate-game/src/camera/graph_conditions.rs @@ -0,0 +1,131 @@ +//! Camera condition leaves82DF4A90..82DF61DC, bound to the stock binary graph. +use skate_core::{camera::{CameraMan, ManagerSubject}, graph::conditions::NumericCondition}; +use skate_data::state_graph::attributes::{Attributes, key_hash}; +use super::graph_subject::{CameraGraphEnvironment, CameraGraphSubject}; + +#[derive(Clone, Debug)] +pub(super) enum Condition { + Type(u32), Previous(Vec), Tweak(u32), Volume(String), + Boolean(Boolean), Numeric(Value, NumericCondition), +} +#[derive(Clone, Copy, Debug)] +pub(super) enum Boolean { + Observer, WipingOut, BrokenBone, Offboard, OffboardAir, OnboardAir, + FixedInAir, Preparing, Manual, DroppingIn, MovingObject, Skitching, + Footplant, Handplant, Boneless, HippyJump, HippyHurdle, Grinding, + WasGrinding, OnGround, Reentry, Road, LedgeLeft, LedgeRight, + WorldPainter, Threat, BadCollision, +} +#[derive(Clone, Copy, Debug)] +pub(super) enum Value { + Speed, SpeedY, Acceleration, AirTime, LandingHeight, MaxHeight, HeightRatio, + FrontsideAngle, TransferAngle, LaunchIncline, LandingIncline, GrindIncline, + Incline, AbsoluteIncline, CentredTime, TurningTime, TimeSinceInput, +} +impl Condition { + pub fn parse(a: &Attributes<'_>) -> Result { + use Boolean as B; + use Value as V; + let name = a.text("name").unwrap_or(""); + let boolean = match name { + "IsInObserverMode" => Some(B::Observer), "IsWipingOut" => Some(B::WipingOut), + "IsBrokenBoneSlowMo" => Some(B::BrokenBone), "IsOffboard" => Some(B::Offboard), + "IsAirOffboard" => Some(B::OffboardAir), "IsInOnBoardAir" => Some(B::OnboardAir), + "IsFixedInAir" => Some(B::FixedInAir), "IsPreparingToJump" => Some(B::Preparing), + "IsInManual" => Some(B::Manual), "SkaterIsDroppingIn" => Some(B::DroppingIn), + "SkaterIsMovingObject" => Some(B::MovingObject), "SkaterIsSkitching" => Some(B::Skitching), + "SkaterIsPerformingFootPlant" => Some(B::Footplant), + "SkaterIsPerformingHandPlant" => Some(B::Handplant), + "SkaterIsPerformingBoneless" => Some(B::Boneless), + "SkaterIsPerformingHippyJump" => Some(B::HippyJump), + "SkaterIsPerformingHippyHurdle" => Some(B::HippyHurdle), + "SkaterIsGrinding" => Some(B::Grinding), "SkaterWasGrinding" => Some(B::WasGrinding), + "IsPlayerSkateboardOnGround" => Some(B::OnGround), "IsReentry" => Some(B::Reentry), + "SkaterIsOnRoad" => Some(B::Road), "IsGrindLedgeLeft" => Some(B::LedgeLeft), + "IsGrindLedgeRight" => Some(B::LedgeRight), "IsInWorldPainterRegion" => Some(B::WorldPainter), + "SkaterIsUnderThreat" => Some(B::Threat), "IsCameraInBadCollision" => Some(B::BadCollision), + _ => None, + }; + if let Some(value) = boolean { return Ok(Self::Boolean(value)); } + let value = match name { + "CameraSubjectSpeed" => Some(V::Speed), "CameraSubjectSpeedY" => Some(V::SpeedY), + "CameraSubjectAcceleration" => Some(V::Acceleration), "PredictedAirTime" => Some(V::AirTime), + "PredictedLandingHeight" => Some(V::LandingHeight), "PredictedMaxHeight" => Some(V::MaxHeight), + "PredictedHeightDistanceRatio" => Some(V::HeightRatio), "FrontsideAngle" => Some(V::FrontsideAngle), + "TransferAngle" => Some(V::TransferAngle), "LaunchInclineAngle" => Some(V::LaunchIncline), + "LandingInclineAngle" => Some(V::LandingIncline), "GrindInclineAngle" => Some(V::GrindIncline), + "InclineAngle" => Some(V::Incline), "InclineAngleAbs" => Some(V::AbsoluteIncline), + "TurningCentredTime" => Some(V::CentredTime), "TurningTime" => Some(V::TurningTime), + "TimeSinceLastPlayerInput" => Some(V::TimeSinceInput), _ => None, + }; + if let Some(value) = value { + return Ok(Self::Numeric(value, crate::graph_host::parse_numeric_condition(a))); + } + Ok(match name { + "IsCameraTypeActive" => Self::Type(a.text("type").unwrap_or("0").parse() + .map_err(|_| "Invalid normal camera type")?), + "IsPreviousShot" => Self::Previous(shot_names(a).iter().map(|v| key_hash(v)).collect()), + "IsInVolume" => Self::Volume(a.text("volume").unwrap_or("").to_owned()), + // Constructor82DF5588 uses case-sensitive comparison and keeps0 + // for both Freefall and an unrecognized tweak name. + "IsWipeoutBodyTweak" => Self::Tweak(match a.text("tweak").unwrap_or("") { + "CannonBall" => 1, "JudoKick" => 2, "SwanDive" => 3, "Torpedo" => 4, _ => 0 }), + _ => return Err(format!("Unimplemented stock camera condition {name}")), + }) + } + + pub fn evaluate(&self, m: &CameraMan, s: &ManagerSubject, g: CameraGraphSubject, + world: &CameraGraphEnvironment) -> bool { + use Boolean as B; + use Value as V; + let f = &m.rig.fields; + let state = &m.state; + const DEGREES: f32 = f32::from_bits(0x42652ee1); + match self { + Self::Type(value) => *value == world.camera_type, + Self::Previous(names) => names.contains(&key_hash(&m.shots.current().name)), + Self::Tweak(value) => *value == g.wipeout_tweak, + //82DF90C0 compares exactly strlen(requested) bytes with82990158. + Self::Volume(name) => world.volumes.iter() + .any(|v| v.as_bytes().starts_with(name.as_bytes())), + Self::Boolean(value) => match value { + B::Observer => s.special_effect != 0, B::WipingOut => s.rig.wiping_out != 0, + B::BrokenBone => s.rig.broken_bone_slowmo != 0, + B::Offboard => s.rig.off_board != 0 || g.running_out, + B::OffboardAir => g.offboard_air, B::OnboardAir => g.onboard_air, + B::FixedInAir => s.rig.air_flag_452 != 0, B::Preparing => g.preparing_to_jump, + B::Manual => g.manual, B::DroppingIn => g.dropping_in, B::MovingObject => g.moving_object, + B::Skitching => g.skitching, B::Footplant => g.footplant, B::Handplant => g.handplant, + B::Boneless => g.boneless, B::HippyJump => g.hippy_jump, B::HippyHurdle => g.hippy_hurdle, + B::Grinding => f.height_mode == 2, B::WasGrinding => f.previous_height_mode == 2, + B::OnGround => f.height_mode == 0, B::Reentry => state.flags & 0x10 != 0, + B::Road => world.on_road, B::LedgeLeft => world.ledge_left, B::LedgeRight => world.ledge_right, + // TU382DF5050 returns false. AI is intentionally absent from this game. + B::WorldPainter | B::Threat => false, + B::BadCollision => m.rig.positioner.flags & 0x80 != 0 + && m.rig.positioner.distance < f.distance * 0.75, + }, + Self::Numeric(value, condition) => condition.matches(match value { + //82E03060: signed velocity projected on the subject's forward axis. + V::Speed => { let n = s.rig.transform[2]; let v = f.velocity; + (v[0] * n[0] + v[1] * n[1]) + v[2] * n[2] }, + V::SpeedY => f.velocity[1], V::Acceleration => f.smoothed_acceleration, + V::AirTime => s.valid_trajectory_duration(), V::LandingHeight => state.landing_height_delta, + V::MaxHeight => state.apex_height, V::HeightRatio => state.apex_height_ratio, + V::FrontsideAngle => state.frontside_angle, + V::TransferAngle => state.launch_landing_heading_delta * DEGREES, + V::LaunchIncline => state.launch_incline * DEGREES, + V::LandingIncline => state.landing_incline * DEGREES, + V::GrindIncline => state.direction_incline * DEGREES, + V::Incline => state.velocity_incline * DEGREES, + V::AbsoluteIncline => (state.absolute_velocity_incline * DEGREES).abs(), + V::CentredTime => state.centred_time, V::TurningTime => state.steering_time, + V::TimeSinceInput => g.time_since_player_input, + }), + } + } +} + +pub(super) fn shot_names(a: &Attributes<'_>) -> Vec { + (1..).map(|i| format!("shot{i}")).map_while(|key| a.text(&key).map(str::to_owned)).collect() +} diff --git a/crates/skate-game/src/camera/graph_subject.rs b/crates/skate-game/src/camera/graph_subject.rs new file mode 100644 index 00000000..79c8c4e2 --- /dev/null +++ b/crates/skate-game/src/camera/graph_subject.rs @@ -0,0 +1,34 @@ +//! Remaining physical subject getters consumed by the stock camera graph. +//! These are publications from physics/animation, not deductions from the shot. +#[derive(Clone, Copy, Debug)] +pub(crate) struct CameraGraphSubject { + pub time_since_player_input: f32, // getter520 + pub wipeout_tweak: u32, //536 + pub onboard_air: bool, //572 + pub preparing_to_jump: bool, //576 + pub manual: bool, //580 + pub offboard_air: bool, //612 + pub running_out: bool, //616 + pub footplant: bool, //620 + pub handplant: bool, //624 + pub hippy_jump: bool, //628 + pub hippy_hurdle: bool, //632 + pub boneless: bool, //636 + pub moving_object: bool, //644 + pub skitching: bool, //648 + pub dropping_in: bool, //664 + /// Physical output bundle2+184, read directly by82BB1EE0. + pub slow_motion_air_duration: f32, +} + +/// Engine-owned camera preferences and authored world camera-region membership. +/// An empty custom world has no road/ledge/volume camera annotations. +#[derive(Clone, Debug)] +pub(crate) struct CameraGraphEnvironment { + /// Normal stock camera type0=Low, type1=High (graph-authored selection). + pub camera_type: u32, + pub on_road: bool, + pub ledge_left: bool, + pub ledge_right: bool, + pub volumes: Vec, +} diff --git a/crates/skate-game/src/camera/publication.rs b/crates/skate-game/src/camera/publication.rs new file mode 100644 index 00000000..4f43d032 --- /dev/null +++ b/crates/skate-game/src/camera/publication.rs @@ -0,0 +1,343 @@ +//! Game owners -> normal camera subject82DF69C0. Call after physical output. +//! Missing publications are explicit inputs, never inferred from render pose. +use super::{graph_subject::CameraGraphSubject, subject::CameraSubjectSnapshot}; +use crate::physics::{GamePhysics, SkaterRuntime}; +use skate_core::{ + camera::{ + AnchorInputs, Anchors, CompassPoseInputs, ManagerSubject, ReferencePointInputs, Subject, + SubjectPoseInputs, + }, + physics::{board::BodyId, skeleton_animation_record::AnimationPartTransform as Transform}, +}; + +/// State record fields absent from the input phase's smaller CurrentStateFields. +/// The same output/reset owner must publish these; there is no camera default. +pub(crate) struct CameraStateOutput { + pub height_32: f32, + pub physically_pushing_55: u8, + pub wiping_out_59: u8, + pub manual_60: u8, + pub reset_62: u8, + pub use_skeleton_root_75: u8, + pub flag_79: u8, + pub flag_81: u8, +} + +pub(crate) struct CameraAnimationOutput { + /// PhysOutAnimation32..60 from the existing turn conditioner, once per tick. + pub conditioned_turn: [f32; 8], + pub input_turn_64: f32, + pub input_kickturn_68: f32, + pub time_since_input_128: f32, + pub wipeout_tweak_148: u32, + pub stance_155: u8, + pub running_out_160: u8, + /// Actor's actual SkaterAnim component virtual28, copied at82DF6EA4. + pub skater_animation_stance: u8, +} + +/// Air output2. The ordinary Ground cancellation/reset owns its retained +/// values and validity; a false validity flag does not erase these vectors. +pub(crate) struct CameraAirOutput { + pub apex_0: [f32; 4], + pub landing_position_16: [f32; 4], + pub landing_normal_32: [f32; 4], + pub launch_position_48: [f32; 4], + pub heading_80: [f32; 4], + pub time_176: f32, + pub duration_180: f32, + pub apex_time_196: f32, + pub flag_440: u8, +} + +/// The alternate trajectory and event fields read from OffBoard output18. +pub(crate) struct CameraOffboardOutput { + pub duration_92: f32, + pub time_152: f32, + pub apex_time_156: f32, + pub launch_normal_160: [f32; 4], + pub launch_position_176: [f32; 4], + pub landing_normal_192: [f32; 4], + pub landing_position_208: [f32; 4], + pub heading_224: [f32; 4], + pub apex_240: [f32; 4], + pub object_held_304: u8, + pub hurdle_317: u8, + pub use_trajectory_331: u8, + pub dropping_in_334: u8, +} + +/// Grinds output4, distinct from Ground output8 (whose316 is wall-ride exit). +pub(crate) struct CameraGrindOutput { + pub direction_0: [f32; 4], + pub camera_target_96: [f32; 4], + pub grinding_316: u8, +} + +pub(crate) struct CameraEventsOutput { + pub intent_51: u8, + pub preparing_52: u8, + pub dropping_in_63: u8, + pub trick_125: u8, + pub hippy_jump_322: u8, + pub broken_bone_duration_200: f32, + pub capabilities_204: u32, +} + +/// Engine-owned preference values copied by the original subject publisher. +pub(crate) struct CameraPreferences { + pub invert_look: [bool; 2], + pub shake_variant: u8, + pub value_32: f32, +} + +/// Remaining real output producers needed alongside the existing game owners. +/// This structure deliberately has no Default: its callers must publish the +/// actual native reset/state values even when air/offboard/grinds are inactive. +pub(crate) struct CameraPublicationInputs { + pub state: CameraStateOutput, + pub animation: CameraAnimationOutput, + pub air: CameraAirOutput, + pub offboard: CameraOffboardOutput, + pub grinds: CameraGrindOutput, + pub events: CameraEventsOutput, + /// PhysOutSystemReckoning80, after its own physical output conditioner. + pub damped_com_80: [f32; 4], + /// PhysOutGround80 and288.80 differs from dynamic up64 and wheel normal96. + pub ground_up_80: [f32; 4], + pub ground_scalar_288: f32, + /// PhysOutSkeleton552/556 and PhysOutCollision64, respectively. + pub look_552_556: [f32; 2], + pub collision_look_target_64: [f32; 4], + pub preferences: CameraPreferences, + /// Our stable player/query identity replaces the native actor address. + pub context: u32, +} + +/// Publish from the very same physical bodies and completed output records +/// used by the animation phase. This neither advances physics nor conditions +/// the steering twice. SubjectPublisher owns the subsequent camera histories. +pub(crate) fn snapshot( + physics: &GamePhysics, + skater: &SkaterRuntime, + input: &CameraPublicationInputs, +) -> Result { + let p = &skater.player_input.physical; + let processed = &skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Camera publication requires the completed player input toolkit")?; + let ground = skater + .ground + .output(processed, &skater.animation_input, toolkit); + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let mut physical_transform = [[0.0; 4]; 4]; + for (column, axis) in physics + .riding + .motion + .effective_basis + .columns + .iter() + .enumerate() + { + physical_transform[column][..3].copy_from_slice(axis); + } + physical_transform[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + //82BE3650 -> PhysOutSkeleton0/432 at82BE20F4/2100. Its stance flag is + //Processed2476 bit2, distinct from the board's effective-frame flag2468. + let skeleton_root = effective_skeleton_root( + skater.animated_skeleton.roots.animation_to_world, + processed.flags_2476, + ); + let record = &skater.skeleton.record; + let com = p.reckoning.vector_64.map(f32::from_bits); + let up = p.reckoning.vector_96.map(f32::from_bits); + let velocity = p.skateboard.vector_80.map(f32::from_bits); + //The source getter396 is bound to Motion64, despite its camera name + //"acceleration". Motion64 is the published deck angular velocity. + let acceleration = p.skateboard.vector_64.map(f32::from_bits); + let last_ground_up = p.ground.vector_96.map(f32::from_bits); + let category = p.state.category_12; + let state = p.state.state_16; + let off = &input.offboard; + let air = &input.air; + let alternate = off.use_trajectory_331 != 0; + let ( + launch_position, + launch_normal, + landing_position, + landing_normal, + apex_position, + heading, + trajectory_time, + trajectory_duration, + apex_time, + ) = if alternate { + ( + off.launch_position_176, + off.launch_normal_160, + off.landing_position_208, + off.landing_normal_192, + off.apex_240, + off.heading_224, + off.time_152, + off.duration_92, + off.apex_time_156, + ) + } else { + ( + air.launch_position_48, + last_ground_up, + air.landing_position_16, + air.landing_normal_32, + air.apex_0, + air.heading_80, + air.time_176, + air.duration_180, + air.apex_time_196, + ) + }; + let grinding = input.grinds.grinding_316; + let look = std::array::from_fn(|i| { + if input.preferences.invert_look[i] { + -input.look_552_556[i] + } else { + input.look_552_556[i] + } + }); + let subject = ManagerSubject { + rig: Subject { + //Overwritten by the stock subject's one-frame publication history. + transform: physical_transform, + skeleton_root, + hips_position: record.pose[23][3], + last_valid_ground_up: last_ground_up, + reference_positions: [[0.0; 4]; 10], + context: input.context, + pumping_acceleration: skater.ground.pumping.pump_acceleration, + state_height_32: input.state.height_32, + in_ground_physics: u8::from(category == 100), + grinding, + trajectory_valid: if alternate { 1 } else { p.air.flag_441 }, + wiping_out: input.state.wiping_out_59, + physically_pushing: input.state.physically_pushing_55, + at_pushable_speed: u8::from(ground.skateboard_motion_4.is_at_pushable_speed), + off_board: u8::from(category == 500), + air_flag_452: p.air.use_air_reckoning_452, + state_flag_81: input.state.flag_81, + broken_bone_slowmo: u8::from( + input.events.broken_bone_duration_200 > 0.0 + && input.events.capabilities_204 & 4 != 0, + ), + subject_flag_328: 0, //82DF726C clears this every publication. + }, + anchors: Anchors::new().entries, //filled by the real anchor histories. + compass: [0.0; 9], //filled by the real Compass owner. + board_offset_direction: up, + ground_normal: input.ground_up_80, + launch_position, + launch_normal, + landing_position, + landing_normal, + apex_position, + direction_424: input.grinds.direction_0, + look, + steering: [ + input.animation.conditioned_turn[4], + input.animation.conditioned_turn[3], + input.animation.input_turn_64, + input.animation.input_kickturn_68, + ], + trajectory_time, + trajectory_duration, + apex_time, + value_512: input.preferences.value_32, + value_516: input.ground_scalar_288, + reset: input.state.reset_62, + flag_556: air.flag_440, + stance_560: input.animation.stance_155, + stance_592: input.animation.skater_animation_stance, + flag_652: input.state.flag_79, + shake_variant: input.preferences.shake_variant, + special_effect: 0, //82DF6A44..6A60 clears observer mode for this subject. + flag_684: off.object_held_304, + }; + Ok(CameraSubjectSnapshot { + subject, + pose: SubjectPoseInputs { + physical_transform, + skeleton_root, + center_of_mass: com, + reckoned_center_of_mass: input.damped_com_80, + wiping_out: input.state.wiping_out_59 != 0, + state_flag_75: input.state.use_skeleton_root_75 != 0, + }, + anchors: AnchorInputs { + board_position: physical_transform[3], + board_velocity: velocity, + board_acceleration: acceleration, + board_offset_direction: up, + center_of_mass: com, + damped_center_of_mass: input.damped_com_80, + skeleton_root_up: skeleton_root[1], + grind_point: input.grinds.camera_target_96, + grinding: grinding != 0, + reset: input.state.reset_62 != 0, + }, + reference_points: ReferencePointInputs { + //82BE1E64..1ED4 copies these actual physical volume positions. + head: record.pose[1][3], + hips: record.pose[23][3], + left_foot: record.pose[15][3], + right_foot: record.pose[19][3], + board: skeleton_root[3], + centre_of_mass: com, + damped_centre_of_mass: input.damped_com_80, + grind_position: input.grinds.camera_target_96, + tracked_anchor: [0.0; 4], + incline_normal: [0.0; 4], + grinding, + }, + compass: CompassPoseInputs { + current_physical_transform: physical_transform, + skeleton_direction: skeleton_root[2], + look_target: input.collision_look_target_64, + board_velocity: velocity, + trajectory_direction: heading, + state_103: state == 103, + }, + graph: CameraGraphSubject { + time_since_player_input: input.animation.time_since_input_128, + wipeout_tweak: input.animation.wipeout_tweak_148, + onboard_air: category == 200, + preparing_to_jump: input.events.preparing_52 != 0, + manual: input.state.manual_60 != 0 && input.events.intent_51 == 0, + offboard_air: state == 501, + running_out: input.animation.running_out_160 != 0, + footplant: state == 601, + handplant: state == 600, + hippy_jump: input.events.hippy_jump_322 != 0, + hippy_hurdle: off.hurdle_317 != 0, + boneless: input.events.trick_125 != 0, + moving_object: state == 502, + skitching: state == 104, + dropping_in: input.events.dropping_in_63 != 0 && off.dropping_in_334 != 0, + slow_motion_air_duration: p.air.scalar_184, + }, + }) +} + +fn effective_skeleton_root(mut root: Transform, flags_2476: u32) -> Transform { + if flags_2476 & 4 != 0 { + root[0] = root[0].map(|v| -v); + root[2] = root[2].map(|v| -v); + } + root +} diff --git a/crates/skate-game/src/camera/runtime.rs b/crates/skate-game/src/camera/runtime.rs new file mode 100644 index 00000000..49d39c57 --- /dev/null +++ b/crates/skate-game/src/camera/runtime.rs @@ -0,0 +1,91 @@ +//! Complete normal camera owner: subject publications, stock graph, CameraMan, +//! swept world queries and retained query history. The simulation calls advance +//! once per completed physical/animation publication; rendering only presents it. +use std::path::Path; +use bevy::prelude::Resource; +use skate_core::{camera::{CameraFrame, CameraMan, CompassSettings, ManagerSettings, + MovingObstacleProvider, ShakeSamples, SimulationRateRequest, SlowMotionSettings}, + physics::board_world::BoardWorld, point_graph::PointGraph}; +use skate_data::collections::Collections; +use super::{collision::CameraCollision, graph::CameraGraph, graph_subject::CameraGraphEnvironment, + settings, shake_data, shot_data::StockShots, subject::{CameraSubjectSnapshot, SubjectPublisher}, + trajectory::{CameraTrajectory, TrajectoryResult}}; + +#[derive(Resource)] +pub(crate) struct CameraRuntime { + manager: CameraMan, + subject: SubjectPublisher, + graph: CameraGraph, + shots: StockShots, + settings: ManagerSettings, + compass_settings: CompassSettings, + shakes: [ShakeSamples; 2], + trajectories: [TrajectoryResult; 3], + pub frame: Option, + /// Native message order, including End followed by Begin in one graph tick. + /// The simulation schedule drains these after advance. + pub simulation_rate_requests: Vec, +} + +#[cfg(test)] +#[path = "tests/runtime.rs"] +mod tests; +impl CameraRuntime { + pub fn load(root: &Path) -> Result { + let data = Collections::load(root)?; + let values = data.words::<32>("slowmotion_controller", "default", "timescale")? + .map(f32::from_bits); + let slow_motion = SlowMotionSettings { + timescale: PointGraph { + x: core::array::from_fn(|i| values[i]), + y: core::array::from_fn(|i| values[i + 16]), + }, + fps_at_scale_one: data.float("slowmotion_controller", "default", "fps_at_scale_one")?, + }; + let graph = CameraGraph::load(&root.join( + "private/stock/data/script/camera/Default_cameragraph.stategraph"), slow_motion)?; + let samples = |name: &str| -> Result { + let path = root.join("private/stock/data/camera").join(name); + shake_data::parse(&std::fs::read_to_string(&path) + .map_err(|e| format!("{}: {e}", path.display()))?) + }; + Ok(Self { manager: CameraMan::new(), subject: SubjectPublisher::new(), graph, + shots: StockShots::from_collections(&data)?, settings: settings::manager_settings(&data)?, + compass_settings: settings::compass_settings(&data)?, shakes: [samples("1.shk")?, samples("2.shk")?], + trajectories: core::array::from_fn(|_| TrajectoryResult::new()), frame: None, + simulation_rate_requests: Vec::new() }) + } + + pub fn set_aspect_ratio(&mut self, value: f32) { + self.manager.state.aspect_ratio = value; + } + pub fn selected_shot(&self) -> &str { &self.manager.shots.current().name } + + pub fn advance(&mut self, dt: f32, snapshot: CameraSubjectSnapshot, + world: &BoardWorld, query_gravity: [f32; 4], environment: &CameraGraphEnvironment, + moving: &mut impl MovingObstacleProvider) -> Result { + let mut subject = self.subject.publish(snapshot, &self.manager, self.compass_settings); + self.manager.prepare(&subject, self.settings); + let requests = self.graph.update(dt, &mut self.manager, &subject, + snapshot.graph, environment, &self.shots)?; + self.simulation_rate_requests.extend(requests); + let [a, b, c] = &mut self.trajectories; + let mut trajectories = [a, b, c].map(|result| CameraTrajectory { + world, gravity: query_gravity, result, + }); + let mut collision = CameraCollision::new(world); + let frame = self.manager.update(dt, &mut subject, self.settings, + [&self.shakes[0], &self.shakes[1]], &mut trajectories, moving, &mut collision)?; + if let Some(error) = collision.error { return Err(error); } + if let Some(error) = self.trajectories.iter().find_map(|v| v.error.as_ref()) { + return Err(error.clone()); + } + if !frame.position.iter().all(|v| v.is_finite()) + || !frame.basis.columns.iter().flatten().all(|v| v.is_finite()) + || !frame.field_of_view_degrees.is_finite() { + return Err("Normal gameplay camera produced a non-finite frame".into()); + } + self.frame = Some(frame); + Ok(frame) + } +} diff --git a/crates/skate-game/src/camera/settings.rs b/crates/skate-game/src/camera/settings.rs new file mode 100644 index 00000000..b7ab7144 --- /dev/null +++ b/crates/skate-game/src/camera/settings.rs @@ -0,0 +1,207 @@ +//! Stock normal-camera rig settings, bound by TU3 8252E288/8252EDD0. +//! Runtime offsets in camera kernels are relative to settings-object +16. +use skate_core::{ + camera::{ + AnchorTrackingSettings, AngleTrackingSettings, AvoidanceSettings, DistanceTrackingSettings, + DropSettings, FrameSettings, LookSettings, OrientationSettings, OrientationTrackerSettings, + RigPositioningSettings, RigSettings, ShakeSettings, ManagerSettings, CompassSettings, + }, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RigSettingsSource { + heading: AngleTrackingSettings, + elevation: AngleTrackingSettings, + anchor: AnchorTrackingSettings, + avoidance: AvoidanceSettings, + positioning: RigPositioningSettings, + collision_hold_duration: f32, +} + +pub(crate) fn manager_settings(data: &Collections) -> Result { + Ok(ManagerSettings { + rig: RigSettingsSource::load(data)?.bind(), orientation: orientation_settings(data)?, + framing: frame_settings(data)?, drop: drop_settings(data)?, look: look_settings(data)?, + shake: shake_settings(data)?, + steering_threshold: data.float("camera", "dynamics", "TurningCentredDeadzone")?, + }) +} + +pub(crate) fn compass_settings(data: &Collections) -> Result { + let f = |field| data.float("camera_compass", "default", field); + Ok(CompassSettings { + heading_response: data.float("camera", "dynamics", "HeadingChangeResponseSpeed")?, + time_before_lineup: f("TimeBeforeAutoLineup")?, lineup_speed: f("AutoLineupSpeed")?, + minimum_deadzone_speed: f("MinDeadzoneSpeed")?, maximum_deadzone_speed: f("MaxDeadzoneSpeed")?, + maximum_deadzone_size: f("MaxDeadzoneSize")?, deadzone_smoothing: f("DeadzoneSizeSmoothing")?, + }) +} + +pub(crate) fn drop_settings(data: &Collections) -> Result { + // Runtime parameter652/656/660/668 correspond to settings object + //668/672/676/684, bound from droppredictor layout208/204/220/228. + Ok(DropSettings { + minimum_test_distance: data.float("camera_droppredictor", "default", "MinTestDistance")?, + total_test_time: data.float("camera_droppredictor", "default", "TotalTestTime")?, + maximum_test_distance: data.float( + "camera_droppredictor", + "default", + "Hash_FB5A1024D47298A1", + )?, + maximum_drop_distance: data.float("camera_droppredictor", "default", "MaxDropDistance")?, + }) +} + +pub(crate) fn look_settings(data: &Collections) -> Result { + // Settings object952/960/964/972 -> parameter936/944/948/956. + Ok(LookSettings { + heading_offset_degrees: data.float("camera", "freecam", "FreeCamHeadingOffset")?, + heading_speed: data.float("camera", "freecam", "FreeCamHeadingSpeed")?, + elevation_offset_degrees: data.float("camera", "freecam", "FreeCamElevationOffset")?, + elevation_speed: data.float("camera", "freecam", "FreeCamElevationSpeed")?, + }) +} + +pub(crate) fn shake_settings(data:&Collections)->Result { + let f=|field|data.float("camera_shake","default",field); + let matrix=|field|->Result<[[f32;4];4],String> { + let words=data.words::<16>("camera_shake","default",field)?; + Ok(core::array::from_fn(|row|core::array::from_fn(|i|f32::from_bits(words[row*4+i])))) + }; + Ok(ShakeSettings { + amplitude_curve:matrix("AmplitudeCurve")?,frequency_curve:matrix("FrequencyCurve")?, + impulse_magnitude:f("OneShotMagnitude")?,impulse_minimum_velocity:f("OneShotMinVelocity")?, + impulse_maximum_velocity:f("OneShotMaxVelocity")?,impulse_frequency:f("OneShotFrequency")?, + impulse_decay:f("OneShotDecay")?,amplitude_minimum:f("AmplitudeMin")?, + amplitude_maximum:f("AmplitudeMax")?,amplitude_top_speed:f("AmplitudeTopSkaterSpeed")?, + frequency_minimum:f("FrequencyMin")?,frequency_maximum:f("FrequencyMax")?, + frequency_top_speed:f("FrequencyTopSkaterSpeed")?,data_frames_per_second:f("DataFPS")?, + translation_multiplier:f("TranslationMultiplier")?,rotation_multiplier:f("RotationMultiplier")?, + dutch_multiplier:f("DutchMultiplier")?, + }) +} + +impl RigSettingsSource { + pub fn load(data: &Collections) -> Result { + let f = |class, key, field| data.float(class, key, field); + // 8252E6CC binds heading; 8252E680 binds elevation. These are + // settings for the normal camera's angular trackers, in degrees. + let angle = |key| -> Result { + Ok(AngleTrackingSettings { + speed_clamp_degrees: f("camera_tracker", key, "SpeedClamp")?, + acceleration_min_degrees: f("camera_tracker", key, "AccelerationClampMin")?, + acceleration_max_degrees: f("camera_tracker", key, "AccelerationClampMax")?, + delta_umbra_degrees: f("camera_tracker", key, "DeltaUmbra")?, + delta_penumbra_degrees: f("camera_tracker", key, "DeltaPenumbra")?, + }) + }; + Ok(Self { + heading: angle("heading")?, + elevation: angle("elevation")?, + anchor: AnchorTrackingSettings { + latch_curve: curve(data, "camera_lag_profile", "push", "Curve")?, + input_curve: curve(data, "camera_lag_profile", "pump", "Curve")?, + speed_clamp: f("camera", "subject_tracker", "SubjectTrackerMaxSpeed")?, + acceleration_clamp: f( + "camera", + "subject_tracker", + "SubjectTrackerMaxAcceleration", + )?, + latch_scale: f("camera_lag_profile", "push", "MaxSmoothing")?, + input_threshold: f("camera", "dynamics", "PumpingMinAcceleration")?, + input_scale: f("camera_lag_profile", "pump", "MaxSmoothing")?, + }, + avoidance: AvoidanceSettings { + horizon: f("camera", "dynamics", "AvoidanceLookahead")?, + heading_smoothing: f("camera", "dynamics", "AvoidanceHeadingSmoothing")?, + elevation_smoothing: f("camera", "dynamics", "AvoidanceElevationSmoothing")?, + ease_out_time: f("camera", "dynamics", "AvoidanceEaseOut")?, + radius_padding: f("camera_positioner", "default", "AvoidanceCollisionOffset")?, + }, + positioning: RigPositioningSettings { + normal: DistanceTrackingSettings { + speed_clamp: f("camera_tracker", "distance", "SpeedClamp")?, + smoothing: f("camera_tracker", "distance", "SmoothingConstant")?, + acceleration_clamp: f("camera_tracker", "distance", "AccelerationClampMax")?, + }, + alternate: DistanceTrackingSettings { + speed_clamp: f("camera_tracker", "distance", "FastSpeedClamp")?, + // Native layout +12 has no recovered spelling. Its complete + // key is preserved by the stock collection conversion. + smoothing: f("camera_tracker", "distance", "Hash_E2D9D0FAAB8CB7F3")?, + acceleration_clamp: f( + "camera_tracker", + "distance", + "FastAccelerationClampMax", + )?, + }, + minimum_distance: f("camera_positioner", "default", "MinDistance")?, + alternate_minimum_distance: f( + "camera_positioner", + "default", + "MinDistanceDuringWipeout", + )?, + }, + collision_hold_duration: f("camera", "dynamics", "CollisionInterpolationDuration")?, + }) + } + + pub fn bind(self) -> RigSettings { + RigSettings { + heading: self.heading, + elevation: self.elevation, + anchor: self.anchor, + avoidance: self.avoidance, + positioning: self.positioning, + collision_hold_duration: self.collision_hold_duration, + // 82F826F8 loads82181A88 with lvlx128, splats lane0, and stores + // all four lanes at830BD350. This is not mapped BSS zero data. + normalization_threshold: [f32::from_bits(0x358637bd); 4], + } + } +} + +pub(crate) fn orientation_settings(data: &Collections) -> Result { + let f = |class, key, field| data.float(class, key, field); + let tracker = |key| -> Result { + Ok(OrientationTrackerSettings { + acceleration_min_degrees: f("camera_tracker", key, "AccelerationClampMin")?, + acceleration_max_degrees: f("camera_tracker", key, "AccelerationClampMax")?, + // 8252F448/8252F508 attribute lookup low wordD1CA9582. + smoothing_min: f("camera_tracker", key, "SmoothingMin")?, + delta_umbra_degrees: f("camera_tracker", key, "DeltaUmbra")?, + delta_penumbra_degrees: f("camera_tracker", key, "DeltaPenumbra")?, + }) + }; + Ok(OrientationSettings { + pan_umbra_degrees: f("camera", "rotation", "PanUmbra")?, + pan_penumbra_degrees: f("camera", "rotation", "PanPenumbra")?, + pan_smoothing_curve: curve(data, "camera", "rotation", "PanSmoothingCurve")?, + pan_minimum_smoothing: f("camera", "rotation", "PanMinSmoothing")?, + pan: tracker("pan")?, + tilt: tracker("tilt")?, + }) +} + +pub(crate) fn frame_settings(data: &Collections) -> Result { + // 8252EE80/8252EE90 read camera/dynamics layout624/576 into + // settings-object660/628, hence parameter644/612. + Ok(FrameSettings { + maximum_pitch_degrees: data.float("camera", "dynamics", "MaxPitch")?, + roll_response: data.float("camera", "dynamics", "DutchInterpolationSpeed")?, + }) +} + +fn curve(data: &Collections, class: &str, key: &str, name: &str) -> Result, String> { + let words = data.words::<16>(class, key, name)?; + let values = words.map(f32::from_bits); + if values.iter().any(|value| !value.is_finite()) { + return Err(format!("Non-finite camera curve {class}/{key}/{name}")); + } + Ok(PointGraph { + x: core::array::from_fn(|i| values[i]), + y: core::array::from_fn(|i| values[i + 8]), + }) +} diff --git a/crates/skate-game/src/camera/shake_data.rs b/crates/skate-game/src/camera/shake_data.rs new file mode 100644 index 00000000..2bb76b03 --- /dev/null +++ b/crates/skate-game/src/camera/shake_data.rs @@ -0,0 +1,19 @@ +//! Owned .shk parser; numerical sequence centering follows82E05DC0 in core. +use skate_core::camera::ShakeSamples; + +pub(crate) fn parse(text:&str)->Result { + let mut tokens=text.split_whitespace(); + let count=tokens.next().ok_or("Empty camera .shk file")? + .parse::().map_err(|e|format!("Invalid camera shake sample count: {e}"))?; + let mut rows=Vec::with_capacity(count); + for index in 0..count { + let mut row=[0.0_f32;8]; + for field in &mut row { + *field=tokens.next().ok_or_else(||format!("Truncated camera shake sample {index}"))? + .parse::().map_err(|e|format!("Invalid camera shake sample {index}: {e}"))?; + } + rows.push(row); + } + if tokens.next().is_some() {return Err("Camera shake has extra sample data".into());} + ShakeSamples::from_rows(&rows) +} diff --git a/crates/skate-game/src/camera/shot_data.rs b/crates/skate-game/src/camera/shot_data.rs new file mode 100644 index 00000000..c6b8345f --- /dev/null +++ b/crates/skate-game/src/camera/shot_data.rs @@ -0,0 +1,178 @@ +//! Immutable stock shot definitions. Field order/units follow TU382CA4660. +use skate_core::camera::{Shot, ShotDatabase, ShotDefinition}; +use skate_data::collections::Collections; +use std::collections::BTreeMap; + +const CLASS: &str = "camera_shots"; +const CLASS_HASH: u64 = 0xf27dd93e059ef6cb; +const RADIANS: f32 = f32::from_bits(0x3c8efa35); + +pub(crate) struct StockShots(BTreeMap); + +impl StockShots { + pub fn from_collections(data: &Collections) -> Result { + let mut names = BTreeMap::new(); + for entry in data.entries().iter().filter(|c| c.class_name == CLASS) { + let text = data.field(CLASS, &entry.key, "CollectionName")?; + if text.type_name != "EA::Reflection::Text" { + return Err(format!("Invalid camera collection name {}", entry.key)); + } + if names + .insert( + super::stock_names::lookup8(entry.key.as_bytes()), + text.data.clone(), + ) + .is_some() + { + return Err("Duplicate camera collection hash".into()); + } + } + let mut definitions = BTreeMap::new(); + for entry in data.entries().iter().filter(|c| c.class_name == CLASS) { + let key = entry.key.as_str(); + let float = |field: &str| data.float(CLASS, key, field); + let enumeration = + |field| -> Result { Ok(data.words::<1>(CLASS, key, field)?[0]) }; + let boolean = + |field| -> Result { Ok(u8::from(data.boolean(CLASS, key, field)?)) }; + let floats = |fields: &[&str]| -> Result, String> { + fields.iter().map(|field| float(field)).collect() + }; + let child = |field| -> Result, String> { + let words = data.words::<6>(CLASS, key, field)?; + let class = (u64::from(words[0]) << 32) | u64::from(words[1]); + let hash = (u64::from(words[2]) << 32) | u64::from(words[3]); + if hash == 0 { + return Ok(None); + } + if class != CLASS_HASH { + return Err(format!("Non-shot reference {key}/{field}")); + } + let name = names + .get(&hash) + .ok_or_else(|| format!("Missing camera child {key}/{field}: {hash:016X}"))?; + Ok((!name.is_empty()).then(|| name.to_ascii_lowercase())) + }; + let shot = Shot { + distance: float("PositionDistance")?, + lens_length: float("FramingLensLength")?, + smoothing: floats(&[ + "SmoothingDirection", + "SmoothingElevation", + "SmoothingYaw", + "SmoothingPitch", + ])? + .try_into() + .unwrap(), + reference_weights: floats(&[ + "ReferenceWeightHead", + "ReferenceWeightHips", + "ReferenceWeightGrind", + "ReferenceWeightCOM", + "ReferenceWeightBoard", + "ReferenceWeightTrajectory", + "ReferenceWeightAnchor", + "ReferenceWeightFeet", + "ReferenceWeightDampedCOM", + "Hash_D70C95EDC16A7DFC", + ])? + .try_into() + .unwrap(), + board_offset: float("ReferenceBoardOffset")?, + position_heading: float("PositionHeading")? * RADIANS, + position_elevation: float("PositionElevation")? * RADIANS, + framing: [ + float("FramingRoll")? * RADIANS, + float("FramingYaw")? * RADIANS, + float("FramingPitch")? * RADIANS, + ], + follow_subject_in_air: boolean("OptionFollowSubjectInAir")?, + mirror_for_stance: boolean("OptionMirrorForStance")?, + snap_to_reference_point: boolean("OptionSnapToReferencePoint")?, + use_previous_shot: boolean("OptionUsePreviousShot")?, + use_drop_predictor: boolean("OptionUseDropPredictor")?, + use_free_camera_stick: boolean("OptionUseFreeCamStick")?, + // The sole dynamic attribute has the native missing-value zero. + // All layout fields above are required in the converted data. + avoidance_override: if has_field(data, key, "OptionAvoidanceOverride")? { + boolean("OptionAvoidanceOverride")? + } else { + 0 + }, + blur: float("FXBlurShot")?, + transition_blur: float("FXBlurTransition")?, + subject_opacity: float("FXSubjectOpacity")?, + collision_hint: enumeration("OptionCollisionType")?, + anchor: enumeration("OptionAnchor")?, + compass_north: enumeration("OptionCompassNorth")?, + world_heading: float("WorldHeading")? * RADIANS, + ..Shot::new() + }; + let definition = ShotDefinition { + name: key.to_ascii_lowercase(), + shot_type: enumeration("ShotType")?, + shot, + transition_time: float("TransitionTime")?, + transition_units: enumeration("TransitionTimeUnits")?, + children: [ + child("BlendShot1")?, + child("BlendShot2")?, + child("BlendShot3")?, + ], + blend_points: [ + float("BlendPoint1")?, + float("BlendPoint2")?, + float("BlendPoint3")?, + ], + blend_value: float("BlendValue")?, + blend_type: enumeration("BlendType")?, + blend_smoothing: float("SmoothingBlendValue")?, + }; + if definitions + .insert(definition.name.clone(), definition) + .is_some() + { + return Err(format!("Duplicate lowercase camera shot {key}")); + } + } + for definition in definitions.values() { + for name in definition.children.iter().flatten() { + if !definitions.contains_key(name) { + return Err(format!( + "Camera shot {} references missing named shot {name}", + definition.name + )); + } + } + } + Ok(Self(definitions)) + } +} + +impl ShotDatabase for StockShots { + fn load(&self, name: &str) -> Result { + self.0 + .get(&name.to_ascii_lowercase()) + .cloned() + .ok_or_else(|| format!("Missing stock camera shot {name}")) + } +} + +fn has_field(data: &Collections, key: &str, name: &str) -> Result { + let mut current = key; + for _ in 0..=data.entries().len() { + let entry = data + .entries() + .iter() + .find(|c| c.class_name == CLASS && c.key == current) + .ok_or_else(|| format!("Missing camera collection {current}"))?; + if entry.fields.contains_key(name) { + return Ok(true); + } + if entry.parent.is_empty() { + return Ok(false); + } + current = &entry.parent; + } + Err(format!("Cyclic camera collection inheritance {key}")) +} diff --git a/crates/skate-game/src/camera/stock_names.rs b/crates/skate-game/src/camera/stock_names.rs new file mode 100644 index 00000000..dacaba6c --- /dev/null +++ b/crates/skate-game/src/camera/stock_names.rs @@ -0,0 +1,42 @@ +//! Host decoding of the collection-name hash stored in stock RefSpec values. +//! Same lookup8 byte format as the existing private XML extraction tools. +pub(super) fn lookup8(bytes: &[u8]) -> u64 { + if bytes.is_empty() { + return 0; + } + let mut a = 0xabcdef0011223344_u64; + let mut b = a; + let mut c = 0x9e3779b97f4a7c13_u64; + let mut blocks = bytes.chunks_exact(24); + for block in &mut blocks { + a = a.wrapping_add(u64::from_le_bytes(block[..8].try_into().unwrap())); + b = b.wrapping_add(u64::from_le_bytes(block[8..16].try_into().unwrap())); + c = c.wrapping_add(u64::from_le_bytes(block[16..24].try_into().unwrap())); + mix(&mut a, &mut b, &mut c); + } + c = c.wrapping_add(bytes.len() as u64); + for (i, byte) in blocks.remainder().iter().enumerate() { + match i { + 0..=7 => a = a.wrapping_add(u64::from(*byte) << (8 * i)), + 8..=15 => b = b.wrapping_add(u64::from(*byte) << (8 * (i - 8))), + _ => c = c.wrapping_add(u64::from(*byte) << (8 * (i - 15))), + } + } + mix(&mut a, &mut b, &mut c); + c +} + +fn mix(a: &mut u64, b: &mut u64, c: &mut u64) { + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 43); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 9); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 8); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 38); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 23); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 5); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 35); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 49); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 11); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 12); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 18); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 22); +} diff --git a/crates/skate-game/src/camera/subject.rs b/crates/skate-game/src/camera/subject.rs new file mode 100644 index 00000000..9370826b --- /dev/null +++ b/crates/skate-game/src/camera/subject.rs @@ -0,0 +1,62 @@ +//! Typed physics/animation publication boundary for normal subject82DF69C0. +//! The simulation supplies current physical outputs; camera history stays here. +use bevy::prelude::Resource; +use skate_core::camera::{ + AnchorInputs, Anchors, CameraMan, Compass, CompassPoseInputs, CompassSettings, + ManagerSubject, ReferencePointInputs, SubjectPoseInputs, SubjectPosePublisher, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct CameraSubjectSnapshot { + /// Physical flags, trajectory, steering, root and other direct outputs. + /// The camera overwrites this value's transform, reference_positions, + /// anchors and compass with the source publishers below before use. + pub subject: ManagerSubject, + pub pose: SubjectPoseInputs, + pub anchors: AnchorInputs, + /// Bone positions and raw COM; tracked_anchor, incline_normal and damped + /// COM are filled by their camera owners immediately before publication. + pub reference_points: ReferencePointInputs, + pub compass: CompassPoseInputs, + pub graph: super::graph_subject::CameraGraphSubject, +} + +/// Root's simulation publisher replaces this after a complete physical/pose +/// update. None means there is no valid gameplay subject yet. +#[derive(Resource, Default)] +pub(crate) struct CameraSubjectFrame { + pub snapshot: Option, + pub generation: u64, +} + +pub(super) struct SubjectPublisher { + pose: SubjectPosePublisher, + anchors: Anchors, + compass: Compass, +} +impl SubjectPublisher { + pub fn new() -> Self { + Self { pose: SubjectPosePublisher::new(), anchors: Anchors::new(), compass: Compass::new() } + } + + pub fn publish(&mut self, mut input: CameraSubjectSnapshot, manager: &CameraMan, + settings: CompassSettings) -> ManagerSubject { + let pose = self.pose.publish(input.pose); + input.subject.rig.transform = pose.transform; + input.subject.rig.skeleton_root = input.pose.skeleton_root; + input.anchors.damped_center_of_mass = pose.damped_center_of_mass; + self.anchors.update(input.anchors); + input.subject.anchors = self.anchors.entries; + input.reference_points.damped_centre_of_mass = pose.damped_center_of_mass; + input.reference_points.tracked_anchor = manager.rig.anchor.position; + input.reference_points.incline_normal = manager.state.incline_normal; + input.subject.rig.reference_positions = input.reference_points.positions(); + let selected = if manager.shots.current().name.is_empty() { 5 } + else { manager.shots.current().shot.compass_north }; + let compass_input = input.compass.bind(&input.subject, manager.frame.position, selected); + input.subject.compass = self.compass.update( + if input.subject.reset != 0 { 0.0 } else { f32::from_bits(0x3c88_8889) }, + compass_input, settings); + input.subject + } +} diff --git a/crates/skate-game/src/camera/tests/runtime.rs b/crates/skate-game/src/camera/tests/runtime.rs new file mode 100644 index 00000000..0a28b2be --- /dev/null +++ b/crates/skate-game/src/camera/tests/runtime.rs @@ -0,0 +1,10 @@ +use super::*; + +#[test] +#[ignore = "requires the user's converted stock camera and graph assets"] +fn private_stock_camera_loads_every_graph_operation_and_shot_dependency() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let camera = CameraRuntime::load(root).expect("complete normal stock camera assets"); + assert!(camera.frame.is_none()); +} diff --git a/crates/skate-game/src/camera/trajectory.rs b/crates/skate-game/src/camera/trajectory.rs new file mode 100644 index 00000000..c7390349 --- /dev/null +++ b/crates/skate-game/src/camera/trajectory.rs @@ -0,0 +1,47 @@ +//! Camera trajectory requests82DF9270/82DF9338/82DF9340. Rust executes the +//! world work synchronously; PathEvaluator still consumes it on its next poll. +use skate_core::{ + camera::{PredictionPath, TrajectoryCollisionRequest, TrajectoryQuery}, + physics::board_world::BoardWorld, +}; + +#[derive(Clone, Debug)] +pub(super) struct TrajectoryResult { + ready: bool, + time: f32, + pub error: Option, +} +impl TrajectoryResult { + pub fn new() -> Self { Self { ready: false, time: -1.0, error: None } } +} + +pub(super) struct CameraTrajectory<'a> { + pub world: &'a BoardWorld, + pub gravity: [f32; 4], + pub result: &'a mut TrajectoryResult, +} +impl TrajectoryCollisionRequest for CameraTrajectory<'_> { + fn submit(&mut self, path: PredictionPath, _context: u32, acceleration_flag: u8) { + self.result.ready = false; + self.result.error = None; + let query = TrajectoryQuery { + position: path.position, velocity: path.velocity, + gravity: if acceleration_flag != 0 { self.gravity } else { [0.0; 4] }, + duration: path.horizon, radius: path.radius, + // 82DF92BC/82DF9304 set both maximum-error fractions to1. + start_error: 1.0, end_error: 1.0, + }; + match query.collision_time(|start, end, radius| world_line(self.world, start, end, radius)) { + Ok(time) => { self.result.time = time; self.result.ready = true; } + Err(error) => self.result.error = Some(error), + } + } + fn is_ready(&mut self) -> bool { self.result.ready } + fn collision_time(&mut self) -> f32 { self.result.time } +} + +fn world_line(world: &BoardWorld, start: [f32; 4], end: [f32; 4], radius: f32) + -> Result, String> { + let hit = super::world_query::line(world, start, end, radius)?; + Ok((hit.hit != 0).then_some(hit.position)) +} diff --git a/crates/skate-game/src/camera/world_query.rs b/crates/skate-game/src/camera/world_query.rs new file mode 100644 index 00000000..d6e3b460 --- /dev/null +++ b/crates/skate-game/src/camera/world_query.rs @@ -0,0 +1,37 @@ +//! Camera triangle leaf82771D08 stores the authored face normal, clamps every +//! fraction and retains the first equal-distance hit in world traversal order. +use skate_core::{camera::FatLineResult, math::Vector3, + physics::{board_world::BoardWorld, triangle_query::{TriangleLineHit, triangle_segment}}}; + +pub(super) fn line(world: &BoardWorld, start: [f32; 4], end: [f32; 4], radius: f32) + -> Result { + if !radius.is_finite() || radius < 0.0 { + return Err("Invalid stock camera collision radius".into()); + } + let start = vector(start); + let delta = Vector3::new(end[0] - start.x, end[1] - start.y, end[2] - start.z); + let mut fraction = f32::MAX; + let mut result = no_hit(); + for entry in world.triangles() { + let mut hit = TriangleLineHit { position: Vector3::ZERO, normal: Vector3::ZERO, + fraction: 0.0, volume_parameter: [0.0; 3] }; + // Zero triangle fatness and zero material exclusion mask are native. + // Subject actor exclusion is preserved by BoardWorld's world-only data. + if triangle_segment(&mut hit, start, delta, entry.triangle.vertices, radius, 0.0) { + let lower = if -hit.fraction >= 0.0 { 0.0 } else { hit.fraction }; + let candidate = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + if candidate < fraction { + fraction = candidate; + result = FatLineResult { position: lanes(hit.position), + normal: lanes(entry.triangle.feature.normal), fraction: candidate, + hit: 1, surface: entry.tag }; + } + } + } + Ok(result) +} +pub(super) fn no_hit() -> FatLineResult { + FatLineResult { position: [0.0; 4], normal: [0.0; 4], fraction: 0.0, hit: 0, surface: 0 } +} +fn vector(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } +fn lanes(v: Vector3) -> [f32; 4] { [v.x, v.y, v.z, 0.0] } diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs new file mode 100644 index 00000000..05903dc4 --- /dev/null +++ b/crates/skate-game/src/config.rs @@ -0,0 +1,55 @@ +use bevy::prelude::Resource; +use std::path::PathBuf; + +#[derive(Resource)] +pub(crate) struct Config { + pub asset_root: PathBuf, + pub verification_capture: Option, +} + +impl Config { + pub fn from_env() -> Result { + let mut config = Self { + asset_root: PathBuf::from("assets"), + verification_capture: None, + }; + let mut args = std::env::args_os().skip(1); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--assets") => { + config.asset_root = args.next().ok_or("--assets requires a directory")?.into() + } + Some("--verify") => { + config.verification_capture = Some( + args.next() + .ok_or("--verify requires an output PNG path")? + .into(), + ) + } + _ => { + return Err(format!( + "Unknown argument {arg:?}. Usage: skate-game [--assets DIRECTORY] [--verify CAPTURE.png]" + )); + } + } + } + config.asset_root = config + .asset_root + .canonicalize() + .map_err(|e| format!("Asset root {}: {e}", config.asset_root.display()))?; + if let Some(path) = &mut config.verification_capture { + if path.extension().and_then(|x| x.to_str()) != Some("png") { + return Err("--verify output must be a PNG file".into()); + } + if !path.is_absolute() { + *path = std::env::current_dir() + .map_err(|e| e.to_string())? + .join(&path); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + } + Ok(config) + } +} diff --git a/crates/skate-game/src/graph_host/action.rs b/crates/skate-game/src/graph_host/action.rs new file mode 100644 index 00000000..ec91cc67 --- /dev/null +++ b/crates/skate-game/src/graph_host/action.rs @@ -0,0 +1,349 @@ +//! Typed ActionGraph registration and persistent operation instances. +//! The schedule and concrete gameplay sinks are owned by the caller. + +use super::action_nodes::{ActionFactory, ActionInstance, ActionInstances, ActionOperation}; +use crate::graph_runtime::{LoadedGraph, OperationRemap}; +use skate_core::graph::conditions::ConditionInputs; +use skate_core::graph::intent_handlers::{ConstMgIntent, TimeMgIntent}; +use skate_core::graph::intents::IntentMap; +use skate_core::graph::{ + activation::ConditionHost, + controller::{BehaviorId, Frame, HookId, Host}, +}; +use skate_core::input::body_flip_signal; +use skate_core::input::graph_intents::{CreateMgIntent, IntentMutation}; +use skate_data::collections::Collections; + +/// Original State output consumed by82BA1680/82BA16F0. +#[derive(Clone, Copy, Debug)] +pub struct PhysicalConditions { + pub requests_dismount: bool, + pub state: u32, +} + +/// Runtime owner for the recovered ActionGraph intent path. Conditions read +/// current published physics and intent values when the graph evaluates them. +pub struct ActionHost { + pub instances: ActionInstances, + pub action_intents: IntentMap, + pub motion_intents: IntentMap, + pub condition_inputs: ConditionInputs, + pub physical_conditions: Option, + pub gameplay_conditions: Option, + pub animation_attributes: Vec, + pub is_tricking: Option, + pub stance: Option<(bool, bool)>, + pub errors: Vec, + remap: OperationRemap, + state_parents: Vec>, + next_instance: u32, + created: Vec, + const_handlers: Vec, + time_handlers: Vec, + body_flip: Vec, + body_flip_settings: Option, + /// JuiceHook instance+8 starts with the native null intent name. Its + /// update removes that pending key and resets it,82BA39A8. + juice_pending: Vec, +} + +impl ActionHost { + pub fn from_graph(graph: &LoadedGraph, data: &Collections) -> Result { + let operations = graph + .binding + .instantiate_operations(&graph.source, &mut ActionFactory) + .map_err(|error| error.to_string())?; + let mut instances = ActionInstances::new(operations.operations); + super::condition_nodes::bind_current_states(graph, &mut instances); + let mut host = Self::new(instances, graph.runtime.operations.clone()); + host.body_flip_settings = Some(body_flip_signal::Settings { + //82BA35E0/3790 read Globals296;8289F8F0 binds hash + //3FC308E69AEA6385 (body_flip), independently checked in stock data. + gesture_window: data.float("anim_motion", "body_flip", "extend_bodyflip_gesture")?, + takeoff_window: data.float("anim_motion", "body_flip", "extend_takeoff_point")?, + }); + host.state_parents = graph + .binding + .states + .iter() + .map(|state| state.parent) + .collect(); + Ok(host) + } + + pub(crate) fn new(instances: ActionInstances, remap: OperationRemap) -> Self { + let count = remap.behaviors.len(); + let const_handlers = remap + .behaviors + .iter() + .map(|&operation| { + let instance = &instances.operations[operation]; + ConstMgIntent::new( + instance + .config + .float_bits + .map(f32::from_bits) + .unwrap_or(0.0), + instance.config.on_update, + ) + }) + .collect(); + let time_handlers = vec![TimeMgIntent::default(); count]; + Self { + instances, + action_intents: IntentMap::new(), + motion_intents: IntentMap::new(), + condition_inputs: ConditionInputs::default(), + physical_conditions: None, + gameplay_conditions: None, + animation_attributes: Vec::new(), + is_tricking: None, + state_parents: Vec::new(), + remap, + stance: None, + errors: Vec::new(), + next_instance: 1, + created: vec![false; count], + const_handlers, + time_handlers, + body_flip: vec![body_flip_signal::State::default(); count], + body_flip_settings: None, + juice_pending: vec![String::new(); count], + } + } + + pub fn apply(&mut self, name: impl Into, mutation: IntentMutation) { + let name = name.into(); + match mutation { + IntentMutation::None => {} + IntentMutation::Set(value) => { + self.motion_intents.insert(&name, value); + } + IntentMutation::Remove => { + self.motion_intents.remove(&name); + } + } + } + + fn operation(&self, id: BehaviorId) -> Option<&ActionInstance> { + self.remap + .behaviors + .get(id) + .and_then(|&operation| self.instances.get(operation)) + } + + fn unsupported(&mut self, id: BehaviorId, instance: &ActionInstance) { + if let Some(error) = instance.unsupported() { + self.errors.push(format!( + "ActionGraph behavior {id}: {:?} `{}`", + error.kind, error.name + )); + } + } +} + +impl ConditionHost for ActionHost { + fn condition_activation(&mut self, condition: usize, frame: &Frame) -> u32 { + let result = self + .remap + .conditions + .get(condition) + .and_then(|&id| self.instances.get(id)) + .ok_or_else(|| format!("ActionGraph condition {condition} is unbound")) + .and_then(|instance| match &instance.operation { + ActionOperation::ExtraCondition(condition) => condition.evaluate(self, frame), + ActionOperation::PhysicsRequestsDismount => self + .physical_conditions + .map(|p| p.requests_dismount) + .ok_or_else(|| "PhysicsRequestsDismount requires published State77".into()), + ActionOperation::IsLandingOnBoard => self + .physical_conditions + .map(|p| p.state == 503) + .ok_or_else(|| "IsLandingOnBoard requires actual State16".into()), + ActionOperation::Condition(operation) => operation + .evaluate( + &self.condition_inputs, + &self.action_intents, + frame.current, + &self.state_parents, + ) + .map_err(|error| format!("ActionGraph condition {condition}: {error}")), + _ => Err(format!( + "Unsupported ActionGraph condition {condition}: {:?}", + instance.config.name + )), + }); + match result { + Ok(value) => u32::from(value), + Err(error) => { + self.errors.push(error); + 0 + } + } + } +} + +impl Host for ActionHost { + fn context(&self) -> [u32; 6] { + [0; 6] + } + fn allocate(&mut self, behavior: BehaviorId, _frame: &Frame) -> u32 { + // Every activation owns fresh behavior state. Re-entry must not reuse + // the old pushing clock, even if the source operation ID is unchanged. + self.created[behavior] = false; + self.time_handlers[behavior] = TimeMgIntent::default(); + self.body_flip[behavior] = body_flip_signal::State::default(); + self.juice_pending[behavior].clear(); + let result = self.next_instance; + self.next_instance = self.next_instance.wrapping_add(1).max(1); + result + } + fn begin(&mut self, behavior: BehaviorId, _context: [u32; 6], _frame: &Frame) { + let Some(instance) = self.operation(behavior).cloned() else { + return; + }; + if instance.unsupported().is_some() { + self.unsupported(behavior, &instance); + return; + } + if let ActionOperation::PrintText(text) = &instance.operation { + bevy::log::info!("ActionGraph: {text}"); + return; + } + if let ActionOperation::BodyFlippingSignal = instance.operation { + match self.body_flip_settings { + Some(settings) => self.body_flip[behavior].begin(settings), + None => self + .errors + .push("BodyFlippingSignal requires stock anim_motion settings".into()), + } + return; + } + if let ActionOperation::CreateConstMgIntent = instance.operation { + if let Some(name) = instance.config.mg_intent.clone() { + let mutation = self.const_handlers[behavior].begin(); + self.apply(name, mutation); + } + } + if let ActionOperation::CreateMgIntent = instance.operation { + if let (Some(mg), Some(ag)) = ( + instance.config.mg_intent.clone(), + instance.config.ag_intent.as_deref(), + ) { + let handler = CreateMgIntent { + on_update: instance.config.on_update, + default_value: instance.config.default_value, + scale: instance.config.scale.unwrap_or(1.0), + filters: instance.config.filters, + }; + let value = self.action_intents.get(ag).copied(); + let mutation = handler.enter(&mut self.created[behavior], value, self.stance); + self.apply(mg, mutation); + } else { + self.errors.push(format!( + "ActionGraph behavior {behavior}: missing AGIntent/MGIntent" + )); + } + } + } + fn update(&mut self, behavior: BehaviorId, _context: [u32; 6], frame: &Frame) { + let Some(instance) = self.operation(behavior).cloned() else { + return; + }; + if instance.unsupported().is_some() { + self.unsupported(behavior, &instance); + return; + } + if let ActionOperation::JuiceHook = instance.operation { + self.motion_intents.remove(&self.juice_pending[behavior]); + self.juice_pending[behavior].clear(); + return; + } + if let ActionOperation::BodyFlippingSignal = instance.operation { + let Some(settings) = self.body_flip_settings else { + self.errors + .push("BodyFlippingSignal requires stock anim_motion settings".into()); + return; + }; + let Some(physical) = &self.condition_inputs.physical_state else { + self.errors + .push("BodyFlippingSignal requires published filtered state".into()); + return; + }; + // TU3 initializers82F84DA8/82F84DC0 bind these exact keys. + let names = ["FrontFlip", "BackFlip"]; + let present = names.map(|name| self.action_intents.contains_key(name)); + match self.body_flip[behavior].update(present, physical.category, frame.dt, settings) { + Some(index) => { + self.motion_intents.insert(names[index], 1.0); + } + None => { + for name in names { + self.motion_intents.remove(name); + } + } + } + return; + } + if let ActionOperation::CreateConstMgIntent = instance.operation { + if let Some(name) = instance.config.mg_intent.as_deref() { + let mutation = self.const_handlers[behavior].update(); + self.apply(name.to_owned(), mutation); + } + return; + } + let Some(ag_name) = instance.config.ag_intent.as_deref() else { + return; + }; + let Some(mg_name) = instance.config.mg_intent.as_deref() else { + return; + }; + let ag = self.action_intents.get(ag_name).copied(); + let mutation = match instance.operation { + ActionOperation::CreateMgIntent => { + let handler = CreateMgIntent { + on_update: instance.config.on_update, + default_value: instance.config.default_value, + scale: instance.config.scale.unwrap_or(1.0), + filters: instance.config.filters, + }; + handler.update(&mut self.created[behavior], ag, self.stance) + } + ActionOperation::CreateMgTimeIntent => { + self.time_handlers[behavior].update(ag, frame.dt) + } + _ => return, + }; + self.apply(mg_name.to_owned(), mutation); + } + fn end(&mut self, behavior: BehaviorId, _context: [u32; 6], _frame: &Frame) { + let Some(instance) = self.operation(behavior).cloned() else { + return; + }; + if let Some(name) = instance.config.mg_intent.as_deref() { + let mutation = match instance.operation { + ActionOperation::CreateConstMgIntent => self.const_handlers[behavior].end(), + ActionOperation::CreateMgTimeIntent => self.time_handlers[behavior].end(), + _ => IntentMutation::Remove, + }; + self.apply(name.to_owned(), mutation); + } + } + fn hook(&mut self, hook: HookId, _frame: &Frame) { + let Some(instance) = self + .remap + .hooks + .get(hook) + .and_then(|&operation| self.instances.get(operation)) + .cloned() + else { + self.errors + .push(format!("ActionGraph hook index {hook} is unbound")); + return; + }; + if instance.unsupported().is_some() { + self.unsupported(hook, &instance); + } + } + fn release(&mut self, _instance: u32) {} +} diff --git a/crates/skate-game/src/graph_host/action_conditions.rs b/crates/skate-game/src/graph_host/action_conditions.rs new file mode 100644 index 00000000..8e2be6e5 --- /dev/null +++ b/crates/skate-game/src/graph_host/action_conditions.rs @@ -0,0 +1,88 @@ +//! Original ActionGraph leaves that consume the prior animation/state output. +use super::{action::ActionHost, motion_gameplay_conditions::GameplayCondition}; +use skate_core::{ + animation::{output::attributes::AttributeName, skeleton_input::name::encode}, + graph::{conditions::NumericCondition, controller::Frame}, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum ActionCondition { + Gesture(crate::input::gesture_catalog::Group), + AnimationAttribute { + name: AttributeName, + sequence_id: i32, + numeric: NumericCondition, + }, + ParentTime { + target: Option, + numeric: NumericCondition, + }, + Physical(GameplayCondition), + Tricking, + InLocomotion, + DisableDismount, +} +impl ActionCondition { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + Ok(Some(match a.text("name").unwrap_or("") { + "HasGestureIntent" => Self::Gesture(crate::input::gesture_catalog::Group::parse( + a.text("group").unwrap_or(""), + )?), + "HasAnimAttribute" => Self::AnimationAttribute { + name: encode(a.text("attribute").unwrap_or("").as_bytes()), + sequence_id: f32::from_bits(a.float_bits("sequenceid", (-1.0f32).to_bits())) as i32, + numeric: super::condition_nodes::numeric(a), + }, + "InParentStateForTime" => Self::ParentTime { + target: None, + numeric: super::condition_nodes::numeric(a), + }, + "IsGrabbingObject" | "IsHandPlanting" => Self::Physical(GameplayCondition::parse(a)?), + "IsTricking" => Self::Tricking, + "IsInLocomotion" => Self::InLocomotion, + "DisableDismount" => Self::DisableDismount, + _ => return Ok(None), + })) + } + pub fn evaluate(&self, host: &ActionHost, frame: &Frame) -> Result { + Ok(match self { + Self::Gesture(group) => group.has_intent(&host.action_intents), + Self::AnimationAttribute { + name, + sequence_id, + numeric, + } => super::condition_nodes::animation_attribute( + &host.animation_attributes, + *name, + *sequence_id, + *numeric, + ), + Self::ParentTime { target, numeric } => { + super::condition_nodes::state_time(frame, *target, *numeric) + } + Self::Physical(condition) => condition + .evaluate_physical( + host.gameplay_conditions + .as_ref() + .ok_or("ActionGraph requires actual physical condition outputs")?, + ) + .ok_or("Condition is not a physical-only original leaf")?, + //82BA56B8 uses specific MotionGraph virtual100=8258F8D8 bit27. + Self::Tricking => host + .is_tricking + .ok_or("IsTricking requires actual MotionGraph flag27")?, + //Factory82BC3F68 installs8231ED5C; virtual48=8274CA90 is li r3,0;blr. + Self::InLocomotion => false, + Self::DisableDismount => { + //82BA5368:822F8C9C > Ground80.Y OR Skeleton598. + let p = host + .condition_inputs + .push_brake + .as_ref() + .ok_or("DisableDismount requires actual ground axis and skeleton598")?; + f32::from_bits(0x3f23d70a) > p.ground_axis_y || p.skeleton_disables_push_brake + } + }) + } +} diff --git a/crates/skate-game/src/graph_host/action_nodes.rs b/crates/skate-game/src/graph_host/action_nodes.rs new file mode 100644 index 00000000..36d8085b --- /dev/null +++ b/crates/skate-game/src/graph_host/action_nodes.rs @@ -0,0 +1,225 @@ +//! Authored ActionGraph intent behavior configuration. +use skate_data::state_graph::{ + attributes::Attributes, + binding::{Node, OperationFactory, OperationKind}, +}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Parameter { + pub name: Option, + pub mg_intent: Option, + pub ag_intent: Option, + pub text: Option, + pub float_bits: Option, + pub boolean_byte: Option, + pub default_value: Option, + pub scale: Option, + pub on_update: bool, + pub filters: [u32; 4], +} + +impl Parameter { + fn from_attributes(attributes: &Attributes<'_>) -> Self { + Self { + name: attributes.text("name").map(str::to_owned), + mg_intent: attributes.text("MGIntent").map(str::to_owned), + ag_intent: attributes.text("AGIntent").map(str::to_owned), + text: attributes.text("value").map(str::to_owned), + float_bits: attributes.get("value").map(|value| value.float_bits), + boolean_byte: attributes.get("value").map(|value| value.boolean_byte), + default_value: attributes + .get("defaultValue") + .map(|value| f32::from_bits(value.float_bits)), + scale: attributes + .get("scale") + .map(|value| f32::from_bits(value.float_bits)), + // Both TU3 constructors82BA23C0/82BA1768 default this to true. + on_update: attributes.boolean_byte("onUpdate", 1) != 0, + filters: [ + filter( + attributes + .text("filter1") + .or_else(|| attributes.text("filter")), + ), + filter(attributes.text("filter2")), + filter(attributes.text("fakieFilter")), + filter(attributes.text("mirrorFilter")), + ], + } + } +} + +fn filter(value: Option<&str>) -> u32 { + // TU382F847C0..82F84850 initialize the names consumed by82BA0A48. + match value { + Some("negate") => 1, + Some("abs") => 2, + Some("oneMinus") => 3, + Some("clamp") => 4, + Some("angleFlip") => 5, + Some("angleRot90") => 6, + Some("angleRotN90") => 7, + _ => 0, + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ActionOperation { + Condition(skate_core::graph::conditions::ActionCondition), + ExtraCondition(super::action_conditions::ActionCondition), + PhysicsRequestsDismount, + IsLandingOnBoard, + CreateMgIntent, + CreateMgTimeIntent, + CreateConstMgIntent, + BoardAdjust, + CreateTrickIntentFromGesture, + JuiceHook, + BodyFlippingSignal, + /// Diagnostic presentation is owned by this host and has no intent effect. + PrintText(String), + Unsupported { + kind: OperationKind, + name: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ActionInstance { + pub operation: ActionOperation, + pub config: Parameter, + pub parameters: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActionFactoryError(pub String); + +impl std::fmt::Display for ActionFactoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Default)] +pub struct ActionFactory; + +impl ActionFactory { + fn operation(kind: OperationKind, name: &str) -> ActionOperation { + match (kind, name) { + (OperationKind::Condition, "PhysicsRequestsDismount") => { + ActionOperation::PhysicsRequestsDismount + } + (OperationKind::Condition, "IsLandingOnBoard") => ActionOperation::IsLandingOnBoard, + (OperationKind::Behavior, "CreateMGIntentFromAGIntent") => { + ActionOperation::CreateMgIntent + } + (OperationKind::Behavior, "CreateMGTimeIntentFromAGIntent") => { + ActionOperation::CreateMgTimeIntent + } + (OperationKind::Behavior, "CreateConstMGIntent") => { + ActionOperation::CreateConstMgIntent + } + (OperationKind::Behavior, "BoardAdjust") => ActionOperation::BoardAdjust, + (OperationKind::Behavior, "CreateTrickIntentFromGesture") => { + ActionOperation::CreateTrickIntentFromGesture + } + (OperationKind::Behavior, "JuiceHook") => ActionOperation::JuiceHook, + (OperationKind::Behavior, "BodyFlippingSignal") => ActionOperation::BodyFlippingSignal, + _ => ActionOperation::Unsupported { + kind, + name: name.to_owned(), + }, + } + } +} + +impl OperationFactory for ActionFactory { + type Instance = ActionInstance; + type Error = ActionFactoryError; + + fn create( + &mut self, + kind: OperationKind, + _parent: Node, + attributes: &Attributes<'_>, + ) -> Result, Self::Error> { + let Some(name) = attributes.text("name") else { + return Err(ActionFactoryError("operation has no name attribute".into())); + }; + Ok(Some(ActionInstance { + operation: if kind == OperationKind::Condition { + if let Some(condition) = + super::action_conditions::ActionCondition::parse(attributes) + .map_err(ActionFactoryError)? + { + ActionOperation::ExtraCondition(condition) + } else { + super::condition_nodes::parse(attributes) + .map_err(ActionFactoryError)? + .map(ActionOperation::Condition) + .unwrap_or_else(|| Self::operation(kind, name)) + } + } else { + if kind == OperationKind::Behavior && name == "PrintText2D" { + ActionOperation::PrintText(attributes.text("text").unwrap_or("").to_owned()) + } else { + Self::operation(kind, name) + } + }, + config: Parameter::from_attributes(attributes), + parameters: Vec::new(), + })) + } + + fn add_parameter( + &mut self, + instance: &mut Self::Instance, + attributes: &Attributes<'_>, + ) -> Result<(), Self::Error> { + instance + .parameters + .push(Parameter::from_attributes(attributes)); + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnsupportedOperation { + pub kind: OperationKind, + pub name: String, +} + +impl ActionInstance { + pub fn unsupported(&self) -> Option { + match &self.operation { + ActionOperation::Unsupported { kind, name } => Some(UnsupportedOperation { + kind: *kind, + name: name.clone(), + }), + ActionOperation::BoardAdjust => Some(UnsupportedOperation { + kind: OperationKind::Behavior, + name: "BoardAdjust".into(), + }), + ActionOperation::CreateTrickIntentFromGesture => Some(UnsupportedOperation { + kind: OperationKind::Behavior, + name: "CreateTrickIntentFromGesture".into(), + }), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +pub struct ActionInstances { + pub operations: Vec, +} + +impl ActionInstances { + pub fn new(operations: Vec) -> Self { + Self { operations } + } + + pub fn get(&self, operation: usize) -> Option<&ActionInstance> { + self.operations.get(operation) + } +} diff --git a/crates/skate-game/src/graph_host/condition_nodes.rs b/crates/skate-game/src/graph_host/condition_nodes.rs new file mode 100644 index 00000000..6a1662a5 --- /dev/null +++ b/crates/skate-game/src/graph_host/condition_nodes.rs @@ -0,0 +1,151 @@ +//! Stock condition attributes bound to core evaluations. Values use the binary +//! float/bool encodings directly, including constructor precedence and defaults. +use skate_core::graph::conditions::{ActionCondition, Comparison, NumericCondition}; +use skate_data::state_graph::attributes::Attributes; + +///Original82BA3A78: first cached record, sequence filter, then numeric scalar +///comparison only for kinds0/2. Shared by the ActionGraph and MotionGraph. +pub(crate) fn animation_attribute( + attributes: &[skate_core::animation::output::attributes::AnimationAttribute], + name: skate_core::animation::output::attributes::AttributeName, + sequence_id: i32, + numeric: NumericCondition, +) -> bool { + attributes.iter().find(|a| a.name == name).is_some_and(|a| { + (sequence_id == -1 || sequence_id == a.sequence_id) + && (numeric.comparison == Comparison::None + || !matches!(a.kind, 0 | 2) + || a.payload.0[0].is_some_and(|word| numeric.matches(f32::from_bits(word)))) + }) +} +///Original82BA45D0/82BA4658 rejects an inactive containing state's time. +pub(crate) fn state_time( + frame: &skate_core::graph::controller::Frame, + target: Option, + numeric: NumericCondition, +) -> bool { + target + .and_then(|id| frame.state_times.get(id).copied().flatten()) + .is_some_and(|time| !(time < 0.0) && numeric.matches(time)) +} + +pub(super) fn parse(attributes: &Attributes<'_>) -> Result, String> { + use ActionCondition as C; + Ok(Some(match attributes.text("name").unwrap_or("") { + "HasAGIntent" => C::HasIntent { + name: attributes.text("intent").unwrap_or("").to_owned(), + numeric: numeric(attributes), + }, + "TimeSinceLastInput" => C::TimeSinceLastInput(numeric(attributes)), + "PhysicsSpeedCompare" => C::Speed { + along_skate_z: attributes.boolean_byte("alongSkateZ", 0) != 0, + numeric: numeric(attributes), + }, + "PhysicsSpeedAndSlopeCompare" => C::SpeedAndSlope(numeric(attributes)), + "PhysFilteredState" => C::FilteredState(match attributes.text("state").unwrap_or("") { + "invalid" => 0, + "ground" => 1, + "air" => 2, + "grind" => 3, + "wipeout" => 4, + "teleport" => 5, + "offboard" => 6, + "offboardair" => 7, + other => return Err(format!("PhysFilteredState has undefined state `{other}`")), + }), + "IsGrinding" => C::Grinding { + name: attributes.text("grindName").map(str::to_owned), + }, + "IsMirrored" => C::Mirrored, + "IsRidingFakie" => C::RidingFakie, + "DisablePushBrake" => C::DisablePushBrake, + "CurrentState" => C::CurrentState { + name: attributes.text("state").unwrap_or("").to_owned(), + target: None, + }, + _ => return Ok(None), + })) +} + +pub(crate) fn numeric(attributes: &Attributes<'_>) -> NumericCondition { + use Comparison as C; + // TU382C126F0. The long spelling takes precedence over all short forms; + // unrecognized long spellings retain the constructor's Equals default. + //82AE89B0 confirms case-sensitive comparison. + let (comparison, field) = if let Some(value) = attributes.text("comparison") { + ( + match value { + "NotEqual" => C::NotEqual, + "GreaterThan" => C::Greater, + "LessThan" => C::Less, + "GreaterEqual" => C::GreaterEqual, + "LessEqual" => C::LessEqual, + _ => C::Equal, + }, + "value", + ) + } else { + [ + (C::Equal, "equal"), + (C::NotEqual, "notEqual"), + (C::Greater, "greater"), + (C::GreaterAbsolute, "greaterThanAbs"), + (C::Less, "less"), + (C::GreaterEqual, "greaterEqual"), + (C::LessEqual, "lessEqual"), + ] + .into_iter() + .find(|(_, field)| attributes.get(field).is_some()) + .unwrap_or((C::None, "")) + }; + NumericCondition { + comparison, + threshold: f32::from_bits(attributes.float_bits(field, 0)), + absolute: attributes.boolean_byte("abs", 0) != 0, + } +} + +pub(super) fn bind_current_states( + graph: &crate::graph_runtime::LoadedGraph, + instances: &mut super::action_nodes::ActionInstances, +) { + let mut element_parents = vec![None; graph.source.elements.len()]; + for (parent, element) in graph.source.elements.iter().enumerate() { + for &child in &element.children { + element_parents[child] = Some(parent); + } + } + for &operation in &graph.runtime.operations.conditions { + if let super::action_nodes::ActionOperation::ExtraCondition( + super::action_conditions::ActionCondition::ParentTime { target, .. }, + ) = &mut instances.operations[operation].operation + { + let mut element = element_parents[graph.binding.operations[operation].element]; + while let Some(id) = element { + if let Some(state) = graph.binding.states.iter().position(|s| s.element == id) { + *target = Some(state); + break; + } + element = element_parents[id]; + } + continue; + } + let super::action_nodes::ActionOperation::Condition(ActionCondition::CurrentState { + name, + target, + }) = &mut instances.operations[operation].operation + else { + continue; + }; + // Node::GetParentState82C11B88 climbs through expressions/transitions + // to the containing state; it is not the graph's current leaf. + let mut element = element_parents[graph.binding.operations[operation].element]; + while let Some(id) = element { + if let Some(state) = graph.binding.states.iter().position(|s| s.element == id) { + *target = graph.binding.find_state(state, name, true); //82C13728 + break; + } + element = element_parents[id]; + } + } +} diff --git a/crates/skate-game/src/graph_host/crouching_settings.rs b/crates/skate-game/src/graph_host/crouching_settings.rs new file mode 100644 index 00000000..3cfa301f --- /dev/null +++ b/crates/skate-game/src/graph_host/crouching_settings.rs @@ -0,0 +1,17 @@ +//! anim_motion/crouching (global300) and auto_pump (global348), TU3 schema. +use skate_core::{animation::crouching::{Settings,AutoPumpSettings},point_graph::PointGraph}; +use skate_data::collections::Collections; +pub fn load(data:&Collections)->Result { + let c=|name|data.float("anim_motion","crouching",name); + let a=|name|data.float("anim_motion","auto_pump",name); + Ok(Settings { + maximum_height:negative8(data,"crouching","crouching_max_height")?, + absorption_upforce:plain8(data,"crouching","absorption_upforce")?, + pump_maxspeed:plain8(data,"crouching","pump_maxspeed")?, + minimum_height:c("crouching_min_height")?,maximum_ratio:c("crouch_max_ratio")?,maximum_delta_delta:c("crouch_max_delta_delta")?,maximum_delta:c("crouch_max_delta")?,input_blend:c("crouch_blend_input")?,pump_vertical_speed:c("pump_maxspeed_yaxis")?,maximum_crouch_from_deck:c("MaxCrouchFromDeckAngle")?,skateboard_damping:c("absorption_skateboard_damping")?,maximum_force:c("absorption_maxforce")?,maximum_ground_force:c("absorption_ground_maxforce")?,absorption_factor:c("absorption_factor")?, + auto_pump:AutoPumpSettings {maximum_crouch:negative4(data,"auto_pump","auto_pump_max_crouch")?,sufficient_crouch:a("auto_pump_sufficient_crouch")?,standing_threshold:a("auto_pump_standing_thresh")?,rise_speed:a("auto_pump_rise_speed")?,pump_speed:a("auto_pump_pump_speed")?,potential_threshold:a("auto_pump_potential_thresh")?,potential_blend:a("auto_pump_potential_blend")?,intent_magnitude_start:a("auto_pump_intent_mag_start")?,intent_angle_region:a("auto_pump_intent_angle_region")?,crouch_time:a("auto_pump_crouch_time")?,crouch_speed:a("auto_pump_crouch_speed")?}, + }) +} +fn negative8(data:&Collections,key:&str,name:&str)->Result,String> {let w=data.words::<20>("anim_motion",key,name)?;Ok(PointGraph{x:std::array::from_fn(|i|f32::from_bits(w[4+i])),y:std::array::from_fn(|i|f32::from_bits(w[12+i]))})} +fn negative4(data:&Collections,key:&str,name:&str)->Result,String> {let w=data.words::<12>("anim_motion",key,name)?;Ok(PointGraph{x:std::array::from_fn(|i|f32::from_bits(w[4+i])),y:std::array::from_fn(|i|f32::from_bits(w[8+i]))})} +fn plain8(data:&Collections,key:&str,name:&str)->Result,String> {let w=data.words::<16>("anim_motion",key,name)?;Ok(PointGraph{x:std::array::from_fn(|i|f32::from_bits(w[i])),y:std::array::from_fn(|i|f32::from_bits(w[8+i]))})} diff --git a/crates/skate-game/src/graph_host/mod.rs b/crates/skate-game/src/graph_host/mod.rs new file mode 100644 index 00000000..d785cd05 --- /dev/null +++ b/crates/skate-game/src/graph_host/mod.rs @@ -0,0 +1,38 @@ +//! Production graph host building blocks. Root owns schedule and registration. +pub mod action; +mod action_conditions; +pub(crate) mod action_nodes; +mod condition_nodes; +pub(crate) use condition_nodes::numeric as parse_numeric_condition; +mod crouching_settings; +pub mod motion; +mod motion_air_leg; +mod motion_animation; +mod motion_channels; +pub(crate) mod motion_character_gesture; +mod motion_conditions; +pub(crate) mod motion_gameplay_conditions; +pub(crate) mod motion_hand_services; +mod motion_hooks; +pub(crate) mod motion_intent_filter; +mod motion_kickturn; +pub(crate) mod motion_landing; +mod motion_landing_execute; +pub(crate) mod motion_native; +mod motion_nodes; +#[path = "motion_offboard/push_off.rs"] +pub(crate) mod motion_push_off; +#[path = "motion_offboard/reset.rs"] +pub(crate) mod motion_reset; +mod motion_riding; +pub(crate) mod motion_riding_conditions; +pub(crate) mod motion_shove; +mod motion_sliding; +pub(crate) mod motion_spin; +#[path = "motion_offboard/twist_lean.rs"] +pub(crate) mod motion_twist_lean; +pub(crate) mod motion_wipeout; +mod pumping_settings; +pub mod pushing; +pub mod pushing_settings; +mod turning_settings; diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs new file mode 100644 index 00000000..61c877fd --- /dev/null +++ b/crates/skate-game/src/graph_host/motion.rs @@ -0,0 +1,361 @@ +#[path = "motion_air_execute.rs"] +mod air_execution; +#[path = "motion_execute.rs"] +mod execution; +#[path = "motion_filter_execute.rs"] +mod filter_execution; +#[path = "motion_sliding_execute.rs"] +mod slide_execution; +#[path = "motion_wipeout_execute.rs"] +mod wipeout_execution; +// Persistent host called by the production stock MotionGraph controller. +pub use super::motion_animation::MotionAnimation; +use super::{ + motion_nodes::{MotionFactory, MotionOperation}, + pushing::{PushContext, PushInstance, PushOperation}, + pushing_settings::PushingSettings, +}; +use crate::graph_runtime::{LoadedGraph, OperationRemap}; +use skate_core::graph::intents::IntentMap; +use skate_core::{ + animation::{ + playback::{PlayAnimationInstance, PlaybackContext}, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute}, + skeleton_input::name::encode, + }, + graph::{ + activation::ConditionHost, + conditions::ConditionInputs, + controller::{BehaviorId, Frame, HookId, Host}, + }, + input::set_turning, + riding::push_behaviors::{PushFootFrame, PushState}, +}; +use skate_data::collections::Collections; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug)] +pub struct MotionPhysical { + pub turning: set_turning::Physical, + pub stance: (bool, bool), + pub forward_speed: f32, + pub time_since_teleport: f32, + pub is_switch: bool, + pub foot_frame: Option, +} +enum Instance { + Stateless, + IntentFilter(skate_core::animation::intent_filter::State), + Landing(super::motion_landing::State), + Wipeout(super::motion_wipeout::State), + TwistLean(skate_core::player::offboard::twist_lean::State), + Play(PlayAnimationInstance), + Push(PushInstance), + Turning(set_turning::State), + Crouching(Option), + BodyTilt(skate_core::animation::body_tilt::State), + RidingFakie(skate_core::animation::riding_fakie::State), + Pumping(skate_core::animation::pumping_channel::State), + FakieHead(super::motion_channels::FakieHead), + LandingData(super::motion_native::LandingData), + Sliding(super::motion_sliding::State), + SlideDeceleration(f32), + KickTurn(skate_core::animation::kickturn::State), + BodySpin(super::motion_spin::State), + AirLeg(skate_core::animation::air_leg_extension::State), + CharacterGesture(super::motion_character_gesture::CharacterGesture), + Shove(super::motion_shove::ShoveState), +} +impl Instance { + fn new(operation: &MotionOperation) -> Self { + match operation { + MotionOperation::IntentFilter(_) => Self::IntentFilter(Default::default()), + MotionOperation::Landing(_) => Self::Landing(Default::default()), + MotionOperation::Wipeout(_) => Self::Wipeout(Default::default()), + MotionOperation::TwistLean(_) => Self::TwistLean(Default::default()), + MotionOperation::Play(_) => Self::Play(PlayAnimationInstance::default()), + MotionOperation::Push(operation) => Self::Push(PushInstance::new(operation.clone())), + MotionOperation::SetTurning(_) => Self::Turning(set_turning::State { + elapsed: 0.0, + smoothed: 0.0, + mode: 2, + }), + MotionOperation::Crouching(_) => Self::Crouching(None), + MotionOperation::SettingBodyTilt(_) => { + Self::BodyTilt(skate_core::animation::body_tilt::State::default()) + } + MotionOperation::UpdateRidingFakie(_) => { + Self::RidingFakie(skate_core::animation::riding_fakie::State::default()) + } + MotionOperation::Pumping => { + Self::Pumping(skate_core::animation::pumping_channel::State::default()) + } + MotionOperation::FakieHeadChannel => { + Self::FakieHead(super::motion_channels::FakieHead::default()) + } + MotionOperation::Native(super::motion_native::Operation::StoreLandingData) => { + Self::LandingData(super::motion_native::LandingData::default()) + } + MotionOperation::Slide(super::motion_sliding::Operation::Update) => { + Self::Sliding(super::motion_sliding::State::default()) + } + MotionOperation::Slide(super::motion_sliding::Operation::Deceleration) => { + Self::SlideDeceleration(0.0) + } + MotionOperation::KickTurn(super::motion_kickturn::Operation::Steering(_)) => { + Self::KickTurn(skate_core::animation::kickturn::State::new()) + } + MotionOperation::BodySpin => Self::BodySpin(super::motion_spin::State::default()), + MotionOperation::AirLeg(_) => Self::AirLeg(Default::default()), + MotionOperation::CharacterGesture => { + Self::CharacterGesture(super::motion_character_gesture::CharacterGesture::new()) + } + MotionOperation::Shove(_) => Self::Shove(super::motion_shove::ShoveState::new()), + MotionOperation::ResetAnimation(_) => Self::Stateless, + _ => Self::Stateless, + } + } +} +pub struct MotionHost { + pub animation: MotionAnimation, + pub playback_context: PlaybackContext, + pub condition_inputs: ConditionInputs, + pub gameplay_conditions: Option, + pub riding_conditions: Option, + pub condition_random: super::motion_riding_conditions::MotionRandom, + pub native_physical: Option, + pub prelanding_physical: Option, + pub air_leg_physical: Option, + pub gesture_physical: Option, + pub gesture_publication: Option, + pub shove_physical: Option, + pub hand_services: super::motion_hand_services::HandServices, + pub slide_latch: set_turning::SlideLatch, + ///SpecificCA4 bit26, getter8258F900/setter8258F910; reset clears it. + pub is_power_sliding: bool, + pub score_packet: super::motion_native::ScorePacket, + pub action_intents: IntentMap, + pub time_tags: Option>, + pub physical: Option, + pub push_state: Option, + pub applying_body_tilt: bool, + pub crouching_physical: Option, + pub body_tilt_physical: Option, + pub fakie_physical: Option, + /// PhysOutAnimation158/157, used directly by IsRidingGoofy82BA5AA8. + pub physical_stance: Option<(bool, bool)>, + pub flags: super::motion_landing::Flags, + pub landing_physical: Option, + pub wipeout_physical: Option, + pub wipeout_controls: super::motion_wipeout::Controls, + wipeout_settings: super::motion_wipeout::Settings, + ///Native PhysOutGround pumping acceleration268. + pub pumping_acceleration: Option, + pub allow_pumping: bool, + pub riding: super::motion_riding::RidingState, + pub errors: Vec, + pub(super) state_parents: Vec>, + operations: Vec, + instances: Vec, + remap: OperationRemap, + pushing: PushingSettings, + turning: set_turning::Settings, + crouching: skate_core::animation::crouching::Settings, + body_tilt: skate_core::animation::body_tilt::Settings, + pumping: skate_core::animation::pumping_channel::Settings, + sliding: super::motion_sliding::Settings, + pub(super) spin: super::motion_spin::Settings, + air_leg: skate_core::animation::air_leg_extension::Settings, + kickturn: skate_core::animation::kickturn::Settings, + next_instance: u32, +} +impl MotionHost { + pub fn from_graph( + graph: &LoadedGraph, + data: &Collections, + mut metadata: skate_data::animation_metadata::AnimationMetadata, + playback_context: PlaybackContext, + ) -> Result { + let mut operations = graph + .binding + .instantiate_operations(&graph.source, &mut MotionFactory) + .map_err(|e| e.to_string())? + .operations; + super::motion_conditions::bind_states(graph, &mut operations); + let instances = graph + .runtime + .operations + .behaviors + .iter() + .map(|&id| Instance::new(&operations[id])) + .collect(); + //8258F488 and final reset825953B0 explicitly zero the complete push + //state; reset clears manualing and body-tilt flag bits too. + let zero = skate_core::riding::push_animation::PushBlendParameters { + hstr_vel_b: 0.0, + lstr_vel_b: 0.0, + vel_e: 0.0, + }; + let push_state = Some(PushState { + out_factor: 0.0, + current_push_dv: 0.0, + current: zero, + target: zero, + continue_push: false, + }); + let pushing = PushingSettings::load(data, &mut metadata)?; + Ok(Self { + animation: MotionAnimation::from_metadata(metadata), + playback_context, + condition_inputs: ConditionInputs::default(), + gameplay_conditions: None, + riding_conditions: None, + condition_random: super::motion_riding_conditions::MotionRandom::new(), + native_physical: None, + prelanding_physical: None, + air_leg_physical: None, + gesture_physical: None, + gesture_publication: None, + shove_physical: None, + hand_services: Default::default(), + slide_latch: set_turning::SlideLatch::default(), + is_power_sliding: false, + score_packet: super::motion_native::ScorePacket::default(), + action_intents: IntentMap::new(), + time_tags: None, + physical: None, + push_state, + applying_body_tilt: false, + crouching_physical: None, + body_tilt_physical: None, + fakie_physical: None, + physical_stance: None, + flags: Default::default(), + landing_physical: None, + wipeout_physical: None, + wipeout_controls: Default::default(), + wipeout_settings: super::motion_wipeout::Settings::load(data)?, + pumping_acceleration: None, + //8258F488 seeds bit24, and reset825953B0 preserves that bit. + allow_pumping: true, + riding: super::motion_riding::RidingState::new(), + errors: Vec::new(), + state_parents: graph.binding.states.iter().map(|s| s.parent).collect(), + operations, + instances, + remap: graph.runtime.operations.clone(), + pushing, + turning: super::turning_settings::load(data)?, + crouching: super::crouching_settings::load(data)?, + body_tilt: super::motion_riding::body_tilt_settings(data)?, + pumping: super::pumping_settings::load(data)?, + sliding: super::motion_sliding::Settings::load(data)?, + spin: super::motion_spin::Settings::load(data)?, + air_leg: super::motion_air_leg::load(data)?, + kickturn: super::motion_kickturn::load_settings(data)?, + next_instance: 1, + }) + } + fn run(&mut self, id: BehaviorId, frame: &Frame, phase: u8) { + if let Err(error) = self.execute(id, frame, phase) { + self.errors + .push(format!("MotionGraph behavior {id}: {error}")); + } + } +} +impl ConditionHost for MotionHost { + fn condition_activation(&mut self, condition: usize, frame: &Frame) -> u32 { + let result = self + .remap + .conditions + .get(condition) + .and_then(|&id| self.operations.get(id)) + .ok_or_else(|| "Unbound MotionGraph condition".to_owned()) + .and_then(|op| match op { + MotionOperation::Condition(condition) => condition.evaluate(self, frame), + other => Err(format!("Unsupported MotionGraph condition {other:?}")), + }); + match result { + Ok(value) => u32::from(value), + Err(error) => { + self.errors.push(error); + 0 + } + } + } +} +impl Host for MotionHost { + fn context(&self) -> [u32; 6] { + [0; 6] + } + fn allocate(&mut self, behavior: BehaviorId, _frame: &Frame) -> u32 { + if let Some(&id) = self.remap.behaviors.get(behavior) { + self.instances[behavior] = Instance::new(&self.operations[id]); + } + let handle = self.next_instance; + self.next_instance = self.next_instance.wrapping_add(1).max(1); + handle + } + fn begin(&mut self, id: BehaviorId, _context: [u32; 6], frame: &Frame) { + self.run(id, frame, 0); + } + fn update(&mut self, id: BehaviorId, _context: [u32; 6], frame: &Frame) { + self.run(id, frame, 1); + } + fn end(&mut self, id: BehaviorId, _context: [u32; 6], frame: &Frame) { + self.run(id, frame, 2); + } + fn hook(&mut self, hook: HookId, _frame: &Frame) { + if let Some(MotionOperation::Hook(operation)) = self + .remap + .hooks + .get(hook) + .and_then(|&id| self.operations.get(id)) + .cloned() + { + match operation { + super::motion_hooks::MotionHook::GrabSlide { right } => { + self.slide_latch.grab(right) + } + super::motion_hooks::MotionHook::Override(settings) => { + self.playback_context.transition_override = Some(settings) + } + super::motion_hooks::MotionHook::MongoPushToAntic { animation } => { + //82BBBBB8 passes the authored name for both channel/tree. + let settings = skate_core::animation::channel_playback::ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: f32::from_bits(0x3dcccccd), + hold_during_blend_in: false, + blend_out: f32::from_bits(0x3d8f5c29), + hold_during_blend_out: false, + use_attributes: false, + }; + if let Err(error) = self.animation.new_channel(&animation, &animation, settings) + { + self.errors.push(error); + } + } + } + return; + } + self.errors.push(format!( + "Unsupported MotionGraph hook {:?}", + self.remap + .hooks + .get(hook) + .and_then(|&id| self.operations.get(id)) + )); + } + fn release(&mut self, _instance: u32) {} +} + +#[cfg(test)] +#[path = "tests/motion_stock.rs"] +mod tests; + +#[cfg(test)] +#[path = "tests/motion_slide.rs"] +mod slide_tests; diff --git a/crates/skate-game/src/graph_host/motion_air_execute.rs b/crates/skate-game/src/graph_host/motion_air_execute.rs new file mode 100644 index 00000000..67c8b442 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_air_execute.rs @@ -0,0 +1,100 @@ +//! Original airborne MotionGraph lifecycles on the production host. +use super::*; +impl MotionHost { + pub(super) fn execute_air( + &mut self, + behavior: BehaviorId, + operation: MotionOperation, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + let instance = self + .instances + .get_mut(behavior) + .ok_or("Unallocated airborne MotionGraph behavior")?; + match (operation, instance) { + (MotionOperation::ClearTrickAttr, _) => { + //End82BB5DB0 -> IAnimatable112 (8231E050+112) ->82531278: + //erase the construction-map key. Begin/Update are82B61BB8. + if phase == 2 { + self.animation + .construction_values + .retain(|(name, _)| *name != encode(b"Trick")); + } + } + (MotionOperation::AirLeg(operation), Instance::AirLeg(state)) => { + if phase == 1 { + let physical = self + .air_leg_physical + .ok_or("Air leg extension requires completed physical output")?; + let initial = if state.needs_initial_attribute() { + self.animation.last_attribute(operation.height)? + } else { + None + }; + let prepare = if state.needs_prelanding_query(physical) { + let query = self + .prelanding_physical + .ok_or("Air leg extension requires actual prelanding output")? + .near_landing(&self.spin); + let full = if query { + self.animation.last_attribute(encode(b"FullExtension"))? + } else { + None + }; + skate_core::animation::air_leg_extension::prepare_to_land( + query, + full, + physical, + &self.air_leg, + ) + } else { + false + }; + let [height, extension] = state.update( + operation.bone, + physical, + &self.air_leg, + frame.dt, + initial, + prepare, + ); + for (name, value) in + [(operation.height, height), (encode(b"extend"), extension)] + { + self.animation.set_attribute(SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + (MotionOperation::BodySpin, Instance::BodySpin(state)) => { + if phase == 1 { + state.update( + &mut self.animation, + self.condition_inputs + .physical_state + .as_ref() + .ok_or("BodySpin requires physicalcategory")? + .category, + self.gameplay_conditions + .ok_or("BodySpin requires physicalstate")? + .state, + self.flags.doing_trick, + self.hand_services.busy_hands, + self.playback_context + .is_mirrored + .ok_or("BodySpin requires animstance")?, + self.prelanding_physical, + &self.spin, + )?; + } + } + _ => return Err("Airborne MotionGraph operation/instance mismatch".into()), + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_air_leg.rs b/crates/skate-game/src/graph_host/motion_air_leg.rs new file mode 100644 index 00000000..fa864e84 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_air_leg.rs @@ -0,0 +1,46 @@ +//! Stock ControlAirLegExtension configuration82BAF450 and settings Globals336. +use skate_core::{ + animation::{ + air_leg_extension::{Bone, Settings}, + output::attributes::AttributeName, + skeleton_input::name::encode, + }, + point_graph::PointGraph, +}; +use skate_data::{collections::Collections, state_graph::attributes::Attributes}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Operation { + pub height: AttributeName, + pub bone: Bone, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Self { + Self { + height: encode(a.text("driveDistToCom").unwrap_or("disttocog").as_bytes()), + bone: match a.text("bone").unwrap_or("Skateboard_Root") { + "LeftToeBase" => Bone::LeftToe, + "RightToeBase" => Bone::RightToe, + _ => Bone::Board, + }, + } + } +} +pub fn load(data: &Collections) -> Result { + let value = |name| data.float("anim_motion", "inair_disttocog", name); + //PointNegGraphData4 includes four metadata words before x/y. Update uses + //82481E10 on the four authored knots, not the negative-graph evaluator. + let w = data.words::<12>("anim_motion", "inair_disttocog", "lo_air_arm_extend")?; + Ok(Settings { + going_up_speed: value("goingup_disttocog_speed")?, + going_down_speed: value("goingdown_disttocog_speed")?, + minimum_height: value("min_inair_disttocog")?, + preland_velocity: value("preland_disttocom_vel")?, + preland_final_height: value("preland_final_disttocom")?, + offboard_multiplier: value("offboard_preland_disttocom_vel_multiplier")?, + arm_extension: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(w[8 + i])), + }, + }) +} diff --git a/crates/skate-game/src/graph_host/motion_animation.rs b/crates/skate-game/src/graph_host/motion_animation.rs new file mode 100644 index 00000000..9a9a084e --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_animation.rs @@ -0,0 +1,439 @@ +//! Stock tree ownership and persistent playback used directly by MotionHost. +mod selection_space_host; +mod tree_builder; +use skate_core::animation::posture::{PendingPosture, PosturePose}; +use skate_core::animation::{ + clip_clock::AdvanceResult, + output::attributes::{AnimationAttribute, AttributeName, MotionGraphAttribute}, + phase_blend::PhaseBlend, + playback::{PlaybackRequest, PlaybackService}, + playback_clip::{ClipAttribute, PlaybackClip}, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute, SettableAttributes}, + playback_transition::PlaybackTransition, + playback_tree::{Evaluation, PlaybackTree, PoseCommand}, + skeleton_input::name::encode, +}; +use skate_core::graph::intents::IntentMap; +use skate_data::animation_metadata::{AnimationMetadata, TreeMetadata}; +use tree_builder::build; + +pub struct MotionAnimation { + metadata: AnimationMetadata, + current: Option, + pub channels: super::motion_channels::MotionChannels, + pub current_name: Option, + pub motion_intents: IntentMap, + pub filtered_intents: IntentMap, + pub motion_attributes: Vec, + pub construction_values: Vec<(AttributeName, AttributeName)>, + pub posture: PendingPosture, + pub posture_bank_valid: bool, + ///Actual fullSkaterAnim15180 flags. AddBindPose consumes bits22/21 on + ///construction; the owner publishes the updated flags back to the actor. + pub skater_animation_flags: Option, + attribute_mirror: std::sync::Arc, + settable: SettableAttributes, + tree_attributes: Vec, + property: AdvanceResult, +} +impl MotionAnimation { + pub fn reset_from_stock(&mut self) { + self.current = None; + self.current_name = None; + self.channels.reset_from_stock(); + self.motion_intents.clear(); + self.filtered_intents.clear(); + self.motion_attributes.clear(); + self.tree_attributes.clear(); + self.settable.clear(); + self.skater_animation_flags = Some(0); + } + pub fn from_metadata(metadata: AnimationMetadata) -> Self { + Self { + metadata, + current: None, + channels: super::motion_channels::MotionChannels::default(), + current_name: None, + motion_intents: IntentMap::new(), + filtered_intents: IntentMap::new(), + motion_attributes: Vec::new(), + construction_values: Vec::new(), + posture: PendingPosture::default(), + posture_bank_valid: false, + skater_animation_flags: None, + attribute_mirror: std::sync::Arc::new( + skate_core::animation::playback_attributes::AttributeMirror(Vec::new()), + ), + settable: SettableAttributes::default(), + tree_attributes: Vec::new(), + property: AdvanceResult { + crossed_end: false, + overshoot: -1.0, + remaining_before_wrap: -1.0, + }, + } + } + ///Share the already loaded stock hierarchy with playback; no frame reload. + pub fn set_hierarchy( + &mut self, + names: &[String], + mirror_indices: &[i32], + ) -> Result<(), String> { + if names.len() != mirror_indices.len() { + return Err("Animation hierarchy mirror count differs from bone count".into()); + } + let mut pairs = Vec::with_capacity(names.len()); + for (index, name) in names.iter().enumerate() { + let target = match mirror_indices[index] { + -1 => name, + i if i >= 0 => names.get(i as usize).ok_or("Invalid mirrored bone index")?, + _ => return Err("Invalid negative mirrored bone index".into()), + }; + pairs.push((encode(name.as_bytes()), encode(target.as_bytes()))); + } + self.attribute_mirror = std::sync::Arc::new( + skate_core::animation::playback_attributes::AttributeMirror(pairs), + ); + Ok(()) + } + pub fn tree_attributes(&self) -> &[AnimationAttribute] { + &self.tree_attributes + } + pub fn property(&self) -> AdvanceResult { + self.property + } + pub fn current_time(&self) -> Result { + self.current + .as_ref() + .map(PlaybackTree::time) + .ok_or_else(|| "No current animation tree".into()) + } + pub fn current_length(&self) -> Result { + self.current + .as_ref() + .map(PlaybackTree::length) + .ok_or_else(|| "No current animation tree".into()) + } + pub fn in_transition(&self) -> bool { + self.current.as_ref().is_some_and(has_transition) + } + ///825310F0: retire completed transitions before advancing; reset property + /// once at the root. Graph evaluation, parametrization and pose evaluation + /// remain separate calls so the actor schedule owns their meaningful order. + pub fn advance(&mut self, dt: f32, phase: f32) { + self.channels.retire(); + self.current = self.current.take().map(prune); + self.property = AdvanceResult { + crossed_end: false, + overshoot: -1.0, + remaining_before_wrap: -1.0, + }; + if let Some(tree) = &mut self.current { + tree.advance(dt, phase, &mut self.property); + } + self.channels.advance(dt, phase); + } + pub fn apply_parameters(&mut self) -> Result<(), String> { + let attributes = self.settable.entries().to_vec(); + if let Some(mut tree) = self.current.take() { + let result = self.prepare_selection_spaces(&mut tree, &attributes); + self.current = Some(tree); + result?; + } + let mut channels = std::mem::take(&mut self.channels); + let result = + channels.prepare_trees(|tree| self.prepare_selection_spaces(tree, &attributes)); + self.channels = channels; + result?; + if let Some(tree) = &mut self.current { + tree.set_attributes(self.settable.entries())?; + } + self.channels.set_attributes(self.settable.entries())?; + self.settable.clear(); + Ok(()) + } + pub fn evaluate_pose(&mut self, parameters: Evaluation) -> Result, String> { + let mut output = Vec::new(); + let tree = self + .current + .as_mut() + .ok_or("MotionGraph has not selected an animation")?; + tree.evaluate(parameters, true, &mut output)?; + self.channels.evaluate(parameters, &mut output)?; + Ok(output) + } + pub fn refresh_tree_attributes(&mut self) -> Result<(), String> { + self.tree_attributes = self.channels.attributes( + match &self.current { + Some(tree) => tree.attributes(15)?, + None => Vec::new(), + }, + 15, + )?; + Ok(()) + } + pub fn begin_graph_update(&mut self) { + self.motion_attributes.clear(); + } + pub fn attach(&mut self, intent: &str, attribute: AttributeName, set: bool) { + if let Some(value) = self.motion_intent(intent) { + self.motion_attributes.push(MotionGraphAttribute { + name: attribute, + value, + }); + if set { + self.set_attribute(SettableAttribute { + name: attribute, + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + pub fn emit_packet(&mut self, name: AttributeName, value: f32) { + self.motion_attributes + .push(MotionGraphAttribute { name, value }); + } + pub fn build_tree(&self, name: &str) -> Result { + build( + &self.metadata, + name, + &self.construction_values, + &mut Vec::new(), + ) + } + pub fn new_channel( + &mut self, + name: &str, + animation: &str, + settings: skate_core::animation::channel_playback::ChannelSettings, + ) -> Result { + if self.channels.has(name) { + return Ok(false); + } + let mut motion = self.build_tree(animation)?; + motion.set_speed(settings.speed); + let mut tree = self.add_bind_pose(motion, None)?; + if settings.mirrored { + if let PlaybackTree::BindPose { mirror_modes, .. } = &mut tree { + mirror_modes.push(2); + } + } + self.channels.insert(name.into(), tree, settings); + Ok(true) + } + pub fn transition_channel( + &mut self, + name: &str, + animation: &str, + settings: skate_core::animation::channel_playback::ChannelSettings, + transition: skate_core::animation::playback::TransitionSettings, + resurrect: bool, + create_missing: bool, + ) -> Result { + if !self.channels.has(name) { + return if create_missing { + self.new_channel(name, animation, settings) + } else { + Ok(false) + }; + } + if !self.channels.can_transition(name, resurrect) { + return Ok(false); + } + let mut motion = self.build_tree(animation)?; + motion.set_speed(settings.speed); + let mut tree = self.add_bind_pose(motion, None)?; + if settings.mirrored { + if let PlaybackTree::BindPose { mirror_modes, .. } = &mut tree { + mirror_modes.push(2); + } + } + self.channels + .transition(name, tree, settings, transition, resurrect); + Ok(true) + } + fn add_bind_pose( + &mut self, + motion: PlaybackTree, + posture: Option, + ) -> Result { + let flags = self + .skater_animation_flags + .ok_or("Animation construction requires actual SkaterAnim flags")?; + let mut mirror_modes = Vec::new(); + if flags & 0x40000000 != 0 { + mirror_modes.push(2); + } + if flags & 0x00400000 != 0 { + mirror_modes.push(u32::from(flags & 0x00200000 == 0) + 1); + } + self.skater_animation_flags = Some(flags & !0x00600000); + if !mirror_modes.is_empty() && self.attribute_mirror.0.is_empty() { + return Err("Mirrored animation requires the stock bone hierarchy".into()); + } + Ok(PlaybackTree::BindPose { + motion: Box::new(motion), + posture, + board_backwards: flags & 0x80000000 != 0, + mirror_modes, + attribute_mirror: self.attribute_mirror.clone(), + }) + } +} +fn has_transition(tree: &PlaybackTree) -> bool { + match tree { + PlaybackTree::Transition(_) => true, + PlaybackTree::PhaseBlend(tree) => tree.children.iter().any(has_transition), + PlaybackTree::SelectionSpace(tree) => tree.current().is_some_and(has_transition), + PlaybackTree::BindPose { motion, .. } => has_transition(motion), + _ => false, + } +} +fn prune(tree: PlaybackTree) -> PlaybackTree { + match tree { + PlaybackTree::Transition(mut transition) => { + if transition.complete() { + prune(*transition.to) + } else { + transition.from = Box::new(prune(*transition.from)); + transition.to = Box::new(prune(*transition.to)); + PlaybackTree::Transition(transition) + } + } + PlaybackTree::PhaseBlend(mut tree) => { + tree.children = tree.children.into_iter().map(prune).collect(); + PlaybackTree::PhaseBlend(tree) + } + PlaybackTree::SelectionSpace(mut tree) => { + tree.candidates = tree + .candidates + .into_iter() + .map(|mut candidate| { + candidate.tree = prune(candidate.tree); + candidate + }) + .collect(); + PlaybackTree::SelectionSpace(tree) + } + PlaybackTree::BindPose { + motion, + posture, + board_backwards, + mirror_modes, + attribute_mirror, + } => PlaybackTree::BindPose { + motion: Box::new(prune(*motion)), + posture, + board_backwards, + mirror_modes, + attribute_mirror, + }, + other => other, + } +} +impl ParameterInputs for MotionAnimation { + fn motion_intent(&self, name: &str) -> Option { + self.motion_intents.get(name).copied() + } + fn filtered_intent(&self, name: &str) -> Option { + self.filtered_intents.get(name).copied() + } + fn last_attribute( + &mut self, + name: AttributeName, + ) -> Result, String> { + Ok(self + .tree_attributes + .iter() + .find(|a| a.name == name) + .copied()) + } +} +impl AttributeSink for MotionAnimation { + fn set_attribute(&mut self, attribute: SettableAttribute) { + self.settable.set_attribute(attribute); + } +} +impl super::pushing::PushAnimationSink for MotionAnimation { + fn set_attribute(&mut self, a: super::pushing::SettableAttribute) { + AttributeSink::set_attribute( + self, + SettableAttribute { + name: encode(a.name.as_bytes()), + value: a.value, + normalized: a.normalized, + sequence_id: a.sequence_id, + }, + ); + } +} +impl PlaybackService for MotionAnimation { + fn set_construction_value(&mut self, name: AttributeName, value: AttributeName) { + if let Some(entry) = self + .construction_values + .iter_mut() + .find(|entry| entry.0 == name) + { + entry.1 = value; + } else { + self.construction_values.push((name, value)); + } + } + fn set_posture_enabled(&mut self, enabled: bool) { + self.posture.set_requested(enabled); + } + fn play(&mut self, request: PlaybackRequest) -> Result { + if !(1..=4).contains(&request.transition.kind) { + return Ok(false); + } + if request.transition.kind == 1 { + self.current = None; + } + let motion = self.build_tree(&request.animation)?; + //SkaterAnim::GetAnimTree82B980A0 calls AddBindPose82B98118 before + //Play/Blend set time/speed and before transition initialization. + let (motion, posture) = if self.posture_bank_valid { + self.posture + .apply::<_, String>((motion, None), |(motion, _), pose| Ok((motion, Some(pose))))? + } else { + (motion, None) + }; + let mut tree = self.add_bind_pose(motion, posture)?; + tree.set_speed(request.speed); + let prior = self.current.take(); + //82D186F8's no-current branch calls Play with start0. + if request.start_time > 0.0 && (request.transition.kind == 1 || prior.is_some()) { + tree.set_time(request.start_time); + } + self.current = Some(match prior { + Some(mut from) => { + if request.transition.under != 0 { + if let PlaybackTree::Transition(transition) = &mut from { + let to = std::mem::replace(&mut transition.to, Box::new(tree.clone())); + transition.to = Box::new(PlaybackTree::Transition( + PlaybackTransition::new(*to, tree, request.transition), + )); + from + } else { + PlaybackTree::Transition(PlaybackTransition::new( + from, + tree, + request.transition, + )) + } + } else { + PlaybackTree::Transition(PlaybackTransition::new( + from, + tree, + request.transition, + )) + } + } + None => tree, + }); + self.current_name = Some(request.animation); + Ok(true) + } +} diff --git a/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs b/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs new file mode 100644 index 00000000..377a7e96 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs @@ -0,0 +1,50 @@ +//! Deferred native GetAnimTree callback in SelectionSpace::SetAttributes. +use super::*; + +impl MotionAnimation { + pub(super) fn prepare_selection_spaces( + &mut self, + tree: &mut PlaybackTree, + attributes: &[SettableAttribute], + ) -> Result<(), String> { + match tree { + PlaybackTree::SelectionSpace(space) => { + space.select(attributes)?; + if !space.child_constructed { + let motion = space + .current() + .ok_or("SelectionSpace has no selected child")? + .clone(); + let (motion, posture) = if self.posture_bank_valid { + self.posture + .apply::<_, String>((motion, None), |(motion, _), pose| { + Ok((motion, Some(pose))) + })? + } else { + (motion, None) + }; + *space.current_mut().unwrap() = self.add_bind_pose(motion, posture)?; + space.child_constructed = true; + } + self.prepare_selection_spaces(space.current_mut().unwrap(), attributes)?; + } + PlaybackTree::PhaseBlend(space) => { + for child in &mut space.children { + self.prepare_selection_spaces(child, attributes)?; + } + } + PlaybackTree::Transition(transition) => { + // PlaybackTransition::set_attributes follows this same to/from order. + self.prepare_selection_spaces(&mut transition.to, attributes)?; + if transition.settings.kind == 4 && !transition.complete() { + self.prepare_selection_spaces(&mut transition.from, attributes)?; + } + } + PlaybackTree::BindPose { motion, .. } => { + self.prepare_selection_spaces(motion, attributes)? + } + PlaybackTree::Clip { .. } => {} + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs b/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs new file mode 100644 index 00000000..29d18d8d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs @@ -0,0 +1,90 @@ +//! Stock authored tree construction; runtime selection remains in the tree. +use super::*; + +pub(super) fn build( + metadata: &AnimationMetadata, + name: &str, + construction: &[(AttributeName, AttributeName)], + parents: &mut Vec, +) -> Result { + let key = name.to_ascii_uppercase(); + if parents.contains(&key) { + return Err(format!("Cyclic authored animation tree {key}")); + } + parents.push(key); + let result = match metadata.tree(name)? { + TreeMetadata::Clip(source) => PlaybackTree::Clip { + name: source.name.clone(), + clip: PlaybackClip::new( + f32::from_bits(source.frames_bits), + f32::from_bits(source.fps_bits), + f32::from_bits(source.base_speed_bits), + source.flags_word, + source + .attributes + .iter() + .map(|a| ClipAttribute { + name: encode(a.name.as_bytes()), + kind: a.type_id, + begin: f32::from_bits(a.begin_bits), + end: f32::from_bits(a.end_bits), + payload: a.payload_words.clone(), + }) + .collect(), + ), + }, + TreeMetadata::PhaseBlend(source) => PlaybackTree::PhaseBlend(PhaseBlend::new( + encode(source.parameter.as_bytes()), + source + .children + .iter() + .map(|child| build(metadata, child, construction, parents)) + .collect::>()?, + )?), + TreeMetadata::SelectionSpace(source) => { + use skate_core::animation::playback_tree::selection_space::{ + Candidate, Parameter, SelectionSpace, + }; + let parameters = source + .parameters + .iter() + .map(|p| Parameter { + name: encode(p.name.as_bytes()), + mode: p.mode, + weight: f32::from_bits(p.weight_bits), + minimum: f32::from_bits(p.minimum_bits), + maximum: f32::from_bits(p.maximum_bits), + }) + .collect(); + let candidates = source + .candidates + .iter() + .map(|c| { + Ok(Candidate { + name: c.child.clone(), + values: c.value_bits.iter().map(|v| f32::from_bits(*v)).collect(), + tree: build(metadata, &c.child, construction, parents)?, + }) + }) + .collect::, String>>()?; + PlaybackTree::SelectionSpace(SelectionSpace::new(parameters, candidates)?) + } + //82D1B9B4..BAD8 resolves the first matching construction value at + //creation. A missing key/value selects child0; this is authored data. + TreeMetadata::Selector(source) => { + let value = construction + .iter() + .find(|(name, _)| *name == encode(source.parameter.as_bytes())) + .map(|(_, value)| *value); + let child = source + .values + .iter() + .position(|key| Some(encode(key.as_bytes())) == value) + .map(|index| source.children[index].as_str()) + .unwrap_or(&source.default); + build(metadata, child, construction, parents)? + } + }; + parents.pop(); + Ok(result) +} diff --git a/crates/skate-game/src/graph_host/motion_channels.rs b/crates/skate-game/src/graph_host/motion_channels.rs new file mode 100644 index 00000000..f4e172a2 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_channels.rs @@ -0,0 +1,277 @@ +//! Owned counterpart of ChannelAnimatable's ordered persistent overlays. +use skate_core::animation::{ + channel_playback::{ChannelPlayback, ChannelSettings}, + clip_clock::AdvanceResult, + output::attributes::{AnimationAttribute, AttributeName}, + playback_parameters::{SettableAttribute, intent_key}, + playback_tree::{Evaluation, PlaybackTree, PoseCommand}, +}; + +struct Channel { + name: String, + tree: PlaybackTree, + playback: ChannelPlayback, +} +#[derive(Default)] +pub struct MotionChannels { + channels: Vec, +} +impl MotionChannels { + pub fn reset_from_stock(&mut self) { + self.channels.clear(); + } + pub(super) fn prepare_trees( + &mut self, + mut prepare: impl FnMut(&mut PlaybackTree) -> Result<(), String>, + ) -> Result<(), String> { + for channel in &mut self.channels { + if channel.playback.weight > 0.0 { prepare(&mut channel.tree)?; } + } + Ok(()) + } + pub fn has(&self, name: &str) -> bool { + self.channels + .iter() + .any(|c| intent_key(&c.name) == intent_key(name)) + } + ///82D1D5F8: missing channel returns zero; clamp remaining child time. + pub fn remaining(&self, name: &str) -> f32 { + self.channels + .iter() + .find(|c| intent_key(&c.name) == intent_key(name)) + .map_or(0.0, |c| { + let value = c.tree.length() - c.tree.time(); + if value >= 0.0 { value } else { 0.0 } + }) + } + ///82B96FF0 reads Channel136, set/cleared by Start/CompleteTransition. + pub fn in_transition(&self, name: &str) -> bool { + self.channels + .iter() + .find(|c| intent_key(&c.name) == intent_key(name)) + .is_some_and(|c| matches!(c.tree, PlaybackTree::Transition(_))) + } + pub fn insert(&mut self, name: String, tree: PlaybackTree, settings: ChannelSettings) { + //82D1CF70..D018: greater priority lies above lesser; a new equal + //priority lies above older channels. Store in evaluation order. + let position = self + .channels + .partition_point(|c| c.playback.settings.priority <= settings.priority); + self.channels.insert( + position, + Channel { + name, + tree, + playback: ChannelPlayback::new(settings), + }, + ); + } + pub fn end(&mut self, name: &str) { + if let Some(c) = self.find_mut(name) { + c.playback.end(); + } + } + pub fn end_with(&mut self, name: &str, seconds: f32, from_last_frame: bool) { + if let Some(c) = self.find_mut(name) { + c.playback.end_with(seconds, from_last_frame); + } + } + pub fn influence(&mut self, name: &str, value: f32) -> bool { + match self.find_mut(name) { + Some(c) => { + c.playback.influence = value; + true + } + None => false, + } + } + pub fn can_transition(&mut self, name: &str, resurrect: bool) -> bool { + self.find_mut(name) + .is_some_and(|c| c.playback.can_transition(resurrect)) + } + pub fn transition( + &mut self, + name: &str, + tree: PlaybackTree, + settings: ChannelSettings, + transition: skate_core::animation::playback::TransitionSettings, + resurrect: bool, + ) { + if let Some(index) = self + .channels + .iter() + .position(|c| intent_key(&c.name) == intent_key(name)) + { + let mut c = self.channels.remove(index); + //CompleteTransition82D1DBC0 unconditionally keeps the prior target + //when another channel transition is requested during a transition. + let old = match c.tree { + PlaybackTree::Transition(t) => *t.to, + other => other, + }; + c.tree = PlaybackTree::Transition( + skate_core::animation::playback_transition::PlaybackTransition::new( + old, tree, transition, + ), + ); + c.playback.transition(settings, resurrect); + self.channels.insert(index, c); + } + } + fn find_mut(&mut self, name: &str) -> Option<&mut Channel> { + self.channels + .iter_mut() + .find(|c| intent_key(&c.name) == intent_key(name)) + } + pub fn retire(&mut self) { + //82B96F88: complete channel transitions, remove expired channels, + //then advance the composed tree. + for c in &mut self.channels { + if let PlaybackTree::Transition(t) = &c.tree { + if t.complete() { + c.tree = *t.to.clone(); + } + } + } + self.channels.retain(|c| !c.playback.expired()); + } + pub fn advance(&mut self, dt: f32, phase: f32) { + for c in &mut self.channels { + if c.playback.advance(dt, c.tree.length(), c.tree.time()) { + let mut property = AdvanceResult { + crossed_end: false, + overshoot: -1.0, + remaining_before_wrap: -1.0, + }; + c.tree.advance(dt, phase, &mut property); + c.playback.did_advance(property); + } + } + } + pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result<(), String> { + for c in &mut self.channels { + if c.playback.weight > 0.0 { + c.tree.set_attributes(attributes)?; + } + } + Ok(()) + } + pub fn attributes( + &self, + mut base: Vec, + mask: u32, + ) -> Result, String> { + for c in &self.channels { + if c.playback.settings.use_attributes { + base = c.playback.merge_attributes(base, &c.tree.attributes(mask)?); + } + } + Ok(base) + } + pub fn query_attribute( + &self, + name: AttributeName, + mask: u32, + output: &mut AnimationAttribute, + ) -> Result { + //82B96AB0 queries channel first regardless of use_attributes or weight. + for c in self.channels.iter().rev() { + if c.tree.query_attribute(name, mask, output)? { + return Ok(true); + } + } + Ok(false) + } + pub fn evaluate( + &mut self, + parameters: Evaluation, + output: &mut Vec, + ) -> Result<(), String> { + for c in &mut self.channels { + let enabled = c.playback.weight > 0.0; + if c.tree.evaluate(parameters, enabled, output)? && enabled { + output.push(PoseCommand::ChannelBlend { + weight: c.playback.weight, + use_channels_from_weights: false, + }); + } + } + Ok(()) + } +} + +///FakieHeadChannel82BAC778, instance82BACA30; both instance fields seed0. +#[derive(Default)] +pub struct FakieHead { + value: f32, + was_fakie: bool, +} +impl FakieHead { + pub fn update( + &mut self, + animation: &mut super::motion_animation::MotionAnimation, + manualing: bool, + power_sliding: bool, + fakie: bool, + ) -> Result<(), String> { + use skate_core::animation::{ + playback::TransitionSettings, playback_parameters::AttributeSink, + skeleton_input::name::encode, + }; + let target = if manualing { + 1.0 + } else if power_sliding { + 0.0 + } else { + 0.5 + }; + if !self.was_fakie && fakie { + let settings = ChannelSettings { + priority: 0, + keep_alive: true, + mirrored: false, + speed: 1.0, + blend_in: f32::from_bits(0x3e99999a), + hold_during_blend_in: false, + blend_out: f32::from_bits(0x3e99999a), + hold_during_blend_out: false, + use_attributes: false, + }; + //Actual vtable8231E128+16 is TransitionTo82D1D090. + //The source permits both resurrection and creation when missing. + let transition = TransitionSettings { + kind: 2, + seconds: f32::from_bits(0x3dcccccd), + under: 0, + matching: 0, + use_channels_from_weights: false, + }; + animation.transition_channel( + "fakie", + "B_FAKIE_CHANNEL", + settings, + transition, + true, + true, + )?; + self.value = target; + } else if self.was_fakie && !fakie { + animation.channels.end("fakie"); + } + self.was_fakie = fakie; + if fakie { + let delta = target - self.value; + let limit = f32::from_bits(0x3c23d70a); + let lower = if -limit - delta >= 0.0 { -limit } else { delta }; + let delta = if limit - lower >= 0.0 { lower } else { limit }; + self.value += delta; + animation.set_attribute(SettableAttribute { + name: encode(b"torso"), + value: self.value, + normalized: false, + sequence_id: -1, + }); + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_character_gesture.rs b/crates/skate-game/src/graph_host/motion_character_gesture.rs new file mode 100644 index 00000000..25c2bcd3 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_character_gesture.rs @@ -0,0 +1,334 @@ +//! Original CharacterGesture82BAA320, instance82BAADD0 and helpers +//!82BAAFB0/82BAB080/82BAB258/82BAAED8. Allocation and channel names are owned +//! here; the original entry/cycle/exit decisions and channel timing are kept. +use super::motion_animation::MotionAnimation; +use skate_core::animation::{ + channel_playback::ChannelSettings, + playback::TransitionSettings, + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_core::graph::intents::IntentMap; + +const CHANNELS: [&str; 3] = ["GestureBoth", "GestureRight", "GestureLeft"]; +const TENTH: f32 = f32::from_bits(0x3DCC_CCCD); + +pub struct CharacterGestureInputs<'a> { + /// Actor20 resolves IMotionGraph (8258F180); virtual16 returns this map. + pub motion_intents: &'a IntentMap, + /// Specific MotionGraph+C58/C5C. Nonzero counts make a hand busy. + pub busy_hands: [u32; 2], + pub filtered_state: u32, + pub ground321: bool, + pub board_held311: bool, + pub state_offboard75: bool, + /// PhysOutAnimation+72, used only while a gesture is active. + pub distance_to_cog: f32, + /// ISkaterAnim virtual56: Up, Down, Left, Right gesture catalog indices. + pub selections: Option<[u32; 4]>, + /// ISkaterAnim virtual120, evaluated by82B988B8. + pub suppress_up: bool, + /// Actor+44 virtual24. When true ForceBrake does not suppress a start. + pub force_brake_bypass: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct GesturePublication { + ///8258FB30 writes specific MotionGraph+CE4 and sets byteCE8 to1. + pub gesture: u32, + ///8258FB38 writes byteCE9 from direction==Down. + pub down: bool, +} + +pub struct CharacterGesture { + active: bool, + stage: i32, + direction: i32, + hands: i32, +} +impl Default for CharacterGesture { + fn default() -> Self { + Self::new() + } +} +impl CharacterGesture { + ///82BAAE70/7C/88/8C: no channel is owned until a real gesture starts. + pub fn new() -> Self { + Self { + active: false, + stage: -1, + direction: -1, + hands: -1, + } + } + + pub fn update( + &mut self, + animation: &mut MotionAnimation, + input: CharacterGestureInputs<'_>, + ) -> Result, String> { + let any_channel = CHANNELS.iter().any(|name| animation.channels.has(name)); + if self.active && !any_channel { + self.stage = -1; + self.clear_active(); + } else if !self.active && any_channel { + end_all(animation); + self.clear_active(); + } + let held = [ + "GestureLeftHeld", + "GestureRightHeld", + "GestureUpHeld", + "GestureDownHeld", + ] + .map(|name| has(input.motion_intents, name)); + let mut selected = select_start(input.motion_intents); + let hands = select_hands(animation, &input); + if selected == -1 && self.direction != -1 && !held[self.direction as usize] { + selected = [3, 0, 2, 1] + .into_iter() + .find(|&direction| held[direction as usize]) + .unwrap_or(-1); + } + let braking = has(input.motion_intents, "ForceBrake") && !input.force_brake_bypass; + let start = selected != -1 + && hands != -1 + && !braking + && (!input.suppress_up || selected != 2) + && (self.direction == -1 || !held[self.direction as usize]) + && (!self.active || selected != self.direction); + if start { + let name = animation_name(selected, hands, 0, input.selections)?; + self.direction = selected; + self.stage = 0; + let transition = TransitionSettings { + kind: 2, + seconds: f32::from_bits(0x3E4C_CCCD), + under: 0, + matching: 0, + use_channels_from_weights: false, + }; + animation.transition_channel( + CHANNELS[hands as usize], + &name, + settings(false, true), + transition, + true, + true, + )?; + //82BAA954..AA4C ends the other two channels, in source order. + match hands { + 0 => { + animation.channels.end(CHANNELS[2]); + animation.channels.end(CHANNELS[1]); + } + 1 => { + animation.channels.end(CHANNELS[2]); + animation.channels.end(CHANNELS[0]); + } + 2 => { + animation.channels.end(CHANNELS[0]); + animation.channels.end(CHANNELS[1]); + } + _ => unreachable!(), + } + self.hands = hands; + self.active = true; + } else if self.active { + if self.hands != hands { + end_all(animation); + //The native context-change branch preserves stage12. + self.clear_active(); + } else { + let channel = CHANNELS[self.hands as usize]; + if !animation.channels.in_transition(channel) + && animation.channels.remaining(channel) < TENTH + { + match self.stage { + 0 => self.next_stage(animation, &input, 1)?, + 1 if !held[self.direction as usize] => { + self.next_stage(animation, &input, 2)? + } + 2 => { + self.stage = -1; + self.clear_active(); + } + _ => {} + } + } + } + } + if !self.active { + return Ok(None); + } + let gesture = selection(self.direction, input.selections)?; + animation.set_attribute(SettableAttribute { + name: encode(b"GstrDistToCog"), + value: if input.state_offboard75 { + 1.0 + } else { + input.distance_to_cog + }, + normalized: false, + sequence_id: -1, + }); + Ok(Some(GesturePublication { + gesture, + down: self.direction == 3, + })) + } + + fn next_stage( + &mut self, + animation: &mut MotionAnimation, + input: &CharacterGestureInputs<'_>, + stage: i32, + ) -> Result<(), String> { + let name = animation_name(self.direction, self.hands, stage, input.selections)?; + self.stage = stage; + //82BAAED8 -> channel virtual20=82D1D030 chooses embedded Sequence, + //vtable8231E1F8. It waits for the old clip's actual end; it is not a + //zero-duration blend. The helper enables resurrection and creation. + let transition = TransitionSettings { + kind: 4, + seconds: 0.0, + under: 0, + matching: 0, + use_channels_from_weights: false, + }; + animation.transition_channel( + CHANNELS[self.hands as usize], + &name, + settings(stage == 1, false), + transition, + true, + true, + )?; + Ok(()) + } + fn clear_active(&mut self) { + self.active = false; + self.direction = -1; + self.hands = -1; + } +} + +fn settings(keep_alive: bool, hold_in: bool) -> ChannelSettings { + ChannelSettings { + priority: 0, + keep_alive, + mirrored: false, + speed: 1.0, + blend_in: TENTH, + hold_during_blend_in: hold_in, + blend_out: TENTH, + hold_during_blend_out: true, + use_attributes: false, + } +} +fn end_all(animation: &mut MotionAnimation) { + for index in [2, 1, 0] { + animation.channels.end(CHANNELS[index]); + } +} +fn has(intents: &IntentMap, name: &str) -> bool { + intents.contains_key(name) +} +fn select_start(intents: &IntentMap) -> i32 { + [ + (3, "GestureDownStart"), + (0, "GestureLeftStart"), + (2, "GestureUpStart"), + (1, "GestureRightStart"), + ] + .into_iter() + .find(|(_, name)| has(intents, name)) + .map_or(-1, |(direction, _)| direction) +} +///82BAB258: suppress occupied special channels/mount actions, then choose +///the actual available hand. Offboard states6/7 have a separate board rule. +fn select_hands(animation: &MotionAnimation, input: &CharacterGestureInputs<'_>) -> i32 { + if ["RetrieveBoard", "Shove", "WipeoutPushOff"] + .iter() + .any(|name| animation.channels.has(name)) + || has(input.motion_intents, "OB_Mount") + || has(input.motion_intents, "OB_Dismount") + { + return -1; + } + if matches!(input.filtered_state, 6 | 7) && !input.ground321 { + return if input.board_held311 { 2 } else { 0 }; + } + match (input.busy_hands[0] != 0, input.busy_hands[1] != 0) { + (false, false) => 0, + (false, true) => 1, + (true, false) => 2, + (true, true) => -1, + } +} +fn selection(direction: i32, selections: Option<[u32; 4]>) -> Result { + let index = match direction { + 0 => 2, + 1 => 3, + 2 => 0, + 3 => 1, + _ => return Ok(37), + }; + Ok(selections.ok_or("CharacterGesture needs the actual skater gesture selections")?[index]) +} +fn animation_name( + direction: i32, + hands: i32, + stage: i32, + selections: Option<[u32; 4]>, +) -> Result { + let id = selection(direction, selections)?; + let prefix = NAMES + .get(id as usize) + .ok_or_else(|| format!("Invalid stock gesture selection {id}"))?; + Ok(format!( + "{prefix}_{}_{}", + ["BOTH", "RIGHT", "LEFT"][hands as usize], + ["INTO", "CYC", "OUT"][stage as usize] + )) +} +//82F881D0..83C4 constructs this exact37-entry table at830BF5D8. +const NAMES: [&str; 37] = [ + "B_GSTR_AIRGUITAR", + "B_GSTR_AIRPLANE", + "B_GSTR_BOXING", + "B_GSTR_BRUCE_LEE", + "B_GSTR_CHECKTIME", + "B_GSTR_DEVIL", + "B_GSTR_DBLE_GUN", + "B_GSTR_DUNNO", + "B_GSTR_FINGERWAG", + "B_GSTR_FISTS", + "B_GSTR_BICEPFLEX", + "B_GSTR_FLIPTABLE", + "B_GSTR_FONZ_EH", + "B_GSTR_FREEDOM", + "B_GSTR_FU_FIST", + "B_GSTR_GETAWAY", + "B_GSTR_GETOUTTAHERE", + "B_GSTR_HANDCUFFS", + "B_GSTR_HIGHPUMP", + "B_GSTR_LOWPUMP", + "B_GSTR_PREWIND", + "B_GSTR_PEACE", + "B_GSTR_POINT", + "B_GSTR_RAISEROOF", + "B_GSTR_SHAKA", + "B_GSTR_SHRUG", + "B_GSTR_POINT_SKY", + "B_GSTR_SNAP", + "B_GSTR_SOULARCH", + "B_GSTR_SPOCK", + "B_GSTR_SURFSUP", + "B_GSTR_SWINGHIGH", + "B_GSTR_SWINGLOW", + "B_GSTR_THROWARMS", + "B_GSTR_THMB_DWN", + "B_GSTR_WINGS", + "B_GSTR_YARDSALE", +]; diff --git a/crates/skate-game/src/graph_host/motion_conditions.rs b/crates/skate-game/src/graph_host/motion_conditions.rs new file mode 100644 index 00000000..1495382d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_conditions.rs @@ -0,0 +1,351 @@ +//! Concrete MotionGraph condition leaves and stock attributes. +use super::motion::MotionHost; +use skate_core::graph::controller::Frame; +use skate_core::{ + animation::{output::attributes::AttributeName, skeleton_input::name::encode}, + graph::conditions::{ActionCondition, Comparison, NumericCondition}, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionCondition { + Gesture(crate::input::gesture_catalog::Group), + Landing(super::motion_landing::Condition), + Wipeout(super::motion_wipeout::Condition), + PushOff(super::motion_push_off::IsPushOffEnabled), + Shared(ActionCondition), + Intent { + name: String, + filtered: bool, + numeric: NumericCondition, + }, + AnimationAttribute { + name: AttributeName, + sequence_id: i32, + numeric: NumericCondition, + }, + ExpireInTime(NumericCondition), + WillExpire { + in_time: f32, + tag: Option, + wait_for_transitions: bool, + }, + InTimeWindow { + start: f32, + length: f32, + }, + InStateForTime { + name: String, + target: Option, + numeric: NumericCondition, + }, + InParentStateForTime { + target: Option, + numeric: NumericCondition, + }, + ProSkater(AttributeName), + BreakOutOfPush, + ShouldLeaveSlide { + right: bool, + }, + RidingGoofy, + RidingSwitch, + MongoPushFootTooFar, + LastState { + name: String, + target: Option, + }, + Gameplay(super::motion_gameplay_conditions::GameplayCondition), + Riding(super::motion_riding_conditions::MotionRidingCondition), + /// Native Air condition: PhysOutAnimation collision/trajectory time. + TimeToLand(NumericCondition), + /// Native off-board trajectory time (PhysOutOffBoard+32). + ObTimeToLand(NumericCondition), + /// Native off-board trajectory time used by dismount/runout branches. + ObTrajTime(NumericCondition), +} +impl MotionCondition { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + if let Some(condition) = super::motion_landing::Condition::parse(a) { + return Ok(Some(Self::Landing(condition))); + } + if let Some(condition) = super::motion_wipeout::Condition::parse(a)? { + return Ok(Some(Self::Wipeout(condition))); + } + if let Some(condition) = super::motion_push_off::IsPushOffEnabled::parse(a) { + return Ok(Some(Self::PushOff(condition))); + } + let numeric = || super::condition_nodes::numeric(a); + Ok(Some(match a.text("name").unwrap_or("") { + "HasGestureIntent" => Self::Gesture(crate::input::gesture_catalog::Group::parse( + a.text("group").unwrap_or(""), + )?), + //82BA59D8/82BA6AC0: missing intent is false even for NotEqual. + "HasIntent" | "HasFilteredMotionGraphIntent" => Self::Intent { + name: a.text("intent").unwrap_or("").into(), + filtered: a.text("name") == Some("HasFilteredMotionGraphIntent"), + numeric: numeric(), + }, + "HasAnimAttribute" => Self::AnimationAttribute { + name: encode(a.text("attribute").unwrap_or("").as_bytes()), + sequence_id: f32::from_bits(a.float_bits("sequenceid", (-1.0f32).to_bits())) as i32, + numeric: numeric(), + }, + "ExpireInTime" => Self::ExpireInTime(numeric()), //82BA65A0 + "WillExpire" => Self::WillExpire { + //82BA6620/82BA6708 + in_time: f32::from_bits(a.float_bits("InTime", 0)), + tag: a + .text("InTimeTag") + .filter(|v| !v.is_empty()) + .map(str::to_owned), + wait_for_transitions: a.boolean_byte("waitForTransitions", 1) != 0, + }, + "InTimeWindow" => Self::InTimeWindow { + //82BC4558/82BA6808 + start: f32::from_bits(a.float_bits("StartTime", 0)), + length: f32::from_bits(a.float_bits("WindowFrameLength", 0)), + }, + "InStateForTime" => Self::InStateForTime { + //82BC3788/82BA4658 + name: a.text("state").unwrap_or("").into(), + target: None, + numeric: numeric(), + }, + "InParentStateForTime" => Self::InParentStateForTime { + target: None, + numeric: numeric(), + }, //82BA45D0 + "IsProSkater" => Self::ProSkater(encode(a.text("skater").unwrap_or("").as_bytes())), //82BC56C0/82BA6A48 + "BreakOutOfPush" => Self::BreakOutOfPush, + //Factory82BC54B8 and Activation82BA7130. + "ShouldLeaveSlide" => Self::ShouldLeaveSlide { + right: a.boolean_byte("right", 1) != 0, + }, + "IsRidingGoofy" => Self::RidingGoofy, + "IsRidingSwitch" => Self::RidingSwitch, + "MongoPushFootToFar" => Self::MongoPushFootTooFar, + "LastState" => Self::LastState { + name: a.text("state").unwrap_or("").into(), + target: None, + }, + name if super::motion_gameplay_conditions::GameplayCondition::recognizes(name) => { + Self::Gameplay(super::motion_gameplay_conditions::GameplayCondition::parse( + a, + )?) + } + name if super::motion_riding_conditions::MotionRidingCondition::recognizes(name) => { + Self::Riding(super::motion_riding_conditions::MotionRidingCondition::parse(a)?) + } + "TimeToLand" => Self::TimeToLand(numeric()), + "OBTimeToLand" => Self::ObTimeToLand(numeric()), + "OBTrajTime" => Self::ObTrajTime(numeric()), + _ => return Ok(super::condition_nodes::parse(a)?.map(Self::Shared)), + })) + } + pub fn evaluate(&self, host: &MotionHost, frame: &Frame) -> Result { + use skate_core::animation::playback_parameters::ParameterInputs; + Ok(match self { + Self::ShouldLeaveSlide { right } => host.slide_latch.should_leave(*right), + Self::Gesture(group) => group.has_intent(&host.action_intents), + Self::Shared(condition) => condition + .evaluate( + &host.condition_inputs, + &host.action_intents, + frame.current, + &host.state_parents, + ) + .map_err(str::to_owned)?, + Self::Gameplay(condition) => condition.evaluate(host)?, + Self::Riding(condition) => condition.evaluate(host)?, + Self::TimeToLand(n) => n.matches( + host.gameplay_conditions + .as_ref() + .ok_or("TimeToLand requires physical condition publication")? + .time_to_land, + ), + Self::ObTimeToLand(n) | Self::ObTrajTime(n) => n.matches( + host.gameplay_conditions + .as_ref() + .ok_or("OB trajectory condition requires physical condition publication")? + .offboard_time_to_land, + ), + Self::PushOff(condition) => condition.evaluate(), + Self::Wipeout(condition) => condition.evaluate(host.wipeout_physical)?, + Self::Landing(condition) => condition.evaluate( + host.landing_physical, + &host.flags, + host.prelanding_physical + .map(|p| p.override_prelanding(&host.spin)), + )?, + Self::LastState { target, .. } => { + //82BA4798 obtains the last state, then uses the same native + //ancestor membership test as CurrentState82C13820. + let mut cursor = frame.last; + let mut matched = false; + if let Some(target) = target { + while let Some(state) = cursor { + if state == *target { + matched = true; + break; + } + cursor = host.state_parents.get(state).copied().flatten(); + } + } + matched + } + Self::Intent { + name, + filtered, + numeric, + } => { + let value = if *filtered { + host.animation.filtered_intent(name) + } else { + host.animation.motion_intent(name) + }; + value.is_some_and(|value| { + numeric.comparison == Comparison::None || numeric.matches(value) + }) + } + Self::AnimationAttribute { + name, + sequence_id, + numeric, + } => super::condition_nodes::animation_attribute( + host.animation.tree_attributes(), + *name, + *sequence_id, + *numeric, + ), + Self::ExpireInTime(numeric) => { + numeric.matches(host.animation.property().remaining_before_wrap) + } + Self::WillExpire { + in_time, + tag, + wait_for_transitions, + } => { + //8296E988 uses authored InTime if tag is empty/component absent. + let time = match (tag, &host.time_tags) { + (Some(tag), Some(tags)) => *tags + .get(tag) + .ok_or_else(|| format!("Missing MotionGraph float tag {tag}"))?, + _ => *in_time, + }; + if *wait_for_transitions && host.animation.in_transition() { + false + } else { + let p = host.animation.property(); + p.crossed_end || !(p.remaining_before_wrap > time) + } + } + Self::InTimeWindow { start, length } => { + if host.animation.in_transition() { + false + } else { + let time = host.animation.current_time()?; + !(time < *start) && !(time > *start + *length) + } + } + Self::InStateForTime { + target, numeric, .. + } => super::condition_nodes::state_time(frame, *target, *numeric), + Self::InParentStateForTime { target, numeric } => { + super::condition_nodes::state_time(frame, *target, *numeric) + } + Self::ProSkater(name) => host.playback_context.pro_skater == *name, + Self::BreakOutOfPush => { + //82BA6258: current tree time >= out factor * length, and + // the push lifecycle has not latched continue_push. + let push = host + .push_state + .as_ref() + .ok_or("BreakOutOfPush requires initialized native push state")?; + !(host.animation.current_time()? + < push.out_factor * host.animation.current_length()?) + && !push.continue_push + } + Self::RidingGoofy => { + let (first, second) = host + .physical_stance + .ok_or("IsRidingGoofy requires PhysOutAnimation stance bytes")?; + first == second + } + Self::RidingSwitch => host + .playback_context + .is_switch + .ok_or("IsRidingSwitch requires actual relative stance")?, + Self::MongoPushFootTooFar => { + //82BA6378 queries the FIRST cached tree record via82D19010. + //The event payload is a toe-bone FastString, not its weight. + if let Some(event) = host + .animation + .tree_attributes() + .iter() + .find(|a| a.name == encode(b"push_contact")) + { + let matches = |name| { + event.payload.0[..5] + .iter() + .zip(encode(name).0) + .all(|(word, key)| *word == Some(key)) + }; + let right = matches(b"RightToeBase"); + let left = matches(b"LeftToeBase"); + let mirrored = host + .playback_context + .is_mirrored + .ok_or("MongoPushFootToFar requires actual mirrored stance")?; + if (!mirrored && right) || (mirrored && left) { + host.physical + .and_then(|p| p.foot_frame) + .ok_or("MongoPushFootToFar requires actual foot frame")? + .out_distance(right)[0] + < -0.5 + } else { + false + } + } else { + false + } + } + }) + } +} + +pub fn bind_states( + graph: &crate::graph_runtime::LoadedGraph, + operations: &mut [super::motion_nodes::MotionOperation], +) { + let mut parents = vec![None; graph.source.elements.len()]; + for (parent, element) in graph.source.elements.iter().enumerate() { + for &child in &element.children { + parents[child] = Some(parent); + } + } + for &id in &graph.runtime.operations.conditions { + let super::motion_nodes::MotionOperation::Condition(condition) = &mut operations[id] else { + continue; + }; + let (name, target) = match condition { + MotionCondition::Shared(ActionCondition::CurrentState { name, target }) + | MotionCondition::InStateForTime { name, target, .. } + | MotionCondition::LastState { name, target } => (Some(name), target), + MotionCondition::InParentStateForTime { target, .. } => (None, target), + _ => continue, + }; + let mut element = parents[graph.binding.operations[id].element]; + while let Some(id) = element { + if let Some(state) = graph.binding.states.iter().position(|s| s.element == id) { + // GetParentState82C11B88 climbs expression nodes to the + // containing state; it does not select that state's parent. + *target = name.as_ref().map_or(Some(state), |name| { + graph.binding.find_state(state, name, true) + }); + break; + } + element = parents[id]; + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs new file mode 100644 index 00000000..a09406f1 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -0,0 +1,503 @@ +//! Original behavior lifecycle dispatch for the persistent MotionHost. +use super::*; +use crate::graph_host::motion_kickturn::{self, Operation as KickTurnOperation}; + +impl MotionHost { + pub(super) fn execute( + &mut self, + behavior: BehaviorId, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + let id = *self + .remap + .behaviors + .get(behavior) + .ok_or("Unbound MotionGraph behavior")?; + let operation = self.operations[id].clone(); + if let MotionOperation::Slide(operation) = operation { + return self.execute_slide(behavior, operation, frame, phase); + } + if matches!( + operation, + MotionOperation::ClearTrickAttr + | MotionOperation::AirLeg(_) + | MotionOperation::BodySpin + ) { + return self.execute_air(behavior, operation, frame, phase); + } + if let MotionOperation::TwistLean(operation) = operation { + return self.execute_twist_lean(behavior, operation, phase); + } + if let MotionOperation::Wipeout(operation) = operation { + return self.execute_wipeout(behavior, operation, phase); + } + if let MotionOperation::IntentFilter(operation) = operation { + return self.execute_intent_filter(behavior, operation, frame, phase); + } + if let MotionOperation::ResetAnimation(operation) = operation { + if phase == 0 { + match operation { + crate::graph_host::motion_reset::Operation::SkaterAnimation => { + self.action_intents.clear(); + self.animation.motion_intents.clear(); + self.animation.filtered_intents.clear(); + self.animation.reset_from_stock(); + } + crate::graph_host::motion_reset::Operation::GivenStance => { + self.animation.posture.set_requested(false); + } + } + } + return Ok(()); + } + if let MotionOperation::HandService(operation) = operation { + self.hand_services + .execute(operation, phase, &mut self.animation); + return Ok(()); + } + if let MotionOperation::Landing(operation) = &operation { + let Instance::Landing(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated landing behavior")? + else { + return Err("Landing operation/instance mismatch".into()); + }; + //StoreLandingData can update C54 earlier in this same graph traversal. + let physical = self.landing_physical.map(|mut p| { + p.last_good_landing_velocity = self.riding.last_good_landing_velocity; + p + }); + return super::super::motion_landing_execute::execute( + operation, + state, + &mut self.flags, + &mut self.animation, + &self.condition_random, + physical, + self.playback_context.is_mirrored, + frame.dt, + phase, + ); + } + let instance = self + .instances + .get_mut(behavior) + .ok_or("Unallocated MotionGraph behavior")?; + match (operation, instance) { + //Retail ctor82BA58E8 retains only diagnostic text/layout. All three + //lifecycle slots in82309664 are82B61BB8 (blr), with no state writes. + (MotionOperation::PrintText2D, _) => {} + (MotionOperation::Shove(operation), Instance::Shove(state)) => match phase { + 0 => state.begin(), + 1 => state.update( + &operation, + &mut self.animation, + self.shove_physical + .ok_or("Shove requires actual interaction output")?, + self.hand_services.busy_hands, + self.playback_context + .is_mirrored + .ok_or("Shove requires animation stance")?, + )?, + _ => state.end(&mut self.animation, self.hand_services.keep_shove_channels), + }, + (MotionOperation::CharacterGesture, Instance::CharacterGesture(state)) => { + if phase == 1 { + let physical = self.gesture_physical.ok_or( + "CharacterGesture requires original physical and hostgesture publication", + )?; + let gesture_intents = self.animation.motion_intents.clone(); + let inputs = + crate::graph_host::motion_character_gesture::CharacterGestureInputs { + motion_intents: &gesture_intents, + busy_hands: self.hand_services.busy_hands, + filtered_state: self + .condition_inputs + .physical_state + .as_ref() + .ok_or("CharacterGesture requires filteredcategory")? + .category, + ground321: physical.ground321, + board_held311: self + .playback_context + .board_available + .ok_or("CharacterGesture requires boardheldbyte311")?, + state_offboard75: physical.state_offboard75, + distance_to_cog: self + .crouching_physical + .ok_or("CharacterGesture requires actualheight")? + .animation_height_72, + selections: physical.selections, + suppress_up: physical.suppress_up, + force_brake_bypass: physical.force_brake_bypass, + }; + if let Some(output) = state.update(&mut self.animation, inputs)? { + self.gesture_publication = Some(output); + } + } + } + ( + MotionOperation::Native( + crate::graph_host::motion_native::Operation::StoreLandingData, + ), + Instance::LandingData(state), + ) => { + if phase == 1 { + state.update( + self.condition_inputs + .physical_state + .as_ref() + .ok_or("StoreLandingData requires physical category")? + .category, + self.native_physical + .ok_or("StoreLandingData requires raw physical COM velocity/up")?, + &mut self.riding, + ); + } + } + ( + MotionOperation::Native(crate::graph_host::motion_native::Operation::EndShimmy( + time, + )), + _, + ) => { + if phase == 0 { + crate::graph_host::motion_native::end_shimmy(&mut self.animation, time); + } + } + ( + MotionOperation::Native(crate::graph_host::motion_native::Operation::Score { + regular, + mirrored, + }), + _, + ) => { + if phase == 1 { + self.score_packet.set( + if self + .playback_context + .is_mirrored + .ok_or("Score augmentation requires animation stance")? + { + mirrored + } else { + regular + }, + ); + } + } + (MotionOperation::KickTurn(KickTurnOperation::ResetTimer), _) => { + //82BAE8D0 is both Begin and End; v136=8258F930 clears3224. + if phase == 0 || phase == 2 { + self.riding.time_since_kickturn = 0.0; + } + } + ( + MotionOperation::KickTurn(KickTurnOperation::Steering(parameters)), + Instance::KickTurn(state), + ) => { + motion_kickturn::execute( + state, + parameters, + &self.kickturn, + &mut self.animation, + frame.dt, + phase, + )?; + } + (MotionOperation::FakieHeadChannel, Instance::FakieHead(state)) => { + if phase == 1 { + let fakie = self + .animation + .skater_animation_flags + .ok_or("FakieHeadChannel requires actual animation flags")? + & 0x20000000 + != 0; + state.update( + &mut self.animation, + self.flags.manualing, + self.is_power_sliding, + fakie, + )?; + } + } + (MotionOperation::Pumping, Instance::Pumping(state)) => { + const NAMES: [&str; 5] = ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"]; //82F883C8 + if phase == 2 { + for name in NAMES { + self.animation.channels.end(name); + } + } else if phase == 1 && self.allow_pumping { + let occupied = std::array::from_fn(|i| self.animation.channels.has(NAMES[i])); + let update = state.update( + self.pumping_acceleration + .ok_or("Pumping requires actual ground pumping acceleration")?, + occupied, + &self.pumping, + ); + if let Some(index) = update.start { + let settings = skate_core::animation::channel_playback::ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: self.pumping.blend_in, + hold_during_blend_in: false, + blend_out: self.pumping.blend_out, + hold_during_blend_out: true, + use_attributes: false, + }; + self.animation + .new_channel(NAMES[index], "B_PUMP", settings)?; + } + if let Some((index, value)) = update.influence { + self.animation.channels.influence(NAMES[index], value); + } + } + } + (MotionOperation::DisallowPumping, _) => { + if phase == 0 { + for name in ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"] { + self.animation + .channels + .end_with(name, f32::from_bits(0x3dcccccd), false); + } + self.allow_pumping = false; + } else if phase == 2 { + self.allow_pumping = true; + } + } + (MotionOperation::SetSpeed { name, value }, _) => { + if phase == 1 { + //82BB0948 sets a tree parameter; it does not call SetSpeed + //on the animation clock. Source uses abs(PhysOutMotion164). + let value = match value { + Some(value) => value, + None => { + self.crouching_physical + .ok_or("SetSpeed requires actual ground-projected speed")? + .body_164 + } + }; + self.animation.set_attribute(SettableAttribute { + name, + value: value.abs(), + normalized: false, + sequence_id: -1, + }); + } + } + (MotionOperation::UpdateRidingFakie(settings), Instance::RidingFakie(state)) => { + if phase == 1 { + let mut physical = self + .fakie_physical + .ok_or("UpdateRidingFakie requires actual physical outputs")?; + physical.doing_trick = self.flags.doing_trick; + if let Some(fakie) = state.update(physical, frame.dt, settings) { + let flags = self + .animation + .skater_animation_flags + .as_mut() + .ok_or("UpdateRidingFakie requires actual animation flags")?; + *flags = (*flags & !0x20000000) | if fakie { 0x20000000 } else { 0 }; + } + } + } + (MotionOperation::SettingBodyTilt(name), Instance::BodyTilt(instance)) => { + if phase == 1 && self.applying_body_tilt { + if let Some(value) = instance.update( + true, + self.playback_context + .is_mirrored + .ok_or("SettingBodyTilt needs actual mirrored stance")?, + self.body_tilt_physical + .ok_or("SettingBodyTilt needs actual PhysOut tilt and spin")?, + &self.body_tilt, + ) { + self.animation.set_attribute(SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }); + } + } else if phase == 1 { + // Disabled native branch updates the enable latch without + // reading physics or emitting a parameter. + instance.disable(); + } + } + (MotionOperation::Play(operation), Instance::Play(instance)) => match phase { + 0 => instance.begin(&operation, &mut self.playback_context, &mut self.animation)?, + 1 => instance.update(&operation, &mut self.animation)?, + _ => instance.end(), + }, + ( + MotionOperation::AttachIntent { + intent, + attribute, + set, + }, + _, + ) => { + if phase == 1 { + self.animation.attach(&intent, attribute, set); + } + } + (MotionOperation::Riding(operation), _) => self.riding.execute( + operation, + phase, + frame, + &mut self.animation, + self.condition_inputs + .physical_state + .as_ref() + .map(|p| p.category), + self.crouching_physical.map(|p| p.animation_height_72), + self.flags.manualing, + )?, + (MotionOperation::ApplyingBodyTilt, _) => { + if phase == 0 { + self.applying_body_tilt = true; + } else if phase == 2 { + self.applying_body_tilt = false; + } + } + (MotionOperation::Crouching(name), Instance::Crouching(state)) => { + use skate_core::animation::crouching; + if phase == 0 { + *state = Some(crouching::State::begin( + self.crouching_physical + .ok_or("Crouching Begin requires real physical height and speed")?, + &self.crouching, + )); + } else if phase == 1 { + let intents = crouching::Intents { + auto_pump_angle: self.animation.motion_intent("AutoPumpAngle"), + auto_pump_magnitude: self.animation.motion_intent("AutoPumpMag"), + crouch: self.animation.motion_intent("Crouch"), + hard_turn_crouch: self.animation.motion_intent("HardTurnCrouch"), + manual: self.animation.motion_intent("Manual"), + motion_flag_108: self.is_power_sliding, + }; + let result = state + .as_mut() + .ok_or("Crouching updated before Begin")? + .update( + self.crouching_physical + .ok_or("Crouching requires actual physical outputs")?, + intents, + frame.dt, + &self.crouching, + ); + if result.new_auto_pump { + self.animation.emit_packet(encode(b"NewAutoPump"), 1.0); + } + if result.player_controlled_pump { + self.animation + .emit_packet(encode(b"PlayerControlledPump"), 1.0); + } + self.animation.set_attribute(SettableAttribute { + name, + value: result.height, + normalized: false, + sequence_id: -1, + }); + } + } + (MotionOperation::SetTurning(names), Instance::Turning(instance)) => { + if phase == 0 { + instance.enter(); + } else if phase == 1 { + let physical = self + .physical + .ok_or("SetTurning requires actual PhysOutAnimation and stance outputs")?; + let intents = set_turning::Intents { + fakie_turn: self.animation.motion_intent("FakieTurn"), + mode_0_slide: self.animation.motion_intent("LeftSlide"), + mode_1_slide: self.animation.motion_intent("RightSlide"), + }; + //ISkaterAnim v12=82B970D8/v28=82B97140 read live bits29/30. + //UpdateRidingFakie may have changed them earlier in this graph tick. + let flags = self + .animation + .skater_animation_flags + .ok_or("SetTurning requires actual animation stance flags")?; + set_turning::update( + instance, + &mut self.slide_latch, + physical.turning, + (flags & 0x2000_0000 != 0, flags & 0x4000_0000 != 0), + frame.dt, + &self.turning, + intents, + |attribute, value| { + let index = match attribute { + set_turning::Attribute::Angle => Some(0), + set_turning::Attribute::Direction => Some(1), + set_turning::Attribute::Quickness => Some(2), + set_turning::Attribute::Speed => Some(3), + set_turning::Attribute::Holding => Some(4), + _ => None, + }; + if let Some(index) = index { + self.animation.set_attribute(SettableAttribute { + name: names[index], + value, + normalized: false, + sequence_id: -1, + }); + } else { + self.animation.emit_packet( + encode(if attribute == set_turning::Attribute::Turn { + b"Turn" + } else { + b"Slide" + }), + value, + ); + } + }, + ); + } + } + (MotionOperation::Push(_), Instance::Push(instance)) => { + let physical = self + .physical + .ok_or("Push behavior requires actual skater physical outputs")?; + // The attribute sink borrows animation mutably during this + // callback; retain a snapshot of the same canonical intent map. + let intents = self.animation.motion_intents.clone(); + let mut context = PushContext { + settings: &self.pushing, + shared: self + .push_state + .as_mut() + .ok_or("Native push state initialization has not been published")?, + motion_intents: &intents, + forward_speed: physical.forward_speed, + delta_seconds: frame.dt, + time_since_teleport: self.riding.time_since_teleport, + is_switch: physical.is_switch, + foot_frame: physical.foot_frame, + }; + match phase { + 0 => instance.begin(&mut context, &mut self.animation), + 1 => instance.update(&mut context, &mut self.animation)?, + _ => instance.end(&mut context), + } + } + (MotionOperation::Unsupported { kind, name }, _) => { + return Err(format!("Unsupported MotionGraph {kind:?} {name}")); + } + (operation, _) => { + return Err(format!( + "Invalid MotionGraph behavior instance {operation:?}" + )); + } + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_filter_execute.rs b/crates/skate-game/src/graph_host/motion_filter_execute.rs new file mode 100644 index 00000000..0fc7813f --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_filter_execute.rs @@ -0,0 +1,39 @@ +//! Live stock intent filtering shares the existing MotionAnimation maps. +use super::*; +impl MotionHost { + pub(super) fn execute_intent_filter( + &mut self, + behavior: BehaviorId, + operation: super::super::motion_intent_filter::Operation, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + let Instance::IntentFilter(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated intent-filter behavior")? + else { + return Err("Intent-filter operation/instance mismatch".into()); + }; + match phase { + 0 => operation.begin(state, &mut self.animation.filtered_intents), + 1 => { + //82BB17D0 reads live ISkaterAnim v12/v28; earlier graph + //operations can change these flags during the same traversal. + let flags = self + .animation + .skater_animation_flags + .ok_or("FilterMotionGraphIntent requires live animation stance flags")?; + operation.update( + state, + &self.animation.motion_intents, + &mut self.animation.filtered_intents, + frame.dt, + (flags & 0x2000_0000 != 0, flags & 0x4000_0000 != 0), + ); + } + _ => operation.end(&mut self.animation.filtered_intents), + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs new file mode 100644 index 00000000..f61b2ef9 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs @@ -0,0 +1,175 @@ +//! Original TU3 physical-state condition leaves used by the stock MotionGraph. +use super::motion::MotionHost; +use skate_data::state_graph::attributes::Attributes; + +/// The preceding completed physical publication. These are native output +/// fields, separate from controller intents and presentation transforms. +#[derive(Clone, Copy, Debug)] +pub struct GameplayConditions { + pub state: u32, + pub wants_runout: bool, + pub physics_wiping: bool, + pub body_flipping: bool, + pub wants_wipeout: bool, + pub bumped: bool, + pub grabbing_object: bool, + pub retrieving_board: bool, + pub dropping_board: bool, + pub in_biped_air: bool, + pub hippy_hurdling: bool, + pub handplant_flags: u32, + pub time_to_skitch: f32, + pub skitch_transition_time: f32, + /// PhysOutAnimation+144 collision time used by TimeToLand. + pub time_to_land: f32, + /// PhysOutOffBoard+32, shared by OBTimeToLand and OBTrajTime. + pub offboard_time_to_land: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GameplayCondition { + RetrievingBoard, + DroppingBoard, + InBipedAir, + HippyHurdling, + WantsRunout, + PhysicsWiping, + BodyFlipping, + WantsWipeout, + Bumped, + CanEnterSlide { right: bool }, + GrabbingObject, + Dark, + HandPlanting { state: u8, direction: u8 }, + EnteringSkitch, + Skitching, +} +impl GameplayCondition { + pub fn recognizes(name: &str) -> bool { + matches!( + name, + "IsRetrievingSkateboard" + | "IsDroppingSkateboard" + | "IsInBipedAir" + | "IsHippyHurdling" + | "PhysicsWantsRunout" + | "IsPhysicsWiping" + | "IsBodyFlipping" + | "PhysicsWantsWipeOut" + | "IsBumped" + | "CanEnterSlide" + | "IsGrabbingObject" + | "IsDark" + | "IsHandPlanting" + | "IsEnteringSkitch" + | "IsSkitching" + ) + } + pub fn parse(a: &Attributes<'_>) -> Result { + Ok(match a.text("name").unwrap_or("") { + "IsRetrievingSkateboard" => Self::RetrievingBoard, + "IsDroppingSkateboard" => Self::DroppingBoard, + "IsInBipedAir" => Self::InBipedAir, + "IsHippyHurdling" => Self::HippyHurdling, + "PhysicsWantsRunout" => Self::WantsRunout, + "IsPhysicsWiping" => Self::PhysicsWiping, + "IsBodyFlipping" => Self::BodyFlipping, + "PhysicsWantsWipeOut" => Self::WantsWipeout, + "IsBumped" => Self::Bumped, + "CanEnterSlide" => Self::CanEnterSlide { + right: a.boolean_byte("right", 1) != 0, //82BC53F0 + }, + "IsGrabbingObject" => Self::GrabbingObject, + "IsDark" => Self::Dark, + "IsEnteringSkitch" => Self::EnteringSkitch, + "IsSkitching" => Self::Skitching, + "IsHandPlanting" => Self::HandPlanting { + //82BA54A0 compares these authored strings in this order. + state: match a.text("state").unwrap_or("") { + "air" => 0, + "ground" => 1, + value => return Err(format!("IsHandPlanting has unauthored state {value:?}")), + }, + direction: match a.text("dir").unwrap_or("") { + "FS" => 1, + "BS" => 0, + _ => 2, + }, + }, + name => return Err(format!("Unknown gameplay condition {name}")), + }) + } + /// Both stock graphs register these same native leaves. Return None for + /// conditions that also require the MotionGraph/channel owner. + pub fn evaluate_physical(&self, p: &GameplayConditions) -> Option { + Some(match self { + Self::RetrievingBoard => p.retrieving_board, //82BA5EF0:Offboard323 + Self::InBipedAir => p.in_biped_air, //82BA8030:Offboard328 + Self::HippyHurdling => p.hippy_hurdling, //82BA5DA0:Offboard317 + Self::WantsRunout => p.wants_runout, //82BA44B8:State78 + Self::PhysicsWiping => p.physics_wiping, //82BA4390:State59 + Self::BodyFlipping => p.body_flipping, //82BA71E0:Air441 + Self::WantsWipeout => p.wants_wipeout, //82BA4400:State63 || State65 + Self::Bumped => p.bumped, //82BA7310: published acceleration and anim_motion/bumps + Self::GrabbingObject => p.grabbing_object, //82BA5700:Offboard304 + Self::Skitching => p.state == 104, //82BBBC88:State16 + Self::EnteringSkitch => { + //82BBBDE0:Ground276,Globals400/layout96 + !(p.time_to_skitch < 0.0) && p.time_to_skitch <= p.skitch_transition_time + } + Self::HandPlanting { state, direction } => { + //82BA55A8:Air324,State16 + let active = if *state == 0 { + p.state == 600 + } else { + p.handplant_flags & 0x8000_0000 != 0 + }; + active + && match direction { + 2 => true, + 1 => p.handplant_flags & 0x2000_0000 != 0, + 0 => p.handplant_flags & 0x2000_0000 == 0, + _ => false, + } + } + Self::DroppingBoard | Self::Dark | Self::CanEnterSlide { .. } => return None, + }) + } + + pub fn evaluate(&self, host: &MotionHost) -> Result { + let p = host + .gameplay_conditions + .as_ref() + .ok_or("MotionGraph requires the actual physical condition publication")?; + if let Some(result) = self.evaluate_physical(p) { + return Ok(result); + } + Ok(match self { + //82BA5F60: Offboard322 or the actual RetrieveBoard channel. + Self::DroppingBoard => p.dropping_board || host.animation.channels.has("RetrieveBoard"), + //82BA79A0 calls the specific MotionGraph getter8258FB68. + Self::Dark => host.riding.dark, + Self::CanEnterSlide { right } => { + //82BA6FE0 rejects RevertGround102, reverses the side for fakie, + //then reads its start bit in the retained C84 slide packet. + if p.state == 102 { + false + } else { + //ISkaterAnim virtual12=82B970D8 reads bit29=fakie. + let fakie = host + .animation + .skater_animation_flags + .ok_or("CanEnterSlide requires actual SkaterAnim stance flags")? + & 0x2000_0000 + != 0; + if *right ^ fakie { + host.slide_latch.start(true) + } else { + host.slide_latch.start(false) + } + } + } + _ => unreachable!("Physical condition handled above"), + }) + } +} diff --git a/crates/skate-game/src/graph_host/motion_ground_slope.rs b/crates/skate-game/src/graph_host/motion_ground_slope.rs new file mode 100644 index 00000000..6652d5a2 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_ground_slope.rs @@ -0,0 +1,34 @@ +//! GroundSlopeType: TU3 ctor82BA7BD8 and condition82BA7E80. +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct GroundSlopeType(u32); + +impl GroundSlopeType { + pub fn parse(attributes: &Attributes<'_>) -> Result { + let name = attributes + .text("slopetype") + .ok_or("GroundSlopeType requires slopetype")?; + let value = match name { + "Flat" => 0, + "StairUpShallow" => 1, + "StairUpSteep" => 2, + "StairDnShallow" => 3, + "StairDnSteep" => 4, + "RampUpShallow" => 5, + "RampUpSteep" => 6, + "RampDnShallow" => 7, + "RampDnSteep" => 8, + // The native constructor has no fallback assignment. Reject malformed + // host data rather than reproducing an uninitialized member read. + _ => return Err(format!("Unknown GroundSlopeType slopetype {name}")), + }; + Ok(Self(value)) + } + + /// Exactly PhysOut bundle+72 (OffBoard), integer field+88. The physical + /// producer owns the classification; this graph leaf does not infer slope. + pub fn matches(self, physical_slope_type_88: u32) -> bool { + physical_slope_type_88 == self.0 + } +} diff --git a/crates/skate-game/src/graph_host/motion_hand_services.rs b/crates/skate-game/src/graph_host/motion_hand_services.rs new file mode 100644 index 00000000..ab980d1d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_hand_services.rs @@ -0,0 +1,90 @@ +//! TU3 HandBusy and MaintainShove lifecycle, independently recovered from IDA. +//! HandBusy: factory82BC8B08, Begin82BAB4D0, End82BAB588. +//! MaintainShove: factory82BCB1C8, Update82BBD690, End82BBD6E8. +use super::motion_animation::MotionAnimation; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Operation { + HandBusy(Hand), + MaintainShove, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Hand { + Backside, + Frontside, +} + +impl Operation { + pub fn parse(attributes: &Attributes<'_>) -> Option { + match attributes.text("name")? { + "HandBusy" => Some(Self::HandBusy(Hand::from_name( + attributes.text("hand").unwrap_or("bs"), + ))), + "MaintainShove" => Some(Self::MaintainShove), + _ => None, + } + } +} + +impl Hand { + fn from_name(name: &str) -> Self { + // Factory compares against bs with strcmp82AE89B0. Every unequal + // spelling selects index1; there is no validation or stance swap. + if name == "bs" { Self::Backside } else { Self::Frontside } + } + + fn index(self) -> usize { + match self { + Self::Backside => 0, + Self::Frontside => 1, + } + } +} + +/// One persistent owner shared by all graph instances and hand consumers. +#[derive(Clone, Debug, Default)] +pub struct HandServices { + pub busy_hands: [u32; 2], + pub keep_shove_channels: bool, +} + +impl HandServices { + pub fn execute(&mut self, operation: Operation, phase: u8, animation: &mut MotionAnimation) { + if self.apply(operation, phase) && animation.channels.has("SkitchAntic") { + // End82BBD6E8 first clears the retention flag, then requests + // premature channel end (virtual44), preserving its fade timing. + animation.channels.end("SkitchAntic"); + } + } + + fn apply(&mut self, operation: Operation, phase: u8) -> bool { + match (operation, phase) { + (Operation::HandBusy(hand), 0) => { + let count = &mut self.busy_hands[hand.index()]; + *count = count.wrapping_add(1); + } + (Operation::HandBusy(_), 1) => { + // HandBusy Update vtable8231F97C+52 is blr82B61BB8. + } + (Operation::HandBusy(hand), _) => { + let count = &mut self.busy_hands[hand.index()]; + *count = count.wrapping_sub(1); + } + (Operation::MaintainShove, 0) => { + // Begin vtable82321000+48 is blr82B61BB8. + } + (Operation::MaintainShove, 1) => self.keep_shove_channels = true, + (Operation::MaintainShove, _) => { + self.keep_shove_channels = false; + return true; + } + } + false + } +} + +#[cfg(test)] +#[path = "motion_hand_services/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_hand_services/tests.rs b/crates/skate-game/src/graph_host/motion_hand_services/tests.rs new file mode 100644 index 00000000..3776d336 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_hand_services/tests.rs @@ -0,0 +1,89 @@ +use super::{Hand, HandServices, Operation}; + +#[test] +fn nested_hand_lifetimes_preserve_other_owners() { + let mut state = HandServices::default(); + let bs = Operation::HandBusy(Hand::Backside); + let fs = Operation::HandBusy(Hand::Frontside); + for operation in [bs, fs, bs] { state.apply(operation, 0); } + state.apply(bs, 1); + assert_eq!(state.busy_hands, [2, 1]); + state.apply(bs, 2); + assert_eq!(state.busy_hands, [1, 1]); + state.apply(fs, 2); + assert_eq!(state.busy_hands, [1, 0]); + state.apply(bs, 2); + assert_eq!(state.busy_hands, [0, 0]); +} + +#[test] +fn retail_counter_stores_wrap_without_saturating() { + let mut state = HandServices::default(); + let bs = Operation::HandBusy(Hand::Backside); + state.apply(bs, 2); + assert_eq!(state.busy_hands, [u32::MAX, 0]); + state.apply(bs, 0); + assert_eq!(state.busy_hands, [0, 0]); +} + +#[test] +fn maintain_shove_is_an_update_latch_not_a_reference_count() { + let mut state = HandServices::default(); + let operation = Operation::MaintainShove; + assert!(!state.keep_shove_channels); + assert!(!state.apply(operation, 0)); + assert!(!state.keep_shove_channels); + for _ in 0..2 { assert!(!state.apply(operation, 1)); } + assert!(state.keep_shove_channels); + assert!(state.apply(operation, 2)); + assert!(!state.keep_shove_channels); + assert!(!state.apply(operation, 1)); + assert!(state.keep_shove_channels); +} + +#[test] +fn hand_selection_uses_exact_stock_comparison() { + assert_eq!(Hand::from_name("bs"), Hand::Backside); + for name in ["fs", "BS", "", "both"] { + assert_eq!(Hand::from_name(name), Hand::Frontside); + } +} + +#[test] +fn maintain_end_fades_only_antic_through_existing_animation_runtime() { + use crate::graph_host::motion_animation::MotionAnimation; + use skate_core::animation::{ + channel_playback::ChannelSettings, playback_clip::PlaybackClip, + playback_tree::PlaybackTree, + }; + let metadata = skate_data::animation_metadata::AnimationMetadata::parse( + r#"{"version":1,"source_bank":"test-only","source_sha256":"0000000000000000000000000000000000000000000000000000000000000000","source_bytes":48,"clips":[],"unsupported_trees":[]}"#, + ).unwrap(); + let mut animation = MotionAnimation::from_metadata(metadata); + let settings = ChannelSettings { + priority: 0, keep_alive: true, mirrored: false, speed: 1.0, + blend_in: 0.0, hold_during_blend_in: false, blend_out: 0.5, + hold_during_blend_out: false, use_attributes: false, + }; + for name in ["SkitchAntic", "Shove"] { + animation.channels.insert(name.into(), PlaybackTree::Clip { + name: name.into(), + clip: PlaybackClip::new(301.0, 30.0, 1.0, 0, Vec::new()), + }, settings); + } + let mut state = HandServices::default(); + state.execute(Operation::MaintainShove, 1, &mut animation); + state.execute(Operation::MaintainShove, 2, &mut animation); + assert!(!state.keep_shove_channels); + assert!(animation.channels.has("SkitchAntic")); + animation.channels.advance(0.25, 0.0); + animation.channels.retire(); + assert!(animation.channels.has("SkitchAntic")); + animation.channels.advance(0.25, 0.0); + animation.channels.retire(); + assert!(!animation.channels.has("SkitchAntic")); + assert!(animation.channels.has("Shove")); + // Repeating End with no antic must leave unrelated live channels intact. + state.execute(Operation::MaintainShove, 2, &mut animation); + assert!(animation.channels.has("Shove")); +} diff --git a/crates/skate-game/src/graph_host/motion_hooks.rs b/crates/skate-game/src/graph_host/motion_hooks.rs new file mode 100644 index 00000000..95942602 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_hooks.rs @@ -0,0 +1,32 @@ +//! Native MotionGraph transition hooks, dispatched by the stock controller. +use skate_core::animation::playback::TransitionSettings; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionHook { + ///82BBBB68 copies the authored transition into the next-play override. + Override(TransitionSettings), + MongoPushToAntic { + animation: String, + }, + ///82BBB848, factory82BCA6A8 default right=true. + GrabSlide { + right: bool, + }, +} +impl MotionHook { + pub fn parse(a: &Attributes<'_>) -> Option { + match a.text("name")? { + "GrabSlide" => Some(Self::GrabSlide { + right: a.boolean_byte("right", 1) != 0, + }), + "OverideNextAnimTransitionHook" => { + Some(Self::Override(super::motion_nodes::transition(a))) + } + "MongoPushToAntic" => Some(Self::MongoPushToAntic { + animation: a.text("anim").unwrap_or("").into(), + }), + _ => None, + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_intent_filter.rs b/crates/skate-game/src/graph_host/motion_intent_filter.rs new file mode 100644 index 00000000..71693528 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_intent_filter.rs @@ -0,0 +1,72 @@ +//! Stock FilterMotionGraphIntent constructor and map lifecycle adapter. +use skate_core::{animation::intent_filter::{Settings, State}, graph::intents::IntentMap}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub struct Operation { + pub intent: String, + pub filtered_intent: String, + pub settings: Settings, +} + +impl Operation { + /// TU3 82BB12C8. Presence flags remain distinct from numeric defaults. + pub fn parse(a: &Attributes<'_>) -> Self { + let number = |name, default: f32| f32::from_bits(a.float_bits(name, default.to_bits())); + let optional = |name| a.get(name).map(|value| f32::from_bits(value.float_bits)); + let blend = number("blend", 1.0); + Self { + intent: a.text("intent").unwrap_or("").into(), + filtered_intent: a.text("filteredIntent").unwrap_or("").into(), + settings: Settings { + starting_value: number("startingValue", 0.0), + default_value: number("defaultValue", 0.0), + scale: number("scale", 1.0), + filters: [ + filter(a.text("filter1").or_else(|| a.text("filter"))), + filter(a.text("filter2")), + filter(a.text("fakieFilter")), + filter(a.text("mirrorFilter")), + ], + ramp_time: optional("rampTime"), + blend_rising: number("blendRising", blend), + blend_falling: number("blendFalling", blend), + blend_out: optional("blendOut"), + clamp_velocity: optional("clampVel"), + clamp_acceleration: optional("clampAcc"), + }, + } + } + + pub fn begin(&self, state: &mut State, output: &mut IntentMap) { + output.insert(&self.filtered_intent, state.begin(&self.settings)); + } + + pub fn update( + &self, + state: &mut State, + input: &IntentMap, + output: &mut IntentMap, + dt: f32, + stance: (bool, bool), + ) { + let value = state.update(&self.settings, input.get(&self.intent).copied(), dt, stance); + output.insert(&self.filtered_intent, value); + } + + /// End removes the key even when another active behavior wrote that alias. + /// Native map removal 82BC1068 has no per-behavior ownership counter. + pub fn end(&self, output: &mut IntentMap) { + output.remove(&self.filtered_intent); + } +} + +fn filter(value: Option<&str>) -> u32 { + // 82BA0A48 and original initializers 82F847C0..4850. + use skate_core::animation::playback_parameters::intent_key; + let key = intent_key(value.unwrap_or("none")); + ["negate", "abs", "oneMinus", "clamp", "angleFlip", "angleRot90", "angleRotN90"] + .iter() + .position(|name| intent_key(name) == key) + .map_or(0, |index| index as u32 + 1) +} diff --git a/crates/skate-game/src/graph_host/motion_kickturn.rs b/crates/skate-game/src/graph_host/motion_kickturn.rs new file mode 100644 index 00000000..16c503df --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_kickturn.rs @@ -0,0 +1,80 @@ +//! Original TU3 low-speed KickTurn behaviors, connected to MotionHost. +use super::motion_animation::MotionAnimation; +use skate_core::{ + animation::{kickturn, playback_parameters::ParameterInputs, skeleton_input::name::encode}, + point_graph::PointGraph, +}; +use skate_data::{collections::Collections, state_graph::attributes::Attributes}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Operation { + ResetTimer, + Steering(kickturn::Parameters), +} + +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + match a.text("name")? { + "ResetTimeSinceKickturn" => Some(Self::ResetTimer), + //82BC7218: both constructor defaults are literal8209975C=0.5. + "KickTurnSteering" => Some(Self::Steering(kickturn::Parameters { + max_height: f32::from_bits(a.float_bits("maxHeight", 0x3f000000)), + spin_scale: f32::from_bits(a.float_bits("spinScale", 0x3f000000)), + })), + _ => None, + } + } +} + +pub fn load_settings(data: &Collections) -> Result { + //Original8289FB90..FBB0 binds Globals324 to A168897919752875, + //the independently verified stock anim_motion/kickturn collection key. + let curve = |name| -> Result, String> { + let words = data.words::<20>("anim_motion", "kickturn", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[12 + i])), + }) + }; + Ok(kickturn::Settings { + spin: curve("kickturn_spin")?, //layout240, source X256/Y288 + height: curve("kickturn_balance")?, //layout320, X336/Y368 + }) +} + +pub fn execute( + state: &mut kickturn::State, + parameters: kickturn::Parameters, + settings: &kickturn::Settings, + animation: &mut MotionAnimation, + dt: f32, + phase: u8, +) -> Result<(), String> { + if phase == 0 { + state.begin(settings); + return Ok(()); + } + //Native End82B61BB8 is the no-op lifecycle method. + if phase != 1 { + return Ok(()); + } + if state.needs_animation_length() { + //82BAE51C..560: IAnimatable84 applies pending parameters through + //82D18F98, then v48 supplies the tree for GetLength(v20). + animation.apply_parameters()?; + state.capture_animation_length(animation.current_length()?); + } + //Original830BE1E0 initializer82F84BC8 identifies KickTurn. HasIntent's + //destination is initialized to0; an absent/zero intent retains the sign. + let output = state.update( + dt, + animation.motion_intent("KickTurn").unwrap_or(0.0), + parameters, + settings, + ); + //MGspecific v16=8258F6E0 appends attributes, not animation parameters. + //82F85A08/82F858D0 identify the two original names and this order. + animation.emit_packet(encode(b"Balance"), output.balance); + animation.emit_packet(encode(b"Spin"), output.spin); + Ok(()) +} diff --git a/crates/skate-game/src/graph_host/motion_landing.rs b/crates/skate-game/src/graph_host/motion_landing.rs new file mode 100644 index 00000000..4b4d4152 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_landing.rs @@ -0,0 +1,113 @@ +//! Original TU3 landing graph configuration and persistent instance state. +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum Operation { + IsAnticipating, + IsLanding, + IsManualing, + IsDoingTrick, + SetLandingData, + DisableTricks { length: f32 }, + ChooseRandomLanding { count: i32 }, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "IsAnticipating" => Self::IsAnticipating, + "IsLanding" => Self::IsLanding, + "IsManualing" => Self::IsManualing, + "IsDoingTrick" => Self::IsDoingTrick, + //82BC7618 fixes this operation's attribute to disttocog. + "SetLandingData" => Self::SetLandingData, + "DisableTricks" => Self::DisableTricks { + length: a + .get("length") + .map_or(0.5, |v| f32::from_bits(v.float_bits)), + }, + "ChooseRandomLanding" => Self::ChooseRandomLanding { + //82BC7788 truncates the configured float toward zero. + count: a + .get("numlandings") + .map_or(1.0, |v| f32::from_bits(v.float_bits)) as i32, + }, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Condition { + HasLandingType(u32), + HasTiltToLargeForPreland, + AllowedToTrick, +} +impl Condition { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "HasLandingType" => { + Self::HasLandingType(match a.text("landingType").unwrap_or("straight") { + "spin" => 2, + "sketchy" => 1, + _ => 0, + }) + } + "HasTiltToLargeForPreland" => Self::HasTiltToLargeForPreland, + "AllowedToTrick" => Self::AllowedToTrick, + _ => return None, + }) + } + pub fn evaluate( + self, + physical: Option, + flags: &Flags, + override_preland: Option, + ) -> Result { + match self { + Self::HasLandingType(kind) => { + Ok(physical.ok_or("HasLandingType requires Animation96")?.kind == kind) + } + Self::HasTiltToLargeForPreland => override_preland + .ok_or("HasTiltToLargeForPreland requires original prelanding output".into()), + //82BC5860 ->8231F460+48 ->82BA74D8 ->specific v116. + Self::AllowedToTrick => Ok(flags.tricks_allowed), + } + } +} + +/// Original MotionGraph CA4 flags, distinct from physical balance mode. +pub struct Flags { + pub anticipating: bool, + pub landing: bool, + pub manualing: bool, + pub doing_trick: bool, + pub tricks_allowed: bool, +} +impl Default for Flags { + fn default() -> Self { + //825953B0 clears bits30..27 and sets bit25. + Self { + anticipating: false, + landing: false, + manualing: false, + doing_trick: false, + tricks_allowed: true, + } + } +} + +/// Completed physical conditioner publication; no inferred landing classification. +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub height: f32, + pub spin: f32, + pub kind: u32, + pub last_good_landing_velocity: f32, +} + +/// Original instance scalar8 (height or countdown) and byte12 (timer complete). +#[derive(Default)] +pub struct State { + pub(super) value: f32, + pub(super) complete: bool, +} diff --git a/crates/skate-game/src/graph_host/motion_landing_execute.rs b/crates/skate-game/src/graph_host/motion_landing_execute.rs new file mode 100644 index 00000000..5071f62d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_landing_execute.rs @@ -0,0 +1,115 @@ +//! Original landing Begin/Update/End effects, called by the production graph. +use super::{ + motion_animation::MotionAnimation, + motion_landing::{Flags, Operation, Physical, State}, + motion_riding_conditions::MotionRandom, +}; +use skate_core::animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, +}; + +#[allow(clippy::too_many_arguments)] +pub fn execute( + operation: &Operation, + state: &mut State, + flags: &mut Flags, + animation: &mut MotionAnimation, + random: &MotionRandom, + physical: Option, + mirrored: Option, + dt: f32, + phase: u8, +) -> Result<(), String> { + match *operation { + Operation::IsAnticipating => { + //82BACB40/82BACB90: v80 ->8258F870, CA4 bit30. + if phase != 1 { + flags.anticipating = phase == 0; + } + } + Operation::IsLanding => { + //82BACBE0/82BACC30: v88 ->8258F898, CA4 bit29. + if phase != 1 { + flags.landing = phase == 0; + } + } + Operation::IsManualing => { + //82BACC80/82BACCD0: v96 ->8258F8C0, CA4 bit28. + if phase != 1 { + flags.manualing = phase == 0; + } + } + Operation::IsDoingTrick => { + //82BBDFF8/82BBE048: v104 ->8258F8E8, CA4 bit27. + if phase != 1 { + flags.doing_trick = phase == 0; + } + } + Operation::SetLandingData => match phase { + 0 => { + //82BB0180: sign follows ISkaterAnim v28, then MG v36. + let p = physical.ok_or("SetLandingData requires actual landing publication")?; + let mirror = mirrored.ok_or("SetLandingData requires animation stance")?; + set(animation, b"Spin", if mirror { p.spin } else { -p.spin }); + set(animation, b"AvgVelY", p.last_good_landing_velocity); + state.value = p.height; + } + 1 => set(animation, b"disttocog", state.value), //82BB0338. + _ => {} //Original End82B61BB8 is empty. + }, + Operation::DisableTricks { length } => match phase { + 0 => { + //82BB8DC8. + flags.tricks_allowed = false; + state.value = length; + state.complete = false; + } + 1 => { + //82BB8E58; strict comparison, no reference counting. + if !state.complete { + state.value -= dt; + if state.value < 0.0 { + flags.tricks_allowed = true; + state.complete = true; + } + } + } + _ => flags.tricks_allowed = true, //82BB8F20 unconditionally. + }, + Operation::ChooseRandomLanding { count } => match phase { + 0 => { + //82BB0398 draws from the SAME specific MotionGraph RNG v256. + //The original traps for a nonpositive divisor; reject that asset. + if count <= 0 { + return Err("ChooseRandomLanding requires numlandings > 0".into()); + } + let value = (random.next_u32()? as i32).wrapping_abs() % count; + let name = encode(b"Random"); + let value = encode(value.to_string().as_bytes()); + if let Some(item) = animation + .construction_values + .iter_mut() + .find(|(key, _)| *key == name) + { + item.1 = value; + } else { + animation.construction_values.push((name, value)); + } + } + 2 => animation + .construction_values + .retain(|(name, _)| *name != encode(b"Random")), + _ => {} //Update82B61BB8 is empty; End82BB0498 erases the key. + }, + } + Ok(()) +} +fn set(animation: &mut MotionAnimation, name: &[u8], value: f32) { + animation.set_attribute(SettableAttribute { + name: encode(name), + value, + normalized: false, + sequence_id: -1, + }); +} diff --git a/crates/skate-game/src/graph_host/motion_native.rs b/crates/skate-game/src/graph_host/motion_native.rs new file mode 100644 index 00000000..2c485f23 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_native.rs @@ -0,0 +1,138 @@ +//! Small original MotionGraph leaves used by the live riding ancestry. +use super::{motion_animation::MotionAnimation, motion_riding::RidingState}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug)] +pub struct Physical { + /// PhysOutSystemReckoning16 and96, original StoreLandingData82BAFFF0. + pub centre_of_mass_velocity: [f32; 4], + pub system_up: [f32; 4], + /// PhysOutGround32 (Reckoning752 Z), original slide helper82595930. + pub board_reckoning_z: [f32; 4], + /// Complete PhysOutGround0/16/32/48 from Reckoning752, for82BB31B0. + pub board_reckoning: [[f32; 4]; 4], +} + +#[derive(Clone, Copy, Debug)] +pub struct GesturePhysical { + pub ground321: bool, + pub state_offboard75: bool, + ///Host player gesture preferences, consumed only when starting a gesture. + pub selections: Option<[u32; 4]>, + pub suppress_up: bool, + pub force_brake_bypass: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Operation { + StoreLandingData, + EndShimmy(f32), + Score { regular: u32, mirrored: u32 }, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + Ok(Some(match a.text("name").unwrap_or("") { + "StoreLandingData" => Self::StoreLandingData, + "EndShimmy" => Self::EndShimmy(f32::from_bits(a.float_bits("blendtime", 0x3e4ccccd))), + "SetScoreAugmentation" => { + //82BBFB28's source enum; absent mirrorAugment inherits augment. + let decode = |name: &str| { + [ + "FSPowerslide", + "BSPowerslide", + "FSRevert", + "BSRevert", + "NoseManual", + "TailManual", + "Wipeout", + "Landing", + "RideIdle", + "Switching", + ] + .iter() + .position(|v| *v == name) + .map(|n| n as u32) + .ok_or_else(|| format!("Unknown authored score augmentation {name}")) + }; + let regular = decode(a.text("augment").unwrap_or(""))?; + let mirrored = match a.text("mirrorAugment") { + //Original82BBFE98 writes the regular slot for this name, + //leaving the mirror slot uninitialized. No authored stock + //riding node uses it; do not invent a deterministic value. + Some("Switching") => { + return Err( + "Native mirrorAugment=Switching has no initialized mirrored value" + .into(), + ); + } + Some(name) => decode(name)?, + None => regular, + }; + Self::Score { regular, mirrored } + } + _ => return Ok(None), + })) + } +} + +/// Meaningful graph outputs are retained even though a scoring UI is outside +/// this game.82595D08 sets a name and ORs a bit in the graph's score packet. +#[derive(Default)] +pub struct ScorePacket { + pub name: Option, + pub flags: u32, +} +impl ScorePacket { + pub fn set(&mut self, value: u32) { + let bit = match value { + 0 => 31, + 1 => 30, + 2 => 29, + 3 => 28, + 4 => 27, + 5 => 26, + 6 => 23, + 7 => 21, + 8 => 22, + 9 => 20, + _ => return, + }; + self.flags |= 1 << bit; + if value != 2 && value != 3 { + self.name = Some(value); + } + } +} + +///CreateInstance82BBAA98 seeds velocity0 and prior-air false. +#[derive(Default)] +pub struct LandingData { + previous_velocity: f32, + was_air: bool, +} +impl LandingData { + pub fn update(&mut self, category: u32, p: Physical, riding: &mut RidingState) { + let air = category == 2; + if !self.was_air && air { + riding.last_good_landing_velocity = 0.0; + } + self.was_air = air; + let velocity = skate_core::riding::ground_correction_math::dot_product( + p.centre_of_mass_velocity, + p.system_up, + ); + if velocity < 0.0 && velocity < self.previous_velocity { + riding.last_good_landing_velocity = velocity.abs(); + } + self.previous_velocity = velocity; + } +} +pub fn end_shimmy(animation: &mut MotionAnimation, seconds: f32) { + //82BBCC58; virtual40 uses EndChannelWithTime, not instantaneous removal. + for name in [ + "SKCH_2H_SHIMMY_LEFT_CHANNEL", + "SKCH_2H_SHIMMY_RIGHT_CHANNEL", + ] { + animation.channels.end_with(name, seconds, false); + } +} diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs new file mode 100644 index 00000000..e91637fa --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -0,0 +1,242 @@ +//! Stock MotionGraph configuration. Native constructor defaults are kept here; +//! lifecycle execution and persistent state are owned by MotionHost. +use super::{motion_conditions::MotionCondition, pushing::PushOperation}; +use skate_core::animation::{ + output::attributes::AttributeName, + playback::{PlayAnimation, TransitionSettings}, + playback_parameters::{ParameterSource, PlaybackParameter}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::{ + attributes::Attributes, + binding::{Node, OperationFactory, OperationKind}, +}; + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionOperation { + Play(PlayAnimation), + ///TU3 vtable82309664: Begin/Update/End all point to the empty82B61BB8. + PrintText2D, + AttachIntent { + intent: String, + attribute: AttributeName, + set: bool, + }, + Push(PushOperation), + /// Angle, direction, quickness, speed, holding, in native emission order. + SetTurning([AttributeName; 5]), + ApplyingBodyTilt, + SettingBodyTilt(AttributeName), + Crouching(AttributeName), + Pumping, + DisallowPumping, + FakieHeadChannel, + Native(super::motion_native::Operation), + Slide(super::motion_sliding::Operation), + KickTurn(super::motion_kickturn::Operation), + BodySpin, + AirLeg(super::motion_air_leg::Operation), + ClearTrickAttr, + Landing(super::motion_landing::Operation), + Wipeout(super::motion_wipeout::Operation), + TwistLean(super::motion_twist_lean::Operation), + HandService(super::motion_hand_services::Operation), + IntentFilter(super::motion_intent_filter::Operation), + CharacterGesture, + Shove(super::motion_shove::ShoveOperation), + ResetAnimation(super::motion_reset::Operation), + UpdateRidingFakie(skate_core::animation::riding_fakie::Settings), + SetSpeed { + name: AttributeName, + value: Option, + }, + Hook(super::motion_hooks::MotionHook), + Riding(super::motion_riding::RidingOperation), + Condition(MotionCondition), + Unsupported { + kind: OperationKind, + name: String, + }, +} + +#[derive(Default)] +pub struct MotionFactory; +impl OperationFactory for MotionFactory { + type Instance = MotionOperation; + type Error = String; + + fn create( + &mut self, + kind: OperationKind, + _parent: Node, + a: &Attributes<'_>, + ) -> Result, String> { + let name = a.text("name").ok_or("MotionGraph operation has no name")?; + let operation = if kind == OperationKind::Condition { + MotionCondition::parse(a)?.map(MotionOperation::Condition) + } else if kind == OperationKind::Behavior { + if let Some(operation) = super::motion_landing::Operation::parse(a) { + Some(MotionOperation::Landing(operation)) + } else if let Some(operation) = super::motion_hand_services::Operation::parse(a) { + Some(MotionOperation::HandService(operation)) + } else if let Some(push) = PushOperation::from_attributes(a) { + Some(MotionOperation::Push(push)) + } else if let Some(operation) = super::motion_riding::RidingOperation::parse(a) { + Some(MotionOperation::Riding(operation)) + } else if let Some(operation) = super::motion_native::Operation::parse(a)? { + Some(MotionOperation::Native(operation)) + } else if let Some(operation) = super::motion_sliding::Operation::parse(a) { + Some(MotionOperation::Slide(operation)) + } else if let Some(operation) = super::motion_kickturn::Operation::parse(a) { + Some(MotionOperation::KickTurn(operation)) + } else if let Some(operation) = super::motion_shove::ShoveOperation::parse(a)? { + Some(MotionOperation::Shove(operation)) + } else { + match name { + "ResetSkaterAnimation" | "ResetToGivenStance" => Some(MotionOperation::ResetAnimation( + super::motion_reset::Operation::parse(a).ok_or("Invalid reset operation")?, + )), + "MatchTwistAndLean" => Some(MotionOperation::TwistLean( + super::motion_twist_lean::Operation::parse(a), + )), + "Wipeout" => Some(MotionOperation::Wipeout( + super::motion_wipeout::Operation::Wipeout, + )), + "EnableWipeoutGestures" => Some(MotionOperation::Wipeout( + super::motion_wipeout::Operation::EnableGestures, + )), + "FilterMotionGraphIntent" => Some(MotionOperation::IntentFilter( + super::motion_intent_filter::Operation::parse(a), + )), + "PlayAnimation" => Some(MotionOperation::Play(play(a))), + "PrintText2D" => Some(MotionOperation::PrintText2D), + "ApplyingBodyTilt" => Some(MotionOperation::ApplyingBodyTilt), + "SettingBodyTilt" => { + Some(MotionOperation::SettingBodyTilt(key(a, "tilt_x", "tilt_x"))) + } + "Crouching" => Some(MotionOperation::Crouching(key(a, "crouchName", ""))), + "Pumping" => Some(MotionOperation::Pumping), + "BodySpin" => Some(MotionOperation::BodySpin), + "ControlAirLegExtension" => Some(MotionOperation::AirLeg( + super::motion_air_leg::Operation::parse(a), + )), + "ClearTrickAttr" => Some(MotionOperation::ClearTrickAttr), + "CharacterGesture" => Some(MotionOperation::CharacterGesture), + "DisallowPumping" => Some(MotionOperation::DisallowPumping), + "FakieHeadChannel" => Some(MotionOperation::FakieHeadChannel), + "SetSpeed" => Some(MotionOperation::SetSpeed { + name: key(a, "attribute", ""), + value: a.get("setToValue").map(|v| f32::from_bits(v.float_bits)), + }), + "UpdateRidingFakie" => Some(MotionOperation::UpdateRidingFakie( + skate_core::animation::riding_fakie::Settings { + high_speed: f32::from_bits( + a.float_bits("highSpeedThreshold", 1.0f32.to_bits()), + ), + low_speed: f32::from_bits( + a.float_bits("lowSpeedThreshold", 0.5f32.to_bits()), + ), + slowly_backwards_seconds: f32::from_bits( + a.float_bits("timeSlowlyRollingBackwardsThreshold", 0x3e4ccccd), + ), + after_teleport_seconds: f32::from_bits( + a.float_bits("timeFromTeleportThreshold", 3.0f32.to_bits()), + ), + }, + )), + // Factory82BC6D40 defaults both identifiers to the string0. + "AttachIntent" => Some(MotionOperation::AttachIntent { + intent: a.text("intent").unwrap_or("0").into(), + attribute: encode(a.text("attr").unwrap_or("0").as_bytes()), + set: a.boolean_byte("set", 0) != 0, + }), + // Ctor82BB36D8; layout88 is holding and108 is speed, but + // Update emits speed before holding. + "SetTurning" => Some(MotionOperation::SetTurning([ + key(a, "angleName", "angle"), + key(a, "dirName", "dir"), + key(a, "quicknessName", "quickness"), + key(a, "speedName", "speedtuck"), + key(a, "holdingName", "holding"), + ])), + _ => None, + } + } + } else if kind == OperationKind::Hook { + super::motion_hooks::MotionHook::parse(a).map(MotionOperation::Hook) + } else { + None + }; + Ok(Some(operation.unwrap_or_else(|| { + MotionOperation::Unsupported { + kind, + name: name.into(), + } + }))) + } + fn add_parameter( + &mut self, + instance: &mut MotionOperation, + a: &Attributes<'_>, + ) -> Result<(), String> { + if let MotionOperation::Play(play) = instance { + //82BB56B8: all unrecognized from strings take filteredIntent. + let source = match a.text("from").unwrap_or("") { + "intent" => ParameterSource::MotionIntent(a.text("intent").unwrap_or("").into()), + "lastAnim" => ParameterSource::LastAnimation(key(a, "attribute", "")), + _ => ParameterSource::FilteredIntent(a.text("filteredIntent").unwrap_or("").into()), + }; + let last_animation = matches!(source, ParameterSource::LastAnimation(_)); + play.parameters.push(PlaybackParameter { + source, + rename: a + .text("rename") + .map(|v| encode(v.as_bytes())) + .filter(|v| *v != encode(b"")), + default_value: a.get("defaultValue").map(|v| f32::from_bits(v.float_bits)), + normalized: !last_animation && a.boolean_byte("normalize", 0) != 0, + }); + } + // Other implemented nodes inherit the verified no-op AddParam. + Ok(()) + } +} + +fn key(a: &Attributes<'_>, name: &str, default: &str) -> AttributeName { + encode(a.text(name).unwrap_or(default).as_bytes()) +} +fn play(a: &Attributes<'_>) -> PlayAnimation { + PlayAnimation { + animation: a.text("anim").unwrap_or("").into(), + switch_animation: a.text("switchAnim").map(str::to_owned), + mirror_animation: a.text("mirrorAnim").map(str::to_owned), + no_board_animation: a.text("noBoardAnim").map(str::to_owned), + playback_speed: f32::from_bits(a.float_bits("playBackSpeed", 1.0f32.to_bits())), + apply_posture: a.boolean_byte("applyPosture", 1) != 0, + transition: transition(a), + parameters: Vec::new(), + } +} +pub(super) fn transition(a: &Attributes<'_>) -> TransitionSettings { + TransitionSettings { + kind: match a.text("transType").unwrap_or("") { + "play" => 1, + "sequence" => 4, + "channelblend" => 3, + _ => 2, + }, + // Source literal82099280, not a fitted visual blend duration. + seconds: f32::from_bits(a.float_bits("time", 0x3e4c_cccd)), + under: u32::from(a.boolean_byte("transitionUnder", 0) != 0), + matching: if a.boolean_byte("blendWithCurrentFrame", 0) != 0 { + 1 + } else if a.boolean_byte("blendMatchPhase", 0) != 0 { + 2 + } else if a.boolean_byte("blendMatchFrame", 0) != 0 { + 3 + } else { + 0 + }, + use_channels_from_weights: a.boolean_byte("useChannelFromWeights", 0) != 0, + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/cadence.rs b/crates/skate-game/src/graph_host/motion_offboard/cadence.rs new file mode 100644 index 00000000..4a62ff70 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/cadence.rs @@ -0,0 +1,99 @@ +//! BipedCadence82BB9E78, MatchCadence82BBA938/82BBAA00 and LocoState82BA7B50. +//! TU3 default.patched.xex SHA256 +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +use skate_core::animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::attributes::Attributes; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Operation { + BipedCadence, + MatchCadence, +} +impl Operation { + /// Constructors have no operation-specific XML configuration. The common + /// graph wrapper retains mask, active and ordering behavior. + pub(crate) fn parse(a: &Attributes<'_>) -> Option { + match a.text("name") { + Some("BipedCadence") => Some(Self::BipedCadence), + Some("MatchCadence") => Some(Self::MatchCadence), + _ => None, + } + } +} + +///82BB9E78 calls SkaterAnim virtual40=82531200, a direct phase14652 store. +/// Supply the actual OffBoard80 and the existing AnimationState.phase owner. +/// Component absence skips the write. Begin/End are82B61BB8 no-ops. +pub(crate) fn biped_cadence( + physical_phase: Option, + animation_phase: Option<&mut f32>, + phase: u8, +) { + if phase == 1 { + if let (Some(value), Some(output)) = (physical_phase, animation_phase) { + *output = value; + } + } +} + +/// Per-node state from82BBAA98: value8=0 and byte12=false. This is a captured +/// Begin value, not a second physical cadence or animation phase owner. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct MatchCadence { + pub captured_phase: f32, + pub pending: bool, +} +impl MatchCadence { + ///82BBA938 requires both physical and animation components. Missing either + /// leaves the previous capture and byte untouched. + pub(crate) fn begin(&mut self, physical_phase: Option, animation_present: bool) { + if let (Some(value), true) = (physical_phase, animation_present) { + self.pending = true; + self.captured_phase = value; + } + } + ///82BBAA00 publishes on every Update, even if byte12 is already false. + /// It clears that byte only after writing through virtual80/SetAttribute. + pub(crate) fn update(&mut self, animation: Option<&mut dyn AttributeSink>) { + if let Some(animation) = animation { + animation.set_attribute(SettableAttribute { + name: encode(b"CadenceStartPercent"), + value: self.captured_phase, + normalized: false, + sequence_id: -1, + }); + self.pending = false; + } + } + ///Original vtable End82B61BB8 does not reset the capture or pending byte. + pub(crate) fn end(&mut self) {} +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct LocoState { + expected: u32, +} +impl LocoState { + ///82BA79E8 compares case-sensitive locostate values. An unknown spelling + /// leaves native field28 unwritten; reject invalid data rather than invent0. + pub(crate) fn parse(a: &Attributes<'_>) -> Result { + let expected = match a.text("locostate") { + Some("Stand") => 0, + Some("Walk") => 1, + Some("Run") => 2, + Some("Sprint") => 3, + value => return Err(format!("Invalid stock LocoState locostate {value:?}")), + }; + Ok(Self { expected }) + } + ///82BA7B50 requires the physical component and compares OffBoard84 exactly. + /// The graph wrapper applies its separately parsed mask and negation. + pub(crate) fn evaluate(self, locomotion_state_84: u32) -> bool { + locomotion_state_84 == self.expected + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/cadence/tests.rs b/crates/skate-game/src/graph_host/motion_offboard/cadence/tests.rs new file mode 100644 index 00000000..2ec2acbe --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/cadence/tests.rs @@ -0,0 +1,63 @@ +use super::*; +use skate_core::animation::playback_parameters::SettableAttributes; +#[test] +fn biped_updates_the_existing_phase_only_during_update() { + let mut phase = 0.25; + biped_cadence(Some(0.8), Some(&mut phase), 0); + assert_eq!(phase, 0.25); + biped_cadence(Some(0.8), Some(&mut phase), 1); + assert_eq!(phase, 0.8); + biped_cadence(None, Some(&mut phase), 1); + biped_cadence(Some(0.1), Some(&mut phase), 2); + assert_eq!(phase, 0.8); +} +#[test] +fn match_captures_begin_and_republishes_without_consuming_it() { + let mut state = MatchCadence::default(); + let mut sink = SettableAttributes::default(); + state.begin(Some(0.73), true); + assert!(state.pending); + state.update(Some(&mut sink)); + assert!(!state.pending); + let value = sink.entries()[0]; + assert_eq!(value.name, encode(b"CadenceStartPercent")); + assert_eq!(value.value, 0.73); + assert!(!value.normalized); + assert_eq!(value.sequence_id, -1); + sink.clear(); + state.end(); + state.update(Some(&mut sink)); + assert_eq!(sink.entries()[0].value, 0.73); +} +#[test] +fn absent_components_preserve_retained_capture_and_pending_flag() { + let mut state = MatchCadence::default(); + state.begin(Some(0.4), true); + state.begin(Some(0.9), false); + state.begin(None, true); + state.update(None); + assert_eq!(state.captured_phase, 0.4); + assert!(state.pending); + state.end(); + assert!(state.pending); +} +#[test] +fn native_initial_capture_is_zero_before_any_begin() { + let mut state = MatchCadence::default(); + let mut sink = SettableAttributes::default(); + state.update(Some(&mut sink)); + assert_eq!(sink.entries()[0].value, 0.0); +} +#[test] +fn locomotion_comparison_does_not_clamp_unknown_physical_values() { + for expected in 0..4 { + let condition = LocoState { expected }; + for actual in 0..5 { + assert_eq!(condition.evaluate(actual), expected == actual); + } + } +} +#[test] +fn missing_locomotion_config_is_not_silently_stand() { + assert!(LocoState::parse(&Attributes::new(&[])).is_err()); +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/push_off.rs b/crates/skate-game/src/graph_host/motion_offboard/push_off.rs new file mode 100644 index 00000000..29950f4b --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/push_off.rs @@ -0,0 +1,20 @@ +//! IsPushOffEnabled: original TU3 registration82F88150 -> ctor82BC62F8. +//! The ctor installs vtable8231F6D4; predicate slot+48 is8281DD70. +//! Original bytes386000014E800020 are li r3,1; blr. There is no physical +//! flag read, time gate or runtime setting in this leaf. The graph executor +//! remains responsible for the common active/mask/negation semantics. +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IsPushOffEnabled; + +impl IsPushOffEnabled { + pub fn parse(attributes: &Attributes<'_>) -> Option { + (attributes.text("name") == Some("IsPushOffEnabled")).then_some(Self) + } + + ///8281DD70: unconditional true in the verified original Skate3 TU3 image. + pub fn evaluate(self) -> bool { + true + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/reset.rs b/crates/skate-game/src/graph_host/motion_offboard/reset.rs new file mode 100644 index 00000000..783973a2 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/reset.rs @@ -0,0 +1,45 @@ +//! Original registered reset handlers:82F88440/82F89600. +//! Both have Begin effects; Update and End point to82B61BB8 (raw blr). +use skate_data::state_graph::attributes::Attributes; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Operation { + SkaterAnimation, + GivenStance, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + match a.text("name")? { + "ResetSkaterAnimation" => Some(Self::SkaterAnimation), + "ResetToGivenStance" => Some(Self::GivenStance), + _ => None, + } + } + ///82BB8F70 guarantees AG-intents -> MG-output/intents -> SkaterAnim order. + ///The service must borrow the actual owners; resetting AG/MG controllers or + ///replacing all animation state with Default would change native behavior. + pub fn begin(self, owners: &mut S) -> Result<(), S::Error> { + match self { + Self::SkaterAnimation => { + owners.clear_action_intents()?; + owners.reset_motion_graph_publications()?; + owners.reset_skater_animation_playback() + } + Self::GivenStance => owners.reset_to_given_stance(), + } + } +} +///Required integration boundary for the single ActionGraph, MotionGraph and +///playback owners. No default implementations or fabricated reset observations. +pub trait ResetOwners { + type Error; + ///82C0E308 ->82BC1B68: clear actual ActionGraph intent map only. + fn clear_action_intents(&mut self) -> Result<(), Self::Error>; + ///825953B0: filtered intents, current MG attrs, selective scalar writes, + ///motion intents and output2664..2680. Preserve graph execution and trick data. + fn reset_motion_graph_publications(&mut self) -> Result<(), Self::Error>; + ///82B98050 +82D1CAE8 +82D180E8: masks/stance bit, channel teardown, + ///playback tree/transition/attribute/control reset on the canonical owner. + fn reset_skater_animation_playback(&mut self) -> Result<(), Self::Error>; + ///82B97308 consumes saved15200 via82B972A8; no playback reset. + fn reset_to_given_stance(&mut self) -> Result<(), Self::Error>; +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/twist_lean.rs b/crates/skate-game/src/graph_host/motion_offboard/twist_lean.rs new file mode 100644 index 00000000..923bbf9c --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/twist_lean.rs @@ -0,0 +1,49 @@ +//! MatchTwistAndLean stock configuration and existing animation queue adapter. +use skate_core::{ + animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, + }, + player::offboard::twist_lean::{Observation, State}, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Operation { + pub always: bool, +} +impl Operation { + ///82BCC490 compares exact bytes through strcmp82AE89B0. + pub fn parse(attributes: &Attributes<'_>) -> Self { + Self { always: attributes.text("update") == Some("always") } + } + + ///Supply Some only for available original physical/animation components. + ///A missing implementation of a physical field must be an upstream error, + ///not an absent-component value. End is the original82B61BB8 no-op. + pub fn execute( + self, + state: &mut State, + observation: Option, + animation: Option<&mut impl AttributeSink>, + phase: u8, + ) { + match phase { + 0 => state.begin(observation), + 1 => { + if let Some(animation) = animation { + let values = state.update(self.always, observation); + for (name, value) in [("Twist", values.twist), ("Lean", values.lean)] { + animation.set_attribute(SettableAttribute { + name: encode(name.as_bytes()), + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + _ => {} + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_riding.rs b/crates/skate-game/src/graph_host/motion_riding.rs new file mode 100644 index 00000000..4de190ce --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_riding.rs @@ -0,0 +1,230 @@ +//! Native riding packet and timer leaves. The graph host owns these states; +//! physics/pose callers consume the resulting values in actor update order. +use super::motion_animation::MotionAnimation; +use skate_core::{ + animation::{ + output::attributes::AttributeName, + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, + }, + graph::controller::Frame, +}; +use skate_data::state_graph::attributes::Attributes; + +pub fn body_tilt_settings( + data: &skate_data::collections::Collections, +) -> Result { + let words = data.words::<12>("anim_motion", "body_tilt", "body_tilt_bodyspin_factor")?; + let float = |name| data.float("anim_motion", "body_tilt", name); + Ok(skate_core::animation::body_tilt::Settings { + body_spin_factor: skate_core::point_graph::PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[8 + i])), + }, + ground_velocity: float("body_tilt_ground_clamp_vel")?, + ground_acceleration: float("body_tilt_ground_clamp_acc")?, + air_velocity: float("body_tilt_air_clamp_vel")?, + air_acceleration: float("body_tilt_air_clamp_acc")?, + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ForceMode { + AnimationData, + PhysicsBoard, + AnimationBoard, +} +impl ForceMode { + pub fn native_value(self) -> u32 { + match self { + Self::AnimationData => 0, + Self::PhysicsBoard => 1, + Self::AnimationBoard => 2, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RidingOperation { + CreateAttribute { + name: AttributeName, + values: [Option; 3], + set: bool, + }, + SetDistComToBoard { + name: AttributeName, + manually: bool, + adjust_for_velocity: bool, + }, + ForcePhysics(ForceMode), + TimeSinceTeleport, + TimeSinceKickturn, + ManualOutTimer, +} +impl RidingOperation { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "CreateAttribute" => { + //82BAE918: the always branch returns before reading set and + //writes Update twice, leaving End's initialized zero intact. + let (values, set) = if let Some(value) = a.get("always") { + let value = f32::from_bits(value.float_bits); + ([Some(value), Some(value), Some(0.0)], false) + } else if ["begin", "update", "end"] + .iter() + .all(|key| a.get(key).is_none()) + { + ([None, Some(0.0), None], false) + } else { + ( + ["begin", "update", "end"] + .map(|key| a.get(key).map(|v| f32::from_bits(v.float_bits))), + a.boolean_byte("set", 0) != 0, + ) + }; + Self::CreateAttribute { + name: encode(a.text("attName").unwrap_or("").as_bytes()), + values, + set, + } + } + "SetDistComToBoard" => Self::SetDistComToBoard { + name: encode(a.text("attribute").unwrap_or("").as_bytes()), + manually: a.boolean_byte("setManually", 0) != 0, + adjust_for_velocity: a.boolean_byte("adjustForVel", 0) != 0, + }, + "ForcePhysics" => { + Self::ForcePhysics(match a.text("force").unwrap_or("FOLLOW_ANIMATION_DATA") { + "FORCE_PHYSICS_SKATEBOARD" => ForceMode::PhysicsBoard, + "FORCE_ANIM_SKATEBOARD" => ForceMode::AnimationBoard, + _ => ForceMode::AnimationData, + }) + } + "UpdateTimeSinceTeleport" => Self::TimeSinceTeleport, + "UpdateTimeSinceKickturn" => Self::TimeSinceKickturn, + "UpdateManualOutTimer" => Self::ManualOutTimer, + _ => return None, + }) + } +} + +#[derive(Clone, Debug)] +pub struct RidingState { + pub time_since_teleport: f32, + pub time_since_kickturn: f32, + pub manual_out_timer: f32, + + ///Specific MotionGraph CA4 bit23, getter8258FB68/setter8258FB78. + ///Constructor8258F5B8 clears it; Reset82595480 preserves it. + pub dark: bool, + /// Specific MotionGraph+3156, GetLastGoodLandingVelocity8258F7C8. + pub last_good_landing_velocity: f32, + ///82BB2868 writes ProcessedPhysIn+16420 only on behavior Begin. + ///None means no graph request has changed the physical mode yet. + pub force_mode: Option, +} +impl RidingState { + pub fn new() -> Self { + //8258F488 initializes full5912/5916; final reset825953B0 clears + //specific3156/3224. + Self { + time_since_teleport: 0.0, + time_since_kickturn: 0.0, + manual_out_timer: 0.0, + + dark: false, + last_good_landing_velocity: 0.0, + force_mode: None, + } + } + pub fn execute( + &mut self, + operation: RidingOperation, + phase: u8, + frame: &Frame, + animation: &mut MotionAnimation, + physical_category: Option, + animation_height: Option, + manualing: bool, + ) -> Result<(), String> { + match operation { + RidingOperation::CreateAttribute { name, values, set } => { + if let Some(value) = values[usize::from(phase)] { + //82BAEC90 emits the graph record before setting tree data. + animation.emit_packet(name, value); + if set { + animation.set_attribute(SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + RidingOperation::SetDistComToBoard { + name, + manually, + adjust_for_velocity, + } => { + if phase == 0 { + //82BB04E8: source literal0.65 only when explicitly authored. + let height = if manually { + f32::from_bits(0x3f266666) + } else { + animation_height + .ok_or("SetDistComToBoard requires actual PhysOutAnimation distance")? + }; + let correction = if adjust_for_velocity { + let v = frame.dt * self.last_good_landing_velocity; + let cap = f32::from_bits(0x3e19999a); + if cap - v >= 0.0 { v } else { cap } + } else { + 0.0 + }; + animation.set_attribute(SettableAttribute { + name, + value: height - correction, + normalized: false, + sequence_id: -1, + }); + } + } + RidingOperation::ForcePhysics(mode) => { + if phase == 0 { + self.force_mode = Some(mode); + } + } + RidingOperation::TimeSinceTeleport => { + if phase == 1 { + //82BB9058: actual filtered category5 resets; all others add + //controller delta through8258F968, with no imposed maximum. + if physical_category + .ok_or("UpdateTimeSinceTeleport needs filtered physical state")? + == 5 + { + self.time_since_teleport = 0.0; + } else { + self.time_since_teleport += frame.dt; + } + } + } + RidingOperation::TimeSinceKickturn => { + if phase == 1 { + self.time_since_kickturn += frame.dt; + } + } //82BAE848/8258F940 + RidingOperation::ManualOutTimer => { + if phase == 2 { + self.manual_out_timer = 0.0; + } + //82BB9260/8258F980 + else if phase == 1 && !manualing { + let remaining = self.manual_out_timer - frame.dt; + self.manual_out_timer = if remaining >= 0.0 { remaining } else { 0.0 }; + } //82BB91A8/8258F998 + } + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_riding_conditions.rs b/crates/skate-game/src/graph_host/motion_riding_conditions.rs new file mode 100644 index 00000000..ac61f8c0 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_riding_conditions.rs @@ -0,0 +1,167 @@ +//! Original TU3 random, COM velocity and slope conditions for stock riding. +use super::motion::MotionHost; +use skate_core::{ + graph::conditions::NumericCondition, + math::Vector3, + physics::board_motion_output::{dot, length}, + trigonometry, +}; +use skate_data::state_graph::attributes::Attributes; +use std::sync::Mutex; + +/// Completed physical output, before the next MotionGraph evaluation. +#[derive(Clone, Copy, Debug)] +pub struct RidingConditionInputs { + /// PhysOut bundle36+16, published from Skeleton's physical COM velocity. + pub com_velocity: [f32; 4], + /// PhysOutSkeleton432/464: effective animation-to-world X/Z columns. + /// GetEffectiveRoot82BE3650 negates both iff Processed2476 bit2 is set. + pub skeleton_x: [f32; 4], + pub skeleton_z: [f32; 4], + /// PhysOut bundle0+16.Y: the raw physical deck's up axis. + pub skate_up_y: f32, + /// PhysOutGround80.Y: retained contacting-wheel normal. + pub surface_up_y: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VelocityAxis { + X, + Y, + Z, + All, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionRidingCondition { + Random, + ComVelocity { + axis: VelocityAxis, + numeric: NumericCondition, + }, + SkateSlope(NumericCondition), + SurfaceSlope(NumericCondition), +} + +impl MotionRidingCondition { + pub fn recognizes(name: &str) -> bool { + matches!( + name, + "RandomCond" | "ComVelCompare" | "SkateSlope" | "SurfaceSlope" + ) + } + + pub fn parse(a: &Attributes<'_>) -> Result { + Ok(match a.text("name").unwrap_or("") { + // Factory82BC3CE8 adds no attributes beyond the base condition. + "RandomCond" => Self::Random, + "ComVelCompare" => Self::ComVelocity { + // Ctor82BA3ED0 examines only the first byte, default "0". + axis: match a.text("axis").unwrap_or("0").as_bytes().first() { + Some(b'x' | b'X') => VelocityAxis::X, + Some(b'y' | b'Y') => VelocityAxis::Y, + Some(b'z' | b'Z') => VelocityAxis::Z, + _ => VelocityAxis::All, + }, + numeric: super::condition_nodes::numeric(a), + }, + // Factories82BC3258/82BC31B8 use NumericCondition82C126F0. + "SkateSlope" => Self::SkateSlope(super::condition_nodes::numeric(a)), + "SurfaceSlope" => Self::SurfaceSlope(super::condition_nodes::numeric(a)), + name => return Err(format!("Unknown riding condition {name}")), + }) + } + + pub fn evaluate(&self, host: &MotionHost) -> Result { + if matches!(self, Self::Random) { + //82BA4D50 -> ISkaterMotionGraph256 ->8258FB60 ->82970628. + // Advance only when the graph actually evaluates this leaf. + return Ok(host.condition_random.next_u32()? & 1 == 0); + } + let p = host + .riding_conditions + .as_ref() + .ok_or("MotionGraph requires the actual COM and slope publication")?; + Ok(match *self { + Self::ComVelocity { axis, numeric } => { + //82BA3FA8 uses relative X/Z but WORLD Y. The named Skate2 + //counterpart reads direct XYZ, so its axis behavior differs. + let velocity = xyz(p.com_velocity); + let value = match axis { + VelocityAxis::X => dot(velocity, xyz(p.skeleton_x)), + VelocityAxis::Y => velocity.y, + VelocityAxis::Z => dot(velocity, xyz(p.skeleton_z)), + VelocityAxis::All => length(velocity), + }; + numeric.matches(value) + } + Self::SkateSlope(numeric) => numeric.matches(slope(p.skate_up_y)), + Self::SurfaceSlope(numeric) => numeric.matches(slope(p.surface_up_y)), + Self::Random => unreachable!("Random condition evaluated above"), + }) + } +} + +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +fn slope(up_y: f32) -> f32 { + //82BA4DD0/82BA4F90 inline the same original inverse-sine polynomial as + //DisablePushBrake82BA5150; no clamp or absolute value precedes asin. + 90.0 - trigonometry::asin(up_y) * f32::from_bits(0x4265_2ee1) +} + +/// Per-MotionGraph generator. Original8258F488 seeds this once; its reset +///825953B0 leaves the sequence intact. Locking only gives the host shared +///condition interface interior mutability; it does not alter draw timing. +#[derive(Debug)] +pub struct MotionRandom(Mutex<[u32; 8]>); + +impl MotionRandom { + pub fn new() -> Self { + //FullMotionGraph6000/6004 start at zero, then6008..6031 receive the + //original 24-byte literal821642B0. No external runtime RNG is used. + Self(Mutex::new([ + 0, + 0, + 0xf22d_0e56, + 0x8831_26e9, + 0xc624_dd2f, + 0x0702_c49c, + 0x9e35_3f7d, + 0x6fdf_3b64, + ])) + } + + pub fn next_u32(&self) -> Result { + let mut words = self + .0 + .lock() + .map_err(|_| "MotionGraph random state lock poisoned")?; + //Complete original82970628 carry/update sequence, including the + //separate counter rollover propagation after producing word2. + let last = words[7]; + let previous = words[6]; + let mut sum = previous.wrapping_add(last); + let mut carry = u32::from(sum < last || sum < previous); + words[6] = sum; + for index in (2..=5).rev() { + let previous = words[index]; + sum = previous.wrapping_add(sum).wrapping_add(carry); + carry = u32::from(sum < previous); + words[index] = sum; + } + words[7] = last.wrapping_add(1); + if words[7] == 0 { + for index in (2..=6).rev() { + words[index] = words[index].wrapping_add(1); + if words[index] != 0 { + break; + } + } + } + words[1] = words[1].wrapping_add(1); + Ok(words[2]) + } +} diff --git a/crates/skate-game/src/graph_host/motion_shove.rs b/crates/skate-game/src/graph_host/motion_shove.rs new file mode 100644 index 00000000..32499581 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_shove.rs @@ -0,0 +1,213 @@ +//! Original TU3 Shove constructor82BBCCF0, Begin82BBCDD0, +//! Update82BBCDF0 and End82BBD540. The host owns channels and graph values. +use super::motion_animation::MotionAnimation; +use skate_core::animation::{ + channel_playback::ChannelSettings, + playback::TransitionSettings, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub struct ShoveOperation { + selection: String, + selection_board: String, + anticipation: String, + anticipation_board: String, +} +impl ShoveOperation { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + if a.text("name") != Some("Shove") { + return Ok(None); + } + //The required lookups are virtual8; virtual4 supplies empty defaults + //only for the two board-specific alternatives. + Ok(Some(Self { + selection: a + .text("Selection") + .ok_or("Shove requires Selection")? + .into(), + selection_board: a.text("SelectionBrd").unwrap_or("").into(), + anticipation: a.text("Antic").ok_or("Shove requires Antic")?.into(), + anticipation_board: a.text("AnticBrd").unwrap_or("").into(), + })) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ShovePhysical { + ///PhysOutInteraction (bundle52)+56; ProcessOutput82DB7E38 owns this. + pub interaction_trigger: bool, + ///PhysOutGround240, the interaction direction published by that producer. + pub direction: [f32; 4], + ///PhysOutState75 and PhysOutOffboard311, respectively. + pub in_biped_category: bool, + pub board_on_ground: bool, + ///PhysOutAnimation72, used only while the shove channel is active. + pub animation_height: f32, +} + +#[derive(Clone, Debug)] +pub struct ShoveState { + pub angle: f32, + ///Native instance12 records the anticipation selection. Update does not + ///use this cache to replace the newly observed board state. + pub board_anticipation: bool, +} +impl ShoveState { + pub fn new() -> Self { + Self { + angle: 0.0, + board_anticipation: false, + } + } + pub fn begin(&mut self) { + //82BBCDD0 clears both fields; this does not terminate any channel. + self.angle = 0.0; + self.board_anticipation = false; + } + pub fn update( + &mut self, + operation: &ShoveOperation, + animation: &mut MotionAnimation, + physical: ShovePhysical, + busy_hands: [u32; 2], + mirrored: bool, + ) -> Result<(), String> { + let retrieving = animation.channels.has("RetrieveBoard"); + let board_variant = physical.in_biped_category && physical.board_on_ground; + if animation.motion_intent("GrabWorld").is_some() && !retrieving && busy_hands == [0, 0] { + if !animation.channels.has("SkitchAntic") && !animation.channels.has("Shove") { + self.board_anticipation = board_variant; + start_channel( + animation, + "SkitchAntic", + if board_variant { + &operation.anticipation_board + } else { + &operation.anticipation + }, + true, + )?; + } + } else if animation.channels.has("SkitchAntic") { + animation.channels.end("SkitchAntic"); + } + + let mut shove = animation.channels.has("Shove"); + if !physical.interaction_trigger || retrieving { + if shove { + if retrieving { + animation.channels.end("Shove"); + } else { + set(animation, b"ShoveDirection", self.angle); + } + } + return Ok(()); + } + self.angle = direction_angle(physical.direction, mirrored); + if animation.channels.has("SkitchAntic") { + animation.channels.end("SkitchAntic"); + } + if !shove { + start_channel( + animation, + "Shove", + if board_variant { + &operation.selection_board + } else { + &operation.selection + }, + false, + )?; + //Original marks this true after the call, rather than querying + //the channel manager a second time during the same update. + shove = true; + } + if shove { + set(animation, b"GstrDistToCog", physical.animation_height); + set(animation, b"ShoveDirection", self.angle); + } + Ok(()) + } + pub fn end(&mut self, animation: &mut MotionAnimation, keep_channels: bool) { + //SpecificMotionGraph virtual212=8258FC30, bit18. Native reset clears it. + //Virtual44 is EndChannelPrematurely82D1D6A8, preserving fade timing. + if !keep_channels { + for name in ["SkitchAntic", "Shove"] { + if animation.channels.has(name) { + animation.channels.end(name); + } + } + } + } +} +fn set(animation: &mut MotionAnimation, name: &[u8], value: f32) { + animation.set_attribute(SettableAttribute { + name: encode(name), + value, + normalized: false, + sequence_id: -1, + }); +} +fn start_channel( + animation: &mut MotionAnimation, + channel: &str, + tree: &str, + anticipation: bool, +) -> Result<(), String> { + let seconds = f32::from_bits(if anticipation { 0x3e4ccccd } else { 0x3dcccccd }); + let settings = ChannelSettings { + priority: 0, + keep_alive: anticipation, + mirrored: false, + speed: 1.0, + blend_in: seconds, + hold_during_blend_in: false, + blend_out: seconds, + hold_during_blend_out: false, + use_attributes: false, + }; + //Vtable16 is TransitionTo82D1D090. Stack booleans at87/95/103 are1: + //allow resurrection, create missing and use channel weights. + let transition = TransitionSettings { + kind: 2, + seconds: f32::from_bits(0x3dcccccd), + under: 0, + matching: 0, + use_channels_from_weights: true, + }; + animation.transition_channel(channel, tree, settings, transition, true, true)?; + Ok(()) +} +///82BBD250..D390: original one-refinement reciprocal followed by82473B98. +///Unlike left-stick angle, zero direction has no extra zero-vector guard. +fn direction_angle(direction: [f32; 4], mirrored: bool) -> f32 { + let x = direction[0]; + let z = if mirrored { + -direction[2] + } else { + direction[2] + }; + let reciprocal = z.recip(); + let reciprocal = reciprocal.mul_add((-reciprocal).mul_add(z, 1.0), reciprocal); + let basic = skate_core::input::angle::atan(x.mul_add(reciprocal, 0.0)); + let sign = x.to_bits() & 0x80000000; + let angle = if z < 0.0 { + basic + f32::from_bits(0x40490fdb | sign) + } else { + basic + }; + let angle = if z == 0.0 { + f32::from_bits(0x3fc90fdb | sign) + } else { + angle + }; + let degrees = angle * f32::from_bits(0x42652ee1); + if degrees < 0.0 { + degrees + f32::from_bits(0x43b40000) + } else { + degrees + } +} diff --git a/crates/skate-game/src/graph_host/motion_sliding.rs b/crates/skate-game/src/graph_host/motion_sliding.rs new file mode 100644 index 00000000..e3d02df4 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_sliding.rs @@ -0,0 +1,251 @@ +//! PowerSliding Update82BB28E8, instance82BB2D70 and helper82595930. +use super::motion_animation::MotionAnimation; +use skate_core::input::set_turning::SlideLatch; +use skate_core::{animation::playback_parameters::ParameterInputs, point_graph::PointGraph}; +use skate_data::{collections::Collections, state_graph::attributes::Attributes}; + +pub struct Settings { + speed_threshold: f32, + well_into_slide: f32, + speed_factor: PointGraph<4>, + time_threshold: PointGraph<4>, + threshold: PointGraph<4>, + speed_to_lean: PointGraph<8>, + phys_to_anim_spin: PointGraph<8>, + smooth: f32, + minimum_entry: f32, + turn: f32, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let float = |name| data.float("anim_motion", "power_slide", name); + let curve = |name| -> Result, String> { + let w = data.words::<12>("anim_motion", "power_slide", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(w[8 + i])), + }) + }; + let curve8 = |name| -> Result, String> { + let w = data.words::<20>("anim_motion", "power_slide", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(w[12 + i])), + }) + }; + Ok(Self { + speed_threshold: float("slide_speed_threshold")?, + well_into_slide: float("well_into_slide_time")?, + speed_factor: curve("slide_speed_factor")?, + time_threshold: curve("not_sliding_time_thresh")?, + threshold: curve("not_sliding_threshold")?, + // Native layout0,80,1688,1692,1680 respectively. + speed_to_lean: curve8("slide_speed_to_lean")?, + phys_to_anim_spin: curve8("slide_phys_to_anim_spin")?, + smooth: float("slide_smooth")?, + minimum_entry: float("slide_min_entry")?, + turn: float("slide_turn_value")?, + }) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Operation { + Update, + Create { right: bool }, + ManualAttribute, + Deceleration, + Spin, + Candidate, + IsPowerSliding, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "PowerSliding" => Self::Update, + // Factory82BC9100 explicitly defaults authored right to true. + "CreateSlide" => Self::Create { + right: a.boolean_byte("right", 1) != 0, + }, + "PowerSlideManualAtt" => Self::ManualAttribute, + "PowerSlideDecel" => Self::Deceleration, + "PowerSlideSpin" => Self::Spin, + "InCandidateSlidingState" => Self::Candidate, + "IsPowerSliding" => Self::IsPowerSliding, + _ => return None, + }) + } +} +///Native instance8..20; the three high flags initialize clear. +#[derive(Default)] +pub struct State { + not_sliding_time: f32, + previous_right: f32, + previous_left: f32, + was_ground: bool, + was_right_start: bool, + was_left_start: bool, +} +impl State { + pub fn update( + &mut self, + animation: &MotionAnimation, + category: u32, + speed: f32, + direction: f32, + dt: f32, + settings: &Settings, + output: &mut SlideLatch, + ) { + let right = animation.motion_intent("RightSlide"); + let left = animation.motion_intent("LeftSlide"); + let right_value = right.unwrap_or(0.0); + let left_value = left.unwrap_or(0.0); + let ground = category == 1; + if ground && !self.was_ground { + self.not_sliding_time = 0.0; + self.previous_right = right_value; + self.previous_left = left_value; + self.was_right_start = false; + self.was_left_start = false; + } + self.was_ground = ground; + if !ground { + return; + } + let fast = speed > settings.speed_threshold; + let right_start = animation.motion_intent("RightSlideStart").is_some(); + output.set_start( + true, + fast && right_start && !self.was_right_start && self.previous_right < right_value, + ); + self.previous_right = right_value; + self.was_right_start = right_start; + let left_start = animation.motion_intent("LeftSlideStart").is_some(); + output.set_start( + false, + fast && left_start && !self.was_left_start && self.previous_left > left_value, + ); + self.previous_left = left_value; + self.was_left_start = left_start; + let limit = settings.time_threshold.evaluate(direction); + let sliding = settings.speed_factor.evaluate(speed) * (1.0 - direction.abs()); + self.not_sliding_time = if sliding >= settings.threshold.evaluate(direction) { + 0.0 + } else { + self.not_sliding_time + dt + }; + let stop = self.not_sliding_time > limit; + output.advance_elapsed(true, dt); + output.advance_elapsed(false, dt); + output.set_end( + true, + output.elapsed(true) > settings.well_into_slide && (right.is_none() || !fast || stop), + ); + output.set_end( + false, + output.elapsed(false) > settings.well_into_slide && (left.is_none() || !fast || stop), + ); + } +} + +///82595930 normalizes Motion80 with two root and reciprocal refinements, +/// then dots the authored Reckoning Z, reversing for Motion273. +pub fn direction(velocity: [f32; 4], mut z: [f32; 4], flipped: bool) -> f32 { + use skate_core::riding::ground_correction_math::dot_product; + let squared = dot_product(velocity, velocity); + //825959D4 ble also takes the unordered branch after fcmpu. + if !(squared > f32::from_bits(0x3a83126f)) { + return 1.0; + } + if flipped { + z = z.map(|v| -v); + } + let mut root = 1.0 / squared.sqrt(); + for _ in 0..2 { + let error = (-squared).mul_add(root * root, 1.0); + root = (root * 0.5).mul_add(error, root); + } + let length = squared * root; + let mut inverse = 1.0 / length; + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse); + } + dot_product(velocity.map(|v| v * inverse), z) +} + +/// CreateSlide Update82BB2F60, ordered packet values (Slide, Turn). +pub fn create( + latch: &SlideLatch, + authored_right: bool, + right_intent: Option, + left_intent: Option, + settings: &Settings, +) -> [f32; 2] { + let right = authored_right ^ latch.captured_fakie(); + let mut slide = if right { right_intent } else { left_intent }.unwrap_or(0.0); + if !(latch.elapsed(right) > settings.well_into_slide) { + slide = if right { + if settings.minimum_entry - slide >= 0.0 { + settings.minimum_entry + } else { + slide + } + } else { + let minimum = -settings.minimum_entry; + if minimum - slide >= 0.0 { + slide + } else { + minimum + } + }; + } + let mut turn = if right { settings.turn } else { -settings.turn }; + if latch.captured_fakie() { + turn = -turn; + let negative = -slide; + let absolute = negative.abs(); + let wrapped = if absolute > 0.5 { + 1.0 - absolute + } else { + absolute + }; + slide = if negative >= 0.0 { wrapped } else { -wrapped }; + } + [slide, turn] +} + +/// PowerSlideDecel82BB31B0: inverse Ground basis rotates velocity without +/// translation. Source zeros local Y before measuring the horizontal speed. +pub fn deceleration( + previous: &mut f32, + velocity: [f32; 4], + ground: [[f32; 4]; 4], + settings: &Settings, +) -> f32 { + let mut local = std::array::from_fn::<_, 4, _>(|lane| { + let x = ground[lane][0] * velocity[0]; + let y = ground[lane][1].mul_add(velocity[1], x); + ground[lane][2].mul_add(velocity[2], y) + }); + local[1] = 0.0; + let squared = skate_core::riding::ground_correction_math::dot_product(local, local); + let length = if squared == 0.0 { + 0.0 + } else { + let mut inverse = 1.0 / squared.sqrt(); + for _ in 0..2 { + let error = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(error, inverse); + } + squared * inverse + }; + let target = settings.speed_to_lean.evaluate(length); + let retained = (1.0 - settings.smooth) * *previous; + *previous = target.mul_add(settings.smooth, retained); + *previous +} + +/// PowerSlideSpin Begin82BB33C8; the input is GetSlideDirection82595930. +pub fn spin(direction: f32, settings: &Settings) -> f32 { + -settings.phys_to_anim_spin.evaluate(direction) +} diff --git a/crates/skate-game/src/graph_host/motion_sliding_execute.rs b/crates/skate-game/src/graph_host/motion_sliding_execute.rs new file mode 100644 index 00000000..a2be4c52 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_sliding_execute.rs @@ -0,0 +1,139 @@ +//! Production lifecycle for the authored slide family; no detached state copies. +use super::*; +use crate::graph_host::motion_sliding::{self, Operation}; + +impl MotionHost { + pub(super) fn execute_slide( + &mut self, + behavior: BehaviorId, + operation: Operation, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + match operation { + Operation::Update if phase == 1 => { + let category = self + .condition_inputs + .physical_state + .as_ref() + .ok_or("PowerSliding requires actual filtered physical category")? + .category; + // Native returns before speed/direction reads outside category1. + let (speed, direction) = if category == 1 { + let speed = self + .fakie_physical + .ok_or("PowerSliding requires actual Motion164 speed")? + .ground_projected_speed; + (speed, self.slide_direction()?) + } else { + (0.0, 0.0) + }; + let Instance::Sliding(state) = &mut self.instances[behavior] else { + return Err("PowerSliding has an invalid instance".into()); + }; + state.update( + &self.animation, + category, + speed, + direction, + frame.dt, + &self.sliding, + &mut self.slide_latch, + ); + } + Operation::Create { right } => match phase { + 0 => { + //ISkaterAnim virtual12=82B970D8 reads the live bit29. + let fakie = self + .animation + .skater_animation_flags + .ok_or("CreateSlide requires actual animation stance flags")? + & 0x2000_0000 + != 0; + self.slide_latch.begin_slide(fakie); + } + 1 => { + let values = motion_sliding::create( + &self.slide_latch, + right, + self.animation.motion_intent("RightSlide"), + self.animation.motion_intent("LeftSlide"), + &self.sliding, + ); + self.animation.emit_packet(encode(b"slide"), values[0]); + self.animation.emit_packet(encode(b"turn"), values[1]); + } + _ => {} + }, + Operation::ManualAttribute if phase == 1 => { + //82BB34D8 uses Manual830BE900; only a negative present value emits. + if let Some(value) = self.animation.motion_intent("Manual") + && value < 0.0 + { + self.animation.emit_packet(encode(b"balance"), value); + } + } + Operation::Deceleration => { + let Instance::SlideDeceleration(previous) = &mut self.instances[behavior] else { + return Err("PowerSlideDecel has an invalid instance".into()); + }; + if phase == 0 { + //Begin82BB3198; same zero in native instance constructor. + *previous = 0.0; + } else if phase == 1 { + let velocity = self + .fakie_physical + .ok_or("PowerSlideDecel requires actual Motion80 velocity")? + .deck_velocity; + let ground = self + .native_physical + .ok_or("PowerSlideDecel requires actual Reckoning Ground frame")? + .board_reckoning; + let value = + motion_sliding::deceleration(previous, velocity, ground, &self.sliding); + self.animation.set_attribute(SettableAttribute { + name: encode(b"decel"), + value, + normalized: false, + sequence_id: -1, + }); + } + } + Operation::Spin if phase == 0 => { + let value = motion_sliding::spin(self.slide_direction()?, &self.sliding); + self.animation.set_attribute(SettableAttribute { + name: encode(b"slidespin"), + value, + normalized: false, + sequence_id: -1, + }); + } + Operation::Candidate if phase != 1 => { + self.slide_latch.set_candidate_enabled(phase == 0); + } + Operation::IsPowerSliding if phase != 1 => { + //Begin82BACD20/End82BACD70 -> specific setter8258F910. + self.is_power_sliding = phase == 0; + } + _ => {} + } + Ok(()) + } + + fn slide_direction(&self) -> Result { + let velocity = self + .fakie_physical + .ok_or("Slide direction requires actual Motion80 velocity")? + .deck_velocity; + let flipped = self + .physical + .and_then(|p| p.foot_frame) + .ok_or("Slide direction requires actual Motion273 orientation flag")? + .skateboard_flipped; + let z = self + .native_physical + .ok_or("Slide direction requires actual Reckoning Ground Z")? + .board_reckoning_z; + Ok(motion_sliding::direction(velocity, z, flipped)) + } +} diff --git a/crates/skate-game/src/graph_host/motion_spin.rs b/crates/skate-game/src/graph_host/motion_spin.rs new file mode 100644 index 00000000..d0bb935a --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_spin.rs @@ -0,0 +1,297 @@ +//! BodySpin82BAB640, including its native five-state channel controller. +use super::motion_animation::MotionAnimation; +use skate_core::{ + animation::{channel_playback::ChannelSettings, playback_parameters::ParameterInputs}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +const FRONT: &str = "IA_BODYSPIN_OLLIE_FS_0_N"; +const BACK: &str = "IA_BODYSPIN_OLLIE_BS_0_N"; + +pub struct Settings { + map: PointGraph<8>, + blend_out: f32, + blend_in: f32, + maximum_acceleration: f32, + maximum_delta: f32, + final_height: f32, + height_velocity: f32, + on_deck_height: f32, + override_x: f32, + override_velocity_y: f32, + landing_distance: PointGraph<8>, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + //Global304 writer8289F9C0..F9EC:DCBADE97CA665643=body_spin. + let float = |name| data.float("anim_motion", "body_spin", name); + let w = data.words::<16>("anim_motion", "body_spin", "spin_map")?; + let d = data.words::<16>("animation", "default", "LandingDistanceScalarVsNormalY")?; + Ok(Self { + map: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i])), + y: std::array::from_fn(|i| f32::from_bits(w[8 + i])), + }, + blend_out: float("spin_influence_blendout")?, + blend_in: float("spin_influence_blendin")?, + maximum_acceleration: float("spin_clamp_influence_deltadelta")?, + maximum_delta: float("spin_clamp_influence_delta")?, + //Global336=anim_motion/inair_disttocog;328=body_tilt. + final_height: data.float( + "anim_motion", + "inair_disttocog", + "preland_final_disttocom", + )?, + height_velocity: data.float( + "anim_motion", + "inair_disttocog", + "preland_disttocom_vel", + )?, + on_deck_height: data.float( + "anim_motion", + "inair_disttocog", + "landingondeck_disttocog", + )?, + override_x: data.float("anim_motion", "body_tilt", "overide_preland_x")?, + override_velocity_y: data.float("anim_motion", "body_tilt", "overide_preland_velY")?, + landing_distance: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(d[i])), + y: std::array::from_fn(|i| f32::from_bits(d[8 + i])), + }, + }) + } +} + +///Actual publication fields used only by the native airborne prelanding query. +#[derive(Clone, Copy, Debug)] +pub struct PrelandingPhysical { + pub air_444: bool, + pub air_normal_144_y: f32, + pub animation_16_x: f32, + pub com_velocity_y: f32, + pub offboard_316: bool, + pub offboard_319: bool, + pub offboard_time_32: f32, + pub air_437: bool, + pub air_normal_36: f32, + pub air_remaining_184: f32, + pub animation_height_72: f32, +} +impl PrelandingPhysical { + ///825954C0 ->82595670, original source guards and strict comparisons. + pub(crate) fn override_prelanding(self, s: &Settings) -> bool { + self.air_444 + || (self.air_normal_144_y <= 0.5 + && self.animation_16_x.abs() > s.override_x + && self.com_velocity_y > s.override_velocity_y) + } + pub(crate) fn near_landing(self, s: &Settings) -> bool { + if self.override_prelanding(s) || self.com_velocity_y >= 0.0 { + return false; + } + if self.offboard_316 { + return self.offboard_time_32 < (s.final_height - s.on_deck_height) / s.height_velocity; + } + if self.air_437 { + let mut time = (s.final_height - self.animation_height_72) / s.height_velocity; + time *= s + .landing_distance + .evaluate(clamp(self.air_normal_36, 0.0, 1.0)); + return self.air_remaining_184 < time; + } + self.offboard_319 && self.offboard_time_32 < f32::from_bits(0x3f99999a) + } +} + +///82BABE20 initializes every represented scalar and the state enum to zero. +#[derive(Default)] +pub struct State { + mode: u32, + previous_spin: f32, + back: f32, + back_delta: f32, + front: f32, + front_delta: f32, +} +impl State { + #[allow(clippy::too_many_arguments)] + pub fn update( + &mut self, + animation: &mut MotionAnimation, + category: u32, + physical_state: u32, + doing_trick: bool, + busy_hands: [u32; 2], + mirrored: bool, + prelanding: Option, + settings: &Settings, + ) -> Result<(), String> { + let biped_air = physical_state == 503; + let air = category == 2 || biped_air; + let active = air || doing_trick; + let busy = busy_hands[0] != 0 || busy_hands[1] != 0; + let mut spin = animation.motion_intent("BodySpin").unwrap_or(0.0); + if mirrored { + spin = -spin; + } + let mapped = settings.map.evaluate(spin.abs()); + let influence = if spin >= 0.0 { mapped } else { -mapped }; + let near = if air { + prelanding + .ok_or("BodySpin airborne branch requires actual prelanding physical outputs")? + .near_landing(settings) + } else { + false + }; + let mut mode = self.mode; + match mode { + 0 => { + if category == 3 { + mode = 4; + } + if active { + mode = if busy { 4 } else { 1 }; + } + } + 1 => { + if !active { + mode = 0; + } + if busy { + mode = 4; + } + if spin.abs() > f32::from_bits(0x3e99999a) { + mode = 2; + } + } + 2 => { + if !active { + mode = 0; + } + if busy { + mode = 4; + } + if air { + mode = 3; + } + } + 3 => { + if busy || (near && biped_air) { + mode = 4; + } + if !air { + mode = 0; + } + } + 4 => { + if !active && category == 1 { + mode = 0; + } + } + _ => {} + } + match mode { + 0 | 4 => { + for name in [FRONT, BACK] { + animation + .channels + .end_with(name, f32::from_bits(0x3e4ccccd), false); + } + } + 1 => { + self.front = 0.0; + self.front_delta = 0.0; + self.back = 0.0; + self.back_delta = 0.0; + } + 2 | 3 => { + if mode == 2 { + let channel = ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: f32::from_bits(0x3e4ccccd), + hold_during_blend_in: false, + blend_out: f32::from_bits(0x3e99999a), + hold_during_blend_out: false, + use_attributes: false, + }; + if spin > f32::from_bits(0x3e99999a) { + if !animation.channels.has(FRONT) && !near { + animation.new_channel(FRONT, FRONT, channel)?; + } + animation + .channels + .end_with(BACK, f32::from_bits(0x3e19999a), false); + } + if spin < f32::from_bits(0xbe99999a) { + if !animation.channels.has(BACK) && !near { + animation.new_channel(BACK, BACK, channel)?; + } + animation + .channels + .end_with(FRONT, f32::from_bits(0x3e19999a), false); + } + } + if animation.channels.has(FRONT) { + let target = clamp(influence, 0.0, 1.0); + let blend = if target < self.front { + settings.blend_out + } else { + settings.blend_in + }; + update_influence( + &mut self.front, + &mut self.front_delta, + target, + blend, + settings, + ); + animation.channels.influence(FRONT, self.front); + } + if animation.channels.has(BACK) { + let target = -clamp(influence, -1.0, 0.0); + //The original backside branch intentionally reverses this comparison. + let blend = if target > self.back { + settings.blend_out + } else { + settings.blend_in + }; + update_influence( + &mut self.back, + &mut self.back_delta, + target, + blend, + settings, + ); + animation.channels.influence(BACK, self.back); + } + } + _ => {} + } + self.mode = mode; + self.previous_spin = spin; + Ok(()) + } +} +fn update_influence(value: &mut f32, delta: &mut f32, target: f32, blend: f32, s: &Settings) { + //82BABC68..BD9C: first clamp acceleration, then change, then [0,1]. + let desired = blend.mul_add(target, (1.0 - blend) * *value) - *value; + *delta = clamp( + desired, + *delta - s.maximum_acceleration, + *delta + s.maximum_acceleration, + ); + let next = clamp( + *value + *delta, + *value - s.maximum_delta, + *value + s.maximum_delta, + ); + *value = clamp(next, 0.0, 1.0); +} +fn clamp(v: f32, low: f32, high: f32) -> f32 { + let v = if low - v >= 0.0 { low } else { v }; + if high - v >= 0.0 { v } else { high } +} diff --git a/crates/skate-game/src/graph_host/motion_wipeout.rs b/crates/skate-game/src/graph_host/motion_wipeout.rs new file mode 100644 index 00000000..9138c3d7 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_wipeout.rs @@ -0,0 +1,124 @@ +//! Stock Wipeout graph leaves, using completed physical output. +use skate_core::graph::conditions::NumericCondition; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Operation { + Wipeout, + EnableGestures, +} + +/// Live MG CA4 bits20/19 and gesture outputs CEC/CF0. +#[derive(Default)] +pub struct Controls { + pub seed_from_air_tweak: bool, + pub gestures_enabled: bool, + pub gesture: [f32; 2], +} + +/// Wipeout instance82BC0690: counter8, angles12/16, velocity24, axes28/32, latch36. +#[derive(Default)] +pub struct State { + pub(super) ticks: u32, + pub(super) lean: f32, + pub(super) twist: f32, + pub(super) twist_velocity: f32, + pub(super) gesture: [f32; 2], + pub(super) released: bool, +} + +pub struct Settings { + pub(super) threshold: f32, + pub(super) twist_velocity: f32, + pub(super) twist_acceleration: f32, + pub(super) lean_velocity: f32, + pub(super) twist_blend: f32, + pub(super) lean_blend: f32, + pub(super) gesture_y_blend: f32, + pub(super) gesture_x_blend: f32, +} +impl Settings { + pub fn load(data: &skate_data::collections::Collections) -> Result { + //82BC0358/051C and stock anim_wipeout schema offsets0..28. + let f = |name| data.float("anim_wipeout", "default", name); + Ok(Self { + threshold: f("controlled_drives_thresh")?, + twist_velocity: f("clamp_twist_vel")?, + twist_acceleration: f("clamp_twist_acc")?, + lean_velocity: f("clamp_lean_vel")?, + twist_blend: f("blend_twist")?, + lean_blend: f("blend_lean")?, + gesture_y_blend: f("blend_gesture_y")?, + gesture_x_blend: f("blend_gesture_x")?, + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub over_599: bool, + pub collision_time_144: f32, + pub no_support_time_548: f32, + pub profile_148: u32, + pub below_surface_82: bool, + /// Skeleton output+80 lane Y; required only while below the surface. + pub orientation_y: Option, + pub hips_right_angle_496: f32, + pub hips_up_angle_500: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Condition { + Done, + TimeToLand(NumericCondition), + TimeSinceContact(NumericCondition), + GestureType(u32), + InWater(u32), +} +impl Condition { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + Ok(Some(match a.text("name").unwrap_or("") { + "IsDoneWipingOut" => Self::Done, + "WipeoutTimeToLand" => Self::TimeToLand(super::condition_nodes::numeric(a)), + "WipeoutTimeSinceContact" => Self::TimeSinceContact(super::condition_nodes::numeric(a)), + //82BA82C0: exact authored names; invalid strings leave native storage undefined. + "GestureType" => Self::GestureType(match a.text("gesture") { + Some("Freefall") => 0, + Some("CannonBall") => 1, + Some("JudoKick") => 2, + Some("SwanDive") => 3, + Some("Torpedo") => 4, + other => return Err(format!("Invalid Wipeout gesture type {other:?}")), + }), + //82BCAAF8: unrecognized orientation has no native initialized value. + "IsInWater" => Self::InWater(match a.text("orientation") { + Some("onback") => 0, + Some("onfront") => 1, + Some("either") => 2, + other => return Err(format!("Invalid water orientation {other:?}")), + }), + _ => return Ok(None), + })) + } + + pub fn evaluate(self, physical: Option) -> Result { + let p = physical.ok_or("Wipeout condition requires completed physical output")?; + Ok(match self { + Self::Done => p.over_599, //82BC0AB8: skeleton599. + Self::TimeToLand(n) => n.matches(p.collision_time_144), //82BC0B28. + Self::TimeSinceContact(n) => n.matches(p.no_support_time_548), //82BC0BB0. + Self::GestureType(kind) => p.profile_148 == kind, //82BA8468. + Self::InWater(orientation) => { + //82BBBE90 returns before the vector read when not submerged. + if !p.below_surface_82 { + false + } else { + let y = p + .orientation_y + .ok_or("IsInWater requires skeleton output80.Y")?; + u32::from(!(y > 0.0)) == orientation || orientation == 2 + } + } + }) + } +} diff --git a/crates/skate-game/src/graph_host/motion_wipeout_execute.rs b/crates/skate-game/src/graph_host/motion_wipeout_execute.rs new file mode 100644 index 00000000..623c79ca --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_wipeout_execute.rs @@ -0,0 +1,194 @@ +//! Original82BBFF88/82BC01C0; graph ownership is ours, filter order is stock. +use super::super::motion_wipeout::{Operation, Settings}; +use super::*; + +fn bounded(value: f32, limit: f32) -> f32 { + //Original fsel comparisons, preserving unordered and signed-zero behavior. + let value = if -limit - value >= 0.0 { -limit } else { value }; + if limit - value >= 0.0 { value } else { limit } +} +fn positive_turn(value: f32) -> f32 { + if value >= 0.0 { + value + } else { + value + f32::from_bits(0x40C9_0FDB) + } +} +impl MotionHost { + pub(super) fn execute_wipeout( + &mut self, + behavior: BehaviorId, + operation: Operation, + phase: u8, + ) -> Result<(), String> { + if operation == Operation::EnableGestures { + //82BC07A8/07F8, Update82B61BB8 is empty. + if phase != 1 { + self.wipeout_controls.gestures_enabled = phase == 0; + } + return Ok(()); + } + if phase > 1 { + return Ok(()); + } //Original End82B61BB8. + let Instance::Wipeout(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated Wipeout behavior")? + else { + return Err("Wipeout operation/instance mismatch".into()); + }; + let physical = self + .wipeout_physical + .ok_or("Wipeout requires completed physical outputs")?; + if phase == 0 { + let mirrored = self + .animation + .skater_animation_flags + .ok_or("Wipeout requires animation stance")? + & 0x4000_0000 + != 0; + state.ticks = 0; + state.lean = physical.hips_right_angle_496; + let twist = if mirrored { + -physical.hips_up_angle_500 + } else { + physical.hips_up_angle_500 + }; + state.twist = positive_turn(twist); + state.twist_velocity = 0.0; + state.released = self.wipeout_controls.seed_from_air_tweak; + state.gesture = if state.released { + self.wipeout_controls.seed_from_air_tweak = false; + [ + *self.action_intents.get("OB_AirBodyTweakX").unwrap_or(&0.0), + *self.action_intents.get("OB_AirBodyTweakY").unwrap_or(&0.0), + ] + } else { + [0.0; 2] + }; + return Ok(()); + } + if self.action_intents.contains_key("WipeOutRequest") || (state.ticks as i32) > 80 { + state.released = true; + } + if self.wipeout_controls.gestures_enabled { + let mirrored = self + .animation + .skater_animation_flags + .ok_or("Wipeout requires animation stance")? + & 0x4000_0000 + != 0; + let Settings { + threshold, + twist_velocity, + twist_acceleration, + lean_velocity, + twist_blend, + lean_blend, + gesture_y_blend, + gesture_x_blend, + } = self.wipeout_settings; + let delta = ((1.0 - lean_blend) * state.lean + + physical.hips_right_angle_496 * lean_blend) + - state.lean; + state.lean += bounded(delta, lean_velocity); + set(&mut self.animation, b"Lean", state.lean); + let twist = positive_turn(if mirrored { + -physical.hips_up_angle_500 + } else { + physical.hips_up_angle_500 + }); + let mut delta = twist - state.twist; + let pi = f32::from_bits(0x4049_0FDB); + let turn = f32::from_bits(0x40C9_0FDB); + if delta > pi { + delta -= turn; + } else if delta < -pi { + delta += turn; + } + let delta = ((state.twist + delta) * twist_blend + (1.0 - twist_blend) * state.twist) + - state.twist; + let mut velocity = bounded(delta, twist_velocity); + if state.ticks >= 2 { + velocity = bounded(velocity - state.twist_velocity, twist_acceleration) + + state.twist_velocity; + } + let mut twist = state.twist + velocity; + if twist > turn { + twist -= turn; + } + state.twist_velocity = velocity; + state.twist = positive_turn(twist); + set(&mut self.animation, b"Twist", state.twist); + let x = self.action_intents.get("WipeoutGestureX").copied(); + let y = self.action_intents.get("WipeoutGestureY").copied(); + let xv = x.unwrap_or(0.0); + let yv = y.unwrap_or(0.0); + if !state.released && x.is_some() && y.is_some() && xv.abs() < 0.1 && yv.abs() < 0.1 { + state.released = true; + } + if state.released { + state.gesture[0] = + (1.0 - gesture_x_blend) * state.gesture[0] + xv * gesture_x_blend; + state.gesture[1] = + (1.0 - gesture_y_blend) * state.gesture[1] + yv * gesture_y_blend; + } + if state.gesture[1] * state.gesture[1] + state.gesture[0] * state.gesture[0] + > threshold * threshold + { + self.animation + .emit_packet(encode(b"ControlledWipeout"), 1.0); + } + self.wipeout_controls.gesture = state.gesture; //MGv2528258FB50. + } + set(&mut self.animation, b"WipeoutGestureX", state.gesture[0]); + set(&mut self.animation, b"WipeoutGestureY", state.gesture[1]); + state.ticks = state.ticks.wrapping_add(1); + Ok(()) + } +} + +fn set(animation: &mut MotionAnimation, name: &[u8], value: f32) { + animation.set_attribute(SettableAttribute { + name: encode(name), + value, + normalized: false, + sequence_id: -1, + }); +} + +impl MotionHost { + pub(super) fn execute_twist_lean( + &mut self, + behavior: BehaviorId, + operation: super::super::motion_twist_lean::Operation, + phase: u8, + ) -> Result<(), String> { + let Instance::TwistLean(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated MatchTwistAndLean behavior")? + else { + return Err("MatchTwistAndLean operation/instance mismatch".into()); + }; + let observation = if phase == 0 || (phase == 1 && operation.always) { + let p = self + .wipeout_physical + .ok_or("MatchTwistAndLean requires canonical physical angles")?; + let flags = self + .animation + .skater_animation_flags + .ok_or("MatchTwistAndLean requires live animation stance")?; + Some(skate_core::player::offboard::twist_lean::Observation { + hips_right_angle: p.hips_right_angle_496, + hips_up_angle: p.hips_up_angle_500, + mirrored: flags & 0x4000_0000 != 0, + }) + } else { + None + }; + operation.execute(state, observation, Some(&mut self.animation), phase); + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/pumping_settings.rs b/crates/skate-game/src/graph_host/pumping_settings.rs new file mode 100644 index 00000000..42916218 --- /dev/null +++ b/crates/skate-game/src/graph_host/pumping_settings.rs @@ -0,0 +1,8 @@ +//! Stock anim_motion/anim_pump; TU3 global308 layout offsets1152/1708..1784. +use skate_core::{animation::pumping_channel::Settings,point_graph::PointGraph}; +use skate_data::collections::Collections; +pub fn load(data:&Collections)->Result { + let f=|name|data.float("anim_motion","anim_pump",name); + let w=data.words::<16>("anim_motion","anim_pump","pump_amplify")?; + Ok(Settings {amplify:PointGraph {x:std::array::from_fn(|i|f32::from_bits(w[i])),y:std::array::from_fn(|i|f32::from_bits(w[8+i]))},input_blend:f("pump_prop_blend")?,maximum_physics_pump:f("max_phys_pump")?,new_pump_threshold:f("new_pump_thresh")?,blend_in:f("pump_blendin")?,blend_out:f("pump_blendout")?}) +} diff --git a/crates/skate-game/src/graph_host/pushing.rs b/crates/skate-game/src/graph_host/pushing.rs new file mode 100644 index 00000000..c8ee8425 --- /dev/null +++ b/crates/skate-game/src/graph_host/pushing.rs @@ -0,0 +1,234 @@ +//! MotionGraph pushing operations. The stock graph owns activation and order; +//! these handlers only mutate native push properties and animation attributes. +use super::pushing_settings::PushingSettings; +use skate_core::graph::intents::IntentMap; +use skate_core::riding::push_behaviors::{ + FirstPushStrength, PushCycle, PushFootFrame, PushIntents, PushState, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PushOperation { + InitPush, + ComputeFirstPushStrength, + ComputeTargetCoefsFromSpeedAndStrength { regular_attributes: bool }, + ComputeRepushDeadline { regular_attributes: bool }, + SetPushCoefs { on_first_update_only: bool }, + PushCycle { new_push_name: String }, + PushOut { is_right_foot: bool }, +} +impl PushOperation { + /// Defaults verified in TU3 factories82BC7ED0/7F98/80B8/8180/8248. + /// None means this is another operation, for the enclosing host to resolve. + pub fn from_attributes(attributes: &Attributes<'_>) -> Option { + Some(match attributes.text("name")? { + "InitPush" => Self::InitPush, + "ComputeFirstPushStrength" => Self::ComputeFirstPushStrength, + "ComputeTargetCoefsFromSpeedAndStrength" => { + Self::ComputeTargetCoefsFromSpeedAndStrength { + regular_attributes: attributes.boolean_byte("regularAttributes", 1) != 0, + } + } + "ComputeRepushDeadline" => Self::ComputeRepushDeadline { + regular_attributes: attributes.boolean_byte("regularAttributes", 1) != 0, + }, + "SetPushCoefs" => Self::SetPushCoefs { + on_first_update_only: attributes.boolean_byte("onFirstUpdateOnly", 0) != 0, + }, + "PushCycle" => Self::PushCycle { + new_push_name: attributes + .text("newPushName") + .unwrap_or("LeftPush") + .to_owned(), + }, + "PushOut" => Self::PushOut { + is_right_foot: attributes.boolean_byte("isRightFoot", 1) != 0, + }, + _ => return None, + }) + } +} + +/// The scalar SettableAtt values used here are untimed (begin/end=-1), +/// kind0/status6, as published by82D19100. `normalized` is the control flag +/// consumed by the animation tree, not a request to clamp the stored scalar. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SettableAttribute { + pub name: &'static str, + pub value: f32, + pub normalized: bool, + pub sequence_id: i32, +} + +pub trait PushAnimationSink { + /// Apply in call order to the active animation controller. Upsert the first + /// matching (name,sequence_id), or append, following TU382D19100. + fn set_attribute(&mut self, attribute: SettableAttribute); +} +impl PushAnimationSink for Vec { + fn set_attribute(&mut self, attribute: SettableAttribute) { + if let Some(existing) = self.iter_mut().find(|existing| { + existing.name == attribute.name && existing.sequence_id == attribute.sequence_id + }) { + *existing = attribute; + } else { + self.push(attribute); + } + } +} + +pub struct PushContext<'a> { + pub settings: &'a PushingSettings, + pub shared: &'a mut PushState, + /// The current MG list: Pushing carries accumulated AG-held seconds. + pub motion_intents: &'a IntentMap, + pub forward_speed: f32, + pub delta_seconds: f32, + pub time_since_teleport: f32, + /// ISkaterAnim::GetIsSwitch (TU382B97128), distinct from mirror/natural stance. + pub is_switch: bool, + /// None only for an actor without a physical interface, as in82595B28. + /// A physical actor must supply its actual skeleton/reckoning outputs. + pub foot_frame: Option, +} +impl PushContext<'_> { + fn intents(&self, configured_name: &str) -> PushIntents { + PushIntents { + pushing: self.motion_intents.get("Pushing").copied(), + new_push: self.motion_intents.contains_key("NewPush"), + configured_foot: self.motion_intents.contains_key(configured_name), + } + } +} + +/// Allocate one per graph activation. Its first-push/cycle data must not be +/// shared between separate active nodes, nor retained across a fresh activation. +#[derive(Clone, Debug)] +pub struct PushInstance { + pub operation: PushOperation, + first_strength: Option, + cycle: Option, + first_update: bool, +} +impl PushInstance { + pub fn new(operation: PushOperation) -> Self { + Self { + operation, + first_strength: None, + cycle: None, + first_update: false, + } + } + pub fn begin(&mut self, context: &mut PushContext<'_>, sink: &mut impl PushAnimationSink) { + match &self.operation { + PushOperation::InitPush => context.shared.initialize_push(), //82BAD5B8 + PushOperation::ComputeFirstPushStrength => { + self.first_strength = Some(FirstPushStrength::begin( + context.time_since_teleport, + context.settings.teleport_window, + )); //82BAD6E8 + } + PushOperation::SetPushCoefs { .. } => self.first_update = true, //82BACEF0 + PushOperation::PushCycle { new_push_name } => { + let intents = context.intents(new_push_name); + self.cycle = Some(PushCycle::begin( + context.shared, + &context.settings.curves, + intents, + )); + } + PushOperation::PushOut { is_right_foot } => { + //82BAE210 ->82595B28; output writes are begin-only and ordered X,Y. + let [x, y] = context + .foot_frame + .map_or([0.0; 2], |frame| frame.out_distance(*is_right_foot)); + publish(sink, "OutDistanceX", x, false); + publish(sink, "OutDistanceY", y, false); + } + _ => {} + } + } + pub fn update( + &mut self, + context: &mut PushContext<'_>, + sink: &mut impl PushAnimationSink, + ) -> Result<(), String> { + match &self.operation { + PushOperation::ComputeFirstPushStrength => { + self.first_strength + .as_mut() + .ok_or("ComputeFirstPushStrength updated before Begin")? + .update( + context.shared, + &context.settings.curves, + context.motion_intents.get("Pushing").copied(), + context.forward_speed, + context.delta_seconds, + ); + } + PushOperation::ComputeTargetCoefsFromSpeedAndStrength { regular_attributes } => { + context.shared.target = context + .settings + .attributes(*regular_attributes, context.is_switch) + .target(context.forward_speed, context.shared.current_push_dv); //82BAD258/82BAD260 + } + PushOperation::SetPushCoefs { + on_first_update_only, + } => { + if !on_first_update_only || self.first_update { + context.shared.current = context.settings.curves.update_blend( + context.shared.current, + context.shared.target, + context.forward_speed, + context.delta_seconds, + ); //82BACF50 + let values = context.shared.current; + publish(sink, "HStr_Vel_B", values.hstr_vel_b, true); + publish(sink, "LStr_Vel_B", values.lstr_vel_b, true); + publish(sink, "Vel_E", values.vel_e, true); + } + self.first_update = false; //82BACF00 + } + PushOperation::PushCycle { new_push_name } => { + let intents = context.intents(new_push_name); + self.cycle + .as_mut() + .ok_or("PushCycle updated before Begin")? + .update( + context.shared, + &context.settings.curves, + intents, + context.forward_speed, + context.settings.maximum_holding_acceleration, + ); + } + _ => {} + } + Ok(()) + } + pub fn end(&mut self, context: &mut PushContext<'_>) { + if let PushOperation::ComputeRepushDeadline { regular_attributes } = &self.operation { + context.shared.out_factor = context + .settings + .attributes(*regular_attributes, context.is_switch) + .out_factor( + context.forward_speed, + context.shared.current_push_dv, + context.settings.out_speed_weight, + context.settings.maximum_out_factor, + ); //82BADA90 + } + } +} +fn publish(sink: &mut impl PushAnimationSink, name: &'static str, value: f32, normalized: bool) { + sink.set_attribute(SettableAttribute { + name, + value, + normalized, + sequence_id: -1, + }); +} + +#[cfg(test)] +#[path = "tests/pushing.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/pushing_settings.rs b/crates/skate-game/src/graph_host/pushing_settings.rs new file mode 100644 index 00000000..126c1df7 --- /dev/null +++ b/crates/skate-game/src/graph_host/pushing_settings.rs @@ -0,0 +1,188 @@ +//! Stock pushing settings and authored clip metadata (TU382B953E8..82B9584C). +use skate_core::{ + animation::{clip_clock::ClipClock, skeleton_input::name::encode}, + point_graph::PointGraph, + riding::{ + push_animation::PushAnimationCurves, + push_behaviors::{PushAttributes, PushClipMetrics}, + }, +}; +use skate_data::{animation_metadata::AnimationMetadata, collections::Collections}; +use std::path::Path; + +/// A tree's ordered attributes. The value is its first payload lane, including +/// non-scalar types: native82B95620 does not filter the attribute type. +#[derive(Clone, Debug)] +pub struct PushTreeAttribute { + pub name: String, + pub first_payload: f32, +} +#[derive(Clone, Debug)] +pub struct PushTreeMetadata { + pub length: f32, + pub attributes: Vec, +} +pub trait PushTreeSource { + /// Obtain the actual animation tree's length and GetAttributes(mask=15) + /// result in stored order. Missing assets must return an error. + fn metadata( + &mut self, + tree_name: &str, + attribute_mask: u32, + ) -> Result; +} + +impl PushTreeSource for AnimationMetadata { + fn metadata( + &mut self, + tree_name: &str, + attribute_mask: u32, + ) -> Result { + let clip = self.clip(tree_name)?; + let frames = f32::from_bits(clip.frames_bits); + let fps = f32::from_bits(clip.fps_bits); + let base_speed = f32::from_bits(clip.base_speed_bits); + // Init827B8AB0 sets speed1/time0, then recomputes length with this + // specific multiply order (different from later SetSpeed). + let length = (frames - 1.0) / ((1.0 * base_speed) * fps); + let clock = ClipClock { + frames, + fps, + base_speed, + speed: 1.0, + length, + time: 0.0, + previous_time: 0.0, + loops_since_evaluation: 0, + looping: clip.flags_word & 0x1000_0000 != 0, + phase_controlled: clip.flags_word & 0x4000_0000 != 0, + }; + let mut attributes = Vec::new(); + // GetAttributes82D25E30 appends in stored order. Untimed records + // bypass both mask tests; timed records use GetAttributeStatus. + for attribute in &clip.attributes { + let begin = f32::from_bits(attribute.begin_bits); + if begin != -1.0 { + let status = + u32::from(clock.attribute_status(begin, f32::from_bits(attribute.end_bits))); + if attribute_mask & 0x1c & status == 0 || attribute_mask & 3 & status == 0 { + continue; + } + } + // Attribute::Init82D164F8 copies lane0 for types0/1/3. Type2 + // samples a curve; unknown types leave lane0 untouched. Neither + // can be replaced by the first serialized word as a fallback. + if !matches!(attribute.type_id, 0 | 1 | 3) { + return Err(format!( + "{tree_name}: attribute {} needs type{} evaluation", + attribute.name, attribute.type_id, + )); + } + attributes.push(PushTreeAttribute { + name: attribute.name.clone(), + first_payload: f32::from_bits(attribute.payload_words[0]), + }); + } + Ok(PushTreeMetadata { length, attributes }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct PushingSettings { + pub curves: PushAnimationCurves, + pub teleport_window: f32, + pub maximum_holding_acceleration: f32, + pub out_speed_weight: f32, + pub maximum_out_factor: f32, + pub regular: PushAttributes, + pub mongo: PushAttributes, +} +impl PushingSettings { + /// Standalone settings check; game startup shares its already loaded banks. + pub fn load_stock(data: &Collections, asset_root: &Path) -> Result { + let mut trees = + skate_data::animation_banks::AnimationBanks::load(asset_root)?.metadata()?; + Self::load(data, &mut trees) + } + pub fn load(data: &Collections, trees: &mut impl PushTreeSource) -> Result { + let float = |name| data.float("anim_motion", "pushing", name); + Ok(Self { + curves: PushAnimationCurves { + button_time_max: curve(data, "button_time_max")?, + button_time_to_dv: curve(data, "button_time_to_dv")?, + blend_speed_over_frames: curve(data, "blend_speed_over_frames")?, + blend_acc_over_frames: curve(data, "blend_acc_over_frames")?, + }, + // Relative to the anim_motion/pushing layout pointer:1704,1788,1840,1808. + teleport_window: float("pushing_usemaxpushfromteleporttime")?, + maximum_holding_acceleration: float("max_holding_acc")?, + out_speed_weight: float("dynamic_out_factor_speed_vs_acc")?, + maximum_out_factor: float("last_push_out_time")?, + regular: attributes( + trees, + [ + "R_PUSHLSP_HSTR_N_0_CYC1", + "R_PUSHHSP_HSTR_N_0_CYC1", + "R_PUSHLSP_LSTR_N_0_CYC1", + "R_PUSHHSP_LSTR_N_0_CYC1", + ], + )?, + mongo: attributes( + trees, + [ + "R_PUSHLSP_HSTR_MONGO_0_CYC1", + "R_PUSHHSP_HSTR_MONGO_0_CYC1", + "R_PUSHLSP_LSTR_MONGO_0_CYC1", + "R_PUSHHSP_LSTR_MONGO_0_CYC1", + ], + )?, + }) + } + /// TU382BAD3B4..3DC/82BADBB8..BE0; GetIsSwitch, not natural stance. + pub fn attributes(&self, regular_attributes: bool, is_switch: bool) -> &PushAttributes { + if regular_attributes != is_switch { + &self.regular + } else { + &self.mongo + } + } +} + +fn curve(data: &Collections, name: &str) -> Result, String> { + let words = data.words::<20>("anim_motion", "pushing", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[12 + i])), + }) +} +fn attributes(trees: &mut impl PushTreeSource, names: [&str; 4]) -> Result { + let mut metrics = Vec::with_capacity(4); + for name in names { + let tree = trees.metadata(name, 15)?; + let mut begin = None; + let mut end = None; + for attribute in tree.attributes { + // Native compares five-word FastStrings, whose encoding folds + // ASCII case. Decoded bank names are uppercase; XML need not be. + let key = encode(attribute.name.as_bytes()); + if key == encode(b"HStr_Vel_B") || key == encode(b"LStr_Vel_B") { + begin = Some(attribute.first_payload); + } else if key == encode(b"Vel_E") { + end = Some(attribute.first_payload); + } + } + // Native leaves missing outputs untouched. The host loader rejects + // incomplete metadata rather than supplying arbitrary initial values. + metrics.push(PushClipMetrics { + length: tree.length, + begin_velocity: begin + .ok_or_else(|| format!("{name}: missing begin-velocity attribute"))?, + end_velocity: end.ok_or_else(|| format!("{name}: missing Vel_E attribute"))?, + }); + } + Ok(PushAttributes::from_clips(metrics.try_into().unwrap())) +} + +#[cfg(test)] +#[path = "tests/pushing_metadata.rs"] +mod metadata_tests; diff --git a/crates/skate-game/src/graph_host/tests/motion_slide.rs b/crates/skate-game/src/graph_host/tests/motion_slide.rs new file mode 100644 index 00000000..7369cdb8 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_slide.rs @@ -0,0 +1,251 @@ +//! Stock bindings plus production Host lifecycle: a split latch breaks this test. +use super::*; +use crate::graph_host::{motion_conditions::MotionCondition, motion_sliding::Operation}; +use skate_core::graph::conditions::PhysicalStateInputs; + +fn host() -> MotionHost { + use crate::graph_runtime::CompiledGraph; + use skate_data::state_graph::{StateGraph, binding::Binding}; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let runtime = CompiledGraph::from_binding(&binding).unwrap(); + MotionHost::from_graph( + &LoadedGraph { + source, + binding, + runtime, + }, + &Collections::load(&root).unwrap(), + skate_data::animation_banks::AnimationBanks::load(&root) + .unwrap() + .metadata() + .unwrap(), + PlaybackContext { + is_switch: Some(false), + is_mirrored: Some(false), + board_available: Some(false), + pro_skater: encode(b""), + transition_override: None, + }, + ) + .unwrap() +} +fn behavior(host: &MotionHost, predicate: impl Fn(&MotionOperation) -> bool) -> usize { + host.remap + .behaviors + .iter() + .position(|&id| predicate(&host.operations[id])) + .expect("Required authored stock behavior is missing") +} +fn slide(host: &MotionHost, operation: Operation) -> usize { + behavior( + host, + |v| matches!(v, MotionOperation::Slide(other) if *other == operation), + ) +} +fn frame() -> Frame { + Frame { + dt: 0.1, + current: None, + last: None, + state_times: Vec::new(), + } +} +fn physical(host: &mut MotionHost) { + host.animation.skater_animation_flags = Some(0); + host.condition_inputs.physical_state = Some(PhysicalStateInputs { + category: 1, + grinding: false, + grind_name: String::new(), + }); + host.physical = Some(MotionPhysical { + turning: set_turning::Physical { + field_32: 0.0, + field_36: 0.0, + field_52: 0.0, + field_56: 0.0, + field_60: 0.0, + body_168: 2.0, + }, + stance: (false, false), + forward_speed: 2.0, + time_since_teleport: 1.0, + is_switch: false, + foot_frame: Some(PushFootFrame { + left_foot: [0.0; 4], + right_foot: [0.0; 4], + deck_position: [0.0; 4], + deck_y: [0.0, 1.0, 0.0, 0.0], + deck_z: [0.0, 0.0, 1.0, 0.0], + skateboard_flipped: false, + }), + }); + host.fakie_physical = Some(skate_core::animation::riding_fakie::Physical { + category: 1, + grind_state: 0, + doing_trick: false, + board_axis: [0.0, 0.0, 1.0, 0.0], + deck_velocity: [2.0, 0.0, 0.0, 0.0], + external_velocity: [0.0; 4], + ground_projected_speed: 2.0, + }); + host.native_physical = Some(crate::graph_host::motion_native::Physical { + centre_of_mass_velocity: [0.0; 4], + system_up: [0.0, 1.0, 0.0, 0.0], + board_reckoning_z: [0.0, 0.0, 1.0, 0.0], + board_reckoning: [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }); +} +fn packet(host: &MotionHost, name: &[u8]) -> f32 { + host.animation + .motion_attributes + .iter() + .rev() + .find(|a| a.name == encode(name)) + .expect("Missing physical packet attribute") + .value +} + +#[test] +#[ignore = "requires the user's original stock graph, banks and collections"] +fn stock_slide_lifecycle_shares_turning_latch_and_captured_stance() { + let mut host = host(); + physical(&mut host); + let frame = frame(); + let power = slide(&host, Operation::Update); + let create = slide(&host, Operation::Create { right: true }); + let candidate = slide(&host, Operation::Candidate); + let active = slide(&host, Operation::IsPowerSliding); + let manual = slide(&host, Operation::ManualAttribute); + let turning = behavior(&host, |v| matches!(v, MotionOperation::SetTurning(_))); + let grab = host + .remap + .hooks + .iter() + .position(|&id| { + matches!( + host.operations[id], + MotionOperation::Hook(crate::graph_host::motion_hooks::MotionHook::GrabSlide { + right: true + }) + ) + }) + .expect("Authored GrabSlide hook missing"); + for fakie in [false, true] { + host.slide_latch.reset(); + host.animation.motion_intents.clear(); + //A pre-graph snapshot must not override the current animation flag. + host.physical.as_mut().unwrap().stance.0 = !fakie; + host.animation.skater_animation_flags = Some((fakie as u32) << 29); + for id in [power, candidate, active, create, turning, manual] { + host.allocate(id, &frame); + } + host.begin(candidate, [0; 6], &frame); + host.begin(turning, [0; 6], &frame); + host.update(power, [0; 6], &frame); // native ground-entry history capture + let (intent, start, right, value) = if fakie { + ("LeftSlide", "LeftSlideStart", false, -0.7) + } else { + ("RightSlide", "RightSlideStart", true, 0.7) + }; + host.animation.motion_intents.insert(intent.into(), value); + host.animation.motion_intents.insert(start.into(), 1.0); + host.update(power, [0; 6], &frame); + assert!(host.slide_latch.start(right)); + host.update(turning, [0; 6], &frame); + assert!(matches!(host.instances[turning], Instance::Turning(s) if s.mode == right as u32)); + assert_eq!(host.slide_latch.captured_fakie(), fakie); + host.hook(grab, &frame); + host.begin(create, [0; 6], &frame); + host.begin(active, [0; 6], &frame); + assert!(host.is_power_sliding); + host.update(create, [0; 6], &frame); + let expected_slide = if fakie { 1.0 - 0.7 } else { 0.7 }; + assert!((packet(&host, b"slide") - expected_slide).abs() < 1.0e-6); + assert_eq!(packet(&host, b"turn"), host.turning.override_turn); + // Current stance changes after entry; the captured side remains authoritative. + host.animation.skater_animation_flags = Some(((!fakie) as u32) << 29); + host.update(turning, [0; 6], &frame); + assert_eq!(host.slide_latch.captured_fakie(), fakie); + host.animation.motion_intents.insert("Manual".into(), -0.25); + host.update(manual, [0; 6], &frame); + assert_eq!(packet(&host, b"balance"), -0.25); + host.animation.motion_intents.remove(intent); + host.animation.motion_intents.remove(start); + for _ in 0..4 { + host.update(power, [0; 6], &frame); + } + assert!( + MotionCondition::ShouldLeaveSlide { right: true } + .evaluate(&host, &frame) + .unwrap() + ); + host.update(turning, [0; 6], &frame); + assert!(matches!(host.instances[turning], Instance::Turning(s) if s.mode == 2)); + host.end(active, [0; 6], &frame); + host.end(candidate, [0; 6], &frame); + assert!(!host.is_power_sliding && !host.slide_latch.candidate_enabled()); + assert!(host.errors.is_empty(), "{:?}", host.errors); + } +} + +#[test] +#[ignore = "requires the user's original stock graph, banks and collections"] +fn stock_slide_deceleration_uses_ground_frame_and_resets_on_entry() { + let mut host = host(); + physical(&mut host); + let frame = frame(); + let decel = slide(&host, Operation::Deceleration); + let spin = slide(&host, Operation::Spin); + host.allocate(decel, &frame); + host.begin(decel, [0; 6], &frame); + host.fakie_physical.as_mut().unwrap().deck_velocity = [0.0, 2.0, 0.0, 0.0]; + host.update(decel, [0; 6], &frame); + let value = |host: &MotionHost| match host.instances[decel] { + Instance::SlideDeceleration(value) => value, + _ => panic!("Wrong deceleration instance"), + }; + assert_eq!(value(&host), 0.0); // motion parallel to ground up is discarded + host.native_physical.as_mut().unwrap().board_reckoning = [ + [0.0, 1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [100.0, 200.0, 300.0, 1.0], + ]; + host.update(decel, [0; 6], &frame); + let first = value(&host); + assert!(first > 0.0 && first.is_finite()); // same motion is now across the ground + host.update(decel, [0; 6], &frame); + assert!(value(&host) > first); + host.end(decel, [0; 6], &frame); + host.begin(decel, [0; 6], &frame); + assert_eq!(value(&host), 0.0); + host.update(decel, [0; 6], &frame); + assert_eq!(value(&host), first); + host.begin(spin, [0; 6], &frame); + host.update(spin, [0; 6], &frame); + host.end(spin, [0; 6], &frame); + // Both are animation parameters, so neither creates a physical packet scalar. + assert!(host.animation.motion_attributes.is_empty()); + assert!(host.errors.is_empty(), "{:?}", host.errors); +} + +#[test] +fn slide_direction_preserves_native_unordered_threshold_branch() { + assert_eq!( + crate::graph_host::motion_sliding::direction( + [f32::NAN, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + false + ), + 1.0 + ); +} diff --git a/crates/skate-game/src/graph_host/tests/motion_stock.rs b/crates/skate-game/src/graph_host/tests/motion_stock.rs new file mode 100644 index 00000000..851d42d0 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_stock.rs @@ -0,0 +1,158 @@ +use super::*; + +#[test] +#[ignore = "requires the user's private stock graph, collection and animation artifacts"] +fn stock_motion_host_loads_graph_settings_and_authored_riding_tree() { + use crate::graph_runtime::CompiledGraph; + use skate_data::state_graph::{StateGraph, binding::Binding}; + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let runtime = CompiledGraph::from_binding(&binding).unwrap(); + let graph = LoadedGraph { + source, + binding, + runtime, + }; + let collections = Collections::load(&root).unwrap(); + let host = MotionHost::from_graph( + &graph, + &collections, + skate_data::animation_banks::AnimationBanks::load(&root) + .unwrap() + .metadata() + .unwrap(), + PlaybackContext { + is_switch: None, + is_mirrored: None, + board_available: None, + pro_skater: encode(b""), + transition_override: None, + }, + ) + .unwrap(); + let tree = host.animation.build_tree("BTREE_RIDING").unwrap(); + fn count(tree: &skate_core::animation::playback_tree::PlaybackTree) -> (usize, usize) { + use skate_core::animation::playback_tree::PlaybackTree; + match tree { + PlaybackTree::Clip { .. } => (1, 0), + PlaybackTree::PhaseBlend(tree) => tree + .children + .iter() + .map(count) + .fold((0, 1), |(ac, ap), (bc, bp)| (ac + bc, ap + bp)), + PlaybackTree::Transition(_) => { + panic!("Authored bank unexpectedly contains a runtime transition") + } + PlaybackTree::BindPose { motion, .. } => count(motion), + PlaybackTree::SelectionSpace(tree) => tree + .candidates + .iter() + .map(|candidate| count(&candidate.tree)) + .fold((0, 0), |(ac, ap), (bc, bp)| (ac + bc, ap + bp)), + } + } + let (clips, phases) = count(&tree); + assert!(clips > 1 && phases > 1); + for name in [ + "B_MONGO_PUSH_CYC1", + "B_MONGO_PUSH_CYC2", + "B_MONGO_PUSH_INTO", + "B_PUSH_CYC1", + "B_PUSH_CYC2", + "B_PUSH_INTO", + "B_PUSH_MONGO_OUT", + "B_PUSH_OUT", + "B_SWITCH", + ] { + let tree = host.animation.build_tree(name).unwrap(); + let (clips, phases) = count(&tree); + assert!( + clips > 0 && phases > 0, + "Authored push root {name} lost its blend tree" + ); + } + println!( + "Production host: {} operations, real riding closure: {clips} leaf instances, {phases} PhaseBlend instances", + host.operations.len() + ); +} + +/// Enumerate actual factory results; this is a support inventory, not a parity test. +#[test] +#[ignore = "requires the user's private stock MotionGraph"] +fn stock_motion_factory_support_inventory() { + use skate_data::state_graph::{ + StateGraph, + binding::{Binding, Node, OperationKind}, + }; + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let instantiated = binding + .instantiate_operations(&source, &mut MotionFactory) + .unwrap(); + let mut rows = String::from( + "kind\tname\tfactory_status\toperation_id\truntime_id\tenabled\towner\tvariant\n", + ); + let mut counts = [0usize; 3]; + for (id, (operation, implementation)) in binding + .operations + .iter() + .zip(&instantiated.operations) + .enumerate() + { + let (kind, index) = match operation.kind { + OperationKind::Behavior => ("Behavior", 0), + OperationKind::Condition => ("Condition", 1), + OperationKind::Hook => ("Hook", 2), + }; + let status = if matches!(implementation, MotionOperation::Unsupported { .. }) { + "Unsupported" + } else { + "Registered" + }; + let owner = if let Node::State(state) = operation.parent { + let mut names = Vec::new(); + let mut cursor = Some(state); + while let Some(state) = cursor { + names.push(binding.states[state].name.clone()); + cursor = binding.states[state].parent; + } + names.reverse(); + names.join(".") + } else { + format!("{:?}", operation.parent) + }; + let clean = |s: String| s.replace(['\t', '\r', '\n'], " "); + rows.push_str(&format!( + "{kind}\t{}\t{status}\t{id}\t{}\t{}\t{}\t{}\n", + clean(operation.name.clone()), + counts[index], + operation.enabled, + clean(owner), + clean(format!("{implementation:?}")) + )); + counts[index] += 1; + } + assert_eq!(counts.iter().sum::(), binding.operations.len()); + let output = std::path::PathBuf::from( + std::env::var_os("SKATE3_MOTION_INVENTORY").expect("Set SKATE3_MOTION_INVENTORY"), + ); + std::fs::write(&output, rows).unwrap(); + println!( + "MotionGraph factory inventory: {} behaviors, {} conditions, {} hooks; {}", + counts[0], + counts[1], + counts[2], + output.display() + ); +} diff --git a/crates/skate-game/src/graph_host/tests/pushing.rs b/crates/skate-game/src/graph_host/tests/pushing.rs new file mode 100644 index 00000000..0125c657 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/pushing.rs @@ -0,0 +1,387 @@ +use super::*; +use crate::graph_host::pushing_settings::{PushTreeAttribute, PushTreeMetadata, PushTreeSource}; +use skate_core::{ + point_graph::PointGraph, + riding::{ + push_animation::{PushAnimationCurves, PushBlendParameters}, + push_behaviors::{PushAttributes, PushClipMetrics}, + }, +}; +use skate_data::{collections::Collections, state_graph::GraphAttribute}; +fn settings() -> PushingSettings { + let curve = PointGraph { + x: std::array::from_fn(|i| i as f32), + y: [1.0; 8], + }; + let regular = PushAttributes::from_clips([ + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 4.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 8.0, + }, + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 1.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 5.0, + }, + ]); + let mongo = PushAttributes::from_clips(regular.clips.map(|mut c| { + c.begin_velocity += 2.0; + c.end_velocity += 5.0; + c + })); + PushingSettings { + curves: PushAnimationCurves { + button_time_max: curve, + button_time_to_dv: curve, + blend_speed_over_frames: curve, + blend_acc_over_frames: curve, + }, + teleport_window: 0.5, + maximum_holding_acceleration: 4.0, + out_speed_weight: 0.4, + maximum_out_factor: 0.659, + regular, + mongo, + } +} +fn state() -> PushState { + let unset = PushBlendParameters { + hstr_vel_b: -1.0, + lstr_vel_b: -1.0, + vel_e: -1.0, + }; + PushState { + out_factor: 0.375, + current_push_dv: 2.5, + current: unset, + target: unset, + continue_push: false, + } +} +fn context<'a>( + settings: &'a PushingSettings, + state: &'a mut PushState, + intents: &'a IntentMap, +) -> PushContext<'a> { + PushContext { + settings, + shared: state, + motion_intents: intents, + forward_speed: 2.0, + delta_seconds: 1.0, + time_since_teleport: 1.0, + is_switch: false, + foot_frame: None, + } +} +#[test] +fn authored_defaults_match_tu3_factories() { + let attributes = |name: &str| { + vec![GraphAttribute { + name: "name".into(), + text: name.into(), + boolean_byte: 0, + float_bits: 0, + }] + }; + for (name, expected) in [ + ( + "PushCycle", + PushOperation::PushCycle { + new_push_name: "LeftPush".into(), + }, + ), + ( + "PushOut", + PushOperation::PushOut { + is_right_foot: true, + }, + ), + ( + "SetPushCoefs", + PushOperation::SetPushCoefs { + on_first_update_only: false, + }, + ), + ( + "ComputeRepushDeadline", + PushOperation::ComputeRepushDeadline { + regular_attributes: true, + }, + ), + ] { + assert_eq!( + PushOperation::from_attributes(&Attributes::new(&attributes(name))), + Some(expected) + ); + } +} +#[test] +fn first_update_only_blends_once_per_activation_and_publishes_in_source_order() { + let settings = settings(); + let mut state = state(); + let intents = IntentMap::new(); + let mut context = context(&settings, &mut state, &intents); + context.shared.target = PushBlendParameters { + hstr_vel_b: 0.25, + lstr_vel_b: 0.5, + vel_e: 0.75, + }; + let mut node = PushInstance::new(PushOperation::SetPushCoefs { + on_first_update_only: true, + }); + let mut sink = Vec::new(); + node.begin(&mut context, &mut sink); + assert!(sink.is_empty()); + node.update(&mut context, &mut sink).unwrap(); + assert_eq!( + sink.iter().map(|a| a.name).collect::>(), + ["HStr_Vel_B", "LStr_Vel_B", "Vel_E"] + ); + assert!(sink.iter().all(|a| a.normalized && a.sequence_id == -1)); + let first = sink.clone(); + context.shared.target.vel_e = 0.1; + node.update(&mut context, &mut sink).unwrap(); + assert_eq!(sink, first); + node.begin(&mut context, &mut sink); + node.update(&mut context, &mut sink).unwrap(); + assert_eq!(sink[2].value, 0.1); +} +#[test] +fn target_and_out_factor_select_attributes_using_switch_state() { + let settings = settings(); + let mut state = state(); + let intents = IntentMap::new(); + let mut context = context(&settings, &mut state, &intents); + let mut sink = Vec::new(); + let mut target = PushInstance::new(PushOperation::ComputeTargetCoefsFromSpeedAndStrength { + regular_attributes: true, + }); + target.update(&mut context, &mut sink).unwrap(); + assert_eq!(context.shared.target, settings.regular.target(2.0, 2.5)); + context.is_switch = true; + target.update(&mut context, &mut sink).unwrap(); + assert_eq!(context.shared.target, settings.mongo.target(2.0, 2.5)); + assert_ne!( + settings.regular.target(2.0, 2.5), + settings.mongo.target(2.0, 2.5) + ); + let mut out = PushInstance::new(PushOperation::ComputeRepushDeadline { + regular_attributes: true, + }); + out.begin(&mut context, &mut sink); + out.update(&mut context, &mut sink).unwrap(); + assert_eq!(context.shared.out_factor, 0.375); + out.end(&mut context); + assert_eq!( + context.shared.out_factor, + settings.mongo.out_factor(2.0, 2.5, 0.4, 0.659) + ); + assert!(sink.is_empty()); +} +#[test] +fn push_out_projects_actual_selected_foot_and_publishes_only_on_begin() { + let settings = settings(); + let mut state = state(); + let intents = IntentMap::new(); + let mut context = context(&settings, &mut state, &intents); + context.foot_frame = Some(PushFootFrame { + left_foot: [2.0, 3.0, 4.0, 0.0], + right_foot: [5.0, 6.0, 7.0, 0.0], + deck_position: [1.0, 1.0, 1.0, 0.0], + deck_y: [0.0, 1.0, 0.0, 0.0], + deck_z: [0.0, 0.0, 1.0, 0.0], + skateboard_flipped: true, + }); + let mut node = PushInstance::new(PushOperation::PushOut { + is_right_foot: true, + }); + let mut sink = Vec::new(); + node.begin(&mut context, &mut sink); + assert_eq!( + sink, + [ + SettableAttribute { + name: "OutDistanceX", + value: -6.0, + normalized: false, + sequence_id: -1 + }, + SettableAttribute { + name: "OutDistanceY", + value: 5.0, + normalized: false, + sequence_id: -1 + } + ] + ); + context.foot_frame = None; + node.update(&mut context, &mut sink).unwrap(); + node.end(&mut context); + assert_eq!(sink[0].value, -6.0); +} +#[test] +fn settable_sink_updates_first_matching_name_and_sequence_without_reordering() { + let mut sink = vec![ + SettableAttribute { + name: "Vel_E", + value: 0.1, + normalized: true, + sequence_id: 7, + }, + SettableAttribute { + name: "Vel_E", + value: 0.2, + normalized: true, + sequence_id: -1, + }, + SettableAttribute { + name: "Vel_E", + value: 0.3, + normalized: true, + sequence_id: -1, + }, + ]; + sink.set_attribute(SettableAttribute { + name: "Vel_E", + value: 3.0, + normalized: false, + sequence_id: -1, + }); + assert_eq!( + sink.iter().map(|a| a.value).collect::>(), + [0.1, 3.0, 0.3] + ); + assert!(!sink[1].normalized); +} +struct Trees { + requested: Vec, + missing_end: bool, +} +impl PushTreeSource for Trees { + fn metadata(&mut self, name: &str, mask: u32) -> Result { + assert_eq!(mask, 15); + self.requested.push(name.into()); + let mut attributes = vec![ + PushTreeAttribute { + name: "HStr_Vel_B".into(), + first_payload: 1.0, + }, + PushTreeAttribute { + name: "Unrelated".into(), + first_payload: 50.0, + }, + PushTreeAttribute { + name: "LStr_Vel_B".into(), + first_payload: 2.0, + }, + ]; + if !self.missing_end { + attributes.push(PushTreeAttribute { + name: "Vel_E".into(), + first_payload: 6.0, + }); + } + Ok(PushTreeMetadata { + length: 0.5, + attributes, + }) + } +} +fn collections() -> Collections { + let mut fields = Vec::new(); + for name in [ + "pushing_usemaxpushfromteleporttime", + "max_holding_acc", + "dynamic_out_factor_speed_vs_acc", + "last_push_out_time", + ] { + fields.push(format!( + r#""{name}":{{"type":"EA::Reflection::Float","data":"3F000000"}}"# + )); + } + let data = (0..20) + .map(|i| format!("{:08X}", (i as f32).to_bits())) + .collect::(); + for name in [ + "button_time_max", + "button_time_to_dv", + "blend_speed_over_frames", + "blend_acc_over_frames", + ] { + fields.push(format!(r#""{name}":{{"type":"Graph","data":"{data}"}}"#)); + } + let json = format!( + r#"{{"version":1,"collections":[{{"class":"anim_motion","key":"pushing","parent":"","fields":{{{}}},"source":"test","sha256":"test"}}]}}"#, + fields.join(",") + ); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "skate-pushing-test-{}-{unique}", + std::process::id() + )); + let stock = root.join("private/stock"); + std::fs::create_dir_all(&stock).unwrap(); + let file = stock.join("skater-collections.json"); + std::fs::write(&file, json).unwrap(); + let result = Collections::load(&root); + std::fs::remove_file(file).unwrap(); + std::fs::remove_dir(stock).unwrap(); + std::fs::remove_dir(root.join("private")).unwrap(); + std::fs::remove_dir(root).unwrap(); + result.unwrap() +} +#[test] +fn metadata_loader_uses_all_eight_authored_names_and_last_matching_attribute() { + let mut trees = Trees { + requested: vec![], + missing_end: false, + }; + let settings = PushingSettings::load(&collections(), &mut trees).unwrap(); + assert_eq!( + trees.requested, + [ + "R_PUSHLSP_HSTR_N_0_CYC1", + "R_PUSHHSP_HSTR_N_0_CYC1", + "R_PUSHLSP_LSTR_N_0_CYC1", + "R_PUSHHSP_LSTR_N_0_CYC1", + "R_PUSHLSP_HSTR_MONGO_0_CYC1", + "R_PUSHHSP_HSTR_MONGO_0_CYC1", + "R_PUSHLSP_LSTR_MONGO_0_CYC1", + "R_PUSHHSP_LSTR_MONGO_0_CYC1" + ] + ); + assert_eq!(settings.regular.clips[0].begin_velocity, 2.0); + assert_eq!(settings.regular.clips[0].end_velocity, 6.0); + assert_eq!( + settings.curves.button_time_max.x, + [4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0] + ); + assert_eq!( + settings.curves.button_time_max.y, + [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0] + ); +} +#[test] +fn missing_clip_metadata_is_reported_without_guessed_velocity() { + let mut trees = Trees { + requested: vec![], + missing_end: true, + }; + let error = PushingSettings::load(&collections(), &mut trees).unwrap_err(); + assert!(error.contains("R_PUSHLSP_HSTR_N_0_CYC1: missing Vel_E")); +} diff --git a/crates/skate-game/src/graph_host/tests/pushing_metadata.rs b/crates/skate-game/src/graph_host/tests/pushing_metadata.rs new file mode 100644 index 00000000..7a926af5 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/pushing_metadata.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +#[ignore = "requires the user's converted private stock animation bank and collections"] +fn stock_push_metrics_load_through_the_runtime_data_path() { + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let collections = Collections::load(&root).unwrap(); + let settings = PushingSettings::load_stock(&collections, &root).unwrap(); + for group in [&settings.regular, &settings.mongo] { + for clip in group.clips { + assert!(clip.length.is_finite() && clip.length > 0.0); + assert!(clip.end_velocity > clip.begin_velocity); + } + } + // Distinct authored timings must survive data conversion and dispatch. + assert_ne!( + settings.regular.clips[0].length, + settings.mongo.clips[0].length + ); + println!("regular={:?}\nmongo={:?}", settings.regular, settings.mongo); +} diff --git a/crates/skate-game/src/graph_host/turning_settings.rs b/crates/skate-game/src/graph_host/turning_settings.rs new file mode 100644 index 00000000..67498d69 --- /dev/null +++ b/crates/skate-game/src/graph_host/turning_settings.rs @@ -0,0 +1,35 @@ +//! SetTurning82BB3830/TurnRemap82BB4248 stock layout binding. +//! anim_carving/default's schema is752 bytes; native global slot372 selects it. +use skate_core::{input::{set_turning::Settings, turn_remap::TurnRemap}, point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub fn load(data: &Collections) -> Result { + let float = |name| data.float("anim_carving", "default", name); + Ok(Settings { + // Mode0 selects layout288/432/744; mode1 selects0/144/740. + remaps: [ + TurnRemap { magnitude: curve16(data, "quickMagMap")?, angle: curve16(data, "quickAngleMap")?, angle_offset: float("quickAngleOffset")? }, + TurnRemap { magnitude: curve16(data, "slowMagMap")?, angle: curve16(data, "slowAngleMap")?, angle_offset: float("slowAngleOffset")? }, + ], + speed_tuck: curve8(data, "speed_tuck")?, + blend: curve8(data, "blend_lean_in")?, + speed_threshold: float("speed_tuck_start")?, + maximum_delta: float("clamp_curr_lean")?, + // Global332 is anim_motion/power_slide, layout1680. + override_turn: data.float("anim_motion", "power_slide", "slide_turn_value")?, + }) +} +fn curve16(data: &Collections, name: &str) -> Result, String> { + let words = data.words::<36>("anim_carving", "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4+i])), + y: std::array::from_fn(|i| f32::from_bits(words[20+i])), + }) +} +fn curve8(data: &Collections, name: &str) -> Result, String> { + let words = data.words::<20>("anim_carving", "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4+i])), + y: std::array::from_fn(|i| f32::from_bits(words[12+i])), + }) +} diff --git a/crates/skate-game/src/graph_runtime.rs b/crates/skate-game/src/graph_runtime.rs new file mode 100644 index 00000000..82f6356f --- /dev/null +++ b/crates/skate-game/src/graph_runtime.rs @@ -0,0 +1,329 @@ +//! Lossless bridge from the stock on-disk graph binding to the recovered TU3 +//! dynamic-controller runtime. +//! +//! Concrete operations are intentionally retained by source operation ID. The +//! registration layer must construct every named operation before either graph +//! can execute; this module never substitutes a default handler. + +use bevy::prelude::Resource; +use skate_core::graph::{ + activation::{ActivationProgram, Child, Condition, Expression}, + controller::{Behavior, Program}, + selection::{State, Topology, Transition}, +}; +use skate_data::{ + GameAssets, + state_graph::{ + StateGraph, + binding::{Binding, Node, OperationKind}, + }, +}; +use std::{fmt, path::Path}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum GraphCompileError { + UnresolvedTransitionTarget { + transition: usize, + }, + InvalidOperationParent { + operation: usize, + expected: &'static str, + }, + InvalidStateBehavior { + state: usize, + operation: usize, + }, + InvalidTransitionHook { + transition: usize, + operation: usize, + }, + InvalidExpressionChild { + expression: usize, + node: Node, + }, + MissingConditionMask { + operation: usize, + }, +} + +impl fmt::Display for GraphCompileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnresolvedTransitionTarget { transition } => { + write!( + f, + "stock graph transition {transition} has no resolved target" + ) + } + Self::InvalidOperationParent { + operation, + expected, + } => write!( + f, + "stock graph operation {operation} does not have its required {expected} parent" + ), + Self::InvalidStateBehavior { state, operation } => write!( + f, + "stock graph state {state} references non-behavior operation {operation}" + ), + Self::InvalidTransitionHook { + transition, + operation, + } => write!( + f, + "stock graph transition {transition} references non-hook operation {operation}" + ), + Self::InvalidExpressionChild { expression, node } => write!( + f, + "stock graph expression {expression} has invalid child {node:?}" + ), + Self::MissingConditionMask { operation } => { + write!( + f, + "stock graph condition {operation} has no parsed TU3 mask" + ) + } + } + } +} + +impl std::error::Error for GraphCompileError {} + +/// Compact runtime IDs map back to the original binding operation IDs. The +/// future concrete host uses these tables to reach its exact factory instances. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OperationRemap { + pub behaviors: Vec, + pub conditions: Vec, + pub hooks: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct CompiledGraph { + pub program: Program, + pub operations: OperationRemap, +} + +impl CompiledGraph { + pub fn from_binding(binding: &Binding) -> Result { + let operation_count = binding.operations.len(); + let mut behavior_for_operation = vec![None; operation_count]; + let mut condition_for_operation = vec![None; operation_count]; + let mut hook_for_operation = vec![None; operation_count]; + let mut behavior_operations = Vec::new(); + let mut condition_operations = Vec::new(); + let mut hook_operations = Vec::new(); + + for (operation_id, operation) in binding.operations.iter().enumerate() { + match operation.kind { + OperationKind::Behavior => { + behavior_for_operation[operation_id] = Some(behavior_operations.len()); + behavior_operations.push(operation_id); + } + OperationKind::Condition => { + condition_for_operation[operation_id] = Some(condition_operations.len()); + condition_operations.push(operation_id); + } + OperationKind::Hook => { + hook_for_operation[operation_id] = Some(hook_operations.len()); + hook_operations.push(operation_id); + } + } + } + + let topology = Topology { + states: binding + .states + .iter() + .map(|state| State { + parent: state.parent, + enabled: state.enabled != 0, + active: state.active != 0, + interruptibility: state.interruptibility, + interrupt_ancestor: state.interrupt_ancestor, + children: state.children.clone(), + transitions: state.transitions.clone(), + }) + .collect(), + transitions: binding + .transitions + .iter() + .enumerate() + .map(|(id, transition)| { + Ok(Transition { + enabled: transition.enabled != 0, + target: transition.target.ok_or( + GraphCompileError::UnresolvedTransitionTarget { transition: id }, + )?, + priority: transition.priority, + }) + }) + .collect::>()?, + }; + + let conditions = condition_operations + .iter() + .map(|&operation_id| { + let operation = &binding.operations[operation_id]; + Ok(Condition { + enabled: operation.enabled != 0, + mask: operation.condition_mask.ok_or( + GraphCompileError::MissingConditionMask { + operation: operation_id, + }, + )?, + }) + }) + .collect::>()?; + + let expressions = binding + .expressions + .iter() + .enumerate() + .map(|(expression_id, expression)| { + let children = expression + .children + .iter() + .map(|node| match *node { + Node::Expression(id) => Ok(Child::Expression(id)), + Node::Operation(operation) => condition_for_operation[operation] + .map(Child::Condition) + .ok_or(GraphCompileError::InvalidExpressionChild { + expression: expression_id, + node: *node, + }), + _ => Err(GraphCompileError::InvalidExpressionChild { + expression: expression_id, + node: *node, + }), + }) + .collect::>()?; + Ok(Expression { + operator: expression.operator, + children, + }) + }) + .collect::>()?; + + let behaviors = behavior_operations + .iter() + .map(|&operation_id| { + let operation = &binding.operations[operation_id]; + let Node::State(owner) = operation.parent else { + return Err(GraphCompileError::InvalidOperationParent { + operation: operation_id, + expected: "state", + }); + }; + Ok(Behavior { + owner, + enabled: operation.enabled != 0, + }) + }) + .collect::>()?; + + let state_behaviors = binding + .states + .iter() + .enumerate() + .map(|(state, source)| { + source + .behaviors + .iter() + .map(|&operation| { + behavior_for_operation[operation] + .ok_or(GraphCompileError::InvalidStateBehavior { state, operation }) + }) + .collect() + }) + .collect::>()?; + + let transition_hooks = binding + .transitions + .iter() + .enumerate() + .map(|(transition, source)| { + source + .hooks + .iter() + .map(|&operation| { + hook_for_operation[operation].ok_or( + GraphCompileError::InvalidTransitionHook { + transition, + operation, + }, + ) + }) + .collect() + }) + .collect::>()?; + + Ok(Self { + program: Program { + topology, + root: binding.root, + activation: ActivationProgram { + state_expressions: binding + .states + .iter() + .map(|state| state.expression) + .collect(), + transition_expressions: binding + .transitions + .iter() + .map(|transition| transition.expression) + .collect(), + expressions, + conditions, + }, + behaviors, + state_behaviors, + transition_hooks, + }, + operations: OperationRemap { + behaviors: behavior_operations, + conditions: condition_operations, + hooks: hook_operations, + }, + }) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct LoadedGraph { + pub source: StateGraph, + pub binding: Binding, + pub runtime: CompiledGraph, +} + +#[derive(Resource, Clone, Debug)] +pub(crate) struct StockGraphs { + pub action: LoadedGraph, + pub motion: LoadedGraph, +} + +impl StockGraphs { + pub fn load(root: &Path, manifest: &GameAssets) -> Result { + Ok(Self { + action: load_graph(root, &manifest.action_graph)?, + motion: load_graph(root, &manifest.motion_graph)?, + }) + } +} + +fn load_graph(root: &Path, relative: &str) -> Result { + let path = root.join(relative); + let source = StateGraph::load(&path).map_err(|error| error.to_string())?; + let binding = Binding::from_graph(&source).map_err(|error| error.to_string())?; + let runtime = CompiledGraph::from_binding(&binding) + .map_err(|error| format!("Invalid runtime graph {}: {error}", path.display()))?; + Ok(LoadedGraph { + source, + binding, + runtime, + }) +} + +#[cfg(test)] +#[path = "tests/graph_runtime.rs"] +mod tests; diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs new file mode 100644 index 00000000..3de683b0 --- /dev/null +++ b/crates/skate-game/src/input.rs @@ -0,0 +1,42 @@ +//! Platform input adapter; no animation or physics state mutation here. +use crate::app::SimulationSet; +use bevy::prelude::*; + +mod controllers; +pub(crate) mod gesture_catalog; +mod platform; +pub(crate) use controllers::{ControllerInput, ControllerStatus}; + +pub(crate) struct InputPlugin; +impl Plugin for InputPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(PreUpdate, (poll_controllers, exit_requested)) + .add_systems(FixedUpdate, publish_actions.in_set(SimulationSet::Input)); + } +} + +fn poll_controllers(mut input: ResMut) { + let previous = input.status; + input.collect(std::array::from_fn(platform::poll)); + for (index, (&before, &after)) in previous.iter().zip(&input.status).enumerate() { + if before != after { + match after { + ControllerStatus::Ready => info!("Controller {index}: raw XInput ready"), + ControllerStatus::Unavailable(platform::DeviceError::Disconnected) => { + info!("Controller {index}: disconnected"); + } + _ => warn!("Controller {index}: {after:?}"), + } + } + } +} + +fn publish_actions(mut input: ResMut) { + input.publish_actions(); +} +fn exit_requested(keys: Res>, mut exit: MessageWriter) { + if keys.just_pressed(KeyCode::Escape) { + exit.write(AppExit::Success); + } +} diff --git a/crates/skate-game/src/input/controllers.rs b/crates/skate-game/src/input/controllers.rs new file mode 100644 index 00000000..ab7867e9 --- /dev/null +++ b/crates/skate-game/src/input/controllers.rs @@ -0,0 +1,98 @@ +//! Persistent platform-input owner. Device collection runs on host frames; +//! this does not choose the skater simulation clock or advance Derived input. +use super::platform::{DeviceError, DevicePacket}; +use bevy::prelude::Resource; +use skate_core::input::{ + controller::ActionMap, + gameplay_map::GameplayActions, + history::{DEVICE_SLOTS, HistoryRecord, PadHistory}, + pad::Pad, + xbox, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ControllerStatus { + Unpolled, + Ready, + Unavailable(DeviceError), +} + +#[derive(Resource)] +pub(crate) struct ControllerInput { + cache: [[HistoryRecord; DEVICE_SLOTS]; 2], + active: usize, + history: PadHistory, + pads: [Pad; DEVICE_SLOTS], + pub status: [ControllerStatus; DEVICE_SLOTS], + pub packet_numbers: [Option; DEVICE_SLOTS], + pub mapped_actions: [[f32; 18]; DEVICE_SLOTS], + pub publications: u64, + pub consumed_batches: u64, +} + +impl Default for ControllerInput { + fn default() -> Self { + Self { + cache: [[HistoryRecord::new(&[]); DEVICE_SLOTS]; 2], + active: 0, + history: PadHistory::new(), + pads: std::array::from_fn(|_| Pad::new()), + status: [ControllerStatus::Unpolled; DEVICE_SLOTS], + packet_numbers: [None; DEVICE_SLOTS], + mapped_actions: [[0.0; 18]; DEVICE_SLOTS], + publications: 0, + consumed_batches: 0, + } + } +} + +impl ControllerInput { + pub(crate) fn player_actions(&self) -> GameplayActions { + let device = self.status.iter().position(|status| *status == ControllerStatus::Ready).unwrap_or(0); + GameplayActions::from_pad(&self.pads[device]) + } + /// TU3 8296D288/8296D0D0: write the inactive cache, publish one four-device + /// batch. Even an unchanged platform packet is sampled; packet-number + /// deduplication would alter native Pad edge/repeat behavior. + pub(super) fn collect(&mut self, samples: [Result; DEVICE_SLOTS]) { + let next = self.active ^ 1; + for (device, sample) in samples.into_iter().enumerate() { + match sample { + Ok(packet) => { + // 8296D480 sets byte13 only when capability SubType == 7. + let values = xbox::convert(&packet.state, u8::from(packet.subtype == 7)); + self.cache[next][device] = HistoryRecord::new(&values); + self.packet_numbers[device] = Some(packet.number); + self.status[device] = ControllerStatus::Ready; + } + Err(error) => { + self.cache[next][device].clear_count(); + self.packet_numbers[device] = None; + self.status[device] = ControllerStatus::Unavailable(error); + } + } + } + self.active = next; + self.history.publish(&self.cache[self.active]); + self.publications += 1; + } + + /// TU3 82699230 drains first, then updates each Pad once. The game owns + /// this snapshot for subsequent consumers; Derived timers require the + /// actual actor timestep and state flags and are not driven by render dt. + pub(super) fn publish_actions(&mut self) -> bool { + if !self.history.drain_to_latest(&mut self.pads) { + return false; + } + for (device, pad) in self.pads.iter().enumerate() { + let mut actions = GameplayActions::from_pad(pad); + self.mapped_actions[device] = std::array::from_fn(|i| actions.value(64 + i as u32)); + } + self.consumed_batches += 1; + true + } +} + +#[cfg(test)] +#[path = "tests/controllers.rs"] +mod tests; diff --git a/crates/skate-game/src/input/gesture_catalog.rs b/crates/skate-game/src/input/gesture_catalog.rs new file mode 100644 index 00000000..b37b2c91 --- /dev/null +++ b/crates/skate-game/src/input/gesture_catalog.rs @@ -0,0 +1,153 @@ +//! Original TU3 gesture membership,82BA10C0 ->82BA07F0. +//! The catalog is constructed by82B98DB8 ->82B98F70, independently checked +//! against all270 raw map-insertion calls. PAT paths do not define these groups. +use skate_core::graph::intents::IntentMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Group { + Square, + Nose, + Tail, + Nose90, + Tail90, + NoseN90, + TailN90, +} + +impl Group { + /// Original82BA0F78 does case-sensitive constructor comparisons. + pub(crate) fn parse(name: &str) -> Result { + Ok(match name { + "Square" => Self::Square, + "Nose" => Self::Nose, + "Tail" => Self::Tail, + "90Nose" => Self::Nose90, + "90Tail" => Self::Tail90, + "N90Nose" => Self::NoseN90, + "N90Tail" => Self::TailN90, + _ => return Err(format!("HasGestureIntent has undefined group `{name}`")), + }) + } + + ///82BA07F0 tests map-key presence, never the intent value. All seven maps + ///contain the same30 unrotated keys; each rotated map adds its15 own keys. + pub(crate) fn has_intent(self, action_intents: &IntentMap) -> bool { + let additional: &[&str] = match self { + Self::Square | Self::Nose | Self::Tail => &[], + Self::Nose90 => &NOSE_90, + Self::Tail90 => &TAIL_90, + Self::NoseN90 => &NOSE_N90, + Self::TailN90 => &TAIL_N90, + }; + COMMON + .iter() + .chain(additional) + .any(|name| action_intents.contains_key(name)) + } +} + +// Square82B99060..99DAC; Tail82B99E20..9AAD0; Nose82B9AB40..9B7F0. +const COMMON: [&str; 30] = [ + "Ollie", + "PopShuvit", + "FSPopShuvit", + "VarialKickflip", + "VarialHeelflip", + "Hardflip", + "InwardHeelflip", + "360PopShuvit", + "FS360PopShuvit", + "360Flip", + "Laserflip", + "360Hardflip", + "360InwardHeelflip", + "Kickflip", + "Heelflip", + "Nollie", + "N_PopShuvit", + "N_FSPopShuvit", + "N_VarialKickflip", + "N_VarialHeelflip", + "N_Hardflip", + "N_InwardHeelflip", + "N_360PopShuvit", + "N_FS360PopShuvit", + "N_360Flip", + "N_Laserflip", + "N_360Hardflip", + "N_360InwardHeelflip", + "N_Kickflip", + "N_Heelflip", +]; +// Tail90 map5676,82B9B864..9BE84; commonkeys82B9BEF4..9CBA4. +const TAIL_90: [&str; 15] = [ + "90_Ollie", + "90_PopShuvit", + "90_FSPopShuvit", + "90_VarialKickflip", + "90_VarialHeelflip", + "90_Hardflip", + "90_InwardHeelflip", + "90_360PopShuvit", + "90_FS360PopShuvit", + "90_360Flip", + "90_Laserflip", + "90_360Hardflip", + "90_360InwardHeelflip", + "90_Kickflip", + "90_Heelflip", +]; +// TailN90 map9460,82B9CC18..9D238; commonkeys82B9D2A8..9DF58. +const TAIL_N90: [&str; 15] = [ + "N90_Ollie", + "N90_PopShuvit", + "N90_FSPopShuvit", + "N90_VarialKickflip", + "N90_VarialHeelflip", + "N90_Hardflip", + "N90_InwardHeelflip", + "N90_360PopShuvit", + "N90_FS360PopShuvit", + "N90_360Flip", + "N90_Laserflip", + "N90_360Hardflip", + "N90_360InwardHeelflip", + "N90_Kickflip", + "N90_Heelflip", +]; +// Nose90 map3784,82B9DFCC..9E5EC; commonkeys82B9E65C..9F30C. +const NOSE_90: [&str; 15] = [ + "90_Nollie", + "90_N_PopShuvit", + "90_N_FSPopShuvit", + "90_N_VarialKickflip", + "90_N_VarialHeelflip", + "90_N_Hardflip", + "90_N_InwardHeelflip", + "90_N_360PopShuvit", + "90_N_FS360PopShuvit", + "90_N_360Flip", + "90_N_Laserflip", + "90_N_360Hardflip", + "90_N_360InwardHeelflip", + "90_N_Kickflip", + "90_N_Heelflip", +]; +// NoseN90 map7568,82B9F380..9F9A0; commonkeys82B9FA10..BA06C0. +const NOSE_N90: [&str; 15] = [ + "N90_Nollie", + "N90_N_PopShuvit", + "N90_N_FSPopShuvit", + "N90_N_VarialKickflip", + "N90_N_VarialHeelflip", + "N90_N_Hardflip", + "N90_N_InwardHeelflip", + "N90_N_360PopShuvit", + "N90_N_FS360PopShuvit", + "N90_N_360Flip", + "N90_N_Laserflip", + "N90_N_360Hardflip", + "N90_N_360InwardHeelflip", + "N90_N_Kickflip", + "N90_N_Heelflip", +]; diff --git a/crates/skate-game/src/input/platform.rs b/crates/skate-game/src/input/platform.rs new file mode 100644 index 00000000..1b675331 --- /dev/null +++ b/crates/skate-game/src/input/platform.rs @@ -0,0 +1,104 @@ +//! Windows device transport. Raw signed axes/trigger bytes reach the TU3 +//! converter without Bevy/gilrs deadzones or normalized-axis reconstruction. +use skate_core::input::xbox::XboxState; + +pub(super) struct DevicePacket { + pub number: u32, + pub state: XboxState, + pub subtype: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DeviceError { + Disconnected, + State(u32), + Capabilities(u32), + #[cfg(not(windows))] + UnsupportedPlatform, +} + +#[cfg(windows)] +mod windows { + use super::*; + use std::mem::MaybeUninit; + + // ABI from the installed Windows SDK Xinput.h. No OS-owned pointers are + // retained and only successful calls permit reading output storage. + #[repr(C)] + struct Gamepad { + buttons: u16, + left_trigger: u8, + right_trigger: u8, + left_x: i16, + left_y: i16, + right_x: i16, + right_y: i16, + } + #[repr(C)] + struct State { + number: u32, + gamepad: Gamepad, + } + #[repr(C)] + struct Vibration { + left: u16, + right: u16, + } + #[repr(C)] + struct Capabilities { + device_type: u8, + subtype: u8, + flags: u16, + gamepad: Gamepad, + vibration: Vibration, + } + const _: () = assert!(size_of::() == 12); + const _: () = assert!(size_of::() == 16); + const _: () = assert!(size_of::() == 20); + + #[link(name = "xinput")] + unsafe extern "system" { + fn XInputGetState(index: u32, state: *mut State) -> u32; + fn XInputGetCapabilities(index: u32, flags: u32, capabilities: *mut Capabilities) -> u32; + } + + pub(super) fn poll(index: u32) -> Result { + let mut state = MaybeUninit::::uninit(); + // SAFETY: properly aligned writable storage with the SDK's exact C ABI. + let result = unsafe { XInputGetState(index, state.as_mut_ptr()) }; + if result == 1167 { + return Err(DeviceError::Disconnected); + } + if result != 0 { + return Err(DeviceError::State(result)); + } + let mut capabilities = MaybeUninit::::uninit(); + // TU3 8296D480 obtains subtype with capability flags=1. A capability + // failure stays explicit; it must not invent the converter's byte 13. + // SAFETY: same output-storage contract as above. + let result = unsafe { XInputGetCapabilities(index, 1, capabilities.as_mut_ptr()) }; + if result != 0 { + return Err(DeviceError::Capabilities(result)); + } + // SAFETY: both successful SDK calls initialized their complete structs. + let (state, capabilities) = unsafe { (state.assume_init(), capabilities.assume_init()) }; + Ok(DevicePacket { + number: state.number, + state: XboxState { + buttons: state.gamepad.buttons, + triggers: [state.gamepad.left_trigger, state.gamepad.right_trigger], + left: [state.gamepad.left_x, state.gamepad.left_y], + right: [state.gamepad.right_x, state.gamepad.right_y], + }, + subtype: capabilities.subtype, + }) + } +} + +pub(super) fn poll(index: usize) -> Result { + assert!(index < 4); + #[cfg(windows)] + return windows::poll(index as u32); + #[cfg(not(windows))] + Err(DeviceError::UnsupportedPlatform) +} diff --git a/crates/skate-game/src/input/tests/controllers.rs b/crates/skate-game/src/input/tests/controllers.rs new file mode 100644 index 00000000..5b191989 --- /dev/null +++ b/crates/skate-game/src/input/tests/controllers.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::input::platform::{DeviceError, DevicePacket}; +use skate_core::input::xbox::XboxState; + +impl ControllerInput { + /// Feed test packets through the production conversion, cache, history and Pad. + /// Only the operating-system read is supplied by the test. + pub(crate) fn sample_raw_for_test(&mut self, state: XboxState) { + self.collect([ + Ok(DevicePacket { + number: self.publications as u32 + 1, + state, + subtype: 1, + }), + Err(DeviceError::Disconnected), + Err(DeviceError::Disconnected), + Err(DeviceError::Disconnected), + ]); + assert!(self.publish_actions()); + } +} + +fn packet(number: u32, buttons: u16, left: [i16; 2]) -> Result { + Ok(DevicePacket { + number, + state: XboxState { + buttons, + triggers: [0; 2], + left, + right: [0; 2], + }, + subtype: 1, + }) +} + +#[test] +fn raw_packets_reach_stock_actions_without_losing_device_identity() { + let mut input = ControllerInput::default(); + input.collect([ + packet(1, 0x1000, [32767, 0]), + packet(2, 0x2000, [-32768, 0]), + packet(3, 0x4000, [0, 0]), + packet(4, 0x8000, [0, 0]), + ]); + assert!(input.publish_actions()); + assert!((input.mapped_actions[0][0] - 1.0).abs() < 0.000001); + assert!((input.mapped_actions[1][0] + 1.0).abs() < 0.000001); + assert_eq!(input.mapped_actions[0][16], 1.0); // GP_AFace + assert_eq!(input.mapped_actions[1][17], 1.0); // GP_BFace + assert_eq!(input.mapped_actions[2][14], 1.0); // GP_XFace + assert_eq!(input.mapped_actions[3][15], 1.0); // GP_YFace + assert_eq!(input.packet_numbers, [Some(1), Some(2), Some(3), Some(4)]); +} + +#[test] +fn unchanged_packet_numbers_do_not_suppress_publication_and_empty_drain_does_not_advance() { + let mut input = ControllerInput::default(); + for _ in 0..4 { + input.collect(std::array::from_fn(|_| packet(7, 0x1000, [0; 2]))); + assert!(input.publish_actions()); + } + assert_eq!(input.publications, 4); + assert_eq!(input.consumed_batches, 4); + assert_ne!(input.pads[0].records()[12][1] & 0x01000000, 0); + let prior = input.pads.clone(); + assert!(!input.publish_actions()); + assert_eq!(input.pads, prior); +} + +#[test] +fn disconnect_publishes_zero_count_and_reconnect_reinitializes_pad_records() { + let mut input = ControllerInput::default(); + input.collect(std::array::from_fn(|_| packet(1, 0x1000, [32767, 0]))); + input.publish_actions(); + input.collect(std::array::from_fn(|_| Err(DeviceError::Disconnected))); + input.publish_actions(); + assert_eq!(input.mapped_actions, [[0.0; 18]; 4]); + assert!(input.pads.iter().all(|pad| pad.count() == 0)); + input.collect(std::array::from_fn(|_| packet(1, 0, [0; 2]))); + input.publish_actions(); + assert_eq!(input.mapped_actions, [[0.0; 18]; 4]); + assert_eq!(input.pads[0].records()[12], [0, 0, 0, 1]); +} + +#[test] +fn alternate_guitar_subtype_uses_verified_conversion_mask_and_errors_stay_visible() { + let mut input = ControllerInput::default(); + let special = DevicePacket { + number: 1, + state: XboxState { + buttons: 0, + triggers: [255; 2], + left: [0; 2], + right: [-32768, 32767], + }, + subtype: 7, + }; + input.collect([ + Ok(special), + Err(DeviceError::Capabilities(5)), + Err(DeviceError::Disconnected), + Err(DeviceError::Disconnected), + ]); + input.publish_actions(); + assert_eq!(input.mapped_actions[0][6..8], [0.0; 2]); + assert_eq!(input.mapped_actions[0][3..5], [0.0; 2]); + assert_eq!( + input.status[1], + ControllerStatus::Unavailable(DeviceError::Capabilities(5)) + ); + assert_eq!(input.mapped_actions[1], [0.0; 18]); +} diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs new file mode 100644 index 00000000..ab2b46f1 --- /dev/null +++ b/crates/skate-game/src/main.rs @@ -0,0 +1,60 @@ +mod animation; +mod animation_pose; +mod app; +mod assets; +mod camera; +mod config; +mod graph_host; +mod graph_runtime; +mod input; +mod physics; +mod skater_animation; +mod verification; +mod world; + +fn main() -> bevy::app::AppExit { + let config = match config::Config::from_env() { + Ok(config) => config, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + if let Err(error) = skate_data::input_config::StockGameplayConfig::load(&config.asset_root) { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + let manifest = match skate_data::GameAssets::load(&config.asset_root) { + Ok(manifest) => manifest, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + let graphs = match graph_runtime::StockGraphs::load(&config.asset_root, &manifest) { + Ok(graphs) => graphs, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + let physics = match physics::GamePhysics::load(&config.asset_root) { + Ok(physics) => physics, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + let skater = match physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, "easy") { + Ok(skater) => skater, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + app::build(config, manifest, graphs, physics, skater).run() +} + +#[cfg(test)] +#[path = "tests/action_host.rs"] +mod action_host_tests; diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs new file mode 100644 index 00000000..764558f5 --- /dev/null +++ b/crates/skate-game/src/physics.rs @@ -0,0 +1,312 @@ +//! Host game physics ownership and schedule. Physical calculations stay in core. +mod animated_skeleton; +mod air_reckoning; +mod air_phase; +mod air_trajectory; +pub(crate) mod camera_output; +mod clock; +mod colliders; +mod controls; +mod foot_ik; +mod footplant; +mod foot_ik_queries; +mod foot_physical_output; +pub(crate) mod ground; +mod render_pose; +mod riding_outputs; +mod skateboard_controller; +mod skater; +mod skeleton_body; +mod skeleton_air; +mod skeleton_controller; +mod skeleton_colliders; +mod skeleton_feedback; +mod skeleton_input_runtime; +mod skeleton_output; +mod solve; +pub(crate) use skater::SkaterRuntime; +mod animation_feedback; +mod animation_feedback_settings; +mod animation_input; +mod animation_phase; +mod landing_quality; +mod frame; +mod ground_phase; +mod ground_animation; +mod slide_state; +mod ground_exit; +mod ground_runtime; +mod input_phase; +mod player_input; +mod player_state; +mod settings; +mod wipeout; +mod wipeout_states; +mod teleport_state; +mod offboard; +mod known_air; +use crate::{ + app::{FrameSet, SimulationSet}, + world::PlayerRoot, +}; +use bevy::prelude::*; +pub(crate) use controls::PlayerControls; +use riding_outputs::RidingOutputs; +use settings::PhysicsSettings; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, + board_runtime::{BoardMotion, BoardRuntime}, + board_world::{BoardWorld, ContactRetentionSettings}, + collision::WorldContactSettings, + drive_frames::RetailAffineTransform, + }, +}; +use skate_data::collections::Collections; + +#[derive(Resource)] +pub(crate) struct GamePhysics { + clock: clock::SimulationClock, + pub board: BoardRuntime, + pub riding: RidingOutputs, + world: BoardWorld, + settings: PhysicsSettings, + animation_profile: animation_phase::AnimationProfile, + query: WorldContactSettings, + retention: ContactRetentionSettings, + pub ticks: u64, + pub contact_count: usize, + pub failed: bool, + /// ProcessedPhysIn reset82BF9EF0 sets0x2000; initial stancebit20 is clear. + /// Animation packet publication owns subsequent stance-bit updates. + pub processed_flags_2468: u32, + /// Toolkit ctor82C0680C clears8384bit7; wipeout entry/exit owns changes. + pub board_wiping_out: bool, +} + +impl GamePhysics { + pub(crate) fn world(&self) -> &BoardWorld { + &self.world + } + + pub(crate) fn world_triangles(&self) -> &[skate_core::physics::board_world::WorldTriangle] { + self.world.triangles() + } + + pub fn load(asset_root: &std::path::Path) -> Result { + let data = Collections::load(asset_root)?; + let settings = PhysicsSettings::load(&data)?; + let animation_profile = animation_phase::AnimationProfile::load(&data, "easy")?; + let spawn = RetailAffineTransform { + translation: Vector3::new( + 0.0, + ground::HEIGHT + settings.wheel_radius - settings.authored[0].translation.y, + 0.0, + ), + ..RetailAffineTransform::IDENTITY + }; + let board = BoardRuntime::new( + settings.masses, + settings.authored, + spawn, + settings.step.simulation, + BoardMotion::Active, + ); + let world = ground::world(settings.floor_material); + let processed_flags_2468 = 0x2000; + let riding = RidingOutputs::load(&data, &board, processed_flags_2468)?; + let (query, retention) = ground::query_settings(); + Ok(Self { + clock: clock::SimulationClock::default(), + board, + riding, + world, + settings, + animation_profile, + query, + retention, + ticks: 0, + contact_count: 0, + failed: false, + processed_flags_2468, + board_wiping_out: false, + }) + } + + #[cfg(test)] + fn advance_board(&mut self) -> Result<(), String> { + self.board.clear_forces(); + self.riding.start_wheel_queries(&self.board, &self.world)?; + self.riding.finish_wheel_queries()?; + let volumes = colliders::world_volumes(&self.board, &self.settings); + let contacts = self + .world + .query_primitives(&volumes, self.query, self.retention); + self.contact_count = contacts.len(); + // The graph/ground-state consumer must supply recovered steering and + // forces before this is playable. This tick verifies physical assembly. + self.board.advance(contacts, [0.0; 2], self.settings.step); + self.riding.finish_post_physics( + &mut self.board, + self.board_wiping_out, + self.processed_flags_2468, + self.settings.step.simulation.time_step, + )?; + self.ticks += 1; + if self.board.bodies().iter().any(|body| { + let p = body.rates.position; + !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() + }) { + self.failed = true; + return Err(format!( + "Board solver produced a non-finite pose on tick {}", + self.ticks + )); + } + Ok(()) + } +} + +pub(crate) struct PhysicsPlugin; +impl Plugin for PhysicsPlugin { + fn build(&self, app: &mut App) { + let period = app.world().resource::().clock.period(); + app.insert_resource(Time::::from_duration(period)) + .init_resource::() + .add_systems( + FixedUpdate, + controls::sample.in_set(SimulationSet::Controls), + ) + .add_systems(FixedUpdate, advance.in_set(SimulationSet::Physics)) + .add_systems(Update, present.in_set(FrameSet::Physics)); + } +} + +fn advance( + mut physics: ResMut, + mut skater: ResMut, + mut controls: ResMut, + graphs: Res, + input: Res, + mut camera: ResMut, + mut cadence: ResMut>, + mut exit: MessageWriter, +) { + if physics.failed { + return; + } + let mut actions = input.player_actions(); + let input_available = input + .status + .iter() + .any(|s| *s == crate::input::ControllerStatus::Ready); + if let Err(message) = frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + input_available, + &mut camera, + ) { + physics.failed = true; + error!( + "{message}; tick={}; mapped_input={:?}; force_mode={}; board_axis_y={}; flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x}", + physics.ticks, + input.mapped_actions, + skater.skeleton_input.force_mode, + skater.skeleton_input.drive_frames[0][2][1], + skater.player_input.processed.flags_2468, + skater.player_input.processed.flags_2472, + skater.player_input.processed.flags_2476, + skater.player_input.processed.flags_2480, + skater.player_input.processed.flags_2484, + ); + exit.write(AppExit::error()); + } + cadence.set_timestep(physics.clock.period()); +} + +impl GamePhysics { + fn finish_skater(&mut self, skater: &mut SkaterRuntime) -> Result<(), String> { + //Skateboard::UpdatePostPhysics82C02158 prepares the wall probe for + //the next StartBoard, using this input phase's cached deck toolkit. + self.riding.probes.prepare_wall( + &skater.player_input.processed, + skater + .player_input + .toolkit + .as_ref() + .ok_or("Postphysics wall probe requires the current board toolkit")?, + ); + self.riding.finish_post_physics( + &mut self.board, + self.board_wiping_out, + self.processed_flags_2468, + self.settings.step.simulation.time_step, + )?; + let partial = skateboard_controller::partial_request( + &skater.skateboard_controller, + &self.riding.ground, + ); + skeleton_feedback::publish(self, skater, partial); + if skater.player_state.current() == skate_core::player::state::PhysicalStateId::WipeoutGround { + wipeout_states::post_physics(skater); + } + if skater.player_state.current() == skate_core::player::state::PhysicalStateId::PhysicsAir { + air_phase::update_apex(self, &mut skater.air_state); + } + if skater.player_state.current() == skate_core::player::state::PhysicalStateId::KnownAir { + known_air::post_physics(self, skater)?; + } + wipeout::check_after_physics(self, skater)?; + let compression = skater.skeleton_output.average_compressions(&self.board); + render_pose::publish(self, skater, compression)?; + self.ticks += 1; + let invalid = self + .board + .bodies() + .iter() + .chain(skater.skeleton.bodies()) + .any(|body| { + let position = body.rates.position; + !position.x.is_finite() || !position.y.is_finite() || !position.z.is_finite() + }); + if invalid { + self.failed = true; + return Err(format!( + "Shared skater solver produced a non-finite pose on tick{}", + self.ticks + )); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "tests/physics_startup.rs"] +mod tests; + +#[cfg(test)] +#[path = "tests/air_playback.rs"] +mod air_tests; + +#[cfg(test)] +#[path = "tests/wipeout_playback.rs"] +mod wipeout_tests; + +fn present( + physics: Res, + skater: Res, + mut roots: Query<&mut Transform, With>, +) { + if physics.failed { + return; + } + for mut root in &mut roots { + *root = Transform::from_matrix(crate::animation::native_matrix( + skater.animated_skeleton.roots.animation_to_world, + )); + } +} diff --git a/crates/skate-game/src/physics/air_phase.rs b/crates/skate-game/src/physics/air_phase.rs new file mode 100644 index 00000000..d423ead5 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase.rs @@ -0,0 +1,149 @@ +//! PhysicsAir200 production lifecycle82D34388/82D346C0/82D346A0. +//! Uses the live player, board, skeleton and selector; no alternate air solver. +mod input; +mod board; +mod skeleton; +pub(crate) use input::AirSettings; +pub(crate) use input::selector_input; +pub(crate) use input::launch_info; +use super::{GamePhysics, SkaterRuntime}; +use skate_core::air::state::{ + AirMath, AirTrajectory, PhysicsAirState, PhysicsAirMath, + angle_between_vectors, calculate_velocity_from_jump, integrate_trajectory_fixed_step, + wrap_signed_angle, +}; + +///Original initializer82F826B0/822F8B40. This COM trajectory acceleration is +///distinct from the world's separately supplied rigid-body gravity setting. +const COM_ACCELERATION: [f32; 4] = [0.0, f32::from_bits(0xc11c_cccd), 0.0, 0.0]; + +pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = input::frame(skater); + let life = &mut skater.ground_lifecycle; + life.skeleton_controller.flag_18 = true; + life.skeleton_controller.request(7, &mut skater.skeleton_collision)?; + skater.foot_ik.enable_feet(true); + life.skeleton_controller.request(6, &mut skater.skeleton_collision)?; + physics.board.hook_mut().drive.enable_angular_only(&mut life.board_animated_290); + skater.footplant.enabled = false; + skater.footplant.reset(); + let state = &mut skater.air_state; + state.selector_latch_174 = false; + state.reached_apex = false; + state.trajectory_query_countdown = 0; + state.landing_normal = [0.0, 1.0, 0.0, 0.0]; + state.time_in_state = 0.0; + state.max_y = physics.board.part_transforms()[6].translation.y; + state.start_y = frame.ground_position_y_500; + let (use_com, heading) = if frame.previous_physics_category_2516 == 400 + || frame.previous_physics_state_2504 == 701 { + (frame.flags_2468 & 0x4000 == 0, false) + } else { + let selected = !matches!(frame.previous_physics_state_2504, 100 | 103 | 201); + (selected, selected) + }; + state.use_centre_of_mass_velocity = use_com; + if use_com { + let mut velocity = frame.trajectory_velocity_608; + if frame.previous_physics_state_2504 == 500 { + velocity[1] = AirMath.minimum_vminfp(velocity[1] * f32::from_bits(0x3f26_6666), 3.0); + } + if frame.frames_since_jump_correction_2576 < 12 { + velocity = calculate_velocity_from_jump(&frame, frame.frames_since_jump_correction_2576, &mut AirMath); + } + state.centre_of_mass_trajectory = AirTrajectory { + position: frame.trajectory_position_592, velocity, + acceleration: COM_ACCELERATION, scalar_48: -1.0, + }; + } + if heading { skater.animated_skeleton.roots.initialize_heading = true; } + Ok(()) +} + +pub(super) fn exit(skater: &mut SkaterRuntime) { + skater.air_state.use_centre_of_mass_velocity = false; + skater.air_reckoning.state.reset_spin(); +} + +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = input::frame(skater); + skater.ground_lifecycle.skeleton_controller.update_air( + skater.player_input.processed.flags_2472, + frame.current_velocity_400[1], &mut skater.skeleton_collision, + )?; + let state = &mut skater.air_state; + state.trajectory_query_countdown = state.trajectory_query_countdown.wrapping_sub(1); + if state.trajectory_query_countdown <= 0 && frame.flags_2468 & 0x8000 == 0 + && frame.previous_physics_state_2504 != 0 && !skater.trajectory.selector.pending() { + let mut info = input::launch_info(physics, skater)?; + if frame.frames_since_jump_correction_2576 < 12 { + info.start_velocity = calculate_velocity_from_jump(&frame, frame.frames_since_jump_correction_2576, &mut AirMath); + } else if skater.air_state.use_centre_of_mass_velocity { + //COM helper82D34570 repeats Fill, then offsets start/board and + //narrows the cone. Caller clears player_jumped again before Launch. + let velocity = skater.air_state.centre_of_mass_trajectory.velocity; + info.start_velocity = std::array::from_fn(|i| COM_ACCELERATION[i].mul_add(f32::from_bits(0x3c88_8889), velocity[i])); + info.start_position_override = info.animation_com_position; + info.start_position_override[1] += f32::from_bits(0xbf26_6666); + info.board_position_override = info.animation_com_position; + info.board_position_override[1] += f32::from_bits(0xbf4c_cccd); + info.use_position_override = true; + info.trajectory_count = 5; + info.cone_angle_x *= f32::from_bits(0x3ecc_cccd); + info.cone_angle_z *= f32::from_bits(0x3ecc_cccd); + } + info.player_jumped = false; + let input = input::selector_input(physics, skater)?; + skater.trajectory.launch(info, input, &physics.world)?; + skater.air_state.trajectory_query_countdown = 7; + } + let input = input::selector_input(physics, skater)?; + skater.trajectory.update(input, &physics.world)?; + let current = skater.air_reckoning.fields(&physics.riding).current_landing_normal_1152; + let normal = skater.trajectory.selector.suggested_normal().unwrap_or(current); + let state = &mut skater.air_state; + state.landing_normal = normal; + let settings = &skater.air_settings.state; + let mut blend = if wrap_signed_angle(angle_between_vectors(normal, current)) <= settings.landing_normal_angle_limit_444 { + settings.landing_normal_blend_388 + } else { 0.0 }; + if !state.use_centre_of_mass_velocity { + if !state.selector_latch_174 { + state.selector_latch_174 = skater.trajectory.selector.all_predictions_missed(); + } + if state.selector_latch_174 { + state.use_centre_of_mass_velocity = true; + state.centre_of_mass_trajectory = AirTrajectory { + position: frame.trajectory_position_592, velocity: frame.trajectory_velocity_608, + acceleration: COM_ACCELERATION, scalar_48: -1.0, + }; + } + } + if frame.state_timer_2664 <= 0.0 { blend = 0.0; } + else if frame.flags_2468 & 0x4000 != 0 { state.use_centre_of_mass_velocity = false; } + let spin = (frame.body_spin_input_2640 * settings.body_spin_scale_428) * f32::from_bits(0x3c8e_fa35); + let target_spin = settings.body_spin_over_time_320.evaluate(state.time_in_state * 2.0) * spin; + skater.air_reckoning.update( + &mut physics.riding, &skater.player_input.processed, + skater.animation_input.extra.physical_body_spin, normal, blend, target_spin, 0.0, + )?; + if skater.air_state.use_centre_of_mass_velocity { + integrate_trajectory_fixed_step(&mut skater.air_state.centre_of_mass_trajectory); + skeleton::advance(physics, skater, Some(skater.air_state.centre_of_mass_trajectory.position))?; + } else { skeleton::advance(physics, skater, None)?; } + board::advance(physics, skater, frame)?; + skater.ground_lifecycle.skeleton_ground_16388 = true; + let height = physics.board.part_transforms()[6].translation.y; + let state = &mut skater.air_state; + if !(state.max_y > height) { state.max_y = height; } + state.time_in_state += frame.delta_time_2604; + Ok(()) +} + +///82D34DD8. The common post-physics coordinator immediately follows this +///apex latch with Wipeout::CheckForAirWipeout(false), after collision feedback. +pub(super) fn update_apex(physics: &GamePhysics, state: &mut PhysicsAirState) { + if !state.reached_apex && physics.board.bodies()[6].rates.linear_velocity.y < 0.0 { + state.reached_apex = true; + } +} diff --git a/crates/skate-game/src/physics/air_phase/board.rs b/crates/skate-game/src/physics/air_phase/board.rs new file mode 100644 index 00000000..c5d9dc36 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase/board.rs @@ -0,0 +1,23 @@ +//!82D34AB0 consumes the completed Air reckoning's collision normal1216. +use super::*; +use skate_core::{air::state::PhysicsAirFrame, math::Vector3, + physics::force_queue::QueuedPointForce, riding::collision_response::CollisionResponsePhysical}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime, frame: PhysicsAirFrame) -> Result<(), String> { + let p = &skater.player_input.processed; + skater.ground.steering.update(0.0, skater.air_settings.steering_blend, p.flags_2468,p.flags_2472); + let t = skater.player_input.toolkit.as_ref().ok_or("Air collision requires the actual BoardToolkit")?; + let normal = physics.riding.reckoning.ground_normal; + if let Some(force) = skater.ground_runtime.calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, collision_displacement: p.collision_pose_error_736.map(f32::from_bits), + velocity: p.vectors_400_416[1].map(f32::from_bits), forward: t.travel_direction, + up: p.vectors_544_560_592_608[0].map(f32::from_bits), ground_normal: [normal.x,normal.y,normal.z,0.0], + time_step: p.timestep_2604, mass: t.total_mass, + }) { + let xyz = |v:[f32;4]| Vector3::new(v[0],v[1],v[2]); + physics.board.forces_mut().append(QueuedPointForce {tag:15,force_world:xyz(force.force_2528),point_body:xyz(force.point_2544)}); + } else if !skater.air_state.use_centre_of_mass_velocity && frame.frames_since_jump_correction_2576 < 12 { + let velocity = calculate_velocity_from_jump(&frame,frame.frames_since_jump_correction_2576,&mut AirMath); + skater.ground_runtime.set_animated_velocity(&mut physics.board,velocity); + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/air_phase/input.rs b/crates/skate-game/src/physics/air_phase/input.rs new file mode 100644 index 00000000..1e352802 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase/input.rs @@ -0,0 +1,78 @@ +//! Actual Processed/animation/stock owners read by PhysicsAir and its launch. +use super::*; +use skate_core::{air::{state::{PhysicsAirFrame, PhysicsAirSettings}, trajectory::{LaunchInfo,SelectorInput}},point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub(crate) struct AirSettings { + pub state: PhysicsAirSettings, + pub steering_blend: f32, + pub grind_lock_distance: [f32; 5], +} +impl AirSettings { + pub fn load(data: &Collections) -> Result { + let values = data.words::<16>("physics_airstates", "default", "BodySpinInputFilter")?.map(f32::from_bits); + let f = |name| data.float("physics_airstates", "default", name); + let mut distances = [0.0; 5]; + for (i, mode) in ["easy", "normal", "hardcore", "motorized", "test"].into_iter().enumerate() { + distances[i] = data.float("physics_mode", mode, "GrindLockDist")?; + } + Ok(Self { + state: PhysicsAirSettings { + body_spin_over_time_320: PointGraph {x: values[..8].try_into().unwrap(), y: values[8..].try_into().unwrap()}, + landing_normal_blend_388: f("SpeedToAlignToGround_PhysAir")?, + body_spin_scale_428: f("MaxSpinSpeed")?, + landing_normal_angle_limit_444: f("DontAlignAnglePhysicsAir")?, + }, + steering_blend: data.float("physics_steering", "default", "SteeringTiltBlending")?, + grind_lock_distance: distances, + }) + } +} +pub(super) fn frame(skater: &SkaterRuntime) -> PhysicsAirFrame { + let p = &skater.player_input.processed; + PhysicsAirFrame { + current_velocity_400: p.vectors_400_416[0].map(f32::from_bits), + ground_position_y_500: f32::from_bits(p.vectors_464_480_496_512_528[2][1]), + trajectory_position_592: p.vectors_544_560_592_608[2].map(f32::from_bits), + trajectory_velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + jump_velocity_848: skater.player_state.post.jump_reference.map(f32::from_bits), + flags_2468: p.flags_2468, previous_physics_state_2504: p.state_2504 as i32, + previous_physics_category_2516: p.category_2516 as i32, + frames_since_jump_correction_2576: skater.player_state.post.jump_fix_frames as i32, + delta_time_2604: p.timestep_2604, body_spin_input_2640: skater.animation_input.fields.body_spin, + gravity_y_2648: p.gravity_2648, state_timer_2664: p.state_timer_2664, + } +} +pub(crate) fn selector_input(physics: &GamePhysics, skater: &SkaterRuntime) -> Result { + let p = &skater.player_input.processed; + let g = physics.settings.step.simulation.gravity_acceleration; + Ok(SelectorInput { + gravity: [g.x,g.y,g.z,0.0], + ground_normal: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + contact_position: p.vectors_464_480_496_512_528[2].map(f32::from_bits), + heading_direction: p.vectors_464_480_496_512_528[3].map(f32::from_bits), + reference_up: p.vectors_544_560_592_608[0].map(f32::from_bits), + board_vertical_velocity: f32::from_bits(p.vectors_400_416[0][1]), + directional_input: p.transition_2636, previous_physics_state: p.state_2504, + flags_2472: p.flags_2472, flags_2476: p.flags_2476, + offboard_flags_1776: p.external_physics_1616.flags, + grind_lock_distance: *skater.air_settings.grind_lock_distance.get(p.state_variant_index_2528 as usize) + .ok_or_else(|| format!("Invalid trajectory physics mode {}",p.state_variant_index_2528))?, + }) +} +pub(crate) fn launch_info(physics: &GamePhysics, skater: &SkaterRuntime) -> Result { + let p = &skater.player_input.processed; + let toolkit = skater.player_input.toolkit.as_ref().ok_or("Air launch requires current BoardToolkit")?; + Ok(LaunchInfo { + reckoning_transform: physics.riding.reckoning_frames.system, + reckoning_inverse: physics.riding.reckoning_frames.inverse_system, + start_velocity: p.vectors_400_416[0].map(f32::from_bits), + com_velocity: p.prepared_jump_704.map(f32::from_bits), + skeleton_vector_160: skater.animated_skeleton.board_frames.local_centre_of_mass, + skeleton_vector_176: skater.animated_skeleton.board_frames.local_board_position, + board_position: toolkit.deck[3], animation_com_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + cone_angle_x: skater.trajectory.settings.cone_x, cone_angle_z: skater.trajectory.settings.cone_z, + timestep: p.timestep_2604, trajectory_count: if p.flags_2468 & 0x2000 != 0 {7} else {1}, + ..LaunchInfo::default() + }) +} diff --git a/crates/skate-game/src/physics/air_phase/skeleton.rs b/crates/skate-game/src/physics/air_phase/skeleton.rs new file mode 100644 index 00000000..a36a5670 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase/skeleton.rs @@ -0,0 +1,25 @@ +//! Same live Skeleton owners as Ground; Air chooses its original update method. +use super::*; +use crate::physics::{input_phase, skeleton_input_runtime::SkeletonOwners}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime, com: Option<[f32;4]>) -> Result<(), String> { + let collision = input_phase::collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, 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, + }; + let packet = &skater.animation.packet; + if let Some(com) = com { + skater.skeleton_input.update_known_air( + &mut skater.skeleton_air,&mut physics.board,&physics.riding.reckoning_frames.system,com,packet, + &mut skater.player_input.processed,&mut owners,&packet.hierarchy,&collision,physics.settings.step.simulation, + )?; + } else { + skater.skeleton_input.update_animated( + &mut skater.skeleton_air,&mut physics.board,&physics.riding.reckoning_frames.system, + &mut skater.player_input.processed,&mut owners,&packet.hierarchy,&collision,physics.settings.step.simulation,false, + )?; + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/air_reckoning.rs b/crates/skate-game/src/physics/air_reckoning.rs new file mode 100644 index 00000000..5ed417b2 --- /dev/null +++ b/crates/skate-game/src/physics/air_reckoning.rs @@ -0,0 +1,84 @@ +//! Production stock settings and live-input adapter for Reckoning82D8DBD8. +mod settings; +use super::riding_outputs::RidingOutputs; +use skate_core::{ + air::{ + reckoning::{self, AirState, Input, Settings}, + state::PhysicsAirReckoningFields, + }, + player::input_phase::ProcessedPhysicsInput, +}; +use skate_data::collections::Collections; + +pub(crate) struct AirReckoning { + pub state: AirState, + settings: Settings, + modes: [settings::Mode; 5], +} +impl AirReckoning { + pub fn load(data: &Collections) -> Result { + let (settings, modes) = settings::load(data)?; + Ok(Self { + state: AirState::new(), + settings, + modes, + }) + } + ///The Ground/PhysicsAir lifecycles borrow this canonical pair for their + ///native spin resets. Filters and matrix1008 remain owned by RidingOutputs. + pub fn fields(&self, riding: &RidingOutputs) -> PhysicsAirReckoningFields { + let up = riding.reckoning.up; + let normal = riding.reckoning.ground_normal; + PhysicsAirReckoningFields { + current_landing_normal_1152: [up.x, up.y, up.z, 0.0], + collision_reference_normal_1216: [normal.x, normal.y, normal.z, 0.0], + body_spin_angle_1568: self.state.spin_angle, + body_spin_speed_1572: self.state.spin_speed, + } + } + ///Called by the actual PhysicsAir/KnownAir update before its Board phase. + ///Return the newly published normal1216, not the caller's earlier snapshot. + pub fn update( + &mut self, + riding: &mut RidingOutputs, + processed: &ProcessedPhysicsInput, + physical_body_spin: f32, + landing_normal: [f32; 4], + normal_blend: f32, + target_spin: f32, + flip_request: f32, + ) -> Result { + let mode = self + .modes + .get(processed.state_variant_index_2528 as usize) + .ok_or_else(|| { + format!( + "Undefined physics mode {}", + processed.state_variant_index_2528 + ) + })?; + reckoning::update( + &mut riding.reckoning, + &mut riding.reckoning_frames, + &mut riding.body_spin, + &mut self.state, + &self.settings, + &Input { + landing_normal, + normal_blend, + target_spin, + flip_request, + com_to_deck: processed.animation_com_to_deck_752.map(f32::from_bits), + timestep: processed.timestep_2604, + physical_body_spin, + grind_adjusted_body_spin: processed.grind_adjusted_body_spin_2644, + additive_spin: processed.flags_2484 & (1 << 15) != 0, + direct_spin: processed.flags_2472 & (1 << 28) != 0, + reverse_stance: processed.flags_2468 & (1 << 20) != 0, + easy_body_spins: mode.easy_body_spins, + perfect_body_flips: mode.perfect_body_flips, + }, + ); + Ok(self.fields(riding)) + } +} diff --git a/crates/skate-game/src/physics/air_reckoning/settings.rs b/crates/skate-game/src/physics/air_reckoning/settings.rs new file mode 100644 index 00000000..1ee17a06 --- /dev/null +++ b/crates/skate-game/src/physics/air_reckoning/settings.rs @@ -0,0 +1,85 @@ +//! Stock classes are verified against original8289D180 and828A0054..00A0: +//! physics_reckoning7CE1B6B581709C6F, physics_bodyspin05EC9CB03A5B2EEE. +use skate_core::{ + air::{body_flip::BodyFlipSettings, body_spin::BodySpinSettings, reckoning::Settings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +#[derive(Clone, Copy)] +pub(super) struct Mode { + pub easy_body_spins: bool, + pub perfect_body_flips: bool, +} +pub(super) fn load(data: &Collections) -> Result<(Settings, [Mode; 5]), String> { + let float = |class, field| data.float(class, "default", field); + let control = data + .words::<4>("physics_reckoning", "default", "GroundNormalSmoothing")? + .map(f32::from_bits); + let mut modes = Vec::with_capacity(5); + //Player ctor82DB1B08..1B78 installs these exact physics_mode keys. + for name in ["easy", "normal", "hardcore", "motorized", "test"] { + modes.push(Mode { + easy_body_spins: data.boolean("physics_mode", name, "EasyBodySpins")?, + perfect_body_flips: data.boolean("physics_mode", name, "PerfectBodyFlips")?, + }); + } + Ok(( + Settings { + ground_normal_smoothing: control, + max_up_angle_delta: negative_graph( + data, + "physics_reckoning", + "Air_MaxUpVectAngleDelta", + )?, + tilt_vs_rotation: graph(data, "TiltVsRotAir")?, + tilt_vs_slope: graph(data, "TiltVsSlopeAir")?, + body_spin: BodySpinSettings { + derivative_floor: float("physics_bodyspin", "MinDerivativeScalar")?, + acceleration_limit: float("physics_bodyspin", "MaxDeltaOppositeDirection")?, + //Original82D8BE38 copies layouts160,240,320,480,400,80,0. + curves: [ + negative_graph(data, "physics_bodyspin", "Hash_BEA30B5DC6AFF26A")?, + negative_graph(data, "physics_bodyspin", "MaxDeltaVsTime")?, + negative_graph(data, "physics_bodyspin", "MaxDeltaForAutoVsTime")?, + negative_graph(data, "physics_bodyspin", "Hash_3974F0228331EB22")?, + negative_graph(data, "physics_bodyspin", "DerivativeBodySpinVsTime")?, + negative_graph(data, "physics_bodyspin", "Hash_D7C6855B7814D048")?, + negative_graph(data, "physics_bodyspin", "PropBodySpinVsTime")?, + ], + //82F82690 splats original821647E0 into830BD300. + input_fade_threshold: [f32::from_bits(0x3780_0000); 4], + }, + body_flip: BodyFlipSettings { + smoothing: Some(float("physics_reckoning", "FlipSpeedSmoothingFactor")?), + maximum_speed: Some(float("physics_reckoning", "FlipMaxSpeed")?), + spin_scale: Some(float("physics_reckoning", "FlipBodySpinScalar")?), + //All three attributes are mandatory above; no missing-attribute + //branch is reachable through this production settings loader. + missing_attribute_value: 0.0, + }, + }, + modes + .try_into() + .map_err(|_| "Expected five physics modes".to_owned())?, + )) +} +fn negative_graph(data: &Collections, class: &str, name: &str) -> Result, String> { + let values = data + .words::<20>(class, "default", name)? + .map(f32::from_bits); + //Source directly calls PointGraphEval on +16/+48, ignoring sign metadata. + Ok(PointGraph { + x: values[4..12].try_into().unwrap(), + y: values[12..20].try_into().unwrap(), + }) +} +fn graph(data: &Collections, name: &str) -> Result, String> { + let values = data + .words::<16>("physics_reckoning", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: values[..8].try_into().unwrap(), + y: values[8..].try_into().unwrap(), + }) +} diff --git a/crates/skate-game/src/physics/air_trajectory/mod.rs b/crates/skate-game/src/physics/air_trajectory/mod.rs new file mode 100644 index 00000000..e7cb2dcb --- /dev/null +++ b/crates/skate-game/src/physics/air_trajectory/mod.rs @@ -0,0 +1,76 @@ +//! Real stock-settings and BoardWorld adapter for the player trajectory selector. +mod settings; +mod world; +use skate_core::{ + air::trajectory::{ + LaunchInfo, QueryRequest, QueryResult, SelectorInput, SelectorSettings, TrajectorySelector, + query_trajectory, + }, + physics::board_world::BoardWorld, +}; +use skate_data::collections::Collections; + +pub struct AirTrajectoryRuntime { + pub selector: TrajectorySelector, + pub settings: SelectorSettings, + pending_results: Option>, +} +impl AirTrajectoryRuntime { + ///Full82E099A0 query shared by trajectory and Footplant callers. + pub fn query(world: &BoardWorld, request: QueryRequest) -> Result { + query_trajectory( + request, + |start, end, radius| world::line(world, start, end, radius), + |position, radius| world::nearby(world, position, radius), + ) + } + pub fn load(collections: &Collections) -> Result { + Ok(Self { + selector: TrajectorySelector::new(), + settings: settings::load(collections)?, + pending_results: None, + }) + } + pub fn launch( + &mut self, + info: LaunchInfo, + input: SelectorInput, + world: &BoardWorld, + ) -> Result { + let launched = self.selector.launch(info, input, &self.settings)?; + if launched { + self.submit(world)?; + } + Ok(launched) + } + pub fn update(&mut self, input: SelectorInput, world: &BoardWorld) -> Result { + let Some(results) = self.pending_results.take() else { + return Ok(self.selector.update_without_completion()); + }; + let valid = self.selector.complete_batch( + &results, + input, + &self.settings, + world::topology(world), + |start, end, radius| world::line(world, start, end, radius), + )?; + if self.selector.pending() { + self.submit(world)?; + } + Ok(valid) + } + pub fn cancel_pending(&mut self) { + self.pending_results = None; + self.selector.cancel_pending(); + } + fn submit(&mut self, world: &BoardWorld) -> Result<(), String> { + let results = self + .selector + .requests() + .iter() + .map(|&request| Self::query(world, request)) + .collect::, _>>()?; + self.pending_results = Some(results); + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/air_trajectory/settings.rs b/crates/skate-game/src/physics/air_trajectory/settings.rs new file mode 100644 index 00000000..f34f6a79 --- /dev/null +++ b/crates/skate-game/src/physics/air_trajectory/settings.rs @@ -0,0 +1,122 @@ +//! Stock class hashes429A8F4385DD539D(trajectory),7CE1B6B581709C6F +//! (reckoning),5338958C408B6009(feet), defaultD7EDBD362D7D2152. +//! Layout names/offsets were checked against original TU3 loads and stock XML. +use skate_core::{air::trajectory::SelectorSettings, point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub(super) fn load(data: &Collections) -> Result { + let t = |name| data.float("physics_trajectory", "default", name); + let r = |name| data.float("physics_reckoning", "default", name); + Ok(SelectorSettings { + cone_x: t("ConeAngleX")?, + cone_z: t("ConeAngleZ")?, + trajectory_max_time: t("TrajectoryMaxTime")?, + trajectory_max_drop: t("TrajectoryMaxDrop")?, //63673FB6DA092B7E + trajectory_error_start: t("TrajectoryMaxErrorStart")?, + trajectory_error_end: t("TrajectoryMaxErrorEnd")?, + speed_factor_min: t("SpeedFactorMin")?, + speed_factor_max: t("SpeedFactorMax")?, + cone_angle_z_vs_speed: graph8(data, "physics_trajectory", "ConeAngleZVsSpeed", false)?, + cone_x_second_pass: t("ConeAngleXSecondPass")?, + cone_z_second_pass: t("ConeAngleZSecondPass")?, + landing_time_bonus: graph8(data, "physics_trajectory", "LandingTimeBonus", true)?, + landing_com_scalar_vs_slope: graph8( + data, + "physics_trajectory", + "LandingComScalarVsSlope", + true, + )?, + landing_force_scalar: graph8( + data, + "physics_trajectory", + "LandingForceScalarVsDPVelNorm", + false, + )?, + grind_penalty_vs_distance: graph8( + data, + "physics_trajectory", + "GrindPenaltyVsDistToGrind", + true, + )?, + score_middle_bonus: t("ScoreMiddleBonus")?, + score_landing_force: t("ScoreLandingForce")?, + score_landing_direction: t("ScoreLandingDirection")?, + score_transition: t("ScoreTransition")?, + surface_unrideable_score: t("ScoreSurface_Unrideable")?, + surface_dont_align_score: t("ScoreSurface_DontAlign")?, + natural_air_off_verts_scalar: t("NaturalAirOffVertsScalar")?, + minimum_valid_time: t("MinTimeForTrajectoryToBeValid")?, + minimum_time_after_apex: t("MinTimeAfterApexForTransition")?, + minimum_normal_delta_second_pass: t("MinNormalDeltaForSecondPass")?, + maximum_trajectory_adjust: t("MaxTrajectoryAdjust")?, + wall_ride_test_distance: t("WallRideTestDist")?, + wall_ride_minimum_height: t("WallRideMinHeightFromGround")?, + wall_ride_angle_allow_landing: t("WallRideAngleToAllowLanding")?, + wall_ride_height_score: t("ScoreWallrideHeight")?, + wall_ride_boost: negative_graph4(data, "physics_trajectory", "WallRideBoost")?, + //Globals8289D5C8:ED14 class lookup;ED48 stores188, layout248. + wall_ride_normal_dot_limit: data.float( + "physics_feet", + "default", + "WallRideMaxDotFloorWall", + )?, + displacement_vs_speed: graph8(data, "physics_reckoning", "TrajectoryDispVsSpeed", false)?, + displacement_vs_ground_normal: graph8( + data, + "physics_reckoning", + "TrajectoryDispVsGroundNorm", + false, + )?, + trajectory_radius: r("TrajectoryRadius")?, //C6710942E297D587 + trajectory_displacement: r("TrajectoryDisplacement")?, //F85A91F6A233DDAE + minimum_trajectory_frames: data.integer( + "physics_reckoning", + "default", + "MinTrajectorySize", + )? as i32, + vert_jump_align_factor: r("VertJumpAlignFactor")?, //0B42DFBB7F7756A0 + vert_jump_align_max_ground_normal_y: r("VertJumpAlignMaxGroundNormalY")?, //94923D136EA2DA37 + vert_jump_align_min_direction_y: r("VertJumpAlignMinJumpDirY")?, //58BFC8D41216C7EE + vert_jump_align_max_angle: r("VertJumpAlignMaxAngle")?, //723FF102068622CF + }) +} +fn graph8( + data: &Collections, + class: &str, + name: &str, + negative: bool, +) -> Result, String> { + let expected = if negative { + "Sk8::PointNegGraphData8" + } else { + "Sk8::PointGraphData8" + }; + if data.field(class, "default", name)?.type_name != expected { + return Err(format!("Wrong stock graph type {class}/{name}")); + } + let (x, y) = if negative { + let words = data.words::<20>(class, "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(words[4 + i])), + std::array::from_fn(|i| f32::from_bits(words[12 + i])), + ) + } else { + let words = data.words::<16>(class, "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(words[i])), + std::array::from_fn(|i| f32::from_bits(words[8 + i])), + ) + }; + //82481E10 receives the x/y spans after any PointNeg header. + Ok(PointGraph { x, y }) +} +fn negative_graph4(data: &Collections, class: &str, name: &str) -> Result, String> { + if data.field(class, "default", name)?.type_name != "Sk8::PointNegGraphData4" { + return Err(format!("Wrong stock graph type {class}/{name}")); + } + let words = data.words::<12>(class, "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[8 + i])), + }) +} diff --git a/crates/skate-game/src/physics/air_trajectory/world.rs b/crates/skate-game/src/physics/air_trajectory/world.rs new file mode 100644 index 00000000..be1ce091 --- /dev/null +++ b/crates/skate-game/src/physics/air_trajectory/world.rs @@ -0,0 +1,142 @@ +//! Original trajectory collision leaf82771D08 and nearby triangle collector +//!82772028, across the host's world-format boundary. BoardWorld stores static +//!world-space triangles, so their source geometry transform is identity. +use skate_core::{ + air::trajectory::{SurfaceHit, WorldWithoutGrindEdges}, + math::Vector3, + physics::{ + board_world::BoardWorld, + triangle_query::{TriangleLineHit, triangle_segment}, + }, +}; +type Vector = [f32; 4]; +const IDENTITY: [[f32; 4]; 4] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0; 4], +]; + +///BoardWorld's authored format contains triangles only; no grind primitive +///collection is present. Update this adapter together with any world format +///extension that introduces actual grind edges. +pub(super) fn topology(_world: &BoardWorld) -> WorldWithoutGrindEdges { + WorldWithoutGrindEdges +} + +pub(super) fn line( + world: &BoardWorld, + start: Vector, + end: Vector, + radius: f32, +) -> Result, String> { + if !radius.is_finite() || radius < 0.0 || start.into_iter().chain(end).any(|v| !v.is_finite()) { + return Err("Non-finite trajectory collision request or invalid radius".into()); + } + let start = vec3(start); + let delta = Vector3::new(end[0] - start.x, end[1] - start.y, end[2] - start.z); + let mut nearest = f32::MAX; + let mut result = None; + for (index, entry) in world.triangles().iter().enumerate() { + let mut hit = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + if triangle_segment(&mut hit, start, delta, entry.triangle.vertices, radius, 0.0) { + let lower = if -hit.fraction >= 0.0 { + 0.0 + } else { + hit.fraction + }; + let fraction = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + if fraction < nearest { + nearest = fraction; + result = Some(SurfaceHit { + position: lanes(hit.position), + normal: lanes(entry.triangle.feature.normal), + transform: IDENTITY, + surface: entry.tag, + geometry: u32::try_from(index).map_err(|_| "World geometry index overflow")?, + }); + } + } + } + Ok(result) +} +pub(super) fn nearby( + world: &BoardWorld, + center: Vector, + radius: f32, +) -> Result, String> { + if !radius.is_finite() || radius < 0.0 { + return Err("Invalid nearby-trajectory triangle radius".into()); + } + let minimum = std::array::from_fn(|i| center[i] - radius); + let maximum = std::array::from_fn(|i| center[i] + radius); + let mut triangles = Vec::with_capacity(64); + for entry in world.triangles() { + let vertices = entry.triangle.vertices.map(lanes); + if triangle_box(vertices, minimum, maximum) { + triangles.push(vertices); + if triangles.len() == 64 { + break; + } + } + } + Ok(triangles) +} +///82761698 full separating-axis triangle/AABB predicate: nine edge-axis +///crosses, three box axes and the face plane. No broadphase-only substitution. +fn triangle_box(vertices: [Vector; 3], minimum: Vector, maximum: Vector) -> bool { + let center: Vector = std::array::from_fn(|i| (minimum[i] + maximum[i]) * 0.5); + let half: Vector = std::array::from_fn(|i| (maximum[i] - minimum[i]) * 0.5); + let v = vertices.map(|p| sub(p, center)); + let edges = [sub(v[1], v[0]), sub(v[2], v[1]), sub(v[0], v[2])]; + let axes = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + ]; + for edge in edges { + for axis in axes { + if separated(v, cross(edge, axis), half) { + return false; + } + } + } + for axis in axes { + if separated(v, axis, half) { + return false; + } + } + !separated(v, cross(edges[0], edges[1]), half) +} +fn separated(vertices: [Vector; 3], axis: Vector, half: Vector) -> bool { + let projections = vertices.map(|v| dot(v, axis)); + let min = projections[0].min(projections[1]).min(projections[2]); + let max = projections[0].max(projections[1]).max(projections[2]); + let radius = (half[0] * axis[0].abs() + half[1] * axis[1].abs()) + half[2] * axis[2].abs(); + min > radius || max < -radius +} +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +fn sub(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn cross(a: Vector, b: Vector) -> Vector { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + 0.0, + ] +} +fn vec3(v: Vector) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> Vector { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/animated_skeleton.rs b/crates/skate-game/src/physics/animated_skeleton.rs new file mode 100644 index 00000000..b330ed95 --- /dev/null +++ b/crates/skate-game/src/physics/animated_skeleton.rs @@ -0,0 +1,244 @@ +//! Stock skeleton-to-physics ownership. Animation globals and real physical +//! observations enter here; native calculations and history stay in core. +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + skeleton_animation_record::{ + AnimationPartTransform, IDENTITY, SkeletonAnimationMasses, SkeletonAnimationRecord, + compose_affine, map_animation_parts, physics_bone_frame, + }, + skeleton_board_frames::SkeletonBoardFrames, + skeleton_board_offset::SkateboardOffset, + skeleton_landing::{LandingAdjustment, LandingInput, LandingSettings}, + skeleton_motion::SkeletonMotion, + skeleton_root::SkeletonRootFrames, + }, + point_graph::PointGraph, +}; +use skate_data::{ + animation_frames::AnimationFrames, collections::Collections, physics_skeleton::PhysicsSkeleton, +}; +use std::path::Path; + +pub(crate) struct AnimatedSkeleton { + pub record: SkeletonAnimationRecord, + pub roots: SkeletonRootFrames, + pub board_frames: SkeletonBoardFrames, + pub board_offset: SkateboardOffset, + pub landing: LandingAdjustment, + pub targets: [AnimationPartTransform; 4], + pub masses: SkeletonAnimationMasses, + pub bone_indices: [usize; 24], + pub physics_frames: [AnimationPartTransform; 24], + pub animation_board: AnimationPartTransform, + pub animation_hips: AnimationPartTransform, + pub motion: SkeletonMotion, + pub board_at_y_delta: f32, + target_bones: [usize; 4], + landing_settings: LandingSettings, +} +impl AnimatedSkeleton { + ///Existing stock82BED688 target mappings; left and right reparented hands. + pub(crate) fn reparented_hand_indices(&self) -> [usize; 2] { + [self.target_bones[2], self.target_bones[3]] + } + + pub fn load( + asset_root: &Path, + data: &Collections, + animation: &AnimationFrames, + head_has_hat: bool, + ) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + &animation.source_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Stock skater physics skeleton must contain24 parts".into()); + } + let lookup = |name: &str| { + animation + .bone_names + .iter() + .position(|n| n.eq_ignore_ascii_case(name)) + .ok_or_else(|| { + format!("Stock physics bone {name} is absent from the animation hierarchy") + }) + }; + let mut bone_indices = [0; 24]; + let mut physics_frames = [IDENTITY; 24]; + let mut sizes = [Vector3::ZERO; 24]; + let mut shapes = [0; 24]; + for (part, bone) in skeleton.bones.iter().enumerate() { + bone_indices[part] = lookup(&bone.name)?; + let translation = std::array::from_fn(|i| f32::from_bits(bone.words[16 + i])); + physics_frames[part] = physics_bone_frame(bone.rotation(), translation); + let [x, y, z] = bone.size(); + sizes[part] = Vector3::new(x, y, z); + shapes[part] = data.words::<6>( + "physics_skeleton_bones", + "default", + &format!("PART_{}", bone.name), + )?[3]; + } + let mut target_bones = [0; 4]; + // Native82BED688 exact order and names. These are board-parented + // targets; do not replace them with the physical foot/hand indices. + for (i, name) in [ + "LeftToeBase_Reparented", + "RightToeBase_Reparented", + "LeftHand_Reparented", + "RightHand_Reparented", + ] + .iter() + .enumerate() + { + target_bones[i] = lookup(name)?; + } + Ok(Self { + record: SkeletonAnimationRecord::default(), + roots: SkeletonRootFrames::default(), + board_frames: SkeletonBoardFrames::default(), + board_offset: SkateboardOffset::default(), + landing: LandingAdjustment::default(), + targets: [IDENTITY; 4], + masses: SkeletonAnimationMasses::from_bone_data(sizes, shapes, head_has_hat), + bone_indices, + physics_frames, + animation_board: IDENTITY, + animation_hips: IDENTITY, + motion: SkeletonMotion::default(), + board_at_y_delta: 0.0, + target_bones, + landing_settings: load_landing_settings(data)?, + }) + } + + /// ProcessData's mapped pose, target frames, landing adjustment, board offset, + /// COM and final board cache. The caller supplies actual processed physical + /// observations; this owner never substitutes the board body's COM for them. + /// Grind/landing-on-board/offboard offset producers refresh board_offset + /// before this call's landing stage when their native conditions require it. + pub fn process_pose( + &mut self, + globals: &[AnimationPartTransform], + mut landing: LandingInput, + dt: f32, + flags_2468: &mut u32, + flags_2472: &mut u32, + ) -> Result<(), String> { + let trajectory = globals.first().ok_or("Animation has no trajectory bone")?; + self.motion + .process_trajectory(trajectory, &self.roots.animation_to_world, dt); + let mut parts = map_animation_parts(globals, &self.bone_indices, &self.physics_frames)?; + SkeletonMotion::publish_unadjusted_board(&parts[0], flags_2472); + for (target, part) in [15, 19, 3, 7].into_iter().enumerate() { + let bone = globals + .get(self.target_bones[target]) + .ok_or("Missing animation IK target bone")?; + self.targets[target] = compose_affine(bone, &self.physics_frames[part]); + } + landing.animation_com_height = self.record.centre_of_mass[1] - parts[0][3][1]; + self.landing + .update(landing, &self.landing_settings, &mut self.board_offset); + self.board_offset.update(&mut parts[0], &mut self.targets); + self.record + .update(&parts, &self.roots.animation_to_board, &self.masses); + // ProcessData82BD8E04..8E10 writes local board11728 AFTER COM. + // World animation target15952 belongs to the later board-update phase. + self.animation_board = parts[0]; + self.animation_hips = parts[23]; + self.board_at_y_delta = self.motion.publish_adjusted_board(&parts[0], flags_2468); + Ok(()) + } + + /// UpdateRootTransforms is called by the native ground/animated board update + /// after ProcessData. Its result becomes the next ProcessData COM frame. + pub fn update_roots( + &mut self, + board: &BoardRuntime, + reckoning: &AnimationPartTransform, + dt: f32, + ) { + let deck = board.part_transforms()[BodyId::Deck.index()]; + let velocity = board.bodies()[BodyId::Deck.index()].rates.linear_velocity; + let mut transform = IDENTITY; + for (axis, column) in deck.basis.columns.iter().enumerate() { + transform[axis][..3].copy_from_slice(column); + } + transform[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + self.roots.update( + transform, + [velocity.x, velocity.y, velocity.z, 0.0], + dt, + &self.animation_board, + reckoning, + ); + } + + ///Ground board phase before GeneralUpdate's IK and physical drives. + pub fn prepare_ground( + &mut self, + board: &BoardRuntime, + reckoning: &AnimationPartTransform, + dt: f32, + flags_2468: &mut u32, + ) -> AnimationPartTransform { + self.roots.initialize_heading = true; //82BDF550, on every Ground update. + self.update_roots(board, reckoning, dt); + self.board_frames.prepare_ground( + &self.roots, + &self.record.pose[0], + self.roots.board, + flags_2468, + ) + } + + ///82BDF778..F7AC runs AFTER the current GeneralUpdate consumes trajectory. + pub fn finish_ground(&mut self) { + self.motion.next_trajectory = IDENTITY; + } +} + +fn load_landing_settings(data: &Collections) -> Result { + let value = |name| data.float("physics_animation", "default", name); + let graph = |name| -> Result, String> { + // PointNegGraphData4 stores four bounds, followed by native X/Y arrays. + let words = data + .words::<12>("physics_animation", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[4..8].try_into().unwrap(), + y: words[8..12].try_into().unwrap(), + }) + }; + Ok(LandingSettings { + manual_blend: graph("LandingAdjustManualBlend")?, + grind_blend: graph("LandingAdjustGrindBlend")?, + coffin_height: graph("LandingAdjustCoffinBounceHeight")?, + minimum_height: value("LandingAdjustMinYPos")?, + maximum_velocity: value("LandingAdjustMaxVel")?, + manual_damping: value("LandingAdjustManualK2")?, + manual_spring: value("LandingAdjustManualK1")?, + ground_minimum_compression_time: value("LandingAdjustGroundMinCompressionTime")?, + ground_damping: value("LandingAdjustGroundK2")?, + ground_spring: value("LandingAdjustGroundK1")?, + grind_animation_target_time: value("LandingAdjustGrindStartUsingAnimTargetAfterTime")?, + grind_damping: value("LandingAdjustGrindK2")?, + grind_spring: value("LandingAdjustGrindK1")?, + grind_target_delta: value("LandingAdjustGrindBlendTowardsAnimTargetSpeed")?, + desired_com_height: value("LandingAdjustDesiredCom")?, + coffin_time: value("LandingAdjustCoffinBounceTime")?, + coffin_maximum_velocity: value("LandingAdjustCoffinBounceMaxVel")?, + coffin_blend_frames: value("LandingAdjustCoffinBounceFramesToBlendAway")?, + coffin_base_height: value("LandingAdjustCoffinBounceBaseHeight")?, + }) +} diff --git a/crates/skate-game/src/physics/animation_feedback.rs b/crates/skate-game/src/physics/animation_feedback.rs new file mode 100644 index 00000000..47109087 --- /dev/null +++ b/crates/skate-game/src/physics/animation_feedback.rs @@ -0,0 +1,75 @@ +//! Persistent physical-to-animation conditioning with stock collection values. +use skate_core::{ + animation::{ + ground_acceleration, + physical_feedback::{self, ControlFeedback, PhysicalFeedback, ReckoningFeedback}, + }, + input::turn_conditioner, + physics::board_motion_output::BoardMotionOutput, + riding::{pumping::state::PumpingState, speed_wobble::SpeedWobbleState}, +}; +use skate_data::collections::Collections; + +pub(crate) struct AnimationFeedback { + settings: turn_conditioner::Settings, + bump_settings: ground_acceleration::Settings, + state: turn_conditioner::State, + previous_lateral_tilt: [f32; 4], + pub published_previous_lateral_tilt: [f32; 4], +} + +impl AnimationFeedback { + pub fn load(data: &Collections) -> Result { + Ok(Self { + //82DEFAA0..FAC8 zeros the retained vector96. + previous_lateral_tilt: [0.0; 4], + published_previous_lateral_tilt: [0.0; 4], + settings: super::animation_feedback_settings::load(data)?, + // Global344 at8289FCA0 binds hash7823ADA8E7C896AA = bumps. + bump_settings: ground_acceleration::Settings { + scale_x_acc: data.float("anim_motion", "bumps", "scale_x_acc")?, + min_bump_mag: data.float("anim_motion", "bumps", "min_bump_mag")?, + }, + //82DEFAA0 resets histories to zero. Every82DEFF38 update refreshes + //the four coefficient words before using each filter. + state: turn_conditioner::State { + history: [0.0; 8], + filters: [[0.0; 9]; 3], + }, + }) + } + + pub fn reset(&mut self) { + self.state.reset_history(); + self.previous_lateral_tilt = [0.0; 4]; + self.published_previous_lateral_tilt = [0.0; 4]; + } + + /// Call once after the native physical outputs are published. Absence of a + /// completed Reckoning frame belongs to the caller, not a fallback here. + pub fn update( + &mut self, + motion: &BoardMotionOutput, + pumping: &PumpingState, + wobble: &SpeedWobbleState, + reckoning: ReckoningFeedback, + controls: ControlFeedback, + acceleration: ground_acceleration::Input, + lateral_tilt: [f32; 4], + ) -> PhysicalFeedback { + //82DEFB6C..FB7C publishes the preceding Animation0 at Animation16, + //then retains current Reckoning1248 for the next conditioner update. + self.published_previous_lateral_tilt = self.previous_lateral_tilt; + self.previous_lateral_tilt = lateral_tilt; + physical_feedback::publish( + &mut self.state, + &self.settings, + motion, + pumping, + wobble, + reckoning, + controls, + ground_acceleration::publish(acceleration, &self.bump_settings), + ) + } +} diff --git a/crates/skate-game/src/physics/animation_feedback_settings.rs b/crates/skate-game/src/physics/animation_feedback_settings.rs new file mode 100644 index 00000000..226ab886 --- /dev/null +++ b/crates/skate-game/src/physics/animation_feedback_settings.rs @@ -0,0 +1,60 @@ +//! PhysOutConditioner_Animation turn settings, TU3 global204/physics_animation. +use skate_core::{input::turn_conditioner::Settings, point_graph::PointGraph}; +use skate_data::collections::Collections; +pub(crate) fn load(data: &Collections) -> Result { + let coefficients = |name| { + data.words::<4>("physics_animation", "default", name) + .map(|w| w.map(f32::from_bits)) + }; + let plain = |name| -> Result, String> { + let w = data + .words::<16>("physics_animation", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: w[..8].try_into().unwrap(), + y: w[8..].try_into().unwrap(), + }) + }; + let speed = data + .words::<20>("physics_animation", "default", "ScaleQuicknessAtSpeed")? + .map(f32::from_bits); + let smoothing = data + .words::<12>("physics_animation", "default", "SpeedWobbleTurnBlend")? + .map(f32::from_bits); + let mut parameters = [0.0; 13]; + for (target, name) in parameters.iter_mut().zip([ + "QuicknessMaxDelta", + "InputQuicknessMax", + "InputQuicknessBlendVal", + "HoldingMinVal", + "HoldingMaxAcceleration", + "HoldingIntegralBlend", + "HoldingBlendUp", + "HoldingBlendDown", + "AnimationTurnWobbleScalar", + "AnimationTurnPhysicsWeight", + "AnimationTurnMaxLean", + "AnimationTurnGraphX", + "AnimationTurnDirMax", + ]) { + *target = data.float("physics_animation", "default", name)?; + } + Ok(Settings { + filter_coefficients: [ + coefficients("AnimationTurnLeanFilterSlow")?, + coefficients("AnimationTurnLeanFilter")?, + coefficients("AnimationTurnDirFilter")?, + ], + input_curve: plain("AnimationTurnSpeedGraph")?, + quickness_curve: plain("QuicknessVsQuickness")?, + speed_curve: PointGraph { + x: speed[4..12].try_into().unwrap(), + y: speed[12..20].try_into().unwrap(), + }, + smoothing_curve: PointGraph { + x: smoothing[4..8].try_into().unwrap(), + y: smoothing[8..12].try_into().unwrap(), + }, + parameters, + }) +} diff --git a/crates/skate-game/src/physics/animation_input.rs b/crates/skate-game/src/physics/animation_input.rs new file mode 100644 index 00000000..e618f6d4 --- /dev/null +++ b/crates/skate-game/src/physics/animation_input.rs @@ -0,0 +1,139 @@ +//! Animation attributes become owned physical inputs through the actual +//! Skeleton dispatcher. This adapter loads stock settings and retains caches; +//! it does not choose animations or manufacture a push from controller input. +use skate_core::{ + animation::{ + output::{ + NativeMatrix, + attributes::{AnimationAttribute, AttributeName}, + }, + skeleton_input::{ + attribute_finalization::{FinalizationInput, JumpAttributeState}, + contact_events::{ContactEventPose, ContactEventState}, + extended_attributes::ExtendedAttributes, + name::encode, + process_attributes, + scalar_attributes::{AnimationControlOutput, ScalarAttributeInputs}, + }, + }, + input::controller::ActionMap, +}; +use skate_data::{animation_frames::AnimationFrames, collections::Collections}; + +pub(crate) struct AnimationInput { + pub fields: ScalarAttributeInputs, + pub extra: ExtendedAttributes, + pub contacts: ContactEventState, + pub output: AnimationControlOutput, + cached_jump: JumpAttributeState, + bone_names: Vec, + right_toe: usize, + settings: FinalizationInput, +} + +impl AnimationInput { + pub fn load(data: &Collections, frames: &AnimationFrames, mode: &str) -> Result { + let right_toe = frames + .bone_names + .iter() + .position(|n| n.eq_ignore_ascii_case("RightToeBase")) + .ok_or("Stock skeleton is missing RightToeBase")?; + Ok(Self { + fields: ScalarAttributeInputs::reset(0, 0), + extra: ExtendedAttributes::reset(0.0), + contacts: ContactEventState { + bone: 0, + push_speed: 0.0, + }, + cached_jump: JumpAttributeState::new(), + // AnimOut construction82DB1E88..1EFC initializes the null name, + // float24=0 and clears all represented high flag bits. + output: AnimationControlOutput { + grind_name: encode(b""), + flags: 0, + }, + bone_names: frames + .bone_names + .iter() + .map(|n| encode(n.as_bytes())) + .collect(), + right_toe, + settings: FinalizationInput { + //TU3 global constructor8289F8B4 binds292 to anim_motion + //collectionAC260B44FA3CA0A4 (jumping), not a default profile. + select_jump_extremes: data.boolean("anim_motion", "jumping", "clamp_jump")?, + low_jump_threshold: data.float("anim_motion", "jumping", "clamp_low_inclusive")?, + high_jump_threshold: data.float( + "anim_motion", + "jumping", + "clamp_high_inclusive", + )?, + allow_height_override: data.boolean( + "physics_mode", + mode, + "JumpHeightOverrideEnabled", + )?, + use_prepared_controls: data.boolean( + "physics_jump", + "default", + "AdjustOnPrepare", + )?, + external_impulse_active: false, + animation_flags: 0, + }, + }) + } + + /// ProcessedPhysIn Reset precedes physical/packet flag publication. The + /// caller then publishes those flags before dispatching the current pose. + pub fn reset_processed(&mut self) { + self.fields = ScalarAttributeInputs::reset(self.fields.flags2468, self.fields.flags2488); + self.extra = ExtendedAttributes::reset(self.extra.footstep_strength); + self.contacts = ContactEventState { + bone: 0, + push_speed: 0.0, + }; + } + + ///82DB7DCC..7E0C runs AFTER publishing AnimOut into the physical output. + /// It is distinct from ProcessedPhysIn reset at the start of input. + pub fn finish_output_publication(&mut self) { + self.output.grind_name = encode(b""); + self.output.flags &= 0x003fffff; + self.extra.footstep_strength = 0.0; + } + + pub fn process( + &mut self, + attributes: &[AnimationAttribute], + hierarchy: &[NativeMatrix], + timestep: f32, + animation_flags: u32, + external_impulse_active: bool, + map: &mut dyn ActionMap, + ) -> Result<(), String> { + let pose = ContactEventPose { + bone_names: &self.bone_names, + hierarchy, + trajectory_bone: 0, + right_toe_bone: self.right_toe as i32, + timestep, + }; + let settings = FinalizationInput { + animation_flags, + external_impulse_active, + ..self.settings + }; + process_attributes::process( + attributes, + &pose, + &mut self.fields, + &mut self.extra, + &mut self.contacts, + &mut self.cached_jump, + &mut self.output, + settings, + Some(map), + ) + } +} diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs new file mode 100644 index 00000000..ca1180dd --- /dev/null +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -0,0 +1,340 @@ +//! The production physical-output -> stock graph -> next physical-input phase. +//! Native actor phases82592FD8/82593128/82593230/82593640 preserve this order. +#[path = "animation_phase_packet.rs"] +mod packet; +use super::{GamePhysics, PlayerControls, skater::SkaterRuntime}; +use crate::{graph_runtime::StockGraphs, skater_animation::AnimationPhysical}; +pub(crate) use packet::{AnimationPhaseOutput, AnimationProfile}; +use skate_core::{ + animation::{ + body_tilt, ground_acceleration, + physical_feedback::{ControlFeedback, PhysicalFeedback, ReckoningFeedback}, + riding_fakie, + }, + graph::conditions::{ConditionInputs, PhysicalStateInputs, PushBrakeInputs}, + math::Vector3, + physics::board::BodyId, + riding::push_behaviors::PushFootFrame, +}; + +/// Observations are from the preceding completed physics tick. The current +/// controller map then advances both stock graphs; returned storage remains +/// alive while PlayerInput consumes packet(), hierarchy and ordered attributes. +pub(crate) fn advance( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + controls: &PlayerControls, + graphs: &StockGraphs, + profile: &AnimationProfile, +) -> Result { + let p = &skater.player_input.processed; + let physical = &skater.player_input.physical; + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let (fakie, mirrored) = skater.animation.stance(); + let feedback = skater.physical_feedback; + skater.animation.motion.wipeout_physical = Some(crate::graph_host::motion_wipeout::Physical { + over_599: physical.skeleton.over_599 != 0, + collision_time_144: physical.animation.collision_time_144, + no_support_time_548: physical.skeleton.no_support_time_548, + profile_148: physical.animation.profile_148, + below_surface_82: skater.player_state.state_flags[82 - 52], + // Skeleton Fill82BE1E94/98 copies hips physical up into output80. + orientation_y: Some(skater.skeleton.record.pose[23][1][1]), + hips_right_angle_496: physical.skeleton.hips_right_angle_496, + hips_up_angle_500: physical.skeleton.hips_up_angle_500, + }); + skater.animation.motion.landing_physical = Some(crate::graph_host::motion_landing::Physical { + height: feedback.crouching.animation_height_72, + spin: skater.landing_quality.spin_92, + kind: skater.landing_quality.landing_type_96, + last_good_landing_velocity: skater.animation.motion.riding.last_good_landing_velocity, + }); + skater.animation.motion.prelanding_physical = + Some(crate::graph_host::motion_spin::PrelandingPhysical { + air_444: physical.air.flag_444 != 0, + air_normal_144_y: f32::from_bits(physical.air.landing_normal_144[1]), + animation_16_x: skater.animation_feedback.published_previous_lateral_tilt[0], + com_velocity_y: f32::from_bits(physical.reckoning.vector_16[1]), + offboard_316: physical.off_board.flag_316 != 0, + offboard_319: physical.off_board.flag_319 != 0, + offboard_time_32: physical.off_board.scalar_32, + air_437: physical.air.known_air_valid_437 != 0, + air_normal_36: f32::from_bits(physical.air.landing_normal_32[1]), + air_remaining_184: physical.air.scalar_184, + animation_height_72: feedback.crouching.animation_height_72, + }); + let [right_toe, left_toe] = skater + .foot_ik + .physical_toe_positions(&skater.skeleton.record); + skater.animation.motion.air_leg_physical = + Some(skate_core::animation::air_leg_extension::Physical { + com_velocity: physical.reckoning.vector_16.map(f32::from_bits), + com_position: physical.reckoning.vector_64.map(f32::from_bits), + system_up: physical.reckoning.vector_96.map(f32::from_bits), + right_toe, + left_toe, + animation_height: feedback.crouching.animation_height_72, + offboard_316: physical.off_board.flag_316 != 0, + remaining_air_time: physical.air.scalar_184, + }); + skater.animation.action.physical_conditions = + Some(crate::graph_host::action::PhysicalConditions { + requests_dismount: skater.player_state.state_flags[77 - 52], + state: physical.state.state_16, + }); + skater.animation.motion.gameplay_conditions = Some( + crate::graph_host::motion_gameplay_conditions::GameplayConditions { + state: physical.state.state_16, + wants_runout: skater.player_state.state_flags[78 - 52], + physics_wiping: skater.player_state.state_flags[59 - 52], + body_flipping: physical.air.flag_441 != 0, + bumped: feedback.bumped, + wants_wipeout: skater.player_state.state_flags[63 - 52] + || skater.player_state.state_flags[65 - 52], + grabbing_object: physical.off_board.flag_304 != 0, + retrieving_board: physical.off_board.retrieving_board_323 != 0, + dropping_board: physical.off_board.dropping_board_322 != 0, + in_biped_air: physical.off_board.flag_328 != 0, + hippy_hurdling: physical.off_board.hippy_hurdling_317 != 0, + handplant_flags: physical.air.handplant_flags_324, + time_to_skitch: physical.ground.scalar_276, + skitch_transition_time: profile.skitch_transition_time, + time_to_land: physical.animation.collision_time_144, + offboard_time_to_land: physical.off_board.scalar_32, + }, + ); + skater.animation.action.gameplay_conditions = skater.animation.motion.gameplay_conditions; + skater.animation.motion.native_physical = Some(crate::graph_host::motion_native::Physical { + centre_of_mass_velocity: skater.animated_skeleton.board_frames.com_velocity, + system_up: vec4(physics.riding.reckoning.up), + //82DB70A0..70FC publishes Reckoning752, not the physical deck frame. + board_reckoning_z: physics.riding.reckoning_frames.ground[2], + board_reckoning: physics.riding.reckoning_frames.ground, + }); + skater.animation.motion.gesture_physical = + Some(crate::graph_host::motion_native::GesturePhysical { + //Original82DB74D8, not the similarly numbered Air output byte. + ground321: p.flags_2480 & 0x1000 != 0, + state_offboard75: physical.state.category_12 == 500, + selections: profile.gesture_selections, + suppress_up: profile.suppress_up_gesture, + force_brake_bypass: profile.gesture_force_brake_bypass, + }); + //Original ProcessOutput82DB7E38. Probe+72 is copied to Processed1848; + //Ground240 resets to zero and receives this point only on an interaction. + let interaction_trigger = matches!(p.category_2512, 100 | 500) + && p.probe_1792.bytes_72_73[0] != 0 + && p.flags_2476 & 0x400000 != 0; + let direction = if interaction_trigger { + let source = if p.flags_2484 & 0x40000 != 0 { 0 } else { 1 }; + let point = p.probe_1792.vectors_16_32_48[source].map(f32::from_bits); + let inverse = skater.animated_skeleton.roots.world_to_animation; + std::array::from_fn(|i| { + inverse[2][i].mul_add( + point[2], + inverse[1][i].mul_add(point[1], inverse[0][i].mul_add(point[0], inverse[3][i])), + ) + }) + } else { + [0.0; 4] + }; + skater.animation.motion.shove_physical = Some(crate::graph_host::motion_shove::ShovePhysical { + interaction_trigger, + direction, + in_biped_category: physical.state.category_12 == 500, + board_on_ground: physical.off_board.flag_311 != 0, + animation_height: feedback.crouching.animation_height_72, + }); + // FilteredState82DE5BA0 clears grind metadata on every nongrind output. + // A future grind phase must supply its actual cached name, not an AG intent. + if physical.filtered_state_0 == 3 { + return Err("Animation phase requires the selected grind's filtered output name".into()); + } + let conditions = ConditionInputs { + speeds: Some(physics.riding.graph_speeds()), + physical_state: Some(PhysicalStateInputs { + category: physical.filtered_state_0, + grinding: false, + grind_name: String::new(), + }), + time_since_last_input: Some(p.time_since_last_input_2748), + mirrored: Some(mirrored), + riding_fakie: Some(fakie), + push_brake: Some(PushBrakeInputs { + // Board FillPhysOut82C03304/3318: CollisionInfo+16 ->Ground80. + ground_axis_y: physics.riding.ground.wheel_normal.y, + skeleton_disables_push_brake: skater.foot_ik.state.contacts.support_failed_this_update, + maximum_ground_angle_degrees: profile.maximum_ground_angle_degrees, + }), + }; + // Skeleton GetEffectiveRoot82BE3650 negates X/Z iff Processed2476 bit2. + let mut effective_z = skater.animated_skeleton.roots.animation_to_world[2]; + if p.flags_2476 & 4 != 0 { + effective_z = effective_z.map(|v| -v); + } + let mut effective_x = skater.animated_skeleton.roots.animation_to_world[0]; + if p.flags_2476 & 4 != 0 { + effective_x = effective_x.map(|v| -v); + } + skater.animation.motion.riding_conditions = Some( + crate::graph_host::motion_riding_conditions::RidingConditionInputs { + com_velocity: physical.reckoning.vector_16.map(f32::from_bits), + skeleton_x: effective_x, + skeleton_z: effective_z, + skate_up_y: deck.basis.columns[1][1], + surface_up_y: physics.riding.ground.wheel_normal.y, + }, + ); + let observations = AnimationPhysical { + conditions, + feedback, + body_tilt: body_tilt::Physical { + lateral_tilt: physics.riding.reckoning_frames.lateral_tilt[0], + body_spin_speed: skater.air_reckoning.state.spin_speed, + filtered_category: physical.filtered_state_0, + }, + fakie: riding_fakie::Physical { + category: physical.filtered_state_0, + grind_state: physical.state.state_16, + doing_trick: skater.animation.motion.flags.doing_trick, + board_axis: effective_z, + deck_velocity: vec4(physics.riding.motion.linear_velocity), + external_velocity: skater.centre_of_mass_output.velocity, + ground_projected_speed: physics.riding.motion.ground_speed, + }, + // Original82DB7A58..7AA4 copies AnimOut10374/10371 respectively. + physical_stance: ( + skater.animation.packet.regular_stance, + skater.animation.packet.riding_switch, + ), + foot_frame: PushFootFrame { + left_foot: skater.skeleton.record.pose[15][3], + right_foot: skater.skeleton.record.pose[19][3], + //82C02D0C: physical deck body+16, not geometric part translation. + deck_position: vec4(physics.board.bodies()[BodyId::Deck.index()].rates.position), + deck_y: axis(deck.basis.columns[1]), + deck_z: axis(deck.basis.columns[2]), + //82DB7598 publishes Motion273 from Processed2468 bit20. + skateboard_flipped: p.flags_2468 & (1 << 20) != 0, + }, + board_present: physical.off_board.flag_311 != 0, + physical_28_byte75: physical.state.category_12 == 500, + time_since_teleport: skater.animation.motion.riding.time_since_teleport, + + }; + let mut output = AnimationPhaseOutput::new(feedback); + skater.animation.advance( + graphs, + physics.settings.step.simulation.time_step, + &controls.action_intents, + observations, + &mut output.reset, + )?; + output.publish(&skater.animation.packet, profile, controls.actor_flags); + if let Some(reply) = skater.teleport_state.take_reply() { + output.publish_external_reset(reply); + } + Ok(output) +} + +/// Run once after completed physical output, before camera consumes that output. +/// Animation reads this same stored result on the following actor phase. +pub(crate) fn publish_feedback( + physics: &GamePhysics, + skater: &mut SkaterRuntime, +) -> PhysicalFeedback { + // World8275F430 -> AirCollector82DA7888 publishes the complete mask. + // This host runs active gameplay with HoM and challenges outside its scope. + skater.player_input.physical.scoring.capabilities_204 = + skate_core::player::conditioner_capabilities::ConditionerCapabilityContext { + in_front_end: false, + hall_of_meat_enabled: false, + challenge_query_active: false, + challenge_configuration_enabled: false, + }.capabilities(); + let p = &skater.player_input.processed; + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + // ProcessOutput82DE53F0 resets the animation packet; CalcLandingQuality + //82DE61D0 follows FilteredState82DE5BA0 and uses the completed physical output. + skater.landing_quality = Default::default(); + if let Some(filtered) = skater.player_state.filtered_output.as_ref() { + skater.landing_quality.update( + skate_core::animation::landing_quality::Input { + previous_filtered_state: filtered.previous_category as u32, + filtered_state: filtered.category as u32, + ground_normal: vec4(physics.riding.ground.overall_normal), + deck_velocity: vec4(physics.riding.motion.linear_velocity), + flipped: p.flags_2468 & (1 << 20) != 0, + // Board Fill82C02AD8..2B1C publishes physical part6 Z to + // bundle0+32; this is separate from Reckoning752's ground frame. + reckoning_forward: axis(deck.basis.columns[2]), + // Ground Enter clears this owner before publication, as in82D375C8. + air_spin: skater.air_reckoning.state.spin_speed, + }, + &skater.landing_quality_settings, + ); + } + let feedback = skater.animation_feedback.update( + &physics.riding.motion, + &skater.ground.pumping, + &skater.ground.wobble, + ReckoningFeedback { + // Skeleton16144 -> PhysOutSystemReckoning64,82BE1D84/8C. + system_position: vec3(skater.animated_skeleton.board_frames.centre_of_mass), + system_up: physics.riding.reckoning.up, + board_position: deck.translation, + target_lean_angle: physics.riding.reckoning_frames.target_lean_angle, + }, + ControlFeedback { + processed_flags: p.flags_2468, + turn: skater.animation_input.fields.turn, + animation_mirrored: skater.animation.packet.mirrored, + }, + ground_acceleration::Input { + deck: super::solve::deck_frame(&physics.board), + ground: physics.riding.reckoning_frames.ground, + world_acceleration: vec4(physics.riding.ground.accelerations[BodyId::Deck.index()]), + }, + physics.riding.reckoning_frames.lateral_tilt, + ); + skater.physical_feedback = feedback; + feedback +} + +/// PhysOutAnimation reset82DEFAA0 and the initial represented body outputs. +pub(crate) fn initial_feedback() -> PhysicalFeedback { + PhysicalFeedback { + turning: skate_core::input::set_turning::Physical { + field_32: 0.0, + field_36: 0.0, + field_52: 0.0, + field_56: 0.0, + field_60: 0.0, + body_168: 0.0, + }, + crouching: skate_core::animation::crouching::Physical { + body_84: 0.0, + body_164: 0.0, + body_188: 0.0, + force_516: 0.0, + ground_force_520: 0.0, + minimum_crouch_528: 0.0, + deck_angle_532: 0.0, + animation_height_72: 0.0, + }, + pumping_acceleration: 0.0, + ground_acceleration: [0.0; 4], + // Animation reset vector112 is zero; stock bumps threshold is positive100. + bumped: false, + conditioned_turn: [0.0; 8], + } +} +fn vec3(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn vec4(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn axis(v: [f32; 3]) -> [f32; 4] { + [v[0], v[1], v[2], 0.0] +} diff --git a/crates/skate-game/src/physics/animation_phase_packet.rs b/crates/skate-game/src/physics/animation_phase_packet.rs new file mode 100644 index 00000000..bec89da1 --- /dev/null +++ b/crates/skate-game/src/physics/animation_phase_packet.rs @@ -0,0 +1,199 @@ +//! Owned PlayerInput packet storage; original actor publication825937EC. +use skate_core::{ + animation::{ + output::{ + actor_packet::ExternalPhysicsInput, + packet_reset::{AdditionalResetFields, RESET_POSE}, + physics_packet::PhysicsPosePacket, + }, + physical_feedback::PhysicalFeedback, + }, + input::animation_packet::AnimationPacketFields, + player::input_phase::AnimationInputPacket, +}; +use skate_data::collections::Collections; + +/// Host-owned player preferences, distinct from the stock response curves. +pub(crate) struct AnimationProfile { + pub maximum_ground_angle_degrees: f32, + pub skitch_transition_time: f32, + pub truck_tightness: f32, + pub wheel_hardness: f32, + pub physics_mode: u32, + pub prevent_manual_respawn: bool, + pub ignore_respawn_reset_button: u8, + pub force_braking: bool, + pub suppress_transition: bool, + /// Optional gesture customization and external context are host policy. + pub gesture_selections: Option<[u32; 4]>, + pub suppress_up_gesture: bool, + pub gesture_force_brake_bypass: bool, +} +impl AnimationProfile { + pub fn load(data: &Collections, mode: &str) -> Result { + //Original82DB1B08..1B78 initializes these variant slots in this order. + let physics_mode = ["easy", "normal", "hardcore", "motorized", "test"] + .iter() + .position(|name| *name == mode) + .ok_or_else(|| format!("Undefined player physics profile {mode}"))? + as u32; + Ok(Self { + //82BBBDE0: Globals400/layout96, bound by828A0154. + skitch_transition_time: data.float( + "anim_skitching", + "default", + "LongSkitchIntoReachTime", + )?, + //82BA5150 global316/layout1844;8289FB24 binds pushing. + maximum_ground_angle_degrees: data.float( + "anim_motion", + "pushing", + "disable_push_brake_at_slope", + )?, + //Initial equipment preferences use the fixed settings value from + //82593988/94. The host allows users to change these independently. + truck_tightness: f32::from_bits(0x3f333333), + wheel_hardness: f32::from_bits(0x3f333333), + physics_mode, + prevent_manual_respawn: false, + ignore_respawn_reset_button: 0, + force_braking: false, + suppress_transition: false, + gesture_selections: None, + //82B988B8 returns false when its optional context is absent. + suppress_up_gesture: false, + gesture_force_brake_bypass: false, + }) + } +} + +pub(crate) struct AnimationPhaseOutput { + /// Camera reuses this result; never run the conditioner a second time. + pub feedback: PhysicalFeedback, + pub(super) reset: AdditionalResetFields, + publication: AnimationPacketFields, + external: ExternalPhysicsInput, + mirrored: u8, + weight_forwards: u8, + flags: u32, + // Actor825938FC/393C consumes these request bits after publication. +} +impl AnimationPhaseOutput { + pub(super) fn new(feedback: PhysicalFeedback) -> Self { + Self { + feedback, + //Reserved storage: native reset writes every field before use. + reset: AdditionalResetFields { + compression: 0.0, + foot_ik_influence: [0.0; 2], + next_step_position_valid: false, + actor_flag_1904_bit23: false, + actor_flag_1908_bit2: false, + external_impulse_active: false, + external_physics_input_active: false, + externally_controlled: false, + prevent_manual_respawn: false, + ignore_respawn_reset_button: 0, + force_braking: false, + truck_tightness: 0.0, + wheel_hardness: 0.0, + auxiliary_vectors: [[0.0; 4]; 6], + requested_physics_mode: 1, + }, + publication: AnimationPacketFields { + stance_byte: 0, + timestep: 0.0, + scalar_10388: 0.0, + flags_10375_10496_10784: [0; 3], + vector_10480: [0; 4], + matrix_10704: RESET_POSE.map(|c| c.map(f32::to_bits)), + byte_10768: 0, + truck_tightness: 0.0, + scalar_10792: 0.0, + flag_10371: 0, + }, + //No external impulse/controller/reset provider in this game. The + //inactive payloads are owned storage and are not consumed. + external: ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags: 0, + }, + mirrored: 0, + weight_forwards: 0, + flags: 0, + } + } + pub(super) fn publish( + &mut self, + pose: &PhysicsPosePacket, + profile: &AnimationProfile, + actor_flags: u32, + ) { + let r = &mut self.reset; + //No external impulse provider: Actor1904 bit23 is inactive. + r.actor_flag_1904_bit23 = false; + r.actor_flag_1908_bit2 = profile.suppress_transition || actor_flags & (1 << 2) != 0; + r.truck_tightness = profile.truck_tightness; + r.wheel_hardness = profile.wheel_hardness; + r.requested_physics_mode = profile.physics_mode; + r.prevent_manual_respawn = profile.prevent_manual_respawn; + r.ignore_respawn_reset_button = profile.ignore_respawn_reset_button; + r.force_braking = profile.force_braking; + //Free skating has no challenge scene19/20 requesting bit22. + //82593924 reads Actor1904 bit25, not the input-filter word1908. + self.flags = pose.flags & !((1 << 24) | (1 << 22)); + self.mirrored = u8::from(pose.mirrored); + self.weight_forwards = u8::from(pose.weight_forwards); + self.publication = AnimationPacketFields { + stance_byte: u8::from(pose.board_flipped), + timestep: pose.timestep, + scalar_10388: r.compression, + flags_10375_10496_10784: [ + u8::from(r.actor_flag_1904_bit23), + 0, + 0, //Actor1904 bit29 is published only by the canonical reset reply. + ], + vector_10480: [0; 4], + matrix_10704: RESET_POSE.map(|c| c.map(f32::to_bits)), + byte_10768: 0, + truck_tightness: r.truck_tightness, + scalar_10792: r.wheel_hardness, + flag_10371: u8::from(pose.riding_switch), + }; + } + ///Actor825926F8 reply ->10704/10768/10784, then82DB5BE0 maps physical input. + pub(super) fn publish_external_reset( + &mut self, reply: skate_core::animation::output::actor_packet::ExternalReset, + ) { + self.publication.matrix_10704 = reply.transform; + self.publication.byte_10768 = reply.byte64; + self.publication.flags_10375_10496_10784[2] = 1; + } + pub fn packet(&self) -> AnimationInputPacket<'_> { + let r = &self.reset; + let vectors = r.auxiliary_vectors.map(|v| v.map(f32::to_bits)); + AnimationInputPacket { + publication: &self.publication, + external_physics_10512: &self.external, + flag_10369: u8::from(r.next_step_position_valid), + flag_10370: self.mirrored, + flag_10373: self.weight_forwards, + suppress_transition_10376: u8::from(r.actor_flag_1908_bit2), + use_external_physics_10688: 0, + external_physics_flag_10689: 0, + flag_10786: u8::from(r.prevent_manual_respawn), + flag_10787: r.ignore_respawn_reset_button, + force_braking_10796: u8::from(r.force_braking), + vector_10816: vectors[0], + vector_10832: vectors[1], + vector_10848: vectors[2], + vector_10864: vectors[3], + vector_10880: vectors[4], + vector_10896: vectors[5], + scalar_10912: r.foot_ik_influence[0], + scalar_10916: r.foot_ik_influence[1], + state_variant_10928: r.requested_physics_mode, + flags_10932: self.flags, + } + } +} diff --git a/crates/skate-game/src/physics/camera_output.rs b/crates/skate-game/src/physics/camera_output.rs new file mode 100644 index 00000000..823b2cad --- /dev/null +++ b/crates/skate-game/src/physics/camera_output.rs @@ -0,0 +1,175 @@ +//! Physical-output publications consumed by gameplay camera82DF69C0. +//! Reset82DE53F0 runs before the actual producers82DB6EC0/82D3A388. +use super::{GamePhysics, SkaterRuntime}; +use crate::camera::{ + CameraAirOutput, CameraAnimationOutput, CameraEventsOutput, CameraGrindOutput, + CameraOffboardOutput, CameraPreferences, CameraPublicationInputs, CameraStateOutput, +}; +use skate_core::{ + animation::physical_feedback::PhysicalFeedback, + camera::{MovingObstacleProvider, PathObstacle}, + physics::centre_of_mass_filter::CentreOfMassOutput, +}; + +/// The actual game camera call, after completed physical output conditioning. +/// The user's selected normal High camera is graph type1. This custom world +/// has no road/ledge/camera-volume annotations or other moving actors. +pub(crate) fn advance( + physics: &GamePhysics, + skater: &SkaterRuntime, + feedback: &PhysicalFeedback, + camera: &mut crate::camera::CameraRuntime, +) -> Result<(), String> { + let inputs = publish( + physics, + skater, + feedback, + skater.centre_of_mass_output, + CameraPreferences { + // User preferences are owned here: ordinary uninverted controls. + invert_look: [false; 2], + // Native preferences constructor82DF6294/629C initializes both + // fields to zero.82DF8E80 only updates its air-side bytes37..39. + shake_variant: 0, + value_32: 0.0, + }, + // SkaterAnim vtable8231E170+28=82B97140: full15180 bit30. + u8::from(skater.animation.stance().1), + 1, // Stable host player identity replaces the original actor pointer. + )?; + let snapshot = crate::camera::publish_camera_subject(physics, skater, &inputs)?; + let environment = crate::camera::CameraGraphEnvironment { + camera_type: 1, + on_road: false, + ledge_left: false, + ledge_right: false, + volumes: Vec::new(), + }; + let gravity = physics.settings.step.simulation.gravity_acceleration; + camera + .advance( + physics.settings.step.simulation.time_step, + snapshot, + physics.world(), + [gravity.x, gravity.y, gravity.z, 0.0], + &environment, + &mut StaticWorld, + ) + .map(|_| ()) +} + +struct StaticWorld; +impl MovingObstacleProvider for StaticWorld { + fn collect(&mut self, _: [f32; 4], _: [f32; 4], _: f32, _: &mut [PathObstacle; 50]) -> usize { + // The subject actor is excluded by the native provider. Our current + // world contains this subject and static triangles, with no other actors. + 0 + } +} + +/// Call after current state, board/Skeleton and animation conditioning have +/// published, before clearing AnimationControlOutput's per-frame intent bits. +///Selected state owners publish their output before this common consumer. +pub(crate) fn publish( + physics: &GamePhysics, + skater: &SkaterRuntime, + feedback: &PhysicalFeedback, + com: CentreOfMassOutput, + preferences: CameraPreferences, + skater_animation_stance: u8, + context: u32, +) -> Result { + let p = &skater.player_input.processed; + let physical = &skater.player_input.physical; + let fields = &skater.animation_input.fields; + let intents = skater.animation_input.output.flags; + let packet = &skater.animation.packet; + let normal = physics.riding.ground.wheel_normal; + Ok(CameraPublicationInputs { + state: CameraStateOutput { + height_32: physical.state.surface_height_32, + physically_pushing_55: bit(p.flags_2468, 25), + wiping_out_59: bit(p.flags_2468, 18), + manual_60: u8::from(fields.balance != 0.0), + reset_62: bit(p.flags_2472, 10), + use_skeleton_root_75: u8::from(physical.state.category_12 == 500), + flag_79: u8::from(p.state_variant_index_2528 == 3), + flag_81: u8::from(skater.player_state.state_flags[81 - 52]), + }, + animation: CameraAnimationOutput { + conditioned_turn: feedback.conditioned_turn, + input_turn_64: fields.turn, + input_kickturn_68: p.spin_input_2672, + time_since_input_128: p.time_since_last_input_2748, + wipeout_tweak_148: physical.animation.profile_148, + stance_155: u8::from(packet.riding_fakie), + running_out_160: bit(p.flags_2480, 2), + skater_animation_stance, + }, + //Consume current output, including KnownAir82D36880's predicted + //landing/apex. Other states retain their actual template fields. + air: CameraAirOutput { + apex_0: physical.air.trajectory_apex_0.map(f32::from_bits), + landing_position_16: physical.air.collision_position_16.map(f32::from_bits), + landing_normal_32: physical.air.landing_normal_32.map(f32::from_bits), + launch_position_48: physical.air.selector_vector_48.map(f32::from_bits), + heading_80: physical.air.landing_heading_80.map(f32::from_bits), + time_176: physical.air.time_in_state_176, + duration_180: physical.air.collision_time_180, + apex_time_196: physical.air.time_to_apex_196, + flag_440: bit(p.flags_2468, 22), + }, + //Offboard82DE4010 has a distinct board-present327=1. None of that + //record's alternate trajectory fields becomes valid while riding. + offboard: CameraOffboardOutput { + duration_92: 0.0, + time_152: 0.0, + apex_time_156: 0.0, + launch_normal_160: [0.0; 4], + launch_position_176: [0.0; 4], + landing_normal_192: [0.0; 4], + landing_position_208: [0.0; 4], + heading_224: [0.0; 4], + apex_240: [0.0; 4], + //82DB76E0 overwrites the Ground Fill's earlier304 publication. + object_held_304: bit(p.flags_2480, 19), + hurdle_317: physical.off_board.hippy_hurdling_317, + use_trajectory_331: 0, + dropping_in_334: 0, + }, + grinds: CameraGrindOutput { + //Reset82DE3518 loads original world-X literal82139A10. + direction_0: [1.0, 0.0, 0.0, 0.0], + camera_target_96: [0.0; 4], + grinding_316: u8::from(matches!(physical.grinds.words_136_140[1], 1 | 2)), + }, + events: CameraEventsOutput { + intent_51: bit(intents, 28), + preparing_52: bit(intents, 27), + dropping_in_63: bit(intents, 22), + trick_125: bit(p.flags_2480, 11), + //Ground322 resets in82DE3728. Air/trick states own active writes. + hippy_jump_322: 0, + //Scoring2 reset82DE4468; HoM duration stays inactive in this host. + broken_bone_duration_200: 0.0, + //AirCollector82DA78D0 publishes the world conditioner capability mask. + capabilities_204: physical.scoring.capabilities_204, + }, + damped_com_80: com.position, + //82C03304..3340 copies actual BoardBody80 into Ground80 and96. + ground_up_80: [normal.x, normal.y, normal.z, 0.0], + //Ground288 resets82DE3728; selected ordinary Ground Fill leaves it. + ground_scalar_288: 0.0, + look_552_556: [ + skater.animation_input.extra.look_x, + skater.animation_input.extra.look_y, + ], + collision_look_target_64: physical.collision.predicted_position_64.map(f32::from_bits), + preferences, + context, + }) +} + +fn bit(value: u32, shift: u32) -> u8 { + ((value >> shift) & 1) as u8 +} diff --git a/crates/skate-game/src/physics/clock.rs b/crates/skate-game/src/physics/clock.rs new file mode 100644 index 00000000..ec0c9317 --- /dev/null +++ b/crates/skate-game/src/physics/clock.rs @@ -0,0 +1,70 @@ +//! Offline tick cadence. Original TU3 Simulation::OnRequestSetSimRate82857EC8 +//! sets a wall-clock timer; it does not change the physical integration step. +use skate_core::camera::SimulationRateRequest; +use std::time::Duration; + +const NORMAL_STEP: f32 = f32::from_bits(0x3c888889); + +#[derive(Clone, Copy, Debug)] +pub(super) struct SimulationClock { + ticks_until_reset: u32, + timer_period: Duration, +} + +impl Default for SimulationClock { + fn default() -> Self { + Self { + ticks_until_reset: 0, + timer_period: timer_period(60), + } + } +} + +impl SimulationClock { + pub fn apply(&mut self, request: SimulationRateRequest) -> Result<(), String> { + //82857F44..7C rounds the reciprocal to an integer timer frequency. + let frequency = (1.0_f32 / request.timestep + 0.5).trunc(); + if !frequency.is_finite() || frequency < 1.0 || frequency > i32::MAX as f32 { + return Err(format!( + "Invalid simulation-rate request: {}", + request.timestep + )); + } + let frequency = frequency as i32; + if 10_000_000 / frequency == 0 { + return Err("Simulation-rate request has a zero timer period".into()); + } + self.timer_period = timer_period(frequency); + self.ticks_until_reset = if request.timestep == NORMAL_STEP { + 0 + } else if (request.ticks as i32) > 0 { + request.ticks.wrapping_add(1) + } else { + //TU3 hardcodes180 at82857F34; S2 used a debug setting here. + 180 + }; + Ok(()) + } + + pub fn finish_tick(&mut self) { + //SimUpdate2 8285A694..A6C8 runs after the camera publication. Pending + //camera messages are dispatched at the start of the following tick. + self.ticks_until_reset = self.ticks_until_reset.wrapping_sub(1); + if self.ticks_until_reset == 0 { + self.timer_period = timer_period(60); + } + } + + pub fn period(&self) -> Duration { + self.timer_period + } +} + +fn timer_period(frequency: i32) -> Duration { + //Original timer setter82966910..2C: integer division in100ns units. + Duration::from_nanos((10_000_000 / frequency) as u64 * 100) +} + +#[cfg(test)] +#[path = "tests/clock.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/colliders.rs b/crates/skate-game/src/physics/colliders.rs new file mode 100644 index 00000000..254ec719 --- /dev/null +++ b/crates/skate-game/src/physics/colliders.rs @@ -0,0 +1,148 @@ +//! Bind stock child geometry to the live solver part transforms. +use super::settings::PhysicsSettings; +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + board_step::CollisionBody, + board_world::BoardWorldVolume, + collision::Sphere, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + mass::{DeckShape, MassShape}, + world_contact::{ContactPrimitive, transform_triangle_volume}, + }, +}; + +pub(super) fn world_volumes( + board: &BoardRuntime, + settings: &PhysicsSettings, +) -> Vec { + let poses = board.part_transforms(); + let mut volumes = Vec::new(); + // SerializeVolumes preserves part order, then compound child order. + for id in BodyId::ORDER { + let body = &board.bodies()[id.index()]; + if body.state_flags == 1 { + continue; + } + let pose = poses[id.index()]; + let mut add = |primitive, material: RetailContactMaterial| { + volumes.push(BoardWorldVolume { + body: CollisionBody::Board(id), + primitive, + material, + linear_velocity: body.rates.linear_velocity, + }); + }; + match id { + BodyId::Deck => { + for child in &settings.deck_geometry.children { + if !child.collision_enabled { + continue; + } + let world = compose(pose, child.transform); + let primitive = match child.shape { + DeckShape::Sphere { radius } => ContactPrimitive::Sphere(Sphere { + center: world.translation, + radius, + }), + DeckShape::Capsule { + radius, + half_length, + } => capsule(world, radius, half_length), + DeckShape::RoundedBox { + half_extents, + radius, + } => ContactPrimitive::RoundedBox { + center: world.translation, + basis: world.basis, + half_extents, + radius, + }, + DeckShape::Triangle { + vertices, + fatness, + edge_cosines, + volume_flags, + } => ContactPrimitive::Triangle(transform_triangle_volume( + vertices, + fatness, + edge_cosines, + volume_flags, + world.basis, + world.translation, + )), + }; + add(primitive, settings.deck_material); + } + } + BodyId::FrontTruck | BodyId::BackTruck => { + if settings.truck_collisions { + let MassShape::Capsule { + radius, + half_length, + } = settings.truck_shape + else { + unreachable!("TU382C0A6F8 constructs a capsule for each truck"); + }; + add(capsule(pose, radius, half_length), settings.truck_material); + } + } + _ => add( + ContactPrimitive::Sphere(Sphere { + center: pose.translation, + radius: settings.wheel_radius, + }), + settings.wheel_material, + ), + } + } + volumes +} + +fn capsule(pose: RetailAffineTransform, radius: f32, half_length: f32) -> ContactPrimitive { + let z = pose.basis.columns[2]; + ContactPrimitive::Capsule { + center: pose.translation, + axis: Vector3::new(z[0], z[1], z[2]), + half_length, + radius, + } +} + +// Same multiply/add order as the Volume-to-world vertex path82ADE468. +fn transform(basis: Basis3, point: Vector3, origin: Vector3) -> Vector3 { + let translation = [origin.x, origin.y, origin.z]; + let component = |row| { + point.z.mul_add( + basis.columns[2][row], + point.y.mul_add( + basis.columns[1][row], + point.x.mul_add(basis.columns[0][row], translation[row]), + ), + ) + }; + Vector3::new(component(0), component(1), component(2)) +} +fn compose(parent: RetailAffineTransform, child: RetailAffineTransform) -> RetailAffineTransform { + RetailAffineTransform { + basis: Basis3 { + columns: child.basis.columns.map(|c| { + // CreateGP82AD8758 starts basis composition with a product; + // only position composition includes an initial translation. + core::array::from_fn(|row| { + c[2].mul_add( + parent.basis.columns[2][row], + c[1].mul_add( + parent.basis.columns[1][row], + c[0] * parent.basis.columns[0][row], + ), + ) + }) + }), + }, + translation: transform(parent.basis, child.translation, parent.translation), + } +} diff --git a/crates/skate-game/src/physics/controls.rs b/crates/skate-game/src/physics/controls.rs new file mode 100644 index 00000000..6c4701ae --- /dev/null +++ b/crates/skate-game/src/physics/controls.rs @@ -0,0 +1,81 @@ +//! Controller-derived state is evaluated on the same fixed gameplay tick. +use super::{GamePhysics, skater::SkaterRuntime}; +use crate::input::ControllerInput; +use bevy::prelude::*; +use skate_core::graph::intents::IntentMap; +use skate_core::input::{ + controller::{ActionMap, DerivedControllerInput, MagnitudeHeldSettings}, + riding_intentions::{self, PushPreferences, RidingIntent}, + wipeout_intentions, +}; + +#[derive(Resource)] +pub(crate) struct PlayerControls { + pub controller: DerivedControllerInput, + pub intents: Vec, + pub action_intents: IntentMap, + pub ticks: u64, + pub actor_flags: u32, + pub bumper_state_502: bool, + pub bumper_state_104: bool, + pub preferences: PushPreferences, +} +impl Default for PlayerControls { + fn default() -> Self { + let mut controller = DerivedControllerInput::from_words([0; 26]); + controller.initialize(); + Self { + controller, + intents: Vec::new(), + action_intents: IntentMap::new(), + ticks: 0, + actor_flags: 0, + bumper_state_502: false, + bumper_state_104: false, + preferences: PushPreferences::default(), + } + } +} + +pub(super) fn sample( + input: Res, + physics: Res, + skater: Res, + mut player: ResMut, +) { + let mut map = input.player_actions(); + player.update( + &mut map, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); +} + +impl PlayerControls { + pub fn update(&mut self, map: &mut impl ActionMap, dt: f32, magnitude_threshold: f32, physical_capabilities: u32) { + self.controller.update( + map, + dt, + self.bumper_state_502, + self.bumper_state_104, + &MagnitudeHeldSettings { + attribute: Some(magnitude_threshold), + // The validated field is present, so no engine missing-field path runs. + missing_attribute_value: 0.0, + }, + ); + self.intents = + riding_intentions::produce(&self.controller, self.actor_flags, self.preferences); + self.intents.extend(wipeout_intentions::produce( + &self.controller, self.actor_flags, physical_capabilities, + )); + //GenerateActionGraphIntents82594310 clears the AG map through82BC1B68 + //before Listener::Fill. MG lifecycle intents use a different persistent map. + self.action_intents.clear(); + for intent in &self.intents { + self.action_intents.insert(intent.name, intent.value); + } + self.ticks += 1; + } +} diff --git a/crates/skate-game/src/physics/foot_ik.rs b/crates/skate-game/src/physics/foot_ik.rs new file mode 100644 index 00000000..c8da136b --- /dev/null +++ b/crates/skate-game/src/physics/foot_ik.rs @@ -0,0 +1,229 @@ +//! Physical skeleton IK data binding. This is the production skeleton's IK owner, +//! using authored physics volumes and the real reparented animation targets. +use super::animated_skeleton::AnimatedSkeleton; +use skate_core::{ + animation::foot_ik::{ + drive::Geometry, + post_contact::SettingsPost, + post_physics, + settings::Settings, + state::{FootIkState, LIMBS, UpdateInput}, + status::BlendSettings, + transforms::inverse_rigid, + two_bone::AngleLimits, + }, + physics::skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, +}; +use skate_data::{animation_frames::AnimationFrames, collections::Collections}; + +pub(crate) struct FootIk { + pub state: FootIkState, + geometry: Geometry, + settings: Settings, + post_settings: SettingsPost, + bone_indices: [usize; 24], +} + +/// Physical observations from the real processed state and body owner. All +///matrices remain in stock simulation coordinates, before renderer conversion. +pub(crate) struct PhysicalInput<'a> { + pub state_id: u32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2480: u32, + pub contact_bone: usize, + /// Skeleton12496, produced by the current board-update branch. + pub physical_board: &'a Transform, + /// Body skeleton's actual hips position, DataIn416. + pub hips_world_position: [f32; 4], + /// Actual processed foot queries960/1008, not fabricated deck hits. + pub current_contacts: [Option<[f32; 4]>; 2], +} + +pub(crate) struct DriveOutput { + pub frames: [Transform; 24], + pub updated_limbs: [bool; 4], + pub support_failed: bool, + pub support_failed_this_update: bool, +} + +impl FootIk { + pub fn load( + data: &Collections, + animation: &AnimationFrames, + skeleton: &AnimatedSkeleton, + ) -> Result { + //82BD3720 walks parents until it finds a represented physical part; + //intermediate hierarchy bones do not become phantom physical joints. + let mut parents = [None; 24]; + for (part, &bone) in skeleton.bone_indices.iter().enumerate() { + let mut ancestor = *animation + .parents + .get(bone) + .ok_or("IK bone exceeds stock hierarchy")?; + let mut visited = vec![false; animation.parents.len()]; + while ancestor >= 0 { + let index = ancestor as usize; + if index >= visited.len() || visited[index] { + return Err(format!( + "Invalid stock IK hierarchy ancestor for part {part}" + )); + } + visited[index] = true; + if let Some(parent) = skeleton.bone_indices.iter().position(|&bone| bone == index) { + parents[part] = Some(parent); + break; + } + ancestor = animation.parents[index]; + } + } + let geometry = Geometry::new(parents, &skeleton.physics_frames)?; + geometry.validate_limbs(&LIMBS)?; + Ok(Self { + state: FootIkState::default(), + geometry, + settings: load_settings(data)?, + post_settings: SettingsPost { + minimum_board_up: data.float( + "physics_animation", + "default", + "PostIKMinYAxisVal", + )?, + wipeout_height: data.float("animation", "default", "FeetRelativeHeightWipeout")?, + riding_height: data.float("animation", "default", "FeetRelativeHeightOnDeck")?, + }, + bone_indices: skeleton.bone_indices, + }) + } + + pub fn enable_feet(&mut self, enabled: bool) { + self.state.enable_feet(enabled); + } + + ///Skeleton Fill82BE1D98..1E00 publishes each physical toe frame applied + ///to the adjacent authored inverse-part translation, not its volume COM. + pub fn physical_toe_positions( + &self, + record: &skate_core::physics::skeleton_body::SkeletonPhysicalRecord, + ) -> [[f32; 4]; 2] { + use skate_core::physics::skeleton_animation_record::transform_point; + [ + transform_point(&record.pose[19], self.geometry.inverse_part_frames[20][3]), + transform_point(&record.pose[15], self.geometry.inverse_part_frames[16][3]), + ] + } + + ///Call after native post-physics records/COM/error publication and board + ///wobble, before SkeletonOutput::publish replaces the final pose buffers. + pub fn post_physics( + &mut self, + body: &mut skate_core::physics::skeleton_body::SkeletonBody, + input: post_physics::Input<'_>, + ) -> [bool; 4] { + post_physics::update( + &mut self.state, + body, + &self.geometry, + &self.settings, + &self.post_settings, + input, + ) + } + + pub fn update( + &mut self, + skeleton: &AnimatedSkeleton, + globals: &[Transform], + input: PhysicalInput<'_>, + ) -> Result { + let mut drives = skeleton.record.pose; + //GeneralUpdate skips all IK stages for702 but still submits mapped + //animation drives. Preserve histories across that skipped update. + let mut updated_limbs = [false; 4]; + if input.state_id != 702 { + let mut originals = skeleton.record.pose; + for (part, &bone) in self.bone_indices.iter().enumerate() { + originals[part] = *globals + .get(bone) + .ok_or("IK original joint is absent from the current pose")?; + } + //UpdateIKData's503 branch uses the animated board in world space + //for both board inputs; every other state consumes distinct caches. + let animated_world = + compose_affine(&skeleton.roots.animation_to_world, &skeleton.record.pose[0]); + let special_inverse = inverse_rigid(&animated_world); + let (physical_board, contact_board, inverse_contact_board) = if input.state_id == 503 { + (&animated_world, &animated_world, &special_inverse) + } else { + ( + input.physical_board, + &skeleton.roots.board, + &skeleton.roots.inverse_board, + ) + }; + updated_limbs = self.state.update( + UpdateInput { + flags_2468: input.flags_2468, + flags_2472: input.flags_2472, + flags_2480: input.flags_2480, + animation_to_world: &skeleton.roots.animation_to_world, + world_to_animation: &skeleton.roots.world_to_animation, + physical_board, + contact_board, + inverse_contact_board, + animation: &skeleton.record.pose, + original_animation: &originals, + targets: &skeleton.targets, + hips_world_position: input.hips_world_position, + contact_bone: input.contact_bone, + foot_bones: [self.bone_indices[15], self.bone_indices[19]], + current_contacts: input.current_contacts, + }, + &self.geometry, + &self.settings, + &mut drives, + ); + } + Ok(DriveOutput { + frames: drives, + updated_limbs, + support_failed: self.state.contacts.support_failed, + support_failed_this_update: self.state.contacts.support_failed_this_update, + }) + } +} + +///82BED418 classF2BD493E resolves to physics_skeletonik/default. XML schema +/// gives the cached offsets; each vector retains its authored fourth word. +pub(crate) fn load_settings(data: &Collections) -> Result { + let vector = |name| { + data.words::<4>("physics_skeletonik", "default", name) + .map(|words| words.map(f32::from_bits)) + }; + let scalar = |name| data.float("physics_skeletonik", "default", name); + let half_width = data.float("physicsdeck", "default", "DeckWidth")? * 0.5; + let half_length = data.float("physicsdeck", "default", "DeckMidLength")? * 0.5; + Ok(Settings { + angle_limits: AngleLimits { + minimum_degrees: data.float("physics_animation", "default", "IKMinAngle")?, + maximum_degrees: data.float("physics_animation", "default", "IKMaxAngle")?, + }, + blend: BlendSettings { + hand_inner_padding: vector("IKOnPadding")?, + hand_outer_padding: vector("IKOffPadding")?, + external_blend_step: scalar("FeetIKMaxBlendDeltaOut")?, + board_blend_step: scalar("FeetIKMaxBlendDelta")?, + }, + post_ik_padding: vector("PostIKPadding")?, + contact_bounds: vector("IKBoneOnDeckBBox")?, + foot_on_deck_padding: vector("FootOnDeckPadding")?, + wipeout_feet_offset: scalar("WipeoutFeetOffset")?, + deck_half_width: half_width, + deck_half_length: half_length, + // Source uses FrontEndSize here, while the deck collider's fan loop + // uses BackEndSize. They are distinct authored parameters. + deck_total_half_length: data.float("physicsdeck", "default", "DeckFrontEndSize")? + + half_length, + deck_front_angle_degrees: data.float("physicsdeck", "default", "DeckFrontEndAngle")?, + }) +} diff --git a/crates/skate-game/src/physics/foot_ik_queries.rs b/crates/skate-game/src/physics/foot_ik_queries.rs new file mode 100644 index 00000000..7edbf147 --- /dev/null +++ b/crates/skate-game/src/physics/foot_ik_queries.rs @@ -0,0 +1,151 @@ +//! PhysicalPlayerHiLOD StartSkeletonLineTests82DB63C0 / result publication +//!82DB6580. These are radius-bearing trajectory tests from actual physical parts. +use skate_core::{ + camera::TrajectoryQuery, + math::Vector3, + physics::{ + board_world::BoardWorld, + skeleton_body::SkeletonBody, + triangle_query::{TriangleLineHit, triangle_segment}, + }, + player::input_phase::{LineTestFields, PlayerInputState}, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct SkeletonLineHit { + /// Result0 and32, copied to the physical query record by82DB65E0/F0. + pub position: [f32; 4], + pub normal: [f32; 4], + /// Result132 and collisionTime48>=0, not an arbitrary geometry-present flag. + pub surface: u32, + pub hit: bool, + pub collision_time: f32, +} +impl SkeletonLineHit { + fn pending_result() -> Self { + // Valid test result initialized by8276C9D8: position0, normalY, + //time-1 and surface132=0. A no-hit result still has this real record. + Self { + position: [0.0; 4], + normal: [0.0, 1.0, 0.0, 0.0], + surface: 0, + hit: false, + collision_time: -1.0, + } + } +} + +pub(crate) struct SkeletonLineTests { + pub hips: SkeletonLineHit, + pub feet: [SkeletonLineHit; 2], +} +impl SkeletonLineTests { + /// EndSkeletonLineTests82DB6580 publishes the completed tests in the + /// order hips, left toe, right toe. ProcessInput copies these retained + /// records into Processed1056/960/1008 during its input phase. + pub fn publish(&self, player: &mut PlayerInputState) { + let fields = |hit: SkeletonLineHit| LineTestFields { + position: hit.position.map(f32::to_bits), + normal: hit.normal.map(f32::to_bits), + surface: hit.surface, + valid: u8::from(hit.collision_time >= 0.0), + }; + player.hips_line_test_1488 = fields(self.hips); + player.left_line_test_1536 = fields(self.feet[0]); + player.right_line_test_1584 = fields(self.feet[1]); + } + + /// These observations become Processed960/1008. SkeletonIK retains its + ///own previous query and consumes it before importing this pair. + pub fn foot_contacts(&self) -> [Option<[f32; 4]>; 2] { + self.feet.map(|foot| foot.hit.then_some(foot.position)) + } +} + +pub(crate) fn query(world: &BoardWorld, body: &SkeletonBody) -> Result { + let parts = body.part_transforms(); + //82DB63EC..647C selects hips23,lefttoe15,righttoe19 from the + //physical pose. Body COM and the reparented animated targets are different. + let hips = query_trajectory(world, parts[23][3], [0.0, -100.0, 0.0, 0.0])?; + let raised = [0.0, f32::from_bits(0x3EA8_F5C3), 0.0, 0.0]; + let lowered = [0.0, -1.5, 0.0, 0.0]; + //Keep the subtraction and subsequent FMA evaluation separate: do not + //replace this with an independently rounded literal -1.83 or finalY-1.5. + let velocity = std::array::from_fn(|lane| lowered[lane] - raised[lane]); + let mut feet = [SkeletonLineHit::pending_result(); 2]; + for (foot, part) in [15, 19].into_iter().enumerate() { + let start = std::array::from_fn(|lane| parts[part][3][lane] + raised[lane]); + feet[foot] = query_trajectory(world, start, velocity)?; + } + Ok(SkeletonLineTests { hips, feet }) +} + +fn query_trajectory( + world: &BoardWorld, + start: [f32; 4], + velocity: [f32; 4], +) -> Result { + //82E0B4E0 -> TestBatchManager backend virtual28. Its ctor82E0B460 + //uses Island virtual84=82764CF8, then82775E48/8276E010 creates + //ClusteredMeshTrajectoryBatch vtable8231093C, AddTest8276E280. + //S2 named827BDCC0 corroborates radius/time/error fields; TU3 preserves + //f1..f4 through AggregateTrajectoryBatch8276EDD0 when multiple batches exist. + let request = TrajectoryQuery { + position: start, + velocity, + gravity: [0.0; 4], + duration: 1.0, + radius: f32::from_bits(0x3CF5_C28F), + start_error: 0.0, + end_error: 0.0, + }; + let mut result = SkeletonLineHit::pending_result(); + result.collision_time = request.collision_time(|start, end, radius| { + let origin = vector(start); + let direction = Vector3::new(end[0] - start[0], end[1] - start[1], end[2] - start[2]); + let mut nearest = f32::MAX; + let mut position = None; + for triangle in world.triangles() { + let mut geometry = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + //Same source82771D08 leaf as camera: zero triangle fatness, + //authored face normal, fraction clamp and first equal hit retained. + if triangle_segment( + &mut geometry, + origin, + direction, + triangle.triangle.vertices, + radius, + 0.0, + ) { + let lower = if -geometry.fraction >= 0.0 { + 0.0 + } else { + geometry.fraction + }; + let fraction = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + if fraction < nearest { + nearest = fraction; + let point = lanes(geometry.position); + result.position = point; + result.normal = lanes(triangle.triangle.feature.normal); + result.surface = triangle.tag; + position = Some(point); + } + } + } + Ok::<_, String>(position) + })?; + result.hit = result.collision_time >= 0.0; + Ok(result) +} +fn vector(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/foot_physical_output.rs b/crates/skate-game/src/physics/foot_physical_output.rs new file mode 100644 index 00000000..9b07c843 --- /dev/null +++ b/crates/skate-game/src/physics/foot_physical_output.rs @@ -0,0 +1,36 @@ +//! Persistent original physical-foot output owner. Call after final physical +//! record correction, at Skeleton::FillPhysOut82BE2138/2140 publication time. +use skate_core::physics::{ + foot_physical_output::{FootPhysicalOutput, FootPhysicalSettings, FootPhysicalState}, + skeleton_body::SkeletonPhysicalRecord, +}; +use skate_data::collections::Collections; +pub(crate) struct FootPhysicalOutputs { + state: FootPhysicalState, + settings: FootPhysicalSettings, + pub output: FootPhysicalOutput, +} +impl FootPhysicalOutputs { + pub fn load(data: &Collections) -> Result { + Ok(Self { + state: FootPhysicalState::default(), + output: FootPhysicalOutput::default(), + settings: FootPhysicalSettings { + deck_half_width: data.float("physicsdeck", "default", "DeckWidth")? * 0.5, + deck_total_half_length: data.float("physicsdeck", "default", "DeckFrontEndSize")? + + data.float("physicsdeck", "default", "DeckMidLength")? * 0.5, + padding: data + .words::<4>("physics_skeletonik", "default", "FootOnDeckPadding")? + .map(f32::from_bits), + }, + }) + } + pub fn reset(&mut self) { + self.state.reset(); + self.output = FootPhysicalOutput::default(); + } + pub fn publish(&mut self, record: &SkeletonPhysicalRecord, dt: f32) -> FootPhysicalOutput { + self.output = self.state.update(record, dt, self.settings); + self.output + } +} diff --git a/crates/skate-game/src/physics/footplant.rs b/crates/skate-game/src/physics/footplant.rs new file mode 100644 index 00000000..9645c505 --- /dev/null +++ b/crates/skate-game/src/physics/footplant.rs @@ -0,0 +1,126 @@ +//! FootPlantManager state, original TU3 inline ctor82DB217C and Reset82D6F440. +//! Query results are retained separately from pending query ownership. +use skate_core::air::trajectory::{QueryResult, Trajectory}; +use skate_data::collections::Collections; +mod math; +mod pose; +mod settings; +mod update; +use settings::Settings; +pub(crate) use update::FootplantFrame; +pub(crate) type V = [f32; 4]; +pub(crate) struct Footplant { + pub enabled: bool, //240; FullReset and Air Enter clear this separately. + pub(super) result: QueryResult, + pub(super) settings: Settings, + pub selected_toe: Option, //616 signed -1 + pub candidate: bool, //624 + pub hit: bool, //625 + pub perform: bool, //626 + pub flag_627: bool, + pub launch_valid: bool, //628 + pub lock_valid: bool, //629 + pub contact_active: bool, //630 + pub requested: bool, //631 + pub contact_time: f32, //592 + pub scalar_596: f32, + pub scalar_600: f32, + pub target_blend: f32, //604 + pub active_elapsed: f32, //608 + pub scalar_612: f32, + pub surface: u32, //620 survives Reset + pub contact: V, //288 + pub adjusted_contact: V, //304 + pub(super) current_up: V, //320: Reset preserves it; ctor does not define it + pub(super) selected_world: V, //256 + pub(super) selected_record: V, //272 + pub(super) leg_direction: V, //336 + pub(super) vectors_352_368: [V; 2], + 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 +} +impl Footplant { + pub fn load(data: &Collections) -> Result { + //Deterministic host storage for fields the native ctor leaves unwritten. + //The first-ever320 value is not claimed as native memory parity. + let mut state = Self { + enabled: false, + result: QueryResult::miss(), + settings: Settings::load(data)?, + selected_toe: None, + candidate: false, + hit: false, + perform: false, + flag_627: false, + launch_valid: false, + lock_valid: false, + contact_active: false, + requested: false, + contact_time: -1.0, + scalar_596: -1.0, + scalar_600: 0.0, + target_blend: 0.0, + active_elapsed: 0.0, + scalar_612: 0.0, + surface: 0, + contact: [0.0; 4], + adjusted_contact: [0.0; 4], + current_up: [0.0; 4], + selected_world: [0.0; 4], + selected_record: [0.0; 4], + leg_direction: [0.0; 4], + vectors_352_368: [[0.0; 4]; 2], + physical_com: [0.0; 4], + animation_com: [0.0; 4], + launch_direction: [0.0; 4], + locked_target: [0.0; 4], + request: Trajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + duration: -1.0, + }, + }; + state.reset(); + Ok(state) + } + ///82D6F440. Does not cancel the query or erase its last delivered result, + ///current up, selected positions, or surface. Enter clears240 separately. + pub fn reset(&mut self) { + self.contact_time = -1.0; + self.scalar_596 = -1.0; + self.selected_toe = None; + self.scalar_600 = 0.0; + self.target_blend = 0.0; + self.active_elapsed = 0.0; + self.scalar_612 = 0.0; + self.candidate = false; + self.hit = false; + self.perform = false; + self.flag_627 = false; + self.launch_valid = false; + self.lock_valid = false; + self.contact_active = false; + self.requested = false; + self.request = Trajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + duration: -1.0, + }; + self.contact = [0.0; 4]; + self.adjusted_contact = [0.0; 4]; + self.vectors_352_368 = [[0.0; 4]; 2]; + self.animation_com = [0.0; 4]; + self.locked_target = [0.0; 4]; + self.launch_direction = [0.0; 4]; + self.physical_com = [0.0; 4]; + } + pub fn full_reset(&mut self) { + self.enabled = false; + self.reset(); + } +} diff --git a/crates/skate-game/src/physics/footplant/math.rs b/crates/skate-game/src/physics/footplant/math.rs new file mode 100644 index 00000000..6873c422 --- /dev/null +++ b/crates/skate-game/src/physics/footplant/math.rs @@ -0,0 +1,74 @@ +//! 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 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +pub(super) 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 { + std::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn scale(a: V, s: f32) -> V { + a.map(|v| v * s) +} +pub(super) 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 { + let mut r = s.recip(); + for _ in 0..2 { + r = (-s).mul_add(r, 1.0).mul_add(r, r); + } + r +} +fn rsqrt(s: f32) -> f32 { + let mut r = s.sqrt().recip(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-s).mul_add(r * r, 1.0), r); + } + r +} +pub(super) 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 { + let s = dot(a, a); + let r = rsqrt(s); + let l = if s == 0.0 { 0.0 } else { s * r }; + if l > f32::from_bits(0x358637bd) { + scale(a, r) + } else { + [0.0; 4] + } +} +pub(super) 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 { + 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 { + let l = length(v); + if !(l >= f32::from_bits(0x37800000)) { + return v; + } + let capped = if maximum - l >= 0.0 { l } else { maximum }; + scale(scale(v, capped), reciprocal(l)) +} +pub(super) 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/pose.rs b/crates/skate-game/src/physics/footplant/pose.rs new file mode 100644 index 00000000..6fbd8dcb --- /dev/null +++ b/crates/skate-game/src/physics/footplant/pose.rs @@ -0,0 +1,68 @@ +//! Footplant82D70070 publishes real FootIK targets and temporary collision disables. +use super::{Footplant, math::*}; +use crate::physics::{animated_skeleton::AnimatedSkeleton, foot_ik::FootIk}; +use skate_core::{ + air::known::KnownAirFootplantInput, physics::skeleton_body::SkeletonCollisionMode, +}; +impl Footplant { + pub(super) fn update_pose( + &mut self, + input: &KnownAirFootplantInput, + skeleton: &AnimatedSkeleton, + ik: &mut FootIk, + collision: &mut SkeletonCollisionMode, + ) { + //82453298, shared source-derived polynomial and independent PC estimate seed. + let angle = skate_core::trigonometry::acos(dot(self.launch_direction, self.leg_direction)); + let turns = angle * f32::from_bits(0x3e22f983); + let fraction = turns - turns.floor(); + let wrapped = + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c90fdb); + if self.requested && self.candidate && self.contact_time < 0.3 { + let contact_length = length(sub(self.adjusted_contact, self.animation_com)); + let leg_length = length(sub(self.selected_world, self.animation_com)); + let distance = if leg_length < contact_length { + leg_length + } else { + contact_length + }; + let inverse = &skeleton.roots.world_to_animation; + let com = point(inverse, self.animation_com); + let velocity = rotate(inverse, scale(input.trajectory.velocity, -STEP)); + let direction = rotate(inverse, self.launch_direction); + let mut target = madd(direction, distance, add(com, velocity)); + if self.lock_valid { + target = add( + self.locked_target, + clamp_length(sub(target, self.locked_target), 0.15), + ); + } + self.locked_target = target; + self.lock_valid = true; + let side = if self.selected_toe == Some(15) { 0 } else { 1 }; + ik.state.external_targets[side].animation_position = target; + ik.state.limbs[side].local_target_set = true; + ik.state.limbs[side].target_blend = self.target_blend; + self.target_blend = clamp01(self.target_blend + 0.12); + if self.contact_time < 0.2 { + for part in if side == 0 { + [15, 16, 17] + } else { + [19, 20, 21] + } { + collision.pending_reenable = true; + collision.disable_count[part] = 2; + collision.parts[part].enabled = false; + } + } + } + if self.requested { + self.perform = self.hit + && !(self.contact_time > 0.017) + && self.settings.max_leg_angle_error * f32::from_bits(0x3c8efa35) > wrapped; + } else { + self.target_blend = 0.0; + self.perform = false; + } + } +} diff --git a/crates/skate-game/src/physics/footplant/settings.rs b/crates/skate-game/src/physics/footplant/settings.rs new file mode 100644 index 00000000..4d831416 --- /dev/null +++ b/crates/skate-game/src/physics/footplant/settings.rs @@ -0,0 +1,23 @@ +//! Cached physics_footplantmanager layout used by82D6FD60/82D6F5F0/82D70070. +use skate_data::collections::Collections; +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 +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let float = |name| data.float("physics_footplantmanager", "default", name); + Ok(Self { + deck_bounds: data + .words::<4>("physics_footplantmanager", "default", "DeckBB")? + .map(f32::from_bits), + max_leg_angle_error: float("MaxLegAngleError")?, + leg_length_on_landing: float("LegLengthOnLanding")?, + foot_volume_y_offset: float("FootVolumeYOffset")?, + deck_bounds_y_offset: float("DeckBBYOffset")?, + }) + } +} diff --git a/crates/skate-game/src/physics/footplant/update.rs b/crates/skate-game/src/physics/footplant/update.rs new file mode 100644 index 00000000..93d0a605 --- /dev/null +++ b/crates/skate-game/src/physics/footplant/update.rs @@ -0,0 +1,161 @@ +//! Original82D6FD60 ->82D6F5F0 ->82D6F910, invoked by82D36728. +use super::{Footplant, V, math::*}; +use crate::physics::{ + air_trajectory::AirTrajectoryRuntime, animated_skeleton::AnimatedSkeleton, foot_ik::FootIk, +}; +use skate_core::{ + air::{ + known::KnownAirFootplantInput, + trajectory::{QueryRequest, Trajectory}, + }, + physics::{ + board_toolkit::BoardToolkit, + board_world::BoardWorld, + skeleton_body::{SkeletonBody, SkeletonCollisionMode}, + }, + player::input_phase::ProcessedPhysicsInput, +}; +pub(crate) struct FootplantFrame<'a> { + pub processed: &'a ProcessedPhysicsInput, + pub toolkit: &'a BoardToolkit, + pub animated: &'a AnimatedSkeleton, + pub body: &'a SkeletonBody, + pub gravity: V, + pub world: &'a BoardWorld, +} +impl Footplant { + pub fn update_candidate(&mut self, input: &KnownAirFootplantInput, frame: &FootplantFrame<'_>) { + self.candidate = false; + let offset = [0.0, self.settings.deck_bounds_y_offset, 0.0, 0.0]; + let upper = add(self.settings.deck_bounds, offset); + let lower = sub(offset, self.settings.deck_bounds); + let physical = frame.body.record.pose; + let toes = [physical[15][3], physical[19][3]]; + let board = toes.map(|p| point(&frame.toolkit.inverse_effective, p)); + let outside = |p: V| (0..3).any(|i| lower[i] > p[i] || p[i] > upper[i]); + self.physical_com = madd( + input.trajectory.velocity, + STEP, + frame.animated.board_frames.centre_of_mass, + ); + self.animation_com = madd( + input.trajectory.velocity, + STEP, + point( + &frame.animated.roots.animation_to_world, + frame.animated.record.centre_of_mass, + ), + ); + let side = if outside(board[0]) { + 0 + } else if outside(board[1]) { + 1 + } else { + return; + }; + let toe = [15, 19][side]; + self.selected_toe = Some(toe); + self.candidate = true; + self.selected_world = point( + &frame.animated.roots.animation_to_world, + frame.animated.record.pose[toe][3], + ); + self.selected_record = toes[side]; + self.leg_direction = normalize(sub(self.selected_record, self.physical_com)); + } + pub fn update_launch(&mut self, input: &KnownAirFootplantInput) { + use skate_core::air::reckoning::clamp_vector_within_max_angle as limit; + if !self.hit { + self.contact_time = input.remaining_collision_time; + } + let future = madd( + input.trajectory.acceleration, + self.contact_time, + input.trajectory.velocity, + ); + let candidate = sub(normalize(future), input.landing_normal); + let from = scale(self.current_up, -1.0); + let angle = f32::from_bits(0x420c0000) * f32::from_bits(0x3c8efa35); + let direction = normalize(limit(candidate, from, angle)); + //82D6F7A8's second pure limiter result is discarded; v125 retains direction. + let _ = limit(sub(self.adjusted_contact, self.physical_com), from, angle); + self.launch_direction = normalize(direction); + self.launch_valid = true; + self.request = Trajectory { + position: madd( + self.launch_direction, + self.settings.leg_length_on_landing, + self.physical_com, + ), + velocity: input.trajectory.velocity, + //830BD330 initializer82F826B0 uses822F8B40, independently read original bytes. + acceleration: [0.0, f32::from_bits(0xc11ccccd), 0.0, 0.0], + duration: -1.0, + }; + } + pub fn consume_and_submit( + &mut self, + input: &KnownAirFootplantInput, + frame: &FootplantFrame<'_>, + ik: &mut FootIk, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + self.hit = false; + if self.enabled && self.result.contact_time >= 0.0 { + self.contact_time = clamp01(self.result.contact_time); + 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.adjusted_contact = madd( + input.landing_normal, + self.settings.foot_volume_y_offset, + self.contact, + ); + if !self.contact_active { + if self.candidate + && self.contact_time > f32::from_bits(0x3de147ae) + && self.contact_time < 0.3 + { + self.contact_active = true; + self.active_elapsed = 0.0; + } else { + self.clear_contact(); + } + } else if self.active_elapsed + self.contact_time < 0.0 { + self.contact_active = false; + self.clear_contact(); + self.active_elapsed = 0.0; + } + if self.contact_active { + self.requested |= frame.processed.flags_2480 & (1 << 23) != 0; + self.active_elapsed += STEP; + self.update_pose(input, frame.animated, ik, collision); + } + } + self.current_up = frame.processed.vectors_544_560_592_608[0].map(f32::from_bits); + let next = AirTrajectoryRuntime::query( + frame.world, + QueryRequest { + trajectory: Trajectory { + position: self.request.position, + velocity: self.request.velocity, + acceleration: frame.gravity, + duration: 1.0, + }, + radius: f32::from_bits(0x3d23d70a), + start_error: 1.0, + end_error: 1.0, + }, + )?; + self.result = next; + self.enabled = true; + Ok(()) + } + fn clear_contact(&mut self) { + self.surface = 0; + self.hit = false; + self.contact_time = -1.0; + self.contact = [0.0; 4]; + } +} diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs new file mode 100644 index 00000000..14d648cb --- /dev/null +++ b/crates/skate-game/src/physics/frame.rs @@ -0,0 +1,147 @@ +//! One production tick: controller graphs, physical input/state, shared solve, +//! physical animation output and the normal gameplay camera. +use super::{ + GamePhysics, PlayerControls, SkaterRuntime, animation_phase, camera_output, ground_phase, + input_phase, player_state, solve, +}; +use crate::{camera::CameraRuntime, graph_runtime::StockGraphs}; +use skate_core::{input::controller::ActionMap, math::Vector3}; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + controls: &mut PlayerControls, + graphs: &StockGraphs, + actions: &mut dyn ActionMap, + input_available: bool, + camera: &mut CameraRuntime, +) -> Result<(), String> { + //SimController8285C968 dispatches the preceding tick's camera messages + //before simulation. Keep End/Begin ordering when both occur in one update. + for request in camera.simulation_rate_requests.drain(..) { + physics.clock.apply(request)?; + } + if physics.ticks == 0 { + player_state::initialize(physics, skater)?; + //Ctor82DB3008 enters Ground without ProcessOutput. Keep the constructed + //State/FilteredState zeros (82DE3BE8/82DE5588) until the completed tick. + //Publishing Ground here runs its ForcePhysics Begin before the initial + //input reset82DB8998, which would immediately clear that mode again. + } + physics.board.clear_forces(); + //World8275EA20 starts both batches before actor SetUpPhysics. Foot + //results stay pending while PlayerInput consumes the preceding records. + physics + .riding + .start_wheel_queries(&physics.board, &physics.world)?; + let skeleton_queries = super::foot_ik_queries::query(&physics.world, &skater.skeleton)?; + let animation = animation_phase::advance( + physics, + skater, + controls, + graphs, + &physics.animation_profile, + ) + .map_err(|e| format!("Animation tick{}: {e}", physics.ticks))?; + //ForcePhysics Begin82BB2868 writes the live Skeleton16420 mode once. + //Consume the graph request so a later physical reset can retain its own0. + if let Some(mode) = skater.animation.motion.riding.force_mode.take() { + skater.skeleton_input.force_mode = mode.native_value(); + } + let teleported = input_phase::advance( + physics, + skater, + &animation.packet(), + actions, + input_available, + )?; + if teleported { + skater.player_state.reset_for_teleport(); + skater.centre_of_mass_filter.reset(); + skater.animation_feedback.reset(); + player_state::enter_after_teleport(physics, skater)?; + //82DB8E40 calls SetUpNormal after pose reset, state100 and board reset. + skater.wipeout_state.ragdoll.restore_normal( + &mut skater.skeleton, &mut skater.skeleton_joints, + &mut skater.skeleton_collision, &mut skater.collision_feedback, + ); + } + //World8275EC0C ends board queries before PostInput/state selection. + physics.riding.finish_wheel_queries()?; + player_state::post_input_and_select(physics, skater)?; + player_state::pre_state(physics, skater)?; + match skater.player_state.current() { + skate_core::player::state::PhysicalStateId::PhysicsGround => { + let p = &skater.player_input.processed; + let com = p.animation_com_to_deck_752.map(f32::from_bits); + physics.riding.update_ground_reckoning( + &physics.board, + super::riding_outputs::RidingPoseInputs { + com_to_deck: Vector3::new(com[0], com[1], com[2]), + body_spin: skater.animation_input.extra.physical_body_spin, + }, + p.flags_2468, + skater.animation_input.fields.balance, + p.flags_2476 & 0x4000_0000 != 0, + ); + //PhysicsGround::PreUpdate82D37C50 clears the previous push suppression + //after Reckoning; this frame's propulsion may then set it again. + skater.ground.state.push_suppressed_2730 = false; + ground_phase::advance(physics, skater)?; + input_phase::update_ground(physics, skater)?; + } + skate_core::player::state::PhysicalStateId::PhysicsAir => { + super::air_phase::advance(physics, skater)? + } + skate_core::player::state::PhysicalStateId::KnownAir => { + super::known_air::update(physics, skater)? + } + skate_core::player::state::PhysicalStateId::GroundAnimation => { + super::ground_animation::advance(physics, skater)? + } + skate_core::player::state::PhysicalStateId::SlideGround => { + super::slide_state::update(physics, skater)? + } + skate_core::player::state::PhysicalStateId::WipeoutGround => { + super::wipeout_states::advance(physics, skater)? + } + skate_core::player::state::PhysicalStateId::Teleporting => { + //VT823272FC slot8=82D431F0; slots12/40 are original empty leaves. + skater.teleport_state.update(&skater.player_input.processed); + } + state => return Err(format!("Selected {state:?} requires its physical update")), + } + // Source State82DB6120 submits the queue once, then advances state time. + // BoardRuntime applies that same queue during its shared solve. + skater.player_input.player.state_timer_1344 += skater.player_input.processed.timestep_2604; + physics.processed_flags_2468 = skater.player_input.processed.flags_2468; + //World8275ECA4 ends skeleton tests after state/forces and before solving. + //Teleport resets previous observations, but preserves this pending batch. + skeleton_queries.publish(&mut skater.player_input.player); + solve::advance(physics, skater, skater.ground.steering.targets)?; + //ProcessOutput82DB6EE8 resets the packet before its component publishers. + //All consumers of the preceding output have completed this frame's input. + super::player_input::reset_outputs(&mut skater.player_input.physical); + physics.finish_skater(skater)?; + let simulation = physics.settings.step.simulation; + skater.player_input.update_dynamic_normal( + &physics.board, + &physics.riding, + simulation.gravity_acceleration, + simulation.time_step, + ); + skater.player_input.publish_board(&physics.riding)?; + player_state::publish(physics, skater)?; + let physical = &skater.player_input.physical; + skater.centre_of_mass_output = skater.centre_of_mass_filter.update( + physical.reckoning.vector_64.map(f32::from_bits), + physical.reckoning.vector_16.map(f32::from_bits), + ); + let feedback = animation_phase::publish_feedback(physics, skater); + camera_output::advance(physics, skater, &feedback, camera)?; + skater.animation_input.finish_output_publication(); + skater.player_input.player.update_count_1316 = + skater.player_input.player.update_count_1316.wrapping_add(1); + physics.clock.finish_tick(); + Ok(()) +} diff --git a/crates/skate-game/src/physics/ground.rs b/crates/skate-game/src/physics/ground.rs new file mode 100644 index 00000000..55b7ffb1 --- /dev/null +++ b/crates/skate-game/src/physics/ground.rs @@ -0,0 +1,196 @@ +//! Our level geometry, queried with the recovered physics/world calculations. +use skate_core::{ + math::Vector3, + physics::{ + board_world::{ + BoardWorld, ContactRetentionSettings, WorldTriangle, + query_metadata::{Bounds, EdgeSegment, QueryMesh, QueryMetadata, QueryPool}, + }, + collision::{TriangleFeature, WorldContactSettings}, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + world_contact::triangle_from_volume, + }, +}; + +pub(crate) const HEIGHT: f32 = -0.035; + +/// Authored level dimensions in metres; HEIGHT remains the starting surface. +pub(crate) const FLOOR_HEIGHT: f32 = HEIGHT - 1.5; + +/// Shared quads for rendering and collision: landing floor, starting box, half pipe. +/// Quads use the same winding as the original floor (0,2,1 and 0,3,2). +pub(crate) fn surfaces() -> [Vec<[Vector3; 4]>; 3] { + let v = Vector3::new; + let floor = vec![[ + v(-50.0, FLOOR_HEIGHT, -50.0), + v(50.0, FLOOR_HEIGHT, -50.0), + v(50.0, FLOOR_HEIGHT, 50.0), + v(-50.0, FLOOR_HEIGHT, 50.0), + ]]; + let top = [ + v(-3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, 4.0), + v(-3.0, HEIGHT, 4.0), + ]; + let mut platform = vec![top]; + for i in [0, 1, 3] { + // Forward face opens directly onto the ramp. + let a = top[i]; + let b = top[(i + 1) % 4]; + platform.push([b, a, v(a.x, FLOOR_HEIGHT, a.z), v(b.x, FLOOR_HEIGHT, b.z)]); + } + + // Full-width downhill route straight ahead from spawn. Ease both ends + // into the horizontal surfaces so the crest is not a sharp launch edge. + let ramp_segments = 48; + let ramp_length = 12.0; + let ramp_point = |i: usize| { + let t = i as f32 / ramp_segments as f32; + let blend = t * t * (3.0 - 2.0 * t); + ( + 4.0 + ramp_length * t, + HEIGHT + (FLOOR_HEIGHT - HEIGHT) * blend, + ) + }; + for i in 0..ramp_segments { + let (z0, y0) = ramp_point(i); + let (z1, y1) = ramp_point(i + 1); + platform.push([ + v(-3.0, y0, z0), + v(3.0, y0, z0), + v(3.0, y1, z1), + v(-3.0, y1, z1), + ]); + } + // Open ends at z +/-6 allow entry from the landing floor. Circular + // transitions meet the four-metre flat bottom tangentially. + let radius = 2.5; + let segments = 32; + let mut profile = Vec::new(); + for i in (0..=segments).rev() { + let angle = std::f32::consts::FRAC_PI_2 * i as f32 / segments as f32; + profile.push(( + 12.0 - 2.0 - radius * angle.sin(), + FLOOR_HEIGHT + radius * (1.0 - angle.cos()), + )); + } + for i in 0..=segments { + let angle = std::f32::consts::FRAC_PI_2 * i as f32 / segments as f32; + profile.push(( + 12.0 + 2.0 + radius * angle.sin(), + FLOOR_HEIGHT + radius * (1.0 - angle.cos()), + )); + } + let mut half_pipe = Vec::new(); + for pair in profile.windows(2) { + let (x0, y0) = pair[0]; + let (x1, y1) = pair[1]; + // The flat bottom already belongs to the landing floor. + if y0 == FLOOR_HEIGHT && y1 == FLOOR_HEIGHT { + continue; + } + half_pipe.push([ + v(x0, y0, -6.0), + v(x1, y1, -6.0), + v(x1, y1, 6.0), + v(x0, y0, 6.0), + ]); + } + // One-metre decks at both lips. + for (x0, x1) in [(6.5, 7.5), (16.5, 17.5)] { + let y = FLOOR_HEIGHT + radius; + half_pipe.push([v(x0, y, -6.0), v(x1, y, -6.0), v(x1, y, 6.0), v(x0, y, 6.0)]); + } + [floor, platform, half_pipe] +} + +pub(super) fn world(material: RetailContactMaterial) -> BoardWorld { + let mut triangles = Vec::new(); + for (tag, quads) in surfaces().into_iter().enumerate() { + for vertices in quads { + for indices in [[0, 2, 1], [0, 3, 2]] { + triangles.push(WorldTriangle { + triangle: triangle_from_volume( + indices.map(|i| vertices[i]), + 0.0, + [1.0; 3], + TriangleFeature::ONE_SIDED, + ), + material, + tag: tag as u32, + }); + } + } + } + //This test level explicitly authors material0/category0/upper flags0. + //This is host metadata, not a claim that the stock material is concrete, + //and not derived from the neutral contact coefficients or rendering tag. + let metadata = QueryMetadata { + packed_surfaces: vec![0u16; triangles.len()], + meshes: vec![QueryMesh { + triangle_range: 0..triangles.len(), + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points( + triangles.iter().flat_map(|entry| entry.triangle.vertices), + ) + .expect("Authored level contains collision triangles"), + matching_group: -1, + pool: QueryPool::Ground, + }], + static_edges: query_edges(), + //No island or conditional meshes exist in the current static level. + island_flags: 0, + }; + BoardWorld::with_query_metadata(triangles, metadata) + .expect("Authored level query metadata matches its collision geometry") +} +///Deliberately authored ledge records, in level order. Neither the triangle +///diagonals nor the smooth ramp/half-pipe tessellation seams are ledges. +fn query_edges() -> Vec { + let v = Vector3::new; + let top = [ + v(-3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, 4.0), + v(-3.0, HEIGHT, 4.0), + ]; + let mut edges: Vec<_> = [0, 1, 3] + .into_iter() + .map(|i| (top[i], top[(i + 1) % 4])) + .collect(); + let lip_height = FLOOR_HEIGHT + 2.5; + for x in [7.5, 16.5] { + edges.push((v(x, lip_height, -6.0), v(x, lip_height, 6.0))); + } + edges + .into_iter() + .map(|(start, end)| EdgeSegment { + start, + end, + local_bounds: Bounds::from_points([start, end]).expect("Two authored edge endpoints"), + }) + .collect() +} + +pub(super) fn query_settings() -> (WorldContactSettings, ContactRetentionSettings) { + ( + WorldContactSettings { + // Serialized primitive82DC3AE4/82DC3B84, source literal82165A00. + volume_padding: 0.05, + // Ground job8277C864/8277C86C; board selection82768728. + maximum_separating_distance: 0.5, + edge_cos_bend_normal_threshold: 0.999, + convexity_epsilon: 0.01, + is_object: false, + }, + ContactRetentionSettings { + capacity: u32::MAX, // Host storage capacity, no artificial row limit. + // Step_Collision1 8276430C ->827771E8 ->82777384 ->8277C664. + duplicate_distance_squared: -1.0, + deferred_reduction: true, + }, + ) +} diff --git a/crates/skate-game/src/physics/ground_animation.rs b/crates/skate-game/src/physics/ground_animation.rs new file mode 100644 index 00000000..a81f07a7 --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation.rs @@ -0,0 +1,84 @@ +//! GroundAnimation103 lifecycle82D33BF8/33DB8/33C70/34150, original Skate3TU3. +mod board; +mod settings; +mod skeleton; +use super::{GamePhysics, SkaterRuntime}; +pub(crate) use settings::GroundAnimationSettings; +use skate_core::{ + air::ground_jump::GroundJump, + math::Vector3, + player::input_phase::{AirOutputFields, ProcessedPhysicsInput}, +}; + +#[derive(Default)] +pub(crate) struct GroundAnimationRuntime { + pub jump: GroundJump, //48..68 + pub launched: bool, //80, reset at each update + pub launch_velocity: [f32; 4], //96, selector launch-info2048 +} +impl GroundAnimationRuntime { + ///82D34150 writes only its active fields after the common PhysOut reset. + pub fn fill(&self, p: &ProcessedPhysicsInput, out: &mut AirOutputFields) { + if p.flags_2468 & 0x0040_0000 != 0 { + out.jump_velocity_delta_112 = std::array::from_fn(|i| { + (self.jump.velocity[i] - f32::from_bits(p.vectors_400_416[0][i])).to_bits() + }); + } + if self.launched { + out.launched_442 = 1; + out.launch_velocity_128 = self.launch_velocity.map(f32::to_bits); + } + } +} +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.ground_animation.launched = false; + skater.ground_animation.launch_velocity = [0.0; 4]; + let life = &mut skater.ground_lifecycle; + life.skeleton_elapsed_16505 = true; + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut life.board_animated_290); + if skater.wipeout.state.mode != 1 { + skater.wipeout.state.mode = 1; + skater.wipeout.state.balance = 0.0; + } + Ok(()) +} +pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.ground_animation.launched = false; + let p = &skater.player_input.processed; + let com = p.animation_com_to_deck_752.map(f32::from_bits); + physics.riding.update_ground_reckoning( + &physics.board, + super::riding_outputs::RidingPoseInputs { + com_to_deck: Vector3::new(com[0], com[1], com[2]), + body_spin: skater.animation_input.extra.physical_body_spin, + }, + p.flags_2468, + skater.animation_input.fields.balance, + p.flags_2476 & 0x4000_0000 != 0, + ); + skeleton::advance(physics, skater)?; + skater.ground_lifecycle.skeleton_ground_16388 = true; + board::advance(physics, skater) +} +pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + board::set_drag(physics, 0.0); + physics.settings.wheel_material = physics.settings.standard_wheel_material; + if skater.ground_animation.jump.active { + let p = &skater.player_input.processed; + let mut reference = skater.ground_animation.jump.velocity; + reference[1] = p.gravity_2648.mul_add(p.timestep_2604, reference[1]); + let velocity = skate_core::air::state::clamp_jump_velocity( + reference, + p.vectors_400_416[0].map(f32::from_bits), + ); + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); + } + skater.ground_animation.jump = GroundJump::default(); + Ok(()) +} diff --git a/crates/skate-game/src/physics/ground_animation/board.rs b/crates/skate-game/src/physics/ground_animation/board.rs new file mode 100644 index 00000000..5a95db48 --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation/board.rs @@ -0,0 +1,192 @@ +//!82D33E30: jump launch or ordered foot/brake/friction/collision forces. +use super::*; +use skate_core::{ + air::ground_jump::{self, GroundJumpInput}, + physics::force_queue::QueuedPointForce, + riding::{ + braking::{self, BrakeInput, LinearDragInput}, + collision_response::CollisionResponsePhysical, + ground_force::{self, GroundForceInput}, + grounded::drag::DRAG_FREQUENCY, + slide_friction::{self, SlideFrictionInput}, + }, +}; + +pub(super) fn set_drag(physics: &mut GamePhysics, drag: f32) { + //BodySetLinearDrag82D9CD60 multiplies by native59.999996, for all seven parts. + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = drag * DRAG_FREQUENCY; + } +} +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("GroundAnimation requires current BoardToolkit")?; + let s = &skater.ground_settings; + skater.ground.steering.update( + 0.0, + s.board().steering.tilt_blending, + p.flags_2468, + p.flags_2472, + ); + physics.settings.wheel_material = s.wheel_material; + let mode = *skater + .ground_animation_settings + .modes + .get(p.state_variant_index_2528 as usize) + .ok_or_else(|| { + format!( + "Invalid GroundAnimation physics mode {}", + p.state_variant_index_2528 + ) + })?; + skater.ground_animation.jump = ground_jump::calculate( + GroundJumpInput { + flags_2468: p.flags_2468, + flags_2484: p.flags_2484, + flags_2488: p.flags_2488, + effective_forward: t.effective[2], + forward: t.forward, + current_velocity: p.vectors_400_416[0].map(f32::from_bits), + ground_reference_position: p.vectors_464_480_496_512_528[1].map(f32::from_bits), + reference_up: p.vectors_544_560_592_608[0].map(f32::from_bits), + filtered_ground_normal: t.filtered_normal, + animation_com_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + prepared_velocity: p.prepared_jump_704.map(f32::from_bits), + jump_strength: skater.animation_input.extra.jump_strength, + jump_controls: skater.animation_input.extra.jump_controls, + gravity_y: p.gravity_2648, + surface_speed: p.scalar_2656, + }, + mode, + &skater.ground_animation_settings.jump, + ); + if skater.ground_animation.jump.active { + let velocity = skater.ground_animation.jump.velocity; + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); + skater.ground_animation.launched = true; + set_drag(physics, 0.0); + let mut info = super::super::air_phase::launch_info(physics, skater)?; + info.start_velocity = velocity; + info.player_jumped = true; + //The native caller does not replace Fill's COM velocity144. + let input = super::super::air_phase::selector_input(physics, skater)?; + skater.trajectory.launch(info, input, &physics.world)?; + skater.trajectory.update(input, &physics.world)?; + //Launch82D678DC stores packet at1920;128 is selector2048. Update's + //second pass may replace that retained launch velocity before this read. + skater.ground_animation.launch_velocity = skater + .trajectory + .selector + .launch_info() + .ok_or("GroundAnimation launch has no retained trajectory packet")? + .start_velocity; + return Ok(()); + } + let settings = s.board(); + let balance = skater.animation_input.fields.balance; + let front_scale = if balance <= 0.0 { + if balance >= -0.0 { 1.0 } else { 2.0 } + } else { + 0.0 + }; + let rear_scale = if balance >= -0.0 { + if balance <= 0.0 { 1.0 } else { 2.0 } + } else { + 0.0 + }; + let foot = |offset, absorption, amount| { + ground_force::calculate( + settings.ground_force, + &GroundForceInput { + argument_1: 1.0, + application_z: offset, + argument_3: absorption, + argument_4: amount, + balance, + surface_speed: p.scalar_2656, + axis_384: t.transverse_up, + velocity_400: p.vectors_400_416[0].map(f32::from_bits), + axis_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + }, + ) + }; + let front = foot(s.foot_force_offset, 0.0, front_scale); + //Toolkit cache1240 is FootForceOffset,1236 is AbsorptionFootForceRearScalar. + let rear = foot(-s.foot_force_offset, s.absorption_rear * 0.0, rear_scale); + let brake = braking::calculate_braking( + BrakeInput { + flags_2468: p.flags_2468, + input_2728: skater.animation_input.fields.brake, + signed_speed: p.scalar_2612, + absolute_body_speed: t.absolute_speed, + surface_factor: s.surface_braking_factor, + direction: xyz(t.horizontal_forward), + }, + settings.propulsion.braking, + ); + let friction = slide_friction::calculate( + settings.slide_friction, + &SlideFrictionInput { + heading_time: p.state_timer_2664, + normal: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + velocity: p.vectors_400_416[0].map(f32::from_bits), + side_axis: t.deck[0], + surface_speed: p.scalar_2656, + scalar_2764: p.scalar_2764, + }, + ); + let normal = physics.riding.reckoning.ground_normal; + let drag = braking::calculate_linear_drag( + LinearDragInput { + flags_2468: p.flags_2468, + absolute_body_speed: t.absolute_speed, + balance_2720: balance, + scalar_2724: skater.ground_lifecycle.manual_drag_2724, + comparison_scalar: normal.y, + }, + settings.linear_drag, + ); + let collision = skater + .ground_runtime + .calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: p.collision_pose_error_736.map(f32::from_bits), + velocity: p.vectors_400_416[1].map(f32::from_bits), + forward: t.travel_direction, + up: p.vectors_544_560_592_608[0].map(f32::from_bits), + ground_normal: [normal.x, normal.y, normal.z, 0.0], + time_step: p.timestep_2604, + mass: t.total_mass, + }); + if let Some(collision) = collision { + physics.board.forces_mut().append(QueuedPointForce { + tag: 15, + force_world: xyz(collision.force_2528), + point_body: xyz(collision.point_2544), + }); + //Collision branch preserves the preceding body's drag coefficient. + } else { + physics.board.forces_mut().append(point_force(4, front)); + physics.board.forces_mut().append(point_force(5, rear)); + physics.board.forces_mut().append(point_force(1, friction)); + physics.board.forces_mut().append(brake); + set_drag(physics, drag); + } + Ok(()) +} +fn point_force(tag: u32, v: [f32; 8]) -> QueuedPointForce { + QueuedPointForce { + tag, + force_world: Vector3::new(v[0], v[1], v[2]), + point_body: Vector3::new(v[4], v[5], v[6]), + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_animation/settings.rs b/crates/skate-game/src/physics/ground_animation/settings.rs new file mode 100644 index 00000000..891949bc --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation/settings.rs @@ -0,0 +1,66 @@ +//!82D94C10 copies physics_jump layout0..456 into the board toolkit cache. +use skate_core::{ + air::ground_jump::{GroundJumpMode, GroundJumpSettings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +pub(crate) struct GroundAnimationSettings { + pub jump: GroundJumpSettings, + pub modes: [GroundJumpMode; 5], +} +impl GroundAnimationSettings { + pub fn load(d: &Collections) -> Result { + let f = |name| d.float("physics_jump", "default", name); + let words = d.words::<32>("physics_jump", "default", "VerticalResponse")?; + let mut modes = [GroundJumpMode { + minimum_height_64: 0.0, + minimum_height_68: 0.0, + maximum_height: 0.0, + }; 5]; + for (slot, key) in modes + .iter_mut() + .zip(["easy", "normal", "hardcore", "motorized", "test"]) + { + *slot = GroundJumpMode { + minimum_height_64: d.float("physics_mode", key, "Hash_BE3F74F978D777E5")?, + minimum_height_68: d.float("physics_mode", key, "JumpMinHeight")?, + maximum_height: d.float("physics_mode", key, "JumpMaxHeight")?, + }; + } + Ok(Self { + modes, + jump: GroundJumpSettings { + vertical_response: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[i])), + y: std::array::from_fn(|i| f32::from_bits(words[16 + i])), + }, + y_scalar_vs_normal_y: graph(d, "JumpYScalarVsGroundNormalY", true)?, + speed_scalar_vs_angle: graph(d, "JumpSpeedScalarVsAngle", true)?, + minimum_height_vs_speed: graph(d, "MinHeightVsSpeed", false)?, + maximum_height_vs_speed: graph(d, "MaxHeightVsSpeed", false)?, + speed_response_max_speed: f("SpeedResponseMaxSpeed")?, + minimum_scalar: f("MinScalar")?, + maximum_y_bonus: f("JumpYBonusMax")?, + adjust_z_factor: f("JumpAdjustZFactor")?, + adjust_x_factor: f("JumpAdjustXFactor")?, + absolute_minimum_height: f("AbsoluteMinHeight")?, + }, + }) + } +} +fn graph(d: &Collections, name: &str, negative: bool) -> Result, String> { + let (x, y) = if negative { + let w = d.words::<20>("physics_jump", "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(w[4 + i])), + std::array::from_fn(|i| f32::from_bits(w[12 + i])), + ) + } else { + let w = d.words::<16>("physics_jump", "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(w[i])), + std::array::from_fn(|i| f32::from_bits(w[8 + i])), + ) + }; + Ok(PointGraph { x, y }) +} diff --git a/crates/skate-game/src/physics/ground_animation/skeleton.rs b/crates/skate-game/src/physics/ground_animation/skeleton.rs new file mode 100644 index 00000000..67aaa387 --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation/skeleton.rs @@ -0,0 +1,27 @@ +//!82D33E00 calls SkeletonUpdateAnimated82BDDA10 with fast blending enabled. +use super::*; +use crate::physics::{input_phase, skeleton_input_runtime::SkeletonOwners}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let collision = input_phase::collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + 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.update_animated( + &mut skater.skeleton_air, + &mut physics.board, + &physics.riding.reckoning_frames.system, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + true, + )?; + Ok(()) +} diff --git a/crates/skate-game/src/physics/ground_exit.rs b/crates/skate-game/src/physics/ground_exit.rs new file mode 100644 index 00000000..e18fd31c --- /dev/null +++ b/crates/skate-game/src/physics/ground_exit.rs @@ -0,0 +1,18 @@ +//!Complete PhysicsGround Exit82D37B20, on the real board and controller owners. +use super::{GamePhysics, SkaterRuntime}; +use skate_core::air::state::clamp_jump_velocity; +pub(super) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + let state = &mut skater.ground.state; + state.steering_damped_turn_2644 = 0.0; + state.steering_push_scalar_2640 = 1.0; + skater.ground.pumping.reset(); + for body in physics.board.bodies_mut() { body.inertia.linear_drag = 0.0; } + physics.settings.wheel_material = physics.settings.standard_wheel_material; + if state.flag_2708 { + let p = &skater.player_input.processed; + let gravity = [0.0,p.gravity_2648,0.0,0.0]; + let reference = std::array::from_fn(|i| gravity[i].mul_add(p.timestep_2604,state.vector_2688[i])); + let velocity = clamp_jump_velocity(reference,p.vectors_400_416[0].map(f32::from_bits)); + skater.ground_runtime.set_animated_velocity(&mut physics.board,velocity); + } +} diff --git a/crates/skate-game/src/physics/ground_phase.rs b/crates/skate-game/src/physics/ground_phase.rs new file mode 100644 index 00000000..d7535a91 --- /dev/null +++ b/crates/skate-game/src/physics/ground_phase.rs @@ -0,0 +1,289 @@ +//! Production Ground entry82D37538 and update82D37C88/82D38800. +//! State selection belongs to the player coordinator. This phase consumes its +//! current processed packet and changes the same board that the solver advances. +use super::{ + GamePhysics, + ground_runtime::{ + GroundEntryTargets, GroundInputObservations, GroundLaunchInfo, GroundLaunchPhysical, + GroundPhysicalFrame, GroundTrajectoryState, GroundUpdateFrame, GroundUpdateTargets, + }, + skater::SkaterRuntime, + skeleton_controller::SkeletonControllerState, +}; +use skate_core::{ + math::Vector3, + physics::{ground_hang_geometry::HangGeometryInput, skeleton_body::SkeletonCollisionMode}, + riding::{ + collision_response::CollisionResponsePhysical, ground_contact_response::WallRidePhysical, + grounded::state::board::GroundBoardOutcome, + }, +}; + +/// Edge observations are owned geometry results, not inferred from the deck. +/// The current authored world explicitly has no grind edges. +#[derive(Clone, Copy)] +pub(crate) struct GroundEdge { + pub flags: u32, + pub point: [f32; 4], + pub start: Vector3, + pub end: Vector3, +} + +pub(crate) struct GroundLifecycle { + pub skeleton_controller: SkeletonControllerState, + /// These are the actual retained Skeleton lifecycle flags, also shared + /// with teleport and GeneralUpdate by the coordinator. + pub skeleton_elapsed_16505: bool, + pub skeleton_ground_16388: bool, + pub board_animated_290: u8, + /// Processed2724: Reset82BFA35C clears this. The original image contains + /// no other direct scalar writer at that offset. + pub manual_drag_2724: f32, + pub edge: Option, + pub trajectory: GroundTrajectoryState, + /// A selected wall jump is retained for its actual selector continuation. + /// It cannot be discarded as if Ground's ordinary tail had completed. + pub pending_wall_jump: Option, +} +impl GroundLifecycle { + pub fn new() -> Self { + Self { + skeleton_controller: SkeletonControllerState::new(), + skeleton_elapsed_16505: false, + skeleton_ground_16388: false, + board_animated_290: 0, + manual_drag_2724: 0.0, + edge: None, + trajectory: GroundTrajectoryState { + pending_request: None, + primary_valid_288: false, + secondary_valid_592: false, + result_valid_9840: false, + flags_12836: 0, + }, + pending_wall_jump: None, + } + } +} + +/// Typed portion of Skateboard Reset82C05F50, alongside the coordinator's +/// actual body/pose/force reset. Native timer fields8364/8368 are not written +/// by either this reset or ResetBoardBody82C0D680, so preserve activation_time. +pub(crate) fn reset_board_state( + ground: &mut super::ground_runtime::GroundState, + runtime: &mut super::ground_runtime::GroundRuntime, + life: &mut GroundLifecycle, + board_wiping_out: &mut bool, +) { + ground.steering.deck_tilt = 0.0; //82C06048, wrapper256. + ground.steering.targets = [0.0; 2]; //82C06060/64, body7680/7684. + runtime.reset_board_toolkit(); + life.board_animated_290 = 0; //82C06070. + *board_wiping_out = false; //82C0D79C..7A0 clears body8384 high2bits. +} + +/// Called only on the coordinator's real transition into Ground. Entry needs +/// the toolkit already produced by this tick's PlayerInput phase. +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Ground entry requires PlayerInput's current board toolkit")?; + enter_components( + &mut physics.board, + &skater.player_input.processed, + &skater.animation_input, + toolkit, + &mut skater.ground, + &mut skater.ground_lifecycle, + &mut skater.air_reckoning.state, + &mut skater.wipeout.state, + &mut skater.skeleton_collision, + &mut skater.foot_ik, + &mut skater.skeleton_output, + &mut physics.board_wiping_out, + ) +} + +/// The same entry for the reset callback while PlayerInput owns its mutable +/// processed packet. Splitting host borrows does not alter the source order. +#[allow(clippy::too_many_arguments)] +pub(crate) fn enter_components( + board: &mut skate_core::physics::board_runtime::BoardRuntime, + processed: &skate_core::player::input_phase::ProcessedPhysicsInput, + animation: &super::animation_input::AnimationInput, + toolkit: &skate_core::physics::board_toolkit::BoardToolkit, + ground: &mut super::ground_runtime::GroundState, + life: &mut GroundLifecycle, + air_reckoning: &mut skate_core::air::reckoning::AirState, + wipeout: &mut skate_core::player::wipeout::Requests, + collision_mode: &mut SkeletonCollisionMode, + ik: &mut super::foot_ik::FootIk, + output: &mut super::skeleton_output::SkeletonOutput, + board_wiping_out: &mut bool, +) -> Result<(), String> { + // GamePhysics owns the meaningful high bit of Skateboard8384. + let mut board_flags = u8::from(*board_wiping_out) << 7; + let mut collision = |state| { + if state != 6 { + return Err("Ground requested a non-ground skeleton collision state".into()); + } + life.skeleton_controller.request_ground(collision_mode) + }; + let mut landing = |reverse| { + output.trigger_wobble(true, reverse); + Ok(()) + }; + let result = ground.enter( + board, + processed, + animation, + toolkit, + GroundEntryTargets { + foot_ik: ik, + skeleton_elapsed_16505: &mut life.skeleton_elapsed_16505, + reckoning_spin_angle: &mut air_reckoning.spin_angle, + reckoning_spin_speed: &mut air_reckoning.spin_speed, + board_flags_8384: &mut board_flags, + board_animated_290: &mut life.board_animated_290, + wipeout_mode: &mut wipeout.mode, + wipeout_timer: &mut wipeout.balance, + set_skeleton_collision_state: &mut collision, + enter_air_landing_modifier: &mut landing, + }, + ); + *board_wiping_out = board_flags & 0x80 != 0; + result +} + +/// Run after the Ground Reckoning preupdate and before the shared body solve. +/// The caller passes ground.steering.targets to that solve and must not clear +/// the board's force queue between this call and the solve. +pub(crate) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result { + let p = &skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Ground update requires PlayerInput's current board toolkit")?; + let life = &mut skater.ground_lifecycle; + if life.pending_wall_jump.is_some() { + return Err("Ground wall jump is pending its trajectory selector continuation".into()); + } + // Native grind reset clears the complete observation block. An authored + // world with no edges therefore has no active edge data to publish. + let edge = life.edge.unwrap_or(GroundEdge { + flags: 0, + point: [0.0; 4], + start: Vector3::ZERO, + end: Vector3::ZERO, + }); + let normal = raw(p.vectors_464_480_496_512_528[0]); + let up = raw(p.vectors_544_560_592_608[0]); + let initial_velocity = raw(p.vectors_400_416[0]); + let mut velocity = initial_velocity; + let mut predicted = skater.animated_skeleton.roots.predicted_board_position; + let targets = &mut skater.skeleton_drives.targets; + let mut move_future = |delta: Vector3| { + targets.apply_future_deck_displacement(delta); + predicted[0] += delta.x; + predicted[1] += delta.y; + predicted[2] += delta.z; + 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 physical = GroundPhysicalFrame { + skeleton_record: &skater.animated_skeleton.record, + wall_ride: WallRidePhysical { + board_normal: normal, + up, + velocity, + board_mass: toolkit.total_mass, + gravity: p.gravity_2648, + speed: p.scalar_2652, + contact_count: p.wheel_count_2556 as i32, + }, + collision: CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: raw(p.collision_pose_error_736), + velocity: raw(p.vectors_400_416[1]), + forward: toolkit.travel_direction, + up, + ground_normal: lanes(physics.riding.reckoning.ground_normal), + time_step: p.timestep_2604, + mass: toolkit.total_mass, + }, + hang_geometry: HangGeometryInput { + edge_start: edge.start, + edge_end: edge.end, + reference_point: xyz(edge.point), + }, + processed_velocity: &mut velocity, + wheel_material: &mut physics.settings.wheel_material, + wipeout: &mut skater.wipeout.state, + time_step: p.timestep_2604, + launch_physical: Some(GroundLaunchPhysical { + reckoning: &physics.riding.reckoning_frames, + skeleton_vector_16208: skater.animated_skeleton.board_frames.local_centre_of_mass, + skeleton_vector_16240: skater.animated_skeleton.board_frames.local_board_position, + board_position: toolkit.deck[3], + physical_center_of_mass: raw(p.vectors_544_560_592_608[2]), + velocity: initial_velocity, + angular_velocity: raw(p.prepared_jump_704), + flags_2468: p.flags_2468, + time_step: p.timestep_2604, + }), + launch_and_update: &mut launch, + }; + let result = skater.ground.update( + &mut skater.ground_runtime, + &mut physics.board, + &physics.world, + &skater.ground_settings, + GroundUpdateFrame { + processed: p, + animation: &skater.animation_input, + riding: &physics.riding, + skeleton: &skater.animated_skeleton, + toolkit, + base_trucks: physics.settings.step.base_truck_transforms, + extra: GroundInputObservations { + manual_drag_2724: life.manual_drag_2724, + trajectory_state_bits: p.external_physics_1616.flags, + edge_flags: edge.flags, + edge_point: edge.point, + }, + }, + physical, + GroundUpdateTargets { + foot_ik: &mut skater.foot_ik, + 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, + }, + ); + // These source writes occur during update. Publish even on a later branch + // error so retrying cannot silently erase a completed physical side effect. + skater.player_input.processed.vectors_400_416[0] = velocity.map(f32::to_bits); + skater.animated_skeleton.roots.predicted_board_position = predicted; + result +} +fn raw(v: [u32; 4]) -> [f32; 4] { + v.map(f32::from_bits) +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_runtime/corrections.rs b/crates/skate-game/src/physics/ground_runtime/corrections.rs new file mode 100644 index 00000000..eaf46ed8 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/corrections.rs @@ -0,0 +1,109 @@ +//! Native numerical callbacks for the ordinary ground-state branch. +use super::GroundRuntime; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, board_runtime::BoardRuntime, manual::controller::ManualAngleMeasurement, + }, + riding::{ + collision_response::signed_angle, ground_correction_math, + grounded::state::corrections::AntiFlipNudgeMath, + }, +}; +use std::convert::Infallible; + +impl ManualAngleMeasurement for GroundRuntime { + type Error = Infallible; + fn angle_between( + &mut self, + deck_z: [f32; 4], + reference_z: [f32; 4], + reference_x: [f32; 4], + ) -> Result { + Ok(signed_angle( + xyz(deck_z), + xyz(reference_z), + xyz(reference_x), + )) + } +} +impl AntiFlipNudgeMath for GroundRuntime { + type Error = Infallible; + fn dot3(&mut self, a: [f32; 4], b: [f32; 4]) -> Result { + Ok(ground_correction_math::dot_product(a, b)) + } + fn scale_to_magnitude( + &mut self, + vector: [f32; 4], + squared_length: f32, + magnitude: f32, + ) -> Result<[f32; 4], Self::Error> { + Ok(ground_correction_math::scale_to_magnitude( + vector, + squared_length, + magnitude, + )) + } +} +impl GroundRuntime { + pub fn detect_hung_up_geometry( + &self, + world: &skate_core::physics::board_world::BoardWorld, + input: skate_core::physics::ground_hang_geometry::HangGeometryInput, + ) -> Result { + skate_core::physics::ground_hang_geometry::detect_hung_geometry( + world, + input, + self.deck_center_to_truck, + ) + .map_err(str::to_owned) + } + pub fn collision_force_projection(&self, force: [f32; 4], velocity: [f32; 4]) -> f32 { + ground_correction_math::collision_force_projection(force, velocity) + } + pub fn build_hang_force( + &self, + board: &BoardRuntime, + edge_start: [f32; 4], + edge_end: [f32; 4], + ) -> [f32; 4] { + let p = board.part_transforms()[BodyId::Deck.index()].translation; + ground_correction_math::hang_force(edge_start, edge_end, [p.x, p.y, p.z, 0.]) + } + pub fn apply_hang_force(&mut self, board: &mut BoardRuntime, force: [f32; 4]) { + let position = board.part_transforms()[BodyId::Deck.index()].translation; + ground_correction_math::apply_world_force( + &mut board.bodies_mut()[BodyId::Deck.index()], + position, + xyz(force), + position, + ); + } + pub fn apply_wheel_catch(&mut self, board: &mut BoardRuntime) { + let frame = board.part_transforms()[BodyId::Deck.index()].basis.columns; + let value = ground_correction_math::wheel_catch_displacement( + [frame[1][0], frame[1][1], frame[1][2], 0.], + [frame[2][0], frame[2][1], frame[2][2], 0.], + ); + self.apply_angular_displacement(board, value); + } + pub fn pin_to_position( + &mut self, + board: &mut BoardRuntime, + captured_x: f32, + captured_z: f32, + dt: f32, + ) { + let p = board.part_transforms()[BodyId::Deck.index()].translation; + let velocity = ground_correction_math::pinning_velocity( + [p.x, p.y, p.z, 0.], + captured_x, + captured_z, + dt, + ); + self.set_animated_velocity(board, velocity); + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_runtime/entry.rs b/crates/skate-game/src/physics/ground_runtime/entry.rs new file mode 100644 index 00000000..f8ae3375 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/entry.rs @@ -0,0 +1,153 @@ +//! Actual Ground entry82D37538. Numerical operations touch live board bodies; +//! the physical skeleton/air modifier remain calls to their separate owners. +use super::{ + super::{animation_input::AnimationInput, foot_ik::FootIk}, + GroundState, +}; +use skate_core::{ + math::Vector3, + physics::{board_runtime::BoardRuntime, board_toolkit::BoardToolkit}, + player::input_phase::ProcessedPhysicsInput, + riding::{ + ground_correction_math::dot_product, + grounded::{ + manual_entry::{self, ManualGroundBodies, ManualGroundInput, ManualGroundProjection}, + state::motion, + }, + }, +}; +use skate_data::collections::Collections; +use std::convert::Infallible; + +pub(super) struct EntrySettings { + deck_angular_drag: f32, + powerslide_exit: f32, + landing_strength: f32, + landing_offset: f32, +} +impl EntrySettings { + pub fn load(data: &Collections) -> Result { + Ok(Self { + //82C091F8 restores the standard angular drag with literal822F860C. + deck_angular_drag: data.float("physicsdeck", "default", "DeckAngularDrag")? + * f32::from_bits(0x426f_ffff), + powerslide_exit: data.float("physics_manual", "default", "PowerslideExitScalar")?, + landing_strength: data.float("physics_feet", "default", "LandingOnDeckEffectScalar")?, + landing_offset: data.float("physics_feet", "default", "LandingOnDeckOffset")?, + }) + } +} +///References to the actual lifecycle owners. These fields are not derived from +///controller input or cached separately in Ground. Collision and air-landing +///calls must apply the recovered physical owner behavior before returning. +pub(crate) struct GroundEntryTargets<'a> { + pub foot_ik: &'a mut FootIk, + pub skeleton_elapsed_16505: &'a mut bool, + pub reckoning_spin_angle: &'a mut f32, + pub reckoning_spin_speed: &'a mut f32, + pub board_flags_8384: &'a mut u8, + pub board_animated_290: &'a mut u8, + pub wipeout_mode: &'a mut u32, + pub wipeout_timer: &'a mut f32, + pub set_skeleton_collision_state: &'a mut dyn FnMut(u32) -> Result<(), String>, + pub enter_air_landing_modifier: &'a mut dyn FnMut(bool) -> Result<(), String>, +} +impl GroundState { + pub fn enter( + &mut self, + board: &mut BoardRuntime, + processed: &ProcessedPhysicsInput, + animation: &AnimationInput, + toolkit: &BoardToolkit, + targets: GroundEntryTargets<'_>, + ) -> Result<(), String> { + //SetStandard82C03AF8: standard dynamic part mode, deck damping and + //source-owned wrapper flag. Authored board volumes stay enabled. + *targets.board_flags_8384 &= 0x7f; + board.bodies_mut()[6].inertia.angular_drag = self.entry_settings.deck_angular_drag; + for body in board.bodies_mut() { + body.state_flags = 4; + } + targets.foot_ik.enable_feet(true); + *targets.skeleton_elapsed_16505 = false; + (targets.set_skeleton_collision_state)(6)?; + self.state.begin_entry(); + *targets.reckoning_spin_angle = 0.0; + *targets.reckoning_spin_speed = 0.0; + board + .hook_mut() + .drive + .disable_animation(targets.board_animated_290); + let normal = processed.vectors_464_480_496_512_528[0].map(f32::from_bits); + let angular = processed.vectors_720_784_800_816_832_864[0].map(f32::from_bits); + board.bodies_mut()[6].rates.angular_velocity = + motion::entry_angular_velocity(normal, angular); + self.speed.target_speed = motion::entry_target_speed( + board.bodies()[6].rates.linear_velocity, + normal, + toolkit.forward, + ); + self.wobble.reset(); + manual_entry::enter_ground( + &mut self.manual, + processed.category_2516, + self.entry_settings.powerslide_exit, + ManualGroundInput { + balance: animation.fields.balance, + ground_normal: normal, + flags_2468: processed.flags_2468, + flags_2472: processed.flags_2472, + }, + &mut ManualBodies(board), + &mut Projection, + ) + .unwrap_or_else(|never| match never {}); + if processed.category_2516 == 200 { + let stance = ((processed.flags_2468 >> 20) ^ (processed.flags_2476 >> 2)) & 1 != 0; + (targets.enter_air_landing_modifier)(stance)?; + } + if processed.state_2504 == 503 { + let force = motion::landing_on_deck_force( + processed.vectors_400_416[0].map(f32::from_bits), + processed.vectors_544_560_592_608[3].map(f32::from_bits), + processed.vectors_544_560_592_608[0].map(f32::from_bits), + toolkit.total_mass, + self.entry_settings.landing_strength, + self.entry_settings.landing_offset, + ); + board.forces_mut().append(force); + } + //The scalar assignment precedes pumping reset in source; no called + //operation between those writes reads it. + self.state.straighten_scale_2672 = if processed.state_2504 == 101 { + f32::from_bits(0x3e23_d70a) + } else { + 1.0 + }; + self.pumping.reset(); + if *targets.wipeout_mode != 1 { + *targets.wipeout_timer = 0.0; + *targets.wipeout_mode = 1; + } + self.state.finish_entry(processed.state_2504); + self.entered = true; + Ok(()) + } +} +struct ManualBodies<'a>(&'a mut BoardRuntime); +impl ManualGroundBodies for ManualBodies<'_> { + fn linear_velocity(&mut self, part: usize) -> [f32; 4] { + let v = self.0.bodies()[part].rates.linear_velocity; + [v.x, v.y, v.z, 0.0] + } + fn set_linear_velocity(&mut self, part: usize, v: [f32; 4]) { + self.0.bodies_mut()[part].rates.linear_velocity = Vector3::new(v[0], v[1], v[2]); + } +} +struct Projection; +impl ManualGroundProjection for Projection { + type Error = Infallible; + fn normal_speed(&mut self, n: [f32; 4], v: [f32; 4]) -> Result { + Ok(dot_product(n, v)) + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/input.rs b/crates/skate-game/src/physics/ground_runtime/input.rs new file mode 100644 index 00000000..4cdff0c9 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/input.rs @@ -0,0 +1,274 @@ +//! UpdateSkateboard82D38800 inputs from the actual processed packet, animation +//! attributes, physical pose and source board toolkit. No raw button-to-force +//! or guessed contact flags are introduced at this boundary. +use super::super::{ + animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, + riding_outputs::RidingOutputs, +}; +use super::{GroundRuntime, GroundSettings}; +use skate_core::{ + math::Vector3, + physics::{ + board_runtime::BoardRuntime, board_toolkit::BoardToolkit, + drive_frames::RetailAffineTransform, manual::controller::ManualInput, + }, + player::input_phase::ProcessedPhysicsInput, + riding::{ + anti_flip::AntiFlipInput, + ground_correction_math::dot_product as dot3, + grounded::{ + drag::GroundDragInput, + propulsion::GroundPropulsionInput, + state::{ + board_types::{GroundBoardInput, GroundForceFrame}, + corrections::{ + AntiFlipNudgeInput, HalfpipeWheelCatchInput, HangUpInput, PinningInput, + }, + }, + }, + heading::HeadingInput, + pumping::{PumpForceInput, state::PumpingState}, + slide_friction::SlideFrictionInput, + speed_model::SpeedModelInput, + speed_wobble::SpeedWobbleInput, + steering::SteeringInput, + straighten::StraightenInput, + }, +}; + +/// Only values owned by separate state producers, not derivable from a rigid +/// body. The coordinator supplies the actual output of those producers. +pub(crate) struct GroundInputObservations { + /// Reset82BF9EF0 writes0; preserve subsequent producer updates. + pub manual_drag_2724: f32, + /// Actual trajectory output, Processed1776. + pub trajectory_state_bits: u32, + /// Processed1516, copied from the grind/edge observation producer. + pub edge_flags: u32, + /// Processed1232, the native detected edge point. + pub edge_point: [f32; 4], +} +impl GroundRuntime { + /// Input preparation before Ground. Retained normal has its own native + /// lifetime and is updated here, not in post-solver contact publication. + pub fn prepare_toolkit( + &mut self, + board: &BoardRuntime, + p: &ProcessedPhysicsInput, + ) -> BoardToolkit { + let result = BoardToolkit::from_board( + board, + p.flags_2468, + p.scalar_2612, + raw(p.vectors_464_480_496_512_528[0]), + self.retained_board_normal, + ); + self.retained_board_normal = result.filtered_normal; + result + } +} +impl GroundSettings { + pub fn input( + &self, + t: &BoardToolkit, + p: &ProcessedPhysicsInput, + a: &AnimationInput, + pumping: &PumpingState, + unintentional_pump_scalar: f32, + riding: &RidingOutputs, + skeleton: &AnimatedSkeleton, + base_trucks: [RetailAffineTransform; 2], + extra: GroundInputObservations, + ) -> GroundBoardInput { + let f = &a.fields; + let normal = raw(p.vectors_464_480_496_512_528[0]); + let velocity = raw(p.vectors_400_416[0]); + let angular = raw(p.vectors_400_416[1]); + let axis544 = raw(p.vectors_544_560_592_608[0]); + let ground_normal = v(riding.reckoning.ground_normal); + let ground = riding.reckoning_frames.ground; + let edge_delta = std::array::from_fn(|i| t.deck[3][i] - extra.edge_point[i]); + let hang_axis = raw(p.vectors_720_784_800_816_832_864[4]); + GroundBoardInput { + steering: SteeringInput { + turn: f.turn, + hard_turn: f.hard_turn, + absolute_body_speed: t.absolute_speed, + flipped_controls_scalar: t.control_sign, + balance: f.balance, + truck_tightness: p.truck_tightness_2760, + pushing: p.flags_2468 & 0x0800_0000 != 0, + }, + //Ground's core controller replaces tilt and height before consuming + //them; these are deliberately not used as substitute observations. + speed_wobble: SpeedWobbleInput { + tilt: 0.0, + speed: p.scalar_2612, + center_of_mass_height: 0.0, + truck_tightness: p.truck_tightness_2760, + activation_threshold: self.wobble_activation, + amplitude_multiplier: self.wobble_amplitude, + }, + truck_flags_2468: p.flags_2468, + truck_flags_2472: p.flags_2472, + contact: riding + .probes + .wall_contact_frame(p.time_on_ground_2752, p.signed_ground_time_2756), + ground_vector_1216: ground_normal, + propulsion: GroundPropulsionInput { + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + target_speed: a.contacts.push_speed, + signed_speed: p.scalar_2612, + absolute_body_speed: t.absolute_speed, + scalar_2660: t.total_mass, + timestep: p.timestep_2604, + brake_input: f.brake, + push_direction: xyz(t.forward), + brake_direction: xyz(t.horizontal_forward), + surface_braking_factor: self.surface_braking_factor, + }, + ground_force: GroundForceFrame { + argument_1_2752: p.time_on_ground_2752, + processed_2776: p.crouch_2776, + processed_2780: p.crouch_delta_2780, + previous_state_scalar_56: pumping.absorption, + ground_scalar_1216: self.foot_force_offset, + ground_scalar_1232: self.absorption_front, + ground_scalar_1236: self.absorption_rear, + ground_scalar_1240: self.foot_force_offset, + balance_2720: f.balance, + surface_speed_2656: p.scalar_2656, + axis_384: t.transverse_up, + velocity_400: velocity, + axis_544: axis544, + }, + speed_model: SpeedModelInput { + flags_2468: p.flags_2468, + flags_2476: p.flags_2476, + wheel_contact_count: p.wheel_count_2556 as i32, + frames_2580: p.spin_same_direction_frames_2580 as i32, + timestep: p.timestep_2604, + signed_speed: p.scalar_2612, + angle_2652: p.scalar_2652, + surface_speed: p.scalar_2656, + turn_2672: p.spin_input_2672, + balance: f.balance, + elapsed_without_input: p.time_since_last_input_2748, + manual_state_276: 0.0, + vector_160: t.effective[2], + vector_352: t.forward, + velocity_416: angular, + normal_464: normal, + effective_forward: t.effective[2], + mass: t.total_mass, + }, + slide_friction: SlideFrictionInput { + heading_time: 0.0, + normal, + velocity, + side_axis: t.deck[0], + surface_speed: p.scalar_2656, + scalar_2764: p.scalar_2764, + }, + pump_force: PumpForceInput { + flags_2476: p.flags_2476, + mode_multiplier: unintentional_pump_scalar, + pumping_scalar: pumping.pump_acceleration, + input_scalar_2660: t.total_mass, + timestep: p.timestep_2604, + direction_432: t.forward_velocity, + normal_threshold: [f32::from_bits(0x3586_37bd); 4], + }, + straighten: StraightenInput { + heading_time: 0.0, + scalar_2764: p.scalar_2764, + turn: f.turn, + forward: t.forward, + velocity, + normal, + }, + heading: HeadingInput { + balance: f.balance, + manual_turn: p.spin_input_2672, + flags_2472: p.flags_2472, + timestep: p.timestep_2604, + signed_speed: p.scalar_2612, + manual_curve_input: p.scalar_2652, + turn_2712: f.raw_turn, + scalar_2740: riding.heading_adjust_factor(), + velocity: t.forward_velocity, + normal, + angular_velocity: raw(p.vectors_720_784_800_816_832_864[0]), + transform: ground, + }, + anti_flip: AntiFlipInput { + flags_2468: p.flags_2468, + balance: f.balance, + axis_96: t.deck[2], + axis_64: t.deck[0], + projection_axis_544: axis544, + }, + manual: ManualInput { + balance: f.balance, + flipped_controls: t.control_sign, + procedural_noise_time: p.state_timer_2664, + absolute_speed: t.absolute_speed, + animation_noise: skeleton.board_at_y_delta, + timestep: p.timestep_2604, + powersliding: false, + braking: p.flags_2468 & 0x2000_0000 != 0, + positive_balance_contact: p.flags_2472 & 0x0800_0000 != 0, + negative_balance_contact: p.flags_2472 & 0x0400_0000 != 0, + reversed_point_selection: p.flags_2468 & 0x0010_0000 != 0, + reference_x: ground[0], + reference_z: ground[2], + deck_z: t.deck[2], + velocity_frame_z: raw(p.effective_anim_transform_192[2]), + angular_velocity_world: angular, + correction_point_7888: v(base_trucks[0].translation), + correction_point_7952: v(base_trucks[1].translation), + }, + ground_drag: GroundDragInput { + flags_2468: p.flags_2468, + absolute_body_speed_2616: t.absolute_speed, + balance_2720: f.balance, + scalar_2724: extra.manual_drag_2724, + ground_normal_y: ground_normal[1], + }, + contact_time_2756: p.signed_ground_time_2756, + trajectory_state_1776_bits: extra.trajectory_state_bits, + anti_flip_nudge: AntiFlipNudgeInput { + deck_speed_2652: p.scalar_2652, + deck_axis_96: t.deck[2], + }, + hang_up: HangUpInput { + flags_1516: extra.edge_flags, + deck_speed_2652: p.scalar_2652, + scalar_84: t.deck[1][1], + geometry_axis_dot_positive: dot3(hang_axis, hang_axis) > 0.0, + }, + halfpipe_wheel_catch: HalfpipeWheelCatchInput { + flags_1516: extra.edge_flags, + deck_speed_2652: p.scalar_2652, + deck_x_axis_y: t.deck[0][1], + deck_y_axis_y: t.deck[1][1], + signed_deck_distance: dot3(t.deck[1], edge_delta), + }, + pinning: PinningInput { + flags_2488: p.flags_2488, + frames_since_teleport_2584: p.frames_since_teleport_2584 as i32, + flags_2472: p.flags_2472, + }, + } + } +} +fn raw(v: [u32; 4]) -> [f32; 4] { + v.map(f32::from_bits) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn v(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/ground_runtime/launch.rs b/crates/skate-game/src/physics/ground_runtime/launch.rs new file mode 100644 index 00000000..5a620f60 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/launch.rs @@ -0,0 +1,83 @@ +//! Wall-jump launch packet;82D33448 constructs it,82BE33D0 fills it. +//! The native selector consumes this packet after body velocity publication. +use skate_core::{ + physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + riding::reckoning_frames::ReckoningFrames, +}; + +#[derive(Clone, Debug)] +pub(crate) struct GroundLaunchInfo { + pub system: AnimationPartTransform, + pub inverse_system: AnimationPartTransform, + pub velocity: [f32; 4], + pub angular_velocity: [f32; 4], + pub skeleton_vector_16208: [f32; 4], + pub skeleton_vector_16240: [f32; 4], + pub board_position: [f32; 4], + pub physical_center_of_mass: [f32; 4], + pub vector_224: [f32; 4], + pub vector_240: [f32; 4], + pub cone_angle_x: f32, + pub cone_angle_z: f32, + pub time_step: f32, + pub flag_269: bool, + pub wall_jump: bool, + pub flags_270: u16, +} +impl Default for GroundLaunchInfo { + fn default() -> Self { + Self { + system: IDENTITY, + inverse_system: IDENTITY, + velocity: [0.; 4], + angular_velocity: [0.; 4], + skeleton_vector_16208: [0.; 4], + skeleton_vector_16240: [0.; 4], + board_position: [0.; 4], + physical_center_of_mass: [0.; 4], + vector_224: [0.; 4], + vector_240: [0.; 4], + cone_angle_x: 0., + cone_angle_z: 0., + time_step: 0., + flag_269: false, + wall_jump: false, + flags_270: 0, + } + } +} +pub(crate) struct GroundLaunchPhysical<'a> { + pub reckoning: &'a ReckoningFrames, + ///Actual Skeleton16208/16240; do not substitute animation COM differences. + pub skeleton_vector_16208: [f32; 4], + pub skeleton_vector_16240: [f32; 4], + ///Processed112,592,400,704,2468 and2604. + pub board_position: [f32; 4], + pub physical_center_of_mass: [f32; 4], + pub velocity: [f32; 4], + pub angular_velocity: [f32; 4], + pub flags_2468: u32, + pub time_step: f32, +} +impl GroundLaunchInfo { + 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; + self.skeleton_vector_16208 = p.skeleton_vector_16208; + self.skeleton_vector_16240 = p.skeleton_vector_16240; + self.system = p.reckoning.system; + self.inverse_system = p.reckoning.inverse_system; + self.velocity = p.velocity; + self.angular_velocity = p.angular_velocity; + self.time_step = p.time_step; + self.flags_270 = if p.flags_2468 & 0x2000 != 0 { 7 } else { 1 }; + self.cone_angle_x = cone_angle_x; + self.cone_angle_z = cone_angle_z; + } + ///Ground82D38CF4..D10 overrides both velocity records and byte268. + pub fn wall_jump(&mut self, velocity: [f32; 4]) { + self.velocity = velocity; + self.angular_velocity = velocity; + self.wall_jump = true; + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/mod.rs b/crates/skate-game/src/physics/ground_runtime/mod.rs new file mode 100644 index 00000000..1834c69e --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/mod.rs @@ -0,0 +1,170 @@ +//! Owned native ground contact/force services for the live BoardRuntime. +//! The coordinator supplies the processed physical packet in native phase order. +mod corrections; +mod entry; +mod state; +mod update; +mod world_grab; +pub(crate) use update::{GroundUpdateFrame, GroundUpdateTargets}; +mod trajectory; +pub(crate) use entry::GroundEntryTargets; +pub(crate) use trajectory::GroundTrajectoryState; +mod surface; +pub(crate) use state::GroundState; +pub(crate) use surface::{active_surface, surface_key}; +mod input; +mod pumping; +pub(crate) use input::GroundInputObservations; +pub(crate) use pumping::GroundPumping; +mod launch; +mod services; +mod settings; +pub(crate) use launch::{GroundLaunchInfo, GroundLaunchPhysical}; +pub(crate) use services::{GroundControllers, GroundPhysicalFrame}; +pub(crate) use settings::GroundSettings; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, board_runtime::BoardRuntime, deck_angular_correction, + force_queue::QueuedPointForce, + }, + point_graph::PointGraph, + riding::{ + collision_response::{ + CollisionResponsePhysical, CollisionResponseSettings, collision_response, + }, + ground_contact_response::{WallRidePhysical, WallRideSettings, wall_ride_response}, + grounded::state::board_types::{ + CollisionForceResponse, GroundContactFrame, GroundContactResponse, + }, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct GroundRuntime { + retained_board_normal: [f32; 4], + wall_ride: WallRideSettings, + collision: CollisionResponseSettings, + deck_center_to_truck: f32, + launch_cone_x: f32, + launch_cone_z: f32, + pub contact: GroundContactResponse, + pub collision_force: Option, +} +impl GroundRuntime { + /// Skateboard Reset82C060B0 publishes [0,1,0,0] to the retained + /// toolkit normal192. Do not reset Ground contact-response state here. + pub fn reset_board_toolkit(&mut self) { + self.retained_board_normal = [0.0, 1.0, 0.0, 0.0]; + } + pub fn load(data: &Collections) -> Result { + let feet = |field| data.float("physics_feet", "default", field); + let collision = |field| data.float("physics_collision", "default", field); + Ok(Self { + //SkateboardReset82C05F50 explicitly clears192..208, then sets196=1. + retained_board_normal: [0.0, 1.0, 0.0, 0.0], + launch_cone_x: data.float("physics_trajectory", "default", "ConeAngleX")?, + launch_cone_z: data.float("physics_trajectory", "default", "ConeAngleZ")?, + deck_center_to_truck: data.float("physics_grinds", "default", "DeckCenterToTruck")?, + wall_ride: WallRideSettings { + anti_gravity_vs_time: curve(data, "physics_feet", "WallRideAntiGravityVsTime")?, + max_dot_floor_wall: feet("WallRideMaxDotFloorWall")?, + foot_force_time: feet("WallRideFootForceTime")?, + auto_jump_height: feet("WallRideAutoJumpHeight")?, + max_time: feet("WallRideMaxTime")?, + velocity_time_to_consider: feet("WallRideVelTimeToConsider")?, + auto_jump_y_down_scalar: feet("WallRideAutoJumpYDownScalar")?, + auto_jump_force: feet("WallRideAutoJumpForce")?, + }, + collision: CollisionResponseSettings { + maximum_velocity_delta: collision("MaxVelDelta")?, + force_y_offset: collision("ForceYOffset")?, + force_scalar: collision("CollisionForceScalar")?, + target_displacement_velocity: collision("TargetDisplacementVel")?, + torque_vs_angle: curve(data, "physics_collision", "CollisionTorqueVsAngle")?, + }, + //PhysicsGround ctor/reset initializes this output vector tozero. + contact: GroundContactResponse { + active_2731: false, + tag_16_force: QueuedPointForce { + tag: 16, + ..Default::default() + }, + vector_2688: [0.; 4], + scalar_2704: 0., + animated_board_2708: false, + }, + collision_force: None, + }) + } + pub fn contact_response( + &mut self, + frame: GroundContactFrame, + physical: WallRidePhysical, + ) -> GroundContactResponse { + self.contact = + wall_ride_response(&self.wall_ride, physical, frame, self.contact.vector_2688); + self.contact + } + /// Slide82D3AA74..AA9C passes a fresh zero previous vector each update. + /// This call does not overwrite Ground's separately retained contact state. + pub fn contact_response_with_previous( + &self, frame: GroundContactFrame, physical: WallRidePhysical, previous: [f32;4], + ) -> GroundContactResponse { + wall_ride_response(&self.wall_ride, physical, frame, previous) + } + /// Called immediately after contact response and before animated/physical + /// branch selection at82D389DC. This changes the solver's actual deck. + pub fn update_body_accumulator(&mut self, board: &mut BoardRuntime) { + deck_angular_correction::apply_ground_body_torque( + &mut board.bodies_mut()[BodyId::Deck.index()].rates, + ); + } + /// Native82D944E8 output publication. Do not discard an existing ground + /// collision vector on a false return; caller's collision-fade state owns it. + pub fn calculate_collision_force( + &mut self, + physical: CollisionResponsePhysical, + ) -> Option { + let result = collision_response(&self.collision, physical)?; + if !result.applied { + return None; + } + let output = CollisionForceResponse { + force_2528: result.force, + point_2544: result.point, + vector_2592: result.angular_displacement, + }; + self.collision_force = Some(output); + Some(output) + } + pub fn apply_angular_target(&mut self, board: &mut BoardRuntime, displacement: [f32; 4]) { + deck_angular_correction::apply_limited_displacement( + &mut board.bodies_mut()[BodyId::Deck.index()].rates, + xyz(displacement), + ); + } + pub fn apply_angular_displacement(&mut self, board: &mut BoardRuntime, displacement: [f32; 4]) { + deck_angular_correction::apply_angular_displacement( + &mut board.bodies_mut()[BodyId::Deck.index()].rates, + xyz(displacement), + ); + } + ///82C04168 publishes the requested linear velocity to all seven real body + /// parts, preserving angular velocity and accumulated forces. + pub fn set_animated_velocity(&mut self, board: &mut BoardRuntime, velocity: [f32; 4]) { + for body in board.bodies_mut() { + body.rates.linear_velocity = xyz(velocity); + } + } +} +fn curve(data: &Collections, class: &str, field: &str) -> Result, String> { + let words = data.words::<20>(class, "default", field)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[12 + i])), + }) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_runtime/pumping.rs b/crates/skate-game/src/physics/ground_runtime/pumping.rs new file mode 100644 index 00000000..8e9b3993 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/pumping.rs @@ -0,0 +1,125 @@ +//! Actual Ground82D37D34..7EAC pumping preparation and stock bindings. +use super::super::{animated_skeleton::AnimatedSkeleton, riding_outputs::RidingOutputs}; +use skate_core::{ + math::Vector3, + physics::board_toolkit::BoardToolkit, + riding::{ + collision_response::signed_angle, + ground_correction_math::dot_product as dot3, + pumping::{ + controller::{self, PumpingSample}, + geometry::NativePumpingGeometry, + settings::{PumpingMode, PumpingSettings}, + state::PumpingState, + }, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct GroundPumping { + settings: PumpingSettings, + modes: [GroundPumpingMode; 5], +} +#[derive(Clone, Copy)] +pub(crate) struct GroundPumpingMode { + controller: PumpingMode, + /// Selected physics_mode layout+8, consumed by CalcPumpForce82D933F0. + pub unintentional_scalar: f32, +} +impl GroundPumping { + 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); + let mode = |key| -> Result { + let m = |field| data.float("physics_mode", key, field); + Ok(GroundPumpingMode { + controller: PumpingMode { + maximum_absorption_per_second: m("Hash_9D1AEE3D7D8A4A7")?, + maximum_acceleration_per_second: m("Hash_D77AFD320B6241C5")?, + absorption_factor: m("PumpEffectFactorAbsorption")?, + acceleration_factor: m("PumpEffectFactor")?, + }, + unintentional_scalar: m("UnintentionalPumpScalar")?, + }) + }; + Ok(Self { + settings: PumpingSettings { + pump_vs_speed: c("PumpVsVel")?, + pump_vs_time: c("PumpVsTime")?, + min_crouch_vs_ground_angle: c("MinCrouchVsGroundAngle")?, + compression_vs_ground_angle: c("CompressionVsGroundAngle")?, + compression_vs_deck_angle: c("CompressionVsDeckAngle")?, + height_change_damping: f("PumpEffectDamping")?, + minimum_height_change: f("MinChangeInCOMBeforePumping")?, + maximum_height_change: f("MaxDeltaHeightAllowedPerFrame")?, + ground_compression_scale: f("CompressionGroundScalar")?, + deck_compression_scale: f("CompressionDeckScalar")?, + angular_speed_damping: f("AngularSpeedDamping")?, + }, + modes: [ + mode("easy")?, + mode("normal")?, + mode("hardcore")?, + mode("motorized")?, + mode("test")?, + ], + }) + } + /// Ground82D37E74 reads the current Processed2548 mode, not the startup mode. + pub fn mode(&self, index: u32) -> Result { + self.modes + .get(index as usize) + .copied() + .ok_or_else(|| format!("Invalid pumping physics mode {index}")) + } + ///Runs after contact-state update and before Ground::UpdateSkateboard. + pub fn update( + &self, + state: &mut PumpingState, + toolkit: &BoardToolkit, + riding: &RidingOutputs, + skeleton: &AnimatedSkeleton, + flags_2476: u32, + mode: GroundPumpingMode, + ) { + let up = v(riding.reckoning.up); + let animated_at = skeleton.board_frames.animation_target[2]; + let deck_angle = if dot3(toolkit.deck[2], up).abs() > dot3(animated_at, up).abs() { + let axis = xyz(riding.reckoning_frames.ground[0]); + let deck = wrap(signed_angle(xyz(toolkit.deck[2]), xyz(up), axis)); + let animated = wrap(signed_angle(xyz(animated_at), xyz(up), axis)); + (deck - animated).abs() + } else { + 0.0 + }; + 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, + intentional_pumping: ((flags_2476 >> 1) & 1) as u8, + }; + match controller::update_ground( + state, + &self.settings, + mode.controller, + sample, + &mut NativePumpingGeometry, + ) { + Ok(()) => {} + Err(never) => match never {}, + } + } +} +fn wrap(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let signed = fraction - if fraction > 0.5 { 1.0 } else { 0.0 }; + signed * f32::from_bits(0x40c9_0fdb) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn v(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/ground_runtime/services.rs b/crates/skate-game/src/physics/ground_runtime/services.rs new file mode 100644 index 00000000..8d4c8b28 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/services.rs @@ -0,0 +1,293 @@ +//! Ground's physical operations execute against the live board, in core's +//! recovered order. Detached queue/inertia ownership is published before the +//! solver runs; no force, drag or steering output is left in a test-only object. +use super::{ + GroundRuntime, GroundSettings, + launch::{GroundLaunchInfo, GroundLaunchPhysical}, +}; +use skate_core::{ + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + board_world::BoardWorld, + contact::RetailContactMaterial, + ground_hang_geometry::HangGeometryInput, + manual::{controller::ManualAngleMeasurement, state::ManualState}, + skeleton_animation_record::SkeletonAnimationRecord, + }, + riding::{ + collision_response::{CollisionResponsePhysical, signed_angle}, + ground_contact_response::{WallRidePhysical, wall_ride_response}, + ground_correction_math, + grounded::{ + drag::BodyInertias, + state::{ + board::{self, GroundBoardOutcome}, + board_types::{ + CollisionForceResponse, GroundBoardComponents, GroundBoardInput, + GroundBoardServices, GroundContactFrame, GroundContactResponse, + }, + corrections::{ + AntiFlipNudgeMath, HalfpipeWheelCatchServices, HangUpServices, PinningServices, + }, + data::PhysicsGroundState, + }, + }, + speed_model::SpeedModelState, + speed_wobble::SpeedWobbleState, + steering::TruckSteeringState, + }, +}; + +pub(crate) struct GroundControllers<'a> { + pub speed_wobble: &'a mut SpeedWobbleState, + pub truck_steering: &'a mut TruckSteeringState, + pub speed_model: &'a mut SpeedModelState, + pub manual: &'a mut ManualState, + pub heading_previous: &'a mut f32, +} +pub(crate) struct GroundPhysicalFrame<'a> { + pub skeleton_record: &'a SkeletonAnimationRecord, + pub wall_ride: WallRidePhysical, + pub collision: CollisionResponsePhysical, + pub hang_geometry: HangGeometryInput, + pub processed_velocity: &'a mut [f32; 4], + ///The same material record used by colliders::world_volumes for all4wheels. + pub wheel_material: &'a mut RetailContactMaterial, + pub wipeout: &'a mut skate_core::player::wipeout::Requests, + pub time_step: f32, + ///Needed only when the native wall-jump branch is selected. Missing data + ///is an error on that branch, never an invented physical skeleton pose. + pub launch_physical: Option>, + ///Own host call boundary for the full native TrajectorySelector Launch + ///and Update. Callers must finish prediction before advancing this board. + pub launch_and_update: &'a mut dyn FnMut(&GroundLaunchInfo) -> Result<(), String>, +} +struct BoardServices<'a, 'p> { + runtime: &'a mut GroundRuntime, + board: &'a mut BoardRuntime, + world: &'a BoardWorld, + settings: &'a GroundSettings, + physical: GroundPhysicalFrame<'p>, + launch: Option, +} +impl GroundRuntime { + pub fn update_board( + &mut self, + board: &mut BoardRuntime, + world: &BoardWorld, + settings: &GroundSettings, + state: &mut PhysicsGroundState, + controllers: GroundControllers<'_>, + mut input: GroundBoardInput, + physical: GroundPhysicalFrame<'_>, + ) -> Result { + input.speed_wobble.activation_threshold = settings.wobble_activation; + input.speed_wobble.amplitude_multiplier = settings.wobble_amplitude; + let mut queue = std::mem::take(board.forces_mut()); + let mut inertias = std::array::from_fn::<_, 7, _>(|i| board.bodies()[i].inertia); + let mut bindings = BodyInertias { + part_inertia_indices: &[0, 1, 2, 3, 4, 5, 6], + inertias: &mut inertias, + }; + let mut services = BoardServices { + runtime: self, + board, + world, + settings, + physical, + launch: None, + }; + let result = board::update( + state, + GroundBoardComponents { + speed_wobble: controllers.speed_wobble, + truck_steering: controllers.truck_steering, + speed_model: controllers.speed_model, + manual: controllers.manual, + heading_previous: controllers.heading_previous, + force_queue: &mut queue, + inertias: &mut bindings, + }, + settings.board(), + input, + &mut services, + ); + //Only SetLinearDrag changes the detached inertia records. Publish that + //field alone: direct body torque/velocity operations above are retained. + for (body, inertia) in services.board.bodies_mut().iter_mut().zip(inertias) { + body.inertia.linear_drag = inertia.linear_drag; + } + *services.board.forces_mut() = queue; + result.map_err(|e| format!("Native Ground board update: {e:?}")) + } +} +impl ManualAngleMeasurement for BoardServices<'_, '_> { + type Error = String; + fn angle_between(&mut self, a: [f32; 4], b: [f32; 4], axis: [f32; 4]) -> Result { + Ok(signed_angle(super::xyz(a), super::xyz(b), super::xyz(axis))) + } +} +impl AntiFlipNudgeMath for BoardServices<'_, '_> { + type Error = String; + fn dot3(&mut self, a: [f32; 4], b: [f32; 4]) -> Result { + Ok(ground_correction_math::dot_product(a, b)) + } + fn scale_to_magnitude( + &mut self, + v: [f32; 4], + square: f32, + size: f32, + ) -> Result<[f32; 4], String> { + Ok(ground_correction_math::scale_to_magnitude(v, square, size)) + } +} +impl HangUpServices for BoardServices<'_, '_> { + type Error = String; + fn build_hang_force(&mut self) -> Result<[f32; 4], String> { + let g = self.physical.hang_geometry; + Ok(self + .runtime + .build_hang_force(self.board, vector(g.edge_start), vector(g.edge_end))) + } + fn apply_hang_force(&mut self, f: [f32; 4]) -> Result<(), String> { + self.runtime.apply_hang_force(self.board, f); + Ok(()) + } + fn detect_hung_up_geometry(&mut self) -> Result { + self.runtime + .detect_hung_up_geometry(self.world, self.physical.hang_geometry) + } + fn request_wipeout(&mut self) -> Result<(), String> { + //82D3983C..54 writes byte32 (reason12), value104 and increments200. + self.physical.wipeout.request(12, 0.0); + Ok(()) + } +} +impl HalfpipeWheelCatchServices for BoardServices<'_, '_> { + type Error = String; + fn angular_displacement(&mut self) -> Result<[f32; 4], String> { + let f = self.board.part_transforms()[BodyId::Deck.index()] + .basis + .columns; + Ok(ground_correction_math::wheel_catch_displacement( + [f[1][0], f[1][1], f[1][2], 0.], + [f[2][0], f[2][1], f[2][2], 0.], + )) + } + fn apply_angular_displacement(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.apply_angular_displacement(self.board, v); + Ok(()) + } +} +impl PinningServices for BoardServices<'_, '_> { + type Error = String; + fn pin_to_captured_position(&mut self, x: f32, z: f32) -> Result<(), String> { + self.runtime + .pin_to_position(self.board, x, z, self.physical.time_step); + Ok(()) + } +} +impl GroundBoardServices for BoardServices<'_, '_> { + type BoardError = String; + type AnimatedPose = GroundLaunchInfo; + fn center_of_mass_height_82d38838(&mut self) -> Result { + Ok(ground_correction_math::center_of_mass_height( + self.physical.skeleton_record.com_to_deck_world, + )) + } + fn set_contact_wheel_materials(&mut self) -> Result<(), String> { + *self.physical.wheel_material = self.settings.wheel_material; + Ok(()) + } + fn contact_response_82d93df0( + &mut self, + frame: GroundContactFrame, + previous: [f32; 4], + ) -> Result { + self.runtime.contact = wall_ride_response( + &self.runtime.wall_ride, + self.physical.wall_ride, + frame, + previous, + ); + Ok(self.runtime.contact) + } + fn update_body_accumulator_82d389dc(&mut self) -> Result<(), String> { + self.runtime.update_body_accumulator(self.board); + Ok(()) + } + fn set_animated_velocity_82c04168(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.set_animated_velocity(self.board, v); + Ok(()) + } + fn write_processed_velocity_400(&mut self, v: [f32; 4]) -> Result<(), String> { + *self.physical.processed_velocity = v; + Ok(()) + } + fn build_animated_pose_82d33448(&mut self) -> Result { + Ok(GroundLaunchInfo::default()) + } + fn publish_animated_pose_82be33d0(&mut self, info: &GroundLaunchInfo) -> Result<(), String> { + let physical = self.physical.launch_physical.as_ref().ok_or( + "Ground wall jump requires actual Skeleton/Reckoning launch observations82BE33D0", + )?; + let mut info = info.clone(); + info.fill( + physical, + self.runtime.launch_cone_x, + self.runtime.launch_cone_z, + ); + self.launch = Some(info); + Ok(()) + } + fn update_external_player_82d67848( + &mut self, + _info: &GroundLaunchInfo, + velocity: [f32; 4], + ) -> Result<(), String> { + self.launch + .as_mut() + .ok_or("Ground launch packet was not filled")? + .wall_jump(velocity); + Ok(()) + } + fn commit_external_player_82d68800(&mut self) -> Result<(), String> { + let info = self + .launch + .as_ref() + .ok_or("Ground launch packet was not prepared")?; + (self.physical.launch_and_update)(info) + } + fn finalize_animated_board_sk83_na_f_01a4(&mut self) -> Result<(), String> { + //TU382B61BB8 is one blr instruction. This confirmed native no-op + //does not replace the preceding required trajectory calculation. + Ok(()) + } + fn collision_force_82d944e8( + &mut self, + normal: [f32; 4], + ) -> Result, String> { + let mut physical = self.physical.collision; + physical.ground_normal = normal; + Ok(self.runtime.calculate_collision_force(physical)) + } + fn collision_force_dot_velocity_82d38e18( + &mut self, + f: [f32; 4], + v: [f32; 4], + ) -> Result { + Ok(self.runtime.collision_force_projection(f, v)) + } + fn apply_vector_82c07000(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.apply_angular_target(self.board, v); + Ok(()) + } + fn apply_angular_displacement_82c075b8(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.apply_angular_displacement(self.board, v); + Ok(()) + } +} +fn vector(v: skate_core::math::Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.] +} diff --git a/crates/skate-game/src/physics/ground_runtime/settings.rs b/crates/skate-game/src/physics/ground_runtime/settings.rs new file mode 100644 index 00000000..539dff85 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/settings.rs @@ -0,0 +1,277 @@ +//! Stock bindings used by the complete Ground::UpdateSkateboard call. +//! Toolkit copies follow82D94C10; selected surface/mode fields stay explicit. +use skate_core::{ + physics::{ + contact::RetailContactMaterial, + manual::settings::{ManualGains, ManualMode, ManualSettings}, + }, + point_graph::PointGraph, + riding::{ + anti_flip::AntiFlipSettings, + braking::{BrakeSettings, LinearDragSettings}, + ground_force::GroundForceSettings, + grounded::{propulsion::GroundPropulsionSettings, state::board_types::GroundBoardSettings}, + heading::HeadingSettings, + slide_friction::SlideFrictionSettings, + speed_model::SpeedModelSettings, + speed_wobble::SpeedWobbleSettings, + steering::SteeringSettings, + straighten::StraightenSettings, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct GroundSettings { + steering: SteeringSettings, + wobble: SpeedWobbleSettings, + propulsion: GroundPropulsionSettings, + force: GroundForceSettings, + speed: SpeedModelSettings, + slide: SlideFrictionSettings, + straighten: StraightenSettings, + heading: HeadingSettings, + anti_flip: AntiFlipSettings, + manual: ManualSettings, + manual_mode: ManualMode, + drag: LinearDragSettings, + collision_duration: f32, + collision_scale: f32, + contact_force_time: f32, + pub wheel_material: RetailContactMaterial, + pub foot_force_offset: f32, + pub absorption_front: f32, + pub absorption_rear: f32, + pub surface_braking_factor: f32, + pub wobble_activation: f32, + pub wobble_amplitude: f32, +} +impl GroundSettings { + pub fn load(data: &Collections, mode: &str, surface: &str) -> Result { + let f = |class, field| data.float(class, "default", field); + let m = |field| data.float("physics_mode", mode, field); + let s = |field| data.float("physics_surfaces", surface, field); + let c8 = |class, field| curve8(data, class, "default", field); + let c16 = |class, field| curve16(data, class, "default", field); + //82F826F8 broadcasts82181A88 into830BD350.82F82610 similarly + //broadcasts82165A10 (zero) into830BD380 for speed override direction. + let threshold = [f32::from_bits(0x3586_37bd); 4]; + Ok(Self { + foot_force_offset: f("physics_feet", "FootForceOffset")?, + absorption_front: f("physics_feet", "AbsorptionFootForceScalar")?, + absorption_rear: f("physics_feet", "AbsorptionFootForceRearScalar")?, + surface_braking_factor: s("BrakingScalar")?, + steering: SteeringSettings { + hard_turn_increase: f("physics_steering", "HardTurnIncrease")?, + damping: f("physics_steering", "Damping")?, + speed_graph_max_speed: f("physics_steering", "SteeringVsSpeedMaxSpeed")?, + push_scalar_increment: f("physics_steering", "PushScalarIncriment")?, + push_scalar_decrement: f("physics_steering", "PushScalarDecriment")?, + push_scalar_min: f("physics_steering", "PushScalarMin")?, + manual_scalar: f("physics_manual", "Tilt_TiltScalar")?, + general_scalar: f("physics_steering", "GeneralScalar")?, + tight_trucks_scalar: f("physics_steering", "SteeringScalar_TightTrucks")?, + speed_graph: c8("physics_steering", "SteeringVsSpeed")?, + input_graph: c8("physics_steering", "SteeringVsInput")?, + tilt_blending: f("physics_steering", "SteeringTiltBlending")?, + }, + wobble: SpeedWobbleSettings { + frequency_time: c8("physics_speed_wobble", "FrequencyVsTime")?, + frequency_speed: c8("physics_speed_wobble", "FrequencyVsSpeed")?, + amplitude_time: c8("physics_speed_wobble", "AmplitudeVsTime")?, + amplitude_speed: c8("physics_speed_wobble", "AmplitudeVsSpeed")?, + tightness_threshold: f("physics_speed_wobble", "TruckTightnessSpeedMaxAdjust")?, + time_range: f("physics_speed_wobble", "TimeMax")?, + speed_range: f("physics_speed_wobble", "SpeedRange")?, + frequency_scale: f("physics_speed_wobble", "MaxFrequency")?, + amplitude_scale: f("physics_speed_wobble", "MaxAmplitude")?, + crouch_threshold: f("physics_speed_wobble", "CrouchStartSpeedMaxAdjust")?, + height_min: f("physics_speed_wobble", "CrouchMin")?, + height_max: f("physics_speed_wobble", "CrouchMax")?, + }, + propulsion: GroundPropulsionSettings { + braking: BrakeSettings { + input_force: f("physics_brakes", "FootBrakeForce")?, + override_force: f("physics_brakes", "TailBrakeForce")?, + minimum_speed: f("physics_brakes", "SlowSpeed")?, + }, + maximum_pushable_speed: f("physics_push", "MaxPushableSpeed")?, + mode_speed_changes: [m("MaxPushDVStart")?, m("MaxPushDVEnd")?], + }, + force: GroundForceSettings { + range_1220: f("physics_feet", "LandingForceTime")?, + speed_scale_1224: f("physics_feet", "MinForce")?, + scale_1228: f("physics_feet", "MaxLandingForce")?, + normal_threshold: threshold, + }, + speed: SpeedModelSettings { + negative_gain: f("physics_speed_conservation", "NegativeGeneralAmount")?, + maximum_gravity_acceleration: f( + "physics_speed_conservation", + "MaxGravityAcceleration", + )?, + gravity: f("physics_speed_conservation", "Gravity")?, + positive_gain: f("physics_speed_conservation", "GeneralAmount")?, + coffin_acceleration: f("physics_speed_conservation", "CoffinAcceleration")?, + speed_error_bound: s("Friction_MaxSpeedChange")?, + surface_friction: curve8(data, "physics_surfaces", surface, "FrictionVsSpeedNew")?, + manual_acceleration: f("physics_manual", "SpinCorrectiveForce")?, + negative_manual_angle_limit: f("physics_manual", "MinSpeedForCorrection")?, + manual_angle_limit: f("physics_manual", "MaxSpeedForCorrection")?, + no_input_delay: f("physics_friction", "NoInputTime")?, + no_input_friction: c8("physics_friction", "FrictionVsSpeed_NoInput")?, + manual_friction: c8("physics_friction", "FrictionVsSpeed_Manual")?, + override_enabled: data.boolean("physics_mode", mode, "MotorEnabled")?, + override_speed: m("MotorTopSpeed")?, + normal_threshold: threshold, + override_direction_threshold: [0.; 4], + }, + slide: SlideFrictionSettings { + angle_response: c16("physics_friction", "SlideVsNormal")?, + speed_response: c8("physics_friction", "SlideVsSpeed")?, + time_response: c16("physics_heading", "SideFrictionFactor")?, + scalar_516: f("physicswheels", "SoftestWheelSideFrictionScalar")?, + heading_time_limit: s("StraightenOut_ForceTime")?, + friction: s("SideFriction_Scalar")?, + }, + straighten: StraightenSettings { + time_response: c16("physics_heading", "StraightenOutForce")?, + opposite_turn_limit: f("physics_heading", "StraightenMaxTurnInput")?, + //Lookup8 DF474D0A659A7AE3, direct82D94F80..FC0 binding. + time_scalar: f("physicswheels", "SoftestWheelStraightenOutTimeScalar")?, + heading_time_limit: s("StraightenOut_ForceTime")?, + strength: s("StraightenOut_Scalar")?, + }, + heading: HeadingSettings { + manual_wrong_wheel_scalar: f("physics_manual", "SpinNoLiftScalar")?, + manual_damping: f("physics_manual", "ManualSpinBlendSpeed")?, + speed_max: f("physics_heading", "HeadingAdjustMaxSpeed")?, + heading_strength: f("physics_heading", "HeadingAdjustFactor")?, + turn_strength: f("physics_heading", "TurnTorqueScalar")?, + angular_response: c8("physics_heading", "TurnTorqueVsNegAngVel")?, + manual_response: c8("physics_manual", "SpinVsSpeed")?, + inclination_response: c8("physics_heading", "HeadingAdjustVsSlope")?, + speed_response: c8("physics_heading", "HeadingAdjustVsSpeed")?, + normal_threshold: threshold, + }, + anti_flip: AntiFlipSettings { + axis_96_response: c8("physics_feet", "AntiFlipTorqueX")?, + axis_64_response: c8("physics_feet", "AntiFlipTorqueZ")?, + normal_threshold: threshold, + }, + manual: manual_settings(data)?, + manual_mode: ManualMode { + correction_angular_speed_threshold: m("Hash_84CB2F459F3FC811")?, + corrective_force_enabled: data.boolean( + "physics_mode", + mode, + "Hash_F8CBC0F5FEF2240E", + )?, + }, + drag: LinearDragSettings { + brake_speed: f("physics_brakes", "SlowSpeed")?, + balance_speed: f("physics_brakes", "ManualSpinDragSpeed")?, + comparison_threshold: f("physics_brakes", "ManualSpinDragNormal")?, + balance_drag: f("physics_brakes", "ManualSpinDrag")?, + }, + collision_duration: f("physics_collision", "CollisionTorqueMaxTime")?, + collision_scale: f("physics_collision", "CollisionTorqueFadeoff")?, + contact_force_time: f("physics_feet", "MaxTimeWallRidingForFootForce")?, + wheel_material: RetailContactMaterial { + static_friction: s("WheelStaticFriction")?, + dynamic_friction: s("WheelDynamicFriction")?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + wobble_activation: m("Hash_77AFCE78FE1206CA")?, + wobble_amplitude: m("Hash_5B57F2CCCCEEF430")?, + }) + } + pub fn board(&self) -> GroundBoardSettings<'_> { + GroundBoardSettings { + steering: &self.steering, + speed_wobble: &self.wobble, + propulsion: self.propulsion, + ground_force: &self.force, + speed_model: &self.speed, + slide_friction: &self.slide, + straighten: &self.straighten, + heading: &self.heading, + anti_flip: &self.anti_flip, + manual: &self.manual, + manual_mode: self.manual_mode, + linear_drag: self.drag, + collision_response_duration: self.collision_duration, + collision_response_scale: self.collision_scale, + ground_force_contact_time_limit: self.contact_force_time, + speed_model_reset_force_squared: f32::from_bits(0x3780_0000), + } + } +} +fn manual_settings(d: &Collections) -> Result { + let f = |field| d.float("physics_manual", "default", field); + let gains = |p, i, v| -> Result { + Ok(ManualGains { + proportional: f(p)?, + integral: f(i)?, + derivative: f(v)?, + }) + }; + Ok(ManualSettings { + noise_vs_speed: curve8(d, "physics_manual", "default", "NoiseVsSpeed")?, + torque_scale_without_contact: f("TorqueScalarWithNoContact")?, + torque_bleed_without_contact: f("TorqueBleedOffWithNoContact")?, + start_torque_scale: f("StartTorqueScalar")?, + procedural_noise_scale: f("ProceduralNoiseScalar")?, + procedural_noise_frequency: f("ProceduralNoiseFreq")?, + powerslide: gains("PowerScalar_P", "PowerScalar_I", "PowerScalar_D")?, + manual: gains("ManualScalar_P", "ManualScalar_I", "ManualScalar_D")?, + maximum_tilt_degrees: f("MaxTiltAngle")?, + maximum_angle_error: f("MaxAngleDiff")?, + derivative_limit: f("D_Scalar_Limit")?, + brake_tilt_degrees: f("BrakeTiltAngle")?, + animation_noise_scale: f("AnimationNoiseScalar")?, + }) +} +pub(super) fn curve8(d: &Collections, c: &str, k: &str, f: &str) -> Result, String> { + let data = &d.field(c, k, f)?.data; + match data.len() { + 128 => { + let w = d.words::<16>(c, k, f)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i])), + y: std::array::from_fn(|i| f32::from_bits(w[i + 8])), + }) + } + 160 => { + let w = d.words::<20>(c, k, f)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i + 4])), + y: std::array::from_fn(|i| f32::from_bits(w[i + 12])), + }) + } + _ => Err(format!("Invalid native eight-point graph {c}/{k}/{f}")), + } +} +fn curve16(d: &Collections, c: &str, k: &str, f: &str) -> Result, String> { + let w = d.words::<32>(c, k, f)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i])), + y: std::array::from_fn(|i| f32::from_bits(w[i + 16])), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[ignore = "requires extracted private stock collections"] + fn stock_ground_settings_closure_loads_without_defaults() { + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"), + ); + let data = Collections::load(&root).unwrap(); + GroundSettings::load(&data, "default", "default").unwrap(); + super::super::GroundRuntime::load(&data).unwrap(); + super::super::GroundPumping::load(&data).unwrap(); + super::super::GroundState::load(&data, "default", true).unwrap(); + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/state.rs b/crates/skate-game/src/physics/ground_runtime/state.rs new file mode 100644 index 00000000..e9ccfb30 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/state.rs @@ -0,0 +1,103 @@ +//! Native Ground controller ownership. Source entry/update and output retain +//! the same instances; constructing this does not mark Ground as entered. +use super::super::animation_input::AnimationInput; +use super::{GroundControllers, GroundPumping}; +use skate_core::physics::board_toolkit::BoardToolkit; +use skate_core::{ + physics::manual::state::ManualState, + player::input_phase::ProcessedPhysicsInput, + riding::{ + grounded::state::{ + PhysicsGroundState, + output::{self, GroundOutputFrame, GroundOutputSettings, PhysicsGroundOutput}, + }, + pumping::state::PumpingState, + speed_model::SpeedModelState, + speed_wobble::SpeedWobbleState, + steering::TruckSteeringState, + }, +}; +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, + pub manual: ManualState, + pub steering: TruckSteeringState, + pub speed: SpeedModelState, + pub heading_previous: f32, + pub pumping_settings: GroundPumping, + output_settings: GroundOutputSettings, + auto_push_enabled: bool, + pub entered: bool, + pub(super) entry_settings: super::entry::EntrySettings, +} +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]), + manual: ManualState { + filtered_angle_error: 0.0, + target_angle: 0.0, + measured_angle: 0.0, + angular_correction: 0.0, + elapsed: 0.0, + }, + //Initial truck state; later board resets preserve activation timers. + steering: TruckSteeringState::default(), + speed: SpeedModelState { + target_speed: 0.0, + flags_1360: 0, + }, + heading_previous: 0.0, + pumping_settings: GroundPumping::load(data)?, + output_settings: GroundOutputSettings { + pushable_speed_terms_4_8: [ + push("MaxPushableSpeed_CameraDelta")?, + push("MaxPushableSpeed")?, + ], + mode_speed_threshold_0: push("Hash_501D5581043D7D3C")?, + }, + entry_settings: super::entry::EntrySettings::load(data)?, + auto_push_enabled: data.boolean("physics_mode", mode, "AutoPushEnabled")?, + entered: false, + }) + } + pub fn output( + &self, + p: &ProcessedPhysicsInput, + animation: &AnimationInput, + t: &BoardToolkit, + ) -> PhysicsGroundOutput { + output::fill_physics_output( + &self.state, + GroundOutputFrame { + axis_464: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + absolute_body_speed_2616: t.absolute_speed, + deck_speed_2652: p.scalar_2652, + state_timer_2664: p.state_timer_2664, + scalar_2720: animation.fields.balance, + flags_2476: p.flags_2476, + flags_2484: p.flags_2484, + selected_mode_flag_109: self.auto_push_enabled, + }, + self.output_settings, + ) + } + pub fn controllers(&mut self) -> GroundControllers<'_> { + GroundControllers { + speed_wobble: &mut self.wobble, + truck_steering: &mut self.steering, + speed_model: &mut self.speed, + manual: &mut self.manual, + heading_previous: &mut self.heading_previous, + } + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/surface.rs b/crates/skate-game/src/physics/ground_runtime/surface.rs new file mode 100644 index 00000000..cb5b4351 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/surface.rs @@ -0,0 +1,30 @@ +//! Native wheel surface vote82C08818 on live wheel lines and contact reports. +use super::super::riding_outputs::RidingOutputs; +use skate_core::physics::{board_runtime::BoardRuntime, contact_feedback::choose_surface}; +/// Wheel surface IDs come from line queries. The report loop82C08198 updates +/// only truck/deck material slots, so wheel contacts contribute vote weights +/// without replacing the query-owned material ID. +pub(crate) fn active_surface(riding: &RidingOutputs, board: &BoardRuntime) -> u32 { + let contacts = std::array::from_fn(|i| riding.ground.parts[i].in_contact); + let forced = board + .contact_reports() + .iter() + .any(|r| r.other_surface & 0x0f80 == 0x0600); + choose_surface(riding.wheel_lines.physics_surfaces, contacts, forced) +} + +/// SurfacePhysics constructor82D85D08 binds these exact five collection keys +/// at24/40/56/72/88. Lookup8 hashes independently match the native immediates. +/// The selector's mode normalization lives in the processed-input producer. +pub(crate) fn surface_key(mode: u32) -> Result<&'static str, String> { + match mode { + 1 => Ok("smooth"), + 2 => Ok("rough"), + 3 => Ok("slow"), + 4 => Ok("slippery"), + 5 => Ok("veryslow"), + _ => Err(format!( + "Processed surface mode {mode} was not normalized by SurfacePhysics" + )), + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/trajectory.rs b/crates/skate-game/src/physics/ground_runtime/trajectory.rs new file mode 100644 index 00000000..97b66476 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/trajectory.rs @@ -0,0 +1,22 @@ +//! Ground ordinary tail82D749D0 cancels pending trajectory work and clears +//!published validity. Rust owns the request lifetime; no guest pool is copied. +///The caller's actual pending request type can cancel work on Drop. Prediction +///data may remain cached, as native Reset clears validity rather than erasing +///all output vectors. Lower six flags retain their existing values. +pub(crate) struct GroundTrajectoryState { + pub pending_request: Option, + pub primary_valid_288: bool, + pub secondary_valid_592: bool, + pub result_valid_9840: bool, + pub flags_12836: u8, +} +impl GroundTrajectoryState { + ///Publish the native cancellation after releasing the current request. + pub fn cancel(&mut self) { + self.pending_request.take(); + self.result_valid_9840 = false; + self.primary_valid_288 = false; + self.secondary_valid_592 = false; + self.flags_12836 &= 0x3f; + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/update.rs b/crates/skate-game/src/physics/ground_runtime/update.rs new file mode 100644 index 00000000..b017e42c --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/update.rs @@ -0,0 +1,121 @@ +//! 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. +use super::{ + super::{ + animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, foot_ik::FootIk, + riding_outputs::RidingOutputs, + }, + GroundControllers, GroundInputObservations, GroundPhysicalFrame, GroundRuntime, GroundSettings, + GroundState, GroundTrajectoryState, +}; +use skate_core::{ + math::Vector3, + physics::{ + board_runtime::BoardRuntime, board_toolkit::BoardToolkit, board_world::BoardWorld, + drive_frames::RetailAffineTransform, + }, + player::input_phase::ProcessedPhysicsInput, + riding::grounded::state::{board::GroundBoardOutcome, motion}, +}; + +pub(crate) struct GroundUpdateFrame<'a> { + pub processed: &'a ProcessedPhysicsInput, + pub animation: &'a AnimationInput, + pub riding: &'a RidingOutputs, + pub skeleton: &'a AnimatedSkeleton, + pub toolkit: &'a BoardToolkit, + pub base_trucks: [RetailAffineTransform; 2], + pub extra: GroundInputObservations, +} +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, +} +impl GroundState { + pub fn update( + &mut self, + runtime: &mut GroundRuntime, + board: &mut BoardRuntime, + world: &BoardWorld, + settings: &GroundSettings, + frame: GroundUpdateFrame<'_>, + physical: GroundPhysicalFrame<'_>, + targets: GroundUpdateTargets<'_, T>, + ) -> Result { + if !self.entered { + return Err("Ground::Enter must finish before Ground::Update".into()); + } + let p = frame.processed; + let signals = self + .state + .begin_update_at(p.wheel_count_2556 as i32, frame.toolkit.deck[3]); + 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); + let pumping_mode = self.pumping_settings.mode(p.state_variant_index_2528)?; + self.pumping_settings.update( + &mut self.pumping, + frame.toolkit, + frame.riding, + frame.skeleton, + p.flags_2476, + pumping_mode, + ); + let input = settings.input( + frame.toolkit, + p, + frame.animation, + &self.pumping, + pumping_mode.unintentional_scalar, + frame.riding, + frame.skeleton, + frame.base_trucks, + frame.extra, + ); + let controllers = GroundControllers { + speed_wobble: &mut self.wobble, + truck_steering: &mut self.steering, + speed_model: &mut self.speed, + manual: &mut self.manual, + heading_previous: &mut self.heading_previous, + }; + let outcome = runtime.update_board( + board, + world, + settings, + &mut self.state, + controllers, + input, + physical, + )?; + let normal = p.vectors_464_480_496_512_528[0].map(f32::from_bits); + if let Some(delta) = motion::future_deck_displacement( + board.forces(), + frame.toolkit.total_mass, + p.timestep_2604, + normal, + self.state.flag_2720, + self.state.manual_correction_2732, + ) { + (targets.move_future_deck)(delta)?; + } + if self.state.flag_2722 { + targets.foot_ik.state.contacts.support_failed_this_update = true; + } + *targets.skeleton_ground_16388 = true; + self.state.finish_update(p.timestep_2604); + targets.trajectory.cancel(); + Ok(outcome) + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/world_grab.rs b/crates/skate-game/src/physics/ground_runtime/world_grab.rs new file mode 100644 index 00000000..0011f46a --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/world_grab.rs @@ -0,0 +1,59 @@ +//! Typed Skitch/world-grab observations. Ground inactive82D38430 executes +//!Stop82D62F20 and predictor reset82D65FB8 before pumping. A target is present +//!only when the world query owner supplied an actual object observation. +#[derive(Clone, Debug)] +pub(crate) struct WorldGrabObservation { + pub object: u64, + pub position: [f32; 4], + pub relative_velocity: [f32; 4], +} +#[derive(Clone, Debug, Default)] +pub(crate) struct GroundWorldGrabState { + pub observation: Option, + pub contact_flags: u32, + pub contact_time: f32, + pub contact_position: [f32; 4], + pub contact_velocity: [f32; 4], + pub contact_valid: bool, + pub active: bool, + pub secondary_active: bool, + pub selected_count: u32, + pub position: [f32; 4], + pub probe_distances: [[f32; 4]; 4], + pub probe_weights: [[f32; 4]; 2], + pub query_points: [[f32; 4]; 2], + pub query_normals: [[f32; 4]; 2], + pub query_word: u32, + pub query_scalar: f32, + pub query_valid: [bool; 2], + pub output_scalar: f32, + pub output_direction: [f32; 4], + pub output_flags: u32, +} +impl GroundWorldGrabState { + ///The shared vector comes from native global830BD4A0's initializer. It + ///must be supplied by that producer, never inferred from ground normal. + pub fn clear_inactive(&mut self, reset_direction: [f32; 4]) { + self.active = false; + self.secondary_active = false; + self.contact_flags &= 0x4fff_ffff; + self.contact_time = f32::MAX; + self.contact_position = [0.0; 4]; + self.contact_valid = false; + self.contact_velocity = [0.0; 4]; + self.observation = None; + self.probe_distances = [[-1.0; 4]; 4]; + self.probe_weights = [[0.0; 4]; 2]; + self.selected_count = 0; + self.position = [0.0; 4]; + //82D65FB8 writes two literal UnitY normals and two zero points. + self.query_points = [[0.0; 4]; 2]; + self.query_normals = [[0.0, 1.0, 0.0, 0.0]; 2]; + self.query_word = 0; + self.query_scalar = 0.0; + self.query_valid = [false; 2]; + self.output_scalar = 0.0; + self.output_direction = reset_direction; + self.output_flags &= 0x3fff_ffff; + } +} diff --git a/crates/skate-game/src/physics/input_phase.rs b/crates/skate-game/src/physics/input_phase.rs new file mode 100644 index 00000000..a7c6410e --- /dev/null +++ b/crates/skate-game/src/physics/input_phase.rs @@ -0,0 +1,214 @@ +//! Connect the animation packet to the one physical player and skeleton. +//! PlayerInput retains the original ordering; callbacks split host ownership. +#[path = "input_teleport.rs"] +mod teleport; +use super::{ + GamePhysics, SkaterRuntime, + animated_skeleton::AnimatedSkeleton, + animation_input::AnimationInput, + foot_ik::FootIk, + ground_phase::GroundLifecycle, + ground_runtime::{GroundRuntime, GroundState}, + player_input::{InputHostFrame, PlayerInputCallbacks}, + riding_outputs::RidingOutputs, + settings::PhysicsSettings, + skeleton_input_runtime::{ + CollisionInput, SkeletonInputRuntime, SkeletonOwners, SkeletonPoseInput, + }, + skeleton_output::SkeletonOutput, +}; +use skate_core::{ + animation::output::{NativeMatrix, attributes::AnimationAttribute}, + input::controller::ActionMap, + physics::{ + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + skeleton_body::{ + SkeletonBody, SkeletonCollisionFeedback, SkeletonCollisionMode, SkeletonDrives, + SkeletonPoseErrors, + }, + }, + player::input_phase::{AnimationInputPacket, PhysicalPlayerInput, ProcessedPhysicsInput}, +}; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + packet: &AnimationInputPacket<'_>, + actions: &mut dyn ActionMap, + input_available: bool, +) -> Result { + let host = InputHostFrame { + // Native actor queries are host identity, not a physical parameter. + actor_query_56: 0, + actor_query_44: 0, + input_available, + transition_action: actions.value(71), + published_board_transform: if skater.player_input.physical.state.flag_61 != 0 { + skater.player_input.physical.teleport_output + .ok_or("Teleport State61 requires its published reset transform")? + .transform.map(|row| row.map(f32::from_bits)) + } else { super::solve::deck_frame(&physics.board) }, + //82D8ABD8 reads the state selector's retained signed counter+40, + //before this tick's CalcSuggestedState advances it at82D8AF78..94. + air_counter_40: skater.player_state.selector.post_grind_jump_counter, + }; + let collision = collision(skater); + let mut callbacks = Callbacks { + skeleton_input: &mut skater.skeleton_input, + animated: &mut skater.animated_skeleton, + skeleton_air: &mut skater.skeleton_air, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + output: &mut skater.skeleton_output, + pose_errors: &mut skater.pose_errors, + collision_mode: &mut skater.skeleton_collision, + feedback: &mut skater.collision_feedback, + collision, + ground: &mut skater.ground, + life: &mut skater.ground_lifecycle, + wipeout: &mut skater.wipeout, + riding: &mut physics.riding, + wiping_out: &mut physics.board_wiping_out, + settings: &physics.settings, + globals: &skater.animation.packet.hierarchy, + attributes: skater.animation.attributes.entries(), + actions, + packet, + teleported: false, + }; + let ground_frame = callbacks.riding.reckoning_frames.ground; + let continuation = skater.player_input.process_stage( + &mut physics.board, + &mut skater.ground_runtime, + ground_frame, + packet, + host, + &mut callbacks, + super::player_input::InputStage::ThroughTeleport, + )?; + if let Some(continuation) = continuation { + skater.player_input.process_stage( + &mut physics.board, + &mut skater.ground_runtime, + ground_frame, + packet, + host, + &mut callbacks, + super::player_input::InputStage::AfterTeleport(continuation), + )?; + } + physics.processed_flags_2468 = skater.player_input.processed.flags_2468; + Ok(callbacks.teleported) +} + +pub(super) fn update_ground( + physics: &GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let collision = collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + 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, + }; + let target = skater.skeleton_input.update_ground( + &physics.board, + &physics.riding.reckoning_frames.system, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + )?; + //Ground82D38000 completes GeneralUpdate;82D38008 then retains the actual + //board/animation error used when Air subsequently blends its board target. + skater.skeleton_air.capture_physics_error(&physics.board, &target); + Ok(()) +} + +pub(super) fn collision(skater: &SkaterRuntime) -> CollisionInput { + CollisionInput { + contact_4070: skater.collision_feedback.flags.compliant, + has_pose_error_4077: skater.collision_feedback.flags.has_impulse, + pose_error_16272: skater.collision_pose_error, + partial_ragdoll: skater.skeleton_collision.partial_ragdoll, + drive_weight_4028: skater.collision_feedback.drive_weight, + } +} + +struct Callbacks<'a, 'p> { + skeleton_input: &'a mut SkeletonInputRuntime, + animated: &'a mut AnimatedSkeleton, + skeleton_air: &'a mut super::skeleton_air::SkeletonAir, + body: &'a mut SkeletonBody, + drives: &'a mut SkeletonDrives, + ik: &'a mut FootIk, + animation_input: &'a mut AnimationInput, + output: &'a mut SkeletonOutput, + pose_errors: &'a mut SkeletonPoseErrors, + collision_mode: &'a mut SkeletonCollisionMode, + feedback: &'a mut SkeletonCollisionFeedback, + collision: CollisionInput, + ground: &'a mut GroundState, + life: &'a mut GroundLifecycle, + wipeout: &'a mut super::wipeout::Wipeout, + riding: &'a mut RidingOutputs, + wiping_out: &'a mut bool, + settings: &'a PhysicsSettings, + globals: &'a [NativeMatrix], + attributes: &'a [AnimationAttribute], + actions: &'a mut dyn ActionMap, + packet: &'a AnimationInputPacket<'p>, + teleported: bool, +} +impl PlayerInputCallbacks for Callbacks<'_, '_> { + fn process_skeleton( + &mut self, + board: &mut BoardRuntime, + toolkit: &BoardToolkit, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.skeleton_input.process_data( + board, + toolkit, + packet, + physical, + processed, + &mut SkeletonOwners { + animated: self.animated, + body: self.body, + drives: self.drives, + ik: self.ik, + animation_input: self.animation_input, + correction: &mut self.output.correction, + pose_errors: self.pose_errors, + }, + SkeletonPoseInput { + globals: self.globals, + attributes: self.attributes, + actions: self.actions, + }, + &self.collision, + ) + } + fn teleport( + &mut self, + board: &mut BoardRuntime, + runtime: &mut GroundRuntime, + target: NativeMatrix, + player: &mut skate_core::player::input_phase::PlayerInputState, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.reset_player(board, runtime, target, player, physical, processed) + } +} diff --git a/crates/skate-game/src/physics/input_teleport.rs b/crates/skate-game/src/physics/input_teleport.rs new file mode 100644 index 00000000..98982f55 --- /dev/null +++ b/crates/skate-game/src/physics/input_teleport.rs @@ -0,0 +1,150 @@ +//! Ordinary local-player teleport82DB8998, operating on the live assemblies. +use super::*; +use skate_core::{ + math::{Basis3, Vector3}, + physics::{drive_frames::RetailAffineTransform, skeleton_animation_record::IDENTITY}, + player::input_phase::PlayerInputState, +}; + +impl Callbacks<'_, '_> { + pub(super) fn reset_player( + &mut self, + board: &mut BoardRuntime, + runtime: &mut GroundRuntime, + requested: NativeMatrix, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + p: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.life + .skeleton_controller + .request_ground(self.collision_mode)?; + let target = horizontal_spawn(requested); + player.flags_1296 = ((player.flags_1296 & 0xffef_ffff) & 0x81ff_ffff) | 0x6000_0000; + player.previous_spin_input_1360 = 0.0; + player.spin_same_direction_frames_1324 = 0; + player.dismount_request_frames_1332 = 0; + player.time_on_ground_1352 = 0.0; + player.signed_ground_time_1356 = 0.0; + player.queued_vector_1280 = [0; 4]; + player.hips_line_test_1488 = Default::default(); + player.left_line_test_1536 = Default::default(); + player.right_line_test_1584 = Default::default(); + crate::physics::player_input::reset_outputs(physical); + board.reset_physical( + self.settings.authored, + affine(target), + p.flags_2468, + self.settings.step.simulation.gravity_acceleration, + ); + crate::physics::ground_phase::reset_board_state( + self.ground, + runtime, + self.life, + self.wiping_out, + ); + self.skeleton_air.reset_board(); //Board Reset82C0606C. + self.riding.reset_for_teleport(); + { + let mut owners = SkeletonOwners { + animated: self.animated, + body: self.body, + drives: self.drives, + ik: self.ik, + animation_input: self.animation_input, + correction: &mut self.output.correction, + pose_errors: self.pose_errors, + }; + self.skeleton_input.reset_for_teleport( + &mut owners, + self.collision_mode, + self.feedback, + &mut self.output.wobble, + &mut self.life.skeleton_elapsed_16505, + ); + } + //ProcessData below reads the reset collision manager, not the snapshot + //taken before this callback entered ResetSystems. + self.collision.contact_4070 = self.feedback.flags.compliant; + self.collision.has_pose_error_4077 = self.feedback.flags.has_impulse; + self.collision.partial_ragdoll = self.collision_mode.partial_ragdoll; + self.collision.drive_weight_4028 = self.feedback.drive_weight; + //ResetSystems82DB92D0 preserves Reckoning filter histories. Seed the + //fresh deck heading and recompute its actual Ground transform. + self.riding.reckoning.reset(); + self.riding.update_ground_reckoning( + board, + crate::physics::riding_outputs::RidingPoseInputs { + com_to_deck: Vector3::new( + self.animated.record.com_to_deck_world[0], + self.animated.record.com_to_deck_world[1], + self.animated.record.com_to_deck_world[2], + ), + body_spin: self.animation_input.extra.physical_body_spin, + }, + p.flags_2468, + self.animation_input.fields.balance, + false, + ); + self.wipeout.state.reset_systems(); + self.life.skeleton_controller.effective = 0; + self.life.skeleton_controller.requested = 0; + self.life.skeleton_controller.has_request = false; + self.life.skeleton_controller.override_enabled = false; + self.life.skeleton_controller.flag_18 = false; + self.life.trajectory.pending_request = None; + self.life.trajectory.primary_valid_288 = false; + self.life.trajectory.secondary_valid_592 = false; + self.life.trajectory.result_valid_9840 = false; + player.manager_1856_counter_320 = 0; + player.probe = Default::default(); + + let toolkit = runtime.prepare_toolkit(board, p); + let packet = self.packet; + self.process_skeleton(board, &toolkit, packet, physical, p)?; + self.skeleton_input.update_teleport( + board, + &self.riding.reckoning_frames.system, + p, + &mut SkeletonOwners { + animated: self.animated, + body: self.body, + drives: self.drives, + ik: self.ik, + animation_input: self.animation_input, + correction: &mut self.output.correction, + pose_errors: self.pose_errors, + }, + self.globals, + &self.collision, + self.settings.step.simulation, + )?; + self.wipeout.state.teleport(); //82DB8DB4, separate from ResetSystems. + self.teleported = true; + Ok(()) + } +} + +fn affine(m: NativeMatrix) -> RetailAffineTransform { + RetailAffineTransform { + basis: Basis3 { + columns: std::array::from_fn(|i| [m[i][0], m[i][1], m[i][2]]), + }, + translation: Vector3::new(m[3][0], m[3][1], m[3][2]), + } +} +///82DB8A10..8BB4 flattens At before normalization, rejects length<=.001, +///then adds the original .1m world-Y teleport clearance. +fn horizontal_spawn(requested: NativeMatrix) -> NativeMatrix { + let mut result = IDENTITY; + let x = requested[2][0]; + let z = requested[2][2]; + let length = (x * x + z * z).sqrt(); + if length > f32::from_bits(0x3a83_126f) { + result[2] = [x / length, 0.0, z / length, 0.0]; + result[0] = [result[2][2], 0.0, -result[2][0], 0.0]; + } + result[3] = requested[3]; + result[3][1] += 0.1; + result +} diff --git a/crates/skate-game/src/physics/known_air.rs b/crates/skate-game/src/physics/known_air.rs new file mode 100644 index 00000000..939481da --- /dev/null +++ b/crates/skate-game/src/physics/known_air.rs @@ -0,0 +1,200 @@ +//! Native KnownAir201 lifecycle82D352D0/82D35C58/82D35920/82D36590/82D36880. +//! All phase adapters borrow the production owners; the state copy below is a +//! stack-local value while those owners are borrowed, then published once. +mod flip; +mod math; +mod output; +mod runtime; +mod settings; +use super::{GamePhysics, SkaterRuntime}; +use skate_core::air::known::{ + self, KnownAirFrame, KnownAirModeSettings, KnownAirOutput, KnownAirReckoningFields, + KnownAirSettings, KnownAirState, KnownAirWipeoutRequest, KnownAirWipeoutSettings, +}; +use skate_data::collections::Collections; +pub(crate) struct KnownAir { + pub state: KnownAirState, + settings: KnownAirSettings, + modes: [KnownAirModeSettings; 5], + wipeout: KnownAirWipeoutSettings, + flip_axis_adjustment: [f32; 4], +} +impl KnownAir { + pub fn load(data: &Collections) -> Result { + let (settings, modes) = settings::load(data)?; + Ok(Self { + settings, + modes, + wipeout: KnownAirWipeoutSettings { + air_falling_min_up_y_260: data.float( + "physics_wipeout", + "default", + "Wipeout_AirFallingMinUpY", + )?, + air_falling_max_angle_264: data.float( + "physics_wipeout", + "default", + "Wipeout_AirFallingMaxAngle", + )?, + }, + //82D8DA84 binds physics_reckoning handle1600, layout pointer1604. + flip_axis_adjustment: data + .words::<4>("physics_reckoning", "default", "FlipAxisAdjustment")? + .map(f32::from_bits), + state: KnownAirState { + landing_normal_64: [0.0, 1.0, 0.0, 0.0], + landing_heading_80: [0.0; 4], + trajectory_apex_96: [0.0; 4], + collision_position_112: [0.0; 4], + selector_vector_128: [0.0; 4], + trajectory_follow_offset_144: [0.0; 4], + target_com_position_160: [0.0; 4], + collision_normal_speed_176: 0.0, + time_in_state_180: 0.0, + start_y_184: 0.0, + max_y_188: 0.0, + com_max_y_192: 0.0, + collision_time_196: 0.0, + time_to_apex_200: 0.0, + body_flip_target_speed_204: 0.0, + reached_apex_208: false, + landing_heading_valid_209: false, + start_flipped_210: false, + body_flipping_211: false, + grind_air_adjust_activated_212: false, + targeting_grind_213: false, + trajectory_index_216: 0, + }, + }) + } + fn mode(&self, index: u32) -> Result { + self.modes + .get(index as usize) + .copied() + .ok_or_else(|| format!("Undefined KnownAir physics mode {index}")) + } +} +fn frame(skater: &SkaterRuntime, next: u32) -> Result { + let p = &skater.player_input.processed; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("KnownAir requires current BoardToolkit")?; + let s = skater + .trajectory + .selector + .selection() + .ok_or("KnownAir requires a completed selected trajectory")?; + Ok(KnownAirFrame { + start_flip_reference_96: t.deck[2], + alternate_head_target_112: t.deck[3], + velocity_400: p.vectors_400_416[0].map(f32::from_bits), + ground_normal_464: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + start_height_484: f32::from_bits(p.vectors_464_480_496_512_528[1][1]), + skater_up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + flags_2488: p.flags_2488, + next_physics_state_2500: next as i32, + delta_time_2604: p.timestep_2604, + forward_speed_2612: p.scalar_2612, + body_spin_input_2640: skater.animation_input.fields.body_spin, + selector_landing_normal_2656: s.landing_normal, + }) +} +fn reckoning(physics: &GamePhysics, skater: &SkaterRuntime) -> KnownAirReckoningFields { + let up = physics.riding.reckoning.up; + KnownAirReckoningFields { + landing_normal_1152: [up.x, up.y, up.z, 0.0], + heading_axis_1200: physics.riding.reckoning_frames.heading, + body_spin_angle_1568: skater.air_reckoning.state.spin_angle, + body_spin_speed_1572: skater.air_reckoning.state.spin_speed, + } +} +pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = frame(skater, 201)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.settings; + let mode = skater + .known_air + .mode(skater.player_input.processed.state_variant_index_2528)?; + let mut r = runtime::Runtime::new(physics, skater)?; + known::enter(&mut state, &frame, &settings, &mode, &mut r); + r.finish()?; + skater.known_air.state = state; + Ok(()) +} +pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = frame(skater, 201)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.settings; + let mode = skater + .known_air + .mode(skater.player_input.processed.state_variant_index_2528)?; + let reckoning = reckoning(physics, skater); + let mut r = runtime::Runtime::new(physics, skater)?; + known::update(&mut state, &frame, &settings, &mode, &reckoning, &mut r); + r.finish()?; + skater.known_air.state = state; + Ok(()) +} +pub(super) fn exit( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + next: u32, +) -> Result<(), String> { + let mut frame = frame(skater, next)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.settings; + let mut reckoning = reckoning(physics, skater); + let mut r = runtime::Runtime::new(physics, skater)?; + known::exit(&mut state, &mut frame, &settings, &mut reckoning, &mut r); + r.finish()?; + skater.player_input.processed.scalar_2612 = frame.forward_speed_2612; + skater.air_reckoning.state.spin_angle = reckoning.body_spin_angle_1568; + skater.air_reckoning.state.spin_speed = reckoning.body_spin_speed_1572; + skater.known_air.state = state; + Ok(()) +} +///82D36590: apex observation, shared air-wipeout(true), then upside-down request. +pub(super) fn post_physics( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let frame = frame(skater, 201)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.wipeout; + let mode = skater + .known_air + .mode(skater.player_input.processed.state_variant_index_2528)?; + let mut request = KnownAirWipeoutRequest { + requested_35: false, + scalar_116: 0.0, + counter_200: 0, + }; + let mut r = runtime::Runtime::new(physics, skater)?; + known::update_post_physics(&mut state, &frame, &mode, &settings, &mut request, &mut r); + r.finish()?; + if request.counter_200 != 0 { + skater.wipeout.state.request(15, request.scalar_116); + } + skater.known_air.state = state; + Ok(()) +} +pub(super) fn fill( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result { + let frame = frame(skater, 201)?; + let state = skater.known_air.state; + let mut out = output::storage(&skater.player_input.physical.air); + let mut r = runtime::Runtime::new(physics, skater)?; + known::fill_physics_output(&state, &frame, &mut out, &mut r); + r.finish()?; + output::publish(&out, &mut skater.player_input.physical.air); + Ok(out) +} diff --git a/crates/skate-game/src/physics/known_air/flip.rs b/crates/skate-game/src/physics/known_air/flip.rs new file mode 100644 index 00000000..5beef40a --- /dev/null +++ b/crates/skate-game/src/physics/known_air/flip.rs @@ -0,0 +1,48 @@ +//! Original Reckoning ResetBodyFlip82D8E9E0 / BeginBodyFlip82D8EAB0. +use super::{math::*, runtime::Runtime}; +use skate_core::physics::skeleton_animation_record::IDENTITY; +impl Runtime<'_> { + pub(super) fn reset_flip(&mut self) { + let frames = &mut self.physics.riding.reckoning_frames; + let n = self.physics.riding.reckoning.up; + let up = rotate(&frames.body_flip, [n.x, n.y, n.z, 0.0]); + self.physics.riding.reckoning.up = xyz(up); + frames.heading = rotate(&frames.body_flip, frames.heading); + frames.body_flip = IDENTITY; + let state = &mut self.skater.air_reckoning.state; + state.flip_active = false; + state.flip_speed = 0.0; + state.flip_side = false; + state.flip_angle = 0.0; + } + pub(super) fn begin_flip(&mut self, side: bool) { + let state = &mut self.skater.air_reckoning.state; + if state.flip_active { + return; + } + self.physics.riding.reckoning_frames.body_flip = IDENTITY; + state.flip_speed = 0.0; + state.flip_angle = 0.0; + let mut basis = self.physics.riding.reckoning_frames.system; + if self.toolkit.control_sign < 0.0 { + basis[0] = scale(basis[0], -1.0); + basis[2] = scale(basis[2], -1.0); + } + let v = self.skater.animation_input.fields.body_spin * 4.0; + let lo = if -v >= 0.0 { 0.0 } else { v }; + let blend = if 1.0 - lo >= 0.0 { lo } else { 1.0 }; + let adjustment = scale(self.skater.known_air.flip_axis_adjustment, blend); + let axis = std::array::from_fn(|i| { + basis[2][i].mul_add( + adjustment[2], + basis[1][i].mul_add(adjustment[1], basis[0][i]), + ) + }); + state.flip_axis = normalized(axis).0; + if side { + state.flip_axis = scale(state.flip_axis, -1.0); + } + state.flip_side = side; + state.flip_active = true; + } +} diff --git a/crates/skate-game/src/physics/known_air/math.rs b/crates/skate-game/src/physics/known_air/math.rs new file mode 100644 index 00000000..b0f2d8ad --- /dev/null +++ b/crates/skate-game/src/physics/known_air/math.rs @@ -0,0 +1,106 @@ +//! Original KnownAir VMX geometry; independent PC estimate seeds. +use super::runtime::Runtime; +use skate_core::{ + air::known::{AffineTransform, KnownAirMath, RestoreVelocityGeometry}, + math::Vector3, +}; +pub(super) type V = [f32; 4]; +pub(super) fn dot(a: V, b: V) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +pub(super) 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 { + a.map(|v| v * s) +} +pub(super) fn cross(a: V, b: V) -> V { + [ + (-a[2]).mul_add(b[1], a[1] * b[2]), + (-a[0]).mul_add(b[2], a[2] * b[0]), + (-a[1]).mul_add(b[0], a[0] * b[1]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +pub(super) fn normalized(v: V) -> (V, f32) { + let s = dot(v, v); + let mut r = s.sqrt().recip(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-s).mul_add(r * r, 1.0), r); + } + let l = if s == 0.0 { 0.0 } else { s * r }; + ( + if l > f32::from_bits(0x358637bd) { + scale(v, r) + } else { + [0.0; 4] + }, + l, + ) +} +pub(super) 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 point(m: &[V; 4], v: V) -> V { + std::array::from_fn(|i| { + let x = m[0][i].mul_add(v[0], m[3][i]); + let y = m[1][i].mul_add(v[1], x); + m[2][i].mul_add(v[2], y) + }) +} +pub(super) fn xyz(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +impl KnownAirMath for Runtime<'_> { + fn dot3(&mut self, a: V, b: V) -> f32 { + dot(a, b) + } + fn divide_vector_by_scalar(&mut self, v: V, s: f32) -> V { + let mut r = s.recip(); + for _ in 0..2 { + r = (-s).mul_add(r, 1.0).mul_add(r, r); + } + scale(v, r) + } + fn normalize_safe_with_length(&mut self, v: V) -> (V, f32) { + normalized(v) + } + fn signed_angle_about_axis(&mut self, a: V, b: V, axis: V) -> f32 { + if !(dot(a, a) * dot(b, b) > f32::from_bits(0x37800000)) { + return 0.0; + } + let a = sub(a, scale(axis, dot(axis, a))); + let b = sub(b, scale(axis, dot(axis, b))); + skate_core::riding::collision_response::signed_angle(xyz(a), xyz(b), xyz(axis)) + } + fn wrap_angle_8258db98(&mut self, mut a: f32) -> f32 { + let pi = f32::from_bits(0x40490fdb); + let tau = f32::from_bits(0x40c90fdb); + if a < -pi || !(a < pi) { + let n = (a * f32::from_bits(0x3e22f983)) as i32; + a = (-(n as f32)).mul_add(tau, a); + if !(a < pi) { + a -= tau; + } else if a < -pi { + a += tau; + } + } + a + } + fn restore_velocity_geometry_82d35998(&mut self, v: V, n: V) -> RestoreVelocityGeometry { + let tangent = sub(v, scale(n, dot(n, v))); + let downhill = cross(n, cross(n, [0.0, 1.0, 0.0, 0.0])); + RestoreVelocityGeometry { + tangential_velocity: tangent, + landing_speed_curve_input: n[1], + landing_speed_blend_source: dot(normalized(downhill).0, normalized(tangent).0), + } + } + fn transform_point_82d36880(&mut self, m: AffineTransform, p: V) -> V { + point(&m.vectors, p) + } +} diff --git a/crates/skate-game/src/physics/known_air/output.rs b/crates/skate-game/src/physics/known_air/output.rs new file mode 100644 index 00000000..1cb4d6ef --- /dev/null +++ b/crates/skate-game/src/physics/known_air/output.rs @@ -0,0 +1,68 @@ +//! Fill82D36880 overwrites every field below except the explicitly retained pair. +use skate_core::{ + air::known::{KnownAirOutput, KnownAirTrajectory}, + player::input_phase::AirOutputFields, +}; +pub(super) fn storage(previous: &AirOutputFields) -> KnownAirOutput { + KnownAirOutput { + trajectory_apex_0: [0.0; 4], + collision_position_16: [0.0; 4], + landing_normal_32: [0.0; 4], + selector_vector_48: [0.0; 4], + trajectory_position_64: [0.0; 4], + landing_heading_80: [0.0; 4], + selector_com_position_96: [0.0; 4], + landing_normal_copy_144: [0.0; 4], + locked_trajectory_velocity_160: previous.vector_160.map(f32::from_bits), + time_in_state_176: 0.0, + collision_time_180: 0.0, + time_until_collision_184: 0.0, + collision_normal_speed_188: 0.0, + time_to_apex_196: 0.0, + jump_height_200: 0.0, + trajectory_index_220: 0, + selected_trajectory_240: KnownAirTrajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + scalar_48: 0.0, + word_52: 0, + word_56: 0, + word_60: 0, + }, + trajectory_plane_samples_336: [0.0; 25], + reached_apex_436: false, + known_air_valid_437: false, + locked_trajectory_velocity_valid_452: previous.use_air_reckoning_452 != 0, + } +} +pub(super) fn publish(out: &KnownAirOutput, physical: &mut AirOutputFields) { + physical.trajectory_apex_0 = out.trajectory_apex_0.map(f32::to_bits); + physical.collision_position_16 = out.collision_position_16.map(f32::to_bits); + physical.landing_normal_32 = out.landing_normal_32.map(f32::to_bits); + physical.selector_vector_48 = out.selector_vector_48.map(f32::to_bits); + physical.trajectory_position_64 = out.trajectory_position_64.map(f32::to_bits); + physical.landing_heading_80 = out.landing_heading_80.map(f32::to_bits); + physical.selector_com_position_96 = out.selector_com_position_96.map(f32::to_bits); + physical.time_in_state_176 = out.time_in_state_176; + physical.collision_time_180 = out.collision_time_180; + physical.collision_normal_speed_188 = out.collision_normal_speed_188; + physical.time_to_apex_196 = out.time_to_apex_196; + physical.trajectory_index_220 = out.trajectory_index_220; + physical.trajectory_plane_samples_336 = out.trajectory_plane_samples_336; + physical.known_air_valid_437 = u8::from(out.known_air_valid_437); + let t = out.selected_trajectory_240; + physical.selected_trajectory_240 = [ + t.position.map(f32::to_bits), + t.velocity.map(f32::to_bits), + t.acceleration.map(f32::to_bits), + [t.scalar_48.to_bits(), t.word_52, t.word_56, t.word_60], + ]; + + physical.vector_160 = out.locked_trajectory_velocity_160.map(f32::to_bits); + physical.use_air_reckoning_452 = u8::from(out.locked_trajectory_velocity_valid_452); + physical.scalar_184 = out.time_until_collision_184; + physical.landing_normal_144 = out.landing_normal_copy_144.map(f32::to_bits); + physical.jump_height_200 = out.jump_height_200; + physical.reached_apex_436 = u8::from(out.reached_apex_436); +} diff --git a/crates/skate-game/src/physics/known_air/runtime.rs b/crates/skate-game/src/physics/known_air/runtime.rs new file mode 100644 index 00000000..b43b66a5 --- /dev/null +++ b/crates/skate-game/src/physics/known_air/runtime.rs @@ -0,0 +1,364 @@ +//! Concrete KnownAir boundary. All callbacks mutate the same live owners. +//! Fallible host queries/pose checks retain their first error for the phase caller. +use super::{GamePhysics, SkaterRuntime, math::*}; +use crate::physics::{ + footplant::FootplantFrame, input_phase, skeleton_input_runtime::SkeletonOwners, + wipeout::Observations, +}; +use skate_core::{ + air::{ + known::*, + trajectory::{Selection, Trajectory}, + }, + physics::board_toolkit::BoardToolkit, +}; +pub(super) struct Runtime<'a> { + pub physics: &'a mut GamePhysics, + pub skater: &'a mut SkaterRuntime, + pub toolkit: BoardToolkit, + selection: Selection, + local_com: V, + board_position: V, + error: Option, +} +impl<'a> Runtime<'a> { + pub fn new( + physics: &'a mut GamePhysics, + skater: &'a mut SkaterRuntime, + ) -> Result { + let selector = &skater.trajectory.selector; + let selection = *selector + .selection() + .ok_or("KnownAir requires its actual winning query result")?; + let local_com = selector + .local_com_position() + .ok_or("KnownAir requires selector local COM")?; + let board_position = selector + .board_position() + .ok_or("KnownAir requires selector board position")?; + let toolkit = skater + .player_input + .toolkit + .ok_or("KnownAir requires prepared board toolkit")?; + Ok(Self { + physics, + skater, + toolkit, + selection, + local_com, + board_position, + error: None, + }) + } + pub fn finish(self) -> Result<(), String> { + match self.error { + Some(e) => Err(e), + None => Ok(()), + } + } + fn record(&mut self, result: Result<(), String>) { + if self.error.is_none() { + self.error = result.err(); + } + } +} +pub(super) fn trajectory(t: Trajectory) -> KnownAirTrajectory { + //82E09A14/2C splats the request horizon into all four final-vector lanes. + //KnownAir Fill copies all64 bytes, including the three repeated words. + KnownAirTrajectory { + position: t.position, + velocity: t.velocity, + acceleration: t.acceleration, + scalar_48: t.duration, + word_52: t.duration.to_bits(), + word_56: t.duration.to_bits(), + word_60: t.duration.to_bits(), + } +} +fn actual(t: &KnownAirTrajectory) -> Trajectory { + Trajectory { + position: t.position, + velocity: t.velocity, + acceleration: t.acceleration, + duration: t.scalar_48, + } +} +impl KnownAirRuntime for Runtime<'_> { + fn enable_board_angular_drive_only(&mut self) { + self.physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut self.skater.ground_lifecycle.board_animated_290); + } + fn set_air_collision_update_enabled(&mut self, v: bool) { + self.skater.ground_lifecycle.skeleton_controller.flag_18 = v; + } + fn set_skeleton_collision_state(&mut self, state: u32) { + let result = self + .skater + .ground_lifecycle + .skeleton_controller + .request(state, &mut self.skater.skeleton_collision); + self.record(result); + } + fn set_skeleton_physics_truck_tilt_enabled(&mut self, v: bool) { + self.skater.ground_lifecycle.skeleton_elapsed_16505 = v; + } + fn set_skeleton_inverse_kinematics_enabled(&mut self, v: bool) { + self.skater.foot_ik.enable_feet(v); + } + fn reset_reckoning_flipping(&mut self) { + self.reset_flip(); + } + fn set_footplant_flag_240(&mut self, v: bool) { + self.skater.footplant.enabled = v; + } + fn reset_footplants(&mut self) { + self.skater.footplant.reset(); + } + fn board_transform_height(&mut self) -> f32 { + self.physics.board.part_transforms()[6].translation.y + } + fn skeleton_com_position(&mut self) -> V { + self.skater.animated_skeleton.board_frames.centre_of_mass + } + fn selector_targeting_grind(&self) -> bool { + self.skater.trajectory.selector.grind_locked_to_middle() + } + fn start_grind_air_adjust_from_selector(&mut self) { + //The host BoardWorld has an explicitly empty edge collection. Its selector + //cannot produce9653; do not manufacture the required2944/2960/edge9568 data. + self.record(Err( + "KnownAir received a grind selection from an edge-free world".into(), + )); + } + fn selector_closest_trajectory_point(&mut self, com: V, zero: V) -> (i32, V) { + let t = self.selection.com_trajectory; + let mut best = 100000000.0; + let mut offset = zero; + for i in 0..100 { + let d = sub(com, t.position_at(i as f32 * f32::from_bits(0x3c888889))); + let squared = dot(d, d); + if squared > best { + return (i - 1, offset); + } + best = squared; + offset = d; + } + (0, zero) + } + fn selected_prediction(&self) -> KnownAirPrediction { + KnownAirPrediction { + trajectory: trajectory(self.selection.prediction.request.trajectory), + collision_time_48: self.selection.prediction.result.contact_time, + collision_frame_128: self.selection.prediction.result.contact_frame, + } + } + fn selector_trajectory_2704(&self) -> KnownAirTrajectory { + trajectory(self.selection.com_trajectory) + } + fn selector_contact_position(&mut self) -> V { + self.selection.prediction.result.contact_position + } + fn selector_vector_2848(&self) -> V { + self.board_position + } + fn selector_com_position_2784(&self) -> V { + self.local_com + } + fn highest_trajectory_position(&mut self, t: &KnownAirTrajectory) -> (V, f32) { + actual(t).highest_position() + } + fn selector_has_just_changed(&self) -> bool { + self.skater.trajectory.selector.just_changed() + } + fn reset_trajectory_selector(&mut self) { + self.skater.trajectory.selector.reset(); + } + fn begin_reckoning_body_flip(&mut self, side: bool) { + self.begin_flip(side); + } + fn update_air_collision(&mut self) { + let p = &self.skater.player_input.processed; + let result = self.skater.ground_lifecycle.skeleton_controller.update_air( + p.flags_2472, + f32::from_bits(p.vectors_400_416[0][1]), + &mut self.skater.skeleton_collision, + ); + self.record(result); + } + fn grind_air_adjust_started(&self) -> bool { + self.skater.skeleton_input.grind_air_started + } + fn set_grind_air_adjust_started(&mut self, v: bool) { + self.skater.skeleton_input.grind_air_started = v; + } + fn set_grind_air_adjust_activated(&mut self, v: bool) { + self.skater.skeleton_input.grind_air_active = v; + } + fn grind_air_adjust_adjusting(&self) -> bool { + self.skater.skeleton_input.grind_air_adjusting + } + fn disable_grind_foot_collision(&mut self, body: GrindFootBody) { + let part = match body { + GrindFootBody::Body3152 => 15, + GrindFootBody::Body3156 => 16, + GrindFootBody::Body3160 => 17, + GrindFootBody::Body3168 => 19, + GrindFootBody::Body3172 => 20, + GrindFootBody::Body3176 => 21, + }; + let collision = &mut self.skater.skeleton_collision; + collision.pending_reenable = true; + collision.disable_count[part] = 2; + collision.parts[part].enabled = false; + } + fn update_reckoning_air_states(&mut self, n: V, blend: f32, spin: f32, flip: f32) { + let result = self + .skater + .air_reckoning + .update( + &mut self.physics.riding, + &self.skater.player_input.processed, + self.skater.animation_input.extra.physical_body_spin, + n, + blend, + spin, + flip, + ) + .map(|_| ()); + self.record(result); + } + fn update_known_air_skeleton(&mut self, target: V) { + let collision = input_phase::collision(self.skater); + let s = &mut self.skater; + let mut owners = SkeletonOwners { + animated: &mut s.animated_skeleton, + body: &mut s.skeleton, + drives: &mut s.skeleton_drives, + ik: &mut s.foot_ik, + animation_input: &mut s.animation_input, + correction: &mut s.skeleton_output.correction, + pose_errors: &mut s.pose_errors, + }; + let packet = &s.animation.packet; + let result = s + .skeleton_input + .update_known_air( + &mut s.skeleton_air, + &mut self.physics.board, + &self.physics.riding.reckoning_frames.system, + target, + packet, + &mut s.player_input.processed, + &mut owners, + &packet.hierarchy, + &collision, + self.physics.settings.step.simulation, + ) + .map(|_| ()); + self.record(result); + } + fn set_skeleton_add_skateboard_error(&mut self, v: bool) { + self.skater.ground_lifecycle.skeleton_ground_16388 = v; + } + fn set_head_tracking_target(&mut self, target: V, valid: bool) { + self.skater.skeleton_input.head_tracking_history[5] = target; + self.skater.skeleton_input.head_tracking_active = valid; + } + fn update_board_steering_tilt(&mut self, tilt: f32) { + let p = &self.skater.player_input.processed; + self.skater.ground.steering.update( + tilt, + self.skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + } + fn shift_footplant_trajectory_to_index(&mut self, t: &mut KnownAirTrajectory, index: i32) { + let source = actual(t); + let time = index as f32 * f32::from_bits(0x3c888889); + t.position = source.position_at(time); + t.velocity = source.velocity_at(time); + } + fn update_footplant_prediction(&mut self, input: &KnownAirFootplantInput) { + let g = self.physics.settings.step.simulation.gravity_acceleration; + let f = FootplantFrame { + processed: &self.skater.player_input.processed, + toolkit: &self.toolkit, + animated: &self.skater.animated_skeleton, + body: &self.skater.skeleton, + gravity: [g.x, g.y, g.z, 0.0], + world: &self.physics.world, + }; + self.skater.footplant.update_candidate(input, &f); + } + fn update_footplant_lock(&mut self, input: &KnownAirFootplantInput) { + self.skater.footplant.update_launch(input); + } + fn update_footplant_pose(&mut self, input: &KnownAirFootplantInput) { + let g = self.physics.settings.step.simulation.gravity_acceleration; + let f = FootplantFrame { + processed: &self.skater.player_input.processed, + toolkit: &self.toolkit, + animated: &self.skater.animated_skeleton, + body: &self.skater.skeleton, + gravity: [g.x, g.y, g.z, 0.0], + world: &self.physics.world, + }; + let result = self.skater.footplant.consume_and_submit( + input, + &f, + &mut self.skater.foot_ik, + &mut self.skater.skeleton_collision, + ); + self.record(result); + } + fn board_body_velocity(&mut self) -> V { + let v = self.physics.board.bodies()[6].rates.linear_velocity; + [v.x, v.y, v.z, 0.0] + } + fn set_board_velocity(&mut self, v: V) { + for body in self.physics.board.bodies_mut() { + body.rates.linear_velocity = xyz(v); + } + } + fn board_forward_axis(&mut self) -> V { + let part = self.physics.board.part_transforms()[6]; + let c = part.basis.columns[2]; + scale( + [c[0], c[1], c[2], 0.0], + if self.skater.player_input.processed.flags_2468 & (1 << 20) != 0 { + -1.0 + } else { + 1.0 + }, + ) + } + fn check_for_air_wipeout(&mut self, use_com: bool) { + let s = &mut self.skater; + let observations = Observations { + processed: &s.player_input.processed, + board: &self.physics.riding.ground, + collision: &s.collision_feedback, + deck: crate::physics::solve::deck_frame(&self.physics.board), + input_board: s.animated_skeleton.board_frames.animation_target, + world_to_animation: s.animated_skeleton.roots.world_to_animation, + pose_error: s.collision_pose_error, + maximum_pose_error: s.collision_maximum_error, + jump_fix_frames: s.player_state.post.jump_fix_frames, + air: &s.air_reckoning.state, + system_up_y: self.physics.riding.reckoning_frames.system[1][1], + grind_locked_to_middle: s.trajectory.selector.grind_locked_to_middle(), + grind_normal: s.trajectory.selector.grind_normal(), + }; + let result = s.wipeout.check_air(&observations, use_com); + self.record(result); + } + fn reckoning_com_transform_816(&self) -> AffineTransform { + AffineTransform { + vectors: self.physics.riding.reckoning_frames.system, + } + } +} diff --git a/crates/skate-game/src/physics/known_air/settings.rs b/crates/skate-game/src/physics/known_air/settings.rs new file mode 100644 index 00000000..dde0dea5 --- /dev/null +++ b/crates/skate-game/src/physics/known_air/settings.rs @@ -0,0 +1,50 @@ +//! Original KnownAir82D35508/82D35F40/82D360B0/82D36650 stock reads. +use skate_core::{ + air::known::{KnownAirModeSettings, KnownAirSettings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +pub(super) fn load( + data: &Collections, +) -> Result<(KnownAirSettings, [KnownAirModeSettings; 5]), String> { + let scalar = |name| data.float("physics_airstates", "default", name); + let graph = |name| -> Result, String> { + let values = data + .words::<20>("physics_airstates", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: values[4..12].try_into().unwrap(), + y: values[12..20].try_into().unwrap(), + }) + }; + let settings = KnownAirSettings { + max_heading_adjust_vs_up_y_160: graph("MaxHeadingAdjustVsUpY")?, + landing_speed_scalar_vs_ground_normal_y_240: graph("LandingSpeedScalarVsGroundNormalY")?, + flip_start_collision_time_vs_normal_y: graph("Hash_AA79C93673533C5B")?, + trajectory_error_blend_away_time_384: scalar("TrajErrorBlendAwayTime")?, + min_target_heading_velocity_420: scalar("MinTargetHeadingVel")?, + min_auto_body_speed_424: scalar("MinAutoBodySpeed")?, + max_spin_speed_428: scalar("MaxSpinSpeed")?, + frames_for_grind_air_assist_436: scalar("FramesForGrindAirAssist")?, + body_flip_min_grab_time_fraction_456: scalar("BodyFlipMinGrabTimeFraction")?, + flip_scalar: data.float("physics_reckoning", "default", "FlipScalar")?, + }; + let mut modes = Vec::with_capacity(5); + for key in ["easy", "normal", "hardcore", "motorized", "test"] { + modes.push(KnownAirModeSettings { + upside_down_falling_wipeout_enabled_2: data.boolean( + "physics_mode", + key, + "Hash_2B913C786BEFF4CD", + )?, + perfect_body_flips_28: data.boolean("physics_mode", key, "PerfectBodyFlips")?, + body_spin_speed_limit_56: data.float("physics_mode", key, "MaxAutoBodySpinSpeed")?, + }); + } + Ok(( + settings, + modes + .try_into() + .map_err(|_| "Expected five physics modes")?, + )) +} diff --git a/crates/skate-game/src/physics/landing_quality.rs b/crates/skate-game/src/physics/landing_quality.rs new file mode 100644 index 00000000..11b7657f --- /dev/null +++ b/crates/skate-game/src/physics/landing_quality.rs @@ -0,0 +1,17 @@ +//! Stock PhysicsAnimation cache +560/+576 and +592/+608, TU3 82DE6480. +use skate_core::{animation::landing_quality::Settings, point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub(crate) fn load(data: &Collections) -> Result { + let graph = |name| -> Result, String> { + let words = data.words::<8>("physics_animation", "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[i])), + y: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + }) + }; + Ok(Settings { + twist_spin: graph("LandingSketchyTwistSpin")?, + side_speed: graph("LandingSketchySideSpeed")?, + }) +} diff --git a/crates/skate-game/src/physics/offboard/ground_query.rs b/crates/skate-game/src/physics/offboard/ground_query.rs new file mode 100644 index 00000000..74038c2d --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query.rs @@ -0,0 +1,83 @@ +//! Borrowed scene adapter for original Biped query82764AB0/82770650/82C1EAD8. +//! All geometry and live transforms belong to the canonical collision scene. +mod edges; +mod lines; +mod transform; +mod world; +pub use world::with_world_scene; +use skate_core::{ + math::Vector3, + physics::board_world::WorldTriangle, + player::offboard::ground_query::{ + Edge, EdgeSearch, Frame, GroundQueryPacket, GroundQueryScene, LineHit, + }, +}; +#[derive(Clone, Copy, Debug)] +pub struct Bounds { + pub min: Vector3, + pub max: Vector3, +} +/// A borrowed mesh from one canonical source pool, in canonical mesh order. +pub struct Mesh<'a> { + pub local_to_world: Frame, + pub world_to_local: Frame, + pub local_bounds: Bounds, + pub matching_group: i32, + pub triangles: &'a [WorldTriangle], + /// Exact packed source surface, one entry per triangle; never inferred from tag. + pub surfaces: &'a [u16], +} +#[derive(Clone, Copy)] +pub struct Segment { + pub edge: Edge, + pub local_bounds: Bounds, +} +pub struct EdgeBody<'a> { + pub local_to_world: Frame, + pub local_bounds: Bounds, + pub segments: &'a [Segment], +} +pub struct AlternateRecord<'a> { + pub enabled: bool, + /// Original record44 and48, selected by context2948 bit1. + pub choices: [Option<(&'a EdgeBody<'a>, i32)>; 2], +} +pub enum PrimaryEdges<'a> { + /// Original826C0630: dynamic provider, then vehicle provider. + Normal { + dynamic: &'a [EdgeBody<'a>], + vehicles: &'a [EdgeBody<'a>], + }, + Alternate(&'a [AlternateRecord<'a>]), +} +pub struct IndexedEdgeBody<'a> { + pub id: u32, + pub disabled: bool, + pub body: &'a EdgeBody<'a>, +} +/// Ephemeral view only: owns no collision geometry, bodies, or cached observations. +pub struct Scene<'a> { + pub ground_pool: &'a [Mesh<'a>], + pub island_pool: &'a [Mesh<'a>], + pub conditional_pool: &'a [Mesh<'a>], + pub island_flags: u32, + pub static_edges: &'a [Segment], + pub primary_edges: PrimaryEdges<'a>, + pub indexed_edges: &'a [IndexedEdgeBody<'a>], +} +impl GroundQueryScene for Scene<'_> { + type Error = &'static str; + fn edge_candidates(&mut self, search: &EdgeSearch) -> Result, Self::Error> { + Ok(edges::candidates(self, search)) + } + fn query_lines( + &mut self, + packet: &GroundQueryPacket, + ) -> Result<[Option; 7], Self::Error> { + lines::query(self, packet) + } +} + +#[cfg(test)] +mod tests; + diff --git a/crates/skate-game/src/physics/offboard/ground_query/edges.rs b/crates/skate-game/src/physics/offboard/ground_query/edges.rs new file mode 100644 index 00000000..80f43386 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/edges.rs @@ -0,0 +1,71 @@ +use super::{Bounds, EdgeBody, PrimaryEdges, Scene, Segment, transform}; +use skate_core::player::offboard::ground_query::{Edge, EdgeSearch, Frame}; +fn append(output: &mut Vec, segments: &[Segment], frame: Frame, query: Bounds) { + for segment in segments { + if output.len() == 40 { + break; + } + if transform::overlaps(transform::bounds(frame, segment.local_bounds), query) { + output.push(Edge { + start: transform::point(frame, segment.edge.start), + end: transform::point(frame, segment.edge.end), + }); + } + } +} +fn body(output: &mut Vec, body: &EdgeBody<'_>, query: Bounds) { + if transform::overlaps( + transform::bounds(body.local_to_world, body.local_bounds), + query, + ) { + append(output, body.segments, body.local_to_world, query); + } +} +pub(super) fn candidates(scene: &Scene<'_>, search: &EdgeSearch) -> Vec { + let query = Bounds { + min: search.min, + max: search.max, + }; + let mut output = Vec::with_capacity(40); + append(&mut output, scene.static_edges, Frame::IDENTITY, query); + match &scene.primary_edges { + PrimaryEdges::Normal { dynamic, vehicles } => { + let center = transform::scale(transform::add(query.min, query.max), 0.5); + for provider in [*dynamic, *vehicles] { + for entry in provider { + let d = transform::sub(entry.local_to_world.position, center); + let squared = d.z.mul_add(d.z, d.y.mul_add(d.y, d.x * d.x)); + if squared >= 225. { + continue; + } + //82C4C4E0 checks individual segment bounds, no asset bound gate. + append(&mut output, entry.segments, entry.local_to_world, query); + if output.len() == 40 { + break; + } + } + } + } + PrimaryEdges::Alternate(records) => { + for record in *records { + if !record.enabled { + continue; + } + let choice = usize::from(search.context.selection_flags_2948 & 2 != 0); + if let Some((entry, group)) = record.choices[choice] { + if transform::matches(search.context.matching_id_2952, group) { + body(&mut output, entry, query); + } + } + } + } + } + let mut ordered: Vec<_> = scene.indexed_edges.iter().collect(); + ordered.sort_by_key(|entry| entry.id); + for entry in ordered { + if !entry.disabled { + body(&mut output, entry.body, query); + } + } + output +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/lines.rs b/crates/skate-game/src/physics/offboard/ground_query/lines.rs new file mode 100644 index 00000000..d1b7478c --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/lines.rs @@ -0,0 +1,99 @@ +use super::{ + Bounds, Scene, + transform::{self, sub}, +}; +use skate_core::{ + math::Vector3, + physics::triangle_query::{TriangleLineHit, triangle_segment}, + player::offboard::ground_query::{GroundQueryPacket, LineHit}, +}; +pub(super) fn query( + scene: &Scene<'_>, + packet: &GroundQueryPacket, +) -> Result<[Option; 7], &'static str> { + let pools = [ + scene.ground_pool, + scene.island_pool, + if scene.island_flags == 3 { + scene.conditional_pool + } else { + &[] + }, + ]; + for pool in pools { + for mesh in pool { + if mesh.triangles.len() != mesh.surfaces.len() { + return Err("Biped query mesh lacks exact per-triangle packed surfaces"); + } + } + } + let mut output = [None; 7]; + for (index, line) in packet.lines.iter().enumerate() { + if !line.radius.is_finite() || line.radius < 0. { + return Err("Invalid Biped line radius"); + } + let delta = sub(line.end, line.start); + let threshold = f32::from_bits(0x37800000); + if !(delta.x.abs() > threshold || delta.y.abs() > threshold || delta.z.abs() > threshold) { + continue; + } + let low = |a: f32, b: f32| a.min(b) - line.radius; + let high = |a: f32, b: f32| a.max(b) + line.radius; + let bounds = Bounds { + min: Vector3::new( + low(line.start.x, line.end.x), + low(line.start.y, line.end.y), + low(line.start.z, line.end.z), + ), + max: Vector3::new( + high(line.start.x, line.end.x), + high(line.start.y, line.end.y), + high(line.start.z, line.end.z), + ), + }; + let mut nearest = f32::MAX; + for pool in pools { + for mesh in pool { + if !transform::matches(packet.context.matching_id_2952, mesh.matching_group) + || !transform::overlaps( + transform::bounds(mesh.world_to_local, bounds), + mesh.local_bounds, + ) + { + continue; + } + for (triangle, &packed_surface) in mesh.triangles.iter().zip(mesh.surfaces) { + let vertices = triangle + .triangle + .vertices + .map(|p| transform::point(mesh.local_to_world, p)); + let mut hit = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0., + volume_parameter: [0.; 3], + }; + if triangle_segment(&mut hit, line.start, delta, vertices, line.radius, 0.) { + // Native outer leaf uses fsel, including its unordered choice. + let lower = if -hit.fraction >= 0. { + 0. + } else { + hit.fraction + }; + let fraction = if 1. - lower >= 0. { lower } else { 1. }; + if fraction < nearest { + nearest = fraction; + output[index] = Some(LineHit { + position: hit.position, + face_normal: transform::face(vertices), + fraction, + packed_surface, + }); + } + } + } + } + } + } + Ok(output) +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/tests.rs b/crates/skate-game/src/physics/offboard/ground_query/tests.rs new file mode 100644 index 00000000..143bb48d --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/tests.rs @@ -0,0 +1,132 @@ +use super::*; +use skate_core::player::offboard::ground_query::QueryContext; +#[test] +fn indexed_edges_sort_ids_and_preserve_shared_capacity() { + let bounds = Bounds { + min: Vector3::new(-1., -1., -1.), + max: Vector3::new(1., 1., 1.), + }; + let segments = [Segment { + edge: Edge { + start: Vector3::ZERO, + end: Vector3::new(1., 0., 0.), + }, + local_bounds: bounds, + }]; + let first = EdgeBody { + local_to_world: Frame::IDENTITY, + local_bounds: bounds, + segments: &segments, + }; + let second = EdgeBody { + local_to_world: Frame { + position: Vector3::new(0., 0.25, 0.), + ..Frame::IDENTITY + }, + local_bounds: bounds, + segments: &segments, + }; + let entries = [ + IndexedEdgeBody { + id: 9, + disabled: false, + body: &first, + }, + IndexedEdgeBody { + id: 2, + disabled: false, + body: &second, + }, + ]; + let static_edges = vec![segments[0]; 39]; + let mut scene = Scene { + ground_pool: &[], + island_pool: &[], + conditional_pool: &[], + island_flags: 0, + static_edges: &static_edges, + primary_edges: PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }, + indexed_edges: &entries, + }; + let search = EdgeSearch { + min: bounds.min, + max: bounds.max, + frame: Frame::IDENTITY, + context: QueryContext { + selection_flags_2948: 0, + matching_id_2952: -1, + }, + narrow_forward: false, + }; + let result = scene.edge_candidates(&search).unwrap(); + assert_eq!(result.len(), 40); + assert_eq!(result[39].start.y, 0.25); +} +#[test] +fn decoded_face_uses_world_vertices_and_inclusive_bounds() { + let face = transform::face([ + Vector3::ZERO, + Vector3::new(0., 0., 2.), + Vector3::new(3., 0., 0.), + ]); + assert!((face.y - 1.).abs() < 1e-6); + let a = Bounds { + min: Vector3::ZERO, + max: Vector3::new(1., 1., 1.), + }; + let b = Bounds { + min: Vector3::new(1., 1., 1.), + max: Vector3::new(2., 2., 2.), + }; + assert!(transform::overlaps(a, b)); + assert!(transform::matches(-1, 3)); + assert!(!transform::matches(2, 3)); +} + +fn triangle(y:f32)->WorldTriangle { + use skate_core::physics::contact::RetailContactMaterial; + WorldTriangle::from_vertices([Vector3::new(-2.,y,-2.),Vector3::new(-2.,y,2.),Vector3::new(2.,y,-2.)], + RetailContactMaterial {static_friction:0.,dynamic_friction:0.,restitution:0.},123,0,[0.;3],0.).unwrap() +} +fn packet()->GroundQueryPacket { + use skate_core::player::offboard::ground_query::Line; + GroundQueryPacket {context:QueryContext {selection_flags_2948:0,matching_id_2952:7},center:Vector3::ZERO, + up:Vector3::new(0.,1.,0.),tangent:Vector3::new(0.,0.,1.), + lines:[Line {start:Vector3::new(-0.5,2.,-0.5),end:Vector3::new(-0.5,-2.,-0.5),radius:0.};7]} +} +fn mesh<'a>(triangles:&'a[WorldTriangle],surfaces:&'a[u16],matching_group:i32)->Mesh<'a> { + Mesh {triangles,surfaces,matching_group,local_to_world:Frame::IDENTITY,world_to_local:Frame::IDENTITY, + local_bounds:Bounds {min:Vector3::new(-2.,-2.,-2.),max:Vector3::new(2.,2.,2.)}} +} +#[test] +fn actual_triangle_queries_preserve_pool_ties_group_filter_and_conditional_gate() { + let triangles=[triangle(0.)];let upper=[triangle(1.)]; + let ground=[mesh(&triangles,&[11],7)];let island=[mesh(&triangles,&[22],-1)]; + let conditional=[mesh(&upper,&[33],7)]; + let mut scene=Scene {ground_pool:&ground,island_pool:&island,conditional_pool:&conditional,island_flags:0, + static_edges:&[],primary_edges:PrimaryEdges::Normal {dynamic:&[],vehicles:&[]},indexed_edges:&[]}; + let p=packet();let hit=scene.query_lines(&p).unwrap()[0].unwrap(); + assert_eq!(hit.packed_surface,11);assert_eq!(hit.fraction,0.5);assert!(hit.face_normal.y>0.99); + let mut different=p;different.context.matching_id_2952=8; + assert_eq!(scene.query_lines(&different).unwrap()[0].unwrap().packed_surface,22); + scene.island_flags=3; + assert_eq!(scene.query_lines(&p).unwrap()[0].unwrap().packed_surface,33); +} +#[test] +fn canonical_world_bridge_requires_metadata_and_queries_authored_surfaces() { + use skate_core::physics::{board_world::{BoardWorld,query_metadata::{Bounds as WorldBounds,QueryMesh,QueryMetadata,QueryPool}},drive_frames::RetailAffineTransform}; + let unannotated=BoardWorld::new(vec![triangle(0.)]); + let empty=||PrimaryEdges::Normal {dynamic:&[],vehicles:&[]}; + assert!(with_world_scene(&unannotated,empty(),&[],|scene|scene.query_lines(&packet())).is_err()); + let world=BoardWorld::with_query_metadata(vec![triangle(0.)],QueryMetadata { + packed_surfaces:vec![0x400],island_flags:0,static_edges:vec![],meshes:vec![QueryMesh { + triangle_range:0..1,local_to_world:RetailAffineTransform::IDENTITY,world_to_local:RetailAffineTransform::IDENTITY, + local_bounds:WorldBounds {min:Vector3::new(-2.,0.,-2.),max:Vector3::new(2.,0.,2.)},matching_group:7,pool:QueryPool::Ground, + }], + }).unwrap(); + let hits=with_world_scene(&world,empty(),&[],|scene|scene.query_lines(&packet())).unwrap(); + assert_eq!(hits[0].unwrap().packed_surface,0x400); +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/transform.rs b/crates/skate-game/src/physics/offboard/ground_query/transform.rs new file mode 100644 index 00000000..b966b594 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/transform.rs @@ -0,0 +1,66 @@ +use super::Bounds; +use skate_core::{math::Vector3, player::offboard::ground_query::Frame}; +pub(super) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +pub(super) fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn scale(a: Vector3, s: f32) -> Vector3 { + Vector3::new(a.x * s, a.y * s, a.z * s) +} +fn madd(a: Vector3, s: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(s, b.x), + a.y.mul_add(s, b.y), + a.z.mul_add(s, b.z), + ) +} +pub(super) fn point(f: Frame, p: Vector3) -> Vector3 { + madd( + f.forward, + p.z, + madd(f.up, p.y, madd(f.right, p.x, f.position)), + ) +} +pub(super) fn bounds(f: Frame, b: Bounds) -> Bounds { + let c = point(f, scale(add(b.max, b.min), 0.5)); + let e = scale(sub(b.max, b.min), 0.5); + let abs = |v: Vector3| Vector3::new(v.x.abs(), v.y.abs(), v.z.abs()); + let e = madd( + abs(f.forward), + e.z, + madd(abs(f.up), e.y, scale(abs(f.right), e.x)), + ); + Bounds { + min: sub(c, e), + max: add(c, e), + } +} +pub(super) fn overlaps(a: Bounds, b: Bounds) -> bool { + a.min.x <= b.max.x + && a.max.x >= b.min.x + && a.min.y <= b.max.y + && a.max.y >= b.min.y + && a.min.z <= b.max.z + && a.max.z >= b.min.z +} +pub(super) fn matches(a: i32, b: i32) -> bool { + a == -1 || b == -1 || a == b +} +//82ACA4E0..550: face is formed from decoded WORLD vertices and refined twice. +pub(super) fn face([a, b, c]: [Vector3; 3]) -> Vector3 { + let u = sub(b, a); + let v = sub(c, a); + let n = Vector3::new( + (-u.z).mul_add(v.y, u.y * v.z), + (-u.x).mul_add(v.z, u.z * v.x), + (-u.y).mul_add(v.x, u.x * v.y), + ); + let q = n.z.mul_add(n.z, n.y.mul_add(n.y, n.x * n.x)); + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + scale(n, r) +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/world.rs b/crates/skate-game/src/physics/offboard/ground_query/world.rs new file mode 100644 index 00000000..bf07042e --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/world.rs @@ -0,0 +1,36 @@ +//! Ephemeral adapter over the canonical BoardWorld metadata. Geometry is borrowed. +use super::{Bounds,Frame,Mesh,PrimaryEdges,Scene,Segment,IndexedEdgeBody}; +use skate_core::{math::Vector3,physics::{board_world::{BoardWorld,query_metadata::QueryPool},drive_frames::RetailAffineTransform},player::offboard::ground_query::Edge}; + +/// Caller supplies actual dynamic-provider views from the same live scene owner. +/// Empty providers mean the authored scene actually has none, never a failed lookup. +pub fn with_world_scene( + world:&BoardWorld, + primary_edges:PrimaryEdges<'_>, + indexed_edges:&[IndexedEdgeBody<'_>], + operation:impl FnOnce(&mut Scene<'_>)->Result, +)->Result { + let metadata=world.query_metadata()?; + let triangles=world.triangles(); + let mut pools:[Vec>;3]=std::array::from_fn(|_|Vec::new()); + for source in &metadata.meshes { + let index=match source.pool {QueryPool::Ground=>0,QueryPool::Island=>1,QueryPool::Conditional=>2}; + pools[index].push(Mesh { + local_to_world:frame(source.local_to_world),world_to_local:frame(source.world_to_local), + local_bounds:Bounds {min:source.local_bounds.min,max:source.local_bounds.max}, + matching_group:source.matching_group, + triangles:&triangles[source.triangle_range.clone()], + surfaces:&metadata.packed_surfaces[source.triangle_range.clone()], + }); + } + let edges:Vec<_>=metadata.static_edges.iter().map(|s|Segment { + edge:Edge {start:s.start,end:s.end},local_bounds:Bounds {min:s.local_bounds.min,max:s.local_bounds.max}, + }).collect(); + let mut scene=Scene {ground_pool:&pools[0],island_pool:&pools[1],conditional_pool:&pools[2], + island_flags:metadata.island_flags,static_edges:&edges,primary_edges,indexed_edges}; + operation(&mut scene) +} +fn frame(f:RetailAffineTransform)->Frame { + let v=|a:[f32;3]|Vector3::new(a[0],a[1],a[2]); + Frame {right:v(f.basis.columns[0]),up:v(f.basis.columns[1]),forward:v(f.basis.columns[2]),position:f.translation} +} diff --git a/crates/skate-game/src/physics/offboard/mod.rs b/crates/skate-game/src/physics/offboard/mod.rs new file mode 100644 index 00000000..ced539f5 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/mod.rs @@ -0,0 +1,3 @@ +//! Off-board game adapters over the shared physical and scene owners. +pub(crate) mod ground_query; +pub(crate) mod pose_adjust; diff --git a/crates/skate-game/src/physics/offboard/pose_adjust.rs b/crates/skate-game/src/physics/offboard/pose_adjust.rs new file mode 100644 index 00000000..6b660786 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/pose_adjust.rs @@ -0,0 +1,30 @@ +//! Original82BD8F10 writes the existing AnimatedSkeleton board-offset owner. +use crate::physics::animated_skeleton::AnimatedSkeleton; +use skate_core::{ + physics::skeleton_animation_record::AnimationPartTransform, + player::{input_phase::ProcessedPhysicsInput, offboard::pose_adjust}, +}; +/// Reparented indices are the existing target_bones[2] and[3] loaded by82BED688. +/// No state is changed when category500/state503 eligibility is false. +pub(crate) fn update( + animated: &mut AnimatedSkeleton, + globals: &[AnimationPartTransform], + reparented_hands: [usize; 2], + processed: &ProcessedPhysicsInput, +) -> Result<(), String> { + if processed.category_2512 != 500 || processed.state_2508 == 503 { + return Ok(()); + } + let hand = pose_adjust::selected_hand(processed.flags_2476); + let actual_index = animated.bone_indices[[3, 7][hand]]; + let actual = globals + .get(actual_index) + .ok_or("Off-board pose missing actual animation hand")?; + let reparented = globals + .get(reparented_hands[hand]) + .ok_or("Off-board pose missing board-parented animation hand")?; + animated + .board_offset + .refresh_transform(pose_adjust::adjustment(actual, reparented)); + Ok(()) +} diff --git a/crates/skate-game/src/physics/offboard/settings.rs b/crates/skate-game/src/physics/offboard/settings.rs new file mode 100644 index 00000000..7d6458c4 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings.rs @@ -0,0 +1,67 @@ +//! Stock Biped settings82D7B630 and metric queries82D7AFD8/82D16680. +mod board; +mod curves; +mod metrics; +#[cfg(test)] +mod tests; +use skate_core::{ + player::offboard::{controller, movement_intent, movement_velocity}, + point_graph::PointGraph, +}; +use skate_data::{animation_metadata::AnimationMetadata, collections::Collections}; + +pub(crate) struct Settings { + pub controller: controller::Settings, + pub board: skate_core::player::offboard::ground_sync::BoardSettings, + pub metrics: [Option; 3], + ///82D310F8 full key DF759B46440F16E9. + pub movement_vs_stick_angle: PointGraph<8>, + ///82D310F8 full key2DD95B399BAE313E. + pub turn_vs_stick_angle: PointGraph<8>, +} +impl Settings { + /// Share the same native ABIN metadata loaded for SkaterAnimation. Required + /// stock fields/banks/clips fail loading; an absent matching attribute is None. + pub(crate) fn load(data: &Collections, metadata: &AnimationMetadata) -> Result { + let biped = |name| curves::load::<8>(data, "physics_biped", name); + let (sprint_blend, bounds) = biped("Hash_6B93C51256A30FB4")?; + Ok(Self { + controller: controller::Settings { + movement_intent: movement_intent::Settings { + sprint_speed: curves::load::<4>( + data, + "physics_biped", + "Hash_7209DCFDF3015EBF", + )? + .0, + normal_speed: curves::load::<16>(data, "physics_biped", "SpeedVsInput")?.0, + sprint_blend, + sprint_time_cap: bounds[2], + slide_steering: biped("AutoTurnVsAngle")?.0, + }, + movement_velocity: movement_velocity::Settings { + slope_speed_scalar: biped("Hash_31309236050A8F09")?.0, + slope_mode_speed: biped("Hash_CE45C724B30F9134")?.0, + turn_vs_speed: biped("TurnVsSpeed")?.0, + turn_delta_vs_speed: biped("TurnDeltaVsSpeed")?.0, + }, + slide_vs_slope: biped("SlideVsSlope")?.0, + slide_vs_speed: biped("SlideVsSpeed")?.0, + }, + metrics: metrics::load(metadata)?, + board: board::load(data)?, + movement_vs_stick_angle: curves::load::<8>( + data, + "physics_state_offboard", + "Hash_DF759B46440F16E9", + )? + .0, + turn_vs_stick_angle: curves::load::<8>( + data, + "physics_state_offboard", + "TurnVsStickAngle", + )? + .0, + }) + } +} diff --git a/crates/skate-game/src/physics/offboard/settings/board.rs b/crates/skate-game/src/physics/offboard/settings/board.rs new file mode 100644 index 00000000..e73c4fe6 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/board.rs @@ -0,0 +1,31 @@ +//! Ground ctor82D305D8 passes state36 to82B97060, whose full64 class/key +//! are492026E0F096F283/D7EDBD362D7D2152 (physics_state_offboard/default). +//!82B6CC78 stores the returned collection layout at wrapper4 = state40. +use skate_core::player::offboard::ground_sync::BoardSettings; +use skate_data::collections::Collections; +pub(super) fn load(data: &Collections) -> Result { + let float = |name| data.float("physics_state_offboard", "default", name); + let vector = |name| -> Result<[f32; 4], String> { + let field = data.field("physics_state_offboard", "default", name)?; + if field.type_name != "Math::Vector3" { + return Err(format!("{name}: expected stock Vector3")); + } + let value = data + .words::<4>("physics_state_offboard", "default", name)? + .map(f32::from_bits); + if value.iter().any(|v| !v.is_finite()) { + return Err(format!("{name}: non-finite vector")); + } + Ok(value) + }; + Ok(BoardSettings { + extent_0: vector("GrabBoxSizeGrabbing")?, + extent_16: vector("GrabBoxSize")?, + offset_32: vector("GrabBoxOffset")?, + angle_436: float("GrabSplineMaxAngleToHorizontalGrabbing")?, + angle_440: float("GrabSplineMaxAngleToHorizontal")?, + margin_444: float("GrabSplineEndExclusion")?, + angle_452: float("GrabSplineAngleLimitGrabbing")?, + angle_456: float("GrabSplineAngleLimit")?, + }) +} diff --git a/crates/skate-game/src/physics/offboard/settings/curves.rs b/crates/skate-game/src/physics/offboard/settings/curves.rs new file mode 100644 index 00000000..80bc35b8 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/curves.rs @@ -0,0 +1,41 @@ +//! PointNegGraphData has four bounds followed by N X values and N Y values. +use skate_core::point_graph::PointGraph; +use skate_data::collections::Collections; +pub(super) fn load( + data: &Collections, + class: &str, + name: &str, +) -> Result<(PointGraph, [f32; 4]), String> { + let field = data.field(class, "default", name)?; + if field.type_name != format!("Sk8::PointNegGraphData{N}") { + return Err(format!( + "Expected PointNegGraphData{N} at {class}/default/{name}" + )); + } + parse::(&field.data).map_err(|e| format!("{class}/default/{name}: {e}")) +} +pub(super) fn parse(text: &str) -> Result<(PointGraph, [f32; 4]), String> { + let hex: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + if N == 0 || !hex.is_ascii() || hex.len() != (4 + 2 * N) * 8 { + return Err("Invalid native graph payload length".into()); + } + let values = (0..4 + 2 * N) + .map(|i| { + u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16) + .map(f32::from_bits) + .map_err(|e| e.to_string()) + }) + .collect::, _>>()?; + if values.iter().any(|v| !v.is_finite()) { + return Err("Non-finite native graph value".into()); + } + let graph = PointGraph { + x: std::array::from_fn(|i| values[4 + i]), + y: std::array::from_fn(|i| values[4 + N + i]), + }; + // Duplicate authored X knots are valid and retained in source order. + if graph.x.windows(2).any(|p| p[0] > p[1]) { + return Err("Native graph X values are not ordered".into()); + } + Ok((graph, std::array::from_fn(|i| values[i]))) +} diff --git a/crates/skate-game/src/physics/offboard/settings/metrics.rs b/crates/skate-game/src/physics/offboard/settings/metrics.rs new file mode 100644 index 00000000..693cafab --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/metrics.rs @@ -0,0 +1,45 @@ +//!82D16680 uses first matching record, status4, length=frames/fps. +//!82D164F8 multiplies the authored end by that length, including end=-1. +use skate_core::player::offboard::controller::ClipMetric; +use skate_data::animation_metadata::{AnimationMetadata, ClipMetadata}; +const CLIPS: [&str; 3] = ["NB_WALK_FWD_CYC", "NB_RUN_FWD_CYC", "NB_SPRINT_FWD_CYC"]; +pub(super) fn load(metadata: &AnimationMetadata) -> Result<[Option; 3], String> { + let one = |name| -> Result, String> { + let clip = metadata.clip(name)?; + let source = metadata + .source_for(name) + .ok_or_else(|| format!("Missing bank identity for {name}"))?; + if !source.source_bank.eq_ignore_ascii_case("OffBoard.abin") { + return Err(format!("{name}: expected actual OffBoard.abin source")); + } + metric(clip) + }; + Ok([one(CLIPS[0])?, one(CLIPS[1])?, one(CLIPS[2])?]) +} +pub(super) fn metric(clip: &ClipMetadata) -> Result, String> { + let Some(attribute) = clip.attributes.iter().find(|a| a.name == "ANIMTRANSZ") else { + return Ok(None); + }; + // All three actual stock records are scalar. Reject unsupported encodings + // instead of reading curve metadata or an unwritten native payload as a value. + if attribute.type_id != 0 { + return Err(format!( + "{}: AnimTransZ expected scalar stock record", + clip.name + )); + } + let payload = attribute + .payload_words + .first() + .ok_or_else(|| format!("{}: truncated AnimTransZ", clip.name))?; + let translation_z = f32::from_bits(*payload); + let length = f32::from_bits(clip.frames_bits) / f32::from_bits(clip.fps_bits); + let end_time = f32::from_bits(attribute.end_bits) * length; + if !translation_z.is_finite() || !end_time.is_finite() || end_time == 0.0 { + return Err(format!("{}: invalid AnimTransZ metric", clip.name)); + } + Ok(Some(ClipMetric { + translation_z, + end_time, + })) +} diff --git a/crates/skate-game/src/physics/offboard/settings/tests.rs b/crates/skate-game/src/physics/offboard/settings/tests.rs new file mode 100644 index 00000000..df156b07 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/tests.rs @@ -0,0 +1,88 @@ +use super::{Settings, curves, metrics}; +use skate_data::animation_metadata::{ClipAttribute, ClipMetadata}; +fn clip() -> ClipMetadata { + ClipMetadata { + name: "NB_WALK_FWD_CYC".into(), + source_offset: 48, + fps_bits: 60.0f32.to_bits(), + frames_bits: 57.0f32.to_bits(), + base_speed_bits: 3.0f32.to_bits(), + flags_word: 0, + attributes: vec![ClipAttribute { + name: "ANIMTRANSZ".into(), + type_id: 0, + begin_bits: (-1.0f32).to_bits(), + end_bits: (-1.0f32).to_bits(), + payload_words: vec![1.5f32.to_bits()], + source_offset: 128, + }], + } +} +#[test] +fn query_uses_full_frame_duration_and_negative_untimed_end() { + let metric = metrics::metric(&clip()).unwrap().unwrap(); + assert_eq!(metric.end_time, -0.95); + assert_eq!(metric.translation_z, 1.5); + assert!((-metric.translation_z / metric.end_time - 1.5789474).abs() < 1e-6); +} +#[test] +fn first_matching_record_wins_without_a_playback_status_filter() { + let mut clip = clip(); + clip.attributes[0].begin_bits = 0.8f32.to_bits(); + clip.attributes[0].end_bits = 0.5f32.to_bits(); + let mut later = ClipAttribute { + name: "ANIMTRANSZ".into(), + type_id: 0, + begin_bits: 0, + end_bits: 1.0f32.to_bits(), + payload_words: vec![99.0f32.to_bits()], + source_offset: 192, + }; + later.begin_bits = 0; + clip.attributes.push(later); + let metric = metrics::metric(&clip).unwrap().unwrap(); + assert_eq!(metric.translation_z, 1.5); + assert_eq!(metric.end_time, 0.475); +} +#[test] +fn actual_attribute_absence_is_distinct_from_malformed_data() { + let mut clip = clip(); + clip.attributes[0].type_id = 2; + assert!(metrics::metric(&clip).is_err()); + clip.attributes.clear(); + assert!(metrics::metric(&clip).unwrap().is_none()); +} +#[test] +fn graph_bounds_are_separate_and_duplicate_knots_preserve_endpoint_jump() { + let values = [-9.0f32, -8.0, 7.0, 6.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0]; + let hex = values + .iter() + .map(|v| format!("{:08X}", v.to_bits())) + .collect::(); + let (g, bounds) = curves::parse::<3>(&hex).unwrap(); + assert_eq!(bounds, [-9.0, -8.0, 7.0, 6.0]); + assert_eq!(g.evaluate(-0.01), 2.0); + assert_eq!(g.evaluate(0.0), 3.0); + assert_eq!(g.evaluate(0.5), 3.5); + assert_eq!(g.evaluate(1.0), 4.0); + assert!(curves::parse::<4>(&hex).is_err()); +} +#[test] +#[ignore = "requires extracted private stock assets"] +fn actual_stock_settings_and_metrics_load_together() { + let root = + std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT")); + let collections = skate_data::collections::Collections::load(&root).unwrap(); + let metadata = skate_data::animation_banks::AnimationBanks::load(&root) + .unwrap() + .metadata() + .unwrap(); + let settings = Settings::load(&collections, &metadata).unwrap(); + assert!(settings.metrics.iter().all(Option::is_some)); + let speeds = settings.metrics.map(|m| { + let m = m.unwrap(); + (-1.0 / m.end_time) * m.translation_z + }); + assert!(speeds[0] > 0.0 && speeds[0] < speeds[1] && speeds[1] < speeds[2]); + assert_eq!(settings.controller.movement_intent.sprint_time_cap, 4.0); +} diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground.rs b/crates/skate-game/src/physics/offboard/skeleton_ground.rs new file mode 100644 index 00000000..e32c1e4a --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground.rs @@ -0,0 +1,81 @@ +//! Original Biped Skeleton update82BDE060 with the existing physical owners. +mod frames; +use crate::physics::{ + skeleton_air::SkeletonAir, + skeleton_input_runtime::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}, +}; +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + board_runtime::BoardRuntime, rigid_body::RetailSimulationStep, + skeleton_animation_record::AnimationPartTransform as Transform, + }, + player::input_phase::ProcessedPhysicsInput, +}; +///Actual Sync input: caller's constructed frame and Biped state1056 COM. +///retained_board_16016 belongs to the canonical SkeletonBoardFrames owner. +pub(crate) struct Input<'a> { + pub world_frame: &'a Transform, + pub centre_of_mass_1056: [f32; 4], +} +///Terminal82D8E3E0 arguments, emitted after GeneralUpdate and trajectory clear. +///The caller invokes the canonical ground_reckoning producer in this callback. +pub(crate) struct ReckoningUpdate { + pub up: [f32; 4], + pub forward: [f32; 4], + pub blend: f32, +} +impl SkeletonInputRuntime { + pub(crate) fn update_biped_ground( + &mut self, + air: &mut SkeletonAir, + board: &mut BoardRuntime, + input: Input<'_>, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + finish_reckoning: F, + ) -> Result + where + F: FnOnce(ReckoningUpdate) -> Result<(), String>, + { + let s = &mut owners.animated; + //82BDEE08 uses animation record6464+16, not the current post-IK drive. + s.board_frames.skate_root = skate_core::physics::skeleton_animation_record::compose_affine( + &s.roots.animation_to_world, + &s.record.pose[0], + ); + //82BDE310 must read the OLD root basis, before82BDE100 replaces it. + s.board_frames.update_com_lift( + &s.roots.animation_to_world, + input.centre_of_mass_1056, + f32::from_bits(0x3e75_c28f), + ); + let target = frames::prepare( + &mut s.roots, + input.world_frame, + &self.drive_frames[0], + &mut s.board_frames.retained_board_16016, + p.flags_2476, + p.flags_2484, + &mut p.flags_2468, + ); + s.board_frames.animation_target = target; + if p.flags_2480 & 0x8000 != 0 || p.flags_2484 & 1 != 0 { + //Reuse the shared Ground/Air history and actual board anchor. + s.board_frames.physical_board = air.apply_board(board, &target, true); + } + self.general_update(p, owners, globals, collision, simulation)?; + owners.animated.finish_ground(); + let root = owners.animated.roots.animation_to_world; + finish_reckoning(ReckoningUpdate { + up: root[1], + forward: root[2], + blend: 0.5, + })?; + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(target) + } +} diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs new file mode 100644 index 00000000..28ca8b5b --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs @@ -0,0 +1,33 @@ +use skate_core::physics::{ + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + skeleton_root::SkeletonRootFrames, +}; +pub(super) fn prepare( + roots: &mut SkeletonRootFrames, + world: &Transform, + mapped: &Transform, + retained: &mut Transform, + flags_2476: u32, + flags_2484: u32, + flags_2468: &mut u32, +) -> Transform { + let mut world = *world; + if flags_2476 & 4 != 0 { + //830BD4A0 initializer82F825F0 splats8216DEE0=-1 across all lanes. + for axis in [0, 2] { + for value in &mut world[axis] { + *value *= -1.0; + } + } + } + roots.initialize_heading = true; + roots.reset_initial_alignment(world); + if flags_2484 & 1 == 0 { + *retained = *mapped; + } + *flags_2468 |= 0x80000; + compose_affine(&world, retained) +} +#[cfg(test)] +#[path = "frames_tests.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs new file mode 100644 index 00000000..3323ee62 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs @@ -0,0 +1,21 @@ +use super::*; +use skate_core::physics::skeleton_animation_record::IDENTITY; +#[test] +fn frozen_board_uses_retained_pose_and_flips_complete_axes() { + let mut roots = SkeletonRootFrames::default(); + let mut retained = IDENTITY; + retained[3] = [2., 3., 4., 0.]; + let mut mapped = IDENTITY; + mapped[3] = [9.; 4]; + let mut world = IDENTITY; + world[0][3] = 7.; + world[2][3] = 8.; + let mut flags = 2; + let output = prepare(&mut roots, &world, &mapped, &mut retained, 4, 1, &mut flags); + assert_eq!(retained[3], [2., 3., 4., 0.]); + assert_eq!(roots.animation_to_world[0][3], -7.); + assert_eq!(roots.animation_to_world[2][3], -8.); + assert_eq!(output[3][..3], [-2., 3., -4.]); + assert_eq!(flags, 0x80002); + assert!(roots.initialize_heading); +} diff --git a/crates/skate-game/src/physics/player_input/dynamic_normal.rs b/crates/skate-game/src/physics/player_input/dynamic_normal.rs new file mode 100644 index 00000000..40b454cb --- /dev/null +++ b/crates/skate-game/src/physics/player_input/dynamic_normal.rs @@ -0,0 +1,20 @@ +//! Stock bindings for original82C02388. Hashes were matched to the actual +//! collection names; XML layoutC85F0-C8190 verifies curve offset1120. +use skate_core::{physics::board_dynamic_normal::DynamicNormalSettings, point_graph::PointGraph}; +use skate_data::collections::Collections; +pub(crate) fn settings(data: &Collections) -> Result { + let f = |field| data.float("physics_reckoning", "default", field); + let values = data + .words::<16>("physics_reckoning", "default", "DynamicSpeedMaxDeltaGraphZ")? + .map(f32::from_bits); + Ok(DynamicNormalSettings { + speed_damping: f("DynamicSpeedDamping")?, //AB8F93BA080E0CB3 + up_vector_damping: f("DynamicUpVectorDamping")?, //9F15310DBBE96B8C + maximum_delta: f("DynamicSpeedMaxDelta")?, //30BF4DA4BD7AE00A + speed_scale: f("DynamicSpeedMaxDeltaXScale")?, //20F1ECA637708494 + maximum_delta_vs_speed: PointGraph { + x: values[..8].try_into().unwrap(), + y: values[8..].try_into().unwrap(), + }, + }) +} diff --git a/crates/skate-game/src/physics/player_input/grind.rs b/crates/skate-game/src/physics/player_input/grind.rs new file mode 100644 index 00000000..3d339bdd --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind.rs @@ -0,0 +1,131 @@ +//! Original82D8A828/82D8ABD8/82D875A8 ordinary empty-edge path. +//! The authored host floor explicitly contains no grind edges. This is an +//! asset declaration, not a missing-loader fallback or an inferred no-hit. +use skate_core::{player::input_phase::ProcessedPhysicsInput, point_graph::PointGraph}; +use skate_data::collections::Collections; +#[derive(Clone, Copy, Debug)] +pub(crate) struct NoGrindEdges; +#[derive(Debug)] +pub(crate) struct GrindInputState { + previous_state: u32, + engagement_counter: u32, + cooldown: u32, + pub disabled: bool, + pub suppressed: bool, + pub elapsed: f32, + pub friction_vs_time: f32, + pub previous_velocity: [u32; 4], + pub grind_history: u32, + pub secondary_history: u32, + pub grounded_frames: u32, + pub air_frames: u32, + pub low_wheel_frames: u32, + previous_candidate: bool, + curve: PointGraph<4>, +} +impl GrindInputState { + pub fn load(data: &Collections) -> Result { + //XML C7220-C70E0=320; native PointGraph X336/Y352 skips header16. + let graph = data + .words::<12>("physics_grinds", "default", "FrictionVsTime")? + .map(f32::from_bits); + //82D8A318 scorer storesC/10/14/18/1C/20 and48 tozero;82D8A638 + //resets main464/468/472/476/477/480/484 and all416outputbytes. + Ok(Self { + previous_state: 0, + engagement_counter: 0, + cooldown: 0, + disabled: false, + suppressed: false, + elapsed: 0., + friction_vs_time: 0., + previous_velocity: [0; 4], + grind_history: 0, + secondary_history: 0, + grounded_frames: 0, + air_frames: 0, + low_wheel_frames: 0, + previous_candidate: false, + curve: PointGraph { + x: graph[4..8].try_into().unwrap(), + y: graph[8..12].try_into().unwrap(), + }, + }) + } + pub fn update( + &mut self, + _world: NoGrindEdges, + p: &ProcessedPhysicsInput, + air_counter: i32, + ) -> Result<(), String> { + let state = p.state_2508; + if state != self.previous_state { + self.engagement_counter = self.engagement_counter.wrapping_add(match state { + 400 | 402 | 404 => 50, + 403 => 20, + _ => 0, + }); + self.previous_state = state; + } + self.engagement_counter = decrement(self.engagement_counter); + self.suppressed = self.engagement_counter > 151; + self.cooldown = if self.suppressed { + 90 + } else { + decrement(self.cooldown) + }; + self.disabled = p.flags_2468 & 0x1800_0000 != 0 + || p.flags_2472 & 0x8008 != 0 + || p.flags_2476 & 0x0040_0000 != 0 + || self.cooldown > 0 + || (p.category_2512 == 200 + && (p.state_timer_2664 <= f32::from_bits(0x3da3_d70a) + || air_counter <= 10)) + || p.category_2512 == 500 + || (p.flags_2472 & 4 != 0 && p.category_2512 != 400); + self.elapsed = if p.category_2512 == 400 || state == 701 { + self.elapsed + p.timestep_2604 + } else { + 0. + }; + //Empty real edge query selects standard board collision in82D89DC0 + //for ordinary ground. An active grind state needs its geometry owner. + if p.category_2512 == 400 || state == 701 { + return Err("Active grind state requires authored grind geometry".into()); + } + self.low_wheel_frames = if self.previous_candidate + && state == 100 + && p.wheel_count_2556 < 2 + && p.scalar_2652 < 0.8 + { + self.low_wheel_frames.wrapping_add(1) + } else { + 0 + }; + self.grind_history = if p.grind_words_2532_2536[1] == 2 { + 70 + } else { + decrement(self.grind_history) + }; + self.secondary_history = decrement(self.secondary_history); + self.grounded_frames = if p.category_2512 == 100 { + self.grounded_frames.wrapping_add(1) + } else { + 0 + }; + self.air_frames = if p.category_2512 == 100 { + 0 + } else { + self.air_frames.wrapping_add(1) + }; + //82D875A8 LABEL80 on edge count0 clears candidate384 and owner32. + self.previous_candidate = false; + self.previous_velocity = p.vectors_400_416[0]; + self.friction_vs_time = self.curve.evaluate(self.elapsed); + Ok(()) + } +} +fn decrement(value: u32) -> u32 { + let next = value.wrapping_sub(1); + if next & 0x8000_0000 != 0 { 0 } else { next } +} diff --git a/crates/skate-game/src/physics/player_input/ground_position.rs b/crates/skate-game/src/physics/player_input/ground_position.rs new file mode 100644 index 00000000..e86121a1 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/ground_position.rs @@ -0,0 +1,70 @@ +//! Ground-position observation from TU3 Skateboard::CalculateGroundPosition +//! 82C02840. The four wheel rigid-body centres are expressed in Reckoning752, +//! averaged laterally/longitudinally, and placed at the lowest wheel centre. +use skate_core::physics::{ + board_runtime::BoardRuntime, skeleton_animation_record::AnimationPartTransform, +}; + +pub(crate) fn ground_position(board: &BoardRuntime, ground: &AnimationPartTransform) -> [u32; 4] { + let wheels = std::array::from_fn(|i| { + let p = board.bodies()[i].rates.position; + [p.x, p.y, p.z, 0.0] + }); + wheel_ground_position(wheels, ground).map(f32::to_bits) +} + +fn wheel_ground_position(wheels: [[f32; 4]; 4], ground: &AnimationPartTransform) -> [f32; 4] { + // Native transpose clears its fourth lane; the source does not subtract + // or re-add the frame translation in this calculation. + let transpose = + std::array::from_fn(|axis| [ground[0][axis], ground[1][axis], ground[2][axis], 0.0]); + let local = wheels.map(|position| rotate(&transpose, position)); + let mut mean = + std::array::from_fn(|i| (((local[0][i] + local[1][i]) + local[2][i]) + local[3][i]) * 0.25); + let first_minimum = if local[1][1] > local[0][1] { + local[0][1] + } else { + local[1][1] + }; + // fsel chooses its negative input on an unordered comparison as well. + let next_minimum = if local[2][1] - first_minimum >= 0.0 { + first_minimum + } else { + local[2][1] + }; + mean[1] = if local[3][1] - next_minimum >= 0.0 { + next_minimum + } else { + local[3][1] + }; + rotate(&[ground[0], ground[1], ground[2]], mean) +} + +fn rotate(basis: &[[f32; 4]; 3], point: [f32; 4]) -> [f32; 4] { + std::array::from_fn(|i| { + let x = basis[0][i] * point[0]; + let xy = basis[1][i].mul_add(point[1], x); + basis[2][i].mul_add(point[2], xy) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn minimum_height_is_measured_in_the_ground_frame() { + let ground = [ + [0.0, 1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [99.0; 4], + ]; + let wheels = [ + [1.0, 2.0, 3.0, 0.0], + [2.0, 4.0, 5.0, 0.0], + [3.0, 6.0, 7.0, 0.0], + [4.0, 8.0, 9.0, 0.0], + ]; + assert_eq!(wheel_ground_position(wheels, &ground), [4.0, 5.0, 6.0, 0.0]); + } +} diff --git a/crates/skate-game/src/physics/player_input/initial.rs b/crates/skate-game/src/physics/player_input/initial.rs new file mode 100644 index 00000000..680f967b --- /dev/null +++ b/crates/skate-game/src/physics/player_input/initial.rs @@ -0,0 +1,28 @@ +//! Original82DB18A0 initialization of the represented input owner. Native +//! opaque allocation flags are replaced by zero-initialized host storage. +use skate_core::player::input_phase::{PlayerInputState, StateVariantFields}; +use skate_data::collections::Collections; +pub(super) fn player(data: &Collections) -> Result { + let mut result = PlayerInputState { + //82DB1A9C inserts E0080000; active human actor adds bit18 at307C. + flags_1296: 0xe00c_0000, + ground_history_frames_1304: 100, + ..PlayerInputState::default() + }; + //82DB19C0: the external packet's ninth vector is the -1 sentinel. + result.external_physics_cache_1008.vectors[8] = [(-1.0f32).to_bits(); 4]; + for (index, key) in ["easy", "normal", "hardcore", "motorized", "test"] + .into_iter() + .enumerate() + { + //Original full64 keys at82DB1B08..1B78; stock XML layout+60. + result.state_variants_1408[index] = StateVariantFields { + surface_override_enabled_60: u8::from(data.boolean( + "physics_mode", + key, + "Hash_5548109D7B0CB70C", + )?), + }; + } + Ok(result) +} diff --git a/crates/skate-game/src/physics/player_input/mod.rs b/crates/skate-game/src/physics/player_input/mod.rs new file mode 100644 index 00000000..1fb71897 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/mod.rs @@ -0,0 +1,166 @@ +//! Concrete original Skate3 player input phase for the authored riding world. +mod dynamic_normal; +mod grind; +mod ground_position; +mod initial; +mod output_reset; +mod pre_input; +mod reset; +mod services; +use super::{ground_runtime::GroundRuntime, riding_outputs::RidingOutputs}; +pub(crate) use grind::NoGrindEdges; +pub(crate) use output_reset::reset_outputs; +pub(crate) use services::{InputHostFrame, PlayerInputCallbacks}; +use skate_core::{ + math::Vector3, + physics::{ + board_dynamic_normal::{BoardDynamicNormal, DynamicNormalSettings}, + board_input_output::{BoardInputFrame, publish_board_input}, + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + skeleton_animation_record::AnimationPartTransform, + }, + player::input_phase::{ + self, AnimationInputPacket, PhysicalPlayerInput, PlayerInputState, ProcessedPhysicsInput, + }, +}; +use skate_data::collections::Collections; +pub(crate) enum InputStage { + ThroughTeleport, + AfterTeleport(input_phase::InputContinuation), +} +pub(crate) struct PlayerInputRuntime { + pub player: PlayerInputState, + pub physical: PhysicalPlayerInput, + pub processed: ProcessedPhysicsInput, + pub toolkit: Option, + pub dynamic_normal: BoardDynamicNormal, + normal_settings: DynamicNormalSettings, + pub pre_input: pre_input::PreInputManager, + pub grind: grind::GrindInputState, + pending_teleport: Option, + world_edges: NoGrindEdges, +} +impl PlayerInputRuntime { + pub fn load(data: &Collections, world_edges: NoGrindEdges) -> Result { + let mut physical = PhysicalPlayerInput::default(); + reset_outputs(&mut physical); + let mut processed = ProcessedPhysicsInput::default(); + reset::reset_processed(&mut processed); + Ok(Self { + player: initial::player(data)?, + physical, + processed, + toolkit: None, + dynamic_normal: BoardDynamicNormal::new(), + normal_settings: dynamic_normal::settings(data)?, + pre_input: pre_input::PreInputManager::new(), + grind: grind::GrindInputState::load(data)?, + pending_teleport: None, + world_edges, + }) + } + pub fn request_teleport(&mut self, target: AnimationPartTransform) -> Result<(), String> { + if self.pending_teleport.is_some() { + return Err("A pending teleport must complete before replacement".into()); + } + self.pending_teleport = Some(target); + Ok(()) + } + pub fn pending_teleport(&self) -> Option { + self.pending_teleport + } + ///Run after the current board/contact solve, before ProcessOutput publishes. + pub fn update_dynamic_normal( + &mut self, + board: &BoardRuntime, + riding: &RidingOutputs, + gravity: Vector3, + dt: f32, + ) { + self.dynamic_normal.update( + board, + &riding.ground, + gravity, + dt, + self.processed.scalar_2656, + &self.normal_settings, + ); + } + ///Reset and all other component publications are coordinator-owned phases. + pub fn publish_board(&mut self, riding: &RidingOutputs) -> Result<(), String> { + let t = self + .toolkit + .as_ref() + .ok_or("Board output requires the current input toolkit")?; + publish_board_input( + &mut self.physical, + &riding.motion, + &riding.ground, + BoardInputFrame { + processed_forward: xyz(t.deck[2]), + reckoning_normal_1216: riding.reckoning.ground_normal, + reckoning_ground_up: xyz(riding.reckoning_frames.ground[1]), + retained_ground_normal_112: self.dynamic_normal.normal, + processed_flags_2476: self.processed.flags_2476, + }, + ); + Ok(()) + } + pub fn process_stage( + &mut self, + board: &mut BoardRuntime, + ground: &mut GroundRuntime, + ground_frame: AnimationPartTransform, + packet: &AnimationInputPacket<'_>, + host: InputHostFrame, + callbacks: &mut C, + stage: InputStage, + ) -> Result, String> { + let mut service = services::Services { + board, + ground, + ground_frame, + host, + callbacks, + pre_input: &mut self.pre_input, + grind: &mut self.grind, + world_edges: self.world_edges, + toolkit: &mut self.toolkit, + pending_teleport: &mut self.pending_teleport, + }; + match stage { + InputStage::ThroughTeleport => input_phase::start_input( + &mut self.player, &mut self.physical, packet, + &mut self.processed, &mut service, + ).map(Some), + InputStage::AfterTeleport(continuation) => input_phase::finish_input( + continuation, &mut self.player, &mut self.physical, packet, + &mut self.processed, &mut service, + ).map(|()| None), + } + .map_err(|e| format!("Player input: {e:?}")) + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[ignore = "requires private stock collections"] + fn original_player_input_settings_and_reset_load() { + let assets = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"), + ); + let data = Collections::load(&assets).unwrap(); + let state = PlayerInputRuntime::load(&data, NoGrindEdges).unwrap(); + assert_eq!(state.player.flags_1296, 0xe00c0000); + assert!(state.pending_teleport().is_none()); + assert_eq!(state.physical.ground.scalar_276, -1.0); + assert_eq!(state.dynamic_normal.normal, Vector3::new(0.0, 1.0, 0.0)); + } +} + diff --git a/crates/skate-game/src/physics/player_input/output_reset.rs b/crates/skate-game/src/physics/player_input/output_reset.rs new file mode 100644 index 00000000..03839b49 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/output_reset.rs @@ -0,0 +1,43 @@ +//! Represented PhysOut reset values. Original82DE53F0 copies the templates +//! built by82DE4940; scalar zero does not replace any active producer. +use skate_core::player::input_phase::*; +const UP: RawVector = [0, 0x3f800000, 0, 0]; +pub(crate) fn reset_outputs(out: &mut PhysicalPlayerInput) { + //These are host-owned observations, outside the native PhysOut templates. + let surface = out.surface_default_mode; + let world_grab = out.component_1832_word_1876; + let anim_to_world = out.skeleton.anim_to_world_11920; + //82DE2EF8 clears these three represented Air324 bits; preserve the rest. + let handplant_flags = out.air.handplant_flags_324 & !0xb000_0000; + *out = PhysicalPlayerInput { + air: AirOutputFields { + handplant_flags_324: handplant_flags, + //82DE2F70..78 loads original world-up into144.82DE304C + //splats-1 into the final four-word trajectory descriptor lane. + landing_normal_144: UP, + selected_trajectory_240: [[0;4],[0;4],[0;4],[0xbf80_0000;4]], + ..Default::default() + }, + reckoning: SystemReckoningFields { + vector_96: UP, + ..Default::default() + }, //82DE3CC8 + ground: GroundOutputFields { + vector_64: UP, + vector_96: UP, + scalar_276: -1., + ..Default::default() + }, //82DE3728 + grinds: GrindOutputFields { + words_136_140: [u32::MAX, 0], + ..Default::default() + }, //82DE3518 + skeleton: SkeletonOutputFields { + anim_to_world_11920: anim_to_world, + ..Default::default() + }, + surface_default_mode: surface, + component_1832_word_1876: world_grab, + ..Default::default() + }; +} diff --git a/crates/skate-game/src/physics/player_input/pre_input.rs b/crates/skate-game/src/physics/player_input/pre_input.rs new file mode 100644 index 00000000..b19fd387 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/pre_input.rs @@ -0,0 +1,67 @@ +//! Original82D81610 inactive-query branch and82D2DDD0 result reset. Pending +//! geometry is explicit; this owner never treats an unimplemented query as a miss. +use skate_core::physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}; +#[derive(Clone, Debug)] +pub(crate) struct PreInputResult { + pub position: [f32; 4], + pub normal: [f32; 4], + pub frame: AnimationPartTransform, + pub vector96: [f32; 4], + pub vector112: [f32; 4], + pub vector128: [f32; 4], + pub vector144: [f32; 4], + pub scalar160: f32, + pub word164: u32, + pub scalar168: f32, + pub scalar172: f32, + pub word176: u32, + pub flags180: u32, +} +impl PreInputResult { + fn reset() -> Self { + let mut frame = IDENTITY; + frame[3] = [0.; 4]; + Self { + position: [0.; 4], + normal: [0., 1., 0., 0.], + frame, + vector96: [0.; 4], + vector112: [0., 1., 0., 0.], + vector128: [0.; 4], + vector144: [0.; 4], + scalar160: 0., + word164: 0, + scalar168: f32::from_bits(0x5015_02f9), + scalar172: f32::from_bits(0x5015_02f9), + word176: 0, + flags180: 0, + } + } +} +pub(crate) struct PreInputManager { + pub pending_geometry: bool, + pub result: PreInputResult, + pub result_counts: [u32; 3], +} +impl PreInputManager { + pub fn new() -> Self { + //82D81100 clears pending316; result reset called by ctor82D8108C. + Self { + pending_geometry: false, + result: PreInputResult::reset(), + result_counts: [0; 3], + } + } + pub fn prepare(&mut self, counter: &mut u32) -> Result<(), String> { + self.result = PreInputResult::reset(); + self.result_counts = [0; 3]; + *counter = 30; + if self.pending_geometry { + return Err( + "Pending82D811C8 trajectory batch requires its complete82D81610 geometry consumer" + .into(), + ); + } + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/player_input/reset.rs b/crates/skate-game/src/physics/player_input/reset.rs new file mode 100644 index 00000000..2e754eeb --- /dev/null +++ b/crates/skate-game/src/physics/player_input/reset.rs @@ -0,0 +1,60 @@ +//! Selective reset of the represented ProcessedPhysIn fields, TU3 82BF9EF0. +//! Packet buffers and retained flag lanes deliberately survive this reset. +use skate_core::player::input_phase::ProcessedPhysicsInput; + +pub(crate) fn reset_processed(input: &mut ProcessedPhysicsInput) { + let external = input.external_physics_1616; + let vector_832 = input.vectors_720_784_800_816_832_864[4]; + let mut reset = ProcessedPhysicsInput { + effective_anim_transform_192: [ + [1.0f32.to_bits(), 0, 0, 0], + [0, 1.0f32.to_bits(), 0, 0], + [0, 0, 1.0f32.to_bits(), 0], + [0; 4], + ], + vector_1520: input.vector_1520, + matrix_1536: input.matrix_1536, + byte_1600: input.byte_1600, + external_physics_1616: external, + probe_1792: input.probe_1792, + flags_2468: 0x2000 | (input.flags_2468 & 8), + flags_2488: input.flags_2488 & 0x001f_ffff, + state_variant_index_2528: 1, + grind_words_2532_2536: [u32::MAX, 0], + timestep_2604: f32::from_bits(0x3c88_8889), + gravity_2648: f32::from_bits(0xc11c_cccd), + actor_query_2952: u32::MAX, + ..ProcessedPhysicsInput::default() + }; + reset.external_physics_1616.flags &= 0x01ff_ffff; + reset.vectors_544_560_592_608[0] = [0, 1.0f32.to_bits(), 0, 0]; + // Raw 82BFA1D0 and 82BFA1D8 both store to +816; +832 is retained. + reset.vectors_720_784_800_816_832_864[4] = vector_832; + *input = reset; +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn reset_retains_only_native_packet_lanes_and_masks() { + let mut input = ProcessedPhysicsInput::default(); + input.flags_2468 = u32::MAX; + input.flags_2488 = u32::MAX; + input.external_physics_1616.flags = u32::MAX; + input.external_physics_1616.vectors[0] = [7; 4]; + input.vectors_720_784_800_816_832_864 = [[9; 4]; 6]; + input.vector_1520 = [11; 4]; + input.scalar_2652 = 123.0; + reset_processed(&mut input); + assert_eq!(input.flags_2468, 0x2008); + assert_eq!(input.flags_2488, 0x001f_ffff); + assert_eq!(input.external_physics_1616.flags, 0x01ff_ffff); + assert_eq!(input.external_physics_1616.vectors[0], [7; 4]); + assert_eq!(input.vectors_720_784_800_816_832_864[3], [0; 4]); + assert_eq!(input.vectors_720_784_800_816_832_864[4], [9; 4]); + assert_eq!(input.vector_1520, [11; 4]); + assert_eq!(input.scalar_2652, 0.0); + assert_eq!(input.state_variant_index_2528, 1); + } +} diff --git a/crates/skate-game/src/physics/player_input/services.rs b/crates/skate-game/src/physics/player_input/services.rs new file mode 100644 index 00000000..967c7b46 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/services.rs @@ -0,0 +1,177 @@ +//! Host ownership for the original 82DB4048 input phase. Every numerical +//! preparation is performed here; the two callbacks address the shared +//! skeleton/whole-player owners that cannot be borrowed twice by the host. +use super::{ + grind::{GrindInputState, NoGrindEdges}, + pre_input::PreInputManager, +}; +use crate::physics::ground_runtime::GroundRuntime; +use skate_core::{ + physics::{ + board_runtime::BoardRuntime, board_toolkit::BoardToolkit, + skeleton_animation_record::AnimationPartTransform, + }, + player::input_phase::{ + AnimationInputPacket, InputPhaseServices, PhysicalPlayerInput, PlayerInputState, + ProcessedPhysicsInput, RawVector, + }, +}; + +#[derive(Clone, Copy)] +pub(crate) struct InputHostFrame { + pub actor_query_56: u32, + pub actor_query_44: u32, + pub input_available: bool, + /// Actual gameplay action 71 from ControllerInput::player_actions(). + pub transition_action: f32, + /// Motion output's published +0 frame, read by the native reset request. + pub published_board_transform: AnimationPartTransform, + /// Native air-state manager +40, only read by the category200 grind gate. + pub air_counter_40: i32, +} + +pub(crate) trait PlayerInputCallbacks { + fn process_skeleton( + &mut self, + board: &mut BoardRuntime, + toolkit: &BoardToolkit, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String>; + + /// Complete 82DB8998 reset including board, skeleton, Reckoning, current + /// physical outputs and Ground entry. Returning an error retains the request. + fn teleport( + &mut self, + board: &mut BoardRuntime, + ground: &mut GroundRuntime, + target: AnimationPartTransform, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String>; +} + +pub(super) struct Services<'a, C> { + pub board: &'a mut BoardRuntime, + pub ground: &'a mut GroundRuntime, + pub ground_frame: AnimationPartTransform, + pub host: InputHostFrame, + pub callbacks: &'a mut C, + pub pre_input: &'a mut PreInputManager, + pub grind: &'a mut GrindInputState, + pub world_edges: NoGrindEdges, + pub toolkit: &'a mut Option, + pub pending_teleport: &'a mut Option, +} +impl InputPhaseServices for Services<'_, C> { + type Error = String; + fn update_pre_input_manager_82d81610( + &mut self, + player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + ) -> Result<(), String> { + self.pre_input.prepare(&mut player.manager_1856_counter_320) + } + fn reset_processed_input_82bf9ef0( + &mut self, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + super::reset::reset_processed(output); + Ok(()) + } + fn actor_query_slot_56(&mut self) -> Result { + Ok(self.host.actor_query_56) + } + fn actor_query_slot_44(&mut self) -> Result { + Ok(self.host.actor_query_44) + } + fn reset_player_probe_82d7a330( + &mut self, + player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + ) -> Result<(), String> { + // Native clears every represented field after copying it to Processed. + player.probe = Default::default(); + Ok(()) + } + fn check_teleport_82db88c8( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + // Native handles its state request before its explicit reset bit. + // Host requests remain owned until the entire reset succeeds. + if physical.state.flag_61 != 0 { + let target = self.host.published_board_transform; + self.callbacks + .teleport(self.board, self.ground, target, player, physical, output)?; + } + if player.flags_1296 & (1 << 19) != 0 { + let deck = self.board.part_transforms()[6]; + let mut target = [[0.0; 4]; 4]; + for (dst, src) in target[..3].iter_mut().zip(deck.basis.columns) { + dst[..3].copy_from_slice(&src); + } + let p = deck.translation; + target[3] = [p.x, p.y, p.z, 0.0]; + self.callbacks + .teleport(self.board, self.ground, target, player, physical, output)?; + player.flags_1296 &= !(1 << 19); + } + if let Some(target) = *self.pending_teleport { + self.callbacks + .teleport(self.board, self.ground, target, player, physical, output)?; + *self.pending_teleport = None; + } + Ok(()) + } + fn actor_input_available_slot_4(&mut self) -> Result { + Ok(self.host.input_available) + } + fn transition_action_825903c8(&mut self) -> Result { + Ok(self.host.transition_action) + } + fn calculate_ground_position_82c02840( + &mut self, + _physical: &PhysicalPlayerInput, + ) -> Result { + Ok(super::ground_position::ground_position( + self.board, + &self.ground_frame, + )) + } + fn prepare_board_toolkit_82c013f0( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + *self.toolkit = Some(self.ground.prepare_toolkit(self.board, output)); + Ok(()) + } + fn process_skeleton_82bd8918( + &mut self, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + let toolkit = self + .toolkit + .as_ref() + .ok_or("Input toolkit must precede Skeleton::ProcessData")?; + self.callbacks + .process_skeleton(self.board, toolkit, packet, physical, output) + } + fn update_grind_manager_82d8a828( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.grind + .update(self.world_edges, output, self.host.air_counter_40) + } +} diff --git a/crates/skate-game/src/physics/player_state.rs b/crates/skate-game/src/physics/player_state.rs new file mode 100644 index 00000000..96fc9cab --- /dev/null +++ b/crates/skate-game/src/physics/player_state.rs @@ -0,0 +1,132 @@ +//! PhysicalPlayer startup, PostInput, state selection and state publication. +//! Native control decisions use original TU3 functions and stock collections; +//! host ownership replaces the original player component pointers. +mod post_input; +mod pre_state; +mod publication; +mod selection; +mod transition; +mod wipeout_output; +use super::{GamePhysics, skater::SkaterRuntime}; +use skate_core::{ + physics::filtered_state::{FilteredState, FilteredStateOutput}, + player::{ + lifecycle::PhysicalPlayerStateLifecycle, + selector::{StateSelector, conditions::TwoStageThresholds}, + state::PhysicalStateId, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct PlayerState { + pub lifecycle: PhysicalPlayerStateLifecycle, + pub selector: StateSelector, + pub requested_state: PhysicalStateId, + pub filtered: FilteredState, + pub filtered_output: Option, + pub ground_output: Option, + pub post: post_input::PostInputState, + pub state_flags: [bool; 36], + pub state_count: u32, + pub update_count: u32, + normal_off_ground: TwoStageThresholds, + skitching_off_ground: TwoStageThresholds, + animated_board_threshold: f32, + initialized: bool, +} +impl PlayerState { + pub fn load(data: &Collections, _mode: &str) -> Result { + let thresholds = |class| -> Result { + Ok(TwoStageThresholds { + field_856_primary: data.float(class, "default", "NaturalAirMaxDist")?, + field_856_secondary: data.float(class, "default", "NaturalAirMinDist")?, + field_7692: data.float(class, "default", "NaturalAirTime")?, + }) + }; + Ok(Self { + //82DB3008 selects the owned Sleeping object before SetPhysicsState100. + lifecycle: PhysicalPlayerStateLifecycle::new(PhysicalStateId::Sleeping), + selector: StateSelector::default(), + requested_state: PhysicalStateId::Sleeping, + filtered: FilteredState::default(), + filtered_output: None, + ground_output: None, + post: post_input::PostInputState::new(), + state_flags: [false; 36], + state_count: 0, + update_count: 0, + //82D8BD50 reads Globals260; its layout392/396/400 is physics_airstates. + normal_off_ground: thresholds("physics_airstates")?, + skitching_off_ground: thresholds("physics_state_skitching")?, + //Skeleton+24 physics_animation, original82BDC6C8 layout800. + animated_board_threshold: data.float( + "physics_animation", + "default", + "MaxDeckZAxisYForAnimatedDeck", + )?, + initialized: false, + }) + } + pub fn current(&self) -> PhysicalStateId { + self.lifecycle.active().state + } + ///ResetSystems82DB92D0 resets the selector and physical-output filter; + ///the coordinator owns board/skeleton resets and the following Ground entry. + pub fn reset_for_teleport(&mut self) { + self.filtered.reset(); + self.filtered_output = None; + self.ground_output = None; + //ResetSystems82DB92D0 clears20..44, sets48=10 and52=0. + //Current pointer16 and flags56/57 survive until Calculate writes them. + self.selector.nonspecific_collision_free_frames = 0; + self.selector.nonspecific_collision_frames = 0; + self.selector.something_colliding_frames = 0; + self.selector.two_wheel_counter = 0; + self.selector.three_wheel_counter = 0; + self.selector.post_grind_jump_counter = 0; + self.selector.air_frames = 0; + self.selector.teleport_countdown = 10; + self.selector.skitch_exit_countdown = 0; + //TrajectorySelector::Reset82D67228 clears9658, preserving9657. + self.post.trajectory_available = false; + } +} +///Call once before first input; startup constructs the toolkit from live board/reset inputs. +pub(crate) fn initialize( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + if skater.player_state.initialized { + return Ok(()); + } + transition::set(physics, skater, PhysicalStateId::PhysicsGround)?; + skater.player_state.initialized = true; + Ok(()) +} +///Runs after ProcessInput, before the chosen state's pre-update/force phase. +pub(crate) fn post_input_and_select( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + post_input::advance(physics, skater)?; + selection::advance(physics, skater) +} +///The reset/board/skeleton publications precede this selected-state FillPhysOut. +pub(crate) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + publication::publish(physics, skater) +} +///Reset continuation uses the same native SetPhysicsState path and actual entry. +pub(crate) fn enter_after_teleport( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + transition::set(physics, skater, PhysicalStateId::PhysicsGround) +} + +///Original82DB6050 prefix; coordinator calls the selected state pre-update next. +pub(crate) fn pre_state( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + pre_state::advance(physics, skater) +} diff --git a/crates/skate-game/src/physics/player_state/post_input.rs b/crates/skate-game/src/physics/player_state/post_input.rs new file mode 100644 index 00000000..66b5c89e --- /dev/null +++ b/crates/skate-game/src/physics/player_state/post_input.rs @@ -0,0 +1,231 @@ +//! Original PostInput82DB5588 with the live ballistic trajectory selector. +//! The authored world has no grind edges or grab-spline descriptors. +use super::*; +use skate_core::player::post_input::*; + +pub(crate) struct PostInputState { + pub jump_reference: [u32; 4], + pub jump_fix_frames: u32, + pub latch_frames: u32, + pub state_frames: u32, + pub heading_adjust: f32, + pub complete: bool, + pub trajectory_pending: bool, + pub trajectory_valid: bool, + pub trajectory_available: bool, + pub trajectory_new_candidate: bool, + pub candidates: CandidatePublicationFields, + pub grind: EmptyEdgePost, +} +impl PostInputState { + pub fn new() -> Self { + Self { + //Original Player ctor82DB1AAC=1000,1ACC=0,3028 clears1264. + jump_reference: [0; 4], + jump_fix_frames: 1000, + latch_frames: 0, + state_frames: 100, + heading_adjust: 0.0, + complete: false, + trajectory_pending: false, + trajectory_valid: false, + trajectory_available: false, + trajectory_new_candidate: false, + candidates: CandidatePublicationFields { + first_object_present_196: false, + first_pending_288: false, + second_object_present_500: false, + second_pending_592: false, + staged_word_12768: 0, + staged_valid_12772: 0, + staged_latched_12776: 0, + staged_pending_12780: false, + }, + grind: EmptyEdgePost::new(), + } + } +} +///Scorer/decision state that still advances in the genuine no-candidate path +///82D8AB08 ->82D86808/82D86C88/82D86DE8/82D89F58/82D739D8. +pub(crate) struct EmptyEdgePost { + free_frames: u32, + duration: u32, + engage_state: u32, + expiry: f32, + pop_frames: u32, + lean: [f32; 2], + reset_frames: u32, + previous_grind: u32, + blend: f32, +} +impl EmptyEdgePost { + fn new() -> Self { + Self { + free_frames: 0, + duration: 0, + engage_state: 0, + expiry: 0.0, + pop_frames: 0, + lean: [0.0; 2], + reset_frames: 0, + previous_grind: 0, + blend: 0.0, + } + } + fn advance(&mut self, p: &mut skate_core::player::input_phase::ProcessedPhysicsInput) { + //No candidate means82D8ACF0 skips wallassist,82D86318 skips geometry. + self.expiry = (self.expiry - p.timestep_2604).max(0.0); + self.free_frames = self.free_frames.wrapping_add(1); + if self.free_frames > 20 { + self.duration = 0; + self.engage_state = 2; + } + self.pop_frames = decrement(self.pop_frames); + for value in &mut self.lean { + *value *= f32::from_bits(0x3f68f5c3); + if value.abs() < 0.0001 { + *value = 0.0; + } + } + self.reset_frames = decrement(self.reset_frames); + if self.reset_frames > 0 { + p.flags_2476 |= 0x02000000; + } + if p.grind_words_2532_2536[0] != u32::MAX { + self.previous_grind = p.grind_words_2532_2536[0]; + } + self.blend = (self.blend + 0.0035).min(1.0); + } +} +fn decrement(v: u32) -> u32 { + let n = v.wrapping_sub(1); + if n & 0x80000000 != 0 { 0 } else { n } +} +struct Services<'a> { + heading: f32, + grind: &'a mut EmptyEdgePost, + processed: &'a mut skate_core::player::input_phase::ProcessedPhysicsInput, + trajectory: &'a mut super::super::air_trajectory::AirTrajectoryRuntime, + trajectory_input: skate_core::air::trajectory::SelectorInput, + world: &'a skate_core::physics::board_world::BoardWorld, + error: Option, +} +impl PostInputServices for Services<'_> { + fn update_grind_manager_82d8ab08(&mut self) { + self.grind.advance(self.processed); + } + fn update_trajectory_selector_82d68800(&mut self) -> u8 { + //82DB56B0 runs the real selector after GrindManager. That manager + //can change2476, so use its current output rather than a prior snapshot. + let mut input = self.trajectory_input; + input.flags_2476 = self.processed.flags_2476; + match self.trajectory.update(input, self.world) { + Ok(valid) => u8::from(valid), + Err(error) => { + self.error = Some(error); + 0 + } + } + } + fn calculate_scalar_2740_82db5e10(&mut self) -> f32 { + self.heading + } + fn register_candidate_82762ab0(&mut self, _: CandidateRegistration) { + //82762AB0 copies a grab-spline record; the inherited trait name does + //not imply engine registration or ballistic trajectory publication. + unreachable!( + "grab-spline publication requires an authored descriptor checked before this phase" + ) + } +} +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let trajectory_input = super::super::air_phase::selector_input(physics, skater)?; + let post = &mut skater.player_state.post; + let p = &mut skater.player_input.processed; + if skater.ground_lifecycle.edge.is_some() || p.category_2512 == 400 || p.state_2508 == 701 { + return Err("PostInput active grind requires its actual candidate/scorer output".into()); + } + let requests = &skater.ground_lifecycle.trajectory; + if requests.primary_valid_288 + || requests.secondary_valid_592 + || post.candidates.staged_pending_12780 + { + return Err( + "PostInput grab-spline publication requires its retained authored descriptor".into(), + ); + } + let mut player = PostInputPlayerFields { + jump_reference_1264: post.jump_reference, + flags_1296: skater.player_input.player.flags_1296, + state_frames_1304: skater.player_input.player.ground_history_frames_1304 as u32, + jump_fix_frames_1308: post.jump_fix_frames, + latch_frames_1320: post.latch_frames, + }; + let mut processed = PostInputProcessedFields { + jump_reference_848: post.jump_reference, + word_2464: 0, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + current_state_2508: p.state_2508, + state_frames_2572: post.state_frames, + jump_fix_frames_2576: post.jump_fix_frames, + scalar_2740: post.heading_adjust, + }; + let mut output = PostInputPhysOutFields { + reset_state_frames_316: skater + .player_state + .ground_output + .as_ref() + .is_some_and(|g| g.ground_32.wall_ride_exit), + //82DB5588 consumes the preceding state's actual jump publication. + capture_jump_reference_442: skater.player_input.physical.air.launched_442 != 0, + jump_reference_128: skater.player_input.physical.air.launch_velocity_128, + complete_76: post.complete, + }; + let heading = physics.riding.update_input_heading( + f32::from_bits(p.vectors_464_480_496_512_528[0][1]), + p.scalar_2612, + ); + let mut services = Services { + heading, + grind: &mut post.grind, + processed: p, + trajectory: &mut skater.trajectory, + trajectory_input, + world: &physics.world, + error: None, + }; + run_post_input( + PostInputContext { + player: &mut player, + processed: &mut processed, + phys_out: &mut output, + candidates: &mut post.candidates, + }, + &mut services, + ); + if let Some(error) = services.error.take() { + return Err(error); + } + drop(services); + //These are observations of the actual owner, never inputs to selection. + post.trajectory_pending = skater.trajectory.selector.pending(); + post.trajectory_valid = skater.trajectory.selector.valid(); + post.trajectory_available = skater.trajectory.selector.valid(); + post.trajectory_new_candidate = skater.trajectory.selector.just_changed(); + skater.player_input.player.flags_1296 = player.flags_1296; + skater.player_input.player.ground_history_frames_1304 = player.state_frames_1304 as i32; + p.flags_2468 = processed.flags_2468; + p.flags_2472 = processed.flags_2472; + p.flags_2480 = processed.flags_2480; + p.flags_2484 = processed.flags_2484; + post.jump_reference = player.jump_reference_1264; + post.jump_fix_frames = player.jump_fix_frames_1308; + post.latch_frames = player.latch_frames_1320; + post.state_frames = processed.state_frames_2572; + post.heading_adjust = processed.scalar_2740; + post.complete = output.complete_76; + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/pre_state.rs b/crates/skate-game/src/physics/player_state/pre_state.rs new file mode 100644 index 00000000..f217d208 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/pre_state.rs @@ -0,0 +1,52 @@ +//! Update3_State82DB6050 prefix through grab-spline query synchronization. +//! Ground/Air/KnownAir vtable24 ->82D34DA8 ->82D2D860 predicts from the actual deck part +//! transform and its physical body's linear velocity, with a single VMADD. +use super::*; +use skate_core::physics::board::BodyId; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let player = &mut skater.player_input.player; + player.state_count_1312 = player.state_count_1312.wrapping_add(1); + //KnownAir ctor82D35130 installs82327210; vslot24 at82327228 is + //the same82D34DA8. Its target-following trajectory is not this deck sample. + if !matches!( + skater.player_state.current(), + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + ) { + return Err("PreState requires the selected state's actual PredictFutureOfDeck".into()); + } + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let velocity = physics.board.bodies()[BodyId::Deck.index()] + .rates + .linear_velocity; + let dt = skater.player_input.processed.timestep_2604; + let prediction = [ + velocity.x.mul_add(dt, deck.translation.x), + velocity.y.mul_add(dt, deck.translation.y), + velocity.z.mul_add(dt, deck.translation.z), + 0.0, + ]; + let roots = &mut skater.animated_skeleton.roots; + roots.predicted_board_position = prediction; + roots.supplied_prediction = Some(prediction); + //82DB60DC clears sticky3184, not this-update3185. + skater.foot_ik.state.contacts.support_failed = false; + let requests = &mut skater.ground_lifecycle.trajectory; + //82D74270 is PlayerGrabSpline::Sync. Its288-byte grab-spline records are + //separate from the240-byte ballistic predictions. It clears flag6 first. + //This world's triangles have no authored grab-spline descriptors, so no + //pending line batch and no query result9840 take the actual empty branch. + requests.flags_12836 &= !0x40; + if requests.pending_request.is_some() || requests.result_valid_9840 { + return Err( + "PreState has a retained grab-spline request/result requiring82D74270 synchronization" + .into(), + ); + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/publication.rs b/crates/skate-game/src/physics/player_state/publication.rs new file mode 100644 index 00000000..4bbbc9d0 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/publication.rs @@ -0,0 +1,181 @@ +//! Selected-state FillPhysOut and common state82DB7580..7790. +use super::*; +use skate_core::{ + physics::{ + contact_feedback::choose_surface, + filtered_state::{FilteredStateInput, GrindState}, + }, + player::input_phase::CurrentStateFields, +}; +pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let state = skater.player_state.current(); + if !matches!( + state, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + ) { + return Err(format!( + "FillPhysOut requires the actual {state:?} output owner" + )); + } + if state == PhysicalStateId::KnownAir { + super::super::known_air::fill(physics, skater)?; + } + let p = &skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("State output requires actual board toolkit")?; + let output = (state == PhysicalStateId::PhysicsGround) + .then(|| skater.ground.output(p, &skater.animation_input, toolkit)); + let player = &mut skater.player_input.player; + skater.player_state.state_count = player.state_count_1312; + skater.player_state.update_count = player.update_count_1316; + let flags = &mut skater.player_state.state_flags; + //State template82DE3BE8 clears bytes52..87. Only source-owned writes follow. + *flags = [false; 36]; + let mut set = |offset: usize, value: bool| flags[offset - 52] = value; + set(52, p.flags_2468 & (1 << 30) != 0); + set(54, p.flags_2468 & (1 << 29) != 0); + set(55, p.flags_2468 & (1 << 25) != 0); + set(56, p.flags_2468 & (1 << 27) != 0); + set(57, p.flags_2468 & (1 << 26) != 0); + set(58, p.flags_2468 & (1 << 28) != 0); + set(59, p.flags_2468 & (1 << 18) != 0); + set(60, skater.animation_input.fields.balance != 0.0); + set(62, p.flags_2472 & (1 << 10) != 0); + // 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(65, skater.wipeout.requests_wipeout(p)); + set(78, skater.wipeout.requests_runout(p)); + set(71, player.flags_1296 & (1 << 24) != 0); + set(73, p.flags_2476 & (1 << 24) != 0); + set(75, state.category() == 500); + set(76, state as u32 == 500); + //82DB78A8..78DC: the processed off-board request refreshes three + //outputs, then this publication consumes one and exposes State77. + if p.state_identifier_2496 == 500 { + player.dismount_request_frames_1332 = 3; + } + let dismount_requested = player.dismount_request_frames_1332 > 0; + if dismount_requested { + player.dismount_request_frames_1332 -= 1; + } + set(77, dismount_requested); + set(79, p.state_variant_index_2528 == 3); + set(80, p.flags_2484 & (1 << 21) != 0); + if let Some(output) = &output { + set(84, output.state_28.flag_84); + set(86, output.state_28.has_world_grab_intent_without_object); + if let Some(value) = output.state_28.manual_correction_write_78 { + set(78, value); + } + } + set(87, p.flags_2484 & (1 << 10) != 0); + if state == PhysicalStateId::SlideGround { + set(84, skater.slide_state.state.wall_riding); + } + let physical = &mut skater.player_input.physical; + //Common ProcessOutput82DB7044/7048 and70C0/70C4; these precede state Fill. + physical.air.flag_444 = u8::from( + skater + .trajectory + .selector + .selection() + .is_some_and(|s| s.wall_ride), + ); + physical.air.flag_441 = u8::from(skater.air_reckoning.state.flip_active); + if state == PhysicalStateId::GroundAnimation { + skater.ground_animation.fill(p, &mut physical.air); + } + physical.state = CurrentStateFields { + category_12: state.category(), + state_16: state as u32, + //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), + ..CurrentStateFields::default() + }; + skater.player_state.state_flags[69 - 52] = physical.state.flag_69 != 0; + if let Some(output) = &output { + physical.state.skitch_value_40 = output.state_28.grab_spline_object_id; + physical.state.signed_ground_step_84 = u8::from(output.state_28.flag_84); + physical.ground.vector_128 = output.ground_32.anti_flip_torque.map(f32::to_bits); + physical.ground.scalar_276 = output.ground_32.time_to_skitch; + physical.ground.flag_317 = u8::from(output.ground_32.anti_flip_nudge_present); + physical.ground.flag_318 = u8::from(output.ground_32.is_pinning); + physical.off_board.flag_304 = u8::from(output.is_grabbing_object_72_304); + } else if state == PhysicalStateId::PhysicsAir { + let air = skate_core::air::state::fill_physics_output(&skater.air_state); + physical.air.landing_normal_144 = air.landing_normal.map(f32::to_bits); + physical.air.jump_height_200 = air.jump_height; + physical.air.reached_apex_436 = u8::from(air.is_at_apex); + if let Some(value) = air.scalar_184_write { + physical.air.scalar_184 = value; + } + } else if state == PhysicalStateId::SlideGround { + //State Fill82D3B010 is a single byte from this retained Slide owner. + physical.state.signed_ground_step_84 = u8::from(skater.slide_state.state.wall_riding); + } + //ProcessOutput82DB71C4 calls SkateboardController::FillPhysOut82D76D20 + //for every selected state. The constructor82D74DD8 seeds448=0; use the + //same controller owner as state transitions and post-physics ragdoll. + physical.off_board.dropping_board_322 = u8::from(p.flags_2476 & (1 << 9) != 0); + physical.off_board.retrieving_board_323 = u8::from( + p.flags_2476 & (1 << 10) != 0 || skater.skateboard_controller.fields.state_448 == 4, + ); + + //ProcessOutput82DB7130..7180 copies the HandPlantManager animation + //flags1876 into Air304+20 one bit at a time, preserving the low28 bits. + //The same manager word is consumed by the actual input publication. + physical.air.handplant_flags_324 = (physical.air.handplant_flags_324 & 0x0fff_ffff) + | (physical.component_1832_word_1876 & 0xf000_0000); + + if let Some(projection) = output + .as_ref() + .and_then(|output| output.velocity_projection_36) + { + physical.reckoning.vector_144 = + projection.velocity_without_axis_component.map(f32::to_bits); + physical.reckoning.flag_164 = u8::from(projection.active); + } + let surface = choose_surface( + physics.riding.wheel_lines.physics_surfaces, + core::array::from_fn(|i| physics.riding.ground.parts[i].in_contact), + physics.riding.ground.collision_flags & (1 << 25) != 0, + ); + let filtered = skater.player_state.filtered.update(FilteredStateInput { + physics_category: state.category() as i32, + physics_state: state as i32, + anything_in_contact: physical.collision.flag_3477 != 0, + physics_surface_type: surface as i32, + wall_ride_exit: output + .as_ref() + .is_some_and(|output| output.ground_32.wall_ride_exit), + //Original reset records; the selected ordinary Ground state and + //authored empty-edge world do not publish the active air/grind fields. + targeting_grind: state == PhysicalStateId::KnownAir + && skater.known_air.state.targeting_grind_213, + offboard_has_landed: false, + offboard_on_deck: false, + grind: GrindState::default(), + last_grind_distance: 0.0, + }); + physical.filtered_state_0 = filtered.category as u32; + skater.player_state.filtered_output = Some(filtered); + skater.player_state.ground_output = output; + if state == PhysicalStateId::WipeoutGround { + super::wipeout_output::publish(skater); + } + if state == PhysicalStateId::Teleporting { + skater.teleport_state.publish_output(&mut skater.player_input.physical); + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/selection.rs b/crates/skate-game/src/physics/player_state/selection.rs new file mode 100644 index 00000000..2ce31ac2 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/selection.rs @@ -0,0 +1,55 @@ +//! CalcSuggestedState82D8ADE8 gets actual contacts, line distances and pose. +use super::*; +use skate_core::player::selector::{ + ProcessedStateInput, StateSelectionInput, + conditions::{BoardBodyState, SkeletonAnimationState}, +}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let input = StateSelectionInput { + processed: ProcessedStateInput { + //The authored world has no grind edges. GrindInput's real empty + //edge path clears candidate384; zero flags are its reset record. + grind_type_1248: 0, + grind_candidate_1488: false, + grind_investigation_flags_1516: 0, + field_1776: p.external_physics_1616.flags as i32, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + flags_2488: p.flags_2488, + category_2512: p.category_2512, + wheel_contact_count_2556: p.wheel_count_2556 as i32, + field_2572: skater.player_state.post.state_frames, + state_timer_2664: p.state_timer_2664, + field_2732: skater.animation_input.fields.slide, + field_2744: skater.animation_input.extra.revert_direction, + trajectory_collision_time_2772: p.air_scalar_2772, + }, + skateboard_contact_count_869: physics.riding.ground.wheel_contact_count, + board_body: BoardBodyState { + field_856: physics.riding.wheel_lines.minimum_distance, + field_7692: physics.riding.ground.time_without_wheel_contact, + }, + skeleton: SkeletonAnimationState { + mode_16420: skater.skeleton_input.force_mode, + back_chain_lane_12656: skater.skeleton_input.drive_frames[0][2][1], + threshold_800: skater.player_state.animated_board_threshold, + }, + normal_off_ground: skater.player_state.normal_off_ground, + skitching_off_ground: skater.player_state.skitching_off_ground, + }; + let current = skater.player_state.current(); + let requested = skater.player_state.selector.calculate(current, &input); + skater.player_state.requested_state = requested; + if requested != current { + //82DB5FF4 restores the actual counter also used by ground-history. + skater.player_input.player.ground_history_frames_1304 = 100; + super::transition::set(physics, skater, requested)?; + } + //SystemLogic82DB6000 clears the per-frame Wipeout request accumulator. + skater.wipeout.state.clear_after_selection(); + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/transition.rs b/crates/skate-game/src/physics/player_state/transition.rs new file mode 100644 index 00000000..d4155d41 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/transition.rs @@ -0,0 +1,165 @@ +//! Native SetPhysicsState82DB8540 publication, followed by owned Enter/Exit. +use super::*; +use skate_core::{physics::board_toolkit::BoardToolkit, player::lifecycle::*}; + +struct Calls; +impl PhysicalStateCalls for Calls { + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId { + state.state + } + //Host lifecycle publication is completed below before applying physical + //effects. These supported Exit methods do not inspect the active binding. + fn exit(&mut self, call: StateCall) { + assert!(matches!( + call.state.state, + PhysicalStateId::Sleeping + | PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + )); + } + fn enter(&mut self, call: StateCall) { + assert!(matches!( + call.state.state, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + )); + } +} +struct InactiveController; +impl SkateboardControllerActions for InactiveController { + fn hold_skateboard(&mut self) { + unreachable!("active controller rejected before transition") + } + fn let_go_of_skateboard(&mut self) { + unreachable!("active controller rejected before transition") + } +} +pub(super) fn set( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + requested: PhysicalStateId, +) -> Result<(), String> { + skater.player_state.requested_state = requested; + let current = skater.player_state.current(); + if current == requested { + return Ok(()); + } + //Keep the actual selector request for the coordinator; do not publish a + //state whose physical Enter/Exit owner has not been connected. + let supported = match current { + PhysicalStateId::Sleeping => requested == PhysicalStateId::PhysicsGround, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting => matches!( + requested, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + ), + _ => false, + }; + if !supported { + return Err(format!( + "Physical state transition {current:?} -> {requested:?} requires its native Enter/Exit production adapter" + )); + } + if skater.skateboard_controller.fields.system_on_452 { + return Err( + "Ground transition requires active SkateboardController::LetGoOfSkateboard".into(), + ); + } + let p = &mut skater.player_input.processed; + let player = &mut skater.player_input.player; + let mut data = StateChangeData { + player: PlayerStateChangeFields { + word_1312: player.state_count_1312, + previous_category_latch_1336: player.state_value_1336, + scalar_1344: player.state_timer_1344, + }, + processed: ProcessedStateChangeFields { + flags_2480: p.flags_2480, + requested_state_2500: requested as u32, + previous_state_2504: p.state_2504, + current_state_2508: p.state_2508, + current_category_2512: p.category_2512, + previous_category_2516: p.category_2516, + previous_category_latch_2520: p.player_state_value_2520, + word_2564: p.state_count_2564, + scalar_2664: p.state_timer_2664, + }, + skateboard_controller: skater.skateboard_controller.fields, + }; + skater + .player_state + .lifecycle + .set_physics_state( + requested as u32, + &mut data, + &mut Calls, + &mut InactiveController, + ) + .map_err(|e| format!("Unknown physical state {}", e.0))?; + player.state_count_1312 = data.player.word_1312; + player.state_value_1336 = data.player.previous_category_latch_1336; + player.state_timer_1344 = data.player.scalar_1344; + p.flags_2480 = data.processed.flags_2480; + p.state_2504 = data.processed.previous_state_2504; + p.state_2508 = data.processed.current_state_2508; + p.category_2512 = data.processed.current_category_2512; + p.category_2516 = data.processed.previous_category_2516; + p.player_state_value_2520 = data.processed.previous_category_latch_2520; + p.state_count_2564 = data.processed.word_2564; + p.state_timer_2664 = data.processed.scalar_2664; + skater.skateboard_controller.fields = data.skateboard_controller; + if skater.player_input.toolkit.is_none() { + skater.player_input.toolkit = Some(BoardToolkit::from_board( + &physics.board, + p.flags_2468, + p.scalar_2612, + p.vectors_464_480_496_512_528[0].map(f32::from_bits), + [0.0, 1.0, 0.0, 0.0], + )); + } + //Native82DB8540 publishes Processed state/history BEFORE old Exit. The + //same retained objects then receive Exit followed by the new Enter. + match current { + PhysicalStateId::PhysicsGround => super::super::ground_exit::exit(physics, skater), + PhysicalStateId::PhysicsAir => super::super::air_phase::exit(skater), + PhysicalStateId::KnownAir => { + super::super::known_air::exit(physics, skater, requested as u32)? + } + PhysicalStateId::GroundAnimation => super::super::ground_animation::exit(physics, skater)?, + PhysicalStateId::SlideGround => super::super::slide_state::exit(physics, skater)?, + PhysicalStateId::WipeoutGround => super::super::wipeout_states::exit(physics, skater), + PhysicalStateId::Sleeping | PhysicalStateId::Teleporting => {} //82B61BB8. + _ => unreachable!("state support checked before publication"), + } + match requested { + PhysicalStateId::PhysicsGround => super::super::ground_phase::enter(physics, skater), + PhysicalStateId::PhysicsAir => super::super::air_phase::enter(physics, skater), + PhysicalStateId::KnownAir => super::super::known_air::enter(physics, skater), + PhysicalStateId::GroundAnimation => super::super::ground_animation::enter(physics, skater), + PhysicalStateId::SlideGround => super::super::slide_state::enter(physics, skater), + PhysicalStateId::WipeoutGround => super::super::wipeout_states::enter(physics, skater), + PhysicalStateId::Teleporting => { skater.teleport_state.enter(); Ok(()) }, + _ => unreachable!("state support checked before publication"), + } +} diff --git a/crates/skate-game/src/physics/player_state/wipeout_output.rs b/crates/skate-game/src/physics/player_state/wipeout_output.rs new file mode 100644 index 00000000..0c7b5fea --- /dev/null +++ b/crates/skate-game/src/physics/player_state/wipeout_output.rs @@ -0,0 +1,45 @@ +//! Wipeout300 Fill82D3EEE8 writes the shared completed physical packet. +use super::super::SkaterRuntime; + +pub(super) fn publish(skater: &mut SkaterRuntime) { + let output = super::super::wipeout_states::fill(skater); + let physical = &mut skater.player_input.physical; + let skeleton = &mut physical.skeleton; + skeleton.over_599 = u8::from(output.over_599); + skeleton.scalar_544 = output.scalar_544; + skeleton.no_support_time_548 = output.no_support_time_548; + skeleton.response_strength_580 = output.response_strength_580; + skeleton.extra_weight_584 = output.extra_weight_584; + skeleton.time_until_teleport_576 = output.time_until_teleport_576; + skeleton.teleport_pending_604 = u8::from(output.teleport_pending_604); + skeleton.hips_right_angle_496 = output.hips_right_angle_496; + skeleton.hips_up_angle_500 = output.hips_up_angle_500; + if let Some(value) = output.response_change_588 { + skeleton.response_change_588 = value; + skeleton.response_changed_605 = 1; + } + physical.animation.collision_time_144 = output.collision_time_144; + physical.animation.profile_148 = output.profile_148; + physical.collision.flag_3479 = u8::from(output.material_ten_3479); + physical.collision.flag_3480 = u8::from(output.material_eleven_3480); + physical.collision.flag_3483 = u8::from(output.imminent_surface_twelve_3483); + physical.collision.predicted_position_64 = output.predicted_position_64.map(f32::to_bits); + if let Some(velocity) = output.retained_air_velocity_160 { + physical.air.vector_160 = velocity.map(f32::to_bits); + physical.air.use_air_reckoning_452 = 1; + } + physical.state.surface_height_32 = output.surface_height_32; + let flags = &mut skater.player_state.state_flags; + for (offset, value) in [ + (72, output.can_leave_72), (81, output.special_surface_81), + (82, output.below_surface_82), (83, output.surface_height_valid_83), + ] { + flags[offset - 52] = value; + } + // These source branches only store true, preserving earlier publication. + if output.teleport_countdown_68 { flags[68 - 52] = true; } + if output.request_teleport_69 { + flags[69 - 52] = true; + physical.state.flag_69 = 1; + } +} diff --git a/crates/skate-game/src/physics/render_pose.rs b/crates/skate-game/src/physics/render_pose.rs new file mode 100644 index 00000000..fbd7e945 --- /dev/null +++ b/crates/skate-game/src/physics/render_pose.rs @@ -0,0 +1,100 @@ +//! Finish the actual physical pose and publish it to the single render skin. +//! Call after collision/error/COM output and before the next animation update. +use super::{GamePhysics, SkaterRuntime, solve::deck_frame}; +use skate_core::animation::{foot_ik::post_physics, output}; + +pub(super) fn publish( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + average_compression: [f32; 2], +) -> Result<(), String> { + let deck = deck_frame(&physics.board); + let p = &skater.player_input.processed; + let wiping_out = skater.skeleton_collision.is_ragdoll; + skater.skeleton_output.correction.apply( + &mut skater.skeleton, + p.vectors_544_560_592_608[0].map(f32::from_bits), + wiping_out, + p.flags_2468, + p.flags_2476, + ); + let board = skater + .skeleton_output + .advance_wobble(&mut skater.skeleton, &deck); + skater.foot_ik.post_physics( + &mut skater.skeleton, + post_physics::Input { + state_id: p.state_2508, + category_id: p.category_2512, + board_body_flag_868: physics.riding.ground.part_contact_count != 0, + wipeout: skater.wipeout.requests_wipeout(p), + flags_2468: p.flags_2468, + flags_2484: p.flags_2484, + value_2664: p.state_timer_2664, + state_2520: p.player_state_value_2520, + world_to_animation: &skater.animated_skeleton.roots.world_to_animation, + board: &board, + }, + ); + let feet = skater.foot_physical.publish( + &skater.skeleton.record, + physics.settings.step.simulation.time_step, + ); + let physical = &mut skater.player_input.physical; + physical.skeleton.flag_597 = u8::from(skater.foot_ik.state.contacts.support_failed); + physical.skeleton.flag_600 = u8::from(feet.within_deck_box[0]); + physical.skeleton.flag_601 = u8::from(feet.within_deck_box[1]); + physical.skeleton.anim_to_world_11920 = skater + .animated_skeleton + .roots + .animation_to_world + .map(|v| v.map(f32::to_bits)); + physical.reckoning.vector_16 = skater + .animated_skeleton + .board_frames + .com_velocity + .map(f32::to_bits); + physical.reckoning.vector_64 = skater + .animated_skeleton + .board_frames + .centre_of_mass + .map(f32::to_bits); + let up = physics.riding.reckoning.up; + physical.reckoning.vector_96 = [up.x, up.y, up.z, 0.0].map(f32::to_bits); + + // Use this tick's evaluated animation as the base so unmapped bones keep + // the real animation. The physical worker changes mapped bones and wheels. + let mut globals = skater + .animation + .evaluator + .hierarchy(&skater.animation.pose)?; + let mut locals: Vec<_> = skater + .animation + .pose + .iter() + .copied() + .map(output::sqt_to_matrix) + .collect(); + skater.skeleton_output.publish( + &skater.animated_skeleton, + &skater.skeleton, + &physics.board, + physics.settings.step.base_truck_transforms, + skater.ground.steering.targets, + average_compression, + &mut globals, + &mut locals, + )?; + // Native output changes truck/wheel locals after globals. Recompose those + // final locals for the host skin instead of displaying the stale globals. + output::compose_hierarchy_in_place( + locals.len() as i32, + &skater.animation.evaluator.frames.parents, + 0, + &mut locals, + ) + .map_err(|e| format!("Physical render hierarchy: {e:?}"))?; + skater.render_pose = locals; + skater.pose_generation = skater.pose_generation.wrapping_add(1); + Ok(()) +} diff --git a/crates/skate-game/src/physics/riding_outputs.rs b/crates/skate-game/src/physics/riding_outputs.rs new file mode 100644 index 00000000..dc3dfa60 --- /dev/null +++ b/crates/skate-game/src/physics/riding_outputs.rs @@ -0,0 +1,350 @@ +//! Live board/query/solver outputs used by the riding gameplay owner. +//! Physical calculations stay in core; this owns stock settings and scheduling +//! state. Solved contacts and line probes remain distinct native inputs. +mod probes; +pub(crate) use probes::BoardProbes; +use skate_core::{ + air::body_spin::{self, BodySpinState}, + graph::conditions::SpeedInputs, + math::Vector3, + physics::{ + board::BodyId, + board_ground::{BoardGroundState, WheelLineHit, WheelLineState, wheel_lines}, + board_motion_output::BoardMotionOutput, + board_runtime::BoardRuntime, + board_world::BoardWorld, + }, + point_graph::PointGraph, + riding::{ + ground_orientation::{ + GroundOrientation, GroundOrientationInput, GroundOrientationSettings, + }, + reckoning_frames::ReckoningFrames, + speed_and_slope::SpeedAndSlopeSettings, + }, +}; +use skate_data::collections::Collections; + +/// Pose-side results which the native Skeleton/complete Reckoning transform own. +/// They cannot be inferred from the board body's center of mass or its up axis. +#[derive(Clone, Copy, Debug)] +pub(crate) struct RidingPoseInputs { + /// Skeleton11008 -> Processed752 in82BD8918. + pub com_to_deck: Vector3, + ///Processed2812, supplied by the actual PhysBodySpin animation attribute. + pub body_spin: f32, +} + +pub(crate) struct RidingOutputs { + pub ground: BoardGroundState, + pub wheel_lines: WheelLineState, + pub probes: BoardProbes, + pub reckoning: GroundOrientation, + pub reckoning_frames: ReckoningFrames, + pub body_spin: BodySpinState, + pub motion: BoardMotionOutput, + orientation_settings: GroundOrientationSettings, + tilt_vs_rotation: PointGraph<8>, + tilt_vs_slope: PointGraph<8>, + speed_settings: SpeedAndSlopeSettings, + maximum_ground_angle: f32, + heading_adjust_factor: f32, + pending_wheel_queries: Option<[Option; 4]>, +} +impl RidingOutputs { + ///Board82C0D680 resets CollisionInfo and both probes, preserving7692. + pub fn reset_for_teleport(&mut self) { + let elapsed = self.ground.time_without_wheel_contact; + let drag = self.ground.wheel_linear_drag; + self.ground = BoardGroundState::default(); + self.ground.time_without_wheel_contact = elapsed; + self.ground.wheel_linear_drag = drag; + self.wheel_lines = WheelLineState::default(); + self.probes.reset_results(); + //Original82C0D680 preserves query handles203C..2050. EndBoard + //still consumes the batch submitted before this input-phase reset. + } + pub fn load( + data: &Collections, + board: &BoardRuntime, + processed_flags_2468: u32, + ) -> Result { + let f = |field| data.float("physics_reckoning", "default", field); + let control = |field| { + data.words::<4>("physics_reckoning", "default", field) + .map(|v| v.map(f32::from_bits)) + }; + let curve = |field| graph(data, "physics_reckoning", field); + let deck_curve = data + .words::<20>("physics_reckoning", "default", "DeckAngleUsageVsSpeed")? + .map(f32::from_bits); + let orientation_settings = GroundOrientationSettings { + ground_normal_smoothing: control("GroundNormalSmoothing")?, + up_vector_smoothing_slow: control("UpVectorSmoothingSlow")?, + up_vector_smoothing_fast: control("UpVectorSmoothingFast")?, + dynamic_up_vs_ground_y: curve("DynamicUpVsGroundY")?, + ground_vector_blend: curve("GroundVectorBlendGraph")?, + deck_angle_usage_vs_speed: PointGraph { + x: deck_curve[4..12].try_into().unwrap(), + y: deck_curve[12..20].try_into().unwrap(), + }, + up_vector_smoothing_vs_speed: curve("UpVectorSmoothingVsSpeed")?, + up_vector_max_delta_vs_speed: curve("UpVectorMaxDeltaVsSpeed")?, + ground_blend_max_delta: f("GroundVMaxBlendDelta")?, + up_vector_max_acceleration: f("UpVectorMaxAcceleration")?, + anti_wobble_damping: f("UpVectAntiWobbleDamping")?, + extra_side_damping: f("ExtraSideDamping")?, + minimum_wheels_for_ground_blend: data.integer( + "physics_reckoning", + "default", + "MinWheelsToUseGroundVector", + )? as i32, + }; + let speed_settings = SpeedAndSlopeSettings { + turn_torque_vs_speed: graph(data, "physics_heading", "TurnTorqueVsSpeed")?, + turn_torque_vs_slope: graph(data, "physics_heading", "TurnTorqueVsSlope")?, + heading_adjust_max_speed: data.float( + "physics_heading", + "default", + "HeadingAdjustMaxSpeed", + )?, + }; + let reckoning = GroundOrientation::new(&orientation_settings); + let motion = + BoardMotionOutput::from_board(board, reckoning.ground_normal, processed_flags_2468); + let ground = BoardGroundState::default(); + let heading_adjust_factor = + speed_settings.calculate(ground.wheel_normal.y, motion.forward_speed); + Ok(Self { + ground, + wheel_lines: WheelLineState::default(), + probes: BoardProbes::default(), + reckoning, + reckoning_frames: ReckoningFrames::new(), + body_spin: BodySpinState::new(), + motion, + orientation_settings, + tilt_vs_rotation: curve("TiltVsRotGround")?, + tilt_vs_slope: curve("TiltVsSlopeGround")?, + speed_settings, + maximum_ground_angle: f("MaxAllowedGroundNormalFromUp")?, + heading_adjust_factor, + pending_wheel_queries: None, + }) + } + + ///PostInput82DB5E10 uses preceding physical outputs before state updates. + pub fn prepare_input(&mut self) { + self.heading_adjust_factor = self + .speed_settings + .calculate(self.ground.wheel_normal.y, self.motion.forward_speed); + } + + ///Original82DB5E10 reads current Processed464.y and2612 after Skeleton. + pub fn update_input_heading(&mut self, normal_y: f32, speed: f32) -> f32 { + self.heading_adjust_factor = self.speed_settings.calculate(normal_y, speed); + self.heading_adjust_factor + } + + pub fn heading_adjust_factor(&self) -> f32 { + self.heading_adjust_factor + } + + ///Ground preupdate82D37C50 ->82D38018 submits82D4E250. The host computes + ///that job directly: its heading seed, BodySpin, up filters, full transform, + ///dynamic lean and tilt. This must complete before ground forces consume it. + pub fn update_ground_reckoning( + &mut self, + board: &BoardRuntime, + pose: RidingPoseInputs, + processed_flags_2468: u32, + animation_balance: f32, + coffin: bool, + ) { + let deck = board.part_transforms()[BodyId::Deck.index()]; + //82D4E250 overrides heading1200 from input752 (raw board Z). + //The source takes the previous final frame X for the damping step. + let previous_right = self.reckoning_frames.system[0]; + self.reckoning_frames.heading = [ + deck.basis.columns[2][0], + deck.basis.columns[2][1], + deck.basis.columns[2][2], + 0.0, + ]; + body_spin::update_ground(&mut self.body_spin, pose.body_spin); + let effective = BoardMotionOutput::from_board( + board, + self.reckoning.ground_normal, + processed_flags_2468, + ); + // PrepareBoardToolkit82C01718..1744 selects +1 only for ordered >0. + let direction = if self.motion.forward_speed > 0.0 { + 1.0 + } else { + -1.0 + }; + self.reckoning.update( + &self.orientation_settings, + GroundOrientationInput { + com_to_deck: pose.com_to_deck, + ground_normal: self.ground.wheel_normal, + dynamic_up: self.ground.overall_normal, + speed: self.motion.speed, + wheel_contact_count: i32::from(self.ground.wheel_contact_count), + animation_balance, + deck_angle_curve_input: self.motion.forward_speed * direction, + board_up: vector(deck.basis.columns[1]), + board_forward: vector(deck.basis.columns[2]), + effective_board_forward: vector(effective.effective_basis.columns[2]), + previous_reckoning_right: Vector3::new( + previous_right[0], + previous_right[1], + previous_right[2], + ), + prevent_up_behind_board: coffin, + }, + ); + let up = lanes(self.reckoning.up); + self.reckoning_frames + .calculate_transform(up, lanes(self.reckoning.ground_normal)); + self.reckoning_frames + .calculate_dynamic_lean(up, lanes(self.reckoning.dynamic_up)); + self.reckoning_frames.calculate_tilt( + processed_flags_2468 & 0x100000 != 0, + &self.tilt_vs_rotation, + &self.tilt_vs_slope, + ); + } + + /// Slide82D3A890 seeds1136/1200 from Processed528/96 and calls82D8C8F0 + /// directly. Its data block is exactly the Processed snapshot82D8E5C0. + pub fn update_slide_reckoning( + &mut self, + p: &skate_core::player::input_phase::ProcessedPhysicsInput, + toolkit: &skate_core::physics::board_toolkit::BoardToolkit, + physical_body_spin: f32, + balance: f32, + ) { + let raw = |v:[u32;4]| {let v=v.map(f32::from_bits);Vector3::new(v[0],v[1],v[2])}; + let xyz = |v:[f32;4]| Vector3::new(v[0],v[1],v[2]); + let previous_right = self.reckoning_frames.system[0]; + self.reckoning_frames.heading = toolkit.deck[2]; + self.reckoning.dynamic_up = raw(p.vectors_464_480_496_512_528[4]); + body_spin::update_ground(&mut self.body_spin, physical_body_spin); + self.reckoning.update(&self.orientation_settings, GroundOrientationInput { + com_to_deck: raw(p.animation_com_to_deck_752), + ground_normal: raw(p.vectors_464_480_496_512_528[0]), + dynamic_up: raw(p.vectors_464_480_496_512_528[4]), + speed: p.scalar_2652, + wheel_contact_count: p.wheel_count_2556 as i32, + animation_balance: balance, + deck_angle_curve_input: toolkit.absolute_speed, + board_up: xyz(toolkit.deck[1]), board_forward: xyz(toolkit.deck[2]), + effective_board_forward: xyz(toolkit.effective[2]), + previous_reckoning_right: xyz(previous_right), + prevent_up_behind_board: p.flags_2476 & 0x4000_0000 != 0, + }); + let up = lanes(self.reckoning.up); + self.reckoning_frames.calculate_transform(up, lanes(self.reckoning.ground_normal)); + self.reckoning_frames.calculate_dynamic_lean(up, lanes(self.reckoning.dynamic_up)); + self.reckoning_frames.calculate_tilt(p.flags_2468 & 0x100000 != 0, + &self.tilt_vs_rotation, &self.tilt_vs_slope); + } + + /// StartSkateboardLineTests82DB6310 ->82C07788: four current wheel positions, + /// each queried0.2m along negative Reckoning up, radius0 and static-world + /// backend. World8275EA20 submits these before actor SetUpPhysics. + pub fn start_wheel_queries( + &mut self, + board: &BoardRuntime, + world: &BoardWorld, + ) -> Result<(), String> { + if self.pending_wheel_queries.is_some() { + return Err("Wheel queries were started twice without result publication".into()); + } + let lines = wheel_lines(board, self.reckoning.up); + let mut results = [None; 4]; + for (i, line) in lines.into_iter().enumerate() { + results[i] = world + .query_thin_line(line.start, line.end) + .map_err(|e| format!("Wheel{i} stock line query: {e}"))? + .map(|hit| WheelLineHit { + fraction: hit.geometry.fraction, + normal: hit.geometry.normal, + surface_tag: hit.tag, + }); + } + self.probes.start(board, world)?; + self.pending_wheel_queries = Some(results); + Ok(()) + } + + ///World8275EB10 calls EndBoard8275E6E8 after PlayerInput and before + ///PostInput/state work. These results are available to this frame's state + ///selector, independently of the subsequent solve's contact reports. + pub fn finish_wheel_queries(&mut self) -> Result<(), String> { + let hits = self.pending_wheel_queries.take() + .ok_or("EndBoard requires its submitted wheel query batch")?; + self.wheel_lines.publish(hits); + self.probes.publish() + } + + ///UpdatePostPhysics82C07D20 consumes current solved reports. + ///Contact-dependent inertia drag affects the next step. ProcessOutput + ///82C02A80 publishes actual body rates with filtered normal1216. + pub fn finish_post_physics( + &mut self, + board: &mut BoardRuntime, + board_wiping_out: bool, + processed_flags_2468: u32, + time_step: f32, + ) -> Result<(), String> { + self.ground.update( + board.contact_reports(), + &self.wheel_lines, + self.reckoning.up, + self.maximum_ground_angle, + board_wiping_out, + ); + self.ground.sample_accelerations( + core::array::from_fn(|i| board.bodies()[i].rates.linear_velocity), + time_step, + ); + self.ground.advance_contact_time(time_step); + for (body, drag) in board.bodies_mut()[..4] + .iter_mut() + .zip(self.ground.wheel_linear_drag) + { + body.inertia.linear_drag = drag; + } + self.motion = BoardMotionOutput::from_board( + board, + self.reckoning.ground_normal, + processed_flags_2468, + ); + Ok(()) + } + + pub fn graph_speeds(&self) -> SpeedInputs { + SpeedInputs { + speed: self.motion.ground_speed, + forward_speed: self.motion.forward_speed, + speed_and_slope: self.heading_adjust_factor, + } + } +} +fn vector(v: [f32; 3]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn graph(data: &Collections, class: &str, field: &str) -> Result, String> { + let words = data + .words::<16>(class, "default", field)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[..8].try_into().unwrap(), + y: words[8..].try_into().unwrap(), + }) +} diff --git a/crates/skate-game/src/physics/riding_outputs/probes.rs b/crates/skate-game/src/physics/riding_outputs/probes.rs new file mode 100644 index 00000000..e6a579d5 --- /dev/null +++ b/crates/skate-game/src/physics/riding_outputs/probes.rs @@ -0,0 +1,120 @@ +//! The fifth/sixth board probes, kept in the same query/publication phase as +//! the wheel probes. Geometry is the world's recovered triangle dispatcher. +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, + board_ground::WheelLine, + board_probes::{self, BoardProbeHit, BoardProbeState, WallLineInput}, + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + board_world::{BoardWorld, WorldLineHit}, + }, + player::input_phase::ProcessedPhysicsInput, +}; + +pub(crate) struct BoardProbes { + pub deck: BoardProbeState, + pub wall: BoardProbeState, + wall_line: Option, + pending: Option, +} +struct Pending { + deck: Option, + wall: Option>, +} +impl Default for BoardProbes { + fn default() -> Self { + //Skateboard ctor82C0111C starts optional wall line byte291 clear. + Self { + deck: BoardProbeState::default(), + wall: BoardProbeState::default(), + wall_line: None, + pending: None, + } + } +} +impl BoardProbes { + ///ResetBoardBody82C0D680 clears published probe records, preserving + ///query handles204C/2050 and the wall-query enable bit. + pub fn reset_results(&mut self) { + self.deck = BoardProbeState::default(); + self.wall = BoardProbeState::default(); + } + pub fn wall_contact_frame( + &self, + time2752: f32, + time2756: f32, + ) -> skate_core::riding::grounded::state::board_types::GroundContactFrame { + let v = |p: skate_core::math::Vector3| [p.x, p.y, p.z, 0.]; + skate_core::riding::grounded::state::board_types::GroundContactFrame { + vector_8032: v(self.wall.start), + vector_8048: v(self.wall.point), + vector_8064: v(self.wall.normal), + word_8080: self.wall.surface_tag, + flag_8084: self.wall.hit, + scalar_2752: time2752, + scalar_2756: time2756, + } + } + /// PostPhysics82C02138 calls82C01F10 before updating board contact output. + /// Its saved endpoints and enabled291 feed the NEXT StartBoard82DB6310. + /// Processed112 is the input phase's cached deck translation, published by + ///82C01460, not the newly solved body's current transform. + pub fn prepare_wall(&mut self, input: &ProcessedPhysicsInput, toolkit: &BoardToolkit) { + let vector = |value: [f32; 4]| Vector3::new(value[0], value[1], value[2]); + self.wall_line = board_probes::wall_probe(WallLineInput { + state: input.state_2508, + contact_normal: vector(input.vectors_464_480_496_512_528[0].map(f32::from_bits)), + skater_up: vector(input.vectors_544_560_592_608[0].map(f32::from_bits)), + deck_position: vector(toolkit.deck[3]), + time: input.time_on_ground_2752, + }); + } + pub fn start(&mut self, board: &BoardRuntime, world: &BoardWorld) -> Result<(), String> { + if self.pending.is_some() { + return Err("Board probes submitted twice without publication".into()); + } + let line = + board_probes::deck_probe(board.part_transforms()[BodyId::Deck.index()].translation); + self.deck.start(line.start); + let deck = world + .query_swept_line(line.start, line.end, board_probes::DECK_PROBE_RADIUS) + .map_err(|e| format!("Stock deck ground probe: {e}"))? + .map(hit); + let wall = if let Some(line) = self.wall_line { + self.wall.start(line.start); + Some( + world + .query_thin_line(line.start, line.end) + .map_err(|e| format!("Stock wall floor probe: {e}"))? + .map(hit), + ) + } else { + self.wall.disable(); + None + }; + self.pending = Some(Pending { deck, wall }); + Ok(()) + } + pub fn publish(&mut self) -> Result<(), String> { + let pending = self + .pending + .take() + .ok_or("Board probe publication requires a submitted batch")?; + self.deck.publish(pending.deck); + if let Some(result) = pending.wall { + self.wall.publish(result); + } else { + self.wall.disable(); + } + Ok(()) + } +} +fn hit(hit: WorldLineHit) -> BoardProbeHit { + BoardProbeHit { + point: hit.geometry.position, + normal: hit.geometry.normal, + surface_tag: hit.tag, + } +} diff --git a/crates/skate-game/src/physics/settings.rs b/crates/skate-game/src/physics/settings.rs new file mode 100644 index 00000000..e11a461e --- /dev/null +++ b/crates/skate-game/src/physics/settings.rs @@ -0,0 +1,149 @@ +//! Stock skater XML values bound to the recovered physical calculations. +use skate_core::{ + math::Vector3, + physics::{ + board_step::BoardStepSettings, + contact::RetailContactMaterial, + drive_frames::{ + AuthoredTransformInputs, RetailAffineTransform, RetailTruckTransformInputs, + authored_body_transforms, calculate_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + mass::{ + DeckGeometry, DeckGeometrySettings, MassShape, TruckMassSettings, WheelMassSettings, + deck_mass_properties, truck_mass_input, truck_mass_properties, wheel_mass_properties, + }, + rigid_body::{RetailBodyMassProperties, RetailSimulationStep}, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct PhysicsSettings { + pub step: BoardStepSettings, + pub masses: [RetailBodyMassProperties; 7], + pub authored: [RetailAffineTransform; 7], + pub deck_geometry: DeckGeometry, + pub truck_shape: MassShape, + pub truck_collisions: bool, + pub truck_material: RetailContactMaterial, + pub deck_material: RetailContactMaterial, + pub wheel_radius: f32, + pub wheel_material: RetailContactMaterial, + pub standard_wheel_material: RetailContactMaterial, + pub floor_material: RetailContactMaterial, + pub input_magnitude_threshold: f32, +} + +impl PhysicsSettings { + pub fn load(data: &Collections) -> Result { + let f = |class, field| data.float(class, "default", field); + let b = |class, field| data.boolean(class, "default", field); + let mass_factor = f("physics_world", "SkateboardMassFactor")?; + let wheel_radius = f("physicswheels", "WheelRadius")?; + let wheel_x_distance = f("physicswheels", "WheelXDist")?; + let geometry = AuthoredTransformInputs { + deck_mid_length: f("physicsdeck", "DeckMidLength")?, + wheel_x_distance, + truck_z_position_front: f("physicstrucks", "TruckZPosFront")?, + truck_z_position_back: f("physicstrucks", "TruckZPosBack")?, + truck_y_position: f("physicstrucks", "TruckYPos")?, + }; + let wheel = wheel_mass_properties(WheelMassSettings { + radius: wheel_radius, + mass: f("physicswheels", "WheelMass")?, + mass_factor, + }); + let truck_settings = TruckMassSettings { + wheel_radius, + wheel_x_distance, + mass_factor, + radius_scalar: f("physicstrucks", "RadiusScalar")?, + half_height_scalar: f("physicstrucks", "HalfHeightScalar")?, + mass: f("physicstrucks", "TruckMass")?, + }; + let truck = truck_mass_properties(truck_settings); + let gravity = data + .words::<4>("physics", "default", "WorldGravity")? + .map(f32::from_bits); + let simulation = RetailSimulationStep::fixed_60_hz( + 0, // Our host keeps its single board active; sleeping is not scheduled. + f("physics", "FreezingEnergy")?, + Vector3::new(gravity[0], gravity[1], gravity[2]), + ); + let deck_geometry = DeckGeometry::new(DeckGeometrySettings { + width: f("physicsdeck", "DeckWidth")?, + mid_length: geometry.deck_mid_length, + thickness: f("physicsdeck", "DeckThickness")?, + back_end_size: f("physicsdeck", "DeckBackEndSize")?, + front_end_angle_degrees: f("physicsdeck", "DeckFrontEndAngle")?, + back_end_angle_degrees: f("physicsdeck", "DeckBackEndAngle")?, + end_capsule_count: data.integer("physicsdeck", "default", "DeckEndCapsules")? as i32, + enable_deck_volume_collisions: b("physicsdeck", "DeckEnableDeckVolumeCollisions")?, + enable_end_volume_collisions: b("physicsdeck", "DeckEnableEndVolumeCollisions")?, + }); + let deck = deck_mass_properties( + &deck_geometry, + f("physicsdeck", "DeckMass")? * mass_factor, + f("physicsdeck", "DeckAngularDrag")? * simulation.frequency, + ); + let step = BoardStepSettings { + simulation, + iterations: data.integer("physics", "default", "RWMaxIterations")?, + base_truck_transforms: calculate_truck_transforms(RetailTruckTransformInputs { + deck_mid_length: geometry.deck_mid_length, + truck_z_position_front: geometry.truck_z_position_front, + truck_z_position_back: geometry.truck_z_position_back, + truck_y_position: geometry.truck_y_position, + truck_rotation_axis_angle_degrees: f("physicstrucks", "TruckRotationAxisAngle")?, + }), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings { + use_linear_drives: b("physicstrucks", "UseTruckDrives")?, + use_hard_linear_drives: b("physicsdeck", "UseHardDrives")?, + angular_displacement: f("physicstrucks_drives", "Angular_Hard_Displacement")?, + angular_damping: f("physicstrucks_drives", "Angular_Hard_Damping")?, + angular_strength: f("physicstrucks_drives", "Angular_Hard_Strength")?, + }), + force_point_y_offset: f("physicsdeck", "DeckForceYOffset")?, + }; + if wheel_radius <= 0.0 || mass_factor <= 0.0 || step.iterations == 0 { + return Err("Invalid stock board radius, mass factor or solver iterations".into()); + } + Ok(Self { + step, + masses: [wheel, wheel, wheel, wheel, truck, truck, deck], + authored: authored_body_transforms(geometry), + deck_geometry, + truck_shape: truck_mass_input(truck_settings).shape, + truck_collisions: b("physicstrucks", "TruckEnableVolumeCollisions")?, + truck_material: RetailContactMaterial { + static_friction: f("physicstrucks", "TruckStaticFriction")?, + dynamic_friction: f("physicstrucks", "TruckDynamicFriction")?, + restitution: f("physicstrucks", "TruckRestitution")?, + }, + deck_material: RetailContactMaterial { + static_friction: f("physicsdeck", "DeckStaticFriction")?, + dynamic_friction: f("physicsdeck", "DeckDynamicFriction")?, + restitution: f("physicsdeck", "DeckRestitution")?, + }, + wheel_radius, + wheel_material: RetailContactMaterial { + static_friction: f("physicswheels", "WheelStaticFriction")?, + dynamic_friction: f("physicswheels", "WheelDynamicFriction")?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + standard_wheel_material: RetailContactMaterial { + static_friction: f("physicswheels", "WheelStaticFriction")?, + dynamic_friction: f("physicswheels", "WheelDynamicFriction")?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + floor_material: RetailContactMaterial { + // Ground job8277C5D8 uses context83034F34/38/3C. The + // max/max/min combine preserves the moving volume material. + static_friction: 0.0, + dynamic_friction: 0.0, + restitution: 1.0, + }, + input_magnitude_threshold: f("inputlistener", "StickMagnitudeMinToCountHeld")?, + }) + } +} diff --git a/crates/skate-game/src/physics/skateboard_controller.rs b/crates/skate-game/src/physics/skateboard_controller.rs new file mode 100644 index 00000000..f548d625 --- /dev/null +++ b/crates/skate-game/src/physics/skateboard_controller.rs @@ -0,0 +1,12 @@ +//! Actual hand-controller lifecycle and board contact producer binding. +use skate_core::physics::board_ground::BoardGroundState; +pub(crate) use skate_core::physics::skateboard_controller::SkateboardController; + +pub(crate) fn partial_request( + controller: &SkateboardController, + ground: &BoardGroundState, +) -> bool { + //82C08450..8494 counts Board844..850 into byte868. This is not a + //geometric overlap estimate or the separate four-wheel count869. + controller.request_partial_ragdoll(ground.part_contact_count) +} diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs new file mode 100644 index 00000000..85e49669 --- /dev/null +++ b/crates/skate-game/src/physics/skater.rs @@ -0,0 +1,243 @@ +//! The game's single skater: stock graph animation and its physical skeleton. +//! Loading creates the real bodies from the animator's initial evaluated pose. +use super::{ + GamePhysics, + animated_skeleton::AnimatedSkeleton, + animation_feedback::AnimationFeedback, + animation_input::AnimationInput, + foot_ik::FootIk, + foot_physical_output::FootPhysicalOutputs, + ground_runtime::{GroundRuntime, GroundSettings, GroundState}, + player_input::{NoGrindEdges, PlayerInputRuntime}, + skeleton_body, + skeleton_input_runtime::SkeletonInputRuntime, + skeleton_output::SkeletonOutput, +}; +use crate::{graph_runtime::StockGraphs, skater_animation::SkaterAnimation}; +use bevy::prelude::Resource; +use skate_core::physics::{ + board::BodyId, + skeleton_animation_record::{IDENTITY, map_animation_parts}, + skeleton_body::{ + SkeletonBody, SkeletonCollisionMode, SkeletonDriveBatch, SkeletonDrives, SkeletonJoints, + }, +}; +use skate_data::collections::Collections; +use std::path::Path; + +#[derive(Resource)] +pub(crate) struct SkaterRuntime { + /// Completed physical pose in native animation space, read by rendering. + pub render_pose: Vec, + pub pose_generation: u64, + pub centre_of_mass_filter: skate_core::physics::centre_of_mass_filter::CentreOfMassFilter, + pub centre_of_mass_output: skate_core::physics::centre_of_mass_filter::CentreOfMassOutput, + pub animation: SkaterAnimation, + pub animated_skeleton: AnimatedSkeleton, + pub skeleton_air: super::skeleton_air::SkeletonAir, + pub air_reckoning: super::air_reckoning::AirReckoning, + pub air_state: skate_core::air::state::PhysicsAirState, + pub air_settings: super::air_phase::AirSettings, + pub known_air: super::known_air::KnownAir, + pub ground_animation: super::ground_animation::GroundAnimationRuntime, + pub ground_animation_settings: super::ground_animation::GroundAnimationSettings, + pub slide_state: super::slide_state::SlideState, + pub trajectory: super::air_trajectory::AirTrajectoryRuntime, + pub footplant: super::footplant::Footplant, + pub wipeout: super::wipeout::Wipeout, + pub wipeout_state: super::wipeout_states::WipeoutState, + pub teleport_state: super::teleport_state::Runtime, + pub skeleton: SkeletonBody, + pub skeleton_joints: SkeletonJoints, + pub skeleton_drives: SkeletonDrives, + pub skeleton_collision: SkeletonCollisionMode, + pub collision_feedback: skate_core::physics::skeleton_body::SkeletonCollisionFeedback, + pub pose_errors: skate_core::physics::skeleton_body::SkeletonPoseErrors, + pub collision_pose_error: [f32; 4], + ///Skeleton16384 is published by the completed pose-error response. Wipeout + ///runs after that publication; no fabricated pre-solve measurement exists. + pub collision_maximum_error: Option, + pub solved_drives: Option, + pub foot_ik: FootIk, + pub foot_physical: FootPhysicalOutputs, + pub player_input: PlayerInputRuntime, + pub player_state: super::player_state::PlayerState, + pub skeleton_input: SkeletonInputRuntime, + pub ground: GroundState, + pub ground_runtime: GroundRuntime, + pub ground_settings: GroundSettings, + pub ground_lifecycle: super::ground_phase::GroundLifecycle, + pub animation_input: AnimationInput, + pub animation_feedback: AnimationFeedback, + pub physical_feedback: skate_core::animation::physical_feedback::PhysicalFeedback, + pub landing_quality: skate_core::animation::landing_quality::Output, + pub landing_quality_settings: skate_core::animation::landing_quality::Settings, + pub skeleton_output: SkeletonOutput, + pub skateboard_controller: super::skateboard_controller::SkateboardController, +} + +impl SkaterRuntime { + pub fn load( + asset_root: &Path, + graphs: &StockGraphs, + physics: &GamePhysics, + mode: &str, + ) -> Result { + let data = Collections::load(asset_root)?; + // The host's current character is a custom skater with no pro selector + // or equipped physical hat. These are profile choices, not force values. + let mut animation = SkaterAnimation::load(asset_root, &data, graphs, b"")?; + let initial_hierarchy = animation.evaluate_initial_pose()?; + let mut animated_skeleton = + AnimatedSkeleton::load(asset_root, &data, &animation.evaluator.frames, false)?; + let initial_parts = map_animation_parts( + &initial_hierarchy, + &animated_skeleton.bone_indices, + &animated_skeleton.physics_frames, + )?; + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let mut spawn = IDENTITY; + for (column, axis) in deck.basis.columns.iter().enumerate() { + spawn[column][..3].copy_from_slice(axis); + } + spawn[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + let mut skeleton = skeleton_body::load( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + &initial_parts, + spawn, + physics.settings.step.simulation, + None, + )?; + animated_skeleton + .roots + .reset_initial_alignment(skeleton.animation_to_world); + animated_skeleton.board_frames.reset(spawn); + for _ in 0..2 { + animated_skeleton.board_frames.publish_centre_of_mass( + skeleton.record.centre_of_mass, + physics.settings.step.simulation.time_step, + 0, + ); + } + animated_skeleton + .board_frames + .publish_local_observations(&animated_skeleton.roots, &spawn); + let animation_input = AnimationInput::load(&data, &animation.evaluator.frames, mode)?; + let skeleton_joints = skeleton_body::load_joints( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + &initial_hierarchy, + &animation.evaluator.frames.parents, + &animated_skeleton.bone_indices, + )?; + let mut skeleton_drives = skeleton_body::load_drives( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + &initial_hierarchy, + &animated_skeleton.bone_indices, + &initial_parts, + &skeleton_joints, + skeleton.animation_to_world, + spawn, + physics.settings.step.simulation, + )?; + // Reset82BD9990 finishes by publishing both COM targets to the + // actual extra bodies. These bodies subsequently share the solver. + let initial_targets = skeleton_drives.targets.update_extra_targets( + &mut skeleton, + &animated_skeleton.board_frames.com_frame, + &animated_skeleton.board_frames.lifted_com_frame, + ); + let foot_ik = FootIk::load(&data, &animation.evaluator.frames, &animated_skeleton)?; + let skeleton_output = + SkeletonOutput::load(&data, &animation.evaluator.frames, &animated_skeleton)?; + // Actor82591448 passes network || ghost for self-pair suppression. + // This local game has neither networking nor a ghost actor. + let skeleton_collision = skeleton_body::load_collision( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + false, + )?; + let wipeout_state = super::wipeout_states::WipeoutState::load( + &data, asset_root, &animation.evaluator.frames.source_sha256, + )?; + Ok(Self { + render_pose: initial_hierarchy, + pose_generation: 0, + centre_of_mass_filter: Default::default(), + // PhysOut reset82DE53F0 clears these observations before first output. + centre_of_mass_output: skate_core::physics::centre_of_mass_filter::CentreOfMassOutput { + velocity: [0.0; 4], + acceleration: [0.0; 4], + position: [0.0; 4], + }, + animation, + landing_quality: Default::default(), + landing_quality_settings: super::landing_quality::load(&data)?, + animated_skeleton, + skeleton_air: super::skeleton_air::SkeletonAir::load(&data)?, + air_reckoning: super::air_reckoning::AirReckoning::load(&data)?, + air_state: Default::default(), + air_settings: super::air_phase::AirSettings::load(&data)?, + known_air: super::known_air::KnownAir::load(&data)?, + ground_animation: Default::default(), + ground_animation_settings: super::ground_animation::GroundAnimationSettings::load(&data)?, + slide_state: super::slide_state::SlideState::load(&data)?, + trajectory: super::air_trajectory::AirTrajectoryRuntime::load(&data)?, + footplant: super::footplant::Footplant::load(&data)?, + wipeout: super::wipeout::Wipeout::load(&data)?, + wipeout_state, + teleport_state: super::teleport_state::Runtime::new(super::teleport_state::Checkpoint { + transform: spawn, on_board: true, + }), + skeleton, + skeleton_joints, + skeleton_drives, + collision_feedback: skeleton_body::load_feedback(&data, skeleton_collision.settings)?, + skeleton_collision, + pose_errors: skate_core::physics::skeleton_body::SkeletonPoseErrors { + targets: [ + initial_targets.com, + initial_targets.lifted_com, + initial_targets.following_com, + ], + ..Default::default() + }, + collision_pose_error: [0.0; 4], + collision_maximum_error: None, + solved_drives: None, + foot_ik, + foot_physical: FootPhysicalOutputs::load(&data)?, + player_input: PlayerInputRuntime::load(&data, NoGrindEdges)?, + player_state: super::player_state::PlayerState::load(&data, mode)?, + skeleton_input: SkeletonInputRuntime { + drive_frames: initial_parts, + extra_target_positions: [ + initial_targets.com, + initial_targets.lifted_com, + initial_targets.following_com, + ], + ..Default::default() + }, + ground: GroundState::load(&data, mode, true)?, + ground_runtime: GroundRuntime::load(&data)?, + ground_settings: GroundSettings::load(&data, mode, "smooth")?, + ground_lifecycle: super::ground_phase::GroundLifecycle::new(), + animation_input, + animation_feedback: AnimationFeedback::load(&data)?, + physical_feedback: super::animation_phase::initial_feedback(), + skeleton_output, + skateboard_controller: super::skateboard_controller::SkateboardController::new(), + }) + } +} diff --git a/crates/skate-game/src/physics/skeleton_air.rs b/crates/skate-game/src/physics/skeleton_air.rs new file mode 100644 index 00000000..19f8b761 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_air.rs @@ -0,0 +1,178 @@ +//! Production animated-board and COM-controlled air Skeleton updates. +//! Original TU3 82BDDA10 / 82BDE600. All owners are the same objects used by +//! ProcessData, GeneralUpdate, the shared solve and final pose publication. +use super::skeleton_input_runtime::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}; +use skate_core::{ + animation::output::{NativeMatrix, physics_packet::PhysicsPosePacket}, + math::{Basis3, Vector3}, + physics::{ + board::BodyId, + board_animation::{BoardAnimation, BoardAnimationSettings, target_velocity}, + board_runtime::BoardRuntime, + drive_frames::RetailAffineTransform, + rigid_body::RetailSimulationStep, + skeleton_air_frames::{self, AirDismountRevert}, + skeleton_animation_record::AnimationPartTransform as Transform, + }, + player::input_phase::ProcessedPhysicsInput, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +///The original board's persistent blend history, shared across Ground/Air +///state changes. This must live alongside the physical board, not per entry. +pub(crate) struct SkeletonAir { + pub board_animation: BoardAnimation, + settings: BoardAnimationSettings, +} +impl SkeletonAir { + pub fn load(data: &Collections) -> Result { + let graph = |name| -> Result, String> { + //Original82C01900: Globals260 layout4, graphs0/80; + //stock physics_airstates/default has80-byte PointNegGraphData8. + let words = data + .words::<20>("physics_airstates", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[4..12].try_into().unwrap(), + y: words[12..20].try_into().unwrap(), + }) + }; + Ok(Self { + board_animation: BoardAnimation::default(), + settings: BoardAnimationSettings { + slow: graph("PhysToAnimSlow")?, + fast: graph("PhysToAnimFast")?, + }, + }) + } + + ///Original82D38000 GroundUpdate ->82D38008 UpdatePhysicsError. Call after + ///Skeleton Ground GeneralUpdate and before the physical solve, every tick. + pub fn capture_physics_error(&mut self, board: &BoardRuntime, animated_target: &Transform) { + self.board_animation.capture_physics_error( + animated_target, + &matrix(board.part_transforms()[BodyId::Deck.index()]), + ); + } + + ///82C0606C; preserves the source's independently retained blending flag. + pub fn reset_board(&mut self) { + self.board_animation.reset(); + } + + ///82C041C0: publish the effective matrix to the separate deck constraint + ///anchor. It does not teleport the dynamic board assembly. + fn apply_board( + &mut self, + board: &mut BoardRuntime, + target: &Transform, + fast: bool, + ) -> Transform { + let effective = self.board_animation.apply(target, fast, &self.settings); + board.set_hook_transform(affine(effective)); + effective + } +} + +impl SkeletonInputRuntime { + ///82BDDA10. PhysicsAir passes fast=false; GroundAnimation passes true. + ///Returns Processed0..48 / Skeleton15952, the UNBLENDED animation target. + ///The effective blended matrix is published to Skeleton12496 separately. + pub fn update_animated( + &mut self, + air: &mut SkeletonAir, + board: &mut BoardRuntime, + reckoning: &Transform, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + fast_blend: bool, + ) -> Result { + let s = &mut owners.animated; + //Unlike Ground82BDF530, this does NOT force initialize_heading=true. + s.update_roots(board, reckoning, p.timestep_2604); + let target = skeleton_air_frames::prepare_animated( + &s.roots, + &mut s.board_frames, + &self.drive_frames[0], + &mut p.flags_2468, + ); + s.board_frames.physical_board = air.apply_board(board, &target, fast_blend); + self.general_update(p, owners, globals, collision, simulation)?; + //82BDDC20..54 clears only next trajectory, after GeneralUpdate. + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(target) + } + + ///82BDE600. target_com is PhysicsAir's actually integrated COM trajectory + ///position (82D34A28 r5), not a predicted board position or skeleton root. + ///The animation packet is the real82B985E8 publication, including its + ///consumed dismount-revert request and retained frame count. + pub fn update_known_air( + &mut self, + air: &mut SkeletonAir, + board: &mut BoardRuntime, + reckoning: &Transform, + target_com: [f32; 4], + packet: &PhysicsPosePacket, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + let s = &mut owners.animated; + skeleton_air_frames::update_known_air_roots( + &mut s.roots, + reckoning, + target_com, + s.record.centre_of_mass, + AirDismountRevert { + requested: packet.flags & (1 << 28) != 0, + //The native load zero-extends all32bits before fcfid. + frames: packet.air_dismount_revert_frames as u32, + goofy: p.flags_2476 & (1 << 2) != 0, + }, + ); + let target = skeleton_air_frames::prepare_known_air( + &s.roots, + &mut s.board_frames, + &self.drive_frames[0], + &mut p.flags_2468, + ); + let effective = air.apply_board(board, &target, true); + s.board_frames.physical_board = effective; + //82C04270 uses the deck BODY COM, not Part6's mass-offset transform. + let pos = 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 board.bodies_mut() { + body.rates.linear_velocity = Vector3::new(velocity[0], velocity[1], velocity[2]); + } + skeleton_air_frames::finish_known_air(&mut s.roots, &mut s.board_frames, effective); + self.general_update(p, owners, globals, collision, simulation)?; + owners.animation_input.fields.flags2468 = p.flags_2468; + //KnownAir does not reset next_trajectory at the end of this method. + Ok(target) + } +} + +fn matrix(t: RetailAffineTransform) -> Transform { + let mut m = [[0.0; 4]; 4]; + for i in 0..3 { + m[i][..3].copy_from_slice(&t.basis.columns[i]); + } + m[3] = [t.translation.x, t.translation.y, t.translation.z, 0.0]; + m +} +fn affine(m: Transform) -> RetailAffineTransform { + RetailAffineTransform { + basis: Basis3 { + columns: std::array::from_fn(|i| m[i][..3].try_into().unwrap()), + }, + translation: Vector3::new(m[3][0], m[3][1], m[3][2]), + } +} diff --git a/crates/skate-game/src/physics/skeleton_body.rs b/crates/skate-game/src/physics/skeleton_body.rs new file mode 100644 index 00000000..8424c8c4 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_body.rs @@ -0,0 +1,309 @@ +//! Stock physical body construction. Initial mapped pose and spawn are real +//! actor/physics inputs supplied by the coordinator; no pose or COM fallback. +use skate_core::{ + math::Vector3, + physics::{ + rigid_body::RetailSimulationStep, + skeleton_animation_record::AnimationPartTransform, + skeleton_body::{ + BoneSettings, HatGeometry, SkeletonBody, SkeletonBodyDefinition, SkeletonBodySettings, + }, + }, +}; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; +#[path = "skeleton_body_drive_settings.rs"] +mod drive_settings; +#[path = "skeleton_body_collision_settings.rs"] +mod collision_settings; + +pub(crate) fn load_collision(asset_root: &Path, data: &Collections, bank_sha: &str, + cull_all_self_pairs: bool) -> Result { + Ok(skate_core::physics::skeleton_body::SkeletonCollisionMode::new_normal( + collision_settings::load(asset_root, data, bank_sha)?, cull_all_self_pairs)) +} + +pub(crate) fn load_feedback(data: &Collections, + settings: skate_core::physics::skeleton_body::SkeletonCollisionSettings) + -> Result { + Ok(skate_core::physics::skeleton_body::SkeletonCollisionFeedback::new( + collision_settings::feedback(data,settings)?)) +} + +/// Build the complete original26-part drive owner using the already-resolved +/// joint topology. The initial bone matrices precede physical-volume mapping. +pub(crate) fn load_drives( + asset_root: &Path, + data: &Collections, + animation_bank_sha256: &str, + initial_hierarchy: &[AnimationPartTransform], + bone_indices: &[usize; 24], + initial_mapped: &[AnimationPartTransform; 24], + joints: &skate_core::physics::skeleton_body::SkeletonJoints, + animation_to_world: AnimationPartTransform, + spawn: AnimationPartTransform, + simulation: RetailSimulationStep, +) -> Result { + use skate_core::physics::skeleton_body::{SkeletonDriveSettings, SkeletonDrives}; + if bone_indices.iter().any(|i| *i >= initial_hierarchy.len()) { + return Err("Skeleton drive initial hierarchy has missing bones".into()); + } + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + animation_bank_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Skeleton drives require24 bone records".into()); + } + let mut parents = [None; 24]; + for joint in &joints.records { + parents[joint.child] = Some(joint.parent); + } + let initial_bones = std::array::from_fn(|i| initial_hierarchy[bone_indices[i]]); + let mut collision_strength = [[0.0; 2]; 24]; + //82BE7B98..BAC builds057EDDFA341F9492=CollisionDriveScalar. + let scalar = data.float("physics_skeleton_drives", "default", "CollisionDriveScalar")?; + for part in 1..24 { + let name = format!("PART_{}", skeleton.bones[part].name); + let words = data.words::<9>("physics_skeleton_drives", "default", &name)?; + collision_strength[part] = [ + f32::from_bits(words[5]) * scalar, + f32::from_bits(words[6]) * scalar, + ]; + } + let settings = SkeletonDriveSettings { + bone: drive_settings::load(data)?, + enabled: data.boolean("animation", "default", "Use_Drives")?, + strength: [ + data.float("animation", "default", "DriveStrengthLocal")?, + data.float("animation", "default", "DriveStrengthRootLocal")?, + ], + collision_strength, + }; + SkeletonDrives::new( + &initial_bones, + initial_mapped, + &parents, + animation_to_world, + spawn, + simulation, + settings, + ) + .map_err(str::to_string) +} + +/// SkeletonData::Init82BD6C40 / closest physical ancestor82BD3720. +/// Both the rig pose and hierarchy are the actor's evaluated initial RIG_TPOSE. +pub(crate) fn load_joints( + asset_root: &Path, + data: &Collections, + animation_bank_sha256: &str, + initial_hierarchy: &[AnimationPartTransform], + hierarchy_parents: &[i32], + bone_indices: &[usize; 24], +) -> Result { + use skate_core::physics::{ + skeleton_animation_record::physics_bone_frame, + skeleton_body::{JointBone, JointSettings, SkeletonJointSettings, SkeletonJoints}, + }; + if hierarchy_parents.len() != initial_hierarchy.len() + || bone_indices.iter().any(|i| *i >= initial_hierarchy.len()) + { + return Err("Physical joint hierarchy differs from initial rig pose".into()); + } + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + animation_bank_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Physical joints require24 bone records".into()); + } + let initial_bones = std::array::from_fn(|i| initial_hierarchy[bone_indices[i]]); + let mut parents = [None; 24]; + for child in 0..24 { + let mut parent = hierarchy_parents[bone_indices[child]]; + let mut visited = 0; + while parent != -1 { + let index = usize::try_from(parent).map_err(|_| "Invalid animation parent index")?; + if index >= hierarchy_parents.len() || visited >= hierarchy_parents.len() { + return Err("Invalid or cyclic animation hierarchy".into()); + } + if let Some(physical) = bone_indices.iter().position(|bone| *bone == index) { + parents[child] = Some(physical); + break; + } + parent = hierarchy_parents[index]; + visited += 1; + } + } + let bones = std::array::from_fn(|i| { + let bone = &skeleton.bones[i]; + let [x, y, z] = bone.translation(); + JointBone { + parent_orientation: std::array::from_fn(|lane| f32::from_bits(bone.words[lane])), + joint_orientation: std::array::from_fn(|lane| f32::from_bits(bone.words[4 + lane])), + volume_frame: physics_bone_frame(bone.rotation(), [x, y, z, 0.0]), + swing_limit: f32::from_bits(bone.words[21]), + twist_limit: f32::from_bits(bone.words[20]), + } + }); + // InitAttribData stores these in ascending physical child order1..22. + let names = [ + "JOINT_NECK_NECK1", + "JOINT_SPINE3_NECK", + "JOINT_LEFT_FOREARM_HAND", + "JOINT_LEFT_ARM_FOREARM", + "JOINT_LEFT_SHOULDER_ARM", + "JOINT_SPINE3_LEFT_SHOULDER", + "JOINT_RIGHT_FOREARM_HAND", + "JOINT_RIGHT_ARM_FOREARM", + "JOINT_RIGHT_SHOULDER_ARM", + "JOINT_SPINE3_RIGHT_SHOULDER", + "JOINT_SPINE2_SPINE3", + "JOINT_SPINE1_SPINE2", + "JOINT_SPINE_SPINE1", + "JOINT_HIPS_SPINE", + "JOINT_LEFT_FOOT_TOE_BASE", + "JOINT_LEFT_LEG_FOOT", + "JOINT_LEFT_UPLEG_LEG", + "JOINT_HIPS_LEFT_LEG", + "JOINT_RIGHT_FOOT_TOE_BASE", + "JOINT_RIGHT_LEG_FOOT", + "JOINT_RIGHT_UPLEG_LEG", + "JOINT_HIPS_RIGHT_LEG", + ]; + let mut settings = Vec::with_capacity(22); + for name in names { + let words = data.words::<5>("physics_skeleton_joints", "default", name)?; + settings.push(JointSettings { + ball_joint: words[0] >> 24 != 0, + swing_angle: f32::from_bits(words[1]), + twist_angle: f32::from_bits(words[2]), + swing_ragdoll: f32::from_bits(words[3]), + twist_ragdoll: f32::from_bits(words[4]), + }); + } + let global = SkeletonJointSettings { + displacement_limit: data + .words::<4>("physics_skeleton", "default", "DisplacementLimit")? + .map(f32::from_bits), + twist_displacement_limit: data.float( + "physics_skeleton", + "default", + "TwistDisplacementLimit", + )?, + swing_displacement_limit: data.float( + "physics_skeleton", + "default", + "SwingDisplacementLimit", + )?, + enforce_swing_free: data.boolean( + "physics_skeleton_joints", + "default", + "EnforceSwingFree", + )?, + enforce_twist_free: data.boolean( + "physics_skeleton_joints", + "default", + "EnforceTwistFree", + )?, + }; + SkeletonJoints::new( + &initial_bones, + &parents, + &bones, + &settings + .try_into() + .map_err(|_| "Physical joint setting count")?, + global, + ) + .map_err(str::to_string) +} + +pub(crate) fn load( + asset_root: &Path, + data: &Collections, + animation_bank_sha256: &str, + initial_mapped_pose: &[AnimationPartTransform; 24], + spawn: AnimationPartTransform, + simulation: RetailSimulationStep, + hat_collection: Option<&str>, +) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + animation_bank_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Physical skeleton requires24 bone records".into()); + } + let mut sizes = [Vector3::ZERO; 24]; + let mut bones = Vec::with_capacity(24); + for (i, bone) in skeleton.bones.iter().enumerate() { + let [x, y, z] = bone.size(); + sizes[i] = Vector3::new(x, y, z); + let w = data.words::<6>( + "physics_skeleton_bones", + "default", + &format!("PART_{}", bone.name), + )?; + bones.push(BoneSettings { + mass_factor: f32::from_bits(w[0]), + ragdoll_mass_factor: f32::from_bits(w[1]), + has_collision: w[2] >> 24 != 0, + use_root_drive: (w[2] >> 16) & 255 != 0, + volume_type: w[3], + volume_scalar: f32::from_bits(w[4]), + num_parents: w[5], + }); + } + let value = |name| data.float("physics_skeleton", "default", name); + let settings = SkeletonBodySettings { + density: value("MassOfSkeleton")?, + root_radius: value("SkateRootCapsuleRadius")?, + root_half_length: value("SkateRootCapsuleLength")?, + capsule_radius_scalar: data.float( + "physics_skeleton_bones", + "default", + "CapsuleRadiusScalar", + )?, + capsule_length_scalar: value("CapsuleLengthScalar")?, + ragdoll_inverse_mass_factor: data.float( + "physics_wipeout", + "default", + "RagdollInvMassFactor", + )?, + inertia_multiply_type: data.integer("animation", "default", "InertiaMultiplyType")?, + inertia_factor: data.float("animation", "default", "InertiaMultFactor")?, + }; + let hat = if let Some(key) = hat_collection { + let vector = |name| -> Result { + let w = data + .words::<4>("physics_hat", key, name)? + .map(f32::from_bits); + Ok(Vector3::new(w[0], w[1], w[2])) + }; + Some(HatGeometry::from_offsets( + data.float("physics_hat", key, "Hash_372DF5DB27AF1C79")?, + data.float("physics_hat", key, "Thickness")?, + vector("OrientationOffset")?, + vector("PosOffset")?, + )) + } else { + None + }; + let definition = SkeletonBodyDefinition::new( + sizes, + bones.try_into().map_err(|_| "Skeleton bone count")?, + settings, + hat, + )?; + Ok(SkeletonBody::new( + definition, + initial_mapped_pose, + spawn, + simulation, + )) +} diff --git a/crates/skate-game/src/physics/skeleton_body_collision_settings.rs b/crates/skate-game/src/physics/skeleton_body_collision_settings.rs new file mode 100644 index 00000000..34d0774f --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_body_collision_settings.rs @@ -0,0 +1,75 @@ +//! Original82BE7860/82BE7280 and exact locations in stock physics_skeleton XML. +use skate_core::physics::{ + contact::RetailContactMaterial, + skeleton_body::{SkeletonCollisionSettings, SkeletonFeedbackSettings}, +}; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; + +pub(super) fn load( + asset_root: &Path, + data: &Collections, + bank_sha: &str, +) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + bank_sha, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Skeleton collision requires24 bone records".into()); + } + let mut compliant = [false; 24]; + let mut priority = [0.0; 24]; + compliant[0] = true; //82BE7484; part0 priority is cleared by collisionReset. + for part in 1..24 { + let words = data.words::<9>( + "physics_skeleton_drives", + "default", + &format!("PART_{}", skeleton.bones[part].name), + )?; + compliant[part] = words[7] >> 24 != 0; + priority[part] = f32::from_bits(words[8]); + } + // XML baseC8840; frictionC8904, restitutionC88EC, enabledC8910, + // effectC8918 => native offsets196,172,208,216 respectively. + let friction = data.float("physics_skeleton", "default", "FrictionNormal")?; + Ok(SkeletonCollisionSettings { + enabled: data.boolean("physics_skeleton", "default", "EnableCollision")?, + normal_material: RetailContactMaterial { + static_friction: friction, + dynamic_friction: friction, + restitution: data.float("physics_skeleton", "default", "RestitutionNormal")?, + }, + compliant, + priority, + effect_time: data.float("physics_skeleton", "default", "CollisionEffectTime")?, + }) +} + +/// GlobalAttributeCollection+268 binds original low hashes7AA75DF9/2D7D2152 +/// at8289F634: physics_collision/default. Stock XML baseC6AE0 independently +/// resolves every layout offset consumed by82BD4A30 and82BD6038. +pub(super) fn feedback( + data: &Collections, + body: SkeletonCollisionSettings, +) -> Result { + let value = |name| data.float("physics_collision", "default", name); + let vector = |name| -> Result<[f32; 4], String> { + Ok(data + .words::<4>("physics_collision", "default", name)? + .map(f32::from_bits)) + }; + Ok(SkeletonFeedbackSettings { + body, + small_object_mass: data.float("physics_skeleton", "default", "SmallObjectMassThreshold")?, + ground_plane_max_distance: value("GroundPlaneMaxDist")?, //136 + ground_plane_max_angle: value("GroundPlaneMaxAngle")?, //140 + skater_scalar: value("SkaterSkeletonScalar")?, //116 + ai_scalar: value("AISkeletonScalar")?, //188 + groin_offset: vector("GroinLocalOffset")?, //0 + face_offset: vector("FaceLocalOffset")?, //16 + groin_radius: value("GroinRadius")?, //144 + face_radius: value("FaceRadius")?, //168 + }) +} diff --git a/crates/skate-game/src/physics/skeleton_body_drive_settings.rs b/crates/skate-game/src/physics/skeleton_body_drive_settings.rs new file mode 100644 index 00000000..fe326c6a --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_body_drive_settings.rs @@ -0,0 +1,56 @@ +//! Original BoneDrives constructor82BEC4A0/82BEC7B0 collection bindings. +//!82BEC50C..544 builds local=00132D1E84F0BC32;82BEC5B4..D4 builds +//! root=550B23128E0C3838. The12 transition keys preserve native offsets232..276. +use skate_core::physics::skeleton_body::{ + AnimationDriveSettings, BoneDriveSettings, DriveInterpolation, +}; +use skate_data::collections::Collections; + +pub(super) fn load(data: &Collections) -> Result { + let scalar = |name| data.float("physics_skeleton_drives", "default", name); + let animation = |key| -> Result { + let f = |name| data.float("animation_drives", key, name); + Ok(AnimationDriveSettings { + linear_strength: f("LinearStrMin")?, + linear_displacement: f("LinearDispMin")?, + angular_strength: f("AngularStrMin")?, + angular_displacement: f("AngularDispMin")?, + }) + }; + Ok(BoneDriveSettings { + animation: [animation("local")?, animation("root")?], + collision_soft_displacement: scalar("CollisionSoftDisp")?, + collision_soft_strength: scalar("CollisionSoftStrength")?, + ragdoll_soft_displacement: scalar("RagdollSoftDisp")?, + ragdoll_soft_strength: scalar("RagdollSoftStrength")?, + transition_linear: DriveInterpolation { + spring: [ + scalar("Hash_138976EAF99DF926")?, + scalar("Hash_C313093118C7A83F")?, + ], + strength: [ + scalar("Hash_1E6BDF62043784FC")?, + scalar("Hash_BD3E4F132848A588")?, + ], + damping: [ + scalar("Hash_D7652B4B19BB4747")?, + scalar("Hash_C4B564DFD233CF04")?, + ], + }, + transition_angular: DriveInterpolation { + spring: [ + scalar("Hash_14A2DDDD0A35C267")?, + scalar("Hash_24D5AA7F214A901C")?, + ], + strength: [ + scalar("Hash_6C6CCD16A0A438AD")?, + scalar("Hash_7379F8AB250BF611")?, + ], + damping: [ + scalar("Hash_28446A9E25DCC04C")?, + scalar("Hash_B59D493B3DB372C8")?, + ], + }, + transition_calls: scalar("Hash_E1B89BB09D725EEC")?, + }) +} diff --git a/crates/skate-game/src/physics/skeleton_colliders.rs b/crates/skate-game/src/physics/skeleton_colliders.rs new file mode 100644 index 00000000..4c4c67e2 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_colliders.rs @@ -0,0 +1,80 @@ +//! Bind original physical skater shapes to the bodies receiving solver reactions. +//! The collision owner selects enabled parts and materials; shapes and local-Z +//! capsule axes come from the same stock definition used to construct each part. +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + board_step::CollisionBody, + board_world::BoardWorldVolume, + collision::Sphere, + mass::MassShape, + skeleton_body::{SkeletonBody, SkeletonCollisionMode}, + world_contact::ContactPrimitive, + }, +}; + +pub(super) fn world_volumes( + skeleton: &SkeletonBody, + collision: &SkeletonCollisionMode, +) -> Result, String> { + let transforms = skeleton.part_transforms(); + let mut volumes = Vec::new(); + for (index, (part, state)) in skeleton + .definition + .parts + .iter() + .zip(&collision.parts) + .enumerate() + { + if !state.enabled { + continue; + } + if state.volume_group != 0 { + return Err(format!( + "Skater part{index} requires volume group{} world filtering", + state.volume_group + )); + } + if part.hat.is_some() { + return Err("The equipped hat requires its original cylinder collision query".into()); + } + let frame = transforms[index]; + let xyz = |value: [f32; 4]| Vector3::new(value[0], value[1], value[2]); + let center = xyz(frame[3]); + let primitive = match part.shape { + MassShape::Sphere { radius } => ContactPrimitive::Sphere(Sphere { center, radius }), + MassShape::Capsule { + radius, + half_length, + } => ContactPrimitive::Capsule { + center, + axis: xyz(frame[2]), + half_length, + radius, + }, + MassShape::RoundedBox { + half_extents, + radius, + } => ContactPrimitive::RoundedBox { + center, + basis: Basis3 { + columns: std::array::from_fn(|i| [frame[i][0], frame[i][1], frame[i][2]]), + }, + half_extents, + radius, + }, + shape => { + return Err(format!( + "Skater part{index} has no recovered collision query for {shape:?}" + )); + } + }; + volumes.push(BoardWorldVolume { + body: CollisionBody::Attached(index), + primitive, + linear_velocity: skeleton.bodies()[index].rates.linear_velocity, + material: state.material, + }); + } + Ok(volumes) +} diff --git a/crates/skate-game/src/physics/skeleton_controller.rs b/crates/skate-game/src/physics/skeleton_controller.rs new file mode 100644 index 00000000..3b947e6a --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_controller.rs @@ -0,0 +1,84 @@ +//! Shared collision-policy controller used by Ground and both Air states. +//! Requests82D911A8 and ascending/falling update82D91660 precede the solve. +use skate_core::physics::skeleton_body::SkeletonCollisionMode; + +pub(crate) struct SkeletonControllerState { + pub effective: u32, + pub requested: u32, + pub has_request: bool, + pub override_enabled: bool, + ///Native byte18 tracks upward motion/jump input; it is not an enable bit. + pub flag_18: bool, +} +impl SkeletonControllerState { + pub fn new() -> Self { + //Original inline constructor82DB2498..24B0. + Self { + effective: 0, + requested: 0, + has_request: false, + override_enabled: false, + flag_18: false, + } + } + + pub fn request_ground(&mut self, collision: &mut SkeletonCollisionMode) -> Result<(), String> { + self.request(6, collision) + } + + pub fn request( + &mut self, + requested: u32, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + if let Some(mode) = self.select_request(requested) { + collision.select_driven(mode).map_err(str::to_owned)?; + } + Ok(()) + } + + ///Original request gate shared by driven and physical ragdoll adapters. + pub(crate) fn select_request(&mut self, requested: u32) -> Option { + if self.has_request && self.requested == requested { + return None; + } + let effective = if self.override_enabled { + self.requested = requested; + self.has_request = true; + //82D911D8..1264: unsigned requested-4 indexes the eight cases. + match requested { + 4 => 1, + 5..=7 => 2, + 8 => 3, + 9..=11 => requested, + _ => return None, + } + } else { + if (1..=2).contains(&self.effective) { + return None; + } + requested + }; + self.effective = effective; + let mode = effective.wrapping_sub(1); + if mode > 10 { + return None; + } + Some(mode) + } + + ///Original82D91660 changes the collision request only when byte18 changes. + pub fn update_air( + &mut self, + processed_flags_2472: u32, + velocity_y: f32, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + let ascending = processed_flags_2472 & 0x8000 != 0 || velocity_y > 0.0; + if self.flag_18 != ascending { + self.flag_18 = ascending; + self.request(if ascending { 7 } else { 6 }, collision)?; + } + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/skeleton_feedback.rs b/crates/skate-game/src/physics/skeleton_feedback.rs new file mode 100644 index 00000000..f13d29db --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_feedback.rs @@ -0,0 +1,177 @@ +//! Post-solver contact reports and physical/animation errors for the live skater. +//! Engine report storage is ours; spy arithmetic and report order are native. +use super::{GamePhysics, SkaterRuntime, solve::deck_frame}; +use skate_core::physics::{ + assembly::BodySnapshot, + board_step::CollisionBody, + contact_feedback::spy_contact_jacobians, + skeleton_body::{SkeletonCollisionInput, SkeletonContactBody, SkeletonContactReport}, +}; + +pub(super) fn publish( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + request_partial_ragdoll: bool, +) { + let reports = collect(physics, skater); + let p = &skater.player_input.processed; + let deck = deck_frame(&physics.board); + skater.skeleton_output.correction.observe_board( + deck[3], + skater.animated_skeleton.roots.predicted_board_position, + ); + let state = p.state_2508; + let offboard = p.category_2512 == 500 && !matches!(state, 501 | 503); + let mut point = p.vectors_464_480_496_512_528[if matches!(state, 601 | 602) { 1 } else { 2 }] + .map(f32::from_bits); + // Both82BD8280 and82BE2538 read Skeleton16424 ->Reckoning1216. + let n = physics.riding.reckoning.ground_normal; + let mut normal = [n.x, n.y, n.z, 0.0]; + if offboard && p.flags_2480 & 2 != 0 { + point = p.vectors_880_896_912_928_944[0].map(f32::from_bits); + normal = p.vectors_880_896_912_928_944[1].map(f32::from_bits); + } + let frames = skater.skeleton.part_transforms(); + skater.collision_feedback.update( + &SkeletonCollisionInput { + dt: physics.settings.step.simulation.time_step, + plane_point: point, + plane_normal: normal, + reference_velocity: if state == 201 { + skater.skeleton.record.velocities[23] + } else { + skater.skeleton_input.deck_velocity + }, + com_velocity: skater.animated_skeleton.board_frames.com_velocity, + ragdoll: skater.skeleton_collision.is_ragdoll, + disable_ground_filter: p.flags_2472 & 0x8000 != 0, + ai_collision_scalar: p.flags_2472 & 0x1000_0000 != 0, + offboard, + entering_offboard: matches!(state, 501 | 503), + category_600: p.category_2512 == 600, + request_partial_ragdoll, + physical: &skater.skeleton.record, + part_weights: &skater.skeleton.definition.animation_masses.part_weights, + body_frames: &frames, + }, + &reports, + ); + skater.skeleton_collision.finish_contact_frame(); + skater.pose_errors.targets = skater.skeleton_input.extra_target_positions; + skater.pose_errors.update( + &skater.skeleton.record, + skater.animated_skeleton.roots.animation_to_world, + &skater.skeleton_input.drive_frames, + ); + let response = if skater.skeleton_collision.partial_ragdoll { + skater.pose_errors.partial_response() + } else { + skater + .pose_errors + .normal_response(&skater.collision_feedback, [n.x, n.y, n.z, 0.0]) + }; + skater.collision_pose_error = response.impulse; + skater.collision_maximum_error = Some(response.maximum_error); + skater + .animated_skeleton + .board_frames + .publish_centre_of_mass( + skater.skeleton.record.centre_of_mass, + physics.settings.step.simulation.time_step, + p.flags_2472, + ); + skater + .animated_skeleton + .board_frames + .publish_local_observations(&skater.animated_skeleton.roots, &deck); +} + +fn collect(physics: &GamePhysics, skater: &SkaterRuntime) -> Vec { + let rows = physics.board.solved_contacts(); + let mut storage: Vec<_> = rows + .iter() + .flat_map(|row| row.words().iter().copied()) + .collect(); + let mut count = 0; + spy_contact_jacobians( + &mut storage, + rows.len() as u32, + &mut count, + physics.settings.step.simulation.frequency, + |id| { + resolve(id, physics, skater).map_or([0; 4], |body| { + let p = body.rates.position; + [p.x.to_bits(), p.y.to_bits(), p.z.to_bits(), 0] + }) + }, + ); + let mut reports = Vec::with_capacity(16); + for spy in storage[..count as usize * 28].chunks_exact(28) { + let a = CollisionBody::from_contact_id(spy[24]); + let b = CollisionBody::from_contact_id(spy[25]); + //82768418 rejects the same logical Body and equal nonnull Body+32 + //owners. Board82C067F4 and Skeleton82BE4620 both store this actor. + //Their eligible cross contacts still participate in the shared solve. + if !matches!(a, CollisionBody::StaticWorld) && !matches!(b, CollisionBody::StaticWorld) { + continue; + } + for (own, other, side_a) in [(a, b, true), (b, a, false)] { + let CollisionBody::Attached(part) = own else { + continue; + }; + if part >= 24 || reports.len() == 16 { + continue; + } + let vector = |offset| std::array::from_fn(|i| f32::from_bits(spy[offset + i])); + reports.push(SkeletonContactReport { + part, + normal: vector(0).map(|v| v * if side_a { 1.0 } else { -1.0 }), + point: vector(12), + tag: if side_a { + spy[26] & 0xffff + } else { + spy[26] >> 16 + }, + other_group: match other { + CollisionBody::StaticWorld => 0, + CollisionBody::Board(_) => physics.board.collision_group(), + CollisionBody::Attached(_) => skater.skeleton_collision.assembly_group, + }, + other_entity: None, + body_a: contact_body(resolve(spy[24], physics, skater)), + body_b: contact_body(resolve(spy[25], physics, skater)), + side_a, + solved_vector: vector(20), + }); + } + } + reports +} +fn resolve<'a>( + id: u32, + physics: &'a GamePhysics, + skater: &'a SkaterRuntime, +) -> Option<&'a BodySnapshot> { + match CollisionBody::from_contact_id(id) { + CollisionBody::StaticWorld => None, + CollisionBody::Board(id) => Some(&physics.board.bodies()[id.index()]), + CollisionBody::Attached(part) => Some(&skater.skeleton.bodies()[part]), + } +} +fn contact_body(body: Option<&BodySnapshot>) -> SkeletonContactBody { + match body { + Some(body) => { + let v = body.rates.linear_velocity; + SkeletonContactBody { + state_flags: body.state_flags, + inverse_mass: body.inertia.inverse_mass, + linear_velocity: [v.x, v.y, v.z, 0.0], + } + } + None => SkeletonContactBody { + state_flags: 1, + inverse_mass: 0.0, + linear_velocity: [0.0; 4], + }, + } +} diff --git a/crates/skate-game/src/physics/skeleton_input_general.rs b/crates/skate-game/src/physics/skeleton_input_general.rs new file mode 100644 index 00000000..9bba0f91 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_input_general.rs @@ -0,0 +1,146 @@ +//! Movement-phase continuation of the original Skeleton input owner. +use super::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}; +use crate::physics::foot_ik::{DriveOutput, PhysicalInput}; +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + board_runtime::BoardRuntime, + drive_parameters::{RetailDriveParams, RetailDriveType}, + rigid_body::RetailSimulationStep, + skeleton_animation_record::AnimationPartTransform as Transform, + skeleton_body::{SkeletonDrives, SkeletonTargetInput}, + skeleton_general, + }, + player::input_phase::ProcessedPhysicsInput, +}; + +impl SkeletonInputRuntime { + ///Ground82BDF530: update actual roots/board frames, GeneralUpdate, then + ///reset next trajectory. Return the native Processed0..48 publication. + pub fn update_ground( + &mut self, + board: &BoardRuntime, + reckoning: &Transform, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + let frame = + owners + .animated + .prepare_ground(board, reckoning, p.timestep_2604, &mut p.flags_2468); + self.general_update(p, owners, globals, collision, simulation)?; + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(frame) + } + + ///Source order: root derivative -> real target writes -> discontinuity + ///reset -> IK -> bone drive update -> fixed-step gravity displacement. + pub fn general_update( + &mut self, + p: &ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + self.root_velocity = skeleton_general::root_velocity( + &mut self.previous_root_position, + owners.animated.roots.animation_to_world[3], + p.timestep_2604, + ); + let s = &owners.animated; + let target = owners.drives.targets.update_positions( + SkeletonTargetInput { + animation_hips: &s.animation_hips, + animation_board: &s.animation_board, + animation_to_world: &s.roots.animation_to_world, + inverse_board: &s.roots.inverse_board, + skate_root: &s.board_frames.skate_root, + com_frame: &s.board_frames.com_frame, + lifted_com_frame: &s.board_frames.lifted_com_frame, + teleporting: self.teleporting, + }, + owners.body, + ); + self.animation_board_to_physics = target.animation_board_to_physics; + self.extra_target_positions = [ + target.positions.com, + target.positions.lifted_com, + target.positions.following_com, + ]; + owners.pose_errors.set_targets(target.positions); + if !target.continuous { + self.reset_physical_pose(owners, simulation); + if p.state_2508 != 0 && p.state_2508 != 702 { + self.invalid_target_reset = true; + } + } + let contacts = std::array::from_fn(|i| { + let query = p.line_tests_960_1008_1056[i]; + (query.valid != 0).then(|| query.position.map(f32::from_bits)) + }); + let physical_hips = owners.body.part_transforms()[23][3]; + let drives = owners.ik.update( + owners.animated, + globals, + PhysicalInput { + state_id: p.state_2508, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2480: p.flags_2480, + //Unknown event bone -1 cannot equal either real toe index. + contact_bone: owners.animation_input.contacts.bone as usize, + physical_board: &owners.animated.board_frames.physical_board, + hips_world_position: physical_hips, + current_contacts: contacts, + }, + )?; + self.drive_frames = drives.frames; + owners.drives.update( + &drives.frames, + collision.partial_ragdoll, + collision.drive_weight_4028, + ); + skeleton_general::apply_gravity_displacement(owners.body, p.gravity_2648); + Ok(drives) + } + + ///Real82BE2798 reset is also callable by the coordinated player reset; + ///it clears correction pending but does not invent a fresh state machine. + pub fn reset_physical_pose( + &mut self, + owners: &mut SkeletonOwners<'_>, + simulation: RetailSimulationStep, + ) { + owners.correction.pending = false; + let s = &mut owners.animated; + let observation = skeleton_general::reset_to_animation( + owners.body, + &mut s.record, + &self.drive_frames, + &s.roots.animation_to_world, + s.board_frames.com_frame, + simulation, + ); + s.board_frames.centre_of_mass = observation.world_hips; + s.board_frames.previous_centre_of_mass = observation.world_hips; + s.board_frames.com_velocity = [0.; 4]; + self.reset_local_hips = observation.local_hips; + owners.pose_errors.reset_history(); + } +} + +pub(super) fn restore_board_target_drives(drives: &mut SkeletonDrives) { + let hard = RetailDriveParams { + spring_or_max_velocity: f32::from_bits(0x4415_FFFF), + damping: 0., + max_strength: f32::from_bits(0x470C_9FFF), + drive_type: RetailDriveType::HardDrive, + }; + drives.targets.dynamics[0].linear = hard; + drives.targets.dynamics[0].angular = hard; +} diff --git a/crates/skate-game/src/physics/skeleton_input_runtime.rs b/crates/skate-game/src/physics/skeleton_input_runtime.rs new file mode 100644 index 00000000..e4634cc2 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_input_runtime.rs @@ -0,0 +1,231 @@ +//! The production Skeleton::ProcessData82BD8918 boundary. All borrows point +//! at the same owners subsequently used by Ground, the solve and pose output. +//! GeneralUpdate belongs to the later movement-state phase, not PlayerInput. +#[path = "skeleton_input_general.rs"] +mod general; +#[path = "skeleton_input_teleport.rs"] +mod teleport; + +use super::{ + animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, foot_ik::FootIk, +}; +use skate_core::{ + animation::output::{NativeMatrix, attributes::AnimationAttribute}, + input::controller::ActionMap, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY}, + skeleton_body::{SkeletonBody, SkeletonDrives, SkeletonPoseErrors}, + skeleton_landing::LandingInput, + skeleton_output::correction::CorrectionState, + }, + player::input_phase::{AnimationInputPacket, PhysicalPlayerInput, ProcessedPhysicsInput}, + riding::ground_correction_math::dot_product as dot3, +}; + +/// Original SkeletonCollision publications. The collision owner fills these +/// from actual solved contacts; there is deliberately no default observation. +pub(crate) struct CollisionInput { + pub contact_4070: bool, + pub has_pose_error_4077: bool, + pub pose_error_16272: [f32; 4], + pub partial_ragdoll: bool, + pub drive_weight_4028: f32, +} + +pub(crate) struct SkeletonOwners<'a> { + pub animated: &'a mut AnimatedSkeleton, + pub body: &'a mut SkeletonBody, + pub drives: &'a mut SkeletonDrives, + pub ik: &'a mut FootIk, + pub animation_input: &'a mut AnimationInput, + pub correction: &'a mut CorrectionState, + pub pose_errors: &'a mut SkeletonPoseErrors, +} + +pub(crate) struct SkeletonPoseInput<'a> { + pub globals: &'a [NativeMatrix], + pub attributes: &'a [AnimationAttribute], + pub actions: &'a mut dyn ActionMap, +} + +pub(crate) struct SkeletonInputRuntime { + ///Skeleton16128, read from the actual deck before ProcessAnimAttributes. + pub deck_velocity: [f32; 4], + ///Skeleton16336 and16352, the animation root derivative and its history. + pub root_velocity: [f32; 4], + pub previous_root_position: [f32; 4], + ///Skeleton12240 from the actual target update, not animation-to-world. + pub animation_board_to_physics: Transform, + ///Skeleton11632/11648/11664 from the three extra-target positions. + pub extra_target_positions: [[f32; 4]; 3], + ///Skeleton16208, authored hips translation cached by physical reset. + pub reset_local_hips: [f32; 4], + ///Skeleton12624, rewritten by ProcessData then modified in place by IK. + ///The animation COM record retains its pre-IK copy independently. + pub drive_frames: [Transform; 24], + pub reenable_requested: bool, + pub invalid_target_reset: bool, + pub teleporting: bool, + ///Skeleton16420, changed by graph force-mode requests and reset82BE3508. + pub force_mode: u32, + ///HeadTracking32..144 and160. Reset82BE3550..35A4 preserves its settings. + pub head_tracking_history: [[f32; 4]; 8], + pub head_tracking_active: bool, + ///GrindAirAdjust ctor82D7116C initializes activated289=false. + ///Only KnownAir Update82D35D30/Exit82D3597C subsequently write this flag. + ///288, ctor82D71160; activated289 is a separate retained flag. + pub grind_air_started: bool, + pub grind_air_active: bool, + pub grind_air_adjusting: bool, +} + +impl Default for SkeletonInputRuntime { + fn default() -> Self { + //Original Skeleton ctor82BD78xx/79xx zeroes these vector histories; + //82BD7A78/84/8C clear16504/16506/16508. Frames are native identity. + Self { + deck_velocity: [0.; 4], + root_velocity: [0.; 4], + previous_root_position: [0.; 4], + animation_board_to_physics: IDENTITY, + extra_target_positions: [[0.; 4]; 3], + reset_local_hips: [0.; 4], + drive_frames: [IDENTITY; 24], + reenable_requested: false, + invalid_target_reset: false, + teleporting: false, + force_mode: 0, + head_tracking_history: [[0.0; 4]; 8], + head_tracking_active: false, + grind_air_started: false, + grind_air_active: false, + grind_air_adjusting: false, + } + } +} + +impl SkeletonInputRuntime { + ///Called by PlayerInputCallbacks::process_skeleton, after the actual + ///BoardToolkit and line tests have been published. It does not advance + ///Ground, rebuild a toolkit or submit a second physical simulation. + pub fn process_data( + &mut self, + board: &mut BoardRuntime, + _toolkit: &BoardToolkit, + packet: &AnimationInputPacket<'_>, + _physical: &mut PhysicalPlayerInput, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + pose: SkeletonPoseInput<'_>, + collision: &CollisionInput, + ) -> Result<(), String> { + let velocity = board.bodies()[BodyId::Deck.index()].rates.linear_velocity; + self.deck_velocity = [velocity.x, velocity.y, velocity.z, 0.]; + //82ADF7B8: wake sleeping state2, preserving bit3, and clear cooldown + //even for already active parts. Pool/list ownership is our engine's. + for body in owners.body.bodies_mut() { + if body.state_flags & 7 == 2 { + body.state_flags = (body.state_flags & 8) | 4; + } + body.rates.cool_down = 0; + } + let input = &mut owners.animation_input; + input.reset_processed(); + input.fields.flags2468 = p.flags_2468; + input.fields.flags2472 = p.flags_2472; + input.fields.flags2476 = p.flags_2476; + input.fields.flags2484 = p.flags_2484; + input.fields.flags2488 = p.flags_2488; + input.extra.flags2480 = p.flags_2480; + input.process( + pose.attributes, + pose.globals, + packet.publication.timestep, + packet.flags_10932, + packet.publication.flags_10375_10496_10784[1] != 0, + pose.actions, + )?; + publish_attribute_flags(input, p); + p.spin_input_2672 = input.fields.spin; + + //These authored offset producers are separate from Ground riding. + //Do not silently reuse a riding pose if a future state enables them. + let reparented_hands = owners.animated.reparented_hand_indices(); + super::offboard::pose_adjust::update( + owners.animated, pose.globals, reparented_hands, p, + )?; + if p.flags_2476 & 0x100 != 0 { + if p.flags_2476 & 0x10000 == 0 { + return Err( + "Landing-on-board pose requires UpdateLandingOnSkateboardAdjust82BDCC28".into(), + ); + } + } else { + //82D712E0 clears result48/49 first and always writes adjusting290. + //Inactive Ground leaves retained total offset/angle untouched. + self.grind_air_adjusting = false; + if self.grind_air_active { + return Err("Active air grind adjustment requires82D712E0 geometric stages".into()); + } + } + let up = p.vectors_544_560_592_608[0].map(f32::from_bits); + let com_velocity = p.vectors_544_560_592_608[3].map(f32::from_bits); + let com_delta = std::array::from_fn(|i| { + owners.body.record.centre_of_mass[i] - owners.body.record.positions[0][i] + }); + let landing = LandingInput { + filtered_state: p.filtered_state_2524, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + balance: input.fields.balance, + physical_com_velocity_along_up: dot3(com_velocity, up), + physical_com_height: dot3(com_delta, up), + //process_pose replaces this with prior animation COM-currentboard. + animation_com_height: owners.animated.record.centre_of_mass[1], + }; + owners.animated.process_pose( + pose.globals, + landing, + packet.publication.timestep, + &mut p.flags_2468, + &mut p.flags_2472, + )?; + self.drive_frames = owners.animated.record.pose; + p.board_at_y_delta_2768 = owners.animated.board_at_y_delta; + p.flags_2472 = (p.flags_2472 & !(1 << 17)) | (u32::from(collision.contact_4070) << 17); + if collision.has_pose_error_4077 { + p.collision_pose_error_736 = collision.pose_error_16272.map(f32::to_bits); + } + p.animation_com_to_deck_752 = owners.animated.record.com_to_deck_world.map(f32::to_bits); + p.animation_com_to_deck_delta_768 = owners + .animated + .record + .com_to_deck_world_delta + .map(f32::to_bits); + //The scalar fields and ProcessedPhysicsInput share these native words. + input.fields.flags2468 = p.flags_2468; + input.fields.flags2472 = p.flags_2472; + if self.reenable_requested { + self.reenable_requested = false; + owners.ik.enable_feet(true); + general::restore_board_target_drives(owners.drives); + //Native16416 is the prediction-supplied flag;16417 heading is + //distinct and must not be cleared by the request's completion. + owners.animated.roots.supplied_prediction = None; + } + Ok(()) + } +} + +fn publish_attribute_flags(input: &AnimationInput, p: &mut ProcessedPhysicsInput) { + p.flags_2468 = input.fields.flags2468; + p.flags_2472 = input.fields.flags2472; + p.flags_2476 = input.fields.flags2476; + p.flags_2480 = input.extra.flags2480; + p.flags_2484 = input.fields.flags2484; + p.flags_2488 = input.fields.flags2488; +} diff --git a/crates/skate-game/src/physics/skeleton_input_teleport.rs b/crates/skate-game/src/physics/skeleton_input_teleport.rs new file mode 100644 index 00000000..8aa4c491 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_input_teleport.rs @@ -0,0 +1,90 @@ +//! Actual teleport skeleton stages82BE3508 and82BDF7C0. The player owns the +//! intervening ResetSystems and ProcessData calls; this is the same skeleton. +use super::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}; +use skate_core::{ + animation::{foot_ik::state::FootIkState, output::NativeMatrix}, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + rigid_body::RetailSimulationStep, + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY}, + skeleton_body::{SkeletonCollisionFeedback, SkeletonCollisionMode}, + skeleton_output::wobble::Wobble, + }, + player::input_phase::ProcessedPhysicsInput, +}; + +impl SkeletonInputRuntime { + ///82BE3508, after board reset and before player ResetSystems82DB92D0. + ///Does not replace owners with startup instances or reset unrelated rates. + pub fn reset_for_teleport( + &mut self, + owners: &mut SkeletonOwners<'_>, + mode: &mut SkeletonCollisionMode, + feedback: &mut SkeletonCollisionFeedback, + wobble: &mut Wobble, + ground_elapsed_16505: &mut bool, + ) { + owners.animated.motion.reset_board_orientation_history(); + self.reenable_requested = true; + self.invalid_target_reset = false; + *ground_elapsed_16505 = true; + mode.reset_body_state(feedback); + self.force_mode = 0; + self.head_tracking_history = [[0.0; 4]; 8]; + self.head_tracking_active = false; + owners.ik.state = FootIkState::default(); + //Native reset preserves the previously selected cached curves. + wobble.time = 0.0; + wobble.amplitude = 0.0; + wobble.direction = 1.0; + wobble.active = false; + wobble.landing = false; + owners.animated.board_frames.animation_target = IDENTITY; + owners.animated.board_offset = Default::default(); + owners.animated.board_frames.lift_height = 0.0; + } + + ///82DB8998 brackets82BDF7C0 with Skeleton16508=true/false after the real + ///ProcessData call. Reckoning816 is its freshly reset original frame. + pub fn update_teleport( + &mut self, + board: &BoardRuntime, + reckoning_frame_816: &Transform, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + self.teleporting = true; + let part = board.part_transforms()[BodyId::Deck.index()]; + let mut deck = [[0.0; 4]; 4]; + for axis in 0..3 { + deck[axis][..3].copy_from_slice(&part.basis.columns[axis]); + } + deck[3] = [ + part.translation.x, + part.translation.y, + part.translation.z, + 0.0, + ]; + let skeleton = &mut owners.animated; + skeleton + .roots + .update_teleport(deck, &skeleton.animation_board, reckoning_frame_816); + let frame = skeleton.board_frames.prepare_teleport( + &skeleton.roots, + &self.drive_frames[0], + deck, + &mut p.flags_2468, + ); + let result = self.general_update(p, owners, globals, collision, simulation); + //Keep the actual lifecycle flag coherent even if host data validation fails. + self.teleporting = false; + result?; + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(frame) + } +} diff --git a/crates/skate-game/src/physics/skeleton_output.rs b/crates/skate-game/src/physics/skeleton_output.rs new file mode 100644 index 00000000..bced1ad0 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_output.rs @@ -0,0 +1,177 @@ +//! Production adapter for the original physical-pose job82DB6698/82DB95E8. +use super::animated_skeleton::AnimatedSkeleton; +use skate_core::{ + animation::foot_ik::drive::Geometry, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + drive_frames::RetailAffineTransform, + skeleton_animation_record::AnimationPartTransform as Transform, + skeleton_body::SkeletonBody, + skeleton_output::{self, board, wobble}, + truck_frames::steering_truck_transforms, + }, + point_graph::PointGraph, +}; +use skate_data::{animation_frames::AnimationFrames, collections::Collections}; + +pub(crate) struct SkeletonOutput { + pub pose: skeleton_output::SkeletonOutput, + pub wobble: wobble::Wobble, + wobble_settings: wobble::Settings, + pub deck_wobble: wobble::Output, + pub correction: skeleton_output::correction::CorrectionState, + compression_rest_height: f32, +} +impl SkeletonOutput { + pub fn load( + data: &Collections, + animation: &AnimationFrames, + skeleton: &AnimatedSkeleton, + ) -> Result { + let mut parents = [None; 24]; + for (part, &bone) in skeleton.bone_indices.iter().enumerate() { + let mut ancestor = *animation + .parents + .get(bone) + .ok_or("Missing output bone ancestor")?; + let mut visited = 0; + while ancestor >= 0 { + let index = ancestor as usize; + if index >= animation.parents.len() || visited >= animation.parents.len() { + return Err("Invalid physical output hierarchy".into()); + } + if let Some(parent) = skeleton.bone_indices.iter().position(|&b| b == index) { + parents[part] = Some(parent); + break; + } + ancestor = animation.parents[index]; + visited += 1; + } + } + let bone = |name: &str| { + animation + .bone_names + .iter() + .position(|n| n.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("Missing stock output bone {name}")) + }; + let scalar = |key| data.float("physics_skeleton", "default", key); + //82BD7DD0..7E88 names and stores these six ids; no authored index literals. + let board_bones = board::BoneIndices { + front_truck: bone("Truck_Front")?, + back_truck: bone("Truck_Back")?, + front_left_wheel: bone("Left_WheelFront")?, + front_right_wheel: bone("Right_WheelFront")?, + back_left_wheel: bone("Left_WheelBack")?, + back_right_wheel: bone("Right_WheelBack")?, + }; + let graph = |key| -> Result, String> { + let words = data + .words::<20>("physics_deck_wobble", "default", key)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[4..12].try_into().unwrap(), + y: words[12..20].try_into().unwrap(), + }) + }; + Ok(Self { + pose: skeleton_output::SkeletonOutput { + bone_indices: skeleton.bone_indices, + geometry: Geometry::new(parents, &skeleton.physics_frames)?, + board_bones, + board_settings: board::Settings { + truck_tilt_scalar: scalar("TruckTiltScalar")?, + truck_tilt_max_angle: scalar("TruckTiltMaxAngle")?, + truck_tilt_wobble_scalar: scalar("TruckTiltWobbleScalar")?, + truck_displacement_max: scalar("TruckDisplacementMax")?, + }, + }, + wobble: wobble::Wobble::default(), + deck_wobble: wobble::Output::default(), + correction: skeleton_output::correction::CorrectionState::default(), + //8289F128 global220 = AF781D7B614DAB50 physicstrucks/default; + //82C08D04..18 copies layout16 to SkateboardBody8380. + compression_rest_height: data.float("physicstrucks", "default", "TruckYPos")?, + wobble_settings: wobble::Settings { + takeoff_tilt: graph("TiltVsTimeTakeOff")?, + landing_tilt: graph("TiltVsTimeLanding")?, + takeoff_squish: graph("SquishVsTimeTakeOff")?, + landing_squish: graph("SquishVsTimeLanding")?, + maximum_time: data.float("physics_deck_wobble", "default", "MaxTime")?, + }, + }) + } + ///82BF2F18 is called on the actual landing/takeoff transition; reverse is + ///the native stance decision passed by that transition's caller. + pub fn trigger_wobble(&mut self, landing: bool, reverse: bool) { + self.wobble.trigger(landing, reverse); + } + ///82C08968 observes the solved COM frames, not geometric part frames. + pub fn average_compressions(&self, board: &BoardRuntime) -> [f32; 2] { + let deck = to_matrix(board.body_transform(BodyId::Deck)); + let wheels = core::array::from_fn(|i| to_matrix(board.body_transform(BodyId::ORDER[i]))[3]); + skate_core::physics::contact_feedback::average_wheel_compressions( + deck, + wheels, + self.compression_rest_height, + ) + } + ///Call in82BD83E0's post-wipeout-check phase, before the pose job. + pub fn advance_wobble(&mut self, physical: &mut SkeletonBody, deck: &Transform) -> Transform { + self.deck_wobble = self.wobble.update(&self.wobble_settings); + if self.deck_wobble.sampled { + wobble::apply(self.deck_wobble, &mut physical.record.pose[0]); + physical.record.pose[0] + } else { + *deck + } + } + pub fn publish( + &self, + skeleton: &AnimatedSkeleton, + physical: &SkeletonBody, + board_runtime: &BoardRuntime, + base_trucks: [RetailAffineTransform; 2], + current_truck_targets: [f32; 2], + average_compression: [f32; 2], + globals: &mut [Transform], + locals: &mut [Transform], + ) -> Result<(), String> { + let bodies = + core::array::from_fn(|i| to_matrix(board_runtime.body_transform(BodyId::ORDER[i]))); + let trucks = steering_truck_transforms(base_trucks, current_truck_targets).map(to_matrix); + let physical_parts = core::array::from_fn(|i| physical.record.pose[i]); + self.pose + .publish( + skeleton_output::Input { + physical_parts: &physical_parts, + world_to_animation: &skeleton.roots.world_to_animation, + board: board::Input { + bodies: &bodies, + skeleton_board: &physical_parts[0], + truck_frames: &trucks, + deck_wobble_tilt: self.deck_wobble.tilt, + deck_wobble_squish: self.deck_wobble.squish, + average_compression, + }, + }, + globals, + locals, + ) + .map_err(str::to_string) + } +} +fn to_matrix(value: RetailAffineTransform) -> Transform { + let mut result = [[0.0; 4]; 4]; + for axis in 0..3 { + result[axis][..3].copy_from_slice(&value.basis.columns[axis]); + } + result[3] = [ + value.translation.x, + value.translation.y, + value.translation.z, + 0.0, + ]; + result +} diff --git a/crates/skate-game/src/physics/slide_state.rs b/crates/skate-game/src/physics/slide_state.rs new file mode 100644 index 00000000..34000190 --- /dev/null +++ b/crates/skate-game/src/physics/slide_state.rs @@ -0,0 +1,72 @@ +//! PhysicsSlideGround101 production lifecycle, original TU3 vtable823285B4. +mod settings; +mod update; +use super::{GamePhysics, SkaterRuntime}; +use skate_core::{ + math::Vector3, + physics::contact::RetailContactMaterial, + player::slide_state::{SlideSettings, SlideSurface}, +}; +pub(crate) struct SlideState { + pub state: skate_core::player::slide_state::SlideState, + settings: SlideSettings, + surfaces: Vec<(SlideSurface, RetailContactMaterial)>, + manual_scalar: f32, +} +/// Enter82D3A700. Body652 points to the deck's real dynamics; its+48 is angular +/// velocity. Preserve all other body rates, inertia and collision modes. +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + skater.ground_lifecycle.skeleton_elapsed_16505 = true; + skater.air_reckoning.state.spin_angle = 0.0; + skater.air_reckoning.state.spin_speed = 0.0; + physics + .board + .hook_mut() + .drive + .disable_animation(&mut skater.ground_lifecycle.board_animated_290); + physics.board.bodies_mut()[6].rates.angular_velocity = Vector3::ZERO; + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = 0.0; + } + if p.category_2516 != 100 { + skater.ground.manual.reset(); + } + if skater.wipeout.state.mode != 1 { + skater.wipeout.state.balance = 0.0; + skater.wipeout.state.mode = 1; + } + skater.slide_state.state.enter(p.scalar_2656); + Ok(()) +} +/// Exit82D3A828 restores the board's cached standard wheel material8280. +pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.slide_state.state.exit(); + physics.settings.wheel_material = physics.settings.standard_wheel_material; + Ok(()) +} +/// Update82D3A890: complete Reckoning, Skeleton Ground, then Slide board. +pub(crate) 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("Slide requires current BoardToolkit")?; + 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)?; + update::board(physics, skater) +} +/// Fill82D3B010 writes only State84. Common output publication owns other fields. +pub(crate) fn fill(_physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.player_input.physical.state.signed_ground_step_84 = + u8::from(skater.slide_state.state.wall_riding); + Ok(()) +} +// Post vtable+40 is the same82D387A8 as Ground. The shared coordinator invokes +// Wipeout::check_ground with the actual complete solved Observations once. diff --git a/crates/skate-game/src/physics/slide_state/settings.rs b/crates/skate-game/src/physics/slide_state/settings.rs new file mode 100644 index 00000000..69d2b541 --- /dev/null +++ b/crates/skate-game/src/physics/slide_state/settings.rs @@ -0,0 +1,61 @@ +//! Direct original bindings82D94EC8..4F48,82D3AB30/AD3C/AE68 and stock schema. +use super::SlideState; +use skate_core::{ + physics::contact::RetailContactMaterial, + player::slide_state::{SlideSettings, SlideSurface}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +impl SlideState { + pub fn load(data: &Collections) -> Result { + let f = |class, field| data.float(class, "default", field); + let c = |field| curve(data, "physics_slide", "default", field); + let mut surfaces = Vec::new(); + for key in ["smooth", "rough", "slow", "slippery", "veryslow"] { + surfaces.push(( + SlideSurface { + speed_to_force: curve( + data, + "physics_surfaces", + key, + "Powerslide_SpeedToForce", + )?, + yaw_strength: data.float("physics_surfaces", key, "Powerslide_YawStrength")?, + yaw_damping: data.float("physics_surfaces", key, "Powerslide_YawDamping")?, + }, + RetailContactMaterial { + static_friction: data.float("physics_surfaces", key, "WheelStaticFriction")?, + dynamic_friction: data.float( + "physics_surfaces", + key, + "WheelDynamicFriction", + )?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + )); + } + Ok(Self { + state: skate_core::player::slide_state::SlideState::new(), + settings: SlideSettings { + input_remap: c("slide_input_remap")?, + remap_vs_speed: c("Hash_DE162591FD9D91D7")?, + // Toolkit528 copies layout160;608 copies layout80, not table order. + force_vs_angle: c("Hash_91282E2CC4252731")?, + force_vs_speed: c("Hash_2AD93E5ABA231D2")?, + softest_wheel_force: f("physicswheels", "SoftestWheelPowerslideFactor")?, + softest_wheel_spin: f("physicswheels", "SoftestWheelPowerslideSpinFactor")?, + angular_force: f("physics_slide", "AngularForceScalar")?, + force_y_offset: f("physics_slide", "PowerSlideForceYOffset")?, + }, + surfaces, + manual_scalar: f("physics_manual", "PowerSlideScalar")?, + }) + } +} +fn curve(d: &Collections, c: &str, k: &str, n: &str) -> Result, String> { + let w = d.words::<20>(c, k, n)?.map(f32::from_bits); + Ok(PointGraph { + x: w[4..12].try_into().unwrap(), + y: w[12..20].try_into().unwrap(), + }) +} diff --git a/crates/skate-game/src/physics/slide_state/update.rs b/crates/skate-game/src/physics/slide_state/update.rs new file mode 100644 index 00000000..9f392da4 --- /dev/null +++ b/crates/skate-game/src/physics/slide_state/update.rs @@ -0,0 +1,232 @@ +//! Ordered UpdateSkateboard82D3A900: steering, manual, wall launch or Slide forces. +use super::*; +use crate::physics::ground_runtime::GroundInputObservations; +use skate_core::{ + physics::{ + force_queue::QueuedPointForce, + manual::controller::{self, ManualAngleMeasurement}, + }, + player::slide_state::{SlideInput, angular_correction, sliding_force}, + riding::{ + collision_response::{CollisionResponsePhysical, signed_angle}, + ground_contact_response::WallRidePhysical, + ground_force::{self, GroundForceInput}, + steering, + }, +}; +pub(super) fn board(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + // The preceding Skeleton call captured the actual unblended board target. + // Re-capture at Slide's own82D3A924 call; no tick elapses between these writes. + let target = skater.animated_skeleton.board_frames.animation_target; + skater + .skeleton_air + .capture_physics_error(&physics.board, &target); + let p = &skater.player_input.processed; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("Slide requires current BoardToolkit")?; + let surface_index = + p.surface_mode_2540 + .checked_sub(1) + .ok_or("Slide requires the selected SurfacePhysics profile")? as usize; + let (surface, material) = skater + .slide_state + .surfaces + .get(surface_index) + .ok_or_else(|| format!("Invalid Slide surface mode {}", p.surface_mode_2540))?; + // All four actual wheel query volumes share the material copied from2552. + physics.settings.wheel_material = *material; + let edge = skater.ground_lifecycle.edge; + let mut 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: edge.map_or(0, |e| e.flags), + edge_point: edge.map_or([0.0; 4], |e| e.point), + }, + ); + let settings = skater.ground_settings.board(); + let state = &mut skater.slide_state.state; + let tilt = steering::calculate_tilt( + settings.steering, + input.steering, + Some(&mut state.steering_push), + Some(&mut state.damped_turn), + ); + skater.ground.steering.update( + tilt, + settings.steering.tilt_blending, + p.flags_2468, + p.flags_2472, + ); + input.manual.powersliding = true; + let manual = controller::calculate( + &mut skater.ground.manual, + settings.manual, + settings.manual_mode, + &input.manual, + &mut Angle, + ) + .map_err(|e| format!("Slide manual controller: {e:?}"))?; + let normal = raw(p.vectors_464_480_496_512_528[0]); + let up = raw(p.vectors_544_560_592_608[0]); + let velocity = raw(p.vectors_400_416[0]); + let mut contact_frame = input.contact; + contact_frame.scalar_2756 = 0.0; + let response = skater.ground_runtime.contact_response_with_previous( + contact_frame, + WallRidePhysical { + board_normal: normal, + up, + velocity, + board_mass: t.total_mass, + gravity: p.gravity_2648, + speed: p.scalar_2652, + contact_count: p.wheel_count_2556 as i32, + }, + [0.0; 4], + ); + state.wall_riding = response.active_2731; + if response.animated_board_2708 { + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, response.vector_2688); + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = 0.0; + } + let mut launch = super::super::air_phase::launch_info(physics, skater)?; + launch.start_velocity = response.vector_2688; + launch.player_jumped = true; + let input = super::super::air_phase::selector_input(physics, skater)?; + skater.trajectory.launch(launch, input, &physics.world)?; + skater.trajectory.update(input, &physics.world)?; + //82D3AB10 branches directly to the epilogue. No ordinary force tail. + return Ok(()); + } + let slide = SlideInput { + velocity, + normal, + side: t.deck[0], + effective_forward: t.effective[2], + reference_forward: t.forward, + angular_velocity: raw(p.vectors_720_784_800_816_832_864[0]), + absolute_speed: t.absolute_speed, + surface_speed: p.scalar_2656, + slide: skater.animation_input.fields.slide, + elapsed: p.state_timer_2664, + wheel_hardness: p.scalar_2764, + }; + let displacement = angular_correction(&skater.slide_state.settings, surface, slide); + skater + .ground_runtime + .apply_angular_displacement(&mut physics.board, displacement); + let manual = manual + .angular_displacement + .map(|v| v * skater.slide_state.manual_scalar); + skater + .ground_runtime + .apply_angular_displacement(&mut physics.board, manual); + let f = &input.ground_force; + let balance = f.balance_2720; + //82D3AEC0..AF10 preserves the strict scalar branches and fsel signs. + let front_factor = if balance > 0.0 { + 0.0 + } else { + fsel(balance, 1.0, 2.0) + }; + let rear_factor = fsel(balance, if balance > 0.0 { 2.0 } else { 1.0 }, 0.0); + let front = ground_force::calculate( + settings.ground_force, + &GroundForceInput { + argument_1: f.argument_1_2752, + application_z: f.ground_scalar_1216, + argument_3: 0.0, + argument_4: front_factor, + balance, + surface_speed: f.surface_speed_2656, + axis_384: f.axis_384, + velocity_400: f.velocity_400, + axis_544: f.axis_544, + }, + ); + let rear = ground_force::calculate( + settings.ground_force, + &GroundForceInput { + argument_1: f.argument_1_2752, + application_z: -f.ground_scalar_1240, + argument_3: f.ground_scalar_1236 * 0.0, + argument_4: rear_factor, + balance, + surface_speed: f.surface_speed_2656, + axis_384: f.axis_384, + velocity_400: f.velocity_400, + axis_544: f.axis_544, + }, + ); + let sliding = sliding_force(&skater.slide_state.settings, surface, slide); + let n = physics.riding.reckoning.ground_normal; + let collision = skater + .ground_runtime + .calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: raw(p.collision_pose_error_736), + velocity: raw(p.vectors_400_416[1]), + forward: t.travel_direction, + up, + ground_normal: [n.x, n.y, n.z, 0.0], + time_step: p.timestep_2604, + mass: t.total_mass, + }); + let q = physics.board.forces_mut(); + if let Some(hit) = collision { + q.append(QueuedPointForce { + tag: 15, + force_world: xyz(hit.force_2528), + point_body: xyz(hit.point_2544), + }); + // Original Slide caller discards the collision angular output. + } else { + q.append(point_force(4, front)); + q.append(point_force(5, rear)); + q.append(response.tag_16_force); + q.append(sliding); + } + Ok(()) +} +struct Angle; +impl ManualAngleMeasurement for Angle { + type Error = std::convert::Infallible; + fn angle_between(&mut self, a: [f32; 4], b: [f32; 4], c: [f32; 4]) -> Result { + Ok(signed_angle(xyz(a), xyz(b), xyz(c))) + } +} +fn point_force(tag: u32, f: [f32; 8]) -> QueuedPointForce { + QueuedPointForce { + tag, + force_world: Vector3::new(f[0], f[1], f[2]), + point_body: Vector3::new(f[4], f[5], f[6]), + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn raw(v: [u32; 4]) -> [f32; 4] { + v.map(f32::from_bits) +} +fn fsel(test: f32, a: f32, b: f32) -> f32 { + if test >= -0.0 { a } else { b } +} diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs new file mode 100644 index 00000000..a3b3a130 --- /dev/null +++ b/crates/skate-game/src/physics/solve.rs @@ -0,0 +1,95 @@ +//! 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; +use super::{GamePhysics, SkaterRuntime, colliders, skeleton_colliders}; +use skate_core::physics::{ + board::BodyId, + board_step::{ATTACHED_REACTION_BASE, AttachedStep}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + skeleton_body::PART_COUNT, +}; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + truck_targets: [f32; 2], +) -> Result<(), String> { + let board_volumes = colliders::world_volumes(&physics.board, &physics.settings); + let skeleton_volumes = + skeleton_colliders::world_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 + // shared values. Do not let the second query overwrite the first's rows. + let mut contacts = physics + .world + .query_primitives(&board_volumes, physics.query, physics.retention) + .to_vec(); + let mut skeleton_query = physics.query; + skeleton_query.edge_cos_bend_normal_threshold = -1.0; + contacts.extend_from_slice(physics.world.query_primitives( + &skeleton_volumes, + skeleton_query, + physics.retention, + )); + assembly_contacts::append( + &mut contacts, + &board_volumes, + &skeleton_volumes, + physics.board.collision_group(), + &skater.skeleton_collision, + )?; + physics.contact_count = contacts.len(); + let dt = physics.settings.step.simulation.time_step; + let mut joints = + skater + .skeleton_joints + .build(skater.skeleton.bodies(), ATTACHED_REACTION_BASE, dt); + let mut drives = skater.skeleton_drives.build( + skater.skeleton.bodies(), + ATTACHED_REACTION_BASE, + ATTACHED_REACTION_BASE + PART_COUNT, + dt, + ); + let bodies = skater + .skeleton + .bodies_mut() + .iter_mut() + .chain(skater.skeleton_drives.targets.bodies.iter_mut()) + .collect(); + physics.board.advance_attached( + &contacts, + truck_targets, + physics.settings.step, + AttachedStep { + bodies, + contacts: &mut [], + joints: &mut joints, + drives: &mut drives.rows, + }, + ); + skater + .skeleton + .publish_physical_record(deck_frame(&physics.board)); + // These solved rows are consumed by the actual collision/drive feedback + // phase; keep their identity and impulses after the shared solve. + skater.solved_drives = Some(drives); + Ok(()) +} + +pub(super) fn deck_frame( + board: &skate_core::physics::board_runtime::BoardRuntime, +) -> AnimationPartTransform { + let deck = board.part_transforms()[BodyId::Deck.index()]; + let mut frame = IDENTITY; + for (axis, column) in deck.basis.columns.iter().enumerate() { + frame[axis][..3].copy_from_slice(column); + } + frame[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + frame +} diff --git a/crates/skate-game/src/physics/solve/assembly_contacts.rs b/crates/skate-game/src/physics/solve/assembly_contacts.rs new file mode 100644 index 00000000..1f5dc933 --- /dev/null +++ b/crates/skate-game/src/physics/solve/assembly_contacts.rs @@ -0,0 +1,96 @@ +//! Original single-player SkaterSkaterCollisionPipeline8276556C. +//! Board construction precedes Skeleton construction in82DB18A0. Preserve +//! that assembly order: board self (all culled), board/rider, rider self. +use skate_core::physics::{ + board_step::{BoardCollision, CollisionBody}, + board_world::BoardWorldVolume, + contact::{RetailContactInput, combine_contact_materials}, + skeleton_body::SkeletonCollisionMode, + world_contact::{PrimitivePairSettings, primitive_pair_contacts}, +}; + +pub(super) fn append( + contacts: &mut Vec, + board: &[BoardWorldVolume], + rider: &[BoardWorldVolume], + board_group: u32, + collision: &SkeletonCollisionMode, +) -> Result<(), String> { + //82765EF0 initializes the single-player (Island flags3) group bitmap. + //Rows1/2/3 are culled except2/3 against0;4<->5 is always culled. + //Its later extra4..7 exclusions run only for the online/non3 branch. + if board_group > 7 || collision.assembly_group > 7 { + return Err("Skater assembly collision requires its original group-table extension".into()); + } + if group_pair_allowed(board_group, collision.assembly_group) { + for a in board { + for b in rider { + let CollisionBody::Attached(part) = b.body else { + unreachable!() + }; + //Serialized volume+212 is source Volume+84 group. + //Stock board child constructors leave that field zero. + if !group_pair_allowed(0, collision.parts[part].volume_group) { + continue; + } + append_pair(contacts, a, b); + } + } + } + //8277AE80 suppresses the reversed assembly pair. The same-assembly + //branch8277B2C0 instead visits both directed primitive pairs and uses + //the skeleton's part bitmap. Board82C0B560 culls all7x7 self pairs. + for a in rider { + let CollisionBody::Attached(part_a) = a.body else { + unreachable!() + }; + for b in rider { + let CollisionBody::Attached(part_b) = b.body else { + unreachable!() + }; + if part_a != part_b && !collision.self_culling[part_a][part_b] { + append_pair(contacts, a, b); + } + } + } + Ok(()) +} + +fn group_pair_allowed(a: u32, b: u32) -> bool { + match (a, b) { + (0, 0 | 2..=7) | (2..=7, 0) => true, + (4, 5) | (5, 4) => false, + (4..=7, 4..=7) => true, + _ => false, + } +} + +fn append_pair(contacts: &mut Vec, a: &BoardWorldVolume, b: &BoardWorldVolume) { + //8277B0F0 and8277B2C0 enter the same8277A508 primitive walker with + //identical padding/triangle settings. Keep A/B orientation and point order. + let Some(manifold) = primitive_pair_contacts( + a.primitive, + b.primitive, + PrimitivePairSettings::skater_self_collision(), + ) else { + return; + }; + let material = combine_contact_materials(a.material, b.material); + for points in &manifold.points[..manifold.count] { + contacts.push(BoardCollision { + body_a: a.body, + body_b: b.body, + contact: RetailContactInput { + position_on_a: points.a, + position_on_b: points.b, + normal: manifold.normal, + restitution: material.restitution, + static_friction: material.static_friction, + dynamic_friction: material.dynamic_friction, + //8277A7B4/B8 packs original Volume88 tags, both zero for + //stock skater/board constructors (not the part indices). + tag: 0, + }, + }); + } +} diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs new file mode 100644 index 00000000..c2079894 --- /dev/null +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -0,0 +1,73 @@ +//! Host checkpoint response to original actor reset callback82592518. +//! Shared scheduling preserves request -> next input reply ->702 output -> reset. +use skate_core::{ + animation::output::actor_packet::ExternalReset, + physics::skeleton_animation_record::AnimationPartTransform, + player::{ + input_phase::{PhysicalPlayerInput, ProcessedPhysicsInput}, + teleport_state::{Output, Target, TeleportState, Update}, + }, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Checkpoint { + pub transform: AnimationPartTransform, + /// Original actor82592518 inverts checkpoint byte68. Its ground validator + ///82BFBC18 sets that byte for surface category8. Authored rideable spawn + ///surfaces supply true; an off-board checkpoint must explicitly supply false. + pub on_board: bool, +} + +pub(crate) struct Runtime { + state: TeleportState, + checkpoint: Checkpoint, + pending_reply: Option, +} +impl Runtime { + pub fn new(checkpoint: Checkpoint) -> Self { + Self { + state: TeleportState::default(), + checkpoint, + pending_reply: None, + } + } + pub fn enter(&mut self) { + self.state.enter(); + } + /// Update82D431F0 calls the actor only without Processed2468 bit1. + ///825926F8 stores the reply and marks it for the next input publication. + pub fn update(&mut self, input: &ProcessedPhysicsInput) { + if self + .state + .update(input.flags_2468, input.matrix_1536, input.byte_1600) + == Update::RequestCheckpoint + { + self.request_checkpoint(); + } + } + /// Host response for an independently verified actor-reset request. + pub fn request_checkpoint(&mut self) { + self.pending_reply = Some(Target { + transform: self.checkpoint.transform.map(|row| row.map(f32::to_bits)), + on_board: self.checkpoint.on_board, + }); + } + /// Consume at the next actor-reset publication boundary. Publish this as + ///ExternalReset plus Actor1904 bit29, then use the ordinary animation-packet + ///mapper to produce1536/1600 and2468 bit1. Never reset physical bodies here. + pub fn take_reply(&mut self) -> Option { + self.pending_reply.take().map(|reply| ExternalReset { + transform: reply.transform, + byte64: u8::from(reply.on_board), + }) + } + /// Call after ordinary physical publication so702 owns State8/61 and + ///the returned board0 transform/board272 publication for this tick. + pub fn publish_output(&self, physical: &mut PhysicalPlayerInput) -> Option { + let output = self.state.output()?; + physical.teleport_output = Some(output); + physical.state.identifier_8 = output.next_state; + physical.state.flag_61 = output.state_61; + Some(output) + } +} diff --git a/crates/skate-game/src/physics/tests/clock.rs b/crates/skate-game/src/physics/tests/clock.rs new file mode 100644 index 00000000..0edb404f --- /dev/null +++ b/crates/skate-game/src/physics/tests/clock.rs @@ -0,0 +1,56 @@ +use super::*; +use skate_core::{ + camera::{SlowMotionController, SlowMotionSettings}, + point_graph::PointGraph, +}; + +#[test] +fn camera_rate_requests_expire_and_preserve_message_order() { + let mut clock = SimulationClock::default(); + let normal = clock.period(); + let slow = SimulationRateRequest { + timestep: 1.0 / 35.4, + ticks: 1, + }; + clock.apply(slow).unwrap(); + clock.finish_tick(); + assert_eq!(clock.period(), Duration::from_nanos(28_571_400)); + clock.finish_tick(); + assert_eq!(clock.period(), normal); + + //An End followed by a new Begin must retain the new request. + clock.apply(SlowMotionController::end()).unwrap(); + clock.apply(slow).unwrap(); + clock.finish_tick(); + assert_ne!(clock.period(), normal); + clock.apply(SlowMotionController::end()).unwrap(); + clock.finish_tick(); + assert_eq!(clock.period(), normal); + + clock + .apply(SimulationRateRequest { ticks: 0, ..slow }) + .unwrap(); + for _ in 0..179 { + clock.finish_tick(); + } + assert_ne!(clock.period(), normal); + clock.finish_tick(); + assert_eq!(clock.period(), normal); +} + +#[test] +fn camera_slow_motion_waits_for_a_positive_air_prediction() { + let settings = SlowMotionSettings { + timescale: PointGraph { + x: std::array::from_fn(|i| i as f32 / 15.0), + y: std::array::from_fn(|i| i as f32 / 15.0), + }, + fps_at_scale_one: 35.0, + }; + let (mut controller, _) = SlowMotionController::begin(settings); + let waiting = controller.update(0.1, f32::NAN, settings); + assert!((waiting.timestep - 1.0 / 57.5).abs() < 1e-6); + let predicted = controller.update(0.1, 1.0, settings); + let expected = 1.0 / (60.0 - 25.0 * (0.2 / 1.1)); + assert!((predicted.timestep - expected).abs() < 1e-6); +} diff --git a/crates/skate-game/src/physics/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs new file mode 100644 index 00000000..25bd4207 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout.rs @@ -0,0 +1,98 @@ +//! Shared Ground/Air wipeout requests from original TU3 82D8F9E0/82D90358. +mod observations; +mod settings; +pub(crate) use observations::Observations; +use skate_core::player::{ + input_phase::ProcessedPhysicsInput, + wipeout::{self, Mode, Requests, Settings}, +}; +use skate_data::collections::Collections; +pub(crate) struct Wipeout { + pub state: Requests, + settings: Settings, + modes: [Mode; 5], +} +impl Wipeout { + pub fn load(data: &Collections) -> Result { + let (settings, modes) = settings::load(data)?; + let mut state = Requests::new(); + state.initialize_player(); //Player82DB3024, after the component ctor. + Ok(Self { + state, + settings, + modes, + }) + } + pub fn check_ground(&mut self, input: &Observations<'_>) -> Result<(), String> { + let mode = self.mode(input.processed)?; + let frame = input.frame()?; + wipeout::check_ground(&mut self.state, &self.settings, &mode, &frame); + Ok(()) + } + pub fn check_ground_animation( + &mut self, + input: &Observations<'_>, + scale: f32, + ) -> Result<(), String> { + let mode = self.mode(input.processed)?; + let frame = input.frame()?; + wipeout::check_ground_animation(&mut self.state, &self.settings, &mode, &frame, scale); + Ok(()) + } + pub fn check_air(&mut self, input: &Observations<'_>, use_com: bool) -> Result<(), String> { + let mode = self.mode(input.processed)?; + let frame = input.frame()?; + wipeout::check_air(&mut self.state, &self.settings, &mode, &frame, use_com); + Ok(()) + } + fn mode(&self, p: &ProcessedPhysicsInput) -> Result { + self.modes + .get(p.state_variant_index_2528 as usize) + .copied() + .ok_or_else(|| { + format!( + "Undefined wipeout physics mode {}", + p.state_variant_index_2528 + ) + }) + } + ///Consumed by state selection and by the same tick's postphysics IK branch. + pub fn requests_runout(&self, p: &ProcessedPhysicsInput) -> bool { + self.state.requests_runout(&observations::request_input(p)) + } + pub fn requests_wipeout(&self, p: &ProcessedPhysicsInput) -> bool { + self.state.requests_wipeout(&observations::request_input(p)) + } +} + +///Player postphysics82BD83E0 follows contact/error publication with the +///selected state's check, before FootIK sees that tick's wipeout request. +pub(super) fn check_after_physics( + physics: &super::GamePhysics, + skater: &mut super::SkaterRuntime, +) -> Result<(), String> { + use skate_core::player::state::PhysicalStateId; + let observations = Observations { + processed: &skater.player_input.processed, + board: &physics.riding.ground, + collision: &skater.collision_feedback, + deck: super::solve::deck_frame(&physics.board), + //Ground82BDF694..6A4 stores this target into Processed0..48; + //Animated/KnownAir publish the same unblended target before board drive. + input_board: skater.animated_skeleton.board_frames.animation_target, + world_to_animation: skater.animated_skeleton.roots.world_to_animation, + pose_error: skater.collision_pose_error, + maximum_pose_error: skater.collision_maximum_error, + jump_fix_frames: skater.player_state.post.jump_fix_frames, + air: &skater.air_reckoning.state, + system_up_y: physics.riding.reckoning_frames.system[1][1], + grind_locked_to_middle: skater.trajectory.selector.grind_locked_to_middle(), + grind_normal: skater.trajectory.selector.grind_normal(), + }; + match skater.player_state.current() { + PhysicalStateId::PhysicsGround | PhysicalStateId::SlideGround => skater.wipeout.check_ground(&observations), + PhysicalStateId::GroundAnimation => skater.wipeout.check_ground_animation(&observations, 1.0), + PhysicalStateId::PhysicsAir => skater.wipeout.check_air(&observations, false), + _ => Ok(()), //Other concrete states dispatch their own postphysics check. + } +} diff --git a/crates/skate-game/src/physics/wipeout/observations.rs b/crates/skate-game/src/physics/wipeout/observations.rs new file mode 100644 index 00000000..7d3706e1 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout/observations.rs @@ -0,0 +1,95 @@ +//! Borrow the actual completed collision and physical owners; no default contacts. +use skate_core::{ + air::reckoning::AirState, + math::Vector3, + physics::{ + board_ground::BoardGroundState, skeleton_animation_record::AnimationPartTransform, + skeleton_body::SkeletonCollisionFeedback, + }, + player::{ + input_phase::ProcessedPhysicsInput, + wipeout::{Frame, RequestInput}, + }, +}; +pub(crate) struct Observations<'a> { + pub processed: &'a ProcessedPhysicsInput, + pub board: &'a BoardGroundState, + pub collision: &'a SkeletonCollisionFeedback, + pub deck: AnimationPartTransform, + ///Actual Processed0/16/32 publication, supplied by its retained producer. + pub input_board: AnimationPartTransform, + pub world_to_animation: AnimationPartTransform, + pub pose_error: [f32; 4], + ///Skeleton16384 is unavailable before the first actual postphysics feedback. + pub maximum_pose_error: Option, + pub jump_fix_frames: u32, + pub air: &'a AirState, + ///Reckoning816's up column Y, original832.Y. + pub system_up_y: f32, + pub grind_locked_to_middle: bool, + pub grind_normal: Option<[f32; 4]>, +} +impl Observations<'_> { + pub(super) fn frame(&self) -> Result { + let p = self.processed; + let c = self.collision; + let b = self.board; + Ok(Frame { + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + category: p.category_2512, + timestep: p.timestep_2604, + time_on_ground: p.time_on_ground_2752, + speed: p.scalar_2652, + animation_up: p.vectors_544_560_592_608[0].map(f32::from_bits), + landing_angle: p.scalar_2736, + deck_velocity: p.vectors_400_416[0].map(f32::from_bits), + com_velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + jump_fix_frames: self.jump_fix_frames as i32, + deck: self.deck, + input_board: self.input_board, + world_to_animation: self.world_to_animation, + closing_velocity: vector(b.closing_velocity), + board_material_flags: b.collision_flags, + //82C08494/82C08640 publish the actual counts to868/869. + board_contact: b.part_contact_count != 0, + wheel_contact: b.wheel_contact_count != 0, + board_contact_normal: vector(b.overall_normal), + opposing_contact: b.opposing_contact, + regions_force: c.regions.map(|r| r.force), + maximum_skater_force: c.maximum_skater_force, + vehicle_force: c.maximum_group_8_force, + group_8: c.flags.group_8, + conflicting: c.flags.conflicting, + compliant: c.flags.compliant, + highest_normal: c.highest_normal, + pose_error: self.pose_error, + maximum_pose_error: self + .maximum_pose_error + .ok_or("Wipeout requires completed Skeleton pose-error feedback")?, + flip_active: self.air.flip_active, + flip_requested_speed: self.air.flip_requested_speed, + system_up_y: self.system_up_y, + grind_selected: self.grind_locked_to_middle, + grind_normal_valid: self.grind_normal.is_some(), + //Unused when invalid; retain native default up without publishing a hit. + grind_normal: self.grind_normal.unwrap_or([0.0, 1.0, 0.0, 0.0]), + }) + } +} +pub(super) fn request_input(p: &ProcessedPhysicsInput) -> RequestInput { + RequestInput { + flags_2468: p.flags_2468, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + animation_up_y: f32::from_bits(p.vectors_544_560_592_608[0][1]), + category: p.category_2512, + } +} +fn vector(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/wipeout/settings.rs b/crates/skate-game/src/physics/wipeout/settings.rs new file mode 100644 index 00000000..e01cce5b --- /dev/null +++ b/crates/skate-game/src/physics/wipeout/settings.rs @@ -0,0 +1,79 @@ +//! Globals264 -> original8289D270 class EB844CFDBF928EFD/default. +//! Fields are independently mapped through the stock physics_wipeout schema. +use skate_core::{ + player::wipeout::{AirSettings, GroundSettings, Mode, Settings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +pub(super) fn load(data: &Collections) -> Result<(Settings, [Mode; 5]), String> { + let f = |name| data.float("physics_wipeout", "default", name); + let graph = data + .words::<20>("physics_wipeout", "default", "Hash_4F08D9BAE6831524")? + .map(f32::from_bits); + let mut modes = Vec::new(); + for name in ["easy", "normal", "hardcore", "motorized", "test"] { + modes.push(Mode { + check_squash: data.boolean("physics_mode", name, "Hash_CC890A3BDCF6290A")?, + check_bad_landing: data.boolean("physics_mode", name, "WipeoutCheckForBadLanding")?, + ground_xz: data.float("physics_mode", name, "Wipeout_GroundXZAcceleration")?, + //82D90D40..94 constructs full64 D978550D6DB4E6F7. + bad_landing_scale: data.float("physics_mode", name, "Hash_D978550D6DB4E6F7")?, + }); + } + Ok(( + Settings { + ground: GroundSettings { + vehicle_scalar: f("Wipeout_GroundVehicleScalar")?, + vehicle_contact: f("Wipeout_GroundVehicleContact")?, + skitch_contact: f("Wipeout_GroundSkitchingContact")?, + skitch_scalar: f("Wipeout_GroundSkitchingScalar")?, + skitch_arms_scalar: f("Wipeout_GroundSkitchingScalarArms")?, + skater_scalar: f("SkaterSkaterThresholdScalar")?, + max_squash: f("Wipeout_GroundMaxSquash")?, + max_squash_coffin: f("Wipeout_GroundMaxSquashCoffin")?, + max_displacement: f("Wipeout_GroundSkeletonMaxDisp")?, + max_contact: f("Wipeout_GroundSkeletonMaxContact")?, + max_arm_contact: f("Wipeout_GroundSkeletonMaxContactArms")?, + max_deck_error: f("Wipeout_GroundMaxAngularDeckError")?, + opposing_contact: f("Wipeout_GroundOpposingContact")?, + y_acceleration: f("Wipeout_GroundYAcceleration")?, + light_dmo_scalar: f("WipeoutGroundLightDMOScalar")?, + player_scalar: f("DeckAccPlayerScalar")?, + ai_scalar: f("DeckAccAIScalar")?, + skitch_acc_scalar: f("DeckAccSkitchScalar")?, + balance_total: f("Wipeout_GroundBalanceTotal")?, + balance_min_speed: f("Wipeout_GroundBalanceMinSpeed")?, + balance_base: f("Wipeout_GroundBalanceBase")?, + }, + air: AirSettings { + xz_trick: f("Wipeout_AirXZTrick")?, + y_trick: f("Wipeout_AirYTrick")?, + xz_acceleration: f("Wipeout_AirXZAcceleration")?, + y_acceleration: f("Wipeout_AirYAcceleration")?, + max_squash: f("Wipeout_AirMaxSquash")?, + max_displacement: f("Wipeout_AirSkeletonMaxDisp")?, + max_contact: f("Wipeout_AirSkeletonMaxContact")?, + max_arm_contact: f("Wipeout_AirSkeletonMaxContactArms")?, + body_flip_scalar: f("Wipeout_AirBodyFlipScalar")?, + body_flip_acc_scalar: f("DeckAccBodyFlipScalar")?, + light_dmo_scalar: f("WipeoutAirLightDMOScalar")?, + ignore_danger_frames: data.integer( + "physics_wipeout", + "default", + "FramesAtStartOfAirToIgnoreDangerZone", + )? as i32, + max_landing_speed: f("Wipeout_AirMaxSpeedIntoGround")?, + max_stairs_speed: f("Wipeout_AirMaxSpeedIntoStairs")?, + max_grind_speed: f("Wipeout_AirMaxSpeedIntoCollisionNearGrind")?, + max_landing_angle: PointGraph { + x: graph[4..12].try_into().unwrap(), + y: graph[12..20].try_into().unwrap(), + }, + }, + lean_contact_y: f("Wipeout_GroundLeanContactYThresh")?, + }, + modes + .try_into() + .map_err(|_| "Expected five wipeout physics modes".to_owned())?, + )) +} diff --git a/crates/skate-game/src/physics/wipeout_states/contact.rs b/crates/skate-game/src/physics/wipeout_states/contact.rs new file mode 100644 index 00000000..0fdbb216 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/contact.rs @@ -0,0 +1,61 @@ +//! Wipeout300's material corrections and collision-policy requests82D3EC08. +use crate::physics::skeleton_controller::SkeletonControllerState; +use skate_core::{ + physics::skeleton_body::{ + SkeletonBody, SkeletonCollisionFeedback, SkeletonCollisionMode, SkeletonJoints, + }, + player::wipeout_state::{ + State, + contact_response::{ContactResponse, ContactResponseInput}, + }, +}; + +pub(crate) fn update( + state: &mut State, + response: &mut ContactResponse, + body: &mut SkeletonBody, + feedback: &SkeletonCollisionFeedback, + controller: &mut SkeletonControllerState, + collision: &mut SkeletonCollisionMode, + joints: &mut SkeletonJoints, + ragdoll: &super::ragdoll::RagdollSetup, + contact: bool, + com_velocity: [f32; 4], + effective_axis: [f32; 4], + controls: [f32; 2], +) -> Result<(), String> { + let output = response.update( + body, + ContactResponseInput { + com_velocity_608: com_velocity, + material10_normal: feedback + .flags + .material_10 + .then_some(feedback.material_normals[0]), + material11_normal: feedback + .flags + .material_11 + .then_some(feedback.material_normals[1]), + effective_axis_224: effective_axis, + wipeout_control_2824_2828: controls, + }, + ); + state.material_ten_response = output.material10_finished_77; + // The original comparison rejects <=0.3; unordered values retain true. + let air_mode = !contact && !(state.predicted_time <= 0.3); + let air_changed = state.air_collision_mode != air_mode; + state.air_collision_mode = air_mode; + let material_changed = state.material_eleven_response != output.material11_active_73; + state.material_eleven_response = output.material11_active_73; + if !state.special_surface && (air_changed || material_changed) { + let requested = if state.material_eleven_response { + 9 + } else if air_mode { + 11 + } else { + 8 + }; + ragdoll.request(controller, requested, body, joints, collision)?; + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/wipeout_states/drive_settings.rs b/crates/skate-game/src/physics/wipeout_states/drive_settings.rs new file mode 100644 index 00000000..706ad795 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/drive_settings.rs @@ -0,0 +1,39 @@ +//! Native82BE7860's per-part records and82BEB0C8/82BEB790 settings. +use skate_core::player::wipeout_state::drives::Settings; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; + +pub(crate) fn load( + data: &Collections, + asset_root: &Path, + primary_bank_sha: &str, +) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + primary_bank_sha, "PHYS_TPOSE")?; + if skeleton.bones.len() != 24 { + return Err("Wipeout requires the actual24 physical skeleton parts".into()); + } + let mut bone = [[0.0; 5]; 24]; + for part in 1..24 { + let name = format!("PART_{}", skeleton.bones[part].name); + let words = data.words::<9>("physics_skeleton_drives", "default", &name)?; + bone[part] = std::array::from_fn(|i| f32::from_bits(words[i])); + } + let roots = ["root_drive_start_scalar", "root_drive_scalar", + "root_drive_controlled_scalar", "root_drive_end_scalar"]; + let mut root = [0.0; 4]; + for (i, name) in roots.into_iter().enumerate() { + root[i] = data.float("physics_skeleton_drives", "default", name)?; + } + Ok(Settings { + bone, root, + strength: [ + data.float("animation", "default", "DriveStrengthLocal")?, + data.float("animation", "default", "DriveStrengthRootLocal")?, + ], + hook_spring: data.float("physics_animation", "default", "HookSoftDsp")?, + hook_strength: data.float("physics_animation", "default", "HookSoftStr")?, + hook_damping: data.float("physics_animation", "default", "HookSoftDmp")?, + }) +} diff --git a/crates/skate-game/src/physics/wipeout_states/lifecycle.rs b/crates/skate-game/src/physics/wipeout_states/lifecycle.rs new file mode 100644 index 00000000..ca591466 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/lifecycle.rs @@ -0,0 +1,130 @@ +//! Original Wipeout Enter82D3B5E8 and Exit82D3B990. +use super::*; +use crate::physics::{GamePhysics, SkaterRuntime}; +use skate_core::{ + animation::foot_ik::status::Mode, + math::Vector3, + physics::{board::BodyId, contact::RetailContactMaterial}, + player::wipeout_state::{body, math}, +}; + +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.ground_lifecycle.skeleton_elapsed_16505 = false; + let runtime = &mut skater.wipeout_state; + runtime.state = State::default(); + runtime.contact.reset(); + runtime.prediction.reset(); + physics.board_wiping_out = true; + physics.board.set_collision_group(7); + physics.board.bodies_mut()[BodyId::Deck.index()] + .inertia + .angular_drag = 0.0; + let material = RetailContactMaterial { + static_friction: runtime.settings.board_friction, + dynamic_friction: runtime.settings.board_friction, + restitution: runtime.settings.board_restitution, + }; + physics.settings.wheel_material = material; + physics.settings.truck_material = material; + physics.settings.deck_material = material; + skater.ground.steering.targets = [0.0; 2]; + let drive = &mut physics.board.hook_mut().drive; + drive.disable_angular(); + drive.disable_linear(); + //Do not reset the target history or the other limb ownership flags. + for limb in &mut skater.foot_ik.state.limbs { + limb.board_blend = 0.0; + limb.external_blend = 0.0; + limb.mode = Mode::Disabled; + } + skater.foot_ik.state.enable_feet(false); + drives::set_angular_root(&mut skater.skeleton_drives, &runtime.drives, 0.5); + drives::set_linear_root(&mut skater.skeleton_drives, &runtime.drives, 0.0); + let p = &skater.player_input.processed; + if p.flags_2488 & 0x0020_0000 != 0 { + body::blend_velocity( + &mut skater.skeleton, + p.vectors_880_896_912_928_944[4].map(f32::from_bits), + ); + } + if skater.wipeout.state.reasons[6] { + let normal = p.vectors_544_560_592_608[0].map(f32::from_bits); + body::remove_normal_velocity(&mut skater.skeleton, normal); + for part in physics.board.bodies_mut() { + let v = part.rates.linear_velocity; + let next = math::sub( + [v.x, v.y, v.z, 0.0], + math::scale(normal, math::dot(normal, [v.x, v.y, v.z, 0.0])), + ); + part.rates.linear_velocity = Vector3::new(next[0], next[1], next[2]); + } + physics.board.bodies_mut()[BodyId::Deck.index()] + .rates + .angular_velocity = Vector3::ZERO; + } + body::limit_velocity(&mut skater.skeleton, 20.0); + if p.flags_2468 & 4 != 0 { + skater.skeleton.apply_part_displacement( + 12, + math::scale( + p.vector_1520.map(f32::from_bits), + runtime.settings.push_force, + ), + ); + } + if p.probe_1792.byte_104 != 0 { + body::add_velocity( + &mut skater.skeleton, + p.probe_1792.vector_80.map(f32::from_bits), + ); + } + runtime.state.special_surface = p.flags_2488 & 0x4000_0000 != 0; + runtime.state.surface_height = p.collision_scalar_2924; + runtime.ragdoll.request( + &mut skater.ground_lifecycle.skeleton_controller, + if runtime.state.special_surface { 10 } else { 8 }, + &mut skater.skeleton, + &mut skater.skeleton_joints, + &mut skater.skeleton_collision, + )?; + for part in skater.skeleton.bodies_mut() { + part.inertia.linear_drag = 0.0; + } + runtime.state.move_board = p.flags_2472 & 0x800 != 0; + runtime.state.board_offset = p.vectors_720_784_800_816_832_864[3].map(f32::from_bits); + runtime.state.board_move_frames = 0; + runtime.state.predicted_time = f32::MAX; + runtime.state.airborne_frames = 15; + if p.flags_2468 & 8 != 0 { + body::set_velocity( + &mut skater.skeleton, + p.vectors_544_560_592_608[3].map(f32::from_bits), + ); + } + runtime.state.velocity = skater.animated_skeleton.board_frames.com_velocity; + Ok(()) +} + +pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + physics.board_wiping_out = false; + let runtime = &mut skater.wipeout_state; + physics.board.bodies_mut()[BodyId::Deck.index()] + .inertia + .angular_drag = runtime.settings.deck_angular_drag; + physics.board.set_collision_group(4); + [ + physics.settings.wheel_material, + physics.settings.truck_material, + physics.settings.deck_material, + ] = runtime.settings.standard_materials; + runtime.state.prevent_manual = false; +} + +pub(crate) fn post_physics(skater: &mut SkaterRuntime) { + skater.wipeout_state.state.post_physics( + skater.skeleton.record.velocities[23], + skater.skeleton.record.velocities[1], + skater.collision_feedback.flags.compliant, + &skater.wipeout_state.settings.recovery, + ); +} diff --git a/crates/skate-game/src/physics/wipeout_states/mod.rs b/crates/skate-game/src/physics/wipeout_states/mod.rs new file mode 100644 index 00000000..28e381fa --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/mod.rs @@ -0,0 +1,52 @@ +//! Original physical WipeoutGround300. Off-board recovery destinations are +//! separate states; this owner retains the native request and timing fields. +mod contact; +mod drive_settings; +mod lifecycle; +mod output; +mod prediction; +pub(crate) mod ragdoll; +mod settings; +mod skeleton; +mod update; +use skate_core::player::wipeout_state::{ + State, contact_response::ContactResponse, drives, profiles::Profile, +}; +use skate_data::collections::Collections; +use std::path::Path; + +pub(crate) struct WipeoutState { + pub(crate) ragdoll: ragdoll::RagdollSetup, + pub state: State, + settings: settings::Settings, + drives: drives::Settings, + profiles: [Profile; 5], + contact: ContactResponse, + prediction: prediction::Prediction, +} +impl WipeoutState { + pub fn load(data: &Collections, assets: &Path, primary_bank_sha: &str) -> Result { + //Ctor82D3B1EC..B25C full64 handle hashes, verified against the stock keys. + let names = [ + "free_fall", + "cannon_ball", + "judo_kick", + "swan_dive", + "torpedo", + ]; + let profiles = names.map(|name| settings::load_profile(data, name)); + let [a, b, c, d, e] = profiles; + Ok(Self { + ragdoll: ragdoll::RagdollSetup::load(data, assets, primary_bank_sha)?, + state: State::default(), + settings: settings::Settings::load(data)?, + drives: drive_settings::load(data, assets, primary_bank_sha)?, + profiles: [a?, b?, c?, d?, e?], + contact: ContactResponse::new(), + prediction: prediction::Prediction::new(), + }) + } +} +pub(crate) use lifecycle::{enter, exit, post_physics}; +pub(crate) use output::fill; +pub(crate) use update::advance; diff --git a/crates/skate-game/src/physics/wipeout_states/output.rs b/crates/skate-game/src/physics/wipeout_states/output.rs new file mode 100644 index 00000000..c9c1df74 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/output.rs @@ -0,0 +1,18 @@ +use crate::physics::SkaterRuntime; +use skate_core::player::wipeout_state::{output::Output, response}; + +pub(crate) fn fill(skater: &SkaterRuntime) -> Output { + let runtime = &skater.wipeout_state; + let feedback = &skater.collision_feedback; + let normal = response::response_normal( + feedback.flags.compliant.then_some(feedback.highest_normal), + runtime + .prediction + .result + .valid() + .then_some(runtime.prediction.result.landing_normal), + ); + runtime + .state + .output(&skater.skeleton.record.pose[23], normal) +} diff --git a/crates/skate-game/src/physics/wipeout_states/prediction.rs b/crates/skate-game/src/physics/wipeout_states/prediction.rs new file mode 100644 index 00000000..a5835af2 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/prediction.rs @@ -0,0 +1,86 @@ +//! Original Wipeout Update82D3C02C..C288; completed query precedes next submit. +use crate::physics::air_trajectory::AirTrajectoryRuntime; +use skate_core::{ + air::trajectory::{QueryRequest, QueryResult, Trajectory}, + physics::board_world::BoardWorld, + player::wipeout_state::State, +}; + +pub(crate) struct Prediction { + pub result: QueryResult, + pending: Option<(QueryResult, Trajectory, bool)>, +} +impl Prediction { + pub fn new() -> Self { + Self { + result: QueryResult::miss(), + pending: None, + } + } + ///Enter clears completion816; the old results are not consumed afterward. + pub fn reset(&mut self) { + self.pending = None; + self.result = QueryResult::miss(); + } + + pub fn advance( + &mut self, + state: &mut State, + world: &BoardWorld, + com_position: [f32; 4], + gravity: [f32; 4], + ) -> Result<(), String> { + if let Some((result, trajectory, surface_query)) = self.pending.take() { + self.result = result; + if surface_query { + if !result.valid() || result.surface & 0xF80 != 0x600 { + state.below_surface = false; + state.special_surface = false; + } + } else if result.valid() { + state.predicted_time = result.contact_time; + state.imminent_surface_twelve = + result.surface & 0xF80 == 0x600 && result.contact_time < 0.1; + state.predicted_position = trajectory.position_at(result.contact_time); + } else { + state.predicted_position = trajectory.position_at(3.0); + //The native miss branch preserves832 and573. + } + } + let surface_query = state.below_surface; + state.surface_query = surface_query; + let request = if surface_query { + let mut position = com_position; + position[1] = state.surface_height + 0.1; + QueryRequest { + trajectory: Trajectory { + position, + velocity: [0.0, -0.2, 0.0, 0.0], + acceleration: [0.0; 4], + duration: 1.0, + }, + radius: 0.01, + start_error: 1.0, + end_error: 1.0, + } + } else { + QueryRequest { + trajectory: Trajectory { + position: com_position, + velocity: state.velocity, + acceleration: gravity, + duration: 3.0, + }, + radius: 0.3, + start_error: 1.0, + end_error: 1.0, + } + }; + let result = AirTrajectoryRuntime::query(world, request)?; + //Host query execution is synchronous. The next tick's early control + //must see this completed result, before consuming it at the update tail. + self.result = result; + self.pending = Some((result, request.trajectory, surface_query)); + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/wipeout_states/ragdoll.rs b/crates/skate-game/src/physics/wipeout_states/ragdoll.rs new file mode 100644 index 00000000..230fa80b --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/ragdoll.rs @@ -0,0 +1,71 @@ +//! Physical-owner adapter for selector82D913C0 requests8..11 and82BE7280. +mod settings; +use crate::physics::skeleton_controller::SkeletonControllerState; +use skate_core::physics::skeleton_body::{ + SkeletonBody, SkeletonCollisionFeedback, SkeletonCollisionMode, SkeletonJoints, +}; +use skate_data::collections::Collections; +use std::path::Path; + +pub(crate) struct RagdollSetup { + settings: settings::Settings, +} +impl RagdollSetup { + pub fn load(data: &Collections, assets: &Path, bank_sha: &str) -> Result { + Ok(Self { + settings: settings::Settings::load(data, assets, bank_sha)?, + }) + } + + pub fn request( + &self, + controller: &mut SkeletonControllerState, + requested: u32, + body: &mut SkeletonBody, + joints: &mut SkeletonJoints, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + let Some(mode) = controller.select_request(requested) else { + return Ok(()); + }; + if !(7..=10).contains(&mode) { + return collision.select_driven(mode).map_err(str::to_owned); + } + //Request11 calls82BE3350(true), selecting normal limits while the + //body still receives ragdoll masses, materials, drag and group6. + let limits = if mode == 10 { + &self.settings.normal_limits + } else { + &self.settings.ragdoll_limits + }; + for (joint, limits) in joints.records.iter_mut().zip(limits) { + joint.parameters.words[10..14].copy_from_slice(limits); + } + collision.apply_ragdoll_properties( + body, + self.settings.inverse_mass, + self.settings.inverse_inertia, + self.settings.drag, + self.settings.materials, + ); + collision.finish_ragdoll_request(mode); + Ok(()) + } + + ///Original SetUpNormal82BE7280: limits, normal table/group, observation + ///reset, zero drags, authored animated mass/inertia/material, collision mode. + ///Call at the original physical reset owner, never at WipeoutExit alone. + pub fn restore_normal( + &self, + body: &mut SkeletonBody, + joints: &mut SkeletonJoints, + collision: &mut SkeletonCollisionMode, + feedback: &mut SkeletonCollisionFeedback, + ) { + for (joint, limits) in joints.records.iter_mut().zip(&self.settings.normal_limits) { + joint.parameters.words[10..14].copy_from_slice(limits); + } + feedback.set_up_normal(); + collision.restore_normal_properties(body); + } +} diff --git a/crates/skate-game/src/physics/wipeout_states/ragdoll/settings.rs b/crates/skate-game/src/physics/wipeout_states/ragdoll/settings.rs new file mode 100644 index 00000000..75e93b6d --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/ragdoll/settings.rs @@ -0,0 +1,97 @@ +//! Authored inputs to normal82BE58A8 and ragdoll82BE59A8/6D60. +use skate_core::physics::contact::RetailContactMaterial; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; + +pub(super) struct Settings { + pub normal_limits: [[u32; 4]; 22], + pub ragdoll_limits: [[u32; 4]; 22], + pub inverse_mass: bool, + pub inverse_inertia: bool, + pub drag: [f32; 2], + pub materials: [RetailContactMaterial; 2], +} +impl Settings { + pub fn load(data: &Collections, assets: &Path, bank_sha: &str) -> Result { + let skeleton = PhysicsSkeleton::load( + &assets.join("private/stock/physics-skeletons.json"), + bank_sha, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Ragdoll requires24 physical bones".into()); + } + let names = [ + "JOINT_NECK_NECK1", + "JOINT_SPINE3_NECK", + "JOINT_LEFT_FOREARM_HAND", + "JOINT_LEFT_ARM_FOREARM", + "JOINT_LEFT_SHOULDER_ARM", + "JOINT_SPINE3_LEFT_SHOULDER", + "JOINT_RIGHT_FOREARM_HAND", + "JOINT_RIGHT_ARM_FOREARM", + "JOINT_RIGHT_SHOULDER_ARM", + "JOINT_SPINE3_RIGHT_SHOULDER", + "JOINT_SPINE2_SPINE3", + "JOINT_SPINE1_SPINE2", + "JOINT_SPINE_SPINE1", + "JOINT_HIPS_SPINE", + "JOINT_LEFT_FOOT_TOE_BASE", + "JOINT_LEFT_LEG_FOOT", + "JOINT_LEFT_UPLEG_LEG", + "JOINT_HIPS_LEFT_LEG", + "JOINT_RIGHT_FOOT_TOE_BASE", + "JOINT_RIGHT_LEG_FOOT", + "JOINT_RIGHT_UPLEG_LEG", + "JOINT_HIPS_RIGHT_LEG", + ]; + let swing = data.float("physics_skeleton_joints", "default", "SwingRagdollScalar")?; + let twist = data.float("physics_skeleton_joints", "default", "TwistRagdollScalar")?; + let mut normal_limits = [[0; 4]; 22]; + let mut ragdoll_limits = [[0; 4]; 22]; + for (i, name) in names.into_iter().enumerate() { + let words = data + .words::<5>("physics_skeleton_joints", "default", name)? + .map(f32::from_bits); + let bone = &skeleton.bones[i + 1]; + let s = f32::from_bits(bone.words[21]); + let t = f32::from_bits(bone.words[20]); + normal_limits[i] = limits(s * words[1], t * words[2]); + ragdoll_limits[i] = limits((s * words[3]) * swing, (t * words[4]) * twist); + } + let material = |friction, restitution| -> Result<_, String> { + let friction = data.float("physics_skeleton", "default", friction)?; + Ok(RetailContactMaterial { + static_friction: friction, + dynamic_friction: friction, + restitution: data.float("physics_skeleton", "default", restitution)?, + }) + }; + let scale = f32::from_bits(0x426f_ffff); + Ok(Self { + normal_limits, + ragdoll_limits, + inverse_mass: data.boolean("physics_wipeout", "default", "DoInverseMass")?, + inverse_inertia: data.boolean("physics_wipeout", "default", "DoInverseInertia")?, + drag: [ + data.float("physics_wipeout", "default", "LinearDrag")? * scale, + data.float("physics_wipeout", "default", "AngularDrag")? * scale, + ], + materials: [ + material("FrictionRagdoll", "RestitutionRagdoll")?, + material("FrictionRagdollHead", "RestitutionRagdollHead")?, + ], + }) + } +} +fn limits(swing: f32, twist: f32) -> [u32; 4] { + let swing = if swing - 0.01 >= 0.0 { swing } else { 0.01 }; + let twist = if twist - 0.01 >= 0.0 { twist } else { 0.01 }; + [ + swing, + twist, + skate_core::trigonometry::cos(swing), + skate_core::trigonometry::cos(twist), + ] + .map(f32::to_bits) +} diff --git a/crates/skate-game/src/physics/wipeout_states/settings.rs b/crates/skate-game/src/physics/wipeout_states/settings.rs new file mode 100644 index 00000000..498bf7be --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/settings.rs @@ -0,0 +1,100 @@ +//! Stock vault bindings for WipeoutGround300 and its authored control profiles. +use skate_core::{ + player::wipeout_state::{profiles::Profile, recovery}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +pub(crate) struct Settings { + pub recovery: recovery::Settings, + pub remove_target_time: f32, + pub remove_drives_time: f32, + pub controlled_weight_step: f32, + pub collision_weight_step: f32, + pub board_restitution: f32, + pub board_friction: f32, + pub deck_angular_drag: f32, + pub push_force: f32, + pub standard_materials: [skate_core::physics::contact::RetailContactMaterial; 3], +} + +impl Settings { + pub(crate) fn load(data: &Collections) -> Result { + let f = |name| data.float("physics_wipeout", "default", name); + Ok(Self { + recovery: recovery::Settings { + minimum_time: f("TeleportMinTimeForAutoReset")?, + minimum_settled: f("TeleportMinTimeAfterSettlingForAutoReset")?, + maximum_time: f("TeleportMaxTime")?, + fade_time: f("TeleportAutoResetFadeOutTime")?, + over_speed: f("WipeoutOverSpeed")?, + over_minimum_time: f("WipeoutOverMinTime")?, + }, + remove_target_time: f("TimeToRemoveHook")?, + remove_drives_time: f("TimeToRemoveDrives")?, + controlled_weight_step: f("DynamicDriveWeightVelControlled")?, + collision_weight_step: f("DynamicDriveWeightVelCollision")?, + board_restitution: f("SkateboardRestitution")?, + board_friction: f("SkateboardFriction")?, + deck_angular_drag: data.float("physicsdeck", "default", "DeckAngularDrag")? + * f32::from_bits(0x426F_FFFF), + push_force: f("LivingWorldPushForce")?, + standard_materials: [ + material(data, "physicswheels", "Wheel")?, + material(data, "physicstrucks", "Truck")?, + material(data, "physicsdeck", "Deck")?, + ], + }) + } +} + +fn material( + data: &Collections, + class: &str, + prefix: &str, +) -> Result { + Ok(skate_core::physics::contact::RetailContactMaterial { + static_friction: data.float(class, "default", &format!("{prefix}StaticFriction"))?, + dynamic_friction: data.float(class, "default", &format!("{prefix}DynamicFriction"))?, + restitution: data.float(class, "default", &format!("{prefix}Restitution"))?, + }) +} + +/// The key is supplied by the original constructor's verified handle binding. +/// PointNegGraph metadata occupies four words before the eight X/Y samples. +pub(crate) fn load_profile(data: &Collections, key: &str) -> Result { + let class = "physics_wipeout_control"; + let f = |name| data.float(class, key, name); + let b = |name| data.boolean(class, key, name); + let v = |name| { + data.words::<4>(class, key, name) + .map(|words| words.map(f32::from_bits)) + }; + let graph = data.words::<20>(class, key, "SpinVsTime")?; + Ok(Profile { + roll_axis: v("RollOnGroundAxis")?, + horizontal_axis: v("HorizAlignAxis")?, + align_euler: v("AlignAxisEuler")?, + spin_vs_time: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(graph[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(graph[12 + i])), + }, + direction_follow: f("Hash_1BC908CDB3520A8E")?, + torque_velocity: f("TorqueVelScalar")?, + torque_distance: f("TorqueDistScalar")?, + spin_inertia: f("SpinInertia")?, + roll_on_ground: b("RollOnGround")?, + ground_roll_torque: f("RollingOnGroundTorqueVelScalar")?, + sideways_spin: f("Hash_40B2B3C1A87A4D76")?, + forward_spin: f("Hash_9047531918285FC3")?, + horizontal_directed: b("Hash_6009FD75F9EDC436")?, + drift_maximum_speed: f("DriftMaxSpeed")?, + drift_forward: f("DriftFactorZ")?, + drift_sideways: f("DriftFactorX")?, + align_with_velocity: b("AlignWithVel")?, + align_ground_with_velocity: b("Hash_9626703A9939FE36")?, + ground_align_torque: f("AlignOnGroundTorqueVelScalar")?, + tilt_degrees: f("Hash_B65276A8CC7FB760")?, + drift_drag: f("Hash_7E6B3A99C0A33ABC")?, + }) +} diff --git a/crates/skate-game/src/physics/wipeout_states/skeleton.rs b/crates/skate-game/src/physics/wipeout_states/skeleton.rs new file mode 100644 index 00000000..c4dbd50d --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/skeleton.rs @@ -0,0 +1,93 @@ +//! Original Skeleton::UpdateWipeout82BDFA88 on the live physical owners. +use crate::physics::{ + foot_ik::PhysicalInput, + skeleton_input_runtime::{SkeletonInputRuntime, SkeletonOwners}, +}; +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + skeleton_animation_record::IDENTITY, + skeleton_body::SkeletonTargetInput, + }, + player::{ + input_phase::ProcessedPhysicsInput, + wipeout_state::{drives, skeleton}, + }, +}; + +///The original r8 byte skips only target-position updates. It does not skip +///root construction, COM frames, first-state-tick IK, or ragdoll drive updates. +pub(crate) fn update( + input: &mut SkeletonInputRuntime, + owners: &mut SkeletonOwners<'_>, + processed: &ProcessedPhysicsInput, + globals: &[NativeMatrix], + settings: &drives::Settings, + start: f32, + end: f32, + controlled: f32, + extra: f32, + skip_targets: bool, +) -> Result { + let s = &mut owners.animated; + s.roots.initialize_heading = false; + skeleton::update_roots( + &mut s.roots, + &s.animation_hips, + &owners.body.record.pose[23], + owners.body.record.velocities[23], + s.board_frames.centre_of_mass, + processed.vectors_544_560_592_608[3].map(f32::from_bits), + processed.state_timer_2664, + ); + if !skip_targets { + let target = owners.drives.targets.update_positions( + SkeletonTargetInput { + animation_hips: &s.animation_hips, + animation_board: &s.animation_board, + animation_to_world: &s.roots.animation_to_world, + inverse_board: &s.roots.inverse_board, + skate_root: &s.board_frames.skate_root, + com_frame: &s.board_frames.com_frame, + lifted_com_frame: &s.board_frames.lifted_com_frame, + teleporting: input.teleporting, + }, + owners.body, + ); + input.animation_board_to_physics = target.animation_board_to_physics; + input.extra_target_positions = [ + target.positions.com, target.positions.lifted_com, + target.positions.following_com, + ]; + owners.pose_errors.set_targets(target.positions); + //82BDFA88 ignores the target continuity result: no GeneralUpdate reset. + } + s.board_frames.skate_root = s.roots.animation_to_world; + s.board_frames.update_com_lift( + &s.roots.animation_to_world, s.board_frames.centre_of_mass, 0.0); + if processed.state_timer_2664 == 0.0 { + let contacts = std::array::from_fn(|i| { + let query = processed.line_tests_960_1008_1056[i]; + (query.valid != 0).then(|| query.position.map(f32::from_bits)) + }); + let result = owners.ik.update(s, globals, PhysicalInput { + state_id: processed.state_2508, + flags_2468: processed.flags_2468, + flags_2472: processed.flags_2472, + flags_2480: processed.flags_2480, + contact_bone: owners.animation_input.contacts.bone as usize, + physical_board: &s.board_frames.physical_board, + hips_world_position: owners.body.part_transforms()[23][3], + current_contacts: contacts, + })?; + input.drive_frames = result.frames; + } + let residual = drives::update(owners.drives, &input.drive_frames, settings, + drives::Weights { + start, end, controlled, + upper_extra: if processed.flags_2484 & 0x20 != 0 { extra } else { 0.0 }, + lower_extra: if processed.flags_2484 & 0x10 != 0 { extra } else { 0.0 }, + }); + s.motion.next_trajectory = IDENTITY; + Ok(residual) +} diff --git a/crates/skate-game/src/physics/wipeout_states/update.rs b/crates/skate-game/src/physics/wipeout_states/update.rs new file mode 100644 index 00000000..a5f80c22 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/update.rs @@ -0,0 +1,228 @@ +//! Complete Wipeout300 update order82D3B9C8, on the game body/pose owners. +use super::*; +use crate::physics::{GamePhysics, SkaterRuntime, skeleton_input_runtime::SkeletonOwners}; +use skate_core::{ + math::Vector3, + physics::board::BodyId, + player::wipeout_state::{ + body, control_air, control_ground, control_profile_air, math, profiles, response, weights, + }, + riding::ground_correction_math::apply_world_force, +}; +const DT: f32 = f32::from_bits(0x3C88_8889); + +pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let runtime = &mut skater.wipeout_state; + let state = &mut runtime.state; + let contact = skater.collision_feedback.flags.any || state.below_surface; + state.airborne_frames = if contact { + -1 + } else { + state.airborne_frames.wrapping_add(1) + }; + if !state.special_surface && p.flags_2488 & 0x4000_0000 != 0 { + state.special_surface = true; + state.surface_height = p.collision_scalar_2924; + runtime.ragdoll.request( + &mut skater.ground_lifecycle.skeleton_controller, + 10, + &mut skater.skeleton, + &mut skater.skeleton_joints, + &mut skater.skeleton_collision, + )?; + } + let velocity = p.vectors_544_560_592_608[3].map(f32::from_bits); + if velocity[1] > 5.0 && velocity[1] - state.velocity[1] > 5.0 { + body::limit_velocity(&mut skater.skeleton, 5.0); + } + state.velocity = velocity; + let speed = math::length(velocity); + response::retained_velocity( + state, + &mut skater.skeleton, + &mut skater.skeleton_drives, + &runtime.drives, + contact, + p.flags_2468, + velocity, + ); + let position = p.vectors_544_560_592_608[2].map(f32::from_bits); + if state.special_surface { + state.below_surface = position[1] - state.surface_height < 0.0; + body::special_surface(&mut skater.skeleton, state.surface_height); + } + let effective = p + .effective_anim_transform_192 + .map(|row| row.map(f32::from_bits)); + let controls = skater.animation_input.extra.wipeout_control; + let gesture = skater.animation_input.extra.wipeout_gesture; + if !state.below_surface { + contact::update( + state, + &mut runtime.contact, + &mut skater.skeleton, + &skater.collision_feedback, + &mut skater.ground_lifecycle.skeleton_controller, + &mut skater.skeleton_collision, + &mut skater.skeleton_joints, + &runtime.ragdoll, + contact, + velocity, + effective[2], + controls, + )?; + let drag = if state.slow && contact { + if state.over { + math::clamp(state.settled_time * 0.15 + 0.05, 0.0, 0.5) + } else { + 0.05 + } + } else { + 0.0 + }; + body::set_wipeout_drag(&mut skater.skeleton, drag); + } + state.time += DT; + state.response_time += DT; + state.extra_weight_zero_time += DT; + if state.extra_weight > 0.0 { + state.extra_weight_zero_time = 0.0; + } + state.response_scalar = 0.0; + state.slow_time += DT; + state.maximum_speed = if speed - state.maximum_speed >= 0.0 { + speed + } else { + state.maximum_speed + }; + if speed > 2.0 { + state.slow_time = 0.0; + } + response::update_counter(state, contact); + let com = skater.animated_skeleton.board_frames.centre_of_mass; + let mut control_applied = false; + if state.below_surface || state.imminent_surface_twelve { + control_air::update(state, &mut skater.skeleton, com, controls, true); + } else if p.flags_2484 & 0x100 != 0 && state.response_time >= 0.1 { + let normal = response::response_normal( + skater + .collision_feedback + .flags + .compliant + .then_some(skater.collision_feedback.highest_normal), + runtime + .prediction + .result + .valid() + .then_some(runtime.prediction.result.landing_normal), + ); + response::trigger(state, &mut skater.skeleton, normal, controls); + } else if state.airborne_frames > 15 && !state.over { + profiles::prepare(state, &runtime.profiles, gesture, controls); + let profile = &runtime.profiles[state.profile]; + control_profile_air::update( + state, + &mut skater.skeleton, + com, + &effective, + controls, + profile, + ); + profiles::drift(state, &mut skater.skeleton, profile); + control_applied = true; + } else if (!contact && state.airborne_frames > 15) || state.over { + control_applied = control_air::update(state, &mut skater.skeleton, com, controls, contact); + } else { + profiles::prepare(state, &runtime.profiles, gesture, controls); + control_ground::update( + state, + &mut skater.skeleton, + com, + &effective, + state.predicted_position, + &runtime.profiles[state.profile], + &skater.animated_skeleton.roots.animation_to_world, + &skater.animated_skeleton.record.pose, + ); + control_applied = true; + } + let weight = weights::update( + state, + weights::Settings { + remove_target_time: runtime.settings.remove_target_time, + remove_drives_time: runtime.settings.remove_drives_time, + collision_step: runtime.settings.collision_weight_step, + controlled_step: runtime.settings.controlled_weight_step, + }, + contact, + control_applied, + p.flags_2472, + gesture, + ); + skater.ground.steering.update( + 0.0, + skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + drives::set_angular_root( + &mut skater.skeleton_drives, + &runtime.drives, + weight.target_weight, + ); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + 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, + }; + skeleton::update( + &mut skater.skeleton_input, + &mut owners, + p, + &skater.animation.packet.hierarchy, + &runtime.drives, + weight.start, + weight.end, + weight.controlled, + weight.extra, + state.retained_velocity_active, + )?; + if state.move_board && (state.board_move_frames as i32) < 21 { + let deck = physics.board.part_transforms()[BodyId::Deck.index()].translation; + let point = skater + .player_input + .toolkit + .as_ref() + .ok_or("Wipeout board force requires the current BoardToolkit")? + .deck[3]; + apply_world_force( + &mut physics.board.bodies_mut()[BodyId::Deck.index()], + deck, + vector(state.board_offset), + vector(point), + ); + state.board_move_frames = state.board_move_frames.wrapping_add(1); + } + state.manage_recovery( + p.flags_2468, + p.flags_2472, + p.flags_2484, + p.timestep_2604, + &runtime.settings.recovery, + ); + let gravity = physics.settings.step.simulation.gravity_acceleration; + runtime.prediction.advance( + state, + physics.world(), + position, + [gravity.x, gravity.y, gravity.z, 0.0], + ) +} +fn vector(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs new file mode 100644 index 00000000..559aaba0 --- /dev/null +++ b/crates/skate-game/src/skater_animation.rs @@ -0,0 +1,278 @@ +//! The skater's stock graph -> tree -> pose -> physics-packet owner. +//! Physical observations come from the preceding completed simulation output. +mod state; +use crate::{ + animation_pose::PoseEvaluator, + graph_host::{ + action::ActionHost, + motion::{MotionHost, MotionPhysical}, + }, + graph_runtime::StockGraphs, +}; +use skate_core::graph::intents::IntentMap; +use skate_core::{ + animation::{ + body_tilt, + output::{ + self, Sqt, + attributes::PacketAttributes, + packet_reset::{self, AdditionalResetFields, RESET_POSE}, + physics_packet::{self, PhysicsPosePacket}, + }, + physical_feedback::PhysicalFeedback, + playback::PlaybackContext, + playback_tree::{Evaluation, PoseCommand}, + riding_fakie, + skeleton_input::name::encode, + }, + graph::{conditions::ConditionInputs, controller::Controller}, + riding::push_behaviors::PushFootFrame, +}; +use skate_data::collections::Collections; +use std::path::Path; + +/// Completed native publications, never inferred from a render transform. +pub(crate) struct AnimationPhysical { + pub conditions: ConditionInputs, + pub feedback: PhysicalFeedback, + pub body_tilt: body_tilt::Physical, + pub fakie: riding_fakie::Physical, + ///PhysOutAnimation158/157, consumed by IsRidingGoofy. + pub physical_stance: (bool, bool), + pub foot_frame: PushFootFrame, + ///PhysOut72 byte311 (true means a board is present). + ///both PlayAnimation82BB54D4 and Phase1 consume the byte without inversion. + pub board_present: bool, + pub physical_28_byte75: bool, + pub time_since_teleport: f32, +} + +pub(crate) struct SkaterAnimation { + pub action: ActionHost, + pub motion: MotionHost, + pub evaluator: PoseEvaluator, + pub pose: Vec, + pub packet: PhysicsPosePacket, + pub attributes: PacketAttributes, + pub action_controller: Controller, + pub motion_controller: Controller, + state: state::AnimationState, + pub ticks: u64, +} + +impl SkaterAnimation { + pub fn stance(&self) -> (bool, bool) { + (self.state.fakie(), self.state.mirrored()) + } + /// Actor8259147C evaluates the stored initialization pose before creating + /// the physical skeleton. Initialize82B97E38 selects rig_Tpose; + /// 82D181D0 stores it at13484, and82531050 emits that pose command. + /// This is separate from both the graph's first update and AnimOut reset. + pub fn evaluate_initial_pose(&mut self) -> Result, String> { + self.pose = self.evaluator.evaluate(&[PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }])?; + self.evaluator.hierarchy(&self.pose) + } + + pub fn load( + root: &Path, + data: &Collections, + graphs: &StockGraphs, + pro_skater: &[u8], + ) -> Result { + let banks = skate_data::animation_banks::AnimationBanks::load(root)?; + let evaluator = PoseEvaluator::from_banks(&banks)?; + let state = state::AnimationState::new(true); + let count = evaluator.frames.bone_names.len(); + let mut motion = MotionHost::from_graph( + &graphs.motion, + data, + banks.metadata()?, + PlaybackContext { + is_switch: Some(state.switch()), + is_mirrored: Some(state.mirrored()), + board_available: None, + pro_skater: encode(pro_skater), + transition_override: None, + }, + ) + .map_err(|error| format!("Stock MotionGraph initialization: {error}"))?; + motion.animation.set_hierarchy( + &evaluator.frames.bone_names, + &evaluator.frames.mirror_indices, + )?; + //82858810 registers these three static poses in this exact order. + use skate_core::animation::posture::PosturePose; + for posture in [PosturePose::Stiff, PosturePose::Slouch, PosturePose::Buff] { + let pose = evaluator.frames.named_pose(posture.name())?; + if pose.samples.len() != count { + return Err(format!( + "Stock posture {} has {} bones; skater hierarchy has {count}", + posture.name(), + pose.samples.len() + )); + } + } + motion.animation.posture_bank_valid = true; + motion.animation.skater_animation_flags = Some(state.flags); + Ok(Self { + action: ActionHost::from_graph(&graphs.action, data) + .map_err(|error| format!("Stock ActionGraph initialization: {error}"))?, + motion, + evaluator, + pose: Vec::new(), + attributes: PacketAttributes::default(), + state, + action_controller: Controller::new(graphs.action.runtime.program.topology.states.len()), + motion_controller: Controller::new(graphs.motion.runtime.program.topology.states.len()), + //Represented pose fields after AnimOutPhysIn Reset82590028. + packet: PhysicsPosePacket { + bone_count: count as u32, + hierarchy: vec![RESET_POSE; count], + local: vec![RESET_POSE; count], + timestep: f32::from_bits(0x3c88_8889), + foot_surface_ids: [0; 2], + flags: 0, + board_flipped: false, + mirrored: false, + riding_switch: false, + riding_fakie: false, + weight_forwards: false, + regular_stance: false, + air_dismount_revert_frames: 0, + }, + ticks: 0, + }) + } + + ///Actor Phase1/2/3 plus SetUpPhysics:82592FD8,82593128,82593230,82593640. + ///GenerateActionGraphIntents has completed before this call. Keep one MG + ///intent map across both controllers; native preupdate clears only attrs. + pub fn advance( + &mut self, + graphs: &StockGraphs, + dt: f32, + action_intents: &IntentMap, + physical: AnimationPhysical, + reset_fields: &mut AdditionalResetFields, + ) -> Result<(), String> { + self.publish_physical(physical)?; + self.action.action_intents.clone_from(action_intents); + self.motion.action_intents.clone_from(action_intents); + //HasAnimAttribute82BA3A78 reads the preceding cached animation + //attributes. Specific flag27 is observed before this frame's MG update. + self.action.animation_attributes.clear(); + self.action + .animation_attributes + .extend_from_slice(self.motion.animation.tree_attributes()); + self.action.is_tricking = Some(self.motion.flags.doing_trick); + // Specific MotionGraph+8 is its own persistent filtered-intent map + // (82D0B660), initially cleared by825953B0. Only authored filtering + // behaviors write it; raw actor/controller intents do not replace it. + std::mem::swap( + &mut self.action.motion_intents, + &mut self.motion.animation.motion_intents, + ); + self.action_controller + .update(&graphs.action.runtime.program, dt, &mut self.action); + std::mem::swap( + &mut self.action.motion_intents, + &mut self.motion.animation.motion_intents, + ); + if !self.action.errors.is_empty() { + return Err(self.action.errors.join("\n")); + } + self.motion.animation.begin_graph_update(); + self.motion_controller + .update(&graphs.motion.runtime.program, dt, &mut self.motion); + if !self.motion.errors.is_empty() { + return Err(self.motion.errors.join("\n")); + } + self.state.flags = self + .motion + .animation + .skater_animation_flags + .ok_or("MotionGraph lost the SkaterAnim flag owner")?; + + self.motion.animation.apply_parameters()?; + self.motion.animation.advance(dt, self.state.phase); + //Collect before pose evaluation consumes clip history;82B98980 then + //uses those exact records for board/mirror/switch event publication. + self.motion.animation.refresh_tree_attributes()?; + self.state + .apply_stance_events(self.motion.animation.tree_attributes()); + let commands = self.motion.animation.evaluate_pose(Evaluation { + cull_threshold: self.state.cull_threshold(), + update_history: true, + })?; + self.pose = self.evaluator.evaluate(&commands)?; + let hierarchy = self.evaluator.hierarchy(&self.pose)?; + let local: Vec<_> = self + .pose + .iter() + .copied() + .map(output::sqt_to_matrix) + .collect(); + self.attributes.replace_from( + &self.motion.animation.motion_attributes, + self.motion.animation.tree_attributes(), + ); + self.state.prepare_publication(); + // Actor SetUpPhysics82593640 resets the output after animation has + // evaluated, before publishing this pose and the actor-owned fields. + packet_reset::reset(&mut self.packet, reset_fields) + .map_err(|error| format!("Animation packet reset: {error:?}"))?; + physics_packet::publish_evaluated( + &mut self.state.publication, + &hierarchy, + &local, + &mut self.packet, + //Actor825936AC..D4 passes this fixed physical step even when + //the graph/clip clock differs or camera slow motion is active. + f64::from(f32::from_bits(0x3c888889)), + b"signup", + ) + .map_err(|e| format!("Skater pose publication: {e:?}"))?; + self.state.finish_publication(); + self.motion.animation.skater_animation_flags = Some(self.state.flags); + self.ticks += 1; + Ok(()) + } + + fn publish_physical(&mut self, p: AnimationPhysical) -> Result<(), String> { + let speed = p + .conditions + .speeds + .ok_or("Animation requires completed board speed output")?; + let stance = (self.state.fakie(), self.state.mirrored()); + self.action.stance = Some(stance); + self.action.condition_inputs = p.conditions.clone(); + self.motion.condition_inputs = p.conditions; + self.motion.playback_context.is_mirrored = Some(self.state.mirrored()); + self.motion.playback_context.is_switch = Some(self.state.switch()); + self.motion.playback_context.board_available = Some(p.board_present); + self.motion.physical = Some(MotionPhysical { + turning: p.feedback.turning, + stance, + forward_speed: speed.forward_speed, + time_since_teleport: p.time_since_teleport, + is_switch: self.state.switch(), + foot_frame: Some(p.foot_frame), + }); + self.motion.physical_stance = Some(p.physical_stance); + self.motion.crouching_physical = Some(p.feedback.crouching); + self.motion.body_tilt_physical = Some(p.body_tilt); + self.motion.fakie_physical = Some(p.fakie); + self.motion.pumping_acceleration = Some(p.feedback.pumping_acceleration); + + //8259303C..78: on-board category keeps this true even when the + //off-board owner's board-present byte is false. 82B971A8 copies it + //straight into flag17. It is not a no-board bit. + let board_attached_or_onboard = p.board_present || !p.physical_28_byte75; + self.state.flags = + (self.state.flags & !(1 << 17)) | (u32::from(board_attached_or_onboard) << 17); + self.motion.animation.skater_animation_flags = Some(self.state.flags); + Ok(()) + } +} diff --git a/crates/skate-game/src/skater_animation/state.rs b/crates/skate-game/src/skater_animation/state.rs new file mode 100644 index 00000000..ff6ee47f --- /dev/null +++ b/crates/skate-game/src/skater_animation/state.rs @@ -0,0 +1,87 @@ +//! State shared by graph construction, stance events and physics publication. +use skate_core::animation::{ + output::{attributes::AnimationAttribute, physics_packet::SkaterPublicationState}, + skeleton_input::name::encode, +}; + +pub(super) struct AnimationState { + pub flags: u32, + pub publication: SkaterPublicationState, + /// Base animator14652: ctor82D17CDC sets zero; virtual40/82531200 writes it. + pub phase: f32, +} + +impl AnimationState { + pub fn new(local_player: bool) -> Self { + //82B975F4 selects bit27;82B97674 selects bit17 and clears the + //orientation,mirror,fakie,weight and request bits used by this owner. + //Unrepresented allocator/padding bits are not gameplay state here. + Self { + flags: (u32::from(local_player) << 27) | 0x0002_0000, + phase: 0.0, + publication: SkaterPublicationState { + orientation_bit31: false, + mirrored: false, + riding_fakie: false, + weight_on_nose: false, + //82B975B8..82B97674: GOOFY natural stance, NATURAL relative. + natural_stance: 1, + relative_stance: 0, + request_bit16: false, + request_bit15: false, + air_dismount_revert_requested: false, + air_dismount_revert_frames: 0, + signal: None, + }, + } + } + + pub fn mirrored(&self) -> bool { + self.flags & 0x4000_0000 != 0 + } + pub fn fakie(&self) -> bool { + self.flags & 0x2000_0000 != 0 + } + pub fn switch(&self) -> bool { + self.publication.relative_stance == 1 + } + + ///82B98980 calls82D19010's first-match query. No scalar/status test: + ///presence in the collected tree attribute list toggles the state once. + pub fn apply_stance_events(&mut self, attributes: &[AnimationAttribute]) { + let present = |name: &[u8]| attributes.iter().any(|a| a.name == encode(name)); + if present(b"animboardbackward") { + self.flags ^= 0x8000_0000; + } + if present(b"mirrored") { + self.flags ^= 0x4000_0000; + } + if present(b"switch") { + self.publication.relative_stance = i32::from(self.publication.relative_stance == 0); + } + } + + pub fn prepare_publication(&mut self) { + let p = &mut self.publication; + p.orientation_bit31 = self.flags & 0x8000_0000 != 0; + p.mirrored = self.flags & 0x4000_0000 != 0; + p.riding_fakie = self.flags & 0x2000_0000 != 0; + p.weight_on_nose = self.flags & 0x1000_0000 != 0; + p.request_bit16 = self.flags & 0x0001_0000 != 0; + p.request_bit15 = self.flags & 0x0000_8000 != 0; + p.air_dismount_revert_requested = self.flags & 0x0010_0000 != 0; + } + + pub fn finish_publication(&mut self) { + self.flags &= !(0x0001_0000 | 0x0000_8000 | 0x0010_0000); + } + + ///SkaterAnim virtual172/82B98078, selected by local-player bit27. + pub fn cull_threshold(&self) -> f32 { + f32::from_bits(if self.flags & 0x0800_0000 != 0 { + 0x3c23_d70a + } else { + 0x3dcc_cccd + }) + } +} diff --git a/crates/skate-game/src/tests/action_host.rs b/crates/skate-game/src/tests/action_host.rs new file mode 100644 index 00000000..a2b4b4ce --- /dev/null +++ b/crates/skate-game/src/tests/action_host.rs @@ -0,0 +1,184 @@ +use crate::graph_host::action::*; +use crate::graph_host::action_nodes::Parameter; +use crate::graph_host::action_nodes::{ActionInstance, ActionInstances, ActionOperation}; +use crate::graph_runtime::OperationRemap; +use skate_core::graph::controller::{Frame, Host}; + +fn parameter(mg_intent: &str, value: f32) -> Parameter { + Parameter { + name: None, + mg_intent: Some(mg_intent.into()), + ag_intent: None, + text: None, + float_bits: Some(value.to_bits()), + boolean_byte: None, + default_value: None, + scale: None, + on_update: false, + filters: [0; 4], + } +} + +#[test] +fn const_callbacks_publish_then_remove_motion_intent() { + let instance = ActionInstance { + operation: ActionOperation::CreateConstMgIntent, + config: parameter("Crouch", 1.0), + parameters: Vec::new(), + }; + // Conditions are interleaved with behaviors in the source binding. A + // compact behavior ID must select its mapped operation and configuration. + let mut earlier = instance.clone(); + earlier.config = parameter("WrongSourceOperation", 9.0); + let mut host = ActionHost::new( + ActionInstances::new(vec![earlier, instance]), + OperationRemap { + behaviors: vec![1], + conditions: vec![0], + hooks: vec![], + }, + ); + Host::begin( + &mut host, + 0, + [0; 6], + &Frame { + dt: 0.0, + current: None, + last: None, + state_times: Vec::new(), + }, + ); + assert_eq!(host.motion_intents.get("Crouch"), Some(&1.0)); + assert!(!host.motion_intents.contains_key("WrongSourceOperation")); + Host::end( + &mut host, + 0, + [0; 6], + &Frame { + dt: 0.0, + current: None, + last: None, + state_times: Vec::new(), + }, + ); + assert!(!host.motion_intents.contains_key("Crouch")); +} + +#[test] +fn pushing_clock_starts_fresh_when_graph_reenters_behavior() { + let mut config = parameter("Pushing", 0.0); + config.ag_intent = Some("Pushing".into()); + let instance = ActionInstance { + operation: ActionOperation::CreateMgTimeIntent, + config, + parameters: Vec::new(), + }; + let mut host = ActionHost::new( + ActionInstances::new(vec![instance]), + OperationRemap { + behaviors: vec![0], + conditions: vec![], + hooks: vec![], + }, + ); + let frame = Frame { + dt: 0.125, + current: None, + last: None, + state_times: vec![], + }; + host.action_intents.insert("Pushing", 1.0); + host.allocate(0, &frame); + for _ in 0..3 { + host.update(0, [0; 6], &frame); + } + assert_eq!(host.motion_intents.get("Pushing"), Some(&0.375)); + host.end(0, [0; 6], &frame); + assert!(!host.motion_intents.contains_key("Pushing")); + host.allocate(0, &frame); // TU382BBCB50 initializes each instance to zero. + host.update(0, [0; 6], &frame); + assert_eq!(host.motion_intents.get("Pushing"), Some(&0.125)); +} + +#[test] +fn native_intent_aliases_share_action_motion_and_end_lifecycle() { + use crate::graph_host::motion::MotionAnimation; + use skate_core::animation::playback_parameters::ParameterInputs; + use skate_core::graph::{ + activation::ConditionHost, + conditions::{ActionCondition, Comparison, NumericCondition}, + }; + use skate_data::animation_metadata::AnimationMetadata; + + // These case variants both occur in the authored ActionGraph. No animation + // tree is needed to exercise its real persistent MotionAnimation intent map. + for pair in [ + ["Fingerflip", "FingerFlip"], + ["OB_OBJECTMVX", "OB_ObjectMvX"], + ] { + for [first, second] in [pair, [pair[1], pair[0]]] { + let create = |name: &str| { + let mut config = parameter(name, 0.0); + config.ag_intent = Some(name.into()); + ActionInstance { + operation: ActionOperation::CreateMgIntent, + config, + parameters: vec![], + } + }; + let condition = ActionInstance { + operation: ActionOperation::Condition(ActionCondition::HasIntent { + name: first.into(), + numeric: NumericCondition { + comparison: Comparison::Equal, + threshold: 0.75, + absolute: false, + }, + }), + config: parameter(first, 0.0), + parameters: vec![], + }; + let mut host = ActionHost::new( + ActionInstances::new(vec![create(first), create(second), condition]), + OperationRemap { + behaviors: vec![0, 1], + conditions: vec![2], + hooks: vec![], + }, + ); + let mut motion = MotionAnimation::from_metadata(AnimationMetadata::parse( + r#"{"version":1,"source_bank":"intent-test","source_sha256":"0000000000000000000000000000000000000000000000000000000000000000","source_bytes":48,"clips":[],"unsupported_trees":[]}"# + ).unwrap()); + let frame = Frame { + dt: 1.0 / 60.0, + current: None, + last: None, + state_times: vec![], + }; + host.action_intents.insert(first, 0.25); + assert_eq!(host.action_intents.insert(second, 0.75), Some(0.25)); + assert_eq!(host.action_intents.len(), 1); + assert_eq!(host.condition_activation(0, &frame), 1); + host.begin(0, [0; 6], &frame); + assert_eq!(host.motion_intents.get(second), Some(&0.75)); + host.action_intents.insert(first, 1.25); + host.begin(1, [0; 6], &frame); + assert_eq!(host.motion_intents.len(), 1); + + // Preserve the production coordinator's single-map handoff. + std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + assert_eq!(motion.motion_intent(first), Some(1.25)); + assert_eq!(motion.motion_intent(second), Some(1.25)); + std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + host.end(0, [0; 6], &frame); + std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + assert_eq!(motion.motion_intent(first), None); + assert_eq!(motion.motion_intent(second), None); + assert!(motion.motion_intents.is_empty()); + assert_eq!(host.action_intents.remove(first), Some(1.25)); + assert!(!host.action_intents.contains_key(second)); + assert!(host.errors.is_empty(), "{:?}", host.errors); + } + } +} diff --git a/crates/skate-game/src/tests/air_playback.rs b/crates/skate-game/src/tests/air_playback.rs new file mode 100644 index 00000000..d1c46cf0 --- /dev/null +++ b/crates/skate-game/src/tests/air_playback.rs @@ -0,0 +1,174 @@ +//! Real controller/graph/physical/pose/camera regression across a level edge. +use super::*; +use skate_core::player::state::PhysicalStateId; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn stock_push_off_edge_runs_air_and_landing_in_production() { + roll_off(None); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn airborne_stick_reaches_physical_spin() { + for stick in [-32767, 32767] { + roll_off(Some(stick)); + } +} + +fn roll_off(air_stick: Option) { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + // Keep this air fixture's open edge now that the production level has a + // downhill ramp. The dedicated ramp regression uses the unmodified world. + physics.world = BoardWorld::new(physics.world.triangles().iter().copied() + .filter(|face| !(face.tag == 1 + && face.triangle.vertices.iter().any(|vertex| vertex.z > 4.0))) + .collect()); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut previous = skater.player_state.current(); + let mut saw_air = false; + let mut landed_frames = 0; + let mut airborne_frames = 0; + for tick in 0..1000 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick >= 12 && !saw_air { 0x1000 } else { 0 }, + triggers: [0; 2], + left: [if saw_air { air_stick.unwrap_or(0) } else { 0 }, 0], + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + let result = frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ); + let state = skater.player_state.current(); + if state != previous { + eprintln!( + "Roll-off tick{tick}: {previous:?} -> {state:?}; deck={:?}; velocity={:?}; wipeout={:?}", + physics.board.bodies()[6].rates.position, + physics.board.bodies()[6].rates.linear_velocity, + skater.wipeout.state + ); + previous = state; + } + result.unwrap_or_else(|e| { + panic!( + "Roll-off tick{tick}: {e}; selected={:?}; flags={:08x}/{:08x}/{:08x}; board={:?}", + skater.player_state.requested_state, + skater.player_input.processed.flags_2468, + skater.player_input.processed.flags_2472, + skater.player_input.processed.flags_2476, + physics.board.bodies()[6].rates + ) + }); + saw_air |= state.category() == 200; + if saw_air && air_stick.is_some() { + airborne_frames += 1; + eprintln!( + "Air stick={air_stick:?} frame{airborne_frames}: AG={:?}/{:?} MG={:?}/{:?} attr={}/{} speed={} angle={} heading={:?} target={:?} deck={:?}", + controls.action_intents.get("BodySpin"), + controls.action_intents.get("PhysBodySpin"), + skater + .animation + .motion + .animation + .motion_intents + .get("BodySpin"), + skater + .animation + .motion + .animation + .motion_intents + .get("PhysBodySpin"), + skater.animation_input.fields.body_spin, + skater.animation_input.extra.physical_body_spin, + skater.air_reckoning.state.spin_speed, + skater.air_reckoning.state.spin_angle, + physics.riding.reckoning_frames.heading, + skater.animated_skeleton.roots.animation_to_world[2], + physics.board.part_transforms()[6].basis.columns[2] + ); + if airborne_frames == 16 { + assert!( + skater.air_reckoning.state.spin_angle.abs() > 0.001, + "Raw airborne stick did not reach physical angular integration" + ); + return; + } + } + if saw_air + && state == PhysicalStateId::PhysicsGround + && physics.riding.ground.wheel_contact_count > 0 + { + landed_frames += 1; + } + assert!(camera.frame.is_some()); + assert_eq!(skater.pose_generation, physics.ticks); + if landed_frames >= 120 { + break; + } + } + assert!(saw_air, "Raw pushing never entered the actual Air family"); + assert!( + landed_frames >= 120, + "Air never returned to stable grounded riding" + ); + eprintln!( + "Completed roll-off/landing: ticks={}; grounded_after_air={landed_frames}", + physics.ticks + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn stock_starting_ramp_reaches_floor_without_air() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + for tick in 0..600 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick >= 12 { 0x1000 } else { 0 }, + triggers: [0; 2], left: [0; 2], right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update(&mut actions, physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, skater.player_input.physical.scoring.capabilities_204); + frame::advance(&mut physics, &mut skater, &mut controls, + &graphs, &mut actions, true, &mut camera) + .unwrap_or_else(|e| panic!("Ramp tick{tick}: {e}")); + assert_ne!(skater.player_state.current().category(), 200, + "Ramp entered air at tick{tick}, deck={:?}", physics.board.bodies()[6].rates.position); + let deck = physics.board.bodies()[6].rates.position; + if deck.z > 19.0 { + assert!(deck.y < ground::FLOOR_HEIGHT + 0.5, "Did not descend to floor: {deck:?}"); + assert!(physics.riding.ground.wheel_contact_count > 0); + eprintln!("Reached lower floor without air at tick{tick}: {deck:?}"); + return; + } + } + panic!("Did not reach the lower floor within 600 ticks"); +} \ No newline at end of file diff --git a/crates/skate-game/src/tests/animation_pose.rs b/crates/skate-game/src/tests/animation_pose.rs new file mode 100644 index 00000000..f615a528 --- /dev/null +++ b/crates/skate-game/src/tests/animation_pose.rs @@ -0,0 +1,133 @@ +use super::*; + +#[test] +#[ignore = "requires the user's decoded stock animation assets"] +fn stock_pose_commands_sample_complete_bones_and_preserve_tree_order() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"); + let evaluator = PoseEvaluator::load(Path::new(&root)).unwrap(); + assert_eq!(evaluator.frames.parents.len(), 36); + assert_eq!(evaluator.frames.clip_count(), 2672 + 652); + for name in evaluator.frames.clip_names() { + let clip = evaluator.frames.clip(name).unwrap(); + let end = (clip.frames.len() - 1) as f32 / f32::from_bits(clip.fps_bits); + let commands = [ + PoseCommand::Clip { + name: clip.name.clone(), + time: 0.0, + previous_time: 0.0, + loops: 0, + }, + PoseCommand::Clip { + name: clip.name.clone(), + time: end * 0.5, + previous_time: 0.0, + loops: 0, + }, + PoseCommand::Blend { weight: 0.375 }, + PoseCommand::Clip { + name: clip.name.clone(), + time: end, + previous_time: end * 0.5, + loops: 1, + }, + PoseCommand::ChannelBlend { + weight: 0.25, + use_channels_from_weights: false, + }, + ]; + let pose = evaluator.evaluate(&commands).unwrap(); + assert_eq!(pose.len(), 36, "{}", clip.name); + assert!( + pose.iter().all(|s| s + .scale + .iter() + .chain(&s.rotation) + .chain(&s.translation) + .all(|v| v.is_finite())), + "{}", + clip.name + ); + // Regular stance takes every native AddBindPose branch. Include the + // trajectory-relative mode used by the source's one-shot mirror flag. + let mut wrapped = commands.to_vec(); + wrapped.extend([ + PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS".into(), + }, + PoseCommand::Add { motion_is_a: false }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS_IK".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Mirror { trajectory_mode: 2 }, + PoseCommand::Mirror { trajectory_mode: 1 }, + ]); + let pose = evaluator.evaluate(&wrapped).unwrap(); + assert!( + pose.iter().all(|s| s + .scale + .iter() + .chain(&s.rotation) + .chain(&s.translation) + .all(|v| v.is_finite())), + "{}", + clip.name + ); + let hierarchy = evaluator.hierarchy(&pose).unwrap(); + assert!( + hierarchy.iter().flatten().flatten().all(|v| v.is_finite()), + "{}", + clip.name + ); + } + // Native SetDBContent82D1B45C overwrites equal keys; last RIG_TPOSE wins. + assert_eq!( + evaluator + .frames + .reference_pose(0xA46D20) + .unwrap() + .samples + .len(), + 36 + ); + assert_eq!( + evaluator + .frames + .named_pose("RIG_TPOSE") + .unwrap() + .source_offset, + 0xA46D20 + ); + assert_eq!( + evaluator + .frames + .named_pose("BOARD_BACKWARDS") + .unwrap() + .source_offset, + 0xA473D0 + ); + assert_eq!(evaluator.frames.bone_names[0], "TRAJECTORY"); + assert_eq!(evaluator.frames.bone_names[25], "SKATEBOARD_ROOT"); +} + +#[test] +fn additive_pose_keeps_motion_channel_and_reference_rotation_order() { + let motion = Sqt { + scale: [1.0; 4], + rotation: [1.0, 0.0, 0.0, 0.0], + translation: [2.0, 3.0, 4.0, 0.25], + }; + let reference = Sqt { + scale: [2.0; 4], + rotation: [0.0, 1.0, 0.0, 0.0], + translation: [7.0, 11.0, 13.0, 0.75], + }; + let result = pose_add::add(motion, reference, true); + assert_eq!(result.rotation, [0.0, 0.0, -1.0, 0.0]); + assert_eq!(result.translation, [5.0, 14.0, 9.0, 0.25]); + assert_eq!(pose_add::add(motion, reference, false).translation[3], 0.75); +} diff --git a/crates/skate-game/src/tests/graph_runtime.rs b/crates/skate-game/src/tests/graph_runtime.rs new file mode 100644 index 00000000..0ef1e908 --- /dev/null +++ b/crates/skate-game/src/tests/graph_runtime.rs @@ -0,0 +1,105 @@ +use super::*; +use skate_core::graph::activation::Child; +use skate_data::state_graph::binding::{ + Expression as BoundExpression, Operation, State as BoundState, Transition as BoundTransition, +}; + +fn binding() -> Binding { + Binding { + states: vec![BoundState { + element: 0, + name: "root".into(), + parent: None, + children: Vec::new(), + behaviors: vec![0], + transitions: vec![0], + expression: Some(0), + enabled: 1, + active: 1, + interruptibility: 2, + interrupt_ancestor: Some(0), + }], + transitions: vec![BoundTransition { + element: 1, + owner: 0, + target: Some(0), + enabled: 1, + priority: 3, + expression: Some(0), + hooks: vec![2], + }], + expressions: vec![BoundExpression { + element: 2, + enabled: 1, + operator: 1, + children: vec![Node::Operation(1)], + }], + operations: vec![ + Operation { + element: 3, + parent: Node::State(0), + kind: OperationKind::Behavior, + name: "behavior".into(), + enabled: 1, + condition_mask: None, + parameters: vec![6], + }, + Operation { + element: 4, + parent: Node::Expression(0), + kind: OperationKind::Condition, + name: "condition".into(), + enabled: 1, + condition_mask: Some(3), + parameters: vec![7, 8], + }, + Operation { + element: 5, + parent: Node::Transition(0), + kind: OperationKind::Hook, + name: "hook".into(), + enabled: 1, + condition_mask: None, + parameters: Vec::new(), + }, + ], + root: 0, + } +} + +#[test] +fn preserves_topology_and_authored_operation_identity() { + let compiled = CompiledGraph::from_binding(&binding()).unwrap(); + + assert_eq!(compiled.program.root, 0); + assert_eq!(compiled.program.topology.states[0].interruptibility, 2); + assert_eq!(compiled.program.topology.transitions[0].target, 0); + assert_eq!(compiled.program.topology.transitions[0].priority, 3); + assert_eq!(compiled.program.activation.state_expressions, [Some(0)]); + assert_eq!( + compiled.program.activation.transition_expressions, + [Some(0)] + ); + assert_eq!( + compiled.program.activation.expressions[0].children, + [Child::Condition(0)] + ); + assert_eq!(compiled.program.activation.conditions[0].mask, 3); + assert_eq!(compiled.program.behaviors[0].owner, 0); + assert_eq!(compiled.program.state_behaviors, [vec![0]]); + assert_eq!(compiled.program.transition_hooks, [vec![0]]); + assert_eq!(compiled.operations.behaviors, [0]); + assert_eq!(compiled.operations.conditions, [1]); + assert_eq!(compiled.operations.hooks, [2]); +} + +#[test] +fn refuses_an_unresolved_transition_instead_of_choosing_a_fallback() { + let mut source = binding(); + source.transitions[0].target = None; + + assert_eq!( + CompiledGraph::from_binding(&source).unwrap_err(), + GraphCompileError::UnresolvedTransitionTarget { transition: 0 } + ); +} diff --git a/crates/skate-game/src/tests/physics_startup.rs b/crates/skate-game/src/tests/physics_startup.rs new file mode 100644 index 00000000..ec4a7a84 --- /dev/null +++ b/crates/skate-game/src/tests/physics_startup.rs @@ -0,0 +1,456 @@ +use super::*; + +#[test] +#[ignore = "requires the user's converted stock skater and graph assets"] +fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let manifest = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + // This regression isolates grounded controls on the original flat fixture. + // The production box is only six metres wide: sustained kickturning leaves + // it at x=-3.32, correctly selecting KnownAir and ForcePhysics2. Separate + // air_playback tests exercise the shipped course and deliberate roll-off. + let corners = [ + Vector3::new(-20.0, ground::HEIGHT, -20.0), + Vector3::new(20.0, ground::HEIGHT, -20.0), + Vector3::new(20.0, ground::HEIGHT, 20.0), + Vector3::new(-20.0, ground::HEIGHT, 20.0), + ]; + physics.world = BoardWorld::new([[0, 2, 1], [0, 3, 2]].map(|indices| { + skate_core::physics::board_world::WorldTriangle::from_vertices( + indices.map(|i| corners[i]), physics.settings.floor_material, 0, + skate_core::physics::collision::TriangleFeature::ONE_SIDED, [1.0; 3], 0.0, + ).unwrap() + }).to_vec()); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + assert_eq!( + skater.animation.pose.len(), + skater.animation.evaluator.frames.bone_names.len() + ); + assert_eq!(skater.skeleton.bodies().len(), 26); + //InitJoints uses the raw animation frames, while physical part setup + //orthonormalizes its frames. Coincident startup anchors are not a source + //guarantee; validate finite geometry and the real compiled joint rows. + let parts = skater.skeleton.part_transforms(); + let point = |part: usize, anchor: &[u32]| -> [f32; 3] { + let p: [f32; 3] = std::array::from_fn(|i| f32::from_bits(anchor[i])); + std::array::from_fn(|i| { + let x = parts[part][0][i].mul_add(p[0], parts[part][3][i]); + let y = parts[part][1][i].mul_add(p[1], x); + parts[part][2][i].mul_add(p[2], y) + }) + }; + for joint in &skater.skeleton_joints.records { + let child = point(joint.child, &joint.frames.words[4..8]); + let parent = point(joint.parent, &joint.frames.words[12..16]); + assert!(child.into_iter().chain(parent).all(f32::is_finite)); + } + let joints = skater.skeleton_joints.build( + skater.skeleton.bodies(), + 8, + physics.settings.step.simulation.time_step, + ); + assert_eq!(joints.len(), 22); + let drives = skater.skeleton_drives.build( + skater.skeleton.bodies(), + 8, + 34, + physics.settings.step.simulation.time_step, + ); + assert_eq!(drives.rows.len(), 48); + assert!( + drives + .rows + .iter() + .all(|drive| (8..38).contains(&drive.frame_a_body.reaction_index) + && (8..38).contains(&drive.frame_b_body.reaction_index)) + ); + assert_eq!( + skater.skeleton_drives.targets.transform(2)[3], + skater.animated_skeleton.board_frames.lifted_com_frame[3] + ); + assert_eq!( + skater.skeleton_drives.targets.transform(3)[3], + skater.animated_skeleton.board_frames.com_frame[3] + ); + assert!( + joints + .iter() + .all(|j| (8..34).contains(&j.reaction_a) && (8..34).contains(&j.reaction_b)) + ); + let com = skater.skeleton.record.centre_of_mass; + assert!(com.iter().all(|v| v.is_finite())); + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + assert!(com[1] > deck.translation.y); + for (body, part) in skater + .skeleton + .bodies() + .iter() + .zip(skater.skeleton.part_transforms()) + { + assert!(body.inertia.inverse_mass.is_finite() && body.inertia.inverse_mass > 0.0); + assert!(part.into_iter().flatten().all(f32::is_finite)); + } + assert!( + skater + .skeleton + .record + .velocities + .iter() + .flatten() + .all(|v| *v == 0.0) + ); + let volumes = + skeleton_colliders::world_volumes(&skater.skeleton, &skater.skeleton_collision).unwrap(); + assert_eq!(volumes.len(), 23); + let before = skater.skeleton.bodies().map(|body| body.rates.position); + let render_before = skater.render_pose.clone(); + let mut controls = PlayerControls::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut input = crate::input::ControllerInput::default(); + let physical_step = physics.settings.step.simulation.time_step; + let normal_period = physics.clock.period(); + camera + .simulation_rate_requests + .push(skate_core::camera::SimulationRateRequest { + timestep: 1.0 / 35.0, + ticks: 1, + }); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + false, + &mut camera, + ) + .unwrap(); + assert_eq!(physics.ticks, 1); + assert!(camera.simulation_rate_requests.is_empty()); + assert!(physics.clock.period() > normal_period); + assert_eq!(physics.settings.step.simulation.time_step, physical_step); + assert_eq!(skater.player_input.processed.timestep_2604, physical_step); + //Ctor does not publish Ground before the initial input reset. The stock + //graph enters Ground on the next tick and its Begin writes ForcePhysics1. + assert_eq!(skater.skeleton_input.force_mode, 0); + assert!( + camera.frame.is_some(), + "First frame did not publish the gameplay camera" + ); + eprintln!( + "First gameplay frame: deck={:?}; COM={:?}; feet={:?}; camera={:?}", + physics.board.bodies()[6].rates.position, + skater.skeleton.record.centre_of_mass, + [ + skater.skeleton.record.pose[15][3], + skater.skeleton.record.pose[19][3] + ], + camera.frame + ); + assert_eq!(skater.pose_generation, physics.ticks); + assert_ne!(render_before, skater.render_pose); + assert!( + skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()) + ); + assert_eq!(skater.solved_drives.as_ref().unwrap().rows.len(), 48); + assert_ne!( + before, + skater.skeleton.bodies().map(|body| body.rates.position) + ); + // User crash: standing kickturn after spawn, left stick nearly full left, + // no right-stick/trigger/button input. Stock Riding retains ForcePhysics1. + let mut saw_kickturn_balance = false; + for tick in 0..1800 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0, + triggers: [0; 2], + left: if (12..612).contains(&tick) { + [-32768, 256] + } else { + [0; 2] + }, + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physical_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|e| { + panic!( + "Standing kickturn tick{tick}: {e}; force_mode={}; board_axis_y={}", + skater.skeleton_input.force_mode, skater.skeleton_input.drive_frames[0][2][1] + ) + }); + assert_eq!( + skater.skeleton_input.force_mode, 1, + "Stock Ground ForcePhysics Begin was lost at standing tick{tick}; state={:?}; deck={:?}; contacts={}", + skater.player_state.current(), physics.board.bodies()[6].rates.position, + physics.riding.ground.wheel_contact_count + ); + saw_kickturn_balance |= skater.animation_input.fields.balance != 0.0; + } + assert!( + saw_kickturn_balance, + "Standing input never reached the stock kickturn balance" + ); + eprintln!( + "Standing kickturn and release completed1800ticks; mode={}; balance_seen={saw_kickturn_balance}", + skater.skeleton_input.force_mode + ); + // Use raw signed axes and XInput button bits through the same converter, + // history, Pad and action map as the game. This verifies connectivity; + // matched original-game recordings and hardware polling remain separate. + let start = physics.board.bodies()[6].rates.position; + let mut saw_push = false; + let mut saw_steering = false; + for tick in 0..180 { + let left = match tick { + 0..20 => [if tick % 2 == 0 { 8191 } else { -8191 }, 0], + 90..120 => [32767, 0], + 120..150 => [-32768, 0], + 150..180 => [32767, 32767], + _ => [0; 2], + }; + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if (20..130).contains(&tick) { 0x1000 } else { 0 }, + triggers: [0; 2], + left, + right: [0; 2], + }); + if tick < 20 { + assert_eq!( + input.mapped_actions[0][..2], + [0.0; 2], + "Sub-dead-zone stick drift reached gameplay actions" + ); + } + let mut action = input.player_actions(); + controls.update( + &mut action, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance(&mut physics, &mut skater, &mut controls, &graphs, + &mut action, true, &mut camera).unwrap_or_else(|e| panic!( + "Production tick{tick}: {e}; state={:?}; flags={:08x}; contacts={}; speed={}; targets={:?}; force_mode={}; board_axis_y={}", + skater.player_state.current(), skater.player_input.processed.flags_2468, + physics.riding.ground.wheel_contact_count, physics.riding.motion.ground_speed, + skater.ground.steering.targets, skater.skeleton_input.force_mode, + skater.skeleton_input.drive_frames[0][2][1])); + saw_push |= skater.player_input.processed.flags_2468 & (1 << 25) != 0; + saw_steering |= skater + .ground + .steering + .targets + .iter() + .any(|v| v.abs() > 0.001); + if matches!(tick, 119 | 149 | 179) { + eprintln!( + "Raw stick={left:?}; mapped={:?}; physical Turn={}; trucks={:?}", + &input.mapped_actions[0][..2], + skater.animation_input.fields.turn, + skater.ground.steering.targets + ); + assert!( + skater.animation_input.fields.turn.abs() > 0.001, + "Raw stick input did not reach physical steering" + ); + } + } + let end = physics.board.bodies()[6].rates.position; + assert_eq!(physics.clock.period(), normal_period); + eprintln!( + "Completed riding: deck={end:?}; COM={:?}; wheel contacts={}; speed={}; truck targets={:?}; push={saw_push}; steering={saw_steering}", + skater.skeleton.record.centre_of_mass, + physics.riding.ground.wheel_contact_count, + physics.riding.motion.ground_speed, + skater.ground.steering.targets + ); + assert!( + saw_push, + "Stock push animation never reached physical push input" + ); + assert!( + saw_steering, + "Stock steering never reached truck drive targets" + ); + assert!( + (end.x - start.x).hypot(end.z - start.z) > 0.01, + "Pushing did not move the live board" + ); + assert!( + physics.riding.ground.wheel_contact_count > 0, + "Grounded riding lost every wheel contact on the flat test surface" + ); + assert!( + end.y > ground::HEIGHT, + "The live deck penetrated the flat test surface" + ); + assert!(camera.frame.is_some(), "Gameplay camera never published"); + assert_eq!(skater.pose_generation, physics.ticks); + assert_eq!(skater.animation.ticks, physics.ticks); + let speed_before_brake = physics.riding.motion.ground_speed.abs(); + let mut saw_brake = false; + for tick in 180..360 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0x2000, + triggers: [0; 2], + left: [0; 2], + right: [0; 2], + }); + let mut action = input.player_actions(); + controls.update( + &mut action, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut action, + true, + &mut camera, + ) + .unwrap_or_else(|e| panic!("Braking tick{tick}: {e}")); + // Brake82BDB0D4 writes the scalar consumed by Ground forces; + // flags2468 bit29 is the separate ManualBrake attribute. + saw_brake |= skater.animation_input.fields.brake > 0.0; + } + assert!(saw_brake, "Stock braking never reached physical input"); + assert!( + physics.riding.motion.ground_speed.abs() < speed_before_brake, + "Braking did not reduce the moving board's speed" + ); + assert!(physics.riding.ground.wheel_contact_count > 0); + eprintln!( + "Completed braking: speed={} -> {}; deck={:?}", + speed_before_brake, + physics.riding.motion.ground_speed, + physics.board.bodies()[6].rates.position + ); +} + +#[test] +#[ignore = "requires private stock skater, animation banks and collections"] +fn stock_wheel_hardness_reaches_ground_force_and_torque() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let manifest = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); + let mut samples = Vec::new(); + for hardness in [0.0_f32, 0.7, 1.0] { + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let input = crate::input::ControllerInput::default(); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + false, + &mut camera, + ) + .unwrap(); + + // Replay identical observed sideways travel at the Ground input boundary. + // Only the published profile input changes; run the actual Ground adapter. + let p = &mut skater.player_input.processed; + p.scalar_2764 = hardness; + p.vectors_400_416 = [[1.0_f32, 0.0, 3.0, 0.0].map(f32::to_bits); 2]; + p.scalar_2656 = 10.0_f32.sqrt(); + skater.ground.state.elapsed_2648 = 0.25; + physics.board.forces_mut().clear(); + let outcome = ground_phase::advance(&mut physics, &mut skater).unwrap(); + assert!(matches!( + outcome, + skate_core::riding::grounded::state::board::GroundBoardOutcome::Ordinary(_) + )); + let side = physics + .board + .forces() + .entries() + .iter() + .find(|f| f.tag == 1) + .unwrap(); + samples.push(( + side.force_world.x, + physics.board.bodies()[6].rates.torque_acceleration.y, + )); + } + // Original82D92AD4 multiplies side friction by lerp(softest,1,hardness). + let data = skate_data::collections::Collections::load(root).unwrap(); + let softest = data + .float("physicswheels", "default", "SoftestWheelSideFrictionScalar") + .unwrap(); + assert!( + samples[2].0.abs() > 0.001, + "Sideways travel produced no side force: {samples:?}" + ); + for (index, hardness) in [0.0_f32, 0.7, 1.0].into_iter().enumerate() { + let expected = (1.0 - softest).mul_add(hardness, softest); + assert!( + (samples[index].0 / samples[2].0 - expected).abs() < 0.0001, + "Profile hardness was lost before live friction: {samples:?}" + ); + } + // Original82D92BBC also changes straighten duration, reaching deck torque. + assert!( + (samples[0].1 - samples[2].1).abs() > 0.001, + "Profile hardness was lost before live straighten torque: {samples:?}" + ); + eprintln!("Ground wheel-hardness force/torque samples: {samples:?}"); +} +#[test] +#[ignore = "requires the user's converted stock collection assets"] +fn stock_geometry_loads_and_reaches_the_live_board_solver() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let mut physics = GamePhysics::load(std::path::Path::new(&root)).unwrap(); + let volumes = colliders::world_volumes(&physics.board, &physics.settings); + assert_eq!(volumes.len(), 21); // Four wheels, two trucks, 15 stock deck children. + for _ in 0..120 { + physics.advance_board().unwrap(); + } + assert!(physics.contact_count > 0); + let reports = physics.board.contact_reports(); + assert!( + !reports.is_empty(), + "actual solved contacts must reach gameplay feedback" + ); + assert!(reports.len() <= 16); + assert!(reports.iter().any(|report| report.part.index() < 4)); + assert!( + reports + .iter() + .all(|report| report.normal_force_on_a.y.is_finite()) + ); + assert_eq!(physics.ticks, 120); + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + assert!(deck.translation.y > ground::HEIGHT); +} diff --git a/crates/skate-game/src/tests/wipeout_playback.rs b/crates/skate-game/src/tests/wipeout_playback.rs new file mode 100644 index 00000000..e6ad432b --- /dev/null +++ b/crates/skate-game/src/tests/wipeout_playback.rs @@ -0,0 +1,221 @@ +//! Real Xbox bail chord through input, stock graphs, physical state and pose. +use super::*; +use skate_core::{input::xbox::XboxState, player::state::PhysicalStateId}; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut settled_ticks = 0; + let mut requested_at = None; + let mut entered_at = None; + let mut saw_action_request = false; + for tick in 0..600 { + let request = settled_ticks >= 60 && requested_at.is_none(); + if request { + requested_at = Some(tick); + } + //8259BDCC..BEF0 requires exact full trigger endpoints, both stick + //buttons and a fresh edge. Supply one raw packet, then release it. + input.sample_raw_for_test(XboxState { + buttons: if request { 0x00C0 } else { 0 }, + triggers: if request { [255; 2] } else { [0; 2] }, + left: [0; 2], + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|error| { + panic!( + "Raw bail tick{tick}: {error}; state={:?}; requested={requested_at:?}; entered={entered_at:?}", + skater.player_state.current() + ) + }); + let action_request = skater.animation.action.action_intents.get("WipeOutRequest"); + if action_request == Some(&1.0) { + assert!( + requested_at.is_some(), + "Bail intent appeared before the raw chord" + ); + saw_action_request = true; + } + let state = skater.player_state.current(); + if requested_at.is_none() { + assert_ne!( + state, + PhysicalStateId::WipeoutGround, + "Bailed before controller request" + ); + if state == PhysicalStateId::PhysicsGround + && physics.riding.ground.wheel_contact_count > 0 + { + settled_ticks += 1; + } else { + settled_ticks = 0; + } + } else if state == PhysicalStateId::WipeoutGround && entered_at.is_none() { + assert!( + saw_action_request, + "State300 entered without stock ActionGraph bail intent" + ); + entered_at = Some(tick); + } + assert_finite(&physics, &skater, tick); + assert_eq!( + skater.pose_generation, physics.ticks, + "Stale pose at tick{tick}" + ); + assert!( + camera.frame.is_some(), + "Missing gameplay camera at tick{tick}" + ); + if let Some(entered) = entered_at { + if tick - entered >= 120 { + eprintln!( + "Raw bail completed: request={requested_at:?}, state300={entered}, final_tick={tick}" + ); + return; + } + } + } + assert!( + requested_at.is_some(), + "Never settled for60 consecutive grounded ticks" + ); + assert!( + saw_action_request, + "Raw chord never reached stock ActionGraph WipeOutRequest" + ); + panic!("Did not complete120 ticks after state300: entered={entered_at:?}"); +} + +fn assert_finite(physics: &GamePhysics, skater: &SkaterRuntime, tick: usize) { + for body in physics + .board + .bodies() + .iter() + .chain(skater.skeleton.bodies()) + { + for vector in [ + body.rates.position, + body.rates.linear_velocity, + body.rates.angular_velocity, + ] { + assert!( + [vector.x, vector.y, vector.z] + .into_iter() + .all(f32::is_finite), + "Nonfinite physical body at tick{tick}: {body:?}" + ); + } + } + assert!( + physics.board.part_transforms().iter().all(|part| part + .basis + .columns + .iter() + .flatten() + .all(|v| v.is_finite())), + "Nonfinite board orientation at tick{tick}" + ); + assert!( + skater + .skeleton + .part_transforms() + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Nonfinite physical skeleton pose at tick{tick}" + ); + assert!( + !skater.animation.pose.is_empty(), + "Missing animation pose at tick{tick}" + ); + assert!( + skater.animation.pose.iter().all(|sample| sample + .scale + .iter() + .chain(&sample.rotation) + .chain(&sample.translation) + .all(|v| v.is_finite())), + "Nonfinite animation at tick{tick}" + ); +} + +/// Exercise the actual actor checkpoint reply, not a forced physical state. +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn authored_checkpoint_reply_runs_teleport_state_and_restores_riding() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let checkpoint = physics.board.bodies()[6].rates.position; + let mut saw_ready = false; + let mut restored_ticks = 0; + for tick in 0..160 { + if tick == 60 { + assert!((physics.board.bodies()[6].rates.position.z - checkpoint.z).abs() > 0.1); + skater.teleport_state.request_checkpoint(); + } + input.sample_raw_for_test(XboxState { + buttons: if (12..60).contains(&tick) { 0x1000 } else { 0 }, + triggers: [0; 2], left: [0; 2], right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update(&mut actions, physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204); + frame::advance(&mut physics, &mut skater, &mut controls, &graphs, + &mut actions, true, &mut camera) + .unwrap_or_else(|error| panic!("Checkpoint tick{tick}: {error}")); + if tick == 60 { + assert_eq!(skater.player_state.current(), PhysicalStateId::Teleporting); + let output = skater.player_input.physical.teleport_output + .expect("702 must publish the captured reply"); + assert_eq!((output.next_state, output.state_61, output.board_272), (100, 1, 1)); + assert_eq!(f32::from_bits(output.transform[3][2]), checkpoint.z); + saw_ready = true; + } else if tick == 61 { + assert!(saw_ready); + assert_eq!(skater.player_state.current(), PhysicalStateId::PhysicsGround); + let position = physics.board.bodies()[6].rates.position; + assert!((position.x - checkpoint.x).abs() < 0.05); + assert!((position.z - checkpoint.z).abs() < 0.05); + assert!(!skater.skeleton_collision.is_ragdoll); + } + if tick > 61 && skater.player_state.current() == PhysicalStateId::PhysicsGround { + restored_ticks += 1; + } + assert_finite(&physics, &skater, tick); + } + assert!(saw_ready && restored_ticks > 60); +} diff --git a/crates/skate-game/src/verification.rs b/crates/skate-game/src/verification.rs new file mode 100644 index 00000000..d02dfb65 --- /dev/null +++ b/crates/skate-game/src/verification.rs @@ -0,0 +1,93 @@ +//! Opt-in startup verification, with no in-game tool UI. +use crate::{ + animation::AnimationStatus, + app::FrameSet, + config::Config, + input::ControllerInput, + physics::{GamePhysics, PlayerControls, SkaterRuntime}, +}; +use bevy::{ + prelude::*, + render::view::screenshot::{Screenshot, ScreenshotCaptured, save_to_disk}, +}; + +#[derive(Resource, Default)] +struct Verification { + elapsed: f32, + requested: bool, + captured: bool, +} + +pub(crate) struct VerificationPlugin; +impl Plugin for VerificationPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, verify.in_set(FrameSet::Verification)); + } +} +fn verify( + mut commands: Commands, + time: Res>, + config: Res, + animation: Res, + input: Res, + physics: Res, + controls: Res, + skater: Res, + camera: Res, + mut state: ResMut, + mut exit: MessageWriter, +) { + let Some(path) = &config.verification_capture else { + return; + }; + state.elapsed += time.delta_secs(); + if animation.ready && state.elapsed > 4. && !state.requested { + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(path.clone())) + .observe( + |_: On, mut state: ResMut| { + state.captured = true; + }, + ); + state.requested = true; + } + if state.captured && state.elapsed > 6. { + let input_report = format!( + "Game integration capture (not a Skate 3 parity verdict)\nExecutable: {}\nPolls: {}\nConsumed batches: {}\nStatus: {:?}\nPacket numbers: {:?}\nActions 64..81 by device: {:?}\nDerived ticks: {}\nIntents: {:?}\nPhysics ticks: {}\nContacts: {}\nBody positions: {:?}\nPhysical pose publications: {}\nGraph animation ticks: {}\nTruck targets: {:?}\nGround speed: {}\nGameplay camera shot: {}\nCamera frame: {:?}\n", + std::env::current_exe() + .map_or_else(|_| "unavailable".into(), |p| p.display().to_string()), + input.publications, + input.consumed_batches, + input.status, + input.packet_numbers, + input.mapped_actions, + controls.ticks, + controls.intents, + physics.ticks, + physics.contact_count, + physics.board.bodies().map(|body| body.rates.position), + skater.pose_generation, + skater.animation.ticks, + skater.ground.steering.targets, + physics.riding.motion.ground_speed, + camera.selected_shot(), + camera.frame, + ); + let input_report = format!( + "{input_report}Native OnBoard/OffBoard clips available: {}\n", + skater.animation.evaluator.frames.clip_count() + ); + if let Err(error) = std::fs::write(path.with_extension("input.txt"), input_report) { + eprintln!("Cannot save controller verification: {error}"); + exit.write(AppExit::error()); + return; + } + info!("GAME_VERIFY_OK"); + exit.write(AppExit::Success); + } else if state.elapsed > 45. { + error!("Game startup/capture timed out"); + exit.write(AppExit::error()); + } +} diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs new file mode 100644 index 00000000..99b774f2 --- /dev/null +++ b/crates/skate-game/src/world.rs @@ -0,0 +1,71 @@ +use crate::assets::AssetManifest; +use bevy::prelude::*; + +/// World transform owner. Animation only writes descendant bone transforms. +#[derive(Component)] +pub(crate) struct PlayerRoot; + +pub(crate) struct WorldPlugin; +impl Plugin for WorldPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(ClearColor(Color::srgb(0.065, 0.08, 0.10))) + .insert_resource(GlobalAmbientLight { + color: Color::WHITE, + brightness: 350., + ..default() + }) + .add_systems(Startup, spawn); + } +} +fn spawn( + mut commands: Commands, + server: Res, + manifest: Res, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands + .spawn((PlayerRoot, Transform::default(), Visibility::default())) + .with_children(|parent| { + parent.spawn(SceneRoot(server.load( + GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone()), + ))); + }); + let colors = [ + Color::srgb(0.16, 0.19, 0.21), + Color::srgb(0.48, 0.35, 0.22), + Color::srgb(0.30, 0.43, 0.48), + ]; + for (quads, color) in crate::physics::ground::surfaces().into_iter().zip(colors) { + let positions: Vec<[f32; 3]> = quads.into_iter().flat_map(|vertices| { + [0, 2, 1, 0, 3, 2].map(|i| { + let v = vertices[i]; + [v.x, v.y, v.z] + }) + }).collect(); + let mut mesh = Mesh::new( + bevy::mesh::PrimitiveTopology::TriangleList, + bevy::asset::RenderAssetUsages::default(), + ).with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.compute_flat_normals(); + commands.spawn(( + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: color, + perceptual_roughness: 0.9, + double_sided: true, + cull_mode: None, + ..default() + })), + Transform::default(), + )); + } + commands.spawn(( + DirectionalLight { + illuminance: 11000., + shadows_enabled: true, + ..default() + }, + Transform::from_xyz(4., 7., 4.).looking_at(Vec3::ZERO, Vec3::Y), + )); +}