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 seam between the core and whatever executes instructions.

use std::path::{Path, PathBuf};
use std::process::ExitStatus;

use crate::sidecar::layout::{InterruptLevels, Version};
use crate::sidecar::message::{DecodeError, SidecarMessage, StopReason};
use crate::sidecar::turn::WaitError;
use crate::soc::Width;
use crate::time::VirtualNanos;

/// An engine that executes the firmware and hands every peripheral access to the core.
pub trait CpuBackend {
    /// Executes until the virtual clock reaches `deadline`, calling `on_access` for every peripheral access as it happens.
    fn run_until(
        &mut self,
        deadline: VirtualNanos,
        on_access: &mut dyn FnMut(Access, &InterruptLevels) -> Reply,
    ) -> Result<Stop, BackendError>;

    /// The interrupt levels the engine applies before its next instruction; valid between runs.
    fn interrupts(&self) -> &InterruptLevels;

    /// Asks the engine to exit and waits for it.
    fn shutdown(self) -> Result<(), BackendError>;
}

/// What the core was doing when the sidecar exited.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Phase {
    /// Waiting for `Hello`.
    Handshake,
    /// Waiting for the next access or the stop.
    Run,
    /// Waiting for the process to exit after `Shutdown`.
    Shutdown,
}

impl core::fmt::Display for Phase {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        formatter.write_str(match self {
            Self::Handshake => "waiting for Hello",
            Self::Run => "waiting for the next access or the stop",
            Self::Shutdown => "shutting down",
        })
    }
}

/// Which way an access goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AccessKind {
    /// The firmware reads and waits for the value.
    Read,
    /// The firmware writes and waits for the write to take effect.
    Write,
}

/// One peripheral access, as the engine reports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Access {
    /// Read or write.
    pub kind: AccessKind,
    /// The guest address.
    pub address: u64,
    /// The access width.
    pub width: Width,
    /// The value written; zero for a read.
    pub value: u64,
    /// The instructions retired including the accessing one.
    pub instruction_count: u64,
    /// The virtual clock at the access.
    pub virtual_time: VirtualNanos,
    /// The program counter of the access.
    pub program_counter: u64,
}

/// The core's answer to an access.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Reply {
    /// The value read; ignored for a write.
    pub value: u64,
    /// When the core next needs control.
    pub next_deadline: VirtualNanos,
}

/// Where a run ended.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Stop {
    /// The virtual clock at the stop.
    pub at: VirtualNanos,
    /// The instructions retired before the stop.
    pub instruction_count: u64,
    /// Why it stopped.
    pub reason: StopReason,
}

/// What can go wrong on the engine's side; a peripheral fault is never one of these.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BackendError {
    /// No binary was found: the explicit path is not a file, or, with none given, `PATH` has no binary of the name.
    #[error("no sidecar binary: {}", describe_search(explicit.as_deref(), on_path))]
    NoSidecar {
        /// The path given explicitly, when one was.
        explicit: Option<PathBuf>,
        /// The name looked for on `PATH` when no path is given.
        on_path: &'static str,
    },
    /// The binary could not be started.
    #[error("spawning {binary}: {source}", binary = binary.display())]
    Spawn {
        /// The binary that was started.
        binary: PathBuf,
        /// What the operating system said.
        #[source]
        source: std::io::Error,
    },
    /// A shared file could not be created, sized, or mapped.
    #[error("shared file {path}: {source}", path = path.display())]
    Files {
        /// The file.
        path: PathBuf,
        /// What the operating system said.
        #[source]
        source: std::io::Error,
    },
    /// The sidecar speaks another protocol version.
    #[error("the sidecar speaks protocol {sidecar}, this side expects {expected}")]
    Version {
        /// What `Hello` carried.
        sidecar: Version,
        /// What this crate speaks.
        expected: Version,
    },
    /// The turn word went wrong.
    #[error(transparent)]
    Peer(#[from] WaitError),
    /// The cancel flag was set while the core waited on the sidecar.
    #[error("the run was cancelled")]
    Cancelled,
    /// The sidecar wrote a slot this side cannot read.
    #[error(transparent)]
    Decode(#[from] DecodeError),
    /// The sidecar sent a message that has no place at this point of the exchange.
    #[error("the sidecar sent {message:?} where it was not expected")]
    Unexpected {
        /// The message.
        message: SidecarMessage,
    },
    /// The sidecar exited while the core was waiting on it.
    #[error("the sidecar exited with {status} while {during}")]
    Died {
        /// How it exited.
        status: ExitStatus,
        /// What the core was doing.
        during: Phase,
    },
    /// The sidecar process could not be polled, waited for, or killed.
    #[error("the sidecar process: {0}")]
    Process(#[source] std::io::Error),
    /// The sidecar was still running when the grace after `Shutdown` ran out, and was killed.
    #[error("the sidecar ignored Shutdown past the grace period and was killed")]
    ShutdownTimedOut,
    /// The session was used after its sidecar was shut down.
    #[error("the sidecar has already been shut down")]
    ShutDown,
}

// Where the sidecar package is published; the crate does not carry the binary.
const SIDECAR_RELEASES: &str = "https://gitlab.com/rigless-labs/qemu/-/releases";

fn describe_search(explicit: Option<&Path>, on_path: &str) -> String {
    explicit.map_or_else(
        || format!("no {on_path} on PATH; it is the package published at {SIDECAR_RELEASES}"),
        |explicit| format!("the explicit path {} is not a file", explicit.display()),
    )
}