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 wire between the runtime and its QEMU sidecar. The two processes share one page, the control block, and take turns writing to it: the sidecar writes a message into its slot and hands over the turn, the runtime reads it, writes its answer into the other slot, and hands the turn back. The module defines that page, the seven messages, the hand-over, and the wait, and the repository carries the C header the sidecar compiles against. The protocol half does no mapping and holds no process, and `Link` drives the runtime's side of it over any control block; the backend half, `Sidecar`, maps the page, runs the process, and hands `Link` a reference. Every test double lives under `tests/support/`, never beside the code it stands in for.
//!
//! ```rust
//! use rigless::sidecar::{ControlBlock, CoreMessage, SidecarMessage, Turn};
//! use rigless::soc::Width;
//!
//! let block = ControlBlock::new();
//!
//! // The sidecar's side of one crossing.
//! let read = SidecarMessage::Read { address: 0x4002_0014, width: Width::W32, instruction_count: 12, program_counter: 0x0800_0100, virtual_time_ns: 48 };
//! read.encode(&block.sidecar_slot);
//! block.turn.hand_over(Turn::Core);
//!
//! // The runtime's side.
//! block.turn.wait_for(Turn::Core, || Ok(()))?;
//! assert_eq!(SidecarMessage::decode(&block.sidecar_slot)?, read);
//! CoreMessage::Reply { value: 0x55, deadline_ns: 1_000_000 }.encode(&block.core_slot);
//! block.turn.hand_over(Turn::Sidecar);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
//!
//! ## The layout
//!
//! One page, 4096 bytes, aligned to a page. Every offset is pinned by a compile-time assertion in `runtime/sidecar/layout.rs`, by a `_Static_assert` in the header, and by the test that compares the two.
//!
//! | Offset | Size | Field | |
//! |---|---|---|---|
//! | 0 | 8 | `magic` | `RIGLSSCB` as a little-endian word |
//! | 8 | 4 | `version` | `(major << 16) \| minor`; the major changes the layout, the minor adds a kind |
//! | 64 | 64 | `turn` | one `u32` on its own cache line: 0 for the sidecar, 1 for the core |
//! | 128 | 64 | `sidecar_slot` | the message the sidecar last wrote |
//! | 192 | 64 | `core_slot` | the message the core last wrote |
//! | 256 | 128 | `interrupt_levels` | 16 words, one bit per line, 1024 lines |
//!
//! A slot is 64 bytes, aligned to a cache line:
//!
//! | Offset | Size | Field |
//! |---|---|---|
//! | 0 | 4 | `kind` |
//! | 4 | 1 | `width` |
//! | 5 | 1 | `reason` |
//! | 8 | 8 | `address` |
//! | 16 | 8 | `value` |
//! | 24 | 8 | `instruction_count` |
//! | 32 | 8 | `program_counter` |
//! | 40 | 8 | `virtual_time_ns` |
//! | 48 | 8 | `deadline_ns` |
//!
//! The width of an access is `soc::Width`, the same type the bus takes, and it crosses as its `bits()`: 8, 16, 32, or 64 in the `width` byte. That type is the one thing the protocol takes from the rest of the crate.
//!
//! Every word of the block is an atomic rather than a plain integer: both processes hold a shared reference to the same page, and atomics are how safe Rust writes through one and how it stays sound when the other process writes. `AtomicU8`, `AtomicU32`, and `AtomicU64` have the size and alignment of their integers, so the bytes are what the C struct says. Every slot field is stored relaxed; the turn word's release store publishes them, and the peer's acquire load sees them. The slot's fields are private: `encode` and `decode` are the only way through them, and both are valid only between `wait_for` returning and `hand_over`.
//!
//! Two clocks ride on every sidecar message. `instruction_count` is QEMU's raw instruction counter, `icount_get_raw()`, and `virtual_time_ns` is `QEMU_CLOCK_VIRTUAL`, the clock deadlines are in. They are not derivable from each other: every idle warp moves the virtual clock forward with no instruction executed. The virtual clock is the runtime's time; the instruction count is what the trace and the benchmarks report.
//!
//! ## The exchange
//!
//! The sidecar writes `Hello`, `Read`, `Write`, and `Stopped`; the runtime writes `RunUntil`, `Reply`, and `Shutdown`. Whoever holds the turn writes its slot with `encode`, then `hand_over`, which is one release store to the turn word. The other side has been in `wait_for`, an acquire load, and then reads its peer's slot with `decode`. Strict alternation means there is never more than one message in flight, which is why there are no rings and no sequence numbers. `decode` validates the slot: a kind this side does not receive, a width byte that is no `Width`, a reason byte that names nothing, or a version wider than thirty-two bits is a `DecodeError`, never a panic. An empty slot has kind zero and is always an error.
//!
//! `Hello` packs the version into `value` as `Version::pack` lays it out, `(major << 16) | minor`, the same form the control block holds. A message stores zero into the fields it does not use, and `decode` ignores them.
//!
//! ## The wait
//!
//! `wait_for` spins on the turn word with a pause instruction for `SPIN_WINDOW`, 50 microseconds, then polls it with a `SLEEP_QUANTUM`, 20 microseconds, of sleep between polls. Before every sleep it asks the `peer_alive` closure whether the other process still exists and returns `WaitError::PeerGone` when it does not, so a sidecar that crashes mid-run becomes an error within a quantum rather than a hang. A turn word that names neither side is `WaitError::CorruptTurn` on the first load, not a wait that never ends. Nothing here can lose a wake, because the waiter polls. The two constants are in the header as `RIGLESS_PROXY_SPIN_WINDOW_US` and `RIGLESS_PROXY_SLEEP_QUANTUM_US` so both sides pace the same way.
//!
//! ## Interrupt levels
//!
//! `InterruptLevels::raise` sets one bit in the bitmap: word `index / 64`, bit `index % 64`. An `InterruptLine` is an index below `INTERRUPT_LINES`, checked at construction, so raising cannot fail. Like `encode`, `raise` is valid only while this side holds the turn; the same `hand_over` publishes it. `lower` clears the bit under the same rule. The proxy keeps a copy of what it last drove and, on every return from a crossing, drives the lines whose bits differ before the next instruction executes.
//!
//! ## The header
//!
//! `include/rigless_proxy_protocol.h` is written by hand, under Apache-2.0 OR GPL-2.0-or-later so the fork can compile it, and holds one `#define RIGLESS_PROXY_*` per number in the module, the slot with explicit padding; the control block's gaps come from its alignment, and a `_Static_assert` for every size and offset. It is not generated because the Rust types are atomics, which no generator renders as the plain integers C wants, and because a generator emits neither the static assertions nor the explicit padding. Two tests pin it instead: `header_defines_match_rust` parses every define out of the header and compares the set, by name and value, against a table built from `offset_of!`, `size_of`, the constants, and the discriminants, so a define added on either side alone fails; `header_compiles_as_c11` runs `cc -std=c11 -fsyntax-only` on the file, which is where the static assertions run. The fork's CI fetches the file at the pinned commit and diffs it against its own copy.
//!
//! ## The backend
//!
//! Everything on the Rust side talks to an engine through one trait, `CpuBackend`: `run_until` executes to a deadline on the virtual clock and calls a closure for every peripheral access as it happens, `interrupts` is the level bitmap the runtime sets between runs, and `shutdown` ends the process. The closure receives an `Access`, the kind, address, width, value, instruction count, virtual time, and program counter of the crossing, plus the interrupt levels to raise or lower on, and returns a `Reply` with the value read and the next instant the runtime needs control, which the engine re-arms its deadline to. A run ends in a `Stop` naming where and why. `Sidecar` is the one implementation; an engine of our own would implement the same three methods and nothing else.
//!
//! `Sidecar::spawn` takes a `SidecarCommand`, which a machine module builds for one target and the runtime never invents: the binary's name, the engine's machine, its core, the properties that machine declares, the instruction rate, and the regions a second address also reaches; the target's memory map; a `TargetStart`, the firmware image and an optional debugger port; and `SidecarOptions`, shared by every target of a run: an optional explicit binary path and the cancel flag. An explicit path is the binary, and one that is not a file is an error at once; with none given the operating system resolves the configured name, `rigless-sidecar` for the fork's package, on `PATH`, and a name it cannot find is the same `BackendError::NoSidecar`. Nothing is read from the environment. It creates one file in `/dev/shm` per memory region plus one page for the control block, each with exclusive create, sized from the map, and named for this process and the number of the sidecar within it, which is what lets a machine's targets run side by side, maps the page, writes a fresh control block into it, and starts QEMU with every region as a `memory-backend-file` object placed by a `rigless-memory` device at the region's own base, read-only unless the region is read-write memory, and answering at a second address as well when the command names one. Nothing in `sidecar` knows a core family or an SoC, so which region a core finds its reset vector in is the machine module's to say. `-run-with exit-with-parent=on` makes QEMU exit when this process dies, so no signal needs arming for that direction. It then waits for `Hello`, compares the version, and unlinks the files; a sidecar that speaks another version is told to shut down and reported as `BackendError::Version`. `shutdown` writes `Shutdown`, gives the process five seconds to exit, and kills it after; dropping a `Sidecar` kills the process outright. The cancel flag in `SidecarOptions` is how the other direction ends a run: it is a `&'static AtomicBool` the binary's signal handler sets, every wait on the sidecar checks it once per sleep quantum after the spin window, and a set flag ends the wait with `BackendError::Cancelled`, so the error path drops the `Sidecar`, which kills the process and, through the `Drop` of every file still linked, removes it. The `rigless` binary routes SIGINT and SIGTERM to that flag and, once everything has dropped, dies of the signal.

mod backend;
mod command;
mod layout;
mod link;
#[cfg(not(loom))]
mod mapping;
mod message;
#[cfg(not(loom))]
mod process;
mod sync;
mod turn;

pub use backend::{Access, AccessKind, BackendError, CpuBackend, Phase, Reply, Stop};
pub use command::{Alias, CONTROL_BLOCK_PROPERTY, Property, SidecarCommand};
pub use layout::{
    ControlBlock, INTERRUPT_LINES, InterruptLevels, InterruptLine, MAGIC, PROTOCOL_VERSION, Slot,
    Version,
};
pub use link::Link;
pub use message::{CoreMessage, DecodeError, SidecarMessage, StopReason};
#[cfg(not(loom))]
pub use process::{Sidecar, SidecarOptions, TargetStart};
pub use turn::{SLEEP_QUANTUM, SPIN_WINDOW, Turn, TurnWord, WaitError};