rigless 0.1.0

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
//! SEGGER RTT as a probe reads it. SEGGER's RTT documentation, kb.segger.com/RTT, says how it
//! works: a control block in RAM that starts with an ID a probe finds it by, then a descriptor for
//! each up-channel, whose write offset the target moves and whose read offset the probe moves, the
//! buffer empty when they meet. The layout is the one SEGGER's published `SEGGER_RTT.h` gives
//! `SEGGER_RTT_CB` and `SEGGER_RTT_BUFFER_UP`, with 32-bit pointers and offsets.
//!
//! The block is the firmware's `_SEGGER_RTT`, or with no symbols to say where, found by its ID in
//! RAM. Only up-channel 0, the terminal, is drained. Down-channels are input to the firmware, and
//! a run takes no input that isn't in its record.
// ceiling: up-channel 0; upgrade when a firmware logs on a second channel someone reads

use std::ops::Range;

use crate::emulator::Memory;
use crate::soc::Width;
use crate::time::VirtualNanos;

/// How often a probe drains the terminal, in virtual nanoseconds: a millisecond.
pub(crate) const PERIOD: u64 = 1_000_000;

/// The drain after `at`: the next whole period, so drains land on the same instants however the run
/// got to `at`.
pub(crate) fn next_drain(at: VirtualNanos) -> VirtualNanos {
    VirtualNanos((at.0 / PERIOD + 1) * PERIOD)
}

/// The symbol the block is, and the ID it starts with, padded with NULs to `acID`'s 16 bytes.
pub(crate) const SYMBOL: &str = "_SEGGER_RTT";
const ID: &[u8; 16] = b"SEGGER RTT\0\0\0\0\0\0";

/// Where each field the probe reads is in the block, and in up-channel 0's descriptor, which
/// follows `acID`, `MaxNumUpBuffers` and `MaxNumDownBuffers`.
const UP_CHANNELS: u64 = 16;
const UP: u64 = 24;
mod channel {
    pub(super) const BUFFER: u64 = 4;
    pub(super) const SIZE: u64 = 8;
    pub(super) const WRITE: u64 = 12;
    pub(super) const READ: u64 = 16;
}

/// The firmware's block, and the RAM its terminal's buffer has to be in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Rtt {
    block: Block,
    ram: Vec<Range<u64>>,
    /// The block is found by its ID, the firmware having no symbols to say where it is.
    by_id: bool,
    /// Bytes the next drain leaves out, which the run a snapshot was taken from drained at its end.
    skip: u64,
}

/// How the block is found.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Block {
    /// It's here.
    At(u64),
    /// It's somewhere in RAM, which each drain looks through for its ID until it shows up.
    // ceiling: a firmware with no symbols is looked for at every drain until its block shows up;
    // upgrade when a firmware brings its block up late enough for that to cost
    Somewhere,
    /// The firmware's symbols say it has none.
    Nowhere,
}

impl Rtt {
    /// How a firmware's block is found: at `symbol`, the firmware's `_SEGGER_RTT`, nowhere when the
    /// firmware has symbols and that isn't one, or by its ID in `ram` when it has no symbols.
    pub(crate) fn new(symbol: Option<u64>, has_symbols: bool, ram: Vec<Range<u64>>) -> Self {
        let block = match symbol {
            Some(at) => Block::At(at),
            None if has_symbols => Block::Nowhere,
            None => Block::Somewhere,
        };
        Self {
            block,
            ram,
            by_id: block == Block::Somewhere,
            skip: 0,
        }
    }

    /// Where its ID search found the block, for a snapshot to keep, so a run started from it
    /// drains the same block rather than looking again.
    pub(crate) fn found(&self) -> Option<u64> {
        match self.block {
            Block::At(at) if self.by_id => Some(at),
            _ => None,
        }
    }

    /// How many bytes the terminal holds that no drain has taken yet, which a drain at the end of
    /// the run takes past a snapshot taken here.
    pub(crate) fn waiting(&self, memory: &mut dyn Memory) -> u64 {
        let Block::At(block) = self.block else {
            return 0;
        };
        channel(memory, block, &self.ram).map_or(0, |channel| channel.waiting())
    }

    /// Puts back what a snapshot kept: where an ID search found the block, and how many bytes
    /// the run it was taken from drained past it at its end, which the next drain leaves out.
    pub(crate) fn put_back(&mut self, found: Option<u64>, skip: u64) {
        if self.by_id {
            self.block = found.map_or(Block::Somewhere, Block::At);
        }
        self.skip = skip;
    }

