ph-haptics 0.1.0

Host-compiled haptics DSL and no-std, no-alloc scheduling runtime modeling ERM and LRA motors using ph-curves
Documentation
use crate::model::{Haptic, Inst, LoopMode, Motor, Profile};
use crate::semantic::{profile_for_haptic, profile_map, resolve_curve_symbol};
use std::collections::BTreeSet;

fn motor_label(motor: Motor) -> &'static str {
    match motor {
        Motor::Erm => "erm",
        Motor::Lra => "lra",
    }
}

pub(crate) fn validate_haptics(
    haptics: &[Haptic],
    profiles: &[Profile],
    known_curves: Option<&BTreeSet<String>>,
) -> Result<(), String> {
    let profile_map = profile_map(profiles);
    let default_erm = Profile::default_erm_named("__default_erm");
    let default_lra = Profile::default_lra_named("__default_lra");

    for haptic in haptics {
        if haptic.instructions.is_empty() {
            return Err(format!(
                "line {}: haptic `{}` has no instructions",
                haptic.line, haptic.name
            ));
        }

        let profile = profile_for_haptic(haptic, &profile_map, &default_erm, &default_lra)?;

        // An explicitly named profile must be tuned for this haptic's motor.
        // Binding an ERM profile to an LRA haptic silently mistunes kick, floor,
        // and gamma rather than failing.
        if haptic.profile.is_some()
            && let Some(profile) = profile
            && profile.motor != haptic.motor
        {
            return Err(format!(
                "line {}: haptic `{}` is motor={} but profile `{}` is tuned for {}",
                haptic.line,
                haptic.name,
                motor_label(haptic.motor),
                profile.name,
                motor_label(profile.motor)
            ));
        }

        validate_loop_bounds(haptic)?;

        for inst in &haptic.instructions {
            // LRA-only options on an ERM haptic are silently dropped at runtime,
            // so reject them instead of pretending they took effect.
            if haptic.motor == Motor::Erm
                && let Some(option) = inst.lra_option()
            {
                return Err(format!(
                    "line {}: `{option}` is not valid on motor=erm haptic `{}`",
                    inst.line(),
                    haptic.name
                ));
            }

            if let Inst::Ramp {
                line,
                curve: curve_ref,
                ..
            } = inst
            {
                let symbol = resolve_curve_symbol(curve_ref, profile, *line, &haptic.name)?;
                if let Some(curves) = known_curves
                    && !curves.contains(&symbol)
                {
                    return Err(format!("line {line}: unknown curve symbol `{symbol}`"));
                }
            }
        }
    }

    Ok(())
}

