rigless 0.1.0

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 CPU core of its own that translates the firmware's instructions to host code
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! The ARMv7-M machine, what a target of that core family is.
//!
//! This is what an ARMv7-M target says about itself: a typed field for each of its properties, and
//! one each for its variant, its instruction rate, and the region an SoC boots from. [`Core::spec`]
//! renders them with the family's [`processor`](crate::cpu::armv7m::processor) as the spec's
//! constructor, and the family reads the properties back by the names here. A machine module fills
//! the fields with its SoC's numbers and calls [`Core::spec`]. No machine module spells a property
//! name.

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

use crate::emulator::{Alias, Boot, CoreSpec, Property};

/// The core this module describes, as the family's refusals name it.
#[cfg(not(emulator_absent))]
pub(crate) const CORE: &str = "the ARMv7-M core";

/// What separates one window from the next in [`PPB_WINDOWS`].
pub(crate) const WINDOW_SEPARATOR: &str = "+";

/// Where the core reads the address it starts from.
const RESET_VECTOR: u64 = 0;
/// The exceptions every core has before its first interrupt line, reset among them.
const SYSTEM_EXCEPTIONS: u64 = 16;
/// The bytes of one entry in the vector table.
const VECTOR_BYTES: u64 = 4;
/// The least a vector table is aligned to, however few exceptions it has.
const TABLE_ALIGNMENT: u64 = 128;

pub(crate) const CLOCK_HZ: &str = "clock-hz";
pub(crate) const INTERRUPT_LINES: &str = "interrupt-lines";
pub(crate) const PRIORITY_BITS: &str = "priority-bits";
pub(crate) const REFCLK_DIVIDER: &str = "refclk-divider";
pub(crate) const PPB_WINDOWS: &str = "ppb-windows";
pub(crate) const FPU: &str = "fpu";
pub(crate) const MPU_REGIONS: &str = "mpu-regions";

// ceiling: ARMv7-M cores only; upgrade when the family models v8-M's system control space.
/// A core the machine runs, by the name it takes for it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Variant {
    /// Cortex-M3.
    M3,
    /// Cortex-M4.
    M4,
    /// Cortex-M7.
    M7,
}

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

    /// 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",
        }
    }

    /// The core [`Variant::name`] calls `name`.
    #[must_use]
    pub(crate) fn named(name: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|variant| variant.name() == name)
    }

    // ceiling: the Cortex-M3's and M7's options below come from no manual cited here; upgrade when
    // their technical reference manuals are cited.
    /// Whether the core has the DSP extension, which makes it ARMv7E-M, DDI 0403E.b A1.3. The M4
    /// has it, 100166 section 3.3.2.
    #[must_use]
    pub(crate) const fn dsp(self) -> bool {
        matches!(self, Self::M4 | Self::M7)
    }

    /// Every floating-point unit a part on this core may have. The M4's is FPv4-SP, and it's
    /// optional, 100166 sections 1.4 and 7.1.
    #[must_use]
    pub(crate) const fn fpus(self) -> &'static [Fpu] {
        match self {
            Self::M3 => &[Fpu::None],
            Self::M4 => &[Fpu::None, Fpu::FpV4Sp],
            Self::M7 => &[Fpu::None, Fpu::FpV5Sp, Fpu::FpV5Dp],
        }
    }

    /// Every MPU a part on this core may have. The M4's has eight regions, and it's optional,
    /// 100166 sections 1.4 and 2.2.
    #[must_use]
    pub(crate) const fn mpus(self) -> &'static [MpuRegions] {
        match self {
            Self::M3 | Self::M4 => &[MpuRegions::None, MpuRegions::Eight],
            Self::M7 => &[MpuRegions::None, MpuRegions::Eight, MpuRegions::Sixteen],
        }
    }

    /// The unit a board that doesn't name one gets: the fullest the core may have, so a board from
    /// before units were named keeps one. That's the Cortex-M4F's on an M4 and double-precision
    /// `FPv5` on an M7. An M3 can't have one.
    #[must_use]
    pub(crate) const fn default_fpu(self) -> Fpu {
        match self {
            Self::M3 => Fpu::None,
            Self::M4 => Fpu::FpV4Sp,
            Self::M7 => Fpu::FpV5Dp,
        }
    }
}

/// The floating-point unit a part has, DDI 0403E.b A1.3 and Table A1-1.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Fpu {
    /// No floating-point extension, so CP10 and CP11 don't exist.
    None,
    /// FPv4-SP, single precision only: the Cortex-M4F's.
    FpV4Sp,
    /// `FPv5`, single precision only.
    FpV5Sp,
    /// `FPv5` with double precision.
    FpV5Dp,
}

impl Fpu {
    /// Every unit the machine takes.
    pub(crate) const ALL: [Self; 4] = [Self::None, Self::FpV4Sp, Self::FpV5Sp, Self::FpV5Dp];

    /// The name the machine takes for this unit.
    #[must_use]
    pub(crate) const fn name(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::FpV4Sp => "fpv4-sp",
            Self::FpV5Sp => "fpv5-sp",
            Self::FpV5Dp => "fpv5-dp",
        }
    }

    /// The unit [`Fpu::name`] calls `name`.
    #[must_use]
    pub(crate) fn named(name: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|fpu| fpu.name() == name)
    }
}

