rigless 0.2.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, 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
//! A run that prints what `rigless run` prints, for a board the command line doesn't carry.

use std::io::{self, BufWriter, Write};

use super::{
    Access, AccessKind, Driver, Error, Finished, Host, Settled, Start, Stop, StopReason, run,
};
use crate::coverage::Coverage;
use crate::link::values_of;
use crate::machine::Board;
use crate::session::Session;
use crate::time::VirtualNanos;

/// The deadline of a run given none: 292 years of virtual time, which no run reaches.
///
/// It's the most a signed count of nanoseconds holds, so nothing longer can be asked for.
pub const NO_DEADLINE: VirtualNanos = VirtualNanos(i64::MAX as u64);

/// What a printed run does with each access.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Accesses {
    /// A line for each, as it happens.
    Printed,
    /// Counted toward the coverage, and no line.
    Counted,
}

/// What a printed run hands back: the session with its board and its reports, what the firmware
/// reached, and the output it printed to.
#[derive(Debug)]
#[non_exhaustive]
pub struct Printed<D: Board + ?Sized, W> {
    /// The session, its board and its reports as the run left them.
    pub session: Session<D>,
    /// Every instance the MCU carries and which of its registers the firmware reached, and any
    /// address outside all of them. Its `Display` is the file `rigless run --coverage` writes.
    pub coverage: Coverage,
    /// Where the run printed, flushed.
    pub output: W,
    /// The status the firmware ended the run with, through a host call, or `None` when it didn't.
    pub exit_status: Option<u32>,
}

/// Runs a firmware on a session's target like [`run`] does, and prints what `rigless run` prints: a
/// line per access as it happens, each write to the host's console as it happens, each of the
/// board's outputs that changed, read every `outputs` period, then the stop, the resets, the
/// unmapped, refused and unimplemented gaps, the accesses to undefined space, the unrouted gaps,
/// what a model told, and the status the firmware ended the run with. A refusal by a shut gate is the
/// part saying no, so it isn't one.
///
/// With [`Accesses::Counted`] it prints everything but the line per access, which is what you want
/// from a firmware big enough that the accesses bury the rest. The coverage is the same either way.
///
/// The session's `until` is how far to run. [`NO_DEADLINE`] runs until `start`'s interrupt is set,
/// and the stop it prints then is where the run stood, with `reason=interrupted`. The debugger is
/// `start`'s. A host on one of the board's wires is attached before the session is made, with
/// [`Board::attach_host`].
///
/// # Errors
///
/// What [`run`] answers, what a model's access failed with, and a write to `output` that failed. An
/// `outputs` period of zero is [`Error::ZeroPeriod`], and one for a board with no edge is
/// [`Error::NoEdge`], before anything runs.
pub fn run_printed<D, W>(
    mut session: Session<D>,
    start: &Start<'_>,
    outputs: Option<VirtualNanos>,
    accesses: Accesses,
    output: W,
) -> Result<Printed<D, W>, Error>
where
    D: Board + ?Sized + 'static,
    W: Write + Send + 'static,
{
    if outputs.is_some_and(|period| period.0 == 0) {
        return Err(Error::ZeroPeriod);
    }
    if outputs.is_some() && session.board_mut().edge().is_none() {
        return Err(Error::NoEdge {
            machine: session.board().name(),
        });
    }
    let until = session.until();
    // With outputs to read, the first stop is the first period, and each stop asks for the next.
    session.run_until(outputs.map_or(until, |period| period.min(until)));
    let target = session.target();
    let coverage = Coverage::of(
        session
            .board_mut()
            .mcu(target)
            .expect("the session checked the target"),
    );
    let driver = Printing {
        output: BufWriter::new(output),
        coverage,
        until,
        period: outputs,
        outputs: Vec::new(),
        accesses,
        failed: None,
    };
    let Finished {
        mut session,
        driver,
        stop,
        exit_status,
        interrupted,
    } = run(session, start, driver)?;
    let Printing {
        mut output,
        coverage,
        failed,
        ..
    } = driver;
    if let Some(error) = failed {
        return Err(error.into());
    }
    if let Some(error) = session.take_error() {
        return Err(error);
    }
    let ended = stop
        .map(|stop| (stop.at, stop.instruction_count, stop_reason(stop.reason)))
        .or(interrupted.map(|stood| (stood.at, stood.instruction_count, "interrupted")));
    if let Some((at, count, reason)) = ended {
        writeln!(output, "stop t={} n={count} reason={reason}", at.0)?;
    }
    for reset in session.resets() {
        let cause = match reset.cause {
            crate::device::ResetCause::Core => "core".to_owned(),
            crate::device::ResetCause::Model(instance) => instance.to_owned(),
            crate::device::ResetCause::Pin => "pin".to_owned(),
        };
        writeln!(
            output,
            "reset t={} n={} cause={cause}",
            reset.at.0, reset.instructions
        )?;
    }
    for unmapped in session.unmapped() {
        writeln!(output, "{unmapped}")?;
    }
    for (address, fault) in session.refused() {
        if !fault.is_shut_gate() {
            writeln!(output, "refused at={address:#010x} why={fault}")?;
        }
    }
    for (address, seen) in session.unimplemented() {
        let wrote = seen
            .written
            .map_or_else(String::new, |value| format!(" wrote={value:#010x}"));
        writeln!(
            output,
            "unimplemented instance={} register={} at={address:#010x} pc={:#010x} reads={} \
             writes={}{wrote}",
            seen.instance, seen.register, seen.program_counter, seen.reads, seen.writes
        )?;
    }
    for (address, seen) in session.undefined() {
        writeln!(
            output,
            "undefined instance={} at={address:#010x} pc={:#010x} reads={} writes={}",
            seen.instance, seen.program_counter, seen.reads, seen.writes
        )?;
    }
    for (instance, interrupt) in session.unrouted() {
        writeln!(output, "unrouted instance={instance} interrupt={interrupt}")?;
    }
    for told in session.told() {
        writeln!(
            output,
            "told instance={} register={} t={} reason={}",
            told.instance, told.register, told.at.0, told.reason
        )?;
    }
    if let Some(code) = exit_status {
        writeln!(output, "exit code={code}")?;
    }
    let output = output
        .into_inner()
        .map_err(io::IntoInnerError::into_error)?;
    Ok(Printed {
        session,
        coverage,
        output,
        exit_status,
    })
}

