1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum Handedness {
11 Right,
12 Left,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ScrollStyle {
18 Trackpad,
19 Wheel,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BehaviorProfile {
26 #[serde(default = "default_behavior_seed")]
27 pub seed: u64,
28 #[serde(default = "default_handedness")]
29 pub handedness: Handedness,
30 #[serde(default = "default_mouse_dpi")]
31 pub mouse_dpi: u16,
32 #[serde(default = "default_typing_wpm_mean")]
33 pub typing_wpm_mean: f32,
34 #[serde(default = "default_typing_wpm_sigma")]
35 pub typing_wpm_sigma: f32,
36 #[serde(default = "default_scroll_style")]
37 pub scroll_style: ScrollStyle,
38 #[serde(default = "default_fitts_b")]
39 pub fitts_b: f32,
40}
41
42fn default_behavior_seed() -> u64 {
43 rand::random::<u64>()
44}
45fn default_handedness() -> Handedness {
46 Handedness::Right
47}
48fn default_mouse_dpi() -> u16 {
49 1600
50}
51fn default_typing_wpm_mean() -> f32 {
52 50.0
53}
54fn default_typing_wpm_sigma() -> f32 {
55 15.0
56}
57fn default_scroll_style() -> ScrollStyle {
58 ScrollStyle::Trackpad
59}
60fn default_fitts_b() -> f32 {
61 166.0
62}
63
64impl Default for BehaviorProfile {
65 fn default() -> Self {
66 Self {
67 seed: default_behavior_seed(),
68 handedness: default_handedness(),
69 mouse_dpi: default_mouse_dpi(),
70 typing_wpm_mean: default_typing_wpm_mean(),
71 typing_wpm_sigma: default_typing_wpm_sigma(),
72 scroll_style: default_scroll_style(),
73 fitts_b: default_fitts_b(),
74 }
75 }
76}
77
78impl BehaviorProfile {
79 pub fn rng_for(&self, salt: u64) -> rand_chacha::ChaCha20Rng {
81 use rand_chacha::rand_core::SeedableRng;
82 let combined = self
83 .seed
84 .wrapping_mul(0x9E3779B97F4A7C15)
85 .wrapping_add(salt);
86 rand_chacha::ChaCha20Rng::seed_from_u64(combined)
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
92pub struct MousePoint {
93 pub t_ms: f32,
94 pub x: f32,
95 pub y: f32,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
100pub struct KeystrokeTiming {
101 pub ch: char,
102 pub dwell_ms: f32,
103 pub flight_ms: f32,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
108pub struct WheelTick {
109 pub t_ms: f32,
110 pub delta_y: f32,
111 pub mode: u32,
112}
113
114struct Stroke {
117 amplitude: f32,
118 sigma: f32,
119 mu: f32,
120 t0: f32,
121 theta: f32,
122}
123
124fn integrate_x(strokes: &[Stroke], t: f32) -> f32 {
125 strokes
126 .iter()
127 .map(|s| {
128 let dt = t - s.t0;
129 if dt <= 0.0 {
130 return 0.0;
131 }
132 let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
133 let cdf = 0.5 * (1.0 + erf(z));
134 s.amplitude * cdf * s.theta.cos()
135 })
136 .sum()
137}
138
139fn integrate_y(strokes: &[Stroke], t: f32) -> f32 {
140 strokes
141 .iter()
142 .map(|s| {
143 let dt = t - s.t0;
144 if dt <= 0.0 {
145 return 0.0;
146 }
147 let z = (dt.ln() - s.mu) / (s.sigma * std::f32::consts::SQRT_2);
148 let cdf = 0.5 * (1.0 + erf(z));
149 s.amplitude * cdf * s.theta.sin()
150 })
151 .sum()
152}
153
154fn erf(x: f32) -> f32 {
156 let sign = x.signum();
157 let x = x.abs();
158 let a1 = 0.254_829_6;
159 let a2 = -0.284_496_72;
160 let a3 = 1.421_413_8;
161 let a4 = -1.453_152_1;
162 let a5 = 1.061_405_4;
163 let p = 0.3275911;
164 let t = 1.0 / (1.0 + p * x);
165 let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
166 sign * y
167}
168
169pub fn mouse_trajectory(
171 from: (f32, f32),
172 to: (f32, f32),
173 target_w: f32,
174 profile: &BehaviorProfile,
175) -> Vec<MousePoint> {
176 let mut rng = profile
177 .rng_for(((from.0 as u64) << 32) | (from.1 as u64) ^ ((to.0 as u64) << 16) ^ (to.1 as u64));
178 mouse_trajectory_with_rng(from, to, target_w, profile, &mut rng)
179}
180
181pub fn mouse_trajectory_with_rng<R: rand::Rng>(
183 from: (f32, f32),
184 to: (f32, f32),
185 target_w: f32,
186 profile: &BehaviorProfile,
187 rng: &mut R,
188) -> Vec<MousePoint> {
189 use rand_distr::{Distribution, LogNormal, Normal};
190
191 let dx = to.0 - from.0;
192 let dy = to.1 - from.1;
193 let distance = (dx * dx + dy * dy).sqrt().max(1.0);
194 let target_w = target_w.max(1.0);
195
196 let id_bits = ((distance / target_w) + 1.0).log2();
197 let n_strokes = ((1.3 * id_bits).round() as usize).clamp(2, 7);
198
199 let total_ms = 230.0 + profile.fitts_b * id_bits;
200
201 let mut amplitudes: Vec<f32> = Vec::with_capacity(n_strokes);
202 let primary = 0.85 * distance;
203 amplitudes.push(primary);
204 let remaining = distance - primary;
205 let per_corrective = remaining / (n_strokes - 1).max(1) as f32;
206 for _ in 1..n_strokes {
207 let jitter: f32 = Normal::new(0.0_f32, per_corrective * 0.15)
208 .ok()
209 .map_or(0.0, |d| d.sample(rng));
210 amplitudes.push((per_corrective + jitter).max(1.0));
211 }
212
213 let sigma_dist = Normal::new(0.25_f32, 0.05).ok();
214 let mu_dist = Normal::new(-1.6_f32, 0.2).ok();
215 let onset_dist = LogNormal::new(90.0_f32.ln(), 0.3).ok();
216 let theta_dist = Normal::new(0.0_f32, 8.0_f32.to_radians()).ok();
217
218 let target_angle = dy.atan2(dx);
219 let mut strokes: Vec<Stroke> = Vec::with_capacity(n_strokes);
220 let mut t0 = 0.0_f32;
221 for (i, amp) in amplitudes.iter().enumerate() {
222 let sigma = sigma_dist
223 .as_ref()
224 .map_or(0.25, |d| d.sample(rng).clamp(0.15, 0.40));
225 let mu = mu_dist.as_ref().map_or(-1.6, |d| d.sample(rng));
226 let jitter = theta_dist.as_ref().map_or(0.0, |d| d.sample(rng));
227 let theta = if i == 0 {
228 target_angle + jitter
229 } else {
230 target_angle + jitter * 1.5
231 };
232 strokes.push(Stroke {
233 amplitude: *amp,
234 sigma,
235 mu,
236 t0,
237 theta,
238 });
239 t0 += onset_dist.as_ref().map_or(90.0, |d| d.sample(rng));
240 }
241
242 let dt_ms = 8.0_f32;
243 let n_samples = (total_ms / dt_ms).ceil() as usize + 1;
244 let mut points: Vec<MousePoint> = Vec::with_capacity(n_samples);
245
246 let tremor_dist = Normal::new(0.0_f32, 1.5).ok();
247 let mut tremor_x = 0.0_f32;
248 let mut tremor_y = 0.0_f32;
249 let tremor_alpha = 0.3_f32;
250
251 for i in 0..n_samples {
252 let t = (i as f32) * dt_ms;
253
254 let tx = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
255 let ty = tremor_dist.as_ref().map_or(0.0, |d| d.sample(rng));
256 tremor_x = tremor_alpha * tremor_x + (1.0 - tremor_alpha) * tx;
257 tremor_y = tremor_alpha * tremor_y + (1.0 - tremor_alpha) * ty;
258
259 let x = from.0 + integrate_x(&strokes, t) + tremor_x;
260 let y = from.1 + integrate_y(&strokes, t) + tremor_y;
261 points.push(MousePoint { t_ms: t, x, y });
262 }
263
264 if points.len() >= 2 {
266 let n = points.len();
267 let last = &points[n - 1];
268 let res_x = to.0 - last.x;
269 let res_y = to.1 - last.y;
270 let tail = 15.min(n - 1);
271 let start = n - tail - 1;
272 for (k, p) in points.iter_mut().enumerate().skip(start) {
273 let u = (k - start) as f32 / tail as f32;
274 let s = u * u * (3.0 - 2.0 * u);
275 p.x += res_x * s;
276 p.y += res_y * s;
277 }
278 if let Some(last) = points.last_mut() {
279 last.x = to.0;
280 last.y = to.1;
281 }
282 } else if let Some(last) = points.last_mut() {
283 last.x = to.0;
284 last.y = to.1;
285 }
286 points
287}
288
289fn bigram_ratio(prev: char, cur: char) -> f32 {
292 let key = (
293 prev.to_ascii_lowercase() as u8,
294 cur.to_ascii_lowercase() as u8,
295 );
296 match key {
297 (b't', b'h')
298 | (b'h', b'e')
299 | (b'i', b'n')
300 | (b'a', b'n')
301 | (b'o', b'n')
302 | (b'a', b't')
303 | (b'i', b's')
304 | (b'i', b't')
305 | (b'o', b'r')
306 | (b'o', b'f') => 0.7,
307 (b'e', b'd')
308 | (b'u', b'n')
309 | (b'r', b'e')
310 | (b'e', b'r')
311 | (b'e', b'n')
312 | (b'n', b'd')
313 | (b'e', b's')
314 | (b't', b'e')
315 | (b'a', b'l')
316 | (b'a', b'r') => 1.4,
317 (a, b) if a == b => 2.0,
318 _ => 1.0,
319 }
320}
321
322pub fn keystroke_timings(text: &str, profile: &BehaviorProfile) -> Vec<KeystrokeTiming> {
324 let mut rng = profile.rng_for(0xCAFEBABE ^ text.len() as u64);
325 keystroke_timings_with_rng(text, profile, &mut rng)
326}
327
328pub fn keystroke_timings_with_rng<R: rand::Rng>(
330 text: &str,
331 profile: &BehaviorProfile,
332 rng: &mut R,
333) -> Vec<KeystrokeTiming> {
334 use rand_distr::{Distribution, LogNormal};
335
336 let ms_per_char = 60_000.0 / (profile.typing_wpm_mean * 5.0);
337 let flight_median = (ms_per_char - 95.0).max(40.0);
338 let flight_dist = LogNormal::new(flight_median.ln(), 0.55).ok();
339 let dwell_dist = LogNormal::new(95.0_f32.ln(), 0.30).ok();
340
341 let mut out = Vec::with_capacity(text.len());
342 let mut prev_ch: Option<char> = None;
343 for ch in text.chars() {
344 let dwell = dwell_dist
345 .as_ref()
346 .map_or(95.0, |d| d.sample(rng).clamp(40.0, 400.0));
347 let flight = if let Some(p) = prev_ch {
348 let ratio = bigram_ratio(p, ch);
349 flight_dist
350 .as_ref()
351 .map_or(130.0, |d| (d.sample(rng) * ratio).clamp(20.0, 1000.0))
352 } else {
353 0.0
354 };
355 out.push(KeystrokeTiming {
356 ch,
357 dwell_ms: dwell,
358 flight_ms: flight,
359 });
360 prev_ch = Some(ch);
361 }
362 out
363}
364
365pub fn wheel_burst(target_dy: f32, profile: &BehaviorProfile) -> Vec<WheelTick> {
369 let mut rng = profile.rng_for(0xDEAD_BEEF ^ target_dy.to_bits() as u64);
370 wheel_burst_with_rng(target_dy, profile, &mut rng)
371}
372
373pub fn wheel_burst_with_rng<R: rand::RngExt>(
375 target_dy: f32,
376 profile: &BehaviorProfile,
377 rng: &mut R,
378) -> Vec<WheelTick> {
379 use rand_distr::{Distribution, LogNormal};
380
381 let dir = if target_dy >= 0.0 { 1.0 } else { -1.0 };
382 let abs_dy = target_dy.abs().max(1.0);
383
384 match profile.scroll_style {
385 ScrollStyle::Trackpad => {
386 let v0 = LogNormal::new((abs_dy / 8.0).ln(), 0.3)
387 .ok()
388 .map_or(abs_dy / 8.0, |d| d.sample(rng));
389 let decay = 0.94 + rng.random_range(0.0_f32..0.04);
390 let mut t = 0.0_f32;
391 let mut v = v0;
392 let mut ticks = Vec::new();
393 let mut accumulated = 0.0_f32;
394 while v > 0.5 && accumulated < abs_dy * 1.1 {
395 let step = (v.min(abs_dy - accumulated)).max(0.5);
396 ticks.push(WheelTick {
397 t_ms: t,
398 delta_y: step * dir,
399 mode: 0,
400 });
401 accumulated += step;
402 t += 16.0;
403 v *= decay;
404 }
405 ticks
406 }
407 ScrollStyle::Wheel => {
408 let notches = ((abs_dy / 100.0).round() as u32).max(1);
409 let interval_dist = LogNormal::new(180.0_f32.ln(), 0.4).ok();
410 let mut t = 0.0_f32;
411 let mut ticks = Vec::with_capacity(notches as usize);
412 for _ in 0..notches {
413 ticks.push(WheelTick {
414 t_ms: t,
415 delta_y: 100.0 * dir,
416 mode: 0,
417 });
418 t += interval_dist.as_ref().map_or(180.0, |d| d.sample(rng));
419 }
420 ticks
421 }
422 }
423}