rigless 0.0.2

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; needs the rigless-sidecar package from rigless-labs/qemu
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: Apache-2.0
//! The shared page as both sides see it: the control block, its slots, and the interrupt bitmap.

use crate::sidecar::sync::{AtomicU8, AtomicU32, AtomicU64, Ordering};
use crate::sidecar::turn::{Turn, TurnWord};

/// First word of a control block.
pub const MAGIC: u64 = u64::from_le_bytes(*b"RIGLSSCB");

/// The version this crate speaks; the core compares it against `Hello`.
pub const PROTOCOL_VERSION: Version = Version { major: 1, minor: 0 };

/// How many interrupt lines the bitmap has room for.
pub const INTERRUPT_LINES: u16 = 1024;

pub(crate) const VERSION_MAJOR_SHIFT: u32 = 16;

#[cfg(not(loom))]
pub(crate) const CACHE_LINE: usize = 64;

#[cfg(not(loom))]
pub(crate) const PAGE: usize = 4096;

const INTERRUPT_WORDS: usize = INTERRUPT_LINES as usize / u64::BITS as usize;

/// A protocol version; the major changes the layout, the minor adds a kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Version {
    /// Incremented when the layout of the control block or a slot changes.
    pub major: u16,
    /// Incremented when a message kind or a field is added.
    pub minor: u16,
}

impl Version {
    /// The wire form, `(major << 16) | minor`, as the control block and `Hello` carry it.
    #[must_use]
    pub fn pack(self) -> u32 {
        (u32::from(self.major) << VERSION_MAJOR_SHIFT) | u32::from(self.minor)
    }

    #[expect(
        clippy::cast_possible_truncation,
        reason = "each half is sixteen bits wide once shifted and masked"
    )]
    pub(crate) fn unpack(packed: u32) -> Self {
        Self {
            major: (packed >> VERSION_MAJOR_SHIFT) as u16,
            minor: (packed & u32::from(u16::MAX)) as u16,
        }
    }
}

impl core::fmt::Display for Version {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(formatter, "{}.{}", self.major, self.minor)
    }
}

/// One message in flight in one direction, as the integer fields both sides read.
#[repr(C, align(64))]
#[derive(Debug, Default)]
pub struct Slot {
    pub(crate) kind: AtomicU32,
    pub(crate) width: AtomicU8,
    pub(crate) reason: AtomicU8,
    pub(crate) address: AtomicU64,
    pub(crate) value: AtomicU64,
    pub(crate) instruction_count: AtomicU64,
    pub(crate) program_counter: AtomicU64,
    pub(crate) virtual_time_ns: AtomicU64,
    pub(crate) deadline_ns: AtomicU64,
}

/// An interrupt line the proxy drives, so an index below [`INTERRUPT_LINES`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct InterruptLine(u16);

impl InterruptLine {
    /// The line at `index`, or `None` when the bitmap has no bit for it.
    #[must_use]
    pub const fn new(index: u16) -> Option<Self> {
        if index < INTERRUPT_LINES {
            Some(Self(index))
        } else {
            None
        }
    }

    fn word(self) -> usize {
        usize::from(self.0) / u64::BITS as usize
    }

    fn mask(self) -> u64 {
        1 << (u32::from(self.0) % u64::BITS)
    }
}

/// One bit per line the proxy drives; word = index / 64, bit = index % 64.
#[repr(C, align(64))]
#[derive(Debug, Default)]
pub struct InterruptLevels([AtomicU64; INTERRUPT_WORDS]);

impl InterruptLevels {
    /// Sets the bit for `line`; valid only while this side holds the turn, and published by `hand_over`.
    pub fn raise(&self, line: InterruptLine) {
        self.0[line.word()].fetch_or(line.mask(), Ordering::Relaxed);
    }

    /// Clears the bit for `line`; valid only while this side holds the turn, and published by `hand_over`.
    pub fn lower(&self, line: InterruptLine) {
        self.0[line.word()].fetch_and(!line.mask(), Ordering::Relaxed);
    }

    /// Whether `line` is raised in the bitmap.
    pub fn is_raised(&self, line: InterruptLine) -> bool {
        self.0[line.word()].load(Ordering::Relaxed) & line.mask() != 0
    }
}

/// The one shared page: magic, version, the turn, a slot each way, the interrupt levels.
#[repr(C, align(4096))]
#[derive(Debug)]
pub struct ControlBlock {
    /// Always [`MAGIC`].
    pub magic: AtomicU64,
    /// The version the creator speaks, as [`Version::pack`] lays it out.
    pub version: AtomicU32,
    /// Which side may write its slot.
    pub turn: TurnWord,
    /// The slot the sidecar writes.
    pub sidecar_slot: Slot,
    /// The slot the core writes.
    pub core_slot: Slot,
    /// The lines the core holds high.
    pub interrupt_levels: InterruptLevels,
}

impl ControlBlock {
    /// A fresh block with the turn given to the sidecar, ready for its `Hello`.
    #[must_use]
    pub fn new() -> Self {
        Self {
            magic: AtomicU64::new(MAGIC),
            version: AtomicU32::new(PROTOCOL_VERSION.pack()),
            turn: TurnWord::new(Turn::Sidecar),
            sidecar_slot: Slot::default(),
            core_slot: Slot::default(),
            interrupt_levels: InterruptLevels::default(),
        }
    }

