1use crate::compiled::CompiledHapticDef;
2use crate::dsl::{Instruction, LoopMode, Program};
3use crate::error::Error;
4use crate::motor::{DriveCommand, MotorConfig, MotorProfile, frac_to_level};
5use ph_curves::{MonotonicCurve, MonotonicCurveLut256, Tickless};
6
7#[derive(Copy, Clone, Debug, Eq, PartialEq)]
9pub struct Frame {
10 pub command: DriveCommand,
12 pub next_transition_ms: Option<u32>,
21 pub instruction_index: Option<usize>,
23 pub finished: bool,
30}
31
32impl Frame {
33 const fn idle() -> Self {
34 Self {
35 command: DriveCommand::Off,
36 next_transition_ms: None,
37 instruction_index: None,
38 finished: false,
39 }
40 }
41
42 pub const fn is_idle(&self) -> bool {
47 self.instruction_index.is_none() && self.next_transition_ms.is_none() && !self.finished
48 }
49
50 pub const fn is_active(&self) -> bool {
52 self.instruction_index.is_some() && !self.finished
53 }
54
55 pub const fn is_finished(&self) -> bool {
57 self.finished
58 }
59
60 pub const fn command(&self) -> DriveCommand {
62 self.command
63 }
64
65 pub const fn next_transition_ms(&self) -> Option<u32> {
70 self.next_transition_ms
71 }
72}
73
74impl Default for Frame {
75 fn default() -> Self {
76 Self::idle()
77 }
78}
79
80#[derive(Copy, Clone, Debug)]
81struct Located<'a, C> {
82 index: usize,
83 instruction: &'a Instruction<C>,
84 segment_start_ms: u32,
85}
86
87#[derive(Debug)]
93pub struct Runner<'a, C = MonotonicCurveLut256>
94where
95 C: MonotonicCurve<u8, u8> + Copy,
96{
97 program: &'a Program<'a, C>,
98 gamma_curve: Option<&'a MonotonicCurveLut256>,
99 motor: MotorConfig,
100 start_ms: u32,
101 running: bool,
102 min_level: u16,
104 max_level: u16,
106 kick_level: u16,
108 kick_duration_ms: u32,
110 needs_kick: bool,
112 kick_active: bool,
114 kick_end_ms: u32,
116}
117
118impl<'a, C> Runner<'a, C>
119where
120 C: MonotonicCurve<u8, u8> + Copy,
121{
122 fn build(
123 program: &'a Program<'a, C>,
124 profile: Option<&'a MotorProfile>,
125 motor: MotorConfig,
126 ) -> Result<Self, Error> {
127 if program.is_empty() {
128 return Err(Error::EmptyProgram);
129 }
130
131 if program.motor() != motor.kind() {
132 return Err(Error::MotorKindMismatch {
133 expected: program.motor(),
134 got: motor.kind(),
135 });
136 }
137
138 let (min_level, max_level, kick_level, kick_duration_ms) = match profile {
139 Some(p) => {
140 let max = frac_to_level(p.max_frac);
141 let kick = frac_to_level(p.kick_frac).min(max);
142 (
143 frac_to_level(p.min_run_frac),
144 max,
145 kick,
146 u32::from(p.kick_ms),
147 )
148 }
149 None => (0, 0, 0, 0),
150 };
151
152 Ok(Self {
153 program,
154 gamma_curve: profile.and_then(|profile| profile.gamma_curve),
155 motor,
156 start_ms: 0,
157 running: false,
158 min_level,
159 max_level,
160 kick_level,
161 kick_duration_ms,
162 needs_kick: false,
163 kick_active: false,
164 kick_end_ms: 0,
165 })
166 }
167
168 pub fn start(&mut self, now_ms: u32) {
170 self.start_ms = now_ms;
171 self.running = true;
172 self.needs_kick = self.kick_duration_ms > 0;
174 self.kick_active = false;
175 self.kick_end_ms = 0;
176 }
177
178 pub fn restart(&mut self, now_ms: u32) {
180 self.start(now_ms);
181 }
182
183 pub fn start_and_poll(&mut self, now_ms: u32) -> Frame {
185 self.start(now_ms);
186 self.poll(now_ms)
187 }
188
189 pub fn poll_or_start(&mut self, now_ms: u32) -> Frame {
191 if !self.running {
192 self.start(now_ms);
193 }
194 self.poll(now_ms)
195 }
196
197 pub fn stop(&mut self) {
199 self.running = false;
200 }
201
202 pub const fn is_running(&self) -> bool {
204 self.running
205 }
206
207 pub const fn motor(&self) -> MotorConfig {
209 self.motor
210 }
211
212 pub const fn start_ms(&self) -> u32 {
214 self.start_ms
215 }
216
217 pub fn poll(&mut self, now_ms: u32) -> Frame {
225 if !self.running {
226 return Frame::idle();
227 }
228
229 let elapsed_ms = now_ms.wrapping_sub(self.start_ms);
230 let located = locate_instruction(self.program, self.start_ms, elapsed_ms);
231
232 let Some(located) = located else {
233 self.running = false;
234 return Frame {
235 command: DriveCommand::Off,
236 next_transition_ms: None,
237 instruction_index: None,
238 finished: true,
239 };
240 };
241
242 let (level, lra_frequency_hz, next_transition_ms) =
243 eval_instruction(located.instruction, located.segment_start_ms, now_ms);
244 let mut level = level;
245
246 if self.min_level > 0 && level > 0 && level < self.min_level {
250 level = 0;
251 }
252
253 if level == 0 && self.kick_duration_ms > 0 {
255 self.needs_kick = true;
256 self.kick_active = false;
257 }
258
259 if self.needs_kick && level > 0 && level >= self.min_level {
265 self.needs_kick = false;
266 self.kick_active = true;
267 self.kick_end_ms = now_ms.wrapping_add(self.kick_duration_ms);
268 }
269 let kick_remaining_ms = self.kick_end_ms.wrapping_sub(now_ms);
272 if self.kick_active && (kick_remaining_ms == 0 || kick_remaining_ms > i32::MAX as u32) {
273 self.kick_active = false;
274 }
275 let kick_active = self.kick_active;
276 if kick_active && level > 0 {
277 level = level.max(self.kick_level);
278 }
279
280 if self.max_level > 0 && level > self.max_level {
282 level = self.max_level;
283 }
284
285 let level = apply_gamma(level, self.gamma_curve);
286
287 let instruction_remaining_ms = next_transition_ms.wrapping_sub(now_ms);
289 let next_transition_ms = if kick_active && kick_remaining_ms < instruction_remaining_ms {
290 self.kick_end_ms
291 } else {
292 next_transition_ms
293 };
294
295 Frame {
296 command: self.motor.drive(level, lra_frequency_hz),
297 next_transition_ms: Some(next_transition_ms),
298 instruction_index: Some(located.index),
299 finished: false,
300 }
301 }
302}
303
304impl<'a> Runner<'a, MonotonicCurveLut256> {
305 pub(crate) fn from_compiled(
307 compiled: &'a CompiledHapticDef<'a>,
308 motor: MotorConfig,
309 ) -> Result<Self, Error> {
310 Self::build(&compiled.program, compiled.profile, motor)
311 }
312
313 pub(crate) fn from_compiled_started(
315 compiled: &'a CompiledHapticDef<'a>,
316 motor: MotorConfig,
317 now_ms: u32,
318 ) -> Result<Self, Error> {
319 let mut runner = Self::build(&compiled.program, compiled.profile, motor)?;
320 runner.start(now_ms);
321 Ok(runner)
322 }
323}
324
325fn locate_instruction<'a, C>(
326 program: &'a Program<'a, C>,
327 start_ms: u32,
328 elapsed_ms: u32,
329) -> Option<Located<'a, C>> {
330 let cycle_duration = program.total_duration_ms();
331 if cycle_duration == 0 {
332 return None;
333 }
334
335 let cycle_elapsed = match program.loop_mode() {
336 LoopMode::Once => {
337 if elapsed_ms >= cycle_duration {
338 return None;
339 }
340 elapsed_ms
341 }
342 LoopMode::Forever => elapsed_ms % cycle_duration,
343 LoopMode::Count(n) => {
344 let total = cycle_duration.saturating_mul(n);
345 if elapsed_ms >= total {
346 return None;
347 }
348 elapsed_ms % cycle_duration
349 }
350 };
351
352 let now_ms = start_ms.wrapping_add(elapsed_ms);
355 let cycle_origin = now_ms.wrapping_sub(cycle_elapsed);
356 let instructions = program.instructions();
357
358 let mut index = 0usize;
359 let mut offset = 0u32;
360
361 while index < instructions.len() {
362 let instruction = &instructions[index];
363 let next_offset = offset.saturating_add(instruction.duration_ms());
364 if cycle_elapsed < next_offset {
365 return Some(Located {
366 index,
367 instruction,
368 segment_start_ms: cycle_origin.wrapping_add(offset),
369 });
370 }
371 offset = next_offset;
372 index += 1;
373 }
374
375 None
376}
377
378fn eval_instruction<C>(
379 instruction: &Instruction<C>,
380 segment_start_ms: u32,
381 now_ms: u32,
382) -> (u16, Option<u16>, u32)
383where
384 C: MonotonicCurve<u8, u8> + Copy,
385{
386 match instruction {
387 Instruction::Ramp(ramp) => {
388 let elapsed = now_ms.wrapping_sub(segment_start_ms).min(ramp.duration_ms);
391 let schedule = ramp.curve.tickless_schedule(
392 segment_start_ms,
393 ramp.duration_ms,
394 ramp.from,
395 ramp.to,
396 ramp.step,
397 ramp.rounding,
398 ramp.min_dt_ms,
399 );
400 let deadline = schedule.next_deadline(now_ms);
401
402 let amp_offset = deadline.deadline_ms.wrapping_sub(segment_start_ms);
406 let (lra_freq, next_transition_offset_ms) =
407 match (ramp.lra_frequency_hz, ramp.lra_frequency_hz_to) {
408 (Some(from_hz), Some(to_hz)) => {
409 let hz = lerp_lra_hz(from_hz, to_hz, elapsed, ramp.duration_ms);
410 let hz_deadline =
411 next_lra_hz_change_offset_ms(ramp.duration_ms, from_hz, to_hz, elapsed);
412 let next = match hz_deadline {
413 Some(hz_offset_ms) => amp_offset.min(hz_offset_ms),
414 None => amp_offset,
415 };
416 (Some(hz), next)
417 }
418 (freq, _) => (freq, amp_offset),
419 };
420 let next_transition_ms = segment_start_ms.wrapping_add(next_transition_offset_ms);
421
422 (deadline.current_val, lra_freq, next_transition_ms)
423 }
424 Instruction::Hold {
425 duration_ms,
426 level,
427 lra_frequency_hz,
428 } => (
429 *level,
430 *lra_frequency_hz,
431 segment_start_ms.wrapping_add(*duration_ms),
432 ),
433 Instruction::Pause { duration_ms } => {
434 (0, None, segment_start_ms.wrapping_add(*duration_ms))
435 }
436 }
437}
438
439fn lerp_lra_hz(from_hz: u16, to_hz: u16, t_ms: u32, duration_ms: u32) -> u16 {
440 let from = u64::from(from_hz);
443 let to = u64::from(to_hz);
444 let dur = u64::from(duration_ms.max(1));
445 let t = u64::from(t_ms.min(duration_ms));
446 let hz = if to >= from {
447 from + (to - from) * t / dur
448 } else {
449 from - (from - to) * t / dur
450 };
451 hz as u16
452}
453
454fn next_lra_hz_change_offset_ms(
456 duration_ms: u32,
457 from_hz: u16,
458 to_hz: u16,
459 elapsed_ms: u32,
460) -> Option<u32> {
461 if from_hz == to_hz || duration_ms == 0 || elapsed_ms >= duration_ms {
462 return None;
463 }
464
465 let from = u64::from(from_hz);
467 let to = u64::from(to_hz);
468 let current = u64::from(lerp_lra_hz(from_hz, to_hz, elapsed_ms, duration_ms));
469 let dur = u64::from(duration_ms);
470
471 let t = if to >= from {
472 let delta = to - from;
473 if delta == 0 || current >= to {
474 return None;
475 }
476 let need = current + 1 - from;
477 (need * dur).div_ceil(delta)
478 } else {
479 let delta = from - to;
480 if delta == 0 || current <= to {
481 return None;
482 }
483 let need = from - current + 1;
484 (need * dur).div_ceil(delta)
485 };
486
487 let t = t.max(u64::from(elapsed_ms) + 1);
488 if t > dur {
489 return None;
490 }
491 let mut t = t as u32;
492
493 let current_hz = current as u16;
495 while t <= duration_ms {
496 if lerp_lra_hz(from_hz, to_hz, t, duration_ms) != current_hz {
497 return Some(t);
498 }
499 t = t.saturating_add(1);
500 }
501 None
502}
503
504fn apply_gamma(level: u16, gamma_curve: Option<&MonotonicCurveLut256>) -> u16 {
505 if level == 0 {
506 return 0;
507 }
508 let Some(curve) = gamma_curve else {
509 return level;
510 };
511
512 let index = ((u32::from(level) * 255) + (u32::from(u16::MAX) / 2)) / u32::from(u16::MAX);
513 let mapped = u32::from(curve.fwd_lut()[index as usize]);
514 (((mapped * u32::from(u16::MAX)) + 127) / 255) as u16
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::compiled::CompiledHapticDef;
521 use crate::dsl::{Instruction, LoopMode, Program, Ramp};
522 use crate::motor::{DriveCommand, ErmConfig, LraConfig, MotorConfig, MotorKind, MotorProfile};
523 use ph_curves::{MonotonicCurveLut256, Rounding};
524
525 const fn linear_lut() -> [u8; 256] {
526 let mut lut = [0u8; 256];
527 let mut index = 0usize;
528
529 while index < lut.len() {
530 lut[index] = index as u8;
531 index += 1;
532 }
533
534 lut
535 }
536
537 const fn square_lut() -> [u8; 256] {
538 let mut lut = [0u8; 256];
539 let mut index = 0usize;
540
541 while index < lut.len() {
542 let x = index as u32;
543 let y = ((x * x) + 127) / 255;
544 lut[index] = y as u8;
545 index += 1;
546 }
547
548 lut
549 }
550
551 const fn monotonic_inv_lut(fwd: &[u8; 256]) -> [u8; 256] {
552 let mut inv = [0u8; 256];
553 let mut out = 0usize;
554
555 while out < inv.len() {
556 let mut input = 0usize;
557 while input < fwd.len() && (fwd[input] as usize) < out {
558 input += 1;
559 }
560 inv[out] = input as u8;
561 out += 1;
562 }
563
564 inv
565 }
566
567 static LINEAR_FWD: [u8; 256] = linear_lut();
568 static LINEAR_INV: [u8; 256] = linear_lut();
569 const LINEAR: MonotonicCurveLut256 = MonotonicCurveLut256::new(&LINEAR_FWD, &LINEAR_INV);
570 static SQUARE_FWD: [u8; 256] = square_lut();
571 static SQUARE_INV: [u8; 256] = monotonic_inv_lut(&SQUARE_FWD);
572 const SQUARE: MonotonicCurveLut256 = MonotonicCurveLut256::new(&SQUARE_FWD, &SQUARE_INV);
573
574 #[test]
575 fn erm_ramp_is_shaped_and_finishes() {
576 let instructions = [Instruction::Ramp(Ramp::new(100, 0, u16::MAX, LINEAR))];
577 let program = Program::new(MotorKind::Erm, &instructions);
578 let compiled = CompiledHapticDef::new("demo", program, None);
579 let mut runner =
580 Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
581 runner.start(1_000);
582
583 let start = runner.poll(1_000);
584 assert_eq!(start.command, DriveCommand::Off);
585 assert_eq!(start.instruction_index, Some(0));
586
587 let mid = runner.poll(1_050);
588 match mid.command {
589 DriveCommand::Erm { duty } => assert!((120..=136).contains(&duty)),
590 _ => panic!("expected ERM command"),
591 }
592 assert_eq!(mid.instruction_index, Some(0));
593 assert!(mid.next_transition_ms.is_some());
594 assert!(!mid.finished);
595
596 let finished = runner.poll(1_101);
597 assert_eq!(finished.command, DriveCommand::Off);
598 assert_eq!(finished.next_transition_ms, None);
599 assert_eq!(finished.instruction_index, None);
600 assert!(finished.finished);
601 assert!(!runner.is_running());
602 }
603
604 #[test]
605 fn lra_hold_uses_frequency_override() {
606 let instructions: [Instruction<MonotonicCurveLut256>; 1] =
607 [Instruction::hold_with_lra_frequency(20, u16::MAX, 190)];
608 let program = Program::new(MotorKind::Lra, &instructions);
609 let compiled = CompiledHapticDef::new("demo", program, None);
610 let mut runner =
611 Runner::from_compiled(&compiled, MotorConfig::Lra(LraConfig::new(2047, 235))).unwrap();
612 runner.start(0);
613
614 let frame = runner.poll(0);
615 assert_eq!(
616 frame.command,
617 DriveCommand::Lra {
618 amplitude: 2047,
619 frequency_hz: 190
620 }
621 );
622 assert_eq!(frame.next_transition_ms, Some(20));
623 assert!(!frame.finished);
624 }
625
626 #[test]
627 fn repeat_forever_rolls_cycle_origin() {
628 let instructions: [Instruction<MonotonicCurveLut256>; 1] =
629 [Instruction::hold(10, u16::MAX)];
630 let program = Program::new(MotorKind::Erm, &instructions).repeat_forever();
631 let compiled = CompiledHapticDef::new("demo", program, None);
632 let mut runner =
633 Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
634 runner.start(0);
635
636 let frame = runner.poll(25);
637 assert_eq!(frame.command, DriveCommand::Erm { duty: 255 });
638 assert_eq!(frame.next_transition_ms, Some(30));
639 assert_eq!(frame.instruction_index, Some(0));
640 assert!(!frame.finished);
641 }
642
643 #[test]
644 fn lra_ramp_defaults_to_resonant_frequency() {
645 let instructions = [Instruction::Ramp(Ramp::new(20, 0, u16::MAX, LINEAR))];
646 let program = Program::new(MotorKind::Lra, &instructions);
647 let compiled = CompiledHapticDef::new("demo", program, None);
648 let mut runner =
649 Runner::from_compiled(&compiled, MotorConfig::Lra(LraConfig::new(1023, 240))).unwrap();
650 runner.start(0);
651
652 let frame = runner.poll(10);
653 match frame.command {
654 DriveCommand::Lra {
655 amplitude,
656 frequency_hz,
657 } => {
658 assert!(amplitude > 0);
659 assert_eq!(frequency_hz, 240);
660 }
661 _ => panic!("expected LRA command"),
662 }
663 }
664
665 #[test]
666 fn construction_validates_program_and_motor_kind() {
667 let empty: [Instruction<MonotonicCurveLut256>; 0] = [];
668 let program = Program::new(MotorKind::Erm, &empty);
669 let compiled = CompiledHapticDef::new("empty", program, None);
670 let error =
671 Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap_err();
672 assert_eq!(error, Error::EmptyProgram);
673
674 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(5, 100)];
675 let program = Program::new(MotorKind::Erm, &instructions);
676 let compiled = CompiledHapticDef::new("erm", program, None);
677 let error = Runner::from_compiled(&compiled, MotorConfig::Lra(LraConfig::new(1000, 240)))
678 .unwrap_err();
679 assert_eq!(
680 error,
681 Error::MotorKindMismatch {
682 expected: MotorKind::Erm,
683 got: MotorKind::Lra,
684 }
685 );
686 }
687
688 #[test]
689 fn ergonomic_start_helpers_work() {
690 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(10, 1000)];
691 let program = Program::new(MotorKind::Erm, &instructions);
692 let compiled = CompiledHapticDef::new("demo", program, None);
693 let mut runner =
694 Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
695 let first = runner.poll_or_start(42);
696 assert!(first.is_active());
697 assert_eq!(runner.start_ms(), 42);
698
699 let restarted = runner.start_and_poll(100);
700 assert!(restarted.is_active());
701 assert_eq!(runner.start_ms(), 100);
702
703 runner.stop();
704 let idle = runner.poll(110);
705 assert!(idle.is_idle());
706 }
707
708 #[test]
709 fn from_compiled_helpers_work() {
710 let instructions = [Instruction::Ramp(Ramp::new(20, 0, u16::MAX, LINEAR))];
711 let program = Program::new(MotorKind::Erm, &instructions);
712 let compiled = CompiledHapticDef::new("demo", program, None);
713
714 let mut runner =
715 Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
716 runner.start(10);
717 assert!(runner.is_running());
718 assert_eq!(runner.start_ms(), 10);
719
720 let mut from_compiled =
721 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 20)
722 .unwrap();
723 let frame = from_compiled.poll(25);
724 assert!(frame.is_active());
725 assert_eq!(from_compiled.motor().kind(), MotorKind::Erm);
726 }
727
728 #[test]
729 fn profile_gamma_curve_maps_output_level() {
730 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(20, 32_768)];
731 let program = Program::new(MotorKind::Erm, &instructions);
732 let profile = MotorProfile::new(0, 255, 64, 255, Some(&SQUARE), 2, 1, 8);
733 let compiled = CompiledHapticDef::new("gamma_hold", program, Some(&profile));
734 let mut runner =
735 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
736 .unwrap();
737
738 let frame = runner.poll(0);
739 match frame.command {
740 DriveCommand::Erm { duty } => assert!((60..=66).contains(&duty)),
741 _ => panic!("expected ERM command"),
742 }
743 }
744
745 #[test]
746 fn floor_snaps_low_level_to_off() {
747 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(20, 10_000)];
749 let program = Program::new(MotorKind::Erm, &instructions);
750 let profile = MotorProfile::new(0, 255, 153, 255, None, 2, 1, 8);
751 let compiled = CompiledHapticDef::new("floor_test", program, Some(&profile));
752 let mut runner =
753 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
754 .unwrap();
755
756 let frame = runner.poll(0);
757 assert_eq!(frame.command, DriveCommand::Off);
758 }
759
760 #[test]
761 fn max_level_ceiling_clamps_output() {
762 let instructions: [Instruction<MonotonicCurveLut256>; 1] =
764 [Instruction::hold(20, u16::MAX)];
765 let program = Program::new(MotorKind::Erm, &instructions);
766 let profile = MotorProfile::new(0, 128, 0, 128, None, 2, 1, 8);
767 let compiled = CompiledHapticDef::new("max_test", program, Some(&profile));
768 let mut runner =
769 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(1023)), 0)
770 .unwrap();
771
772 let frame = runner.poll(0);
773 match frame.command {
774 DriveCommand::Erm { duty } => {
775 assert!(duty <= 520, "duty {duty} should be clamped by max_level");
777 assert!(duty > 0, "duty should be non-zero");
778 }
779 _ => panic!("expected ERM command"),
780 }
781 }
782
783 #[test]
784 fn kick_injection_after_floor_snap() {
785 let instructions: [Instruction<MonotonicCurveLut256>; 2] = [
788 Instruction::hold(5, 5_000), Instruction::hold(20, 40_000), ];
791 let program = Program::new(MotorKind::Erm, &instructions);
792 let profile = MotorProfile::new(10, 255, 153, 255, None, 2, 1, 8);
794 let compiled = CompiledHapticDef::new("kick_test", program, Some(&profile));
795 let mut runner =
796 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
797 .unwrap();
798
799 let frame0 = runner.poll(0);
801 assert_eq!(frame0.command, DriveCommand::Off);
802
803 let frame1 = runner.poll(5);
805 match frame1.command {
806 DriveCommand::Erm { duty } => {
807 assert_eq!(duty, 255, "during kick pulse, duty should be at kick level");
809 }
810 _ => panic!("expected ERM command"),
811 }
812
813 let frame2 = runner.poll(16);
815 match frame2.command {
816 DriveCommand::Erm { duty } => {
817 assert!(
819 duty < 200,
820 "after kick expires, duty {duty} should be normal level"
821 );
822 }
823 _ => panic!("expected ERM command"),
824 }
825 }
826
827 #[test]
828 fn kick_level_clamped_to_max() {
829 let instructions: [Instruction<MonotonicCurveLut256>; 2] = [
831 Instruction::hold(5, 5_000), Instruction::hold(20, 40_000), ];
834 let program = Program::new(MotorKind::Erm, &instructions);
835 let profile = MotorProfile::new(10, 255, 64, 128, None, 2, 1, 8);
836 let compiled = CompiledHapticDef::new("kick_max_test", program, Some(&profile));
837 let mut runner =
838 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(1023)), 0)
839 .unwrap();
840
841 let _frame0 = runner.poll(0); let frame1 = runner.poll(5); match frame1.command {
844 DriveCommand::Erm { duty } => {
845 assert!(
847 duty <= 520,
848 "kick duty {duty} should be clamped by max_level"
849 );
850 }
851 _ => panic!("expected ERM command"),
852 }
853 }
854
855 #[test]
856 fn pause_instruction_outputs_off() {
857 let instructions: [Instruction<MonotonicCurveLut256>; 3] = [
858 Instruction::hold(10, u16::MAX),
859 Instruction::Pause { duration_ms: 20 },
860 Instruction::hold(10, u16::MAX),
861 ];
862 let program = Program::new(MotorKind::Erm, &instructions);
863 let compiled = CompiledHapticDef::new("pause_test", program, None);
864 let mut runner =
865 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
866 .unwrap();
867
868 let frame0 = runner.poll(0);
870 assert_eq!(frame0.command, DriveCommand::Erm { duty: 255 });
871 assert_eq!(frame0.instruction_index, Some(0));
872
873 let frame1 = runner.poll(10);
875 assert_eq!(frame1.command, DriveCommand::Off);
876 assert_eq!(frame1.instruction_index, Some(1));
877
878 let frame2 = runner.poll(30);
880 assert_eq!(frame2.command, DriveCommand::Erm { duty: 255 });
881 assert_eq!(frame2.instruction_index, Some(2));
882
883 let frame3 = runner.poll(40);
885 assert!(frame3.finished);
886 assert!(frame3.is_finished());
887 }
888
889 #[test]
890 fn initial_kick_fires_on_startup() {
891 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(50, 40_000)];
893 let program = Program::new(MotorKind::Erm, &instructions);
894 let profile = MotorProfile::new(12, 255, 64, 255, None, 2, 1, 8);
895 let compiled = CompiledHapticDef::new("startup_kick", program, Some(&profile));
896 let mut runner =
897 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
898 .unwrap();
899
900 let frame0 = runner.poll(0);
902 match frame0.command {
903 DriveCommand::Erm { duty } => assert_eq!(duty, 255, "kick should boost to max"),
904 _ => panic!("expected ERM command"),
905 }
906
907 let frame1 = runner.poll(12);
909 match frame1.command {
910 DriveCommand::Erm { duty } => {
911 assert!(duty < 200, "after kick, duty {duty} should be normal level");
912 }
913 _ => panic!("expected ERM command"),
914 }
915 }
916
917 #[test]
918 fn next_transition_ms_reflects_kick_expiry() {
919 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(100, 50_000)];
922 let program = Program::new(MotorKind::Erm, &instructions);
923 let profile = MotorProfile::new(10, 255, 64, 255, None, 2, 1, 8);
924 let compiled = CompiledHapticDef::new("kick_deadline", program, Some(&profile));
925 let mut runner =
926 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
927 .unwrap();
928
929 let frame0 = runner.poll(0);
931 assert_eq!(frame0.next_transition_ms, Some(10));
932
933 let frame1 = runner.poll(10);
935 assert_eq!(frame1.next_transition_ms, Some(100));
936 }
937
938 #[test]
939 fn multi_instruction_locate_iterates_correctly() {
940 let instructions: [Instruction<MonotonicCurveLut256>; 3] = [
941 Instruction::hold(10, 10_000),
942 Instruction::hold(10, 30_000),
943 Instruction::hold(10, u16::MAX),
944 ];
945 let program = Program::new(MotorKind::Erm, &instructions);
946 let compiled = CompiledHapticDef::new("multi", program, None);
947 let mut runner =
948 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
949 .unwrap();
950
951 assert_eq!(runner.poll(5).instruction_index, Some(0));
953 assert_eq!(runner.poll(15).instruction_index, Some(1));
954 assert_eq!(runner.poll(25).instruction_index, Some(2));
955 }
956
957 #[test]
958 fn kick_rearms_after_pause_between_pulses() {
959 let instructions: [Instruction<MonotonicCurveLut256>; 3] = [
961 Instruction::hold(20, 40_000),
962 Instruction::Pause { duration_ms: 15 },
963 Instruction::hold(20, 40_000),
964 ];
965 let program = Program::new(MotorKind::Erm, &instructions);
966 let profile = MotorProfile::new(10, 255, 64, 255, None, 2, 1, 8);
967 let compiled = CompiledHapticDef::new("pause_kick", program, Some(&profile));
968 let mut runner =
969 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
970 .unwrap();
971
972 let first = runner.poll(0);
974 match first.command {
975 DriveCommand::Erm { duty } => assert_eq!(duty, 255, "first pulse should kick"),
976 _ => panic!("expected ERM command"),
977 }
978
979 let after_kick = runner.poll(10);
981 match after_kick.command {
982 DriveCommand::Erm { duty } => {
983 assert!(duty < 200, "after first kick, duty {duty} should be normal");
984 }
985 _ => panic!("expected ERM command"),
986 }
987
988 let pause = runner.poll(20);
990 assert_eq!(pause.command, DriveCommand::Off);
991
992 let second = runner.poll(35);
994 match second.command {
995 DriveCommand::Erm { duty } => {
996 assert_eq!(duty, 255, "second pulse after Pause should kick");
997 }
998 _ => panic!("expected ERM command"),
999 }
1000 }
1001
1002 #[test]
1003 fn lra_hz_to_wakes_between_coarse_amplitude_steps() {
1004 let mut ramp = Ramp::new(1_000, u16::MAX, u16::MAX, LINEAR)
1007 .with_quantization(u16::MAX, Rounding::Nearest)
1008 .with_lra_frequency(100);
1009 ramp.lra_frequency_hz_to = Some(200);
1010 let instructions = [Instruction::Ramp(ramp)];
1011 let program = Program::new(MotorKind::Lra, &instructions);
1012 let compiled = CompiledHapticDef::new("hz_wake", program, None);
1013 let mut runner = Runner::from_compiled_started(
1014 &compiled,
1015 MotorConfig::Lra(LraConfig::new(2047, 240)),
1016 0,
1017 )
1018 .unwrap();
1019
1020 let mut now = 0u32;
1021 let mut last_hz = None;
1022 let mut saw_hz_advance_before_amp_end = false;
1023 let mut polls = 0u32;
1024
1025 while now < 1_000 && polls < 2_000 {
1026 let frame = runner.poll(now);
1027 let hz = match frame.command {
1028 DriveCommand::Lra { frequency_hz, .. } => frequency_hz,
1029 other => panic!("expected LRA command, got {other:?}"),
1030 };
1031
1032 if let Some(prev) = last_hz
1033 && hz > prev
1034 && now < 1_000
1035 {
1036 saw_hz_advance_before_amp_end = true;
1037 }
1038 last_hz = Some(hz);
1039
1040 let Some(next) = frame.next_transition_ms else {
1041 break;
1042 };
1043 if next <= now {
1044 break;
1045 }
1046 now = next;
1047 polls += 1;
1048 }
1049
1050 assert!(
1051 saw_hz_advance_before_amp_end,
1052 "Hz should advance between coarse amplitude steps when following next_transition_ms"
1053 );
1054 assert!(
1055 last_hz.unwrap_or(0) > 100,
1056 "sweep should progress past starting Hz, last={last_hz:?}"
1057 );
1058 }
1059
1060 fn count_wakeups(runner: &mut Runner<'_, MonotonicCurveLut256>, window_ms: u32) -> u32 {
1063 let mut now = 0u32;
1064 let mut wakeups = 0u32;
1065
1066 while now < window_ms && wakeups < 10_000 {
1067 let frame = runner.poll(now);
1068 let Some(next) = frame.next_transition_ms else {
1069 break;
1070 };
1071 if next <= now {
1072 break;
1073 }
1074 now = next;
1075 wakeups += 1;
1076 }
1077
1078 wakeups
1079 }
1080
1081 #[test]
1082 fn pause_with_zero_min_level_does_not_storm_wakeups() {
1083 let instructions: [Instruction<MonotonicCurveLut256>; 2] =
1087 [Instruction::pause(5_000), Instruction::hold(20, 40_000)];
1088 let program = Program::new(MotorKind::Erm, &instructions);
1089 let profile = MotorProfile::new(10, 255, 0, 255, None, 2, 1, 8);
1090 let compiled = CompiledHapticDef::new("storm", program, Some(&profile));
1091 let mut runner =
1092 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
1093 .unwrap();
1094
1095 assert_eq!(
1096 count_wakeups(&mut runner, 5_000),
1097 1,
1098 "a 5s pause should schedule exactly one wake-up at its end"
1099 );
1100 }
1101
1102 #[test]
1103 fn kick_still_fires_after_pause_when_min_level_is_zero() {
1104 let instructions: [Instruction<MonotonicCurveLut256>; 2] =
1106 [Instruction::pause(100), Instruction::hold(50, 40_000)];
1107 let program = Program::new(MotorKind::Erm, &instructions);
1108 let profile = MotorProfile::new(10, 255, 0, 255, None, 2, 1, 8);
1109 let compiled = CompiledHapticDef::new("kick_after_pause", program, Some(&profile));
1110 let mut runner =
1111 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
1112 .unwrap();
1113
1114 assert_eq!(runner.poll(0).command, DriveCommand::Off);
1115 match runner.poll(100).command {
1116 DriveCommand::Erm { duty } => assert_eq!(duty, 255, "pulse after Pause should kick"),
1117 other => panic!("expected ERM command, got {other:?}"),
1118 }
1119 match runner.poll(111).command {
1120 DriveCommand::Erm { duty } => assert!(duty < 200, "kick should expire, got {duty}"),
1121 other => panic!("expected ERM command, got {other:?}"),
1122 }
1123 }
1124
1125 #[test]
1126 fn level_scaling_to_zero_duty_reports_off() {
1127 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(20, 100)];
1130 let program = Program::new(MotorKind::Erm, &instructions);
1131 let compiled = CompiledHapticDef::new("tiny", program, None);
1132 let mut runner =
1133 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
1134 .unwrap();
1135
1136 assert_eq!(runner.poll(0).command, DriveCommand::Off);
1137 }
1138
1139 #[test]
1140 fn long_lra_sweep_does_not_overflow() {
1141 let mut ramp = Ramp::new(20_000_000, u16::MAX, u16::MAX, LINEAR).with_lra_frequency(100);
1143 ramp.lra_frequency_hz_to = Some(300);
1144 let instructions = [Instruction::Ramp(ramp)];
1145 let program = Program::new(MotorKind::Lra, &instructions);
1146 let compiled = CompiledHapticDef::new("long_sweep", program, None);
1147 let mut runner = Runner::from_compiled_started(
1148 &compiled,
1149 MotorConfig::Lra(LraConfig::new(2047, 240)),
1150 0,
1151 )
1152 .unwrap();
1153
1154 match runner.poll(10_000_000).command {
1155 DriveCommand::Lra { frequency_hz, .. } => assert_eq!(frequency_hz, 200),
1156 other => panic!("expected LRA command, got {other:?}"),
1157 }
1158 }
1159
1160 #[test]
1161 fn forever_near_u32_max_keeps_segment_deadlines() {
1162 let instructions: [Instruction<MonotonicCurveLut256>; 1] =
1163 [Instruction::hold(10, u16::MAX)];
1164 let program = Program::new(MotorKind::Erm, &instructions).repeat_forever();
1165 let compiled = CompiledHapticDef::new("near_max", program, None);
1166 let mut runner =
1167 Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
1168 runner.start(0);
1169
1170 let now = (u32::MAX / 10) * 10 + 3;
1172 let frame = runner.poll(now);
1173 assert_eq!(frame.command, DriveCommand::Erm { duty: 255 });
1174 assert_eq!(frame.instruction_index, Some(0));
1175 assert_eq!(frame.next_transition_ms, Some(now.wrapping_add(7)));
1176 assert!(!frame.finished);
1177 }
1178
1179 #[test]
1180 fn forever_continues_after_clock_wraparound() {
1181 let instructions: [Instruction<MonotonicCurveLut256>; 2] =
1182 [Instruction::hold(5, u16::MAX), Instruction::hold(5, 10_000)];
1183 let program = Program::new(MotorKind::Erm, &instructions).repeat_forever();
1184 let compiled = CompiledHapticDef::new("clock_wrap", program, None);
1185 let start = u32::MAX - 2;
1186 let mut runner =
1187 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), start)
1188 .unwrap();
1189
1190 let frame = runner.poll(start.wrapping_add(7));
1191 assert_eq!(frame.instruction_index, Some(1));
1192 assert_eq!(frame.next_transition_ms, Some(start.wrapping_add(10)));
1193 assert!(!frame.finished);
1194 }
1195
1196 #[test]
1197 fn ramp_and_lra_sweep_continue_after_clock_wraparound() {
1198 let mut ramp = Ramp::new(10, 0, u16::MAX, LINEAR).with_lra_frequency(100);
1199 ramp.lra_frequency_hz_to = Some(200);
1200 let instructions = [Instruction::Ramp(ramp)];
1201 let program = Program::new(MotorKind::Lra, &instructions);
1202 let compiled = CompiledHapticDef::new("ramp_clock_wrap", program, None);
1203 let start = u32::MAX - 5;
1204 let mut runner = Runner::from_compiled_started(
1205 &compiled,
1206 MotorConfig::Lra(LraConfig::new(1_000, 200)),
1207 start,
1208 )
1209 .unwrap();
1210
1211 let now = start.wrapping_add(6);
1212 let frame = runner.poll(now);
1213 match frame.command {
1214 DriveCommand::Lra {
1215 amplitude,
1216 frequency_hz,
1217 } => {
1218 assert!(amplitude > 0, "ramp amplitude should advance after wrap");
1219 assert_eq!(frequency_hz, 160);
1220 }
1221 _ => panic!("expected LRA command"),
1222 }
1223 let next = frame.next_transition_ms.unwrap();
1224 assert!(next.wrapping_sub(now) <= 4);
1225 }
1226
1227 #[test]
1228 fn kick_expires_after_clock_wraparound() {
1229 let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(50, 40_000)];
1230 let program = Program::new(MotorKind::Erm, &instructions);
1231 let profile = MotorProfile::new(10, 255, 64, 255, None, 2, 1, 8);
1232 let compiled = CompiledHapticDef::new("kick_clock_wrap", program, Some(&profile));
1233 let start = u32::MAX - 5;
1234 let mut runner =
1235 Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), start)
1236 .unwrap();
1237
1238 let kick = runner.poll(start);
1239 assert_eq!(kick.command, DriveCommand::Erm { duty: 255 });
1240 assert_eq!(kick.next_transition_ms, Some(start.wrapping_add(10)));
1241
1242 let after_kick = runner.poll(start.wrapping_add(10));
1243 match after_kick.command {
1244 DriveCommand::Erm { duty } => {
1245 assert!(duty < 200, "after kick, duty {duty} should be normal level");
1246 }
1247 _ => panic!("expected ERM command"),
1248 }
1249 }
1250
1251 #[test]
1252 fn count_near_u32_max_keeps_segment_deadlines() {
1253 let instructions: [Instruction<MonotonicCurveLut256>; 2] =
1254 [Instruction::hold(5, u16::MAX), Instruction::hold(5, 10_000)];
1255 let program = Program::new(MotorKind::Erm, &instructions)
1257 .with_loop_mode(LoopMode::Count(u32::MAX / 10 + 100));
1258 let compiled = CompiledHapticDef::new("count_near_max", program, None);
1259 let mut runner =
1260 Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
1261 runner.start(0);
1262
1263 let now = u32::MAX - 8; let frame = runner.poll(now);
1266 assert_eq!(frame.instruction_index, Some(1));
1267 assert_eq!(frame.next_transition_ms, Some(now.wrapping_add(3)));
1268 }
1269}