/// Reject `loop=N` whose total playback time overflows the runtime's u32 ms
/// clock. `Runner` uses `saturating_mul` there, so an overflowing count would
/// silently behave like `loop=forever` instead of stopping.
fn validate_loop_bounds(haptic: &Haptic) -> Result<(), String> {
    let LoopMode::Count(count) = haptic.loop_mode else {
        return Ok(());
    };

    let cycle_ms: u64 = haptic
        .instructions
        .iter()
        .map(|inst| u64::from(inst.duration_ms()))
        .sum();
    let total_ms = cycle_ms.saturating_mul(u64::from(count));

    if total_ms > u64::from(u32::MAX) {
        return Err(format!(
            "line {}: haptic `{}` loop={count} over a {cycle_ms}ms cycle totals {total_ms}ms, which overflows the runtime's u32 millisecond clock",
            haptic.line, haptic.name
        ));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{CurveRef, Inst, LoopMode, Motor};

    #[test]
    fn rejects_unknown_profile_reference() {
        let haptics = [Haptic {
            line: 1,
            name: "click".to_owned(),
            motor: Motor::Erm,
            loop_mode: LoopMode::Once,
            profile: Some("missing".to_owned()),
            instructions: vec![Inst::Hold {
                line: 2,
                duration_ms: 10,
                level: 100,
                lra_hz: None,
            }],
        }];
        let error = validate_haptics(&haptics, &[], None).unwrap_err();
        assert!(error.contains("unknown profile"));
    }

    #[test]
    fn rejects_unknown_curve_symbol_when_validation_enabled() {
        let haptics = [Haptic {
            line: 1,
            name: "click".to_owned(),
            motor: Motor::Erm,
            loop_mode: LoopMode::Once,
            profile: None,
            instructions: vec![Inst::Ramp {
                line: 2,
                duration_ms: 10,
                from: 0,
                to: 100,
                curve: CurveRef::Named("missing_curve".to_owned()),
                step: None,
                rounding: None,
                min_dt_ms: None,
                lra_hz: None,
                lra_hz_to: None,
            }],
        }];
        let mut known = BTreeSet::new();
        known.insert("LINEAR".to_owned());
        let error = validate_haptics(&haptics, &[], Some(&known)).unwrap_err();
        assert!(error.contains("unknown curve symbol"));
    }

    #[test]
    fn accepts_valid_haptic() {
        let haptics = [Haptic {
            line: 1,
            name: "click".to_owned(),
            motor: Motor::Erm,
            loop_mode: LoopMode::Once,
            profile: None,
            instructions: vec![Inst::Hold {
                line: 2,
                duration_ms: 10,
                level: 30000,
                lra_hz: None,
            }],
        }];
        validate_haptics(&haptics, &[], None).unwrap();
    }

    fn haptic_with(motor: Motor, profile: Option<&str>, instructions: Vec<Inst>) -> Haptic {
        Haptic {
            line: 1,
            name: "probe".to_owned(),
            motor,
            loop_mode: LoopMode::Once,
            profile: profile.map(str::to_owned),
            instructions,
        }
    }

    fn hold(line: u32, lra_hz: Option<u16>) -> Inst {
        Inst::Hold {
            line,
            duration_ms: 10,
            level: 30_000,
            lra_hz,
        }
    }

    #[test]
    fn rejects_profile_tuned_for_the_other_motor() {
        let haptics = [haptic_with(
            Motor::Lra,
            Some("erm_ui_soft"),
            vec![hold(2, None)],
        )];
        let profiles = [Profile::default_erm_named("erm_ui_soft")];
        let error = validate_haptics(&haptics, &profiles, None).unwrap_err();
        assert!(error.contains("tuned for erm"), "error: {error}");
    }

    #[test]
    fn accepts_profile_matching_motor() {
        let haptics = [haptic_with(Motor::Lra, Some("lra_ui"), vec![hold(2, None)])];
        let profiles = [Profile::default_lra_named("lra_ui")];
        validate_haptics(&haptics, &profiles, None).unwrap();
    }

    #[test]
    fn rejects_lra_options_on_erm_haptic() {
        let haptics = [haptic_with(Motor::Erm, None, vec![hold(7, Some(200))])];
        let error = validate_haptics(&haptics, &[], None).unwrap_err();
        assert!(error.contains("line 7"), "error: {error}");
        assert!(error.contains("`lra_hz` is not valid"), "error: {error}");
    }

    #[test]
    fn allows_lra_options_on_lra_haptic() {
        let haptics = [haptic_with(Motor::Lra, None, vec![hold(7, Some(200))])];
        validate_haptics(&haptics, &[], None).unwrap();
    }

    #[test]
    fn rejects_loop_count_that_overflows_the_ms_clock() {
        let mut haptic = haptic_with(Motor::Erm, None, vec![hold(2, None)]);
        // 10ms cycle * 500_000_000 = 5e9 ms, past u32::MAX.
        haptic.loop_mode = LoopMode::Count(500_000_000);
        let error = validate_haptics(&[haptic], &[], None).unwrap_err();
        assert!(error.contains("overflows"), "error: {error}");
    }

    #[test]
    fn accepts_loop_count_within_the_ms_clock() {
        let mut haptic = haptic_with(Motor::Erm, None, vec![hold(2, None)]);
        haptic.loop_mode = LoopMode::Count(1_000);
        validate_haptics(&[haptic], &[], None).unwrap();
    }

    #[test]
    fn rejects_empty_instructions() {
        let haptics = [Haptic {
            line: 1,
            name: "empty".to_owned(),
            motor: Motor::Erm,
            loop_mode: LoopMode::Once,
            profile: None,
            instructions: vec![],
        }];
        let error = validate_haptics(&haptics, &[], None).unwrap_err();
        assert!(error.contains("has no instructions"), "error: {error}");
    }
}