rigless 0.0.5

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; a fork of QEMU is built in and executes the instructions
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! One firmware on one target of one machine: the bus, the events, and what the run records.
//!
//! `Session`, in `rigless::session`, is the models' side of a run, called by whatever executes the
//! instructions. The emulator calls [`Session::access`] for every peripheral access as the core makes
//! it, at the access's own instant: a read answers with the model's or the register file's value and a
//! write applies to it, the models that asked for an access of their own or drove a request get it at
//! that same instant, and every line a model set is driven before the next instruction executes. The
//! answer carries the next instant the models need control at, the earlier of the next event and where
//! the run is asked to reach, and the emulator stops there and calls [`Session::stop`]: the events due
//! fire in order, every model gets a look outside, the part settles again, and the answer is the next
//! instant once more. A run is asked to reach an instant with [`Session::run_until`]; a stop at or past
//! it is [`Session::reached`], and whoever drives the run decides what comes next.
//!
//! An access the bus refuses because no register covers the address answers zero and is remembered once
//! with the program counter of its first occurrence, which [`Session::unmodeled`] lists by ascending
//! address; one it refuses although it carries the register, because a gate the part declares is shut,
//! is remembered with its fault and listed by [`Session::refused`], as is every access a model asked for
//! that the bus would not make. An access served from a register file with no model behind it is
//! remembered for [`Session::unbacked`], and an interrupt the machine's route has no line for as instance
//! and number for [`Session::unrouted`]. A reset the firmware requests is a stop of its own: at its
//! instant the part restarts, every register at its reset value with whatever the board put on its pins
//! still there and the backup domain kept, the emulator lowers every line the part drove, the instant is
//! remembered for [`Session::resets`], and the run goes on from the restarted core, which finds flash and
//! memory as the firmware left them. A core halted when an instant comes has had its clock warped to it,
//! which is the instant reached as well.

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

use crate::device::InterruptChange;
use crate::emulator::{Access, AccessKind, Error, Host, InterruptLine, Stop, StopReason};
use crate::machine::Board;
use crate::mcu::{Mcu, Refused};
use crate::soc::{Bus, Fault};
use crate::time::VirtualNanos;

/// A firmware on one target of a machine, from the first access to the last stop.
///
/// The session owns the board and steps one of its MCUs; the board keeps every model reachable by
/// field once the run has ended, through [`Session::board`].
#[derive(Debug)]
pub struct Session<D: Board + ?Sized = dyn Board> {
    board: Box<D>,
    target: usize,
    route: fn(u16) -> Option<InterruptLine>,
    // Whether each slot of the target's bus has a model, which is fixed once the MCU is built.
    backed: Box<[bool]>,
    until: VirtualNanos,
    report: Report,
    // A settle inside an access cannot answer with its error, so it is kept for the driver.
    failed: Option<Error>,
}

// What the bus refused or answered from a register file alone, kept for the run's report.
#[derive(Debug, Default)]
struct Report {
    unmodeled: BTreeMap<u64, u64>,
    unrouted: BTreeSet<(&'static str, u16)>,
    refused: BTreeMap<u64, Fault>,
    unbacked: BTreeMap<u64, Unbacked>,
    resets: Vec<VirtualNanos>,
}

/// An access the bus served from a register file with no model behind it.
///
/// The instance is one the part carries and has no model for, so a read answers the register's value and a
/// write lands in it and nothing reacts. That is not a fault and the firmware is not told: it is a gap
/// between what the part describes and what it models, and a firmware that waits on something only a model
/// would do waits forever. A write is the sharper half — a firmware writes to hardware to make something
/// happen, and here nothing did.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Unbacked {
    /// The instance, as the part names it.
    pub instance: &'static str,
    /// The register covering the address, as the part names it.
    pub register: &'static str,
    /// The program counter of the first access here.
    pub program_counter: u64,
    /// How many reads were answered from the register file.
    pub reads: u64,
    /// How many writes landed in it.
    pub writes: u64,
    /// What the last of those writes carried, so the report reads on its own without the bus.
    pub written: Option<u64>,
}

