rigless 0.0.6

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, and a fork of QEMU built in to execute the instructions
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! The ARMv7-M machine of the emulator, mirrored field for field.
//!
//! The emulator carries one machine per core family, and this is the Rust side of the ARMv7-M one:
//! a typed field for each property that machine declares, listed in [`PROPERTIES`], and one each
//! for its model, its instruction rate, and the region an SoC boots from. A property the machine
//! does not declare fails `qemu_init` in every test that boots, which is what holds the two
//! together. A machine module fills the fields with its SoC's numbers and calls [`Core::command`].
//! No machine module spells a property name.

use std::num::{NonZeroU32, NonZeroU64};

use crate::emulator::{Alias, EmulatorCommand, Property};

/// The machine this module mirrors.
pub const MACHINE: &str = "rigless-armv7m";

/// The properties the machine declares, with the type each takes.
pub const PROPERTIES: [(&str, &str); 5] = [
    (CLOCK_HZ, "size"),
    (INTERRUPT_LINES, "uint32"),
    (PRIORITY_BITS, "uint8"),
    (PPB_WINDOWS, "string"),
    (REFCLK_DIVIDER, "uint32"),
];

/// What separates one window from the next, which cannot be the comma that ends a machine option.
const WINDOW_SEPARATOR: &str = "+";

/// Where the core reads the address it starts from.
const RESET_VECTOR: u64 = 0;

const CLOCK_HZ: &str = "clock-hz";
const INTERRUPT_LINES: &str = "num-irq";
const PRIORITY_BITS: &str = "num-prio-bits";
const REFCLK_DIVIDER: &str = "refclk-divider";
const PPB_WINDOWS: &str = "ppb-windows";

/// A core the machine runs, by the name it takes for it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Model {
    /// Cortex-M3.
    M3,
    /// Cortex-M4.
    M4,
    /// Cortex-M7.
    M7,
    /// Cortex-M33.
    M33,
}

impl Model {
    /// Every core the machine accepts, in the order it lists them.
    pub const ALL: [Self; 4] = [Self::M3, Self::M4, Self::M7, Self::M33];

    /// The name the machine takes for this core.
    #[must_use]
    pub const fn name(self) -> &'static str {
        match self {
            Self::M3 => "cortex-m3",
            Self::M4 => "cortex-m4",
            Self::M7 => "cortex-m7",
            Self::M33 => "cortex-m33",
        }
    }
}

/// The ARMv7-M core as a machine module configures it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Core {
    /// Which core.
    pub model: Model,
    /// The core clock, which drives the timers inside the core.
    pub clock_hz: NonZeroU64,
    /// The interrupt lines the controller has, each one a line the runtime can raise.
    pub interrupt_lines: NonZeroU32,
    /// The priority bits the interrupt controller implements, which the part's manual gives in its
    /// section on the controller. A firmware that reads back a priority it wrote sees this many bits
    /// of it. Zero leaves the count to the core's own default, as the machine spells it.
    pub priority_bits: u8,
    /// The reference clock of the core's own timer is the core clock divided by this. The part's
    /// manual gives the divider in its section on that timer's clock source, and eight is common. A
    /// wrong one makes an RTOS tick built on that timer run fast or slow by the same ratio.
    pub refclk_divider: NonZeroU32,
    /// Every instruction takes `2 ^ icount_shift` nanoseconds of virtual time. It is a choice: every
    /// target this crate carries uses 2, four nanoseconds, which is about what a core at a few hundred
    /// megahertz does. A larger shift makes the emulated core slower against its own peripherals and
    /// the plant, a smaller one faster, and nothing else depends on it.
    pub icount_shift: u32,
    /// The memory region the SoC's boot configuration puts where the core fetches its reset vector.
    ///
    /// The emulator answers at address zero with that region's bytes, so a vector table linked at the
    /// start of the flash region is what the core finds at its reset vector. A name the MCU's memory
    /// map does not have is refused when the session opens, before the core fetches from it.
    pub boot: &'static str,
}

impl Core {
    /// What a target on this machine is started as, answering for `ppb` of the processor's own bus.
    ///
    /// The windows are the MCU's, from [`crate::cpu::armv7m::windows`], rather than anything this
    /// module decides: the emulator answers the processor's peripheral bus itself unless told which
    /// stretches of it the runtime has a model behind, and the runtime's own bus is what knows.
    #[must_use]
    pub fn command(self, ppb: &[crate::cpu::armv7m::Window]) -> EmulatorCommand {
        EmulatorCommand {
            machine: MACHINE,
            cpu: self.model.name(),
            properties: vec![
                Property::new(CLOCK_HZ, self.clock_hz),
                Property::new(INTERRUPT_LINES, self.interrupt_lines),
                Property::new(PRIORITY_BITS, self.priority_bits),
                Property::new(REFCLK_DIVIDER, self.refclk_divider),
                Property::new(
                    PPB_WINDOWS,
                    ppb.iter()
                        .map(ToString::to_string)
                        .collect::<Vec<String>>()
                        .join(WINDOW_SEPARATOR),
                ),
            ],
            icount_shift: self.icount_shift,
            // The core fetches the reset vector from address zero, so the boot region answers there too.
            aliases: vec![Alias {
                region: self.boot,
                at: RESET_VECTOR,
            }],
        }
    }
}

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

    fn core() -> Core {
        Core {
            model: Model::M4,
            clock_hz: NonZeroU64::new(168_000_000).unwrap(),
            interrupt_lines: NonZeroU32::new(91).unwrap(),
            priority_bits: 4,
            refclk_divider: NonZeroU32::new(8).unwrap(),
            icount_shift: 2,
            boot: "flash",
        }
    }

    /// Two windows, so the separator is under test and not just the rendering of one.
    const WINDOWS: [crate::cpu::armv7m::Window; 2] = [
        crate::cpu::armv7m::Window {
            base: 0xE000_0000,
            size: 0x1000,
        },
        crate::cpu::armv7m::Window {
            base: 0xE000_1000,
            size: 0x8,
        },
    ];

    fn rendered(core: Core) -> Vec<String> {
        core.command(&WINDOWS)
            .properties
            .iter()
            .map(ToString::to_string)
            .collect()
    }

    #[test]
    fn the_properties_render_as_the_machine_spells_them() {
        assert_eq!(
            rendered(core()),
            [
                "clock-hz=168000000",
                "num-irq=91",
                "num-prio-bits=4",
                "refclk-divider=8",
                "ppb-windows=0xe0000000:0x1000+0xe0001000:0x8",
            ]
        );
        let command = core().command(&WINDOWS);
        assert_eq!(command.machine, MACHINE);
        assert_eq!(command.cpu, "cortex-m4");
        assert_eq!(
            command.properties.len(),
            PROPERTIES.len(),
            "every property the machine declares is set"
        );
    }

    #[test]
    fn the_boot_region_is_the_one_the_core_also_finds_at_its_reset_vector() {
        assert_eq!(
            core().command(&WINDOWS).aliases,
            [Alias {
                region: "flash",
                at: 0
            }]
        );
        let from_system = Core {
            boot: "system",
            ..core()
        };
        assert_eq!(
            from_system.command(&WINDOWS).aliases,
            [Alias {
                region: "system",
                at: 0
            }],
            "the SoC's boot configuration says which region, not the family"
        );
    }
}