Scripted Tests
Declarative tests cover one-line behaviors. M7 is the
escape hatch for everything else: a real Rust #[test] function, driving the
target over the same Backend trait, for assertions too
stateful or sequential to express as one string (a multi-step protocol
exchange, a GPIO-then-UART interaction, anything with branching logic).
The device test-agent + RAM mailbox
Scripted tests don't poke arbitrary memory blind — they talk to a small,
fixed-protocol test-agent that runs on the target firmware itself, over a
RAM mailbox at a pinned address. The host SDK (nucleus-test-sdk) drives
that mailbox using only Backend::read_mem32/write_mem32 — no new
backend capability needed, any Backend impl supports scripted tests for
free.
Protocol (v1), mirrored byte-for-byte between nucleus-test-sdk's Rust
constants and the agent firmware's C header:
- Mailbox base:
0x2000_0000(start of SRAM, pinned by the agent's linker script). - Magic:
0x4E54_4167('NTAg'), written by the agent last, after clocks/USART/GPIO init — so a matching magic always implies a fully initialized mailbox. The host polls for it rather than reading once, to avoid a boot race against backends that halt the target the instant the host attaches. - Fields (offsets from base):
MAGIC,VERSION,SEQ,CMD,ARG0,ARG1,STATUS,RESP. - Status values:
IDLE(0) → host setsBUSY(1) → agent postsDONE(2) orERR(3). - Commands:
PING,SET_GPIO,READ_GPIO,READ_REG,UART_TX,UART_RX_POLL(returnsRX_NONE = 0xFFFF_FFFFwhen nothing's buffered).
The host SDK (nucleus-test-sdk)
#![allow(unused)] fn main() { use nucleus_test_sdk::AgentClient; let mut client = AgentClient::new(backend); // backend: &mut dyn Backend client.connect()?; // polls for magic + checks protocol version client.set_gpio(nucleus_db::Port::A, 5, true)?; let level = client.read_gpio(nucleus_db::Port::A, 5)?; client.uart_tx(b'p')?; let byte = client.uart_rx_poll()?; // Option<u8>: None if nothing buffered let reg = client.read_register(0x4002_0000)?; }
Every method blocks until the agent posts DONE/ERR or a fixed poll
timeout (500 ms) elapses, surfacing as SdkError::{BadMagic, VersionMismatch, AgentError, Timeout, Hil}.
[[test]] schema for scripted tests
[[test]]
name = "uart_loopback"
type = "scripted"
script = "uart_loopback" # the `cargo test` name nucleus-cli invokes
backend = "both" # "qemu" | "hardware" | "both" (default)
nucleus test runs cargo test <script> -- --exact --nocapture for each
selected backend label, the same --backend filter and per-test backend
field intersection that declarative tests use (a backend = "qemu" test is
never force-run on hardware just because --backend hardware wasn't passed
— it's simply skipped).
Worked example: UART loopback (both backends)
crates/nucleus-hil/tests/fixtures/agent_loopback/stm32.toml:
[device]
family = "STM32F411RE"
[peripherals.usart2]
tx = "PA2"
rx = "PA3"
[[test]]
name = "uart_loopback"
type = "scripted"
script = "uart_loopback"
backend = "both"
The test (crates/nucleus-hil/tests/e2e_scripted_uart.rs) starts each
backend, connects an AgentClient, sends bytes via uart_tx, and polls
uart_rx_poll until they come back. This is the test that empirically proved
QEMU's netduinoplus2 machine fully models USART2 — even though the same
machine has no real GPIO model, so a set_gpio/read_gpio round-trip in a
scripted test only meaningfully verifies on the hardware backend.
See also
- Dual-Backend HIL Substrate — the
Backendtrait this SDK is built on - Declarative Tests
- Test History & CI