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
//! One firmware per target running on one machine: the engines, the buses, the events, and the run loop that joins them.
//!
//! `Session`, in `rigless::session`, is one firmware per target of one machine over any engine behind `CpuBackend`; `Session::spawn` starts one sidecar per target from the machine's order and completes every handshake, and `Session::over` takes engines already running, which is how the tests drive the loop over scripted ones. `run` executes to an instant on the virtual clock in rounds: each round takes the earliest instant any target has an event at, and steps every target to it in the machine's order, so the targets are always at one instant when `run` returns and no target runs past an instant another was waiting at. Within a target the engine runs to the earlier of its own next event and the round's, every access is served from that target's bus at the access's own time, a read answering with the model's or the register file's value and a write applying to it, and every reply re-arms the engine to the earlier of the next event and the end. At each stop the events due fire in order, and the interrupt levels the models set go through the machine's route to the engine's bitmap, inside the reply for a level set during an access and before the next run for one set by an event, so a line raised during a write is high before the next instruction. An access the bus refuses, an address no register covers, answers zero and is remembered once with the program counter of its first occurrence, which `unmodeled` lists for that target by ascending address; an interrupt the route has no line for is remembered once as instance and number, which `unrouted` lists the same way. Each target keeps its own two reports, so one MCU's gap is never read as another's. The caller's callback sees each access with the index of the target that made it and the value read or written, and `run` answers with each target's stop in the same order. Every target ends the run at the instant asked for: an engine stops at a deadline, and a CPU halted in `wfi` when one comes has had its clock warped to it, which is the deadline reached as well, so an event a sleeping firmware waits on fires, raises its line, and the run goes on to the wake-up. An engine that fails ends the run there instead, with an error. `shutdown` asks every engine to exit and waits for each, and dropping a session kills every sidecar.

use std::collections::{BTreeMap, BTreeSet};

use crate::device::{InterruptChange, Level};
use crate::machine::{Machine, Target};
use crate::sidecar::{
    Access, AccessKind, BackendError, CpuBackend, InterruptLevels, InterruptLine, Reply, Sidecar,
    SidecarOptions, Stop, StopReason, TargetStart,
};
use crate::time::VirtualNanos;

/// A firmware on every target of a machine, from the handshakes to the shutdown, over any engine behind
/// [`CpuBackend`].
///
/// The loop is rounds. Each round takes the earliest instant any target
/// needs control at, and steps every target to it in the machine's order; within a target the engine runs
/// to the earlier of its own next event and that instant, every access is served from that target's bus at
/// its instant, the events due fire in order, and the levels its models set reach its engine before its
/// next instruction. Because a round always steps every target, they are all at the same instant whenever
/// [`Session::run`] returns. Dropping a session kills every sidecar; [`Session::shutdown`] ends them in
/// order and waits for each, and a set cancel flag ends a wait with [`BackendError::Cancelled`] so that
/// drop is reached.
#[derive(Debug)]
pub struct Session<B: CpuBackend = Sidecar> {
    machine: Machine,
    backends: Option<Vec<B>>,
    reports: Vec<Report>,
}

// One target and what runs and records it, for the length of one round.
struct Stepping<'a, B: CpuBackend> {
    target: &'a mut Target,
    backend: &'a mut B,
    report: &'a mut Report,
}

// What one target's bus refused, kept for the run's report.
#[derive(Debug, Default)]
struct Report {
    unmodeled: BTreeMap<u64, u64>,
    unrouted: BTreeSet<(&'static str, u16)>,
}

impl Session<Sidecar> {
    /// Starts one sidecar per target, in the machine's order, and completes every handshake.
    ///
    /// `starts` is one entry per target, in that same order. A target whose sidecar fails to start ends the
    /// whole spawn, and every sidecar already started is killed as it drops.
    ///
    /// # Panics
    ///
    /// Panics when `starts` is not one per target, which is a mistake in the caller.
    pub fn spawn(
        machine: Machine,
        starts: &[TargetStart<'_>],
        options: &SidecarOptions,
    ) -> Result<Self, BackendError> {
        assert_eq!(starts.len(), machine.targets.len(), "one image per target");
        let backends = machine
            .targets
            .iter()
            .zip(starts)
            .map(|(target, start)| {
                Sidecar::spawn(&target.sidecar, target.soc.memory(), start, options)
            })
            .collect::<Result<Vec<_>, _>>()?;
        Ok(Self::over(machine, backends))
    }
}

impl<B: CpuBackend> Session<B> {
    /// A session over engines already running the firmware, one per target in the machine's order.
    ///
    /// # Panics
    ///
    /// Panics when the counts disagree, which is a mistake in the caller and not a run-time condition.
    pub fn over(machine: Machine, backends: Vec<B>) -> Self {
        assert_eq!(
            backends.len(),
            machine.targets.len(),
            "one engine per target"
        );
        let reports = machine.targets.iter().map(|_| Report::default()).collect();
        Self {
            machine,
            backends: Some(backends),
            reports,
        }
    }

