Rust sibling of pico-scroll/app.py — the PM_G-1 'Grid' 17x7 LED metronome on a plain RP2040 Pico (thumbv6m, not the Pico 2). LED-first milestone: - IS31FL3731 driver: vendored bulk 144-byte framebuffer, one I2C block write per frame (port of the CircuitPython Matrix; the is31fl3731 crate isn't used). - Polymeter scheduler driven by track-format::schedule::lane_durs (the cross-impl contract) + per-lane step clocks + tempo ramp + gap-trainer. - 4-button input (A play/stop·hold=view, B next-track·hold=next-setlist, X/Y tempo). - Built-in set lists; 3 views: Ticker (default), Grid, Pendulum. - Ticker (user-designed): name infinite-scrolls left; BPM pinned right rotated 90 CCW = hundreds dot-bar (1 dot/100) + last 2 digits rotated. 130 -> 1 dot + '30'. - Build scaffolding: rp2040-hal 0.10 + boot2, memory.x, build.sh + uf2.py (RP2040 family id). thumbv6m-none-eabi added to rust/Containerfile. Excluded from the host workspace like pm-kit. Compiles clean -> 48 KB pm-grid.uf2. Audio (USB-MIDI; the board has no speaker), live-sync, firmware push, practice log and playback-flow auto-advance are deferred to the next milestone (as on pm-kit). Also: delete COORDINATION.md (solo now); docs/rust-port.md updated with pm-grid status + corrected Grid driver-matrix row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
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, alreadyno_stdand 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.jsandapp.pypass. 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
DrawTargetfor a 17×7 mono frame) + 4 GPIO buttons. - Kit: ST7796 320×480 over SPI — driven by a custom
St7796struct (direct port ofpico/main.py), not mipidsi, which fought the panel's geometry/CS (see rust-st7796-cs-gotcha); UI still renders through anembedded-graphicsframebuffer. GT911 touch over I²C; WS2812 viaws2812-pio; I²S to the PCM5102A via PIO. - Explorer: ST7789V 320×240 over an 8-bit parallel (8080) bus via
mipidsi'sParallelInterface— a different driver story from the Kit's SPI (see the matrix below).
Display driver matrix (researched 2026-06-03)
Displays are not the gate — every controller has a real Rust path; the buses differ:
| Form factor | Controller | Bus | Rust driver | Status |
|---|---|---|---|---|
Kit (pm-kit) |
ST7796 320×480 | SPI | custom St7796 (port of pico/main.py) + embedded-graphics framebuffer; mipidsi dropped |
✅ on hardware — but tearing (see below) |
Explorer (pm-explorer) |
ST7789V 320×240 | 8-bit parallel 8080 | mipidsi ParallelInterface (start here; be ready to port directly if geometry fights, as on the Kit) |
path proven upstream; not yet built |
Grid (pm-grid) |
IS31FL3731 17×7 mono | I²C | vendored bulk-framebuffer driver (port of pico-scroll/app.py's Matrix); is31fl3731 crate not used |
✅ built + compiles (LED-first milestone) — see below |
| Kit touch | GT911 | I²C | gt911 or gt9x crate |
✅ mature (blocking + async, 5-point) |
ST7796 (Kit) — only partially working: tearing. Pixels are correct and the panel boots, but the
image tears badly. The cause is structural, not a bug: mipidsi has no TE-pin / vsync / partial-update
/ double-buffer support (confirmed against the upstream repo github.com/almindor/mipidsi — it offers
only optional draw batching), and this ST7796 module doesn't break out the TE (tearing-effect) line to
sync writes against scan-out. So writes race the panel's refresh. Mitigations available to us, none from
the crate: (a) redraw only changed full-width row-bands to shrink the tear window — already done; (b) DMA
each band as one tight burst; (c) sync to a TE GPIO only if a module that exposes that pin is sourced.
Treat tearing as an open hardware/firmware item, not "display done." See rust-st7796-cs-gotcha.
Explorer parallel bus — correctness and performance are decoupled. mipidsi's ParallelInterface
drives the data pins through an OutputBus trait. The shipped Generic8BitBus is plain GPIO bit-bang
(embedded-hal OutputPins) — works immediately, just CPU-bound. For speed, implement OutputBus over
RP2350 PIO (the PIO supports 8080/6800 bus timing; the C/TFT_eSPI world hits ~4 ms for a full 320×480
clear this way) — a drop-in swap that leaves mipidsi + embedded-graphics + pm-ui untouched. Worst case
is "slow but functional," never "impossible," so the bit-bang fallback de-risks the whole Explorer bring-up.
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) — confirmed on Pico 2: GP25 blink. Toolchain + RP2350 boot block + flash work.
🟡 Milestone 2 (display) — draws correctly on Pico 2, but TEARS: ST7796 320×480 over SPI0 via
rp235x-hal, drawing the shared pm-ui through an embedded-graphics framebuffer. Driver is a
custom St7796 struct ported from pico/main.py — mipidsi was tried first and dropped: its
split-transaction CS and orientation/offset math mangled the geometry; the port uses correct
per-command CS framing (CS low → cmd → params → CS high) and full-width row bands (see
rust-st7796-cs-gotcha). Not done: the image tears badly — no TE/vsync sync is possible because
this module exposes no TE pin, so writes race scan-out (no crate fixes this; see the tearing note in
the display driver matrix above). Open item before the display can be called finished. Diagnosed off-bench with host tools in rust/uisim: uisim renders
pm-ui to PNG; --bin panelsim decodes mipidsi's real command/pixel stream into a PNG (proved the
protocol correct → bug was physical); --bin initdump dumps the init + CASET/RASET sequence.
🟡 Milestone 3 (live metronome) — built, pending on-device check: the firmware is now an actual
metronome. embedded-alloc heap → parses tracks with track-format on-device; 4 built-in grooves;
Timer-driven clock; audio clicks on the master lane's hits (GP13 PWM, short edge-triggered
pulses, accent louder); controls — A = play/stop, B = grid/notation view, joystick (rotated 90°
CCW) up/down = tempo, left/right = groove. Renders pm-ui::draw_metronome / draw_notation, with a
cheap draw_progress strip animating the bar position every frame (full redraw only on change → no
flicker). All loop input reads use unwrap_or (no panics) — addresses the self-test crash.
Compile + simulator verified; needs a flash to confirm audio timing, joystick directions, no crash.
pm-ui views (sim-verified, PNGs): metronome grid (accents/ghosts/polymeter), and drum notation (5-line staff, time sig, hands stem-up / feet stem-down, shared stems, beamed eighths, ledger lines).
Still to do: GT911 touch (GP8/9), WS2812 RGB (GP12), USB-MIDI, set-lists from programs.json,
per-cell live playhead, the rest of the practice features. Then split pm-core out as its own crate
and add pm-explorer/pm-grid binaries. HAL stays rp235x-hal (embassy later if async earns it).
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.
pm-grid — Scroll Pack firmware 🟢 BUILT (LED-first milestone), pending on-device check
The Rust sibling of pico-scroll/app.py (rust/pm-grid/). Target is a plain RP2040 (Cortex-M0+,
thumbv6m-none-eabi) — NOT the Pico 2 — so it has its own HAL (rp2040-hal 0.10 + rp2040-boot2),
.cargo/config.toml, memory.x (BOOT2 + flash + 264 KB RAM) and build.sh+uf2.py (RP2040 family
id 0xe48bff56). thumbv6m-none-eabi added to rust/Containerfile. Compiles clean → 48 KB
pm-grid.uf2 (BOOTSEL drag-flash; no probe needed). Kept out of the host workspace like pm-kit.
What's implemented (faithful port of pico-scroll): the IS31FL3731 driver (vendored bulk
144-byte framebuffer, one I²C block write per frame — the right architecture, mirrors the
CircuitPython Matrix; per-pixel I²C is too slow to animate), the polymeter scheduler driven by
track-format::schedule::lane_durs (the cross-impl contract) with per-lane step clocks + ramp +
gap-trainer, 4-button input (A tap=play/stop, hold=cycle view; B tap=next track, hold=next set
list; X/Y=tempo ∓ with auto-repeat), the built-in set lists, and three LED views:
- Ticker (default): track name infinite-scrolls across the left (cols 0–10, full height); BPM is
pinned right, rotated 90° CCW — a vertical hundreds dot-bar in col 11 (one dot per 100) +
the last two digits rotated into cols 12–16 (tens bottom, units top). So
130→ 1 dot + rotated "30". This is the user-designed landscape readout. Rotation/geometry verified off-bench with an ASCII replica ofdraw_ticker. - Grid (lanes×steps + playhead) and Pendulum (bouncing arm + beat ticks) — ports of
_render_grid/_render_pendulum. Boot splash scrolls "PM-G1 GRID" (liveness + pixel-map check).
Deferred to the next milestone (matches pm-kit's order — none built yet): USB-MIDI note-out
(the Scroll Pack has NO speaker, so this is the real audio path) + MIDI clock, live-sync SysEx
(0x40-0x43 + version query), firmware push (0x10/0x21-0x23), on-device practice log, settings.json,
and playback-flow auto-advance (rep/end/continue). An optional piezo on a free GPIO is also TODO.
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
.mpypush (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 embeddedwasm3/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 3–4 until Stage 1–2 are green and you've decided the live-push tradeoff is acceptable.