impl<D: Board + ?Sized> Session<D> {
    /// A session on `board`'s target at `target`, asked to reach `until`.
    ///
    /// The board's targets are its MCUs in its own order; a board with one has it at zero.
    pub fn new(mut board: Box<D>, target: usize, until: VirtualNanos) -> Result<Self, Error> {
        let machine = board.name();
        let count = board.target_names().len();
        let route = {
            let targets = board.targets();
            targets
                .get(target)
                .map(|chosen| chosen.interrupt_line)
                .ok_or(Error::NoTarget {
                    machine,
                    target,
                    count,
                })?
        };
        let backed = {
            let mcu = board.mcu(target).ok_or(Error::NoTarget {
                machine,
                target,
                count,
            })?;
            (0..mcu.bus().slots().len())
                .map(|slot| mcu.split(slot).1.is_some())
                .collect()
        };
        Ok(Self {
            board,
            target,
            route,
            backed,
            until,
            report: Report::default(),
            failed: None,
        })
    }

    /// The index of the target this session steps.
    #[must_use]
    pub fn target(&self) -> usize {
        self.target
    }

    /// The instant the run is asked to reach.
    #[must_use]
    pub fn until(&self) -> VirtualNanos {
        self.until
    }

    /// Asks the run to reach `until`: the next answer names the earlier of the next event and it.
    pub fn run_until(&mut self, until: VirtualNanos) {
        self.until = until;
    }

    /// Whether `stop` is the instant the run was asked to reach: at or past it, and not a reset.
    #[must_use]
    pub fn reached(&self, stop: &Stop) -> bool {
        stop.reason != StopReason::Reset && stop.at >= self.until
    }

    /// The next instant the models need control at: the earlier of the next event and where the run
    /// is asked to reach.
    pub fn next_deadline(&mut self) -> VirtualNanos {
        let until = self.until;
        deadline_before(self.mcu().next_event(), until)
    }

    /// One access at its instant: the value read, or the value written back, and the next instant the
    /// models need control at.
    ///
    /// The models may drive a request or ask for an access of their own; both happen at the instant of
    /// the access that caused them, through `host`, before the core has its answer, and so does every
    /// line they set.
    pub fn access(&mut self, access: Access, host: &mut dyn Host) -> (u64, VirtualNanos) {
        let Self {
            board,
            target,
            route,
            backed,
            until,
            report,
            failed,
        } = self;
        let route = *route;
        let until = *until;
        let mcu = board.mcu(*target).expect("checked at construction");
        let served = match access.kind {
            AccessKind::Read => mcu.read(access.virtual_time, access.address, access.width),
            AccessKind::Write => mcu
                .write(
                    access.virtual_time,
                    access.address,
                    access.width,
                    access.value,
                )
                .map(|()| access.value),
        };
        let value = match served {
            Ok(value) => {
                answered_from_a_file_alone(report, backed, mcu.bus(), &access);
                value
            }
            Err(fault) => {
                refuse(report, access.address, Some(access.program_counter), fault);
                0
            }
        };
        match mcu.settle(host) {
            Ok(refusals) => note(report, refusals),
            Err(error) => {
                failed.get_or_insert(error);
            }
        }
        apply(
            route,
            mcu.drain_interrupts().into_iter(),
            host,
            &mut report.unrouted,
        );
        (value, deadline_before(mcu.next_event(), until))
    }

    /// The emulator stopped: the events due fire in order, every model gets a look outside, the part
    /// settles, and a reset restarts it. Answers the next instant the models need control at.
    pub fn stop(&mut self, stop: Stop, host: &mut dyn Host) -> VirtualNanos {
        let Self {
            board,
            target,
            route,
            until,
            report,
            failed,
            ..
        } = self;
        let route = *route;
        let until = *until;
        let mcu = board.mcu(*target).expect("checked at construction");
        mcu.advance_to(stop.at);
        mcu.poll(stop.at);
        match mcu.settle(host) {
            Ok(refusals) => note(report, refusals),
            Err(error) => {
                failed.get_or_insert(error);
            }
        }
        apply(
            route,
            mcu.drain_interrupts().into_iter(),
            host,
            &mut report.unrouted,
        );
        if stop.reason == StopReason::Reset {
            mcu.reset();
            report.resets.push(stop.at);
        }
        deadline_before(mcu.next_event(), until)
    }

    /// The first error a settle met inside an access or a stop, which the answer could not carry.
    pub fn take_error(&mut self) -> Option<Error> {
        self.failed.take()
    }

    /// The addresses the bus refused, each with the program counter of its first access, by ascending
    /// address.
    #[must_use]
    pub fn unmodeled(&self) -> &BTreeMap<u64, u64> {
        &self.report.unmodeled
    }

