Skip to main content

ph_haptics/
runtime.rs

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/// One output frame produced by [`Runner::poll`].
8#[derive(Copy, Clone, Debug, Eq, PartialEq)]
9pub struct Frame {
10    /// Driver command for the current time.
11    pub command: DriveCommand,
12    /// Earliest wrapping `u32` clock value at which output may change.
13    ///
14    /// While this is `Some(deadline)`, [`Self::command`] is guaranteed not to
15    /// change before `deadline`, so the caller may sleep until then. Compare or
16    /// wait using wrapping arithmetic rather than absolute `<` / `>`. A deadline
17    /// equal to the current poll time requests another poll at that same time to
18    /// drain an immediate control-flow transition. `None` means there is no
19    /// active or pending transition because the runner is idle or has finished.
20    pub next_transition_ms: Option<u32>,
21    /// Active instruction index in the current cycle.
22    pub instruction_index: Option<usize>,
23    /// `true` only when a finite program has completed.
24    ///
25    /// This is edge-triggered, not a latched state: it is set on the single
26    /// [`Runner::poll`] that observes the end of the program, which also stops
27    /// the runner. Later polls return an idle frame with `finished == false`.
28    /// Act on completion when you first see it rather than polling for it.
29    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    /// Returns `true` for an ordinary idle frame with no active instruction.
43    ///
44    /// The one-shot completion frame is not considered idle; it instead reports
45    /// [`Self::is_finished`].
46    pub const fn is_idle(&self) -> bool {
47        self.instruction_index.is_none() && self.next_transition_ms.is_none() && !self.finished
48    }
49
50    /// Returns `true` when an instruction is active.
51    pub const fn is_active(&self) -> bool {
52        self.instruction_index.is_some() && !self.finished
53    }
54
55    /// Returns `true` when a finite program reached the end.
56    pub const fn is_finished(&self) -> bool {
57        self.finished
58    }
59
60    /// Convenience getter for driver command.
61    pub const fn command(&self) -> DriveCommand {
62        self.command
63    }
64
65    /// Return the earliest wrapping clock value at which output may change.
66    ///
67    /// See [`Frame::next_transition_ms`] for the sleep and same-timestamp polling
68    /// contract.
69    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/// Executes a [`Program`] against an ERM or LRA motor configuration.
88///
89/// The caller supplies a free-running wrapping `u32` millisecond clock to
90/// [`Runner::poll`] and applies the returned [`DriveCommand`]. Polling may then
91/// pause until [`Frame::next_transition_ms`]; fixed-rate polling is unnecessary.
92#[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    /// Minimum level below which output snaps to off (from profile min_run_frac).
103    min_level: u16,
104    /// Maximum level ceiling (from profile max_frac).
105    max_level: u16,
106    /// Level to drive during a kick pulse (from profile kick_frac, clamped to max).
107    kick_level: u16,
108    /// Kick pulse duration in ms (from profile kick_ms).
109    kick_duration_ms: u32,
110    /// Set when output was floor-snapped to off (motor has stalled).
111    needs_kick: bool,
112    /// Whether a kick pulse is currently active.
113    kick_active: bool,
114    /// Absolute time at which an active kick pulse ends.
115    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    /// Start playback at `now_ms`.
169    pub fn start(&mut self, now_ms: u32) {
170        self.start_ms = now_ms;
171        self.running = true;
172        // Motor is at rest — request a kick on the first above-min poll.
173        self.needs_kick = self.kick_duration_ms > 0;
174        self.kick_active = false;
175        self.kick_end_ms = 0;
176    }
177
178    /// Restart playback from the beginning at `now_ms`.
179    pub fn restart(&mut self, now_ms: u32) {
180        self.start(now_ms);
181    }
182
183    /// Start playback at `now_ms` and return the first frame.
184    pub fn start_and_poll(&mut self, now_ms: u32) -> Frame {
185        self.start(now_ms);
186        self.poll(now_ms)
187    }
188
189    /// Poll current output; if not running, start playback at `now_ms` first.
190    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    /// Stop playback.
198    pub fn stop(&mut self) {
199        self.running = false;
200    }
201
202    /// Whether the program is currently running.
203    pub const fn is_running(&self) -> bool {
204        self.running
205    }
206
207    /// Motor configuration assigned to this runner.
208    pub const fn motor(&self) -> MotorConfig {
209        self.motor
210    }
211
212    /// Current start timestamp used as cycle origin.
213    pub const fn start_ms(&self) -> u32 {
214        self.start_ms
215    }
216
217    /// Evaluate output for the wrapping millisecond clock value `now_ms`.
218    ///
219    /// The returned frame's deadline is the earliest time the command may
220    /// change. Callers should wait with wrapping arithmetic and poll again at
221    /// that deadline. If the deadline equals `now_ms`, poll again immediately to
222    /// drain the control-flow transition. Completion is edge-triggered; see
223    /// [`Frame::finished`].
224    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        // Floor enforcement in logical (pre-gamma) space. Codegen clamps authored
247        // levels *up* to `min_run_frac` so they survive this check; anything still
248        // below the floor here cannot spin the motor, so it snaps to full off.
249        if self.min_level > 0 && level > 0 && level < self.min_level {
250            level = 0;
251        }
252
253        // Re-arm kick whenever the motor is at rest (Pause, hold 0, or floor-snap).
254        if level == 0 && self.kick_duration_ms > 0 {
255            self.needs_kick = true;
256            self.kick_active = false;
257        }
258
259        // Kick injection: when recovering from rest/stall, overdrive the motor
260        // for kick_duration_ms so it overcomes static friction. The `level > 0`
261        // guard matters when `min_level == 0`: without it a resting segment would
262        // both arm and consume the kick on every poll, pushing `kick_end_ms`
263        // forward forever and forcing a wake-up every `kick_ms` through a Pause.
264        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        // Expiry uses remaining-time half-range so kick_end_ms after a u32 wrap
270        // still terminates; plain `now < kick_end` would keep the kick forever.
271        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        // Ceiling enforcement in logical (pre-gamma) space.
281        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        // Wake at kick expiry if it precedes the instruction deadline.
288        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    /// Build a runner directly from a generated [`CompiledHapticDef`].
306    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    /// Build from a generated [`CompiledHapticDef`] and start at `now_ms`.
314    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    // Wrap-safe rebase: derive the active cycle origin from conceptual now and
353    // in-cycle phase so long Forever/Count runs do not clamp at u32::MAX.
354    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            // ph-curves ≥ 0.2.1 TicklessSchedule is wrap-safe on free-running
389            // u32 clocks, so feed wall-clock segment_start / now directly.
390            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            // LRA Hz lerp stays segment-relative. Merge wakeups by converting
403            // the amplitude deadline back to an offset before .min() — absolute
404            // .min() on wrapped timestamps is wrong — then map to wall-clock.
405            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    // Widened to u64: `delta * t` overflows u32 for long sweeps (a 200 Hz delta
441    // over a ramp of more than ~6 hours), which would panic in debug builds.
442    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
454/// Segment-relative ms of the next quantized LRA Hz change under linear lerp, if any.
455fn 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    // Widened to u64 for the same overflow reason as `lerp_lra_hz`.
466    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    // Guard closed-form rounding: advance until the quantized Hz actually changes.
494    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        // min_run_frac=153 (~60%), so levels below ~60% of u16::MAX should snap to 0.
748        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        // max_frac=128 (~50%), hold at u16::MAX should be clamped down.
763        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                // max_frac=128 → max_level ≈ 32896.  scale(32896, 1023) ≈ 514
776                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        // First instruction: low level that gets floor-snapped → needs_kick.
786        // Second instruction: level above min → kick should boost it.
787        let instructions: [Instruction<MonotonicCurveLut256>; 2] = [
788            Instruction::hold(5, 5_000),   // below min_run → snapped to off
789            Instruction::hold(20, 40_000), // above min_run → should get kick
790        ];
791        let program = Program::new(MotorKind::Erm, &instructions);
792        // kick_ms=10, kick_frac=255, min_run_frac=153 (~60%), max_frac=255
793        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        // t=0: first hold at 5000, below floor → Off + needs_kick set
800        let frame0 = runner.poll(0);
801        assert_eq!(frame0.command, DriveCommand::Off);
802
803        // t=5: second hold at 40000, above floor → kick active, so level >= kick_level
804        let frame1 = runner.poll(5);
805        match frame1.command {
806            DriveCommand::Erm { duty } => {
807                // kick_frac=255 → kick_level=65535 → scale(65535,255)=255
808                assert_eq!(duty, 255, "during kick pulse, duty should be at kick level");
809            }
810            _ => panic!("expected ERM command"),
811        }
812
813        // t=16: kick expired (kick_ms=10, started at t=5, ends at t=15)
814        let frame2 = runner.poll(16);
815        match frame2.command {
816            DriveCommand::Erm { duty } => {
817                // Normal level: 40000 → scale(40000, 255) ≈ 155
818                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        // kick_frac=255 but max_frac=128 → kick_level should be clamped to max_level.
830        let instructions: [Instruction<MonotonicCurveLut256>; 2] = [
831            Instruction::hold(5, 5_000),   // below min → snapped to off
832            Instruction::hold(20, 40_000), // above min → gets kick, but kick clamped to max
833        ];
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); // floor-snapped
842        let frame1 = runner.poll(5); // kick active
843        match frame1.command {
844            DriveCommand::Erm { duty } => {
845                // max_frac=128 → max_level ≈ 32896.  scale(32896, 1023) ≈ 514
846                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        // t=0: first hold
869        let frame0 = runner.poll(0);
870        assert_eq!(frame0.command, DriveCommand::Erm { duty: 255 });
871        assert_eq!(frame0.instruction_index, Some(0));
872
873        // t=10: pause
874        let frame1 = runner.poll(10);
875        assert_eq!(frame1.command, DriveCommand::Off);
876        assert_eq!(frame1.instruction_index, Some(1));
877
878        // t=30: third hold
879        let frame2 = runner.poll(30);
880        assert_eq!(frame2.command, DriveCommand::Erm { duty: 255 });
881        assert_eq!(frame2.instruction_index, Some(2));
882
883        // t=40: finished
884        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        // With needs_kick=true at start, first poll above min_level triggers kick.
892        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        // t=0: kick fires (needs_kick=true from start, level above min)
901        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        // t=12: kick expired, normal level
908        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        // Hold for 100ms at a level above min. Kick should expire at kick_ms,
920        // so next_transition_ms should be kick_end_ms rather than instruction end.
921        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        // t=0: kick fires, kick_end_ms=10. next_transition = min(100, 10) = 10
930        let frame0 = runner.poll(0);
931        assert_eq!(frame0.next_transition_ms, Some(10));
932
933        // t=10: kick expired, next_transition = instruction end = 100
934        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        // Each hold is 10ms, so t=5→idx0, t=15→idx1, t=25→idx2
952        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        // Two above-min holds with a Pause between them. Kick must fire on both.
960        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        // t=0: first pulse gets startup kick
973        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        // t=10: kick expired on first pulse
980        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        // t=20: pause → Off and re-arm
989        let pause = runner.poll(20);
990        assert_eq!(pause.command, DriveCommand::Off);
991
992        // t=35: second pulse must kick again
993        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        // Flat amplitude (tickless amplitude deadline = segment end only) with a
1005        // wide Hz sweep: polling solely at next_transition_ms must still advance Hz.
1006        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    /// Count the wake-ups a caller performs when it follows `next_transition_ms`
1061    /// exactly, over `window_ms` starting at t=0.
1062    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        // Regression: with min_run_frac == 0 the kick used to arm and fire on
1084        // every poll of a resting segment, pushing kick_end_ms forward and
1085        // forcing a wake-up every kick_ms for the whole Pause.
1086        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        // The storm fix must not cost us the kick on the pulse after the Pause.
1105        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        // Regression: a non-zero level that scales to duty 0 used to emit
1128        // `Erm { duty: 0 }`, which reads as "driver on" to consumers.
1129        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        // `delta * t` overflowed u32 past ~6 hours of ramp; u64 math keeps it sane.
1142        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        // 3ms into a 10ms cycle near the end of the u32 timeline.
1171        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        // Enough repeats that total duration saturates well past u32::MAX.
1256        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        // 7ms into a 10ms cycle → second hold, 3ms remaining (stay below u32::MAX).
1264        let now = u32::MAX - 8; // MAX ≡ 5 (mod 10) ⇒ now ≡ 7 (mod 10)
1265        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}