Skip to main content

veilus_human_input/
mouse.rs

1//! Mouse paths and click timing.
2//!
3//! A path is a cubic Bézier curve whose two control points sit on the SAME
4//! side of the straight line, so the pointer bends one way instead of
5//! wobbling. Points are spaced in time by a fixed polling interval, as a real
6//! mouse reports its position; the movement time follows Fitts' law, so far
7//! and small targets take longer. Long moves overshoot the target and correct,
8//! and some moves wander through a few waypoints first.
9
10use std::ops::{Range, RangeInclusive};
11use std::time::Duration;
12
13use rand::Rng;
14
15/// One point of a path, and how long to wait AFTER sending it.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Step {
18    pub x: f64,
19    pub y: f64,
20    pub delay: Duration,
21}
22
23/// Every tunable number of the mouse model.
24#[derive(Debug, Clone, PartialEq)]
25pub struct MouseParams {
26    /// Interval between two reported pointer positions (the mouse polling
27    /// rate as the page sees it).
28    pub tick: Duration,
29    /// Fewest points in one leg, however short.
30    pub min_steps: usize,
31    /// Fitts' law, milliseconds: `T = a + b * log2(distance / width + 1)`.
32    pub fitts_a_ms: f64,
33    pub fitts_b_ms: f64,
34    /// Control-point offset from the straight line, as a fraction of the
35    /// distance, capped in pixels.
36    pub bend: Range<f64>,
37    pub bend_cap_px: f64,
38    /// Moves longer than this overshoot the target, then correct.
39    pub overshoot_threshold_px: f64,
40    /// How far past the target, as a fraction of the distance, capped.
41    pub overshoot: Range<f64>,
42    pub overshoot_cap_px: f64,
43    /// Sideways offset of the overshoot point, and the bend cap of the short
44    /// correcting leg.
45    pub overshoot_lateral_px: f64,
46    pub correction_bend_cap_px: f64,
47    /// Chance that a move wanders through waypoints before the target, how
48    /// many, and the smallest spread used for short moves.
49    pub detour_chance: f64,
50    pub detour_waypoints: RangeInclusive<usize>,
51    pub detour_min_px: f64,
52    /// Click points fall inside the box, at least this fraction away from
53    /// every edge.
54    pub inset: f64,
55    /// Distance of the starting point when the pointer position is unknown.
56    pub first_start_px: Range<f64>,
57    /// Pause on the target before pressing, and button hold time.
58    pub hover_ms: Range<u64>,
59    pub press_ms: Range<u64>,
60    /// Chance of a 1 px drift while the button is held.
61    pub wiggle_chance: f64,
62}
63
64impl Default for MouseParams {
65    fn default() -> Self {
66        Self {
67            // A 125 Hz USB mouse.
68            tick: Duration::from_millis(8),
69            min_steps: 10,
70            fitts_a_ms: 100.0,
71            fitts_b_ms: 150.0,
72            bend: 0.05..0.25,
73            bend_cap_px: 120.0,
74            overshoot_threshold_px: 500.0,
75            overshoot: 0.03..0.08,
76            overshoot_cap_px: 60.0,
77            overshoot_lateral_px: 10.0,
78            correction_bend_cap_px: 4.0,
79            detour_chance: 0.2,
80            detour_waypoints: 1..=2,
81            detour_min_px: 50.0,
82            inset: 0.25,
83            first_start_px: 100.0..300.0,
84            hover_ms: 10..80,
85            press_ms: 80..140,
86            wiggle_chance: 0.2,
87        }
88    }
89}
90
91/// Bounding box `(x0, y0, x1, y1)` of a quad `[x1,y1, x2,y2, x3,y3, x4,y4]`
92/// (the shape CDP `DOM.getContentQuads` returns). `None` for a malformed quad
93/// or a box narrower than one pixel.
94pub fn bounds(quad: &[f64]) -> Option<(f64, f64, f64, f64)> {
95    if quad.len() != 8 {
96        return None;
97    }
98    let xs = quad.iter().step_by(2);
99    let ys = quad.iter().skip(1).step_by(2);
100    let (x0, x1) = xs.fold((f64::MAX, f64::MIN), |(a, b), &v| (a.min(v), b.max(v)));
101    let (y0, y1) = ys.fold((f64::MAX, f64::MIN), |(a, b), &v| (a.min(v), b.max(v)));
102    (x1 - x0 >= 1.0 && y1 - y0 >= 1.0).then_some((x0, y0, x1, y1))
103}
104
105/// A random point in the middle part of a box — never the exact center, never
106/// at the edge.
107pub fn pick_point(b: (f64, f64, f64, f64), p: &MouseParams, rng: &mut impl Rng) -> (f64, f64) {
108    let (x0, y0, x1, y1) = b;
109    let at = |lo: f64, hi: f64, r: f64| lo + (hi - lo) * (p.inset + (1.0 - 2.0 * p.inset) * r);
110    let x = at(x0, x1, rng.gen());
111    let y = at(y0, y1, rng.gen());
112    (x.round(), y.round())
113}
114
115/// A starting point when the pointer position is unknown: a random direction
116/// from the target, kept inside the top-left corner of the viewport.
117pub fn first_start(to: (f64, f64), p: &MouseParams, rng: &mut impl Rng) -> (f64, f64) {
118    let angle = rng.gen_range(0.0..std::f64::consts::TAU);
119    let dist = rng.gen_range(p.first_start_px.clone());
120    (
121        (to.0 + dist * angle.cos()).max(1.0).round(),
122        (to.1 + dist * angle.sin()).max(1.0).round(),
123    )
124}
125
126/// Pointer path from `from` to `to`, EXCLUDING `from`; the last point is
127/// exactly `to`. `target_width` is the target's width, for Fitts' law.
128///
129/// With probability [`MouseParams::detour_chance`] the pointer first wanders
130/// through a few random waypoints, like a person reading before clicking.
131pub fn path(
132    from: (f64, f64),
133    to: (f64, f64),
134    target_width: f64,
135    p: &MouseParams,
136    rng: &mut impl Rng,
137) -> Vec<Step> {
138    if !rng.gen_bool(p.detour_chance) {
139        return direct_path(from, to, target_width, p, rng);
140    }
141    let raw = detour(from, to, target_width, p, rng);
142    drop_repeats(from, raw)
143}
144
145fn detour(
146    from: (f64, f64),
147    to: (f64, f64),
148    target_width: f64,
149    p: &MouseParams,
150    rng: &mut impl Rng,
151) -> Vec<Step> {
152    let (dx, dy) = (to.0 - from.0, to.1 - from.1);
153    let dist = dx.hypot(dy).max(p.detour_min_px);
154    let (nx, ny) = (-dy / dist, dx / dist);
155    let mut steps = Vec::new();
156    let mut at = from;
157    for _ in 0..rng.gen_range(p.detour_waypoints.clone()) {
158        let t = rng.gen_range(0.2..0.8);
159        let off = rng.gen_range(-0.8..0.8) * dist;
160        let waypoint = (
161            (from.0 + dx * t + nx * off).max(1.0).round(),
162            (from.1 + dy * t + ny * off).max(1.0).round(),
163        );
164        steps.extend(legs(at, waypoint, target_width, p, rng));
165        at = waypoint;
166    }
167    steps.extend(legs(at, to, target_width, p, rng));
168    steps
169}
170
171/// A real mouse never reports a move that did not move, but slow starts and
172/// short legs round to repeated pixels. Drop them, keeping the total time:
173/// a dropped step's wait goes to the step before it.
174fn drop_repeats(from: (f64, f64), steps: Vec<Step>) -> Vec<Step> {
175    let mut out: Vec<Step> = Vec::with_capacity(steps.len());
176    let mut prev = from;
177    for st in steps {
178        if (st.x, st.y) != prev {
179            prev = (st.x, st.y);
180            out.push(st);
181        } else if let Some(last) = out.last_mut() {
182            last.delay += st.delay;
183        }
184    }
185    out
186}
187
188/// Path without waypoints: one leg, or overshoot-then-correct beyond
189/// [`MouseParams::overshoot_threshold_px`].
190pub fn direct_path(
191    from: (f64, f64),
192    to: (f64, f64),
193    target_width: f64,
194    p: &MouseParams,
195    rng: &mut impl Rng,
196) -> Vec<Step> {
197    let raw = legs(from, to, target_width, p, rng);
198    drop_repeats(from, raw)
199}
200
201fn legs(
202    from: (f64, f64),
203    to: (f64, f64),
204    target_width: f64,
205    p: &MouseParams,
206    rng: &mut impl Rng,
207) -> Vec<Step> {
208    let (dx, dy) = (to.0 - from.0, to.1 - from.1);
209    let dist = dx.hypot(dy);
210    if dist <= p.overshoot_threshold_px {
211        return leg(from, to, target_width, p.bend_cap_px, p, rng);
212    }
213    let (ux, uy) = (dx / dist, dy / dist);
214    let ahead = (dist * rng.gen_range(p.overshoot.clone())).min(p.overshoot_cap_px);
215    let lateral = rng.gen_range(-p.overshoot_lateral_px..p.overshoot_lateral_px);
216    let past = (
217        (to.0 + ux * ahead - uy * lateral).max(1.0).round(),
218        (to.1 + uy * ahead + ux * lateral).max(1.0).round(),
219    );
220    let mut steps = leg(from, past, target_width, p.bend_cap_px, p, rng);
221    steps.extend(leg(
222        past,
223        to,
224        target_width,
225        p.correction_bend_cap_px,
226        p,
227        rng,
228    ));
229    steps
230}
231
232fn leg(
233    from: (f64, f64),
234    to: (f64, f64),
235    target_width: f64,
236    bend_cap: f64,
237    p: &MouseParams,
238    rng: &mut impl Rng,
239) -> Vec<Step> {
240    let (dx, dy) = (to.0 - from.0, to.1 - from.1);
241    let dist = dx.hypot(dy);
242    let total_ms = p.fitts_a_ms + p.fitts_b_ms * (dist / target_width.max(1.0) + 1.0).log2();
243    let tick_ms = p.tick.as_secs_f64() * 1000.0;
244    let steps = ((total_ms / tick_ms).round() as usize).max(p.min_steps.max(1));
245
246    // Control points at 1/3 and 2/3 of the line, pushed to the SAME side
247    // along the normal — so the projection on the line grows monotonically.
248    let (nx, ny) = if dist > 0.0 {
249        (-dy / dist, dx / dist)
250    } else {
251        (0.0, 0.0)
252    };
253    let side = if rng.gen_bool(0.5) { 1.0 } else { -1.0 };
254    let bend =
255        |r: f64| side * (dist * (p.bend.start + (p.bend.end - p.bend.start) * r)).min(bend_cap);
256    let (b1, b2) = (bend(rng.gen()), bend(rng.gen()));
257    let c1 = (from.0 + dx / 3.0 + nx * b1, from.1 + dy / 3.0 + ny * b1);
258    let c2 = (
259        from.0 + 2.0 * dx / 3.0 + nx * b2,
260        from.1 + 2.0 * dy / 3.0 + ny * b2,
261    );
262
263    (1..=steps)
264        .map(|i| {
265            let t = i as f64 / steps as f64;
266            // Speed up, then slow down; smoothstep keeps t monotonic.
267            let t = t * t * (3.0 - 2.0 * t);
268            let (x, y) = if i == steps {
269                to
270            } else {
271                bezier(from, c1, c2, to, t)
272            };
273            Step {
274                x: x.round(),
275                y: y.round(),
276                delay: p.tick,
277            }
278        })
279        .collect()
280}
281
282fn bezier(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) {
283    let u = 1.0 - t;
284    let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
285    (
286        a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0,
287        a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1,
288    )
289}
290
291/// Pause on the target before pressing the button.
292pub fn hover_pause(p: &MouseParams, rng: &mut impl Rng) -> Duration {
293    Duration::from_millis(rng.gen_range(p.hover_ms.clone()))
294}
295
296/// How long the button stays down.
297pub fn press_hold(p: &MouseParams, rng: &mut impl Rng) -> Duration {
298    Duration::from_millis(rng.gen_range(p.press_ms.clone()))
299}
300
301/// Small drifts (1 px each, relative to the press point) while the button is
302/// held — usually none.
303pub fn press_wiggle(p: &MouseParams, rng: &mut impl Rng) -> Vec<(f64, f64)> {
304    if !rng.gen_bool(p.wiggle_chance) {
305        return Vec::new();
306    }
307    (0..rng.gen_range(1..=2))
308        .map(|_| loop {
309            let d = (
310                f64::from(rng.gen_range(-1..=1)),
311                f64::from(rng.gen_range(-1..=1)),
312            );
313            if d != (0.0, 0.0) {
314                break d;
315            }
316        })
317        .collect()
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use rand::rngs::StdRng;
324    use rand::SeedableRng;
325
326    fn rng(seed: u64) -> StdRng {
327        StdRng::seed_from_u64(seed)
328    }
329
330    fn params() -> MouseParams {
331        MouseParams::default()
332    }
333
334    #[test]
335    fn path_ends_on_target_and_excludes_start() {
336        for seed in 0..50 {
337            let p = path(
338                (10.0, 10.0),
339                (600.0, 420.0),
340                80.0,
341                &params(),
342                &mut rng(seed),
343            );
344            let last = p.last().unwrap();
345            assert_eq!((last.x, last.y), (600.0, 420.0), "seed {seed}");
346            assert_ne!((p[0].x, p[0].y), (10.0, 10.0), "seed {seed}");
347        }
348    }
349
350    #[test]
351    fn projection_on_line_grows_monotonically() {
352        for seed in 0..50 {
353            let (from, to) = ((50.0, 400.0), (380.0, 190.0));
354            let (dx, dy) = (to.0 - from.0, to.1 - from.1);
355            let proj: Vec<f64> = direct_path(from, to, 60.0, &params(), &mut rng(seed))
356                .iter()
357                .map(|s| (s.x - from.0) * dx + (s.y - from.1) * dy)
358                .collect();
359            // Rounding to whole pixels allows a small step back.
360            let slack = dx.abs() + dy.abs();
361            assert!(
362                proj.windows(2).all(|w| w[1] >= w[0] - slack),
363                "seed {seed}: {proj:?}"
364            );
365        }
366    }
367
368    #[test]
369    fn every_point_is_on_one_side_of_the_line() {
370        for seed in 0..50 {
371            let (from, to) = ((0.0, 0.0), (300.0, 200.0));
372            let side: Vec<f64> = direct_path(from, to, 50.0, &params(), &mut rng(seed))
373                .iter()
374                .map(|s| (to.0 - from.0) * (s.y - from.1) - (to.1 - from.1) * (s.x - from.0))
375                .filter(|c| c.abs() > 1000.0) // drop points on the line (rounding)
376                .collect();
377            assert!(
378                side.iter().all(|c| *c > 0.0) || side.iter().all(|c| *c < 0.0),
379                "seed {seed}: path crosses the line {side:?}"
380            );
381            assert!(!side.is_empty(), "seed {seed}: perfectly straight path");
382        }
383    }
384
385    #[test]
386    fn farther_targets_take_more_points_and_time() {
387        // Both below the overshoot threshold: compare one leg with one leg.
388        let near = direct_path((0.0, 0.0), (120.0, 0.0), 40.0, &params(), &mut rng(1));
389        let far = direct_path((0.0, 0.0), (480.0, 0.0), 40.0, &params(), &mut rng(1));
390        assert!(far.len() > near.len(), "{} vs {}", far.len(), near.len());
391        let total = |p: &[Step]| p.iter().map(|s| s.delay).sum::<Duration>();
392        assert!(total(&far) > total(&near));
393        assert!(near.len() >= params().min_steps);
394    }
395
396    /// Projection of each point on the `from -> to` direction, in pixels from
397    /// `to`: positive means past the target.
398    fn past_target(p: &[Step], from: (f64, f64), to: (f64, f64)) -> f64 {
399        let (dx, dy) = (to.0 - from.0, to.1 - from.1);
400        let d = dx.hypot(dy);
401        p.iter()
402            .map(|s| ((s.x - to.0) * dx + (s.y - to.1) * dy) / d)
403            .fold(f64::MIN, f64::max)
404    }
405
406    #[test]
407    fn long_moves_overshoot_then_correct_short_moves_do_not() {
408        let (from, to) = ((50.0, 50.0), (900.0, 700.0));
409        let cap = params().overshoot_cap_px;
410        for seed in 0..40 {
411            let p = direct_path(from, to, 100.0, &params(), &mut rng(seed));
412            let last = p.last().unwrap();
413            assert_eq!((last.x, last.y), to, "seed {seed}");
414            let past = past_target(&p, from, to);
415            assert!(past > 5.0 && past <= cap + 2.0, "seed {seed}: {past}px");
416        }
417        // Control row: below the threshold no point goes past the target.
418        let near_from = (600.0, 400.0);
419        for seed in 0..40 {
420            let p = direct_path(near_from, to, 100.0, &params(), &mut rng(seed));
421            let past = past_target(&p, near_from, to);
422            assert!(past <= 1.0, "seed {seed}: short move overshot {past}px");
423        }
424    }
425
426    #[test]
427    fn path_sometimes_wanders_and_always_ends_on_target() {
428        let (from, to): ((f64, f64), (f64, f64)) = ((100.0, 100.0), (500.0, 300.0));
429        let straight = (to.0 - from.0).hypot(to.1 - from.1);
430        let mut wandering = 0;
431        for seed in 0..400 {
432            let p = path(from, to, 60.0, &params(), &mut rng(seed));
433            let last = p.last().unwrap();
434            assert_eq!((last.x, last.y), to, "seed {seed}");
435            let mut len = 0.0;
436            let mut prev = from;
437            for s in &p {
438                len += (s.x - prev.0).hypot(s.y - prev.1);
439                prev = (s.x, s.y);
440            }
441            if len / straight > 1.5 {
442                wandering += 1;
443            }
444        }
445        // detour_chance 0.2 of 400; not every detour is long enough to count.
446        assert!(
447            (30..=110).contains(&wandering),
448            "{wandering}/400 paths wander"
449        );
450    }
451
452    #[test]
453    fn no_step_repeats_the_previous_position() {
454        for seed in 0..200 {
455            let from = (10.0, 10.0);
456            let p = path(from, (300.0, 220.0), 40.0, &params(), &mut rng(seed));
457            let mut prev = from;
458            for s in &p {
459                assert_ne!((s.x, s.y), prev, "seed {seed}");
460                prev = (s.x, s.y);
461            }
462        }
463    }
464
465    #[test]
466    fn points_are_spaced_by_the_polling_tick() {
467        let p = MouseParams {
468            tick: Duration::from_micros(16_667),
469            ..params()
470        };
471        for seed in 0..20 {
472            let steps = path((40.0, 40.0), (900.0, 500.0), 60.0, &p, &mut rng(seed));
473            // A dropped repeat hands its wait to the step before it.
474            assert!(
475                steps.iter().all(|s| {
476                    let n = s.delay.as_nanos() / p.tick.as_nanos();
477                    n >= 1 && s.delay == p.tick * n as u32
478                }),
479                "seed {seed}"
480            );
481            assert!(steps.iter().filter(|s| s.delay == p.tick).count() * 10 > steps.len() * 8);
482        }
483    }
484
485    #[test]
486    fn click_point_stays_inside_inset_and_varies() {
487        let b = bounds(&[300.0, 200.0, 400.0, 200.0, 400.0, 240.0, 300.0, 240.0]).unwrap();
488        let mut r = rng(7);
489        let pts: Vec<(f64, f64)> = (0..100).map(|_| pick_point(b, &params(), &mut r)).collect();
490        assert!(pts
491            .iter()
492            .all(|&(x, y)| (325.0..=375.0).contains(&x) && (210.0..=230.0).contains(&y)));
493        let distinct: std::collections::HashSet<(i64, i64)> =
494            pts.iter().map(|&(x, y)| (x as i64, y as i64)).collect();
495        assert!(
496            distinct.len() > 20,
497            "click points repeat: {}",
498            distinct.len()
499        );
500    }
501
502    #[test]
503    fn empty_or_malformed_box_has_no_click_point() {
504        assert_eq!(bounds(&[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]), None);
505        assert_eq!(bounds(&[1.0, 2.0]), None);
506    }
507
508    #[test]
509    fn first_start_is_within_configured_distance() {
510        let p = params();
511        let mut r = rng(3);
512        for _ in 0..200 {
513            let s = first_start((500.0, 400.0), &p, &mut r);
514            let d = (s.0 - 500.0).hypot(s.1 - 400.0);
515            assert!(d >= p.first_start_px.start - 1.0 && d <= p.first_start_px.end + 1.0);
516        }
517    }
518
519    #[test]
520    fn hover_press_and_wiggle_follow_params() {
521        let p = params();
522        let mut r = rng(4);
523        for _ in 0..500 {
524            assert!(p
525                .hover_ms
526                .contains(&(hover_pause(&p, &mut r).as_millis() as u64)));
527            assert!(p
528                .press_ms
529                .contains(&(press_hold(&p, &mut r).as_millis() as u64)));
530        }
531        let wiggles: Vec<Vec<(f64, f64)>> = (0..1000).map(|_| press_wiggle(&p, &mut r)).collect();
532        let with = wiggles.iter().filter(|w| !w.is_empty()).count();
533        assert!((130..=270).contains(&with), "{with}/1000 presses drift");
534        assert!(wiggles
535            .iter()
536            .flatten()
537            .all(|&(dx, dy)| dx.abs() <= 1.0 && dy.abs() <= 1.0 && (dx, dy) != (0.0, 0.0)));
538    }
539}