    // The page arrives zeroed from `create_new` plus `set_len`; the slots and the levels stay so.
    #[cfg(not(loom))]
    pub(crate) fn initialise(page: &Self) {
        page.magic.store(MAGIC, Ordering::Relaxed);
        page.version
            .store(PROTOCOL_VERSION.pack(), Ordering::Relaxed);
        page.turn.hand_over(Turn::Sidecar);
    }
}

impl Default for ControlBlock {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(not(loom))]
const _: () = {
    use core::mem::offset_of;

    assert!(size_of::<Slot>() == CACHE_LINE);
    assert!(align_of::<Slot>() == CACHE_LINE);
    assert!(offset_of!(Slot, kind) == 0);
    assert!(offset_of!(Slot, width) == 4);
    assert!(offset_of!(Slot, reason) == 5);
    assert!(offset_of!(Slot, address) == 8);
    assert!(offset_of!(Slot, value) == 16);
    assert!(offset_of!(Slot, instruction_count) == 24);
    assert!(offset_of!(Slot, program_counter) == 32);
    assert!(offset_of!(Slot, virtual_time_ns) == 40);
    assert!(offset_of!(Slot, deadline_ns) == 48);

    assert!(size_of::<TurnWord>() == CACHE_LINE);
    assert!(align_of::<TurnWord>() == CACHE_LINE);
    assert!(size_of::<InterruptLevels>() == 2 * CACHE_LINE);
    assert!(align_of::<InterruptLevels>() == CACHE_LINE);

    assert!(size_of::<ControlBlock>() == PAGE);
    assert!(align_of::<ControlBlock>() == PAGE);
    assert!(offset_of!(ControlBlock, magic) == 0);
    assert!(offset_of!(ControlBlock, version) == 8);
    assert!(offset_of!(ControlBlock, turn) == CACHE_LINE);
    assert!(offset_of!(ControlBlock, sidecar_slot) == 2 * CACHE_LINE);
    assert!(offset_of!(ControlBlock, core_slot) == 3 * CACHE_LINE);
    assert!(offset_of!(ControlBlock, interrupt_levels) == 4 * CACHE_LINE);
};

// Two attributes, not cfg(all(...)): clippy's tests_outside_test_module wants a literal cfg(test).
#[cfg(not(loom))]
#[cfg(test)]
mod tests {
    use core::mem::offset_of;
    use std::collections::BTreeMap;
    use std::process::Command;

    use super::*;
    use crate::sidecar::message::{Kind, StopReason};
    use crate::sidecar::turn::{SLEEP_QUANTUM, SPIN_WINDOW};