    /// The accesses the bus refused although it carries the register, by ascending address, each with
    /// the fault of its first occurrence.
    ///
    /// This is the gated peripheral: the part declares what has to be true elsewhere for an instance to
    /// answer, and a firmware or a model that reaches one before it is true is refused. It is separate from
    /// [`Session::unmodeled`] because the address is modelled and the part said no.
    #[must_use]
    pub fn refused(&self) -> &BTreeMap<u64, Fault> {
        &self.report.refused
    }

    /// The accesses the bus answered from a register file with no model behind it, by ascending address,
    /// each with the instance, the register, and the program counter of its first occurrence.
    ///
    /// An entry whose `writes` is not zero is the one to read first. A read of a reset value is often
    /// exactly right; a write is a firmware asking the part to do something, and here nothing did.
    ///
    /// Only the core's own accesses are counted. An access a model asks the runtime to make on its behalf
    /// reaches the bus through the settle rather than through an instruction, and the settle answers with
    /// what it refused and not with what it served, so a controller moving a word into an instance with no
    /// model is not reported here.
    #[must_use]
    pub fn unbacked(&self) -> &BTreeMap<u64, Unbacked> {
        &self.report.unbacked
    }

    /// The interrupts the target had no line for, as instance and number, by instance then number.
    #[must_use]
    pub fn unrouted(&self) -> &BTreeSet<(&'static str, u16)> {
        &self.report.unrouted
    }

    /// The instants the firmware requested a system reset, in order: at each the target restarted
    /// from its reset vector with every peripheral at its reset value, the board's chips as they stood, and
    /// its memory as the firmware left it.
    #[must_use]
    pub fn resets(&self) -> &[VirtualNanos] {
        &self.report.resets
    }

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

    /// The board, whole, to reach a model by field and ask what its boundary recorded.
    ///
    /// The session carries the board's own type, so where the caller built a concrete board this is a
    /// `&mut` to that type, and every model on it is a field path from here. Nothing is downcast.
    pub fn board_mut(&mut self) -> &mut D {
        &mut self.board
    }

    /// The target's MCU.
    pub fn mcu(&mut self) -> &mut dyn Mcu {
        self.board
            .mcu(self.target)
            .expect("checked at construction")
    }
}

// An access the bus served from an instance with no model behind it. Remembered per address, with the
// program counter of the first one, so a polling loop is one line rather than thousands.
fn answered_from_a_file_alone(report: &mut Report, backed: &[bool], bus: &Bus, access: &Access) {
    let Some(slot) = bus.slot_at(access.address) else {
        return;
    };
    if backed[slot] {
        return;
    }
    let peripheral = bus.slots()[slot].peripheral();
    let entry = report.unbacked.entry(access.address).or_insert_with(|| {
        let offset = access.address - peripheral.base;
        Unbacked {
            instance: peripheral.name,
            register: peripheral
                .registers
                .iter()
                .find(|register| {
                    (u64::from(register.offset)..u64::from(register.end())).contains(&offset)
                })
                .map_or("?", |register| register.name),
            program_counter: access.program_counter,
            reads: 0,
            writes: 0,
            written: None,
        }
    });
    match access.kind {
        AccessKind::Read => entry.reads += 1,
        AccessKind::Write => {
            entry.writes += 1;
            entry.written = Some(access.value);
        }
    }
}

// An access the bus refused: an address no register covers is a gap in the model, and one a gate shut is
// the part saying no. They are counted apart because the first is work to do and the second is the model.
fn refuse(report: &mut Report, address: u64, program_counter: Option<u64>, fault: Fault) {
    match program_counter {
        Some(program_counter) if !matches!(fault, Fault::Gated { .. }) => {
            report.unmodeled.entry(address).or_insert(program_counter);
        }
        // A model's access carries no program counter, so nothing of its can be read as a gap in the map.
        _ => {
            report.refused.entry(address).or_insert(fault);
        }
    }
}

fn note(report: &mut Report, refusals: Vec<Refused>) {
    for refusal in refusals {
        refuse(report, refusal.address, None, refusal.fault);
    }
}

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 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>,
    host: &mut dyn Host,
    unrouted: &mut BTreeSet<(&'static str, u16)>,
) {
    for change in changes {
        match route(change.interrupt) {
            Some(line) => host.set_interrupt(line, change.level),
            None => {
                unrouted.insert((change.instance, change.interrupt));
            }
        }
    }
}