metronome/docs/rust-port.md
Me Here 0e224393f7 Rust port Stage 3 milestone 1: pm-kit boot-proof blink (RP2350)
First per-board binary. rust/pm-kit/ is a minimal rp235x-hal firmware that blinks
GP25 on the Pico 2 — proves the toolchain, RP2350 boot block (ImageDef), memory
layout, and flash before we add any drivers.

- src/main.rs + memory.x + build.rs + .cargo/config.toml: rp235x-hal blink, builds
  for thumbv8m.main-none-eabihf.
- build.sh + uf2.py: one command builds the ELF in the container, objcopies to a raw
  image, and packs pm-kit.uf2 (rp2350-arm-s family). Drag onto the Pico 2 in BOOTSEL.

Verified: builds clean; produces a valid 6-block UF2. Runtime (does it blink?) is the
on-device check. Next: drivers (display first) + link pm-core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:34:46 -05:00

7.4 KiB
Raw Blame History

Rust port — staged plan

This is the plan for the native-Rust direction first discussed alongside the A/B-bootloader idea. What changed since then: the track format is now formally specced (docs/track-format.md) with a golden-vector conformance suite (tests/run.mjs). That suite is the thing that makes a port safe — any Rust engine has a precise, executable definition of "correct" to validate against, the same one engine.js and app.py already pass.

The core idea

Port from the inside out, lowest-risk first. The pure logic (track codec, then the scheduler) is host-testable with zero hardware and is gated by the existing golden vectors. Only once that's proven do we touch drivers, A/B, and the actual firmware. We do not rip out CircuitPython until the Rust engine passes the vectors and the drivers are proven on hardware.

Architecture: one firmware core, modular drivers per form factor

Trying many form factors (Kit, Explorer, Grid/Scroll Pack, …) is how we discover the line between core and driver. In Rust that line is enforced by the type system instead of copied by hand — today each CircuitPython form factor is its own ~1,500-line app.py clone; the Rust build is one core crate plus a thin per-board binary.

pm-core — the core (no_std, zero hardware):

  • the track-format codec (rust/track-format, Stage 1) and the scheduler/clock (Stage 2, already no_std and building for RP2350),
  • playback-flow (rep/end/continue, segment seams), app state, set-list model,
  • the USB-MIDI / live-sync / firmware-update protocol logic (the SysEx opcode handling, which is form-factor-independent). It is host-testable and gated by the golden vectors — the same suite engine.js and app.py pass. This is "core."

Driver traits — what the core is generic over (the swappable part): define small project traits — Display (or render straight to an embedded-graphics DrawTarget), Inputs (yields button / touch events), Clicker (audio out), Indicator (RGB) — and write each concrete driver against embedded-hal bus traits (I2c, SpiBus, OutputPin, DelayNs). The core's UI code then doesn't care whether the target is a 17×7 mono matrix or a 320×480 colour TFT.

Per-board binary crates — pm-kit, pm-explorer, pm-grid: a thin main.rs BSP that instantiates the right concrete drivers and hands them to the generic core:

  • Grid (Scroll Pack): IS31FL3731 over I²C (a DrawTarget for a 17×7 mono frame) + 4 GPIO buttons.
  • Explorer / Kit: ST7789 via mipidsi + embedded-graphics; GT911 touch (Kit) over I²C; WS2812 via ws2812-pio; I²S to the PCM5102A via PIO.

The honest caveat (what the Grid prototype is teaching us): a 17×7 mono grid and a 320×480 touch TFT are too different for one pixel-identical UI. So the clean split is core engine + protocol + state = fully shared; the view = per-display-class. The Grid is the most extreme, minimal display in the lineup, which makes it the best forcing-function for finding exactly where that boundary falls before we commit drivers to Rust. The CircuitPython pico-scroll/ build exists to nail that UI down on real hardware first.

Stages

Stage 0 — toolchain in a container

