use crate::compiled::CompiledHapticDef;
use crate::dsl::{Instruction, LoopMode, Program};
use crate::error::Error;
use crate::motor::{DriveCommand, MotorConfig, MotorProfile, frac_to_level};
use ph_curves::{MonotonicCurve, MonotonicCurveLut256, Tickless};
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct Frame {
pub command: DriveCommand,
pub next_transition_ms: Option<u32>,
pub instruction_index: Option<usize>,
pub finished: bool,
}
impl Frame {
const fn idle() -> Self {
Self {
command: DriveCommand::Off,
next_transition_ms: None,
instruction_index: None,
finished: false,
}
}
pub const fn is_idle(&self) -> bool {
self.instruction_index.is_none() && self.next_transition_ms.is_none() && !self.finished
}
pub const fn is_active(&self) -> bool {
self.instruction_index.is_some() && !self.finished
}
pub const fn is_finished(&self) -> bool {
self.finished
}
pub const fn command(&self) -> DriveCommand {
self.command
}
pub const fn next_transition_ms(&self) -> Option<u32> {
self.next_transition_ms
}
}
impl Default for Frame {
fn default() -> Self {
Self::idle()
}
}
#[derive(Copy, Clone, Debug)]
struct Located<'a, C> {
index: usize,
instruction: &'a Instruction<C>,
segment_start_ms: u32,
}
#[derive(Debug)]
pub struct Runner<'a, C = MonotonicCurveLut256>
where
C: MonotonicCurve<u8, u8> + Copy,
{
program: &'a Program<'a, C>,
gamma_curve: Option<&'a MonotonicCurveLut256>,
motor: MotorConfig,
start_ms: u32,
running: bool,
min_level: u16,
max_level: u16,
kick_level: u16,
kick_duration_ms: u32,
needs_kick: bool,
kick_active: bool,
kick_end_ms: u32,
}
impl<'a, C> Runner<'a, C>
where
C: MonotonicCurve<u8, u8> + Copy,
{
fn build(
program: &'a Program<'a, C>,
profile: Option<&'a MotorProfile>,
motor: MotorConfig,
) -> Result<Self, Error> {
if program.is_empty() {
return Err(Error::EmptyProgram);
}
if program.motor() != motor.kind() {
return Err(Error::MotorKindMismatch {
expected: program.motor(),
got: motor.kind(),
});
}
let (min_level, max_level, kick_level, kick_duration_ms) = match profile {
Some(p) => {
let max = frac_to_level(p.max_frac);
let kick = frac_to_level(p.kick_frac).min(max);
(
frac_to_level(p.min_run_frac),
max,
kick,
u32::from(p.kick_ms),
)
}
None => (0, 0, 0, 0),
};
Ok(Self {
program,
gamma_curve: profile.and_then(|profile| profile.gamma_curve),
motor,
start_ms: 0,
running: false,
min_level,
max_level,
kick_level,
kick_duration_ms,
needs_kick: false,
kick_active: false,
kick_end_ms: 0,
})
}
pub fn start(&mut self, now_ms: u32) {
self.start_ms = now_ms;
self.running = true;
self.needs_kick = self.kick_duration_ms > 0;
self.kick_active = false;
self.kick_end_ms = 0;
}
pub fn restart(&mut self, now_ms: u32) {
self.start(now_ms);
}
pub fn start_and_poll(&mut self, now_ms: u32) -> Frame {
self.start(now_ms);
self.poll(now_ms)
}
pub fn poll_or_start(&mut self, now_ms: u32) -> Frame {
if !self.running {
self.start(now_ms);
}
self.poll(now_ms)
}
pub fn stop(&mut self) {
self.running = false;
}
pub const fn is_running(&self) -> bool {
self.running
}
pub const fn motor(&self) -> MotorConfig {
self.motor
}
pub const fn start_ms(&self) -> u32 {
self.start_ms
}
pub fn poll(&mut self, now_ms: u32) -> Frame {
if !self.running {
return Frame::idle();
}
let elapsed_ms = now_ms.wrapping_sub(self.start_ms);
let located = locate_instruction(self.program, self.start_ms, elapsed_ms);
let Some(located) = located else {
self.running = false;
return Frame {
command: DriveCommand::Off,
next_transition_ms: None,
instruction_index: None,
finished: true,
};
};
let (level, lra_frequency_hz, next_transition_ms) =
eval_instruction(located.instruction, located.segment_start_ms, now_ms);
let mut level = level;
if self.min_level > 0 && level > 0 && level < self.min_level {
level = 0;
}
if level == 0 && self.kick_duration_ms > 0 {
self.needs_kick = true;
self.kick_active = false;
}
if self.needs_kick && level > 0 && level >= self.min_level {
self.needs_kick = false;
self.kick_active = true;
self.kick_end_ms = now_ms.wrapping_add(self.kick_duration_ms);
}
let kick_remaining_ms = self.kick_end_ms.wrapping_sub(now_ms);
if self.kick_active && (kick_remaining_ms == 0 || kick_remaining_ms > i32::MAX as u32) {
self.kick_active = false;
}
let kick_active = self.kick_active;
if kick_active && level > 0 {
level = level.max(self.kick_level);
}
if self.max_level > 0 && level > self.max_level {
level = self.max_level;
}
let level = apply_gamma(level, self.gamma_curve);
let instruction_remaining_ms = next_transition_ms.wrapping_sub(now_ms);
let next_transition_ms = if kick_active && kick_remaining_ms < instruction_remaining_ms {
self.kick_end_ms
} else {
next_transition_ms
};
Frame {
command: self.motor.drive(level, lra_frequency_hz),
next_transition_ms: Some(next_transition_ms),
instruction_index: Some(located.index),
finished: false,
}
}
}
impl<'a> Runner<'a, MonotonicCurveLut256> {
pub(crate) fn from_compiled(
compiled: &'a CompiledHapticDef<'a>,
motor: MotorConfig,
) -> Result<Self, Error> {
Self::build(&compiled.program, compiled.profile, motor)
}
pub(crate) fn from_compiled_started(
compiled: &'a CompiledHapticDef<'a>,
motor: MotorConfig,
now_ms: u32,
) -> Result<Self, Error> {
let mut runner = Self::build(&compiled.program, compiled.profile, motor)?;
runner.start(now_ms);
Ok(runner)
}
}
fn locate_instruction<'a, C>(
program: &'a Program<'a, C>,
start_ms: u32,
elapsed_ms: u32,
) -> Option<Located<'a, C>> {
let cycle_duration = program.total_duration_ms();
if cycle_duration == 0 {
return None;
}
let cycle_elapsed = match program.loop_mode() {
LoopMode::Once => {
if elapsed_ms >= cycle_duration {
return None;
}
elapsed_ms
}
LoopMode::Forever => elapsed_ms % cycle_duration,
LoopMode::Count(n) => {
let total = cycle_duration.saturating_mul(n);
if elapsed_ms >= total {
return None;
}
elapsed_ms % cycle_duration
}
};
let now_ms = start_ms.wrapping_add(elapsed_ms);
let cycle_origin = now_ms.wrapping_sub(cycle_elapsed);
let instructions = program.instructions();
let mut index = 0usize;
let mut offset = 0u32;
while index < instructions.len() {
let instruction = &instructions[index];
let next_offset = offset.saturating_add(instruction.duration_ms());
if cycle_elapsed < next_offset {
return Some(Located {
index,
instruction,
segment_start_ms: cycle_origin.wrapping_add(offset),
});
}
offset = next_offset;
index += 1;
}
None
}
fn eval_instruction<C>(
instruction: &Instruction<C>,
segment_start_ms: u32,
now_ms: u32,
) -> (u16, Option<u16>, u32)
where
C: MonotonicCurve<u8, u8> + Copy,
{
match instruction {
Instruction::Ramp(ramp) => {
let elapsed = now_ms.wrapping_sub(segment_start_ms).min(ramp.duration_ms);
let schedule = ramp.curve.tickless_schedule(
segment_start_ms,
ramp.duration_ms,
ramp.from,
ramp.to,
ramp.step,
ramp.rounding,
ramp.min_dt_ms,
);
let deadline = schedule.next_deadline(now_ms);
let amp_offset = deadline.deadline_ms.wrapping_sub(segment_start_ms);
let (lra_freq, next_transition_offset_ms) =
match (ramp.lra_frequency_hz, ramp.lra_frequency_hz_to) {
(Some(from_hz), Some(to_hz)) => {
let hz = lerp_lra_hz(from_hz, to_hz, elapsed, ramp.duration_ms);
let hz_deadline =
next_lra_hz_change_offset_ms(ramp.duration_ms, from_hz, to_hz, elapsed);
let next = match hz_deadline {
Some(hz_offset_ms) => amp_offset.min(hz_offset_ms),
None => amp_offset,
};
(Some(hz), next)
}
(freq, _) => (freq, amp_offset),
};
let next_transition_ms = segment_start_ms.wrapping_add(next_transition_offset_ms);
(deadline.current_val, lra_freq, next_transition_ms)
}
Instruction::Hold {
duration_ms,
level,
lra_frequency_hz,
} => (
*level,
*lra_frequency_hz,
segment_start_ms.wrapping_add(*duration_ms),
),
Instruction::Pause { duration_ms } => {
(0, None, segment_start_ms.wrapping_add(*duration_ms))
}
}
}
fn lerp_lra_hz(from_hz: u16, to_hz: u16, t_ms: u32, duration_ms: u32) -> u16 {
let from = u64::from(from_hz);
let to = u64::from(to_hz);
let dur = u64::from(duration_ms.max(1));
let t = u64::from(t_ms.min(duration_ms));
let hz = if to >= from {
from + (to - from) * t / dur
} else {
from - (from - to) * t / dur
};
hz as u16
}
fn next_lra_hz_change_offset_ms(
duration_ms: u32,
from_hz: u16,
to_hz: u16,
elapsed_ms: u32,
) -> Option<u32> {
if from_hz == to_hz || duration_ms == 0 || elapsed_ms >= duration_ms {
return None;
}
let from = u64::from(from_hz);
let to = u64::from(to_hz);
let current = u64::from(lerp_lra_hz(from_hz, to_hz, elapsed_ms, duration_ms));
let dur = u64::from(duration_ms);
let t = if to >= from {
let delta = to - from;
if delta == 0 || current >= to {
return None;
}
let need = current + 1 - from;
(need * dur).div_ceil(delta)
} else {
let delta = from - to;
if delta == 0 || current <= to {
return None;
}
let need = from - current + 1;
(need * dur).div_ceil(delta)
};
let t = t.max(u64::from(elapsed_ms) + 1);
if t > dur {
return None;
}
let mut t = t as u32;
let current_hz = current as u16;
while t <= duration_ms {
if lerp_lra_hz(from_hz, to_hz, t, duration_ms) != current_hz {
return Some(t);
}
t = t.saturating_add(1);
}
None
}
fn apply_gamma(level: u16, gamma_curve: Option<&MonotonicCurveLut256>) -> u16 {
if level == 0 {
return 0;
}
let Some(curve) = gamma_curve else {
return level;
};
let index = ((u32::from(level) * 255) + (u32::from(u16::MAX) / 2)) / u32::from(u16::MAX);
let mapped = u32::from(curve.fwd_lut()[index as usize]);
(((mapped * u32::from(u16::MAX)) + 127) / 255) as u16
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compiled::CompiledHapticDef;
use crate::dsl::{Instruction, LoopMode, Program, Ramp};
use crate::motor::{DriveCommand, ErmConfig, LraConfig, MotorConfig, MotorKind, MotorProfile};
use ph_curves::{MonotonicCurveLut256, Rounding};
const fn linear_lut() -> [u8; 256] {
let mut lut = [0u8; 256];
let mut index = 0usize;
while index < lut.len() {
lut[index] = index as u8;
index += 1;
}
lut
}
const fn square_lut() -> [u8; 256] {
let mut lut = [0u8; 256];
let mut index = 0usize;
while index < lut.len() {
let x = index as u32;
let y = ((x * x) + 127) / 255;
lut[index] = y as u8;
index += 1;
}
lut
}
const fn monotonic_inv_lut(fwd: &[u8; 256]) -> [u8; 256] {
let mut inv = [0u8; 256];
let mut out = 0usize;
while out < inv.len() {
let mut input = 0usize;
while input < fwd.len() && (fwd[input] as usize) < out {
input += 1;
}
inv[out] = input as u8;
out += 1;
}
inv
}
static LINEAR_FWD: [u8; 256] = linear_lut();
static LINEAR_INV: [u8; 256] = linear_lut();
const LINEAR: MonotonicCurveLut256 = MonotonicCurveLut256::new(&LINEAR_FWD, &LINEAR_INV);
static SQUARE_FWD: [u8; 256] = square_lut();
static SQUARE_INV: [u8; 256] = monotonic_inv_lut(&SQUARE_FWD);
const SQUARE: MonotonicCurveLut256 = MonotonicCurveLut256::new(&SQUARE_FWD, &SQUARE_INV);
#[test]
fn erm_ramp_is_shaped_and_finishes() {
let instructions = [Instruction::Ramp(Ramp::new(100, 0, u16::MAX, LINEAR))];
let program = Program::new(MotorKind::Erm, &instructions);
let compiled = CompiledHapticDef::new("demo", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
runner.start(1_000);
let start = runner.poll(1_000);
assert_eq!(start.command, DriveCommand::Off);
assert_eq!(start.instruction_index, Some(0));
let mid = runner.poll(1_050);
match mid.command {
DriveCommand::Erm { duty } => assert!((120..=136).contains(&duty)),
_ => panic!("expected ERM command"),
}
assert_eq!(mid.instruction_index, Some(0));
assert!(mid.next_transition_ms.is_some());
assert!(!mid.finished);
let finished = runner.poll(1_101);
assert_eq!(finished.command, DriveCommand::Off);
assert_eq!(finished.next_transition_ms, None);
assert_eq!(finished.instruction_index, None);
assert!(finished.finished);
assert!(!runner.is_running());
}
#[test]
fn lra_hold_uses_frequency_override() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] =
[Instruction::hold_with_lra_frequency(20, u16::MAX, 190)];
let program = Program::new(MotorKind::Lra, &instructions);
let compiled = CompiledHapticDef::new("demo", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Lra(LraConfig::new(2047, 235))).unwrap();
runner.start(0);
let frame = runner.poll(0);
assert_eq!(
frame.command,
DriveCommand::Lra {
amplitude: 2047,
frequency_hz: 190
}
);
assert_eq!(frame.next_transition_ms, Some(20));
assert!(!frame.finished);
}
#[test]
fn repeat_forever_rolls_cycle_origin() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] =
[Instruction::hold(10, u16::MAX)];
let program = Program::new(MotorKind::Erm, &instructions).repeat_forever();
let compiled = CompiledHapticDef::new("demo", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
runner.start(0);
let frame = runner.poll(25);
assert_eq!(frame.command, DriveCommand::Erm { duty: 255 });
assert_eq!(frame.next_transition_ms, Some(30));
assert_eq!(frame.instruction_index, Some(0));
assert!(!frame.finished);
}
#[test]
fn lra_ramp_defaults_to_resonant_frequency() {
let instructions = [Instruction::Ramp(Ramp::new(20, 0, u16::MAX, LINEAR))];
let program = Program::new(MotorKind::Lra, &instructions);
let compiled = CompiledHapticDef::new("demo", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Lra(LraConfig::new(1023, 240))).unwrap();
runner.start(0);
let frame = runner.poll(10);
match frame.command {
DriveCommand::Lra {
amplitude,
frequency_hz,
} => {
assert!(amplitude > 0);
assert_eq!(frequency_hz, 240);
}
_ => panic!("expected LRA command"),
}
}
#[test]
fn construction_validates_program_and_motor_kind() {
let empty: [Instruction<MonotonicCurveLut256>; 0] = [];
let program = Program::new(MotorKind::Erm, &empty);
let compiled = CompiledHapticDef::new("empty", program, None);
let error =
Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap_err();
assert_eq!(error, Error::EmptyProgram);
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(5, 100)];
let program = Program::new(MotorKind::Erm, &instructions);
let compiled = CompiledHapticDef::new("erm", program, None);
let error = Runner::from_compiled(&compiled, MotorConfig::Lra(LraConfig::new(1000, 240)))
.unwrap_err();
assert_eq!(
error,
Error::MotorKindMismatch {
expected: MotorKind::Erm,
got: MotorKind::Lra,
}
);
}
#[test]
fn ergonomic_start_helpers_work() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(10, 1000)];
let program = Program::new(MotorKind::Erm, &instructions);
let compiled = CompiledHapticDef::new("demo", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
let first = runner.poll_or_start(42);
assert!(first.is_active());
assert_eq!(runner.start_ms(), 42);
let restarted = runner.start_and_poll(100);
assert!(restarted.is_active());
assert_eq!(runner.start_ms(), 100);
runner.stop();
let idle = runner.poll(110);
assert!(idle.is_idle());
}
#[test]
fn from_compiled_helpers_work() {
let instructions = [Instruction::Ramp(Ramp::new(20, 0, u16::MAX, LINEAR))];
let program = Program::new(MotorKind::Erm, &instructions);
let compiled = CompiledHapticDef::new("demo", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
runner.start(10);
assert!(runner.is_running());
assert_eq!(runner.start_ms(), 10);
let mut from_compiled =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 20)
.unwrap();
let frame = from_compiled.poll(25);
assert!(frame.is_active());
assert_eq!(from_compiled.motor().kind(), MotorKind::Erm);
}
#[test]
fn profile_gamma_curve_maps_output_level() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(20, 32_768)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(0, 255, 64, 255, Some(&SQUARE), 2, 1, 8);
let compiled = CompiledHapticDef::new("gamma_hold", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let frame = runner.poll(0);
match frame.command {
DriveCommand::Erm { duty } => assert!((60..=66).contains(&duty)),
_ => panic!("expected ERM command"),
}
}
#[test]
fn floor_snaps_low_level_to_off() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(20, 10_000)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(0, 255, 153, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("floor_test", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let frame = runner.poll(0);
assert_eq!(frame.command, DriveCommand::Off);
}
#[test]
fn max_level_ceiling_clamps_output() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] =
[Instruction::hold(20, u16::MAX)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(0, 128, 0, 128, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("max_test", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(1023)), 0)
.unwrap();
let frame = runner.poll(0);
match frame.command {
DriveCommand::Erm { duty } => {
assert!(duty <= 520, "duty {duty} should be clamped by max_level");
assert!(duty > 0, "duty should be non-zero");
}
_ => panic!("expected ERM command"),
}
}
#[test]
fn kick_injection_after_floor_snap() {
let instructions: [Instruction<MonotonicCurveLut256>; 2] = [
Instruction::hold(5, 5_000), Instruction::hold(20, 40_000), ];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(10, 255, 153, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("kick_test", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let frame0 = runner.poll(0);
assert_eq!(frame0.command, DriveCommand::Off);
let frame1 = runner.poll(5);
match frame1.command {
DriveCommand::Erm { duty } => {
assert_eq!(duty, 255, "during kick pulse, duty should be at kick level");
}
_ => panic!("expected ERM command"),
}
let frame2 = runner.poll(16);
match frame2.command {
DriveCommand::Erm { duty } => {
assert!(
duty < 200,
"after kick expires, duty {duty} should be normal level"
);
}
_ => panic!("expected ERM command"),
}
}
#[test]
fn kick_level_clamped_to_max() {
let instructions: [Instruction<MonotonicCurveLut256>; 2] = [
Instruction::hold(5, 5_000), Instruction::hold(20, 40_000), ];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(10, 255, 64, 128, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("kick_max_test", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(1023)), 0)
.unwrap();
let _frame0 = runner.poll(0); let frame1 = runner.poll(5); match frame1.command {
DriveCommand::Erm { duty } => {
assert!(
duty <= 520,
"kick duty {duty} should be clamped by max_level"
);
}
_ => panic!("expected ERM command"),
}
}
#[test]
fn pause_instruction_outputs_off() {
let instructions: [Instruction<MonotonicCurveLut256>; 3] = [
Instruction::hold(10, u16::MAX),
Instruction::Pause { duration_ms: 20 },
Instruction::hold(10, u16::MAX),
];
let program = Program::new(MotorKind::Erm, &instructions);
let compiled = CompiledHapticDef::new("pause_test", program, None);
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let frame0 = runner.poll(0);
assert_eq!(frame0.command, DriveCommand::Erm { duty: 255 });
assert_eq!(frame0.instruction_index, Some(0));
let frame1 = runner.poll(10);
assert_eq!(frame1.command, DriveCommand::Off);
assert_eq!(frame1.instruction_index, Some(1));
let frame2 = runner.poll(30);
assert_eq!(frame2.command, DriveCommand::Erm { duty: 255 });
assert_eq!(frame2.instruction_index, Some(2));
let frame3 = runner.poll(40);
assert!(frame3.finished);
assert!(frame3.is_finished());
}
#[test]
fn initial_kick_fires_on_startup() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(50, 40_000)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(12, 255, 64, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("startup_kick", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let frame0 = runner.poll(0);
match frame0.command {
DriveCommand::Erm { duty } => assert_eq!(duty, 255, "kick should boost to max"),
_ => panic!("expected ERM command"),
}
let frame1 = runner.poll(12);
match frame1.command {
DriveCommand::Erm { duty } => {
assert!(duty < 200, "after kick, duty {duty} should be normal level");
}
_ => panic!("expected ERM command"),
}
}
#[test]
fn next_transition_ms_reflects_kick_expiry() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(100, 50_000)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(10, 255, 64, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("kick_deadline", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let frame0 = runner.poll(0);
assert_eq!(frame0.next_transition_ms, Some(10));
let frame1 = runner.poll(10);
assert_eq!(frame1.next_transition_ms, Some(100));
}
#[test]
fn multi_instruction_locate_iterates_correctly() {
let instructions: [Instruction<MonotonicCurveLut256>; 3] = [
Instruction::hold(10, 10_000),
Instruction::hold(10, 30_000),
Instruction::hold(10, u16::MAX),
];
let program = Program::new(MotorKind::Erm, &instructions);
let compiled = CompiledHapticDef::new("multi", program, None);
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
assert_eq!(runner.poll(5).instruction_index, Some(0));
assert_eq!(runner.poll(15).instruction_index, Some(1));
assert_eq!(runner.poll(25).instruction_index, Some(2));
}
#[test]
fn kick_rearms_after_pause_between_pulses() {
let instructions: [Instruction<MonotonicCurveLut256>; 3] = [
Instruction::hold(20, 40_000),
Instruction::Pause { duration_ms: 15 },
Instruction::hold(20, 40_000),
];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(10, 255, 64, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("pause_kick", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
let first = runner.poll(0);
match first.command {
DriveCommand::Erm { duty } => assert_eq!(duty, 255, "first pulse should kick"),
_ => panic!("expected ERM command"),
}
let after_kick = runner.poll(10);
match after_kick.command {
DriveCommand::Erm { duty } => {
assert!(duty < 200, "after first kick, duty {duty} should be normal");
}
_ => panic!("expected ERM command"),
}
let pause = runner.poll(20);
assert_eq!(pause.command, DriveCommand::Off);
let second = runner.poll(35);
match second.command {
DriveCommand::Erm { duty } => {
assert_eq!(duty, 255, "second pulse after Pause should kick");
}
_ => panic!("expected ERM command"),
}
}
#[test]
fn lra_hz_to_wakes_between_coarse_amplitude_steps() {
let mut ramp = Ramp::new(1_000, u16::MAX, u16::MAX, LINEAR)
.with_quantization(u16::MAX, Rounding::Nearest)
.with_lra_frequency(100);
ramp.lra_frequency_hz_to = Some(200);
let instructions = [Instruction::Ramp(ramp)];
let program = Program::new(MotorKind::Lra, &instructions);
let compiled = CompiledHapticDef::new("hz_wake", program, None);
let mut runner = Runner::from_compiled_started(
&compiled,
MotorConfig::Lra(LraConfig::new(2047, 240)),
0,
)
.unwrap();
let mut now = 0u32;
let mut last_hz = None;
let mut saw_hz_advance_before_amp_end = false;
let mut polls = 0u32;
while now < 1_000 && polls < 2_000 {
let frame = runner.poll(now);
let hz = match frame.command {
DriveCommand::Lra { frequency_hz, .. } => frequency_hz,
other => panic!("expected LRA command, got {other:?}"),
};
if let Some(prev) = last_hz
&& hz > prev
&& now < 1_000
{
saw_hz_advance_before_amp_end = true;
}
last_hz = Some(hz);
let Some(next) = frame.next_transition_ms else {
break;
};
if next <= now {
break;
}
now = next;
polls += 1;
}
assert!(
saw_hz_advance_before_amp_end,
"Hz should advance between coarse amplitude steps when following next_transition_ms"
);
assert!(
last_hz.unwrap_or(0) > 100,
"sweep should progress past starting Hz, last={last_hz:?}"
);
}
fn count_wakeups(runner: &mut Runner<'_, MonotonicCurveLut256>, window_ms: u32) -> u32 {
let mut now = 0u32;
let mut wakeups = 0u32;
while now < window_ms && wakeups < 10_000 {
let frame = runner.poll(now);
let Some(next) = frame.next_transition_ms else {
break;
};
if next <= now {
break;
}
now = next;
wakeups += 1;
}
wakeups
}
#[test]
fn pause_with_zero_min_level_does_not_storm_wakeups() {
let instructions: [Instruction<MonotonicCurveLut256>; 2] =
[Instruction::pause(5_000), Instruction::hold(20, 40_000)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(10, 255, 0, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("storm", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
assert_eq!(
count_wakeups(&mut runner, 5_000),
1,
"a 5s pause should schedule exactly one wake-up at its end"
);
}
#[test]
fn kick_still_fires_after_pause_when_min_level_is_zero() {
let instructions: [Instruction<MonotonicCurveLut256>; 2] =
[Instruction::pause(100), Instruction::hold(50, 40_000)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(10, 255, 0, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("kick_after_pause", program, Some(&profile));
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
assert_eq!(runner.poll(0).command, DriveCommand::Off);
match runner.poll(100).command {
DriveCommand::Erm { duty } => assert_eq!(duty, 255, "pulse after Pause should kick"),
other => panic!("expected ERM command, got {other:?}"),
}
match runner.poll(111).command {
DriveCommand::Erm { duty } => assert!(duty < 200, "kick should expire, got {duty}"),
other => panic!("expected ERM command, got {other:?}"),
}
}
#[test]
fn level_scaling_to_zero_duty_reports_off() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(20, 100)];
let program = Program::new(MotorKind::Erm, &instructions);
let compiled = CompiledHapticDef::new("tiny", program, None);
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), 0)
.unwrap();
assert_eq!(runner.poll(0).command, DriveCommand::Off);
}
#[test]
fn long_lra_sweep_does_not_overflow() {
let mut ramp = Ramp::new(20_000_000, u16::MAX, u16::MAX, LINEAR).with_lra_frequency(100);
ramp.lra_frequency_hz_to = Some(300);
let instructions = [Instruction::Ramp(ramp)];
let program = Program::new(MotorKind::Lra, &instructions);
let compiled = CompiledHapticDef::new("long_sweep", program, None);
let mut runner = Runner::from_compiled_started(
&compiled,
MotorConfig::Lra(LraConfig::new(2047, 240)),
0,
)
.unwrap();
match runner.poll(10_000_000).command {
DriveCommand::Lra { frequency_hz, .. } => assert_eq!(frequency_hz, 200),
other => panic!("expected LRA command, got {other:?}"),
}
}
#[test]
fn forever_near_u32_max_keeps_segment_deadlines() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] =
[Instruction::hold(10, u16::MAX)];
let program = Program::new(MotorKind::Erm, &instructions).repeat_forever();
let compiled = CompiledHapticDef::new("near_max", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
runner.start(0);
let now = (u32::MAX / 10) * 10 + 3;
let frame = runner.poll(now);
assert_eq!(frame.command, DriveCommand::Erm { duty: 255 });
assert_eq!(frame.instruction_index, Some(0));
assert_eq!(frame.next_transition_ms, Some(now.wrapping_add(7)));
assert!(!frame.finished);
}
#[test]
fn forever_continues_after_clock_wraparound() {
let instructions: [Instruction<MonotonicCurveLut256>; 2] =
[Instruction::hold(5, u16::MAX), Instruction::hold(5, 10_000)];
let program = Program::new(MotorKind::Erm, &instructions).repeat_forever();
let compiled = CompiledHapticDef::new("clock_wrap", program, None);
let start = u32::MAX - 2;
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), start)
.unwrap();
let frame = runner.poll(start.wrapping_add(7));
assert_eq!(frame.instruction_index, Some(1));
assert_eq!(frame.next_transition_ms, Some(start.wrapping_add(10)));
assert!(!frame.finished);
}
#[test]
fn ramp_and_lra_sweep_continue_after_clock_wraparound() {
let mut ramp = Ramp::new(10, 0, u16::MAX, LINEAR).with_lra_frequency(100);
ramp.lra_frequency_hz_to = Some(200);
let instructions = [Instruction::Ramp(ramp)];
let program = Program::new(MotorKind::Lra, &instructions);
let compiled = CompiledHapticDef::new("ramp_clock_wrap", program, None);
let start = u32::MAX - 5;
let mut runner = Runner::from_compiled_started(
&compiled,
MotorConfig::Lra(LraConfig::new(1_000, 200)),
start,
)
.unwrap();
let now = start.wrapping_add(6);
let frame = runner.poll(now);
match frame.command {
DriveCommand::Lra {
amplitude,
frequency_hz,
} => {
assert!(amplitude > 0, "ramp amplitude should advance after wrap");
assert_eq!(frequency_hz, 160);
}
_ => panic!("expected LRA command"),
}
let next = frame.next_transition_ms.unwrap();
assert!(next.wrapping_sub(now) <= 4);
}
#[test]
fn kick_expires_after_clock_wraparound() {
let instructions: [Instruction<MonotonicCurveLut256>; 1] = [Instruction::hold(50, 40_000)];
let program = Program::new(MotorKind::Erm, &instructions);
let profile = MotorProfile::new(10, 255, 64, 255, None, 2, 1, 8);
let compiled = CompiledHapticDef::new("kick_clock_wrap", program, Some(&profile));
let start = u32::MAX - 5;
let mut runner =
Runner::from_compiled_started(&compiled, MotorConfig::Erm(ErmConfig::new(255)), start)
.unwrap();
let kick = runner.poll(start);
assert_eq!(kick.command, DriveCommand::Erm { duty: 255 });
assert_eq!(kick.next_transition_ms, Some(start.wrapping_add(10)));
let after_kick = runner.poll(start.wrapping_add(10));
match after_kick.command {
DriveCommand::Erm { duty } => {
assert!(duty < 200, "after kick, duty {duty} should be normal level");
}
_ => panic!("expected ERM command"),
}
}
#[test]
fn count_near_u32_max_keeps_segment_deadlines() {
let instructions: [Instruction<MonotonicCurveLut256>; 2] =
[Instruction::hold(5, u16::MAX), Instruction::hold(5, 10_000)];
let program = Program::new(MotorKind::Erm, &instructions)
.with_loop_mode(LoopMode::Count(u32::MAX / 10 + 100));
let compiled = CompiledHapticDef::new("count_near_max", program, None);
let mut runner =
Runner::from_compiled(&compiled, MotorConfig::Erm(ErmConfig::new(255))).unwrap();
runner.start(0);
let now = u32::MAX - 8; let frame = runner.poll(now);
assert_eq!(frame.instruction_index, Some(1));
assert_eq!(frame.next_transition_ms, Some(now.wrapping_add(3)));
}
}