Skip to main content

steel_core/physics/
movement_validation.rs

1//! Shared vanilla movement-validation helpers for client-authored movement.
2
3use glam::DVec3;
4
5/// Movement error threshold for anti-cheat validation (squared distance).
6/// Vanilla uses 0.0625 (1/16 block squared).
7pub const MOVEMENT_ERROR_THRESHOLD: f64 = 0.0625;
8
9/// Y-axis tolerance value used by vanilla's movement-error branch.
10///
11/// Vanilla currently uses `yDist > -0.5 || yDist < 0.5`, which zeroes every
12/// finite Y residual before the moved-wrongly check.
13pub const Y_TOLERANCE: f64 = 0.5;
14
15/// Collision state used to decide whether a client-authored movement is accepted.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct MovementCollisionValidation {
18    /// Whether the entity bypasses collision physics.
19    pub no_physics: bool,
20    /// Whether the simulated server position differs too much from the client target.
21    pub moved_wrongly: bool,
22    /// Whether the entity already intersected collision before the movement.
23    pub old_collision: bool,
24    /// Whether accepting the target would newly intersect collision.
25    pub new_collision: bool,
26}
27
28impl MovementCollisionValidation {
29    /// Returns true when vanilla rejects this movement and rolls back the entity.
30    #[must_use]
31    pub const fn rejects(self) -> bool {
32        !self.no_physics && ((self.moved_wrongly && !self.old_collision) || self.new_collision)
33    }
34}
35
36/// State shared by vanilla client-authored movement validation.
37///
38/// Vanilla stores parallel copies of this state in `ServerGamePacketListenerImpl`
39/// for the player body and for the controlled root vehicle. Steel keeps it as a
40/// reusable value so vehicle movement can use the same bookkeeping instead of
41/// growing a second player-local copy.
42#[derive(Debug)]
43pub(crate) struct ClientAuthoredMovementState {
44    /// Last known good position for collision rollback.
45    last_good_position: DVec3,
46    /// Position at the start of the tick for speed validation.
47    first_good_position: DVec3,
48    /// Number of move packets received since connection start.
49    received_move_packet_count: i32,
50    /// Number of move packets at the last tick.
51    known_move_packet_count: i32,
52    /// Last movement accepted from the client.
53    last_known_client_movement: DVec3,
54    /// Whether the last accepted client move appeared unsupported in air.
55    client_is_floating: bool,
56    /// Number of consecutive ticks the client has appeared unsupported in air.
57    above_ground_tick_count: i32,
58}
59
60impl ClientAuthoredMovementState {
61    /// Creates empty client-authored movement state.
62    #[must_use]
63    pub(crate) const fn new() -> Self {
64        Self {
65            last_good_position: DVec3::ZERO,
66            first_good_position: DVec3::ZERO,
67            received_move_packet_count: 0,
68            known_move_packet_count: 0,
69            last_known_client_movement: DVec3::ZERO,
70            client_is_floating: false,
71            above_ground_tick_count: 0,
72        }
73    }
74
75    /// Resets per-tick vanilla movement validation bases.
76    pub(crate) const fn reset_for_tick(&mut self, position: DVec3) {
77        self.first_good_position = position;
78        self.last_good_position = position;
79        self.known_move_packet_count = self.received_move_packet_count;
80    }
81
82    /// Resets validation bases after a server position sync.
83    pub(crate) const fn reset_for_position_sync(&mut self, position: DVec3) {
84        self.last_good_position = position;
85        self.first_good_position = position;
86        self.received_move_packet_count = 0;
87        self.known_move_packet_count = 0;
88        self.last_known_client_movement = DVec3::ZERO;
89        self.above_ground_tick_count = 0;
90    }
91
92    /// Returns the current vanilla first-good and last-good validation positions.
93    #[must_use]
94    pub(crate) const fn good_positions(&self) -> (DVec3, DVec3) {
95        (self.first_good_position, self.last_good_position)
96    }
97
98    /// Records a received movement packet and returns packets since the last tick.
99    pub(crate) const fn record_move_packet_delta(&mut self) -> i32 {
100        self.received_move_packet_count += 1;
101        self.received_move_packet_count - self.known_move_packet_count
102    }
103
104    /// Marks a movement target as the latest accepted vanilla last-good position.
105    pub(crate) const fn mark_last_good_position(&mut self, position: DVec3) {
106        self.last_good_position = position;
107    }
108
109    /// Sets the last accepted client movement vector.
110    pub(crate) const fn set_last_known_client_movement(&mut self, movement: DVec3) {
111        self.last_known_client_movement = movement;
112    }
113
114    /// Clears the last accepted client movement vector.
115    pub(crate) const fn reset_last_known_client_movement(&mut self) {
116        self.last_known_client_movement = DVec3::ZERO;
117    }
118
119    /// Returns the last accepted client movement vector.
120    #[must_use]
121    pub(crate) const fn last_known_client_movement(&self) -> DVec3 {
122        self.last_known_client_movement
123    }
124
125    /// Records whether the latest accepted movement made the client appear to float.
126    pub(crate) const fn record_client_floating(&mut self, client_is_floating: bool) {
127        self.client_is_floating = client_is_floating;
128    }
129
130    /// Clears vanilla floating violation state.
131    pub(crate) const fn clear_client_floating(&mut self) {
132        self.client_is_floating = false;
133        self.above_ground_tick_count = 0;
134    }
135
136    /// Resets the vanilla floating violation counter.
137    pub(crate) const fn reset_flying_ticks(&mut self) {
138        self.above_ground_tick_count = 0;
139    }
140
141    /// Advances the vanilla floating violation tracker.
142    ///
143    /// Returns true once the client has exceeded the configured maximum flying ticks.
144    pub(crate) const fn tick_client_floating(
145        &mut self,
146        should_count: bool,
147        maximum_flying_ticks: i32,
148    ) -> bool {
149        if self.client_is_floating && should_count {
150            self.above_ground_tick_count = self.above_ground_tick_count.saturating_add(1);
151            return self.above_ground_tick_count > maximum_flying_ticks;
152        }
153
154        self.client_is_floating = false;
155        self.above_ground_tick_count = 0;
156        false
157    }
158}
159
160/// Returns the residual between a client target and the server-simulated position.
161#[must_use]
162pub fn movement_error_delta(target_pos: DVec3, simulated_pos: DVec3) -> DVec3 {
163    let error_x = target_pos.x - simulated_pos.x;
164    let mut error_y = target_pos.y - simulated_pos.y;
165    if error_y > -Y_TOLERANCE || error_y < Y_TOLERANCE {
166        error_y = 0.0;
167    }
168    let error_z = target_pos.z - simulated_pos.z;
169    DVec3::new(error_x, error_y, error_z)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn movement_error_delta_matches_vanilla_y_branch() {
178        let delta = movement_error_delta(DVec3::new(10.0, 120.0, -5.0), DVec3::new(8.0, 0.0, -8.0));
179
180        assert_eq!(delta, DVec3::new(2.0, 0.0, 3.0));
181    }
182
183    #[test]
184    fn movement_validation_accepts_no_physics_even_with_new_collision() {
185        assert!(
186            !MovementCollisionValidation {
187                no_physics: true,
188                moved_wrongly: true,
189                old_collision: false,
190                new_collision: true,
191            }
192            .rejects()
193        );
194    }
195
196    #[test]
197    fn movement_validation_rejects_new_collision_for_physical_entity() {
198        assert!(
199            MovementCollisionValidation {
200                no_physics: false,
201                moved_wrongly: false,
202                old_collision: false,
203                new_collision: true,
204            }
205            .rejects()
206        );
207    }
208
209    #[test]
210    fn client_movement_tick_reset_updates_good_positions_and_packet_base() {
211        let mut state = ClientAuthoredMovementState::new();
212        state.mark_last_good_position(DVec3::new(1.0, 2.0, 3.0));
213        state.record_move_packet_delta();
214        state.record_move_packet_delta();
215
216        state.reset_for_tick(DVec3::new(4.0, 5.0, 6.0));
217
218        assert_eq!(
219            state.good_positions(),
220            (DVec3::new(4.0, 5.0, 6.0), DVec3::new(4.0, 5.0, 6.0))
221        );
222        assert_eq!(state.record_move_packet_delta(), 1);
223    }
224
225    #[test]
226    fn client_movement_position_sync_reset_clears_packet_counts_and_known_movement() {
227        let mut state = ClientAuthoredMovementState::new();
228        state.record_move_packet_delta();
229        state.set_last_known_client_movement(DVec3::new(0.1, 0.0, 0.0));
230
231        state.reset_for_position_sync(DVec3::new(2.0, 3.0, 4.0));
232
233        assert_eq!(
234            state.good_positions(),
235            (DVec3::new(2.0, 3.0, 4.0), DVec3::new(2.0, 3.0, 4.0))
236        );
237        assert_eq!(state.last_known_client_movement(), DVec3::ZERO);
238        assert_eq!(state.record_move_packet_delta(), 1);
239    }
240
241    #[test]
242    fn client_movement_floating_tracker_counts_only_while_floating() {
243        let mut state = ClientAuthoredMovementState::new();
244        state.record_client_floating(true);
245
246        assert!(!state.tick_client_floating(true, 2));
247        assert!(!state.tick_client_floating(true, 2));
248        assert!(state.tick_client_floating(true, 2));
249
250        state.record_client_floating(false);
251        assert!(!state.tick_client_floating(true, 2));
252
253        state.record_client_floating(true);
254        assert!(!state.tick_client_floating(true, 2));
255    }
256
257    #[test]
258    fn client_movement_floating_tracker_resets_when_tick_conditions_do_not_count() {
259        let mut state = ClientAuthoredMovementState::new();
260        state.record_client_floating(true);
261
262        assert!(!state.tick_client_floating(true, 1));
263        assert!(!state.tick_client_floating(false, 1));
264
265        state.record_client_floating(true);
266        assert!(!state.tick_client_floating(true, 1));
267    }
268
269    #[test]
270    fn client_movement_clear_floating_resets_flag_and_counter() {
271        let mut state = ClientAuthoredMovementState::new();
272        state.record_client_floating(true);
273        assert!(!state.tick_client_floating(true, 1));
274
275        state.clear_client_floating();
276
277        assert!(!state.tick_client_floating(true, 1));
278        assert!(!state.tick_client_floating(true, 1));
279    }
280
281    #[test]
282    fn client_movement_reset_flying_ticks_preserves_current_floating_status() {
283        let mut state = ClientAuthoredMovementState::new();
284        state.record_client_floating(true);
285
286        assert!(!state.tick_client_floating(true, 1));
287        state.reset_flying_ticks();
288        assert!(!state.tick_client_floating(true, 1));
289        assert!(state.tick_client_floating(true, 1));
290    }
291}