rigless 0.0.5

Virtual hardware-in-the-loop runtime: run unmodified firmware on an emulated MCU with every peripheral, chip model, and unit of virtual time in Rust; a fork of QEMU is built in and executes the instructions
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! The board's edge: what crosses it, and the trait a board with a world implements.
//!
//! A firmware reaches the world through pins and chips. On the way out a timer's pin carries a pulse and
//! the board knows what that pulse commands; on the way in a chip on a bus measures a rate, a force, a
//! field or a pressure, and the board knows which way that chip is mounted. [`Actuator`] is the outgoing
//! half as every board hands it over, [`Board::actuators`], since a pulse and what it commands are the
//! same shape on any board. The incoming half is the board's own type: what a flight controller's
//! transducers measure is not what a motor drive's do, so [`Edge::Signals`] is declared by the board and
//! [`Edge::sense`] takes it. Whatever steps a plant reads the one and writes the other between rounds of
//! the session, when every target stands at one instant. The runtime steps no plant and names none: the
//! session and the command line drive a [`Board`] and never see its signals, and only a host that holds
//! the board as its own type can sense on it.
//!
//! Every quantity a board declares is an integer in the unit its chips take, so a value crosses the seam
//! and lands in a register without a rounding of its own on the way. The chips keep only the setters
//! their datasheets name, each on the chip's own axes, and the board, which is the one thing that knows
//! how each package is mounted, is what turns its signals into those calls.
//!
//! [`Board`]: crate::machine::Board
//! [`Board::actuators`]: crate::machine::Board::actuators

use crate::machine::Board;
use crate::time::VirtualNanos;

/// A board with a world to sense: the type its signals are, and how they reach its chips.
///
/// The type is the board's because the quantities are: a board declares what its transducers measure, in
/// the units its chips take and on the axes its description names, and a plant for that board answers
/// that type. A board that measures nothing has no edge.
pub trait Edge: Board {
    /// What the world does to this board's transducers from one step to the next.
    type Signals;

    /// The names of the outputs [`Board::actuators`] reads, in that same order, so an output crosses a
    /// process boundary as its index and comes back with its name.
    const OUTPUTS: &'static [&'static str];

    /// What the world does to the board from now on. The board hands each chip its share on the chip's
    /// own axes.
    fn sense(&mut self, signals: &Self::Signals);
}

/// A board with an edge, reachable without its signals type: what the command line hands to the
/// engine's link, which carries the signals as bytes and never names their type.
///
/// Every [`Edge`] whose signals cross the wire is one; nothing implements it by hand.
pub trait Coupled: Board {
    /// [`Edge::OUTPUTS`].
    fn outputs(&self) -> &'static [&'static str];

    /// [`Edge::sense`], from the signals as the wire carries them.
    fn sense_wire(&mut self, bytes: &[u8]) -> Result<(), crate::link::WireError>;
}

impl<B: Edge> Coupled for B
where
    B::Signals: crate::link::Wire,
{
    fn outputs(&self) -> &'static [&'static str] {
        B::OUTPUTS
    }

    fn sense_wire(&mut self, bytes: &[u8]) -> Result<(), crate::link::WireError> {
        let signals = <B::Signals as crate::link::Wire>::decode(&mut &*bytes)?;
        self.sense(&signals);
        Ok(())
    }
}

/// A pulse a pin repeats: how long it is high, and how long until it is high again.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Pulse {
    /// The high time.
    pub width: VirtualNanos,
    /// The period.
    pub period: VirtualNanos,
}

/// How the thing on an output reads a pulse: the width it takes as no command, and the one it takes as
/// the whole of it. A speed controller on a servo pulse reads one millisecond as nothing and two as
/// everything; [`Curve::command`] is where between the two a pulse falls.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Curve {
    /// The width that commands nothing; anything narrower commands the same.
    pub zero: VirtualNanos,
    /// The width that commands everything; anything wider commands the same.
    pub full: VirtualNanos,
}

/// The whole of a command, in the thousandths [`Curve::command`] answers in.
pub const FULL: u32 = 1000;

impl Curve {
    /// What `pulse` commands on this curve, in thousandths of the whole, held between nothing and all.
    #[must_use]
    pub fn command(self, pulse: Pulse) -> u32 {
        let span = self.full.0.saturating_sub(self.zero.0).max(1);
        let above = pulse.width.0.saturating_sub(self.zero.0).min(span);
        u32::try_from(above * u64::from(FULL) / span).expect("at most the whole")
    }
}

/// One output of a board as it stands: the name the board's description gives it, the pulse on its pin,
/// and what that pulse commands on the board's curve.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Actuator {
    /// The output, named as the board's description names it.
    pub name: &'static str,
    /// The pulse on the pin, or `None` while the pin is not driven or carries no pulse.
    pub pulse: Option<Pulse>,
    /// The command in thousandths of the whole, for a pulse on the board's curve.
    pub command: Option<u32>,
}

impl Actuator {
    /// The output `name` carrying `pulse`, read on `curve`.
    #[must_use]
    pub fn on(name: &'static str, pulse: Option<Pulse>, curve: Curve) -> Self {
        Self {
            name,
            pulse,
            command: pulse.map(|pulse| curve.command(pulse)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SERVO: Curve = Curve {
        zero: VirtualNanos(1_000_000),
        full: VirtualNanos(2_000_000),
    };

    fn width(nanos: u64) -> Pulse {
        Pulse {
            width: VirtualNanos(nanos),
            period: VirtualNanos(20_000_000),
        }
    }

    #[test]
    fn a_command_is_where_the_width_falls_between_the_ends_and_never_past_them() {
        assert_eq!(SERVO.command(width(1_000_000)), 0);
        assert_eq!(SERVO.command(width(1_250_000)), 250);
        assert_eq!(SERVO.command(width(2_000_000)), FULL);
        assert_eq!(SERVO.command(width(0)), 0, "narrower than nothing");
        assert_eq!(
            SERVO.command(width(3_000_000)),
            FULL,
            "wider than everything"
        );
    }

    #[test]
    fn an_output_without_a_pulse_commands_nothing() {
        let off = Actuator::on("OUT", None, SERVO);
        assert_eq!(off.command, None);
        let on = Actuator::on("OUT", Some(width(1_500_000)), SERVO);
        assert_eq!(on.command, Some(500));
    }
}