    const HEADER_PATH: &str = concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/include/rigless_proxy_protocol.h"
    );
    const HEADER: &str = include_str!("../../../include/rigless_proxy_protocol.h");
    const DEFINE_PREFIX: &str = "#define RIGLESS_PROXY_";

    fn header_defines() -> BTreeMap<String, u64> {
        HEADER
            .lines()
            .filter_map(|line| line.strip_prefix(DEFINE_PREFIX))
            .filter_map(|define| define.split_once(' '))
            .map(|(name, value)| {
                let value = value.trim().trim_end_matches("ULL");
                let parsed = value.strip_prefix("0x").map_or_else(
                    || value.parse(),
                    |hex_digits| u64::from_str_radix(hex_digits, 16),
                );
                (
                    name.to_owned(),
                    parsed.unwrap_or_else(|error| panic!("{name}: {error}")),
                )
            })
            .collect()
    }

    fn rust_defines() -> BTreeMap<String, u64> {
        let as_u64 = |value: usize| u64::try_from(value).expect("sizes and offsets fit in u64");
        let table = [
            ("MAGIC", MAGIC),
            ("VERSION_MAJOR", u64::from(PROTOCOL_VERSION.major)),
            ("VERSION_MINOR", u64::from(PROTOCOL_VERSION.minor)),
            ("VERSION_MAJOR_SHIFT", u64::from(VERSION_MAJOR_SHIFT)),
            ("CACHE_LINE", as_u64(CACHE_LINE)),
            ("PAGE", as_u64(PAGE)),
            ("CONTROL_BLOCK_SIZE", as_u64(size_of::<ControlBlock>())),
            ("SLOT_SIZE", as_u64(size_of::<Slot>())),
            ("MAGIC_OFFSET", as_u64(offset_of!(ControlBlock, magic))),
            ("VERSION_OFFSET", as_u64(offset_of!(ControlBlock, version))),
            ("TURN_OFFSET", as_u64(offset_of!(ControlBlock, turn))),
            (
                "SIDECAR_SLOT_OFFSET",
                as_u64(offset_of!(ControlBlock, sidecar_slot)),
            ),
            (
                "CORE_SLOT_OFFSET",
                as_u64(offset_of!(ControlBlock, core_slot)),
            ),
            (
                "INTERRUPT_LEVELS_OFFSET",
                as_u64(offset_of!(ControlBlock, interrupt_levels)),
            ),
            ("INTERRUPT_LINES", u64::from(INTERRUPT_LINES)),
            ("INTERRUPT_WORDS", as_u64(INTERRUPT_WORDS)),
            ("SLOT_KIND_OFFSET", as_u64(offset_of!(Slot, kind))),
            ("SLOT_WIDTH_OFFSET", as_u64(offset_of!(Slot, width))),
            ("SLOT_REASON_OFFSET", as_u64(offset_of!(Slot, reason))),
            ("SLOT_ADDRESS_OFFSET", as_u64(offset_of!(Slot, address))),
            ("SLOT_VALUE_OFFSET", as_u64(offset_of!(Slot, value))),
            (
                "SLOT_INSTRUCTION_COUNT_OFFSET",
                as_u64(offset_of!(Slot, instruction_count)),
            ),
            (
                "SLOT_PROGRAM_COUNTER_OFFSET",
                as_u64(offset_of!(Slot, program_counter)),
            ),
            (
                "SLOT_VIRTUAL_TIME_NS_OFFSET",
                as_u64(offset_of!(Slot, virtual_time_ns)),
            ),
            (
                "SLOT_DEADLINE_NS_OFFSET",
                as_u64(offset_of!(Slot, deadline_ns)),
            ),
            ("TURN_SIDECAR", u64::from(Turn::Sidecar as u32)),
            ("TURN_CORE", u64::from(Turn::Core as u32)),
            ("KIND_HELLO", u64::from(Kind::Hello as u32)),
            ("KIND_READ", u64::from(Kind::Read as u32)),
            ("KIND_WRITE", u64::from(Kind::Write as u32)),
            ("KIND_STOPPED", u64::from(Kind::Stopped as u32)),
            ("KIND_RUN_UNTIL", u64::from(Kind::RunUntil as u32)),
            ("KIND_REPLY", u64::from(Kind::Reply as u32)),
            ("KIND_SHUTDOWN", u64::from(Kind::Shutdown as u32)),
            ("REASON_DEADLINE", u64::from(StopReason::Deadline as u8)),
            ("REASON_IDLE", u64::from(StopReason::Idle as u8)),
            (
                "SPIN_WINDOW_US",
                u64::try_from(SPIN_WINDOW.as_micros()).expect("fits"),
            ),
            (
                "SLEEP_QUANTUM_US",
                u64::try_from(SLEEP_QUANTUM.as_micros()).expect("fits"),
            ),
        ];
        table
            .into_iter()
            .map(|(name, value)| (name.to_owned(), value))
            .collect()
    }

    #[test]
    fn header_defines_match_rust() {
        let header = header_defines();
        let rust = rust_defines();
        let header_names: Vec<&String> = header.keys().collect();
        let rust_names: Vec<&String> = rust.keys().collect();
        assert_eq!(header_names, rust_names, "the define sets differ");
        assert_eq!(header, rust);
    }

    #[test]
    #[cfg_attr(miri, ignore = "Miri cannot spawn a process")]
    fn header_compiles_as_c11() {
        let status = Command::new("cc")
            .args([
                "-std=c11",
                "-Wall",
                "-Wextra",
                "-Wpedantic",
                "-fsyntax-only",
            ])
            .args(["-x", "c", HEADER_PATH])
            .status()
            .expect("a C compiler named cc is on the path");
        assert!(status.success(), "cc exited with {status}");
    }

    #[test]
    fn version_packs_major_above_minor() {
        let version = Version { major: 1, minor: 2 };
        assert_eq!(version.pack(), 0x0001_0002);
        assert_eq!(Version::unpack(0x0001_0002), version);
    }

    #[test]
    fn version_displays_as_major_dot_minor() {
        assert_eq!(Version { major: 1, minor: 2 }.to_string(), "1.2");
    }

    #[test]
    fn interrupt_line_rejects_index_at_the_limit() {
        assert!(InterruptLine::new(INTERRUPT_LINES - 1).is_some());
        assert!(InterruptLine::new(INTERRUPT_LINES).is_none());
    }

    #[test]
    fn raise_sets_the_bit_in_the_second_word() {
        let levels = InterruptLevels::default();
        let line = InterruptLine::new(70).expect("70 is below the limit");
        levels.raise(line);
        let words: Vec<u64> = levels
            .0
            .iter()
            .map(|word| word.load(Ordering::Relaxed))
            .collect();
        assert_eq!(words[0], 0);
        assert_eq!(words[1], 1 << 6);
        assert!(words[2..].iter().all(|word| *word == 0));
        assert!(levels.is_raised(line));
    }

    #[test]
    fn lower_clears_only_its_own_bit() {
        let levels = InterruptLevels::default();
        let line = InterruptLine::new(70).expect("70 is below the limit");
        let neighbour = InterruptLine::new(71).expect("71 is below the limit");
        levels.raise(line);
        levels.raise(neighbour);
        levels.lower(line);
        assert!(!levels.is_raised(line));
        assert!(levels.is_raised(neighbour));
        assert_eq!(levels.0[1].load(Ordering::Relaxed), 1 << 7);
    }
}