Add a Rust toolchain image (mirroring hardware/eda/): a Containerfile with rustup, the thumbv8m.main-none-eabihf target (RP2350 is Cortex-M33), flip-link, probe-rs, elf2uf2. Driven by a run.sh like the EDA one. Never on the host.

Stage 1 — track-format crate DONE (rust/track-format/)

Implemented and passing: ./rust/run.sh builds the container and runs cargo test, which validates the crate against tests/fixtures/track-format.json (conformance + idempotency). The Rust codec agrees with engine.js and app.py on every vector — and carries vol/cd, so it's the most spec-complete of the three. Original scope below.

(original) Stage 1 — track-format crate ← the concrete first PR

A pure, no_std-compatible crate: parse(&str) -> Track and serialize(&Track) -> String, plus a normalize() that emits the neutral structure from docs/track-format.md §5. Then a cargo test that reads tests/fixtures/track-format.json and asserts each case's norm and round-trip — i.e. a third adapter alongside js_adapter.mjs / py_adapter.py. When this is green, the Rust engine provably agrees with web + device on every groove, euclid, swing, ghost, polymeter, and the playback-flow tokens. No hardware, fully testable in the container.

This is the highest-value slice: small, gated by work already done, and it proves the toolchain.

Stage 2 — scheduler/engine DONE (rust/track-format/src/schedule.rs)

Ported the look-ahead step scheduler (the durs math from app.py tick/_prepare_next). render(track, bars) produces the deterministic click timeline; tests/schedule.rs asserts the timings — quarter-note spacing, subdivisions, swing 2/3:1/3, polymeter 5:4, accents/ghosts, mute, multi-bar looping. All green on the host, no hardware. The real-time firmware loop will just play this timeline against the wall clock.

Also done: the crate is now #![no_std] + alloc and builds for the RP2350 target (cargo build --lib --target thumbv8m.main-none-eabihf) — the codec + scheduler are firmware-ready.

Stage 3 — drivers (hardware) 🔧 IN PROGRESS (rust/pm-kit/)

Milestone 1 (boot-proof) built: rust/pm-kit/ is a minimal RP2350 binary (rp235x-hal) that blinks GP25 — compiles for the target and packs to pm-kit.uf2 via ./rust/pm-kit/build.sh. Confirms toolchain + RP2350 boot block + flash before any drivers. Once it blinks on the Pico 2 we add drivers (display first) and link pm-core. HAL choice (rp235x-hal vs embassy) finalizes with the first real driver.

On embassy / rp-hal:

  • ST7789 240×320 display → mipidsi + embedded-graphics (mature; the parts are well-supported).
  • I²S to the PCM5102A → RP2350 PIO.
  • WS2812 → ws2812-pio. USB-MIDI → usbd-midi / embassy-usb.
  • GT911 touch (Kit) over I²C.

Stage 4 — native A/B + secure boot

Replace the .mpy-level A/B hack (code.py loads app.mpy, rolls back to app.bak) with the RP2350 bootrom's native partition-table A/B + signed boot, configured via picotool (the chip already provides this — see the earlier hardware discussion). The Rust app is the image in the slot; rollback and version selection move into silicon.

What you keep / lose

  • Gain: memory safety, native A/B + secure boot, performance headroom, one typed model instead of three hand-written parsers.
  • Lose: the live one-click .mpy push (Rust is compile→flash→reboot). The editor's data live-sync (tempo/pattern/setlist mirroring) still works — it's a data protocol. Only live logic edits go away, and an embedded wasm3/script module could buy those back if wanted.

Acceptance gate

Every codec/engine change must pass tests/fixtures/track-format.json. The Rust crate joins js/py as a runner adapter, so "same groove on web, device, and the Rust build" is enforced, not hoped for.

Recommendation

Do Stage 1 in a container next — it's small, testable today (given a toolchain), reuses the suite, and produces a real artifact to judge the Rust path on before committing to drivers or a firmware rewrite. Defer Stages 34 until Stage 12 are green and you've decided the live-push tradeoff is acceptable.