    /// Runs every target to `until` on the virtual clock, serving each access from that target's bus and
    /// firing every event on the way, and returns where each target stopped, in the machine's order.
    ///
    /// A read answers with the model's or the register file's value and a write applies to it; an access a
    /// bus refuses answers zero and is remembered once, with the program counter of its first occurrence,
    /// for [`Session::unmodeled`]. An interrupt a target has no line for is remembered once for
    /// [`Session::unrouted`]. `on_access` sees each access with the index of the target that made it and
    /// the value read or written. Every target ends the run at `until`: an engine stops at a deadline, and a
    /// target halted when one comes has had its clock warped to it, which is the deadline reached as well. An
    /// engine that fails ends the run there instead, with an error.
    pub fn run(
        &mut self,
        until: VirtualNanos,
        on_access: &mut dyn FnMut(usize, &Access, u64),
    ) -> Result<Vec<Stop>, BackendError> {
        let Self {
            machine,
            backends,
            reports,
        } = self;
        let backends = backends.as_mut().ok_or(BackendError::ShutDown)?;
        let mut stops: Vec<Stop> = Vec::with_capacity(machine.targets.len());
        loop {
            // ceiling: the quantum is the earliest event any target has when the round begins, and nothing
            // crosses between targets; the first link between two of them also needs a longest quantum, since
            // an event a model creates mid-round is not one an earlier target was held back for.
            let quantum = machine
                .targets
                .iter()
                .filter_map(|target| target.soc.next_event())
                .min()
                .map_or(until, |next| next.min(until));
            stops.clear();
            for ((index, target), backend) in
                machine.targets.iter_mut().enumerate().zip(&mut *backends)
            {
                let stepping = Stepping {
                    target,
                    backend,
                    report: &mut reports[index],
                };
                stops.push(advance(stepping, quantum, &mut |access, value| {
                    on_access(index, access, value);
                })?);
            }
            if quantum >= until {
                return Ok(stops);
            }
        }
    }

    /// The addresses a target's bus refused, each with the program counter of its first access, by
    /// ascending address.
    ///
    /// # Panics
    ///
    /// Panics when `target` is not an index of the machine's targets.
    #[must_use]
    pub fn unmodeled(&self, target: usize) -> &BTreeMap<u64, u64> {
        &self.reports[target].unmodeled
    }

    /// The interrupts a target had no line for, as instance and number, by instance then number.
    ///
    /// # Panics
    ///
    /// Panics when `target` is not an index of the machine's targets.
    #[must_use]
    pub fn unrouted(&self, target: usize) -> &BTreeSet<(&'static str, u16)> {
        &self.reports[target].unrouted
    }

    /// The machine this session runs on.
    #[must_use]
    pub fn machine(&self) -> &Machine {
        &self.machine
    }

    /// The engines this session drives, one per target in the machine's order.
    #[must_use]
    pub fn backends(&self) -> Option<&[B]> {
        self.backends.as_deref()
    }

    /// Asks every engine to exit and waits for each, and reports the first failure once all have been asked.
    pub fn shutdown(mut self) -> Result<(), BackendError> {
        let backends = self.backends.take().ok_or(BackendError::ShutDown)?;
        backends
            .into_iter()
            .map(CpuBackend::shutdown)
            .fold(Ok(()), Result::and)
    }
}

// One target to `quantum`, as `Session::run` describes it.
fn advance<B: CpuBackend>(
    stepping: Stepping<'_, B>,
    quantum: VirtualNanos,
    on_access: &mut dyn FnMut(&Access, u64),
) -> Result<Stop, BackendError> {
    let Stepping {
        target,
        backend,
        report,
    } = stepping;
    let Target {
        soc,
        interrupt_line: route,
        ..
    } = target;
    let route = *route;
    loop {
        let deadline = deadline_before(soc.next_event(), quantum);
        let stop = backend.run_until(deadline, &mut |access, levels| {
            let served = match access.kind {
                AccessKind::Read => soc.read(access.virtual_time, access.address, access.width),
                AccessKind::Write => soc
                    .write(
                        access.virtual_time,
                        access.address,
                        access.width,
                        access.value,
                    )
                    .map(|()| access.value),
            };
            let value = served.unwrap_or_else(|_fault| {
                report
                    .unmodeled
                    .entry(access.address)
                    .or_insert(access.program_counter);
                0
            });
            apply(route, soc.drain_interrupts(), levels, &mut report.unrouted);
            on_access(&access, value);
            Reply {
                value,
                next_deadline: deadline_before(soc.next_event(), quantum),
            }
        })?;
        soc.advance_to(stop.at);
        apply(
            route,
            soc.drain_interrupts(),
            backend.interrupts(),
            &mut report.unrouted,
        );
        // Idle is the deadline reached with the clock warped to it, so nothing was missed by stopping.
        let reached_the_deadline = matches!(stop.reason, StopReason::Deadline | StopReason::Idle);
        if !reached_the_deadline || stop.at >= quantum {
            return Ok(stop);
        }
    }
}

fn deadline_before(next_event: Option<VirtualNanos>, until: VirtualNanos) -> VirtualNanos {
    next_event.map_or(until, |at| at.min(until))
}

// ceiling: a route is a pure function from an interrupt to a proxy line; upgrade when the first bus-side interrupt
// controller needs a change delivered to its model instead of a line.
fn apply(
    route: fn(u16) -> Option<InterruptLine>,
    changes: impl Iterator<Item = InterruptChange>,
    levels: &InterruptLevels,
    unrouted: &mut BTreeSet<(&'static str, u16)>,
) {
    for change in changes {
        match route(change.interrupt) {
            Some(line) => match change.level {
                Level::High => levels.raise(line),
                Level::Low => levels.lower(line),
            },
            None => {
                unrouted.insert((change.instance, change.interrupt));
            }
        }
    }
}