impl std::fmt::Display for Fpu {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(self.name())
    }
}

/// How many regions a part's MPU has, which `MPU_TYPE.DREGION` reads, DDI 0403E.b B3.5.5.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum MpuRegions {
    /// No MPU at all.
    None = 0,
    /// Eight regions: the Cortex-M4's, and what a board that doesn't say takes.
    #[default]
    Eight = 8,
    /// Sixteen regions.
    Sixteen = 16,
}

impl MpuRegions {
    /// Every count the machine takes.
    pub(crate) const ALL: [Self; 3] = [Self::None, Self::Eight, Self::Sixteen];

    /// The regions, as `MPU_TYPE.DREGION` reads them.
    #[must_use]
    pub(crate) const fn count(self) -> u8 {
        self as u8
    }

    /// The count that's `regions`, if the machine takes it.
    #[must_use]
    pub(crate) fn of(regions: u64) -> Option<Self> {
        Self::ALL
            .into_iter()
            .find(|count| u64::from(count.count()) == regions)
    }
}

/// The ARMv7-M core as a machine module configures it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Core {
    /// Which core.
    pub variant: Variant,
    /// 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 a kernel's tick built on that timer run fast or slow by the same ratio.
    pub refclk_divider: NonZeroU32,
    /// How many instructions the core retires in a second of virtual time. It's a choice: every
    /// target this crate carries retires 250 million, four nanoseconds each, which is about what a
    /// core at a few hundred megahertz does. The part's own clock, one instruction a cycle, is the
    /// other natural pick. A lower rate makes the emulated core slower against its own peripherals
    /// and the plant, a higher one faster, and nothing else depends on it.
    pub instructions_per_second: NonZeroU64,
    /// The memory region the SoC's boot configuration puts where the core fetches its reset vector.
    ///
    /// The core 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 {
    /// The description of a target's core on this machine, 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 core 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 spec(self, ppb: &[crate::cpu::armv7m::Window]) -> CoreSpec {
        CoreSpec {
            processor: crate::cpu::armv7m::processor,
            cpu: self.variant.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),
                ),
            ],
            instructions_per_second: self.instructions_per_second,
            // 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,
            }],
            boot: Boot {
                reset: RESET_VECTOR,
                alignment: self.table_alignment(),
            },
        }
    }

    /// [`Core::spec`] for a part whose manual names its floating-point unit and its MPU. One the
    /// variant can't have is refused when the run starts. A board that calls [`Core::spec`] gets an
    /// MPU of eight regions, and the Cortex-M4F's unit on an M4.
    #[must_use]
    pub fn spec_with_parts(
        self,
        ppb: &[crate::cpu::armv7m::Window],
        fpu: Fpu,
        mpu_regions: MpuRegions,
    ) -> CoreSpec {
        let mut spec = self.spec(ppb);
        spec.properties.extend([
            Property::new(FPU, fpu),
            Property::new(MPU_REGIONS, mpu_regions.count()),
        ]);
        spec
    }

    /// What a vector table's address is a multiple of: the table's size rounded up to a power of two,
    /// and never less than 128 bytes (ARMv7-M ARM B1.5.3).
    fn table_alignment(self) -> NonZeroU64 {
        let size = (SYSTEM_EXCEPTIONS + u64::from(self.interrupt_lines.get())) * VECTOR_BYTES;
        NonZeroU64::new(size.next_power_of_two().max(TABLE_ALIGNMENT))
            .expect("a power of two is above zero")
    }
}

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

    fn core() -> Core {
        Core {
            variant: Variant::M4,
            clock_hz: NonZeroU64::new(168_000_000).unwrap(),
            interrupt_lines: NonZeroU32::new(91).unwrap(),
            priority_bits: 4,
            refclk_divider: NonZeroU32::new(8).unwrap(),
            instructions_per_second: NonZeroU64::new(250_000_000).unwrap(),
            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.spec(&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",
                "interrupt-lines=91",
                "priority-bits=4",
                "refclk-divider=8",
                "ppb-windows=0xe0000000:0x1000+0xe0001000:0x8",
            ]
        );
        assert_eq!(core().spec(&WINDOWS).cpu, "cortex-m4");
    }

    #[test]
    fn a_part_that_names_its_units_adds_them_and_nothing_else() {
        let plain = core().spec(&WINDOWS);
        let named = core().spec_with_parts(&WINDOWS, Fpu::None, MpuRegions::Sixteen);
        assert_eq!(named.properties[..plain.properties.len()], plain.properties);
        assert_eq!(
            named.properties[plain.properties.len()..]
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>(),
            ["fpu=none", "mpu-regions=16"]
        );
    }

    #[test]
    fn a_vector_table_is_aligned_to_its_size_and_never_less_than_128_bytes() {
        let alignment = |lines| {
            Core {
                interrupt_lines: NonZeroU32::new(lines).unwrap(),
                ..core()
            }
            .spec(&WINDOWS)
            .boot
            .alignment
            .get()
        };
        assert_eq!(alignment(91), 512, "107 entries are 428 bytes");
        assert_eq!(alignment(1), 128, "17 entries are 68 bytes");
        assert_eq!(alignment(16), 128, "32 entries are 128 bytes");
        assert_eq!(alignment(17), 256);
    }

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