Skip to main content

ghast_client/
rotation.rs

1//! Rotation in whole mouse counts.
2//!
3//! A real client turns by `mouse delta × multiplier × 0.15` degrees, so every yaw / pitch
4//! change is an integer multiple of the sensitivity's GCD (`multiplier × 0.15`). Anti-cheats
5//! (Grim's aim checks) look for that. This module reproduces the client's float arithmetic:
6//! the multiplier is computed in `f32` like `GameRenderer.render`, the
7//! angles are added in `f64` and stored as `f32` like `Entity.increaseTransforms`.
8//!
9//! The speed model (at most 14° yaw / 10.5° pitch per tick with a carried budget) is the one
10//! the reference behaviours were tuned with; it is a behaviour choice, not vanilla.
11
12/// `GameRenderer`: `f = sensitivity * 0.6 + 0.2; f * f * f * 8`, all `f32`.
13#[must_use]
14pub fn mouse_multiplier(sensitivity: f32) -> f32 {
15    let s = sensitivity * 0.6 + 0.2;
16    s * s * s * 8.0
17}
18
19/// The smallest rotation step for a sensitivity: `multiplier × 0.15` (as `f32`).
20#[must_use]
21pub fn mouse_gcd(sensitivity: f32) -> f32 {
22    mouse_multiplier(sensitivity) * 0.15
23}
24
25/// `MathHelper.wrapDegrees`.
26#[must_use]
27pub fn wrap_degrees(a: f32) -> f32 {
28    let mut v = a % 360.0;
29    if v >= 180.0 {
30        v -= 360.0;
31    }
32    if v < -180.0 {
33        v += 360.0;
34    }
35    v
36}
37
38/// Java's `Math.round(double)`: `floor(x + 0.5)` (as an integer).
39fn java_round(x: f64) -> i64 {
40    // Out-of-range values saturate like Java's (long) cast.
41    (x + 0.5).floor() as i64
42}
43
44/// Integer mouse counts with a fractional speed budget carried between ticks.
45#[derive(Debug, Clone, Copy, Default, PartialEq)]
46pub struct MouseStep {
47    budget: f64,
48}
49
50impl MouseStep {
51    /// Mouse counts toward `delta` degrees this tick, at most `degrees_per_tick` (plus the
52    /// carried fraction).
53    pub fn counts(&mut self, delta: f32, multiplier: f32, degrees_per_tick: f64) -> i64 {
54        let step = f64::from(multiplier) * 0.15;
55        let per_tick = degrees_per_tick / step;
56        self.budget = (self.budget + per_tick).min(per_tick + 1.0);
57        let wanted = java_round(f64::from(delta) / step);
58        let lim = self.budget.trunc() as i64;
59        let count = wanted.clamp(-lim, lim);
60        self.budget -= count.unsigned_abs() as f64;
61        if count == wanted {
62            self.budget = 0.0;
63        }
64        count
65    }
66}
67
68/// Turns toward a target in whole mouse counts.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct Rotation {
71    sensitivity: f32,
72    yaw_step: MouseStep,
73    pitch_step: MouseStep,
74    target_yaw: f32,
75    target_pitch: f32,
76}
77
78impl Rotation {
79    /// Maximum yaw change per tick (degrees).
80    pub const MAX_YAW_PER_TICK: f64 = 14.0;
81    /// Maximum pitch change per tick (degrees).
82    pub const MAX_PITCH_PER_TICK: f64 = 10.5;
83
84    /// A controller for an options.txt mouse sensitivity (0 … 1; vanilla default 0.5).
85    #[must_use]
86    pub fn new(sensitivity: f32) -> Self {
87        Rotation {
88            sensitivity,
89            yaw_step: MouseStep::default(),
90            pitch_step: MouseStep::default(),
91            target_yaw: 0.0,
92            target_pitch: 0.0,
93        }
94    }
95
96    /// The sensitivity.
97    #[must_use]
98    pub fn sensitivity(&self) -> f32 {
99        self.sensitivity
100    }
101
102    /// Forgets carried budgets and aims at the current look.
103    pub fn reset(&mut self, yaw: f32, pitch: f32) {
104        self.yaw_step = MouseStep::default();
105        self.pitch_step = MouseStep::default();
106        self.target_yaw = yaw;
107        self.target_pitch = pitch;
108    }
109
110    /// Sets the target (pitch clamped to ±89.5).
111    pub fn set_target(&mut self, yaw: f32, pitch: f32) {
112        self.target_yaw = yaw;
113        self.target_pitch = pitch.clamp(-89.5, 89.5);
114    }
115
116    /// The target yaw / pitch.
117    #[must_use]
118    pub fn target(&self) -> (f32, f32) {
119        (self.target_yaw, self.target_pitch)
120    }
121
122    /// One tick: the new (yaw, pitch) from the current one.
123    ///
124    /// `Entity.increaseTransforms(dx * m, dy * m)`: `yaw = (float)(yaw + dx * 0.15)`,
125    /// `pitch = (float)(pitch - dy * 0.15)`, pitch clamped to ±90.
126    pub fn step(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
127        let m = mouse_multiplier(self.sensitivity);
128        let dx = self.yaw_step.counts(
129            wrap_degrees(self.target_yaw - yaw),
130            m,
131            Self::MAX_YAW_PER_TICK,
132        );
133        let dy = self
134            .pitch_step
135            .counts(self.target_pitch - pitch, m, Self::MAX_PITCH_PER_TICK);
136        // Mouse counts are small integers; the float conversion is exact.
137        let fx = dx as f32 * m;
138        let fy = -(dy as f32) * m;
139        let new_yaw = (f64::from(yaw) + f64::from(fx) * 0.15) as f32;
140        let new_pitch = ((f64::from(pitch) - f64::from(fy) * 0.15) as f32).clamp(-90.0, 90.0);
141        (new_yaw, new_pitch)
142    }
143
144    /// Remaining yaw error to the target (degrees, wrapped).
145    #[must_use]
146    pub fn yaw_error(&self, yaw: f32) -> f32 {
147        wrap_degrees(self.target_yaw - yaw).abs()
148    }
149
150    /// Remaining pitch error to the target.
151    #[must_use]
152    pub fn pitch_error(&self, pitch: f32) -> f32 {
153        (self.target_pitch - pitch).abs()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn gcd_steps() {
163        let sens = 0.338_028_2_f32;
164        let gcd = f64::from(mouse_gcd(sens));
165        let mut r = Rotation::new(sens);
166        let (mut yaw, mut pitch) = (12.5_f32, 3.0_f32);
167        r.set_target(100.0, 40.0);
168        for _ in 0..40 {
169            let (ny, np) = r.step(yaw, pitch);
170            let dy = f64::from(ny) - f64::from(yaw);
171            let dp = f64::from(np) - f64::from(pitch);
172            assert!(dy.abs() <= Rotation::MAX_YAW_PER_TICK + gcd + 1e-3);
173            assert!(dp.abs() <= Rotation::MAX_PITCH_PER_TICK + gcd + 1e-3);
174            // Every change is a whole number of GCD steps (up to f32 rounding of the sum).
175            let k = dy / gcd;
176            assert!(
177                (k - k.round()).abs() < 1e-2,
178                "yaw step {dy} is not a multiple of {gcd}"
179            );
180            yaw = ny;
181            pitch = np;
182        }
183        assert!(r.yaw_error(yaw) < gcd as f32);
184        assert!(r.pitch_error(pitch) < gcd as f32);
185    }
186
187    #[test]
188    fn wrap() {
189        assert_eq!(wrap_degrees(190.0), -170.0);
190        assert_eq!(wrap_degrees(-190.0), 170.0);
191        assert_eq!(wrap_degrees(540.0), -180.0);
192        assert_eq!(wrap_degrees(180.0), -180.0);
193    }
194
195    #[test]
196    fn java_rounding() {
197        assert_eq!(java_round(2.5), 3);
198        assert_eq!(java_round(-2.5), -2);
199        assert_eq!(java_round(-2.6), -3);
200    }
201
202    /// Values computed with an independent TypeScript (`Math.fround`) port.
203    #[test]
204    fn multiplier_is_f32() {
205        assert!((mouse_multiplier(0.5) - 1.0).abs() < 1e-6);
206        let m = mouse_multiplier(0.338_028_2);
207        assert_eq!(m, {
208            let s: f32 = 0.338_028_2 * 0.6 + 0.2;
209            s * s * s * 8.0
210        });
211    }
212}