    /// Whether the firmware's symbols say it has no block, so there's never anything to drain.
    pub(crate) fn has_none(&self) -> bool {
        self.block == Block::Nowhere
    }

    /// Drains the terminal, as a probe does: the bytes from its read offset round to its write
    /// offset, with the read offset then written where the write offset is. Nothing when there's
    /// no block yet, or its ID or its descriptor doesn't check out.
    pub(crate) fn drain(&mut self, memory: &mut dyn Memory) -> Vec<u8> {
        if self.block == Block::Somewhere
            && let Some(at) = find(memory, &self.ram)
        {
            self.block = Block::At(at);
        }
        let Block::At(block) = self.block else {
            return Vec::new();
        };
        let mut bytes = terminal(memory, block, &self.ram).unwrap_or_default();
        let skipped = bytes
            .len()
            .min(usize::try_from(self.skip).unwrap_or(usize::MAX));
        bytes.drain(..skipped);
        self.skip -= skipped as u64;
        bytes
    }
}

/// The value of `width` at `offset` past `base`, or `None` where nothing answers.
fn at(memory: &mut dyn Memory, base: u64, offset: u64, width: Width) -> Option<u64> {
    memory.read(base.checked_add(offset)?, width).ok()
}

/// Whether the block's ID is at `address`.
fn has_id(memory: &mut dyn Memory, address: u64) -> bool {
    (0..)
        .zip(ID)
        .all(|(offset, &byte)| at(memory, address, offset, Width::W8) == Some(u64::from(byte)))
}

/// The first word-aligned place in `ram` that starts with the ID.
fn find(memory: &mut dyn Memory, ram: &[Range<u64>]) -> Option<u64> {
    ram.iter()
        .flat_map(|range| (range.start..range.end).step_by(4))
        .find(|&address| has_id(memory, address))
}

/// Up-channel 0's descriptor as the block at `block` holds it.
struct Channel {
    /// Where its read offset is.
    up: u64,
    buffer: u64,
    size: u64,
    write: u64,
    read: u64,
}

impl Channel {
    /// How many bytes it holds, from its read offset round to its write offset.
    fn waiting(&self) -> u64 {
        (self.write + self.size - self.read) % self.size
    }
}

/// Up-channel 0's descriptor at the block at `block`, when it checks out. A buffer that isn't all
/// inside one of the `ram` ranges doesn't, so a descriptor the firmware hasn't set up, or has
/// spoilt, can't have the probe read anywhere else, or for ever.
fn channel(memory: &mut dyn Memory, block: u64, ram: &[Range<u64>]) -> Option<Channel> {
    if !has_id(memory, block) || at(memory, block, UP_CHANNELS, Width::W32)? == 0 {
        return None;
    }
    let up = block.checked_add(UP)?;
    let word = |memory: &mut dyn Memory, offset| at(memory, up, offset, Width::W32);
    let buffer = word(memory, channel::BUFFER)?;
    let size = word(memory, channel::SIZE)?;
    let write = word(memory, channel::WRITE)?;
    let read = word(memory, channel::READ)?;
    let end = buffer.checked_add(size)?;
    let in_ram = ram
        .iter()
        .any(|range| range.start <= buffer && end <= range.end);
    (in_ram && write < size && read < size).then_some(Channel {
        up,
        buffer,
        size,
        write,
        read,
    })
}

