The parallel agent's full session, committed now that it's solo: - Grammar: flam/drag/roll ornaments (f/F d/D z/Z, per-lane orns channel) across src/engine.js, pico-cp/pico-explorer/pico-scroll app.py, pico/main.py, rust/track-format, + golden vectors / conformance (tests/, rust/track-format/tests). - Live-sync deep-sync: SysEx 0x44 SLSYNC + 0x45 LOGSYNC (docs/livesync-protocol.md, src/livesync.js). - PM_E-2 notation: web engine (pm_e-2.html, build/deploy/index/embed wiring) + Rust device port (pm-ui draw_notation rewrite + LaneView.groups, pm-kit ViewMode, uisim notesim). Verified: node tests/run.mjs 47 pass / 1 known; ./rust/run.sh green; pm-kit firmware + uisim compile.
512 lines
20 KiB
Rust
512 lines
20 KiB
Rust
//! PM_K-1 firmware — Stage 3 bring-up.
|
||
//! Milestone 1 (done): blink GP25 → proved boot/flash.
|
||
//! Milestone 2 (this): init the ST7796 320×480 display over SPI0 and draw to it.
|
||
//! Pins (from the CircuitPython firmware): SCK=GP2, MOSI=GP3, CS=GP5, DC=GP6, RST=GP7;
|
||
//! BGR panel, colours inverted. LED on GP25 keeps blinking as a heartbeat.
|
||
|
||
#![no_std]
|
||
#![no_main]
|
||
|
||
extern crate alloc;
|
||
use alloc::vec::Vec;
|
||
use embedded_alloc::LlffHeap as Heap;
|
||
|
||
#[global_allocator]
|
||
static HEAP: Heap = Heap::empty();
|
||
|
||
use embedded_hal::delay::DelayNs;
|
||
use embedded_hal::digital::{InputPin, OutputPin};
|
||
use embedded_hal::pwm::SetDutyCycle;
|
||
use embedded_hal_0_2::adc::OneShot;
|
||
use embedded_hal::spi::SpiBus;
|
||
use defmt::info;
|
||
use defmt_rtt as _; // global defmt logger over RTT (read by `probe-rs run`)
|
||
use panic_probe as _; // panic handler that prints the message + location over defmt, then halts
|
||
use rp235x_hal as hal;
|
||
use hal::fugit::RateExtU32;
|
||
use hal::Clock;
|
||
use embedded_graphics::pixelcolor::Rgb565;
|
||
use embedded_graphics::prelude::*;
|
||
use embedded_graphics::primitives::Rectangle;
|
||
|
||
/// Image definition block — the RP2350 bootrom looks for this to boot the image.
|
||
#[link_section = ".start_block"]
|
||
#[used]
|
||
pub static IMAGE_DEF: hal::block::ImageDef = hal::block::ImageDef::secure_exe();
|
||
|
||
const XTAL_FREQ_HZ: u32 = 12_000_000;
|
||
const WIDTH: u16 = 320;
|
||
const HEIGHT: u16 = 480;
|
||
|
||
/// Dirty-tile grid for incremental updates. TILE wide is sub-screen-width so each tile blit writes
|
||
/// faster than the panel scan crosses it → minimal tearing. 320/40=8 cols, 480/40=12 rows = 96 tiles.
|
||
const TILE: u16 = 40;
|
||
const NX: usize = WIDTH as usize / TILE as usize;
|
||
const NY: usize = HEIGHT as usize / TILE as usize;
|
||
|
||
/// Direct ST7796 driver — a faithful port of the proven MicroPython driver in `pico/main.py`,
|
||
/// which runs this exact panel on this exact board. Per-command CS framing (CS low → command →
|
||
/// data → CS high), MADCTL 0x48, INVON, NO row/column offset, big-endian RGB565. This replaces
|
||
/// mipidsi, whose split-transaction CS and orientation/offset math were mangling the geometry.
|
||
/// We render into the RAM `FrameBuf` (embedded-graphics), then `blit` the whole frame in one
|
||
/// windowed RAMWR stream — exactly like main.py's `_window` + `spi.write`.
|
||
struct St7796<SPI, DC, CS, RST> {
|
||
spi: SPI,
|
||
dc: DC,
|
||
cs: CS,
|
||
rst: RST,
|
||
}
|
||
impl<SPI, DC, CS, RST> St7796<SPI, DC, CS, RST>
|
||
where
|
||
SPI: SpiBus,
|
||
DC: OutputPin,
|
||
CS: OutputPin,
|
||
RST: OutputPin,
|
||
{
|
||
fn cmd(&mut self, c: u8, data: &[u8]) {
|
||
let _ = self.cs.set_low();
|
||
let _ = self.dc.set_low();
|
||
let _ = self.spi.write(&[c]);
|
||
if !data.is_empty() {
|
||
let _ = self.dc.set_high();
|
||
let _ = self.spi.write(data);
|
||
}
|
||
let _ = self.cs.set_high();
|
||
}
|
||
|
||
/// main.py reset() + init() verbatim.
|
||
fn init(&mut self, delay: &mut impl DelayNs) {
|
||
let _ = self.cs.set_high();
|
||
let _ = self.dc.set_low();
|
||
let _ = self.rst.set_high();
|
||
delay.delay_ms(20);
|
||
let _ = self.rst.set_low();
|
||
delay.delay_ms(40);
|
||
let _ = self.rst.set_high();
|
||
delay.delay_ms(150);
|
||
|
||
self.cmd(0x01, &[]); // SWRESET
|
||
delay.delay_ms(120);
|
||
self.cmd(0x11, &[]); // SLPOUT
|
||
delay.delay_ms(120);
|
||
self.cmd(0xF0, &[0xC3]); // unlock extension command set
|
||
self.cmd(0xF0, &[0x96]);
|
||
self.cmd(0x36, &[0x48]); // MADCTL = MX | BGR
|
||
self.cmd(0x3A, &[0x55]); // COLMOD 16bpp
|
||
self.cmd(0xB4, &[0x01]);
|
||
self.cmd(0xB6, &[0x80, 0x02, 0x3B]);
|
||
self.cmd(0xE8, &[0x40, 0x8A, 0x00, 0x00, 0x29, 0x19, 0xA5, 0x33]);
|
||
self.cmd(0xC1, &[0x06]);
|
||
self.cmd(0xC2, &[0xA7]);
|
||
self.cmd(0xC5, &[0x18]);
|
||
delay.delay_ms(120);
|
||
self.cmd(0xE0, &[0xF0, 0x09, 0x0B, 0x06, 0x04, 0x15, 0x2F, 0x54, 0x42, 0x3C, 0x17, 0x14, 0x18, 0x1B]);
|
||
self.cmd(0xE1, &[0xE0, 0x09, 0x0B, 0x06, 0x04, 0x03, 0x2B, 0x43, 0x42, 0x3B, 0x16, 0x14, 0x17, 0x1B]);
|
||
self.cmd(0xF0, &[0x3C]); // lock
|
||
self.cmd(0xF0, &[0x69]);
|
||
delay.delay_ms(120);
|
||
self.cmd(0x21, &[]); // INVON (INVERT_COLORS = true)
|
||
self.cmd(0x29, &[]); // DISPON
|
||
delay.delay_ms(50);
|
||
}
|
||
|
||
/// Blit a full-width band of rows `ylo..ylo+h`. CASET is the full width (0..=319, exactly like
|
||
/// the full-screen blit), only RASET is partial — and because a full-width band is *contiguous*
|
||
/// in the row-major framebuffer, `px` is just that contiguous slice, streamed with the identical
|
||
/// loop the full blit uses. The full-screen blit is `blit_band(&px[..], 0, HEIGHT)`.
|
||
fn blit_band(&mut self, px: &[Rgb565], ylo: u16, h: u16) {
|
||
let y1 = ylo + h - 1;
|
||
self.cmd(0x2A, &[0x00, 0x00, 0x01, 0x3F]); // CASET 0..=319 (full width)
|
||
self.cmd(0x2B, &[(ylo >> 8) as u8, (ylo & 0xFF) as u8, (y1 >> 8) as u8, (y1 & 0xFF) as u8]);
|
||
let _ = self.cs.set_low();
|
||
let _ = self.dc.set_low();
|
||
let _ = self.spi.write(&[0x2C]); // RAMWR
|
||
let _ = self.dc.set_high();
|
||
let mut buf = [0u8; 1024];
|
||
let mut n = 0;
|
||
for &c in px {
|
||
let raw = c.into_storage(); // u16 RGB565, big-endian like main.py
|
||
buf[n] = (raw >> 8) as u8;
|
||
buf[n + 1] = (raw & 0xFF) as u8;
|
||
n += 2;
|
||
if n == buf.len() {
|
||
let _ = self.spi.write(&buf);
|
||
n = 0;
|
||
}
|
||
}
|
||
if n > 0 {
|
||
let _ = self.spi.write(&buf[..n]);
|
||
}
|
||
let _ = self.cs.set_high();
|
||
}
|
||
|
||
/// Full-screen blit — the known-good path. Just a full-height band.
|
||
fn blit(&mut self, px: &[Rgb565]) {
|
||
self.blit_band(px, 0, HEIGHT);
|
||
}
|
||
|
||
/// Blit a sub-rectangle `(x,y,w,h)` gathered from the full framebuffer. Used for small tile
|
||
/// updates: sub-width writes finish a region faster than the panel scan crosses it (datasheet
|
||
/// §10.8 Example 1), so the visible tear is tiny vs a full-width band. Same streaming loop and
|
||
/// 1024-byte buffer as `blit_band` (the 512-byte buffer was what broke the earlier version).
|
||
fn blit_rect(&mut self, fb: &[Rgb565], x: u16, y: u16, w: u16, h: u16) {
|
||
let x1 = x + w - 1;
|
||
let y1 = y + h - 1;
|
||
self.cmd(0x2A, &[(x >> 8) as u8, (x & 0xFF) as u8, (x1 >> 8) as u8, (x1 & 0xFF) as u8]);
|
||
self.cmd(0x2B, &[(y >> 8) as u8, (y & 0xFF) as u8, (y1 >> 8) as u8, (y1 & 0xFF) as u8]);
|
||
let _ = self.cs.set_low();
|
||
let _ = self.dc.set_low();
|
||
let _ = self.spi.write(&[0x2C]); // RAMWR
|
||
let _ = self.dc.set_high();
|
||
let mut buf = [0u8; 1024];
|
||
let mut n = 0;
|
||
for r in 0..h as usize {
|
||
let row = (y as usize + r) * WIDTH as usize + x as usize;
|
||
for c in 0..w as usize {
|
||
let raw = fb[row + c].into_storage();
|
||
buf[n] = (raw >> 8) as u8;
|
||
buf[n + 1] = (raw & 0xFF) as u8;
|
||
n += 2;
|
||
if n == buf.len() {
|
||
let _ = self.spi.write(&buf);
|
||
n = 0;
|
||
}
|
||
}
|
||
}
|
||
if n > 0 {
|
||
let _ = self.spi.write(&buf[..n]);
|
||
}
|
||
let _ = self.cs.set_high();
|
||
}
|
||
}
|
||
|
||
|
||
/// Off-screen framebuffer: a full 320×480 Rgb565 image in RAM (300 KB). The pm-ui draw functions
|
||
/// render into THIS (instant, no SPI), then we push the whole thing to the panel in one
|
||
/// `fill_contiguous` sweep — so the panel never shows a clear-then-redraw transition → no flicker.
|
||
const FB_LEN: usize = WIDTH as usize * HEIGHT as usize;
|
||
static mut FRAMEBUF: [Rgb565; FB_LEN] = [Rgb565::new(0, 0, 0); FB_LEN];
|
||
|
||
struct FrameBuf {
|
||
px: &'static mut [Rgb565],
|
||
}
|
||
impl DrawTarget for FrameBuf {
|
||
type Color = Rgb565;
|
||
type Error = core::convert::Infallible;
|
||
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
|
||
where
|
||
I: IntoIterator<Item = Pixel<Self::Color>>,
|
||
{
|
||
for Pixel(p, c) in pixels {
|
||
if p.x >= 0 && p.y >= 0 && (p.x as u16) < WIDTH && (p.y as u16) < HEIGHT {
|
||
self.px[p.y as usize * WIDTH as usize + p.x as usize] = c;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
fn fill_contiguous<I>(&mut self, area: &Rectangle, colors: I) -> Result<(), Self::Error>
|
||
where
|
||
I: IntoIterator<Item = Self::Color>,
|
||
{
|
||
// fast path used by clear()/fill — write straight into the backing slice row by row
|
||
let mut it = colors.into_iter();
|
||
let x0 = area.top_left.x.max(0) as u16;
|
||
let y0 = area.top_left.y.max(0) as u16;
|
||
let x1 = (area.top_left.x + area.size.width as i32).min(WIDTH as i32) as u16;
|
||
let y1 = (area.top_left.y + area.size.height as i32).min(HEIGHT as i32) as u16;
|
||
for y in y0..y1 {
|
||
for x in x0..x1 {
|
||
if let Some(c) = it.next() {
|
||
self.px[y as usize * WIDTH as usize + x as usize] = c;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
impl OriginDimensions for FrameBuf {
|
||
fn size(&self) -> Size {
|
||
Size::new(WIDTH as u32, HEIGHT as u32)
|
||
}
|
||
}
|
||
|
||
#[hal::entry]
|
||
fn main() -> ! {
|
||
// heap for track parsing (track-format uses alloc). RP2350 has 520 KB SRAM.
|
||
const HEAP_SIZE: usize = 16 * 1024;
|
||
{
|
||
use core::mem::MaybeUninit;
|
||
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
|
||
unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
|
||
}
|
||
info!("== pm-kit boot == heap {}KB, {} free", HEAP_SIZE / 1024, HEAP.free());
|
||
|
||
let mut pac = hal::pac::Peripherals::take().unwrap();
|
||
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
|
||
let clocks = hal::clocks::init_clocks_and_plls(
|
||
XTAL_FREQ_HZ,
|
||
pac.XOSC,
|
||
pac.CLOCKS,
|
||
pac.PLL_SYS,
|
||
pac.PLL_USB,
|
||
&mut pac.RESETS,
|
||
&mut watchdog,
|
||
)
|
||
.ok()
|
||
.unwrap();
|
||
|
||
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
|
||
let sio = hal::Sio::new(pac.SIO);
|
||
let pins = hal::gpio::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
|
||
|
||
let mut led = pins.gpio25.into_push_pull_output();
|
||
led.set_high().unwrap(); // solid ON during init: if it stays solid → hung in init; slow blink → reached the loop
|
||
|
||
// --- ST7796 over SPI0 (direct driver, ported from pico/main.py) ---
|
||
let sclk = pins.gpio2.into_function::<hal::gpio::FunctionSpi>();
|
||
let mosi = pins.gpio3.into_function::<hal::gpio::FunctionSpi>();
|
||
let dc = pins.gpio6.into_push_pull_output();
|
||
let rst = pins.gpio7.into_push_pull_output();
|
||
let cs = pins.gpio5.into_push_pull_output();
|
||
|
||
let spi = hal::spi::Spi::<_, _, _, 8>::new(pac.SPI0, (mosi, sclk));
|
||
let spi = spi.init(
|
||
&mut pac.RESETS,
|
||
clocks.peripheral_clock.freq(),
|
||
62500.kHz(), // pico-cp SPI_BAUD: 62.5 MHz — "faster SPI = smaller tearing window" (drop to 40 MHz if unstable)
|
||
embedded_hal::spi::MODE_0,
|
||
);
|
||
|
||
let mut st = St7796 { spi, dc, cs, rst };
|
||
st.init(&mut timer);
|
||
info!("display init ok (ported st7796 driver), {} free", HEAP.free());
|
||
|
||
// ---- inputs + speaker ----
|
||
let mut btn_a = pins.gpio15.into_pull_up_input(); // A = play/stop
|
||
let mut btn_b = pins.gpio14.into_pull_up_input(); // B = grid/notation view
|
||
let mut adc = hal::adc::Adc::new(pac.ADC, &mut pac.RESETS);
|
||
let mut joy_x = hal::adc::AdcPin::new(pins.gpio26).unwrap();
|
||
let mut joy_y = hal::adc::AdcPin::new(pins.gpio27).unwrap();
|
||
let pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
|
||
let mut spk = pwm_slices.pwm6; // GP13 = PWM slice 6, channel B
|
||
spk.set_div_int(125);
|
||
spk.set_top(600); // ~2 kHz click
|
||
spk.enable();
|
||
spk.channel_b.output_to(pins.gpio13);
|
||
info!("scaffolding (buttons/adc/pwm) up, {} free", HEAP.free());
|
||
|
||
// ---- built-in grooves ----
|
||
const GROOVES: [&str; 4] = [
|
||
"t120;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx",
|
||
"t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x",
|
||
"t140;kick:4=X..x;snare:4=.X.X;hatClosed:4/4=xxxxxxxxxxxxxxxx",
|
||
"t100;kick:4=Xxxx;claves:5=Xxxxx~",
|
||
];
|
||
const NAMES: [&str; 4] = ["Four on the floor", "Half-time shuffle", "Driving 16ths", "5 over 4"];
|
||
|
||
let mut idx = 0usize;
|
||
let mut track = track_format::parse(GROOVES[idx]);
|
||
info!("parsed groove 0: bpm={} lanes={}, {} free", track.bpm, track.lanes.len(), HEAP.free());
|
||
let mut tempo: i64 = track.bpm;
|
||
let mut playing = true;
|
||
// View cycle on button-B: Grid → Staff → TUBS → Konnakol → Grid …
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum View {
|
||
Grid,
|
||
Staff,
|
||
Tubs,
|
||
Konnakol,
|
||
}
|
||
let mut view = View::Grid;
|
||
|
||
// double-buffer: draw into the RAM framebuffer, then push only the full-width ROW BANDS that
|
||
// changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven
|
||
// full-screen blit — unlike sub-width tiles, which the panel's MX bit mirror-maps wrongly.
|
||
let mut fb = FrameBuf { px: unsafe { &mut *core::ptr::addr_of_mut!(FRAMEBUF) } };
|
||
let mut tile_hash = [0u64; NX * NY]; // FNV-1a of each tile at its last blit (0 → force first paint)
|
||
|
||
let mut bar_start = timer.get_counter().ticks();
|
||
let mut last_step = usize::MAX; // audio: click on a new step
|
||
let mut drawn_step = usize::MAX; // draw: advance the playhead
|
||
let mut click_off_us: u64 = 0;
|
||
let (mut pa, mut pb) = (false, false);
|
||
let mut joy_zone = 0i8;
|
||
let mut dirty = true; // force the first frame
|
||
let mut force_full = true; // first paint + big changes use the known-good full blit
|
||
let mut hb = false;
|
||
let mut hb_us = 0u64;
|
||
led.set_low().unwrap();
|
||
info!("entering metronome loop (double-buffered)");
|
||
|
||
loop {
|
||
let now = timer.get_counter().ticks();
|
||
|
||
// ---- inputs ----
|
||
let a = btn_a.is_low().unwrap_or(false);
|
||
let b = btn_b.is_low().unwrap_or(false);
|
||
if a && !pa {
|
||
playing = !playing;
|
||
if playing {
|
||
bar_start = now;
|
||
last_step = usize::MAX;
|
||
}
|
||
dirty = true;
|
||
}
|
||
if b && !pb {
|
||
view = match view {
|
||
View::Grid => View::Staff,
|
||
View::Staff => View::Tubs,
|
||
View::Tubs => View::Konnakol,
|
||
View::Konnakol => View::Grid,
|
||
};
|
||
dirty = true;
|
||
force_full = true; // whole screen changes — use the clean full blit
|
||
}
|
||
pa = a;
|
||
pb = b;
|
||
|
||
let jx = adc.read(&mut joy_x).unwrap_or(2048);
|
||
let jy = adc.read(&mut joy_y).unwrap_or(2048);
|
||
// rotate the joystick reading 90° CCW for control
|
||
let rx = jy as i32;
|
||
let ry = 4095 - jx as i32;
|
||
let zone: i8 = if ry > 3200 { 1 } else if ry < 900 { 2 } else if rx > 3200 { 3 } else if rx < 900 { 4 } else { 0 };
|
||
if zone != 0 && joy_zone == 0 {
|
||
match zone {
|
||
1 => tempo = (tempo + 4).min(300), // up → tempo+
|
||
2 => tempo = (tempo - 4).max(30), // down → tempo-
|
||
3 => {
|
||
idx = (idx + 1) % GROOVES.len(); // right → next groove
|
||
track = track_format::parse(GROOVES[idx]);
|
||
tempo = track.bpm;
|
||
bar_start = now;
|
||
last_step = usize::MAX;
|
||
}
|
||
4 => {
|
||
idx = (idx + GROOVES.len() - 1) % GROOVES.len(); // left → prev
|
||
track = track_format::parse(GROOVES[idx]);
|
||
tempo = track.bpm;
|
||
bar_start = now;
|
||
last_step = usize::MAX;
|
||
}
|
||
_ => {}
|
||
}
|
||
dirty = true;
|
||
if zone == 3 || zone == 4 {
|
||
force_full = true; // groove change → new lanes, use the clean full blit
|
||
}
|
||
}
|
||
joy_zone = zone;
|
||
|
||
// ---- clock ----
|
||
let master = &track.lanes[0];
|
||
let msteps = master.levels.len().max(1) as u64;
|
||
let beats = master.groups.iter().map(|&g| g as u64).sum::<u64>().max(1);
|
||
let bar_us = 60_000_000u64 * beats / tempo.max(1) as u64;
|
||
let step_us = (bar_us / msteps).max(1);
|
||
let elapsed = now.wrapping_sub(bar_start) % bar_us;
|
||
let cur_step = (elapsed / step_us) as usize;
|
||
let phase = elapsed as f32 / bar_us as f32;
|
||
|
||
// ---- audio: click on a new step that has a hit ----
|
||
if playing && cur_step != last_step {
|
||
let lvl = master.levels[cur_step.min(msteps as usize - 1)];
|
||
if lvl > 0 {
|
||
let _ = spk.channel_b.set_duty_cycle(if lvl == 2 { 380 } else { 240 });
|
||
click_off_us = now + if lvl == 2 { 28_000 } else { 14_000 };
|
||
}
|
||
last_step = cur_step;
|
||
}
|
||
if now >= click_off_us {
|
||
let _ = spk.channel_b.set_duty_cycle(0);
|
||
}
|
||
|
||
// redraw when the playhead steps to a new cell (animation), or on any state change
|
||
if playing && cur_step != drawn_step {
|
||
dirty = true;
|
||
}
|
||
|
||
// ---- draw into the framebuffer, then blit ONCE (flicker-free) ----
|
||
if dirty {
|
||
let lanes: Vec<pm_ui::LaneView> = track
|
||
.lanes
|
||
.iter()
|
||
.map(|l| pm_ui::LaneView {
|
||
name: &l.sound,
|
||
levels: &l.levels,
|
||
orns: &l.orns,
|
||
groups: &l.groups,
|
||
beats: l.groups.iter().map(|&g| g as u32).sum::<u32>().min(255) as u8,
|
||
poly: l.poly,
|
||
muted: l.mute,
|
||
})
|
||
.collect();
|
||
let screen = pm_ui::Screen { name: NAMES[idx], bpm: tempo, playing, phase, lanes: &lanes };
|
||
match view {
|
||
View::Grid => pm_ui::draw_metronome(&mut fb, &screen).ok(),
|
||
View::Staff => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Staff).ok(),
|
||
View::Tubs => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Tubs).ok(),
|
||
View::Konnakol => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Konnakol).ok(),
|
||
};
|
||
// FNV-1a of one TILE×TILE block at grid cell (tx, ty)
|
||
fn tile_fnv(px: &[Rgb565], tx: usize, ty: usize) -> u64 {
|
||
let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis
|
||
let x0 = tx * TILE as usize;
|
||
let y0 = ty * TILE as usize;
|
||
for r in 0..TILE as usize {
|
||
let base = (y0 + r) * WIDTH as usize + x0;
|
||
for c in 0..TILE as usize {
|
||
h ^= px[base + c].into_storage() as u64;
|
||
h = h.wrapping_mul(0x100000001b3);
|
||
}
|
||
}
|
||
h
|
||
}
|
||
if force_full {
|
||
// known-good full-screen blit; seed the tile hashes so increments diff against it
|
||
st.blit(&fb.px[..]);
|
||
for ty in 0..NY {
|
||
for tx in 0..NX {
|
||
tile_hash[ty * NX + tx] = tile_fnv(&fb.px[..], tx, ty);
|
||
}
|
||
}
|
||
force_full = false;
|
||
info!("FULL blit (seeded {} tiles)", (NX * NY) as u32);
|
||
} else {
|
||
// ---- dirty-tile blit: push only the changed TILE×TILE blocks (sub-width = less tearing) ----
|
||
let mut pushed = 0u32;
|
||
for ty in 0..NY {
|
||
for tx in 0..NX {
|
||
let h = tile_fnv(&fb.px[..], tx, ty);
|
||
let idx = ty * NX + tx;
|
||
if h != tile_hash[idx] {
|
||
tile_hash[idx] = h;
|
||
st.blit_rect(&fb.px[..], (tx * TILE as usize) as u16, (ty * TILE as usize) as u16, TILE, TILE);
|
||
pushed += 1;
|
||
}
|
||
}
|
||
}
|
||
if pushed > 0 {
|
||
info!("blit {} / {} tiles", pushed, (NX * NY) as u32);
|
||
}
|
||
}
|
||
drawn_step = cur_step;
|
||
dirty = false;
|
||
}
|
||
|
||
// heartbeat LED + log (~2 Hz)
|
||
if now.wrapping_sub(hb_us) > 500_000 {
|
||
hb = !hb;
|
||
let _ = if hb { led.set_high() } else { led.set_low() };
|
||
info!("alive: idx={} step={} playing={} free={}", idx, cur_step, playing, HEAP.free());
|
||
hb_us = now;
|
||
}
|
||
timer.delay_ms(4);
|
||
}
|
||
}
|
||
|
||
/// picotool metadata (visible via `picotool info`).
|
||
#[link_section = ".bi_entries"]
|
||
#[used]
|
||
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 1] =
|
||
[hal::binary_info::rp_program_name!(c"pm-kit display")];
|