/// Prints every access as it happens and the outputs as they change.
struct Printing<W: Write + Send> {
    output: BufWriter<W>,
    coverage: Coverage,
    until: VirtualNanos,
    period: Option<VirtualNanos>,
    outputs: Vec<i64>,
    accesses: Accesses,
    failed: Option<io::Error>,
}

impl<D: Board + ?Sized, W: Write + Send> Driver<D> for Printing<W> {
    fn on_settled(&mut self, _session: &mut Session<D>, _host: &mut dyn Host, settled: Settled) {
        let Settled::Access { access, value } = settled else {
            return;
        };
        self.coverage.saw(&access);
        if self.accesses == Accesses::Printed && self.failed.is_none() {
            self.failed = write_access(&mut self.output, &access, value).err();
        }
    }

    fn on_console(&mut self, at: VirtualNanos, instruction_count: u64, bytes: &[u8]) {
        if self.failed.is_none() {
            self.failed =
                write_text(&mut self.output, "console", at, instruction_count, bytes).err();
        }
    }

    fn on_debugger_print(&mut self, at: VirtualNanos, instruction_count: u64, text: &[u8]) {
        if self.failed.is_none() {
            self.failed =
                write_text(&mut self.output, "dprintf", at, instruction_count, text).err();
        }
    }

    fn on_debugger_attached(&mut self, at: VirtualNanos, instruction_count: u64, attached: bool) {
        let what = if attached { "attached" } else { "detached" };
        if self.failed.is_none() {
            self.failed = writeln!(
                self.output,
                "debugger {what} t={} n={instruction_count}",
                at.0
            )
            .err();
        }
    }

    /// It prints what it's handed and reads the board's outputs, which changes nothing in the run.
    fn may_rewind(&self) -> bool {
        true
    }

    fn on_stop(
        &mut self,
        session: &mut Session<D>,
        stop: Stop,
        _host: &mut dyn Host,
    ) -> Option<VirtualNanos> {
        let period = self.period?;
        if let Some(edge) = session.board_mut().edge() {
            let fields = edge.output_fields();
            match values_of(fields, &edge.drive_wire()) {
                Ok(read) => {
                    for (index, (field, value)) in fields.iter().zip(&read).enumerate() {
                        if self.outputs.get(index) != Some(value) && self.failed.is_none() {
                            self.failed = writeln!(
                                self.output,
                                "output t={} {}={value}",
                                stop.at.0, field.name
                            )
                            .err();
                        }
                    }
                    self.outputs = read;
                }
                Err(error) => {
                    self.failed.get_or_insert_with(|| io::Error::other(error));
                }
            }
        }
        if stop.at >= self.until {
            return None;
        }
        Some(VirtualNanos(
            stop.at.0.saturating_add(period.0).min(self.until.0),
        ))
    }
}

/// A `kind` line for each write to the console or each print of a debugger's breakpoint, its
/// bytes escaped as Rust's `escape_debug` escapes them, so a line in what was written stays inside
/// one line here.
fn write_text(
    output: &mut impl Write,
    kind: &str,
    at: VirtualNanos,
    instruction_count: u64,
    bytes: &[u8],
) -> io::Result<()> {
    writeln!(
        output,
        "{kind} t={} n={instruction_count} text={}",
        at.0,
        String::from_utf8_lossy(bytes).escape_debug()
    )
}

fn write_access(output: &mut impl Write, access: &Access, value: u64) -> io::Result<()> {
    let kind = match access.kind {
        AccessKind::Read => "read ",
        AccessKind::Write => "write",
    };
    let (time, count, address, bits) = (
        access.virtual_time.0,
        access.instruction_count,
        access.address,
        access.width.bits(),
    );
    // Formatted in place, since a run prints every access.
    match access.program_counter {
        Some(pc) => writeln!(
            output,
            "{kind} t={time} n={count} pc={pc:#010x} at={address:#010x} w={bits} v={value:#010x}"
        ),
        None => writeln!(
            output,
            "{kind} t={time} n={count} pc=debugger at={address:#010x} w={bits} v={value:#010x}"
        ),
    }
}

fn stop_reason(reason: StopReason) -> &'static str {
    match reason {
        StopReason::Deadline => "deadline",
        StopReason::Idle => "idle",
        StopReason::Reset => "reset",
    }
}