/// The terminal's bytes at the block at `block`, with its read offset moved past them, when its
/// descriptor checks out.
fn terminal(memory: &mut dyn Memory, block: u64, ram: &[Range<u64>]) -> Option<Vec<u8>> {
    let Channel {
        up,
        buffer,
        size,
        write,
        read,
    } = channel(memory, block, ram)?;
    let (first, second) = if read <= write {
        (read..write, 0..0)
    } else {
        (read..size, 0..write)
    };
    let bytes = first
        .chain(second)
        .map(|offset| {
            at(memory, buffer, offset, Width::W8).and_then(|byte| u8::try_from(byte).ok())
        })
        .collect::<Option<Vec<u8>>>()?;
    memory.write(up + channel::READ, Width::W32, write).ok()?;
    Some(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::laid::Laid;

    const RAM: Range<u64> = 0x2000_0000..0x2000_1000;
    const BLOCK: u64 = 0x2000_0100;
    const BUFFER: u64 = 0x2000_0400;
    const SIZE: u32 = 16;

    /// A block at `BLOCK` with one up-channel of `SIZE` bytes holding `text` from offset `from`,
    /// round the end when it has to.
    fn laid(text: &[u8], from: u32) -> Laid {
        let mut memory = Laid::default();
        memory.bytes(BUFFER, &[b'.'; SIZE as usize]);
        for (offset, &byte) in (from..).zip(text) {
            memory.bytes(BUFFER + u64::from(offset % SIZE), &[byte]);
        }
        memory.bytes(BLOCK, ID);
        let write = (from + u32::try_from(text.len()).unwrap()) % SIZE;
        memory.word(BLOCK + UP_CHANNELS, 1);
        memory.word(BLOCK + 20, 1);
        for (offset, value) in [
            (0, 0),
            (channel::BUFFER, u32::try_from(BUFFER).unwrap()),
            (channel::SIZE, SIZE),
            (channel::WRITE, write),
            (channel::READ, from),
            (20, 2),
        ] {
            memory.word(BLOCK + UP + offset, value);
        }
        memory
    }

    fn read_offset(memory: &mut Laid) -> u64 {
        memory.read(BLOCK + UP + channel::READ, Width::W32).unwrap()
    }

    #[test]
    fn the_control_block_is_found_by_symbol_or_by_its_id() {
        let ram: Vec<Range<u64>> = [RAM].into();
        let mut memory = laid(b"hi", 0);
        let mut by_symbol = Rtt::new(Some(BLOCK), true, ram.clone());
        assert_eq!(by_symbol.drain(&mut memory), b"hi");
        let mut memory = laid(b"hi", 0);
        let mut by_id = Rtt::new(None, false, ram.clone());
        assert_eq!(by_id.drain(&mut memory), b"hi");
        assert_eq!(by_id.block, Block::At(BLOCK), "found once, kept");
        let mut nowhere = Rtt::new(None, true, ram);
        assert!(nowhere.has_none(), "symbols without the block's");
        assert!(nowhere.drain(&mut memory).is_empty());

        let mut empty = Laid::default();
        empty.bytes(0x2000_0000, &[0; 0x1000]);
        let mut waiting = Rtt::new(None, false, [RAM].into());
        assert!(waiting.drain(&mut empty).is_empty(), "not there yet");
        assert_eq!(waiting.block, Block::Somewhere);
    }

    #[test]
    fn a_drain_takes_from_rdoff_to_wroff_across_the_wrap_and_writes_rdoff_back() {
        let mut rtt = Rtt::new(Some(BLOCK), true, [RAM].into());
        let mut memory = laid(b"0123456789", 3);
        assert_eq!(rtt.drain(&mut memory), b"0123456789");
        assert_eq!(read_offset(&mut memory), 13);
        assert!(rtt.drain(&mut memory).is_empty(), "nothing new");

        let mut memory = laid(b"round the end", 10);
        assert_eq!(rtt.drain(&mut memory), b"round the end", "past the wrap");
        assert_eq!(read_offset(&mut memory), 7);

        let mut spoilt = laid(b"x", 0);
        spoilt.bytes(BLOCK, b"SEGGER RTX");
        assert!(
            rtt.drain(&mut spoilt).is_empty(),
            "an ID that doesn't check out"
        );
        let mut spoilt = laid(b"x", 0);
        spoilt.word(BLOCK + UP + channel::WRITE, SIZE);
        assert!(
            rtt.drain(&mut spoilt).is_empty(),
            "an offset past the buffer"
        );
        assert_eq!(read_offset(&mut spoilt), 0, "left as it was");
    }

    /// A buffer that isn't all in one RAM range doesn't check out, however big its descriptor says
    /// it is, and its read offset stays where it was.
    #[test]
    fn a_buffer_outside_ram_is_never_read() {
        let mut rtt = Rtt::new(Some(BLOCK), true, [RAM].into());
        let mut huge = laid(b"x", 0);
        for (offset, value) in [
            (channel::SIZE, u32::MAX),
            (channel::WRITE, 0xFFFF_FFF0),
            (channel::READ, 0),
        ] {
            huge.word(BLOCK + UP + offset, value);
        }
        assert!(rtt.drain(&mut huge).is_empty(), "a buffer of 4 GiB");
        assert_eq!(read_offset(&mut huge), 0);

        // Memory answers past RAM's end here, as another region's would.
        let mut past = laid(b"x", 0);
        let near_the_end = u32::try_from(RAM.end).unwrap() - SIZE / 2;
        past.bytes(u64::from(near_the_end), &[b'x'; SIZE as usize]);
        past.word(BLOCK + UP + channel::BUFFER, near_the_end);
        assert!(rtt.drain(&mut past).is_empty(), "running past RAM's end");
        assert_eq!(read_offset(&mut past), 0);
    }
}