Introduction

A CLI-first STM32 developer platform: declarative stm32.toml → validated HAL init code → flashed firmware, plus a real-time ITM trace dashboard.

Nucleus replaces STM32CubeIDE/CubeMX's graphical pin configuration and proprietary debug tooling with a version-controllable, CI-friendly CLI and a thin VS Code extension.

Watch the demo video

At a glance

# stm32.toml
[device]
family   = "STM32F446RE"
board    = "NUCLEO-F446RE"
clock_hz = 180_000_000

[peripherals.usart2]
tx   = "PA2"
rx   = "PA3"
baud = 115200
nucleus check     # validate against the constraint database
nucleus build     # generate HAL init code + build firmware
nucleus trace     # decode ITM/SWO and stream to the dashboard

Nucleus supports two NUCLEO boards out of the box: NUCLEO-F446RE (STM32F446RE) and NUCLEO-F411RE (STM32F411RE) — pick one with nucleus init --board <name>.

What's in this book

Installation

The nucleus CLI

Prerequisites

  • A Rust toolchain ≥ 1.85 (the MSRV). Install via rustup.
  • Optional, only for nucleus build / flash: arm-none-eabi-gcc, cmake, st-flash, and a NUCLEO-F446RE board.

From crates.io

cargo install nucleus-cli

This installs the nucleus binary into ~/.cargo/bin (on your PATH if you installed Rust via rustup). To install a specific version, pass --version <x.y.z>.

From source

# From a clone
git clone https://github.com/harshverma27/nucleus
cd nucleus
cargo install --path crates/nucleus-cli --locked

# …or directly from GitHub
cargo install --git https://github.com/harshverma27/nucleus nucleus-cli --locked

Prebuilt binaries

Each tagged release attaches prebuilt nucleus binaries for Linux, macOS, and Windows (x86_64 + arm64), with SHA-256 checksums, on the Releases page. Download, verify, extract, and put nucleus on your PATH.

Verify

nucleus --version
nucleus --help

The VS Code extension

The extension is a thin client for the CLI (LSP + trace dashboard).

  • From the Marketplace (once published): search for Nucleus in the Extensions view, or install the .vsix attached to a release with Extensions: Install from VSIX….
  • From source:
    cd extension
    npm install
    npm run build
    
    Then press F5 in VS Code to launch an Extension Development Host.

The extension expects nucleus on your PATH; override with the nucleus.serverPath setting if needed.

Quickstart: Blink an LED

This walks through setting up everything from scratch — toolchain, STM32 HAL sources, the nucleus CLI — and ends with a blinking LED (LD2, pin PA5) on a NUCLEO-F446RE.


1. Install the ARM cross toolchain

nucleus build cross-compiles with arm-none-eabi-gcc.

Arch Linux:

sudo pacman -S arm-none-eabi-gcc arm-none-eabi-newlib arm-none-eabi-binutils arm-none-eabi-gdb

arm-none-eabi-newlib is required too — it provides nano.specs/nosys.specs, which the generated build links against.

Other platforms: install the ARM GNU Toolchain release and add its bin/ directory to PATH.

Verify:

arm-none-eabi-gcc --version

You'll also need cmake and st-flash (from stlink-tools):

sudo pacman -S cmake stlink

2. Get the STM32CubeF4 HAL/CMSIS sources

This is not STM32CubeIDE/CubeMX (no GUI needed) — just a source checkout of ST's HAL driver and CMSIS device headers.

git clone https://github.com/STMicroelectronics/STM32CubeF4 ~/STM32CubeF4
cd ~/STM32CubeF4
git submodule update --init Drivers/STM32F4xx_HAL_Driver Drivers/CMSIS/Device/ST/STM32F4xx

(Only those two submodules are needed — the full set includes BSPs and middleware for boards/features this demo doesn't use.)

Verify the key headers exist:

ls Drivers/STM32F4xx_HAL_Driver/Inc/stm32f4xx_hal.h
ls Drivers/CMSIS/Device/ST/STM32F4xx/Include/stm32f446xx.h

Export the path (add this to your ~/.bashrc/~/.zshrc so it persists across shells):

export STM32CUBE_PATH=~/STM32CubeF4

3. Install nucleus

From a clone of this repo:

git clone https://github.com/harshverma27/nucleus
cd nucleus
cargo install --path crates/nucleus-cli --locked

Verify:

nucleus --version
nucleus --help

4. Scaffold a new project

mkdir ~/my-blink && cd ~/my-blink
nucleus init .

This creates stm32.toml, CMakeLists.txt, a linker script, HAL config header, interrupt-handler stubs, and a starter src/main.c that calls the generated Nucleus_Init(). The default stm32.toml configures USART2 (the ST-Link virtual COM port) — nothing else is required for this demo.

Validate the config:

nucleus check

LD2 (the on-board green LED) is wired to PA5 on the NUCLEO-F446RE. Plain GPIO toggling isn't part of stm32.toml's declarative peripheral model (that's reserved for pin-muxed peripherals like USART/SPI/I2C/TIM), so it's hand-written in main.c — same as any bare-metal HAL project.

Edit src/main.c:

/* Application entry point. Hand-written — Nucleus only owns nucleus_init.c. */
#include "stm32f4xx_hal.h"
#include "generated/nucleus_config.h"

void SystemClock_Config(void);

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    Nucleus_Init();          /* generated from stm32.toml */

    __HAL_RCC_GPIOA_CLK_ENABLE();

    GPIO_InitTypeDef led = {0};
    led.Pin   = GPIO_PIN_5;
    led.Mode  = GPIO_MODE_OUTPUT_PP;
    led.Pull  = GPIO_NOPULL;
    led.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &led);

    while (1) {
        HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
        HAL_Delay(500);
    }
}

/* Replace with a clock setup matching [device].clock_hz in stm32.toml.
 * A full clock-tree solver is intentionally out of Nucleus's scope. */
__attribute__((weak)) void SystemClock_Config(void) {}

6. Build

nucleus build

This validates stm32.toml, generates src/generated/nucleus_config.h and nucleus_init.c, then drives CMake + arm-none-eabi-gcc. On success you'll see:

Generating firmware.bin / firmware.hex
[100%] Built target firmware
build OK — firmware in ./build

(A few _close/_read/_write is not implemented linker warnings are expected and harmless — they come from -specs=nosys.specs.)


7. Flash

Plug the NUCLEO board in via the ST-Link USB port (the one nearer the edge of the board, not the "USB User" port). Then:

nucleus flash

This programs build/firmware.bin at 0x08000000 via st-flash and resets the board. LD2 should start blinking at ~1 Hz.


Troubleshooting

  • st-flash: Failed to enter SWD mode / Failed to connect to target but st-info --probe finds the programmer: the ST-Link itself is detected, but the SWD link to the target MCU isn't up. Unplug/replug the USB cable, or press the board's black RESET button, then retry.

  • st-info --probe reports an unexpected chipid/dev-type: confirm your board is actually a NUCLEO-F446RE (chip ID 0x421). Nucleus is built and validated against the F446RE only — other F4 boards (e.g. an F411RE, chip ID 0x431) often happen to work for simple GPIO demos since the F4 family shares register layouts, but pin/AF validation (nucleus check) and any clock-tree setup are F446-specific.

  • Missing stm32f4xx_hal_conf.h / undefined HAL functions at link time: make sure you're on a nucleus build that includes the scaffold fix (nucleus init should create src/stm32f4xx_hal_conf.h, src/stm32f4xx_it.c, and STM32F446RETx_FLASH.ld). Re-run cargo install --path crates/nucleus-cli --locked --force from an updated checkout if these files are missing.

  • STM32CUBE_PATH errors (stm32f4xx_hal.h: No such file or directory): confirm echo $STM32CUBE_PATH points at your STM32CubeF4 checkout and that the submodules from step 2 are initialized (their directories aren't empty).

CLI usage

The nucleus binary is the whole toolchain. All commands are scriptable and composable; nucleus check exits non-zero on conflicts so CI can gate on it.

nucleus <command>

  check   Validate stm32.toml against the constraint database
  init    Scaffold a new STM32 project
  build   Generate HAL init code and build firmware (.elf/.bin)
  flash   Flash the built firmware to a connected board
  trace   Decode ITM/SWO trace and stream events over a WebSocket
  lsp     Start the language server over stdio (used by the editor extension)

nucleus init [dir]

Scaffolds a project: stm32.toml, CMakeLists.txt, a cross-toolchain CMake file, src/main.c, a CI workflow, and .gitignore. Idempotent — it never overwrites existing files.

mkdir blinky && cd blinky
nucleus init .

Use --board to target a specific NUCLEO board (default NUCLEO-F446RE):

nucleus init blinky --board NUCLEO-F411RE

Supported boards: NUCLEO-F446RE, NUCLEO-F411RE. The board sets the [device].family, the linker script, the MCU define, the startup file, and the default clock_hz in the scaffolded project.

nucleus check [path]

Validates stm32.toml (default ./stm32.toml) against the constraint database for its [device].family (STM32F446RE or STM32F411RE) and prints any conflicts. Detects pin collisions, AF mismatches, missing required pins, and disabled clock domains. Exit code: 0 when clean, 1 on any conflict or read/parse error.

nucleus check
nucleus check configs/board-a.toml

nucleus build [dir]

Validates the config, regenerates src/generated/nucleus_config.h + nucleus_init.c (typed config structs + a single Nucleus_Init() that calls stock ST HAL Init functions), then drives CMake + arm-none-eabi-gcc to produce firmware.elf / firmware.bin.

Requires cmake + arm-none-eabi-gcc and an STM32CubeF4 (HAL) checkout pointed at by STM32CUBE_PATH. Codegen still runs (and is observable) if the toolchain is missing; you'll get a clear error at the compile step. Build refuses to generate code for a conflicting config.

nucleus flash [dir]

Programs build/firmware.bin to a connected board with st-flash.

nucleus trace

Decodes the ITM/SWO byte stream and streams structured JSON events over a WebSocket (default :7878) for the dashboard.

# Live, via OpenOCD's internal trace TCP port
nucleus trace --trace-tcp 127.0.0.1:3344 --openocd 127.0.0.1:4444

# Replay a captured raw-SWO file (great for development and demos)
nucleus trace --replay capture.swo

# Options
#   --config <stm32.toml>   read [[trace.variables]] (default stm32.toml)
#   --ws-port <port>        WebSocket port (default 7878)

Event shapes (JSON): {"kind":"log","message":…}, {"kind":"variable","port":…,"name":…,"type":…,"value":…}, {"kind":"cpuload","load":…}, {"kind":"overflow"}.

nucleus lsp

Starts the language server over stdio. Normally spawned by the VS Code extension, not run by hand.

Pinout & Peripheral Verification

The base layer every other feature in this book builds on: a deterministic pin/alternate-function/peripheral database for the F446RE and F411RE, and a solver that checks a declared stm32.toml against it.

The database (nucleus-db)

nucleus-db is generated at build time from ST's own STM32_open_pin_data pack XML (vendored under crates/nucleus-db/packdata/), not hand-written. For each supported family it produces:

  • Every physical pin and its package position.
  • Every alternate-function (AF) mapping: which pin, on which AF number, routes to which peripheral signal (e.g. PA9 AF7 → USART1_TX).
  • The peripheral instance list for that family (e.g. F411RE has no USART3/UART4/UART5; F446RE does).

Because it's generated deterministically from the same pack data every build, the database never drifts between machines — there's no "works on my CubeMX install" class of bug. Pack-data inconsistencies are the one place hand correction is allowed: small patches live in nucleus-db/src/pack.rs's patch table, never by editing the vendored XML.

What nucleus check validates

nucleus check [path] (default ./stm32.toml) parses your config and runs it through the solver in nucleus-compiler/src/solver.rs, which reports a Conflict for each of:

ConflictTrigger
PinCollisionTwo peripheral signals assigned to the same physical pin.
AfMismatchA pin assigned to a peripheral signal that pin doesn't actually expose (no AF row for it on this family).
InvalidPinA pin role's string value isn't a real pin name (typo).
MissingPinA peripheral declared without a required pin role set.
ClockDomainDisabledA peripheral configured while its bus ([clocks]) is turned off.
PeripheralUnavailableAn instance that doesn't exist on the selected family (e.g. usart5 on an F411RE).

(Clock-tree, DMA, IRQ, and auto-routing conflicts build on this same Conflict enum and solver pass — see their own pages.)

Exit code: 0 clean, 1 on any conflict or parse error — designed to gate CI (see CI Integration).

Example: a pin collision

# tests/fixtures/pa5_collision.toml — PA5 claimed by both SPI1_SCK and TIM2_CH1
[device]
family = "STM32F446RE"

[peripherals.spi1]
mosi = "PA7"
miso = "PA6"
sck = "PA5"

[peripherals.tim2]
channel1 = "PA5"
$ nucleus check tests/fixtures/pa5_collision.toml
tests/fixtures/pa5_collision.toml: 1 conflict found:

  error: pin collision on PA5: assigned to SPI1_SCK and TIM2_CH1

Example: clean

# tests/fixtures/clean.toml
[device]
family = "STM32F446RE"
board = "NUCLEO-F446RE"
clock_hz = 180_000_000

[peripherals.usart2]
tx = "PA2"
rx = "PA3"
baud = 115200

[peripherals.spi1]
mosi = "PA7"
miso = "PA6"
sck = "PA5"
nss = "PA4"
mode = 0

[peripherals.i2c1]
sda = "PB9"
scl = "PB8"
speed = "standard"

[peripherals.tim2]
channel1 = "PA0"
channel2 = "PA1"
frequency_hz = 1000
$ nucleus check tests/fixtures/clean.toml
tests/fixtures/clean.toml OK — 4 peripheral(s), no conflicts.

Where this surfaces in the editor

nucleus-lsp (analysis.rs) maps every Conflict to the most relevant byte span in the source stm32.toml and reports it as an LSP diagnostic, so VS Code (via the extension's LSP client) underlines the exact offending line — no separate "run check" step needed while editing.

See also

Clock-Tree Solver

CubeMX's clock configurator lets you build a clock tree visually and never checks whether the numbers it produces are actually legal silicon — an APB1 bus quietly clocked at 180 MHz instead of its 45 MHz limit will boot and run until something subtly misbehaves. Nucleus's clock-tree solver (Conflict::ClockConstraint) rejects that config before it ever reaches a board.

The model (nucleus-db/src/clock.rs)

A hand-maintained, family-parameterized description of:

  • Oscillator sources: Hsi, Hse, Lsi, Lse.

  • The main PLL's M/N/P/Q divider ranges and VCO input/output frequency ranges.

  • The SYSCLK source set and the AHB/APB1/APB2 prescaler sets.

  • Per-family silicon limits, cited to ST's reference manuals (RM0390 for F446RE, RM0383 for F411RE):

    BusF446RE limit
    SYSCLK≤ 180 MHz
    AHB≤ 180 MHz
    APB1≤ 45 MHz
    APB2≤ 90 MHz

This module is pure data — it performs no arithmetic. It can't be generated from ST's pack XML (that data carries pin↔AF mappings only, no clock-tree information), so it's hand-maintained and cross-checked by a seed test against the reference manuals.

The solver (nucleus-compiler/src/clocks.rs)

validate() walks the effective config in four stages, stopping early at the first stage that fails (a downstream frequency number computed from an already-illegal divider would just produce a confusing cascade of unrelated errors):

  1. PLL divider legality — M/N/P/Q each checked against the model's ranges.
  2. Prescaler legality — AHB/APB1/APB2 prescaler must be one of the hardware's allowed divisor values.
  3. PLL VCO range — input and output VCO frequency must land inside the silicon's specified band.
  4. Silicon limits — SYSCLK, AHB, APB1, APB2 each checked against the family's max, in that fixed order.

stm32.toml schema

[clocks]
source         = "pll"   # "hsi" | "hse" | "pll" (default "pll")
pll_source     = "hse"   # PLL input: "hsi" | "hse"
pll_m          = 8
pll_n          = 360
pll_p          = 2
pll_q          = 7       # optional, only needed if something derives from PLLQ
apb1_prescaler = 2
apb2_prescaler = 1
ahb1 = true               # bus enable flags; default true if [clocks] section omitted
apb1 = true
apb2 = true

Omitting [clocks] entirely is valid and enables every bus by default.

Example: an over-clocked APB1

# tests/fixtures/overclock_apb1.toml — 180 MHz HCLK ÷ prescaler 1 = 180 MHz
# on a bus whose limit is 45 MHz. CubeMX accepts this; Nucleus doesn't.
[device]
family = "STM32F446RE"

[clocks]
source = "pll"
pll_source = "hse"
pll_m = 8
pll_n = 360
pll_p = 2
apb1_prescaler = 1

[peripherals.usart2]
tx = "PA2"
rx = "PA3"
$ nucleus check tests/fixtures/overclock_apb1.toml
tests/fixtures/overclock_apb1.toml: 1 conflict found:

  error: clock constraint [APB1]: APB1 = 180 MHz exceeds the 45 MHz limit

Other reasons you'll see inside clock constraint [<node>]: ...: PLL M = 3 is outside the valid 2–63 range, PLL VCO output = 432 MHz is outside the 100–432 MHz range, APB2 prescaler 3 is not one of the allowed values {1, 2, 4, 8, 16}.

Baud-rate reachability

Peripheral baud/bit rates (USART, SPI, I2C) are derived from whichever bus clocks them (nucleus-db::peripheral_bus). If the resolved bus frequency can't reach a requested baud rate within the peripheral's divider range, that surfaces as the same ClockConstraint conflict, on the peripheral's node rather than SYSCLK/AHB/APBn.

See also

DMA Arbitration

STM32F4's DMA1/DMA2 controllers have eight streams each, and each stream multiplexes eight peripheral-request channels. Two peripherals can be wired in CubeMX onto the same physical stream — both will compile and flash, and the second one's DMA transfers will simply never fire. Nucleus catches this at nucleus check time instead of at "why is my second sensor not updating".

The model (nucleus-db/src/dma.rs)

A hand-maintained, family-parameterized map of:

  • DMA1 and DMA2, their eight streams each.
  • Each stream's eight request channels.
  • The peripheral-request table: which (peripheral, direction) — e.g. (usart2, Rx) — is servable by which (controller, stream, channel) slots.

Cited to ST's reference manuals: RM0390 Tables 28–29 (F446RE), RM0383 Tables 27–28 (F411RE). Like the clock-tree model, this can't be generated from pack XML (no DMA data there) so it's hand-maintained and seed-tested.

The solver (nucleus-compiler/src/dma.rs)

validate() is a deterministic, greedy first-fit:

  1. Collect every dma = [...] request across configured peripherals, in BTreeMap peripheral order.
  2. For each request, in order, assign the first candidate stream that's still free.
  3. If every candidate for a request is already taken, record a Conflict::DmaCollision against that stream — once per contested stream, not once per colliding pair — naming the existing holder, the new contender, and (if either side has a free alternative slot) a suggestion.

stm32.toml schema

[peripherals.i2c1]
sda = "PB7"
scl = "PB6"
dma = ["rx"]        # request DMA for specific signals…

[peripherals.uart5]
tx = "PC12"
rx = "PD2"
dma = true          # …or `true` for every direction the peripheral supports

Example: a stream collision

# tests/fixtures/dma_collision.toml — I2C1_RX and UART5_RX both map to
# DMA1 stream 0; I2C1_RX has a free alternative on stream 5, UART5_RX doesn't.
[device]
family = "STM32F446RE"

[peripherals.i2c1]
sda = "PB7"
scl = "PB6"
dma = ["rx"]

[peripherals.uart5]
tx = "PC12"
rx = "PD2"
dma = ["rx"]
$ nucleus check tests/fixtures/dma_collision.toml
tests/fixtures/dma_collision.toml: 1 conflict found:

  error: DMA collision: I2C1 and UART5 both need DMA1 stream 0 (move I2C1 to DMA1 stream 5 (channel 1))

See also

IRQ / NVIC Verifier

NVIC vectors and EXTI lines are shared, finite resources CubeMX never checks for you: two GPIO pins wired to interrupts that happen to share an EXTI line, a peripheral interrupt enabled with no vector modeled, or a DMA-vs-IRQ priority inversion that quietly starves a completion handler. The M3 verifier (Conflict::IrqConflict) catches all three at nucleus check time.

The model (nucleus-db/src/irq.rs)

Pure data, hand-maintained (the pack XML carries no NVIC/IRQ information), cross-checked by a reference-manual seed test:

  • EXTI → NVIC grouping: lines 0–4 each get a dedicated vector (EXTI0..EXTI4); lines 5–9 share EXTI9_5; lines 10–15 share EXTI15_10. Identical on F446RE and F411RE (RM0390 / RM0383 Table 38).
  • Peripheral → vector map: one row per modeled peripheral kind (USART/UART, SPI, I2C, TIM), restricted to the instances each family actually has. I2Cx is the one irregular case — two vectors per instance, I2Cx_EV (event) and I2Cx_ER (error); everything else has exactly one.

The solver (nucleus-compiler/src/irq.rs)

Three independent checks, run in fixed order, each producing its own IrqConflict entries:

  1. Unhandled IRQ — a peripheral table sets irq = true for a peripheral the family's IrqMap has no vector for. (Error.)
  2. EXTI collision — two or more [[exti]] entries resolve to the same EXTI line (shared across all eight GPIO ports — PA0, PB0, ... PH0 all land on line 0). One conflict per contested line, naming every distinct port claiming it — not one conflict per colliding pair. (Error.)
  3. Priority inversion — a peripheral with both dma_priority and irq_priority set, where the DMA priority is numerically less urgent (a larger number) than the IRQ priority: the peripheral's own ISR can then preempt the DMA-completion interrupt it's meant to hand off to. (Warning — nucleus check does not fail on this alone.)

IrqConflict is the first Conflict variant carrying its own explicit Severity rather than always being an error — see Pinout & Peripheral Verification for the severity rule nucleus check's exit code follows.

stm32.toml schema

[peripherals.usart2]
tx = "PA2"
rx = "PA3"
irq = true            # opt in to NVIC-vector verification (never inferred)
irq_priority = 5
dma = true
dma_priority = 2      # numerically smaller = more urgent

[[exti]]
pin = "PA0"
priority = 3

Example: an EXTI line collision

# tests/fixtures/exti_collision.toml — PA0 and PB0 both resolve to EXTI0
[device]
family = "STM32F446RE"

[[exti]]
pin = "PA0"

[[exti]]
pin = "PB0"
$ nucleus check tests/fixtures/exti_collision.toml
tests/fixtures/exti_collision.toml: 1 conflict found:

  error: IRQ conflict [PA0]: EXTI0 is shared by PA0 and PB0 but only one can trigger it

See also

Constraint Auto-Router

M1–M3 all validate an explicit, fully-pinned stm32.toml and report a conflict when it's wrong. M4 inverts the problem: name a peripheral instance without assigning some or all of its pins, and nucleus route searches the pin/AF database for a complete, valid, deterministic assignment for exactly the roles left open — declare intent, get a pinout.

Scope: pins only

You always name the exact peripheral instance (usart2, spi1); the router never picks which instance to use, only which pins:

  • A role with its key absent → route this role.
  • A role with its key present → respect this pin as fixed (validated exactly like nucleus check would; a pre-existing hard conflict aborts routing before search starts).
  • An optional role left absent → left alone. The router never force-allocates a pin for a signal you have no use for.
  • [[exti]] entries are pre-occupied pins, treated like any other already-pinned role — there's no EXTI auto-routing in M4.

Cost function

Candidate pins for each open role are sorted by a strict lexicographic priority — not a weighted sum, so there are no tuning constants to fight:

  1. Ascending pin-demand — how many AF-mapping rows reference that pin across the whole database. Lower demand first: using an uncontested pin now keeps high-demand pins free for later roles.
  2. DMA pressure — a documented no-op for this milestone. nucleus-db's DMA candidate slots key off (peripheral, direction) only, with no dependency on which pin/AF was chosen, so this tier can never break a tie in the pins-only scope M4 ships. Kept as an explicit always-equal comparison (rather than silently dropped) so a future instance-selection extension has an obvious place to plug in.
  3. Same-port-as-sibling preference — for a multi-role instance, if one GPIO port has a free candidate for every still-open role of that instance, every role's sort prefers that port. Lowest priority, so it only breaks ties demand left open.

A backtracking depth-first search over the open roles: try the sorted candidates for the first open role, recurse on the rest, undo and try the next candidate on dead-end. A global step counter spans the whole search — past a fixed budget (100,000 steps; a pathological-input safety net, not a normal-path concern) the search aborts and falls back to a pure greedy strategy (same sort order, first candidate per role, no undo).

On a complete candidate assignment, the result is merged into a synthetic fully-pinned config and re-run through the same clock/DMA/IRQ/pin-collision checks nucleus check runs — a successful route is valid by construction, not just by the router's own bookkeeping. Any failure (search exhaustion, greedy exhaustion, or this final validation pass) is reported as Conflict::Unroutable.

Usage

nucleus route [path]              # print the routed config to stdout
nucleus route [path] --out FILE   # write it to FILE instead

path defaults to ./stm32.toml. Exit code 0 on a successful route, 1 if any role couldn't be routed (or on a parse error) — same gating discipline as nucleus check.

Example: a clean route

# tests/fixtures/route_simple.toml
[peripherals.usart2]

[peripherals.spi1]
$ nucleus route tests/fixtures/route_simple.toml
[peripherals.usart2]
rx = "PA3"
tx = "PA2"

[peripherals.spi1]
miso = "PA6"
mosi = "PA7"
sck = "PA5"

Example: unroutable

# tests/fixtures/route_overconstrained.toml — tim5.channel3 pre-occupies PA2,
# USART2_TX's only candidate pin on the F446, leaving zero free candidates.
[peripherals.tim5]
channel3 = "PA2"

[peripherals.usart2]
$ nucleus route tests/fixtures/route_overconstrained.toml
tests/fixtures/route_overconstrained.toml: could not route (1 conflict):

  error: unroutable [USART2_TX]: no free pin among candidates: PA2 (held by tim5.channel3)

See also

Dual-Backend HIL Substrate

Nucleus v1 stopped at "does this firmware build and flash." v2's whole second half is "does this firmware actually behave correctly" — and answering that needs a way to observe a running target. The M5 substrate, nucleus-hil, gives every later test feature (declarative assertions, scripted tests, lockstep) one common Backend trait so they never care whether the target is a simulator or real silicon.

The Backend trait (nucleus-hil/src/backend.rs)

#![allow(unused)]
fn main() {
pub trait Backend {
    fn name(&self) -> BackendKind;
    fn start(&mut self, firmware: &FirmwareArtifact, check_report: &CheckReport)
        -> Result<(), HilError>;
    fn pin(&mut self, port: nucleus_db::Port, pin_num: u8) -> Result<bool, HilError>;
    fn register(&mut self, peripheral: &str, offset: u32) -> Result<u32, HilError>;
    fn read_mem32(&mut self, addr: u32) -> Result<u32, HilError>;
    fn write_mem32(&mut self, addr: u32, value: u32) -> Result<(), HilError>;
    fn await_itm_event(&mut self, timeout: Duration) -> Result<Option<ItmEvent>, HilError>;
    fn finish(&mut self) -> Result<(), HilError>;
}
}

Every test feature built on top — [[test]] assertions (M6), scripted tests (M7), lockstep (M10) — only ever calls these seven methods. A new backend (a different simulator, a different probe) only has to implement this trait once to plug into everything that already exists.

Key supporting types:

  • BackendKindQemu | Hardware.
  • FirmwareArtifact { elf, bin } — the build output nucleus build produces.
  • ItmEvent { port, data } — one decoded ITM stimulus-port packet.
  • SampleTargetPin { port, pin_num } or RegisterChanged { peripheral, offset }, what lockstep's checkpoints sample.
  • HilErrorPreflight | ToolMissing | Io | Protocol | NotObservable. ToolMissing (e.g. no qemu-system-arm on PATH, no probe attached) is treated as skip, not fail — see below.
  • RunStatusCompleted | Skipped { reason } | Failed { error }.

Two backends, one fidelity gap

QEMU backend

Runs firmware under qemu-system-arm -M netduinoplus2 (the closest QEMU machine to an F4 Nucleo board). No probe, no hardware needed — always available in CI.

Empirically confirmed fidelity gap: this QEMU machine has no real GPIO model. pin() reads/writes hit an unimplemented_device stub — writes are silently dropped, reads always return 0. It does fully model USART2 bidirectionally via serial_hd(1), so UART-based tests work end-to-end on QEMU. This is why M7's scripted UART loopback test passes identically on both backends, while a GPIO toggle assertion is only meaningfully verified on hardware — nucleus test's declarative engine still runs the GPIO assertion on QEMU (it doesn't special-case this), but it can only ever observe 0/no toggling there.

Hardware backend

Drives a real attached Nucleo board over SWD/SWO (via OpenOCD), reusing nucleus-trace's ITM decode path (nucleus-itm) for await_itm_event. If no board/probe is present, start() returns HilError::ToolMissing and nucleus test reports that backend as skipped, not failed — a CI runner with no hardware attached still gets a green run for the backends it can exercise, never a false failure for the ones it can't.

Running tests: nucleus test

nucleus test [path]                  # both backends, every [[test]] block
nucleus test [path] --backend qemu   # one backend only
nucleus test [path] --backend hardware
nucleus test [path] --test <name>    # one test only

path defaults to . (expects stm32.toml and build/firmware.{elf,bin} — run nucleus build first). For each declarative test, on each selected backend: start(), evaluate the assertion (see Declarative Tests), finish(). Each result prints as PASS/FAIL/SKIP and is appended to tests/test_history.json (see Test History). Exit code 1 if any test fails on any backend that actually ran (skips never fail the run).

See also

Declarative Tests

M6 lets you state a hardware behavior as one line in stm32.toml instead of writing a test harness: [[test]] blocks with an assertion string, parsed into a typed Assertion and evaluated against the HIL backend trait — no host code to write for the common cases (a pin toggling, a UART echoing, a trace event firing).

[[test]] schema

[[test]]
name = "uart_echo"                            # required
assertion = "USART2 echoes \"ping\" within 10ms"
timeout_ms = 100                              # optional, default 1000
backend = "both"                              # optional: "qemu" | "hardware" | "both" (default)
type = "declarative"                          # optional, default — see scripted-tests.md for "scripted"

assertion and the other fields are independently optional in the parser, but an entry with no assertion and no script is meaningless — fill at least one.

The assertion grammar (nucleus-compiler/src/assertion.rs)

Four forms, hand-parsed (no parser-combinator dependency, consistent with the rest of the config parser):

FormProduces
pin <PIN> toggles at <N>Hz ±<N>%Assertion::PinToggles { pin, hz, tolerance_pct }
pin <PIN> is <high|low> within <N>msAssertion::PinState { pin, level, within }
<PERIPH> echoes "<text>" within <N>msAssertion::UartEcho { instance, payload, within }
trace event "<pattern>" within <N>msAssertion::ItmEvent { pattern, within }

This is the syntactic layer only — pin and instance are kept as raw strings here; resolving them against the nucleus-db pin/peripheral tables and actually sampling the backend happens when nucleus test runs the assertion. A malformed assertion string is a parse error, reported the same way a malformed TOML value is.

Examples of each form:

assertion = "pin PA5 toggles at 1Hz ±5%"
assertion = "pin PC13 is low within 10ms"
assertion = "USART2 echoes \"ping\" within 10ms"
assertion = "trace event \"boot complete\" within 500ms"

Evaluating against a backend

nucleus test resolves each assertion against whichever Backend is selected:

  • PinToggles/PinState → repeated Backend::pin() samples.
  • UartEchoBackend::write_mem32/read_mem32 against the peripheral's data register, or (on hardware) a UART transaction over the same channel nucleus-trace uses.
  • ItmEventBackend::await_itm_event(), matching the decoded packet against pattern.

Remember the QEMU fidelity gap: QEMU's netduinoplus2 machine has no real GPIO model, so a PinToggles/ PinState assertion only meaningfully verifies on the hardware backend — it still runs on QEMU (nothing special-cases it), but will read back 0 there. UART and trace-event assertions are fully verified on both.

Running

nucleus test                      # all backends, all [[test]] blocks in ./stm32.toml
nucleus test --test uart_echo     # just this one
nucleus test --backend qemu       # just this backend

See Dual-Backend HIL Substrate for the full CLI surface, exit-code rules, and how results land in tests/test_history.json.

See also

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 sets BUSY (1) → agent posts DONE (2) or ERR (3).
  • Commands: PING, SET_GPIO, READ_GPIO, READ_REG, UART_TX, UART_RX_POLL (returns RX_NONE = 0xFFFF_FFFF when 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

Trace Dashboard & ITM Decoding

nucleus trace decodes ARM CoreSight ITM/SWO packets and streams them to the trace dashboard. Getting data flowing end-to-end needs two pieces wired together: a few lines of C in your firmware that configure the ITM/TPIU peripherals and write to stimulus ports, and an OpenOCD session that captures the resulting SWO byte stream and forwards it to nucleus trace.

Firmware (ITM) --SWO--> ST-Link --USB--> OpenOCD --TCP--> nucleus trace --WebSocket--> dashboard

Firmware side: the CoreSight register setup

CMSIS headers (pulled in by stm32f4xx_hal.h) define CoreDebug, TPI, and ITM as memory-mapped structs. Enabling SWO output is six register writes:

#include "stm32f4xx_hal.h"

/* Configure SWO output. `core_hz` is [device].clock_hz; `swo_hz` must match
 * [trace].swo_freq in stm32.toml (and the value passed to
 * `nucleus trace --openocd`). */
void itm_init(uint32_t core_hz, uint32_t swo_hz)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 1. enable tracing */

    TPI->SPPR = 2;                       /* 2. SWO protocol = NRZ/UART */
    TPI->ACPR = (core_hz / swo_hz) - 1;  /* 3. SWO baud rate divisor */

    ITM->LAR = 0xC5ACCE55;               /* 4. unlock the ITM registers */
    ITM->TCR |= ITM_TCR_ITMENA_Msk;      /* 5. enable the ITM */
    ITM->TER |= 1UL;                     /* 6. enable stimulus port 0 (log text) */
}

Call itm_init(...) once, after SystemClock_Config(), alongside the generated Nucleus_Init().

Logging text (port 0)

nucleus-trace reassembles single-byte writes to stimulus port 0 into UTF-8 log lines, splitting on \n. Write one byte at a time, blocking while the port's FIFO is full:

void itm_log(const char *s)
{
    while (*s) {
        while (ITM->PORT[0].u32 == 0) { } /* wait for FIFO space */
        ITM->PORT[0].u8 = (uint8_t)*s;
        s++;
    }
}

A trailing \n flushes the line to the dashboard's log panel:

itm_log("system init complete\n");

Tracing typed variables (ports 1-7)

Each [[trace.variables]] entry in stm32.toml names a port and a type (f32, u16, u32, or i32). The access width of the write to ITM->PORT[n] determines the packet size nucleus-itm reports, so match the width to the type: 4 bytes for f32/u32/i32, 2 bytes for u16.

static inline void itm_write32(uint8_t port, uint32_t value)
{
    if (!(ITM->TCR & ITM_TCR_ITMENA_Msk) || !(ITM->TER & (1UL << port)))
        return;
    while (ITM->PORT[port].u32 == 0) { }
    ITM->PORT[port].u32 = value;
}

static inline void itm_write16(uint8_t port, uint16_t value)
{
    if (!(ITM->TCR & ITM_TCR_ITMENA_Msk) || !(ITM->TER & (1UL << port)))
        return;
    while (ITM->PORT[port].u16 == 0) { }
    ITM->PORT[port].u16 = value;
}

To trace an f32 on port 1, write its bits as u32:

float temperature = read_temperature();
uint32_t bits;
memcpy(&bits, &temperature, sizeof(bits));
itm_write32(1, bits);

Remember to enable each port you use — add it to itm_init():

ITM->TER |= (1UL << 1); /* enable port 1 for the temperature trace */

OpenOCD side

OpenOCD captures SWO from the ST-Link and forwards it to a TCP port via tpiu config internal. nucleus trace --openocd <telnet_addr> sends this sequence for you over OpenOCD's telnet console (default port 4444):

tpiu config internal :<trace_port> uart off <core_hz> <swo_hz>
itm ports on

<trace_port> is the TCP port nucleus trace --trace-tcp connects to, and <core_hz>/<swo_hz> must match the values passed to itm_init() in firmware. If you'd rather configure OpenOCD by hand (e.g. to debug a version mismatch), connect with telnet localhost 4444 and run the same two commands.

Putting it together

# stm32.toml
[trace]
enabled  = true
swo_freq = 2_000_000

[[trace.variables]]
name = "temperature"
port = 1
type = "f32"
nucleus trace --trace-tcp 127.0.0.1:3344 --openocd 127.0.0.1:4444 \
              --config stm32.toml

See CLI Usage for the full flag reference, and open the dashboard via the VS Code command Nucleus: Open Trace Dashboard (or extension/dist/index.html standalone) to see the log lines and temperature chart update live.

The dashboard (React + Canvas)

The VS Code extension's webview hosts a React dashboard that connects to nucleus trace's WebSocket and renders two live views, both pure display — per the project's "extension has zero business logic" rule, every decode step (packet framing, DWT/ITM disambiguation, variable typing) happens in nucleus-itm/nucleus-trace; the dashboard only draws JSON events it's handed:

  • Log panel — port-0 lines, newest at the bottom.
  • Variable chart — a <canvas> line plot per [[trace.variables]] entry, keyed by name, updating as each decoded value event arrives.

The same dashboard also renders nucleus history --graph's output as a bar chart — see Test History & CI for what that shows and what was descoped from the original design.

See also

Test History & CI

Nucleus v2's history is deliberately minimal: an earlier design called for a content-addressed .nucleus/ ledger with version hashing and signed verification reports. That was built, then ripped out — the team decided the complexity wasn't earning its keep for what's actually needed (a simple record of what passed) and replaced it with one flat JSON file. If you're looking for hashing, artifacts, or a version store: there isn't one, on purpose.

tests/test_history.json

nucleus test creates this file on first run, then appends one run every time after. No hashing, no content-addressing — just an append-only JSON file that travels with the repo (commit it, or don't; it's a normal file).

{
  "runs": [
    {
      "timestamp": 1750000000,
      "tests": [
        { "name": "uart_echo", "backend": "qemu", "status": "pass", "detail": "echoed within 8ms" },
        { "name": "uart_echo", "backend": "hardware", "status": "skip", "detail": "no probe attached" }
      ]
    }
  ]
}

One TestEntry per assertion-on-a-backend (so a single [[test]] block run on both backends produces two entries in the same run).

nucleus history

nucleus history [path]              # human table, oldest run first
nucleus history [path] --last N     # only the N most recent runs
nucleus history [path] --graph      # per-run pass/fail/skip as JSON
$ nucleus history
#1   2025-06-15 15:06:40Z  1 passed, 0 failed, 1 skipped

--graph is the dashboard's/export data source — always valid JSON, even against an empty history:

$ nucleus history --graph
{
  "schema": "nucleus.history.v1",
  "runs": [
    { "timestamp": 1750000000, "pass": 1, "fail": 0, "skip": 1 }
  ]
}

nucleus show [run]

Every assertion result for one run — defaults to the latest, or pass the 1-based number nucleus history listed.

$ nucleus show
run      #1
recorded 2025-06-15 15:06:40Z
summary  1 passed, 0 failed, 1 skipped
tests:
    PASS uart_echo [qemu]: echoed within 8ms
    SKIP uart_echo [hardware]: no probe attached

The dashboard's history bar chart

The VS Code extension's React dashboard (see Trace Dashboard & ITM Decoding) renders nucleus history --graph's output as a bar chart — one bar per run, stacked pass/fail/skip. Per the "extension has zero business logic" rule, all counting happens in nucleus-history/Rust; the TypeScript side only draws what it's given. Trend lines, CPU/timing charts, and a clickable trend-graph link were all part of the original M9 design and were descoped along with the ledger — the bar chart is what shipped.

CI: the composite action

.github/actions/nucleus/action.yml wraps checkbuildtest (QEMU always, hardware optionally) → a PR summary, with each stage degrading gracefully if the previous one's inputs are off.

- uses: harshverma27/nucleus/.github/actions/nucleus@main
  with:
    config: stm32.toml
    build: "true"        # nucleus build (needs the ARM toolchain)
    run_tests: "true"    # nucleus test, after a successful build (default)
    hardware: "false"    # also run the hardware leg (needs a self-hosted runner + board)
InputDefaultDescription
configstm32.tomlPath to the config to validate.
buildfalseAlso run nucleus build.
run_teststrueRun nucleus test after a successful build (QEMU always; hardware only if hardware: true). Requires build: true.
hardwarefalseAlso run the hardware test leg. Off by default — the report records it as skipped, never failed, so a hosted runner with no board stays green.
version*nucleus-cli version from crates.io, or git to build from main.
commenttruePost the summary as a PR comment.

Outputs: conflicts, firmware-size, qemu-passed, hardware-passed. The PR comment (and the workflow's Job Summary, visible even without comment permissions) reports per-backend pass/fail counts and uploads tests/test_history.json as the nucleus-test-history artifact. A signed, cryptographic verification report was part of the original M9 design and was descoped with the ledger — the plain-text summary + raw history artifact is what ships.

See also

Lockstep Co-Execution

The crown of v2: run the same firmware on both HIL backends at once and detect the moment they disagree. Passing on QEMU only proves your logic is correct in an idealized model — lockstep is how you find out a timing assumption, a register quirk, or a peripheral behavior QEMU doesn't model faithfully is masking a real bug that only shows up on silicon.

How it works (nucleus-hil/src/lockstep.rs)

Sync points are ITM events — both backends already expose Backend::await_itm_event, so no new backend capability is needed. Observables are whatever [trace.variables] decodes a port's bytes into (the same Translator the trace daemon and dashboard use): a traced variable's value already arrives inside the ITM packet that names its port.

  1. Collectcollect(backend, vars, timeout_per_event, total_timeout) drives one already-started backend with await_itm_event until it times out or stops producing events, decoding each event into a Checkpoint { itm_event, decoded }. decoded is None for port-0 log lines and unconfigured ports — they still compare on the raw event bytes.
  2. Comparecompare(sim_trace, silicon_trace) walks both ObservationTraces checkpoint by checkpoint and returns a DivergenceReport:
    • Agreement { checkpoints_compared } — same length, same content at every index.
    • Diverged { first_checkpoint, observable, sim_value, silicon_value } — the first index where they disagree. A differing decoded value is reported by variable name (friendlier); a differing raw event, or one trace running out before the other, is reported as "itm_event".

This is a bare diff, by design — no inference, no root-cause engine. Explaining why two traces diverged (was it a timing race? a register model gap? a real bug?) is explicitly deferred past v2; nucleus lockstep --explain exists as a flag but only prints a note that it isn't implemented yet, never silently swallowed.

Running it: nucleus lockstep

nucleus lockstep [path]            # both backends, compare
nucleus lockstep [path] --explain  # same, plus an "not implemented, see v3" note on divergence

path defaults to . (expects stm32.toml and build/firmware.{elf,bin} — run nucleus build first). Sequence: run nucleus check (abort on any conflict, same as nucleus test) → start both backends (a ToolMissing backend — e.g. no QEMU binary, no probe attached — is reported as skipped, not a hard failure) → collect from each → compare the two that started.

If fewer than two backends actually started, lockstep prints which leg (if any) ran and exits 0 — "no comparison possible" is not itself a failure. With two legs:

$ nucleus lockstep
agreement across 12 checkpoint(s) (Qemu vs Hardware)
$ nucleus lockstep
diverged at checkpoint 4: speed Qemu=10 Hardware=12

Exit code: 0 on agreement (or fewer than two legs), 1 on a detected divergence — the gating signal for "trust this build before flashing it."

Why this catches what single-backend testing can't

Recall the QEMU fidelity gap: the netduinoplus2 machine fully models USART2 but has no real GPIO model. A test that only runs on QEMU can pass cleanly while quietly never exercising real GPIO behavior at all. Lockstep doesn't fix the simulator's fidelity gap — it makes that gap visible: a divergence at a GPIO-driven checkpoint is the system telling you "QEMU and hardware disagree here," which is exactly the signal a model gap (or a real bug) produces.

See also

CI integration

Because nucleus check exits non-zero on any conflict, validating your stm32.toml in CI is a one-liner. Nucleus ships a reusable composite action that installs the CLI, runs checkbuildtest (QEMU always, hardware optionally), and posts a PR summary with per-backend results — see Test History & CI for the full per-backend report format.

Quick start: copy-paste nucleus.yml

Drop this into .github/workflows/nucleus.yml:

name: nucleus
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  pull-requests: write   # needed to post the PR summary comment

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: harshverma27/nucleus/.github/actions/nucleus@main
        with:
          config: stm32.toml
          build: "true"      # also compile firmware (needs the ARM toolchain)
          # run_tests defaults to "true" once build is on: runs `nucleus test --backend qemu`
          # hardware: "true"  # also run the hardware leg (self-hosted runner + board only)

nucleus init scaffolds an equivalent workflow for you.

Action inputs

InputDefaultDescription
configstm32.tomlPath to the config to validate.
buildfalseAlso run nucleus build (requires arm-none-eabi-gcc + cmake in the runner).
run_teststrueRun nucleus test after a successful build (QEMU always; hardware only if hardware: true). Requires build: true.
hardwarefalseAlso run the hardware test leg (needs a self-hosted runner with a connected board + OpenOCD). Off by default — recorded as skipped, never failed, so a hosted runner with no board stays green.
version*nucleus-cli version to install from crates.io, or git to build from main.
commenttruePost the summary as a PR comment (needs pull-requests: write).

Action outputs

OutputDescription
conflictsNumber of conflicts nucleus check reported.
firmware-sizeSize of build/firmware.bin in bytes (when build: true).
qemu-passedAssertions passed on the QEMU backend (empty if tests didn't run).
hardware-passedAssertions passed on the hardware backend (empty if the leg was skipped).

The action also writes the summary to the workflow run's Job Summary, so it is visible even without comment permissions, and uploads tests/test_history.json as the nucleus-test-history artifact whenever tests ran.

version defaults to * (the latest crates.io release). Pin a specific release (e.g. version: 0.1.0) for reproducible CI, or set version: git to build the CLI from main instead of crates.io.

Doing it by hand

If you'd rather not use the action:

- uses: dtolnay/rust-toolchain@stable
- run: cargo install nucleus-cli --locked
- run: nucleus check