picoem_devices/lcd.rs
1//! LCD bit-bang decoder for the emulator showcase.
2//!
3//! The firmware drives a 20x2 character LCD over three GPIO pins:
4//!
5//! | GPIO | Role |
6//! |------|----------------------------------------------|
7//! | SCLK | rising edge latches DATA |
8//! | DATA | MSB-first, stable while SCLK is high |
9//! | CS | active low; frames a transaction |
10//! |------|----------------------------------------------|
11//!
12//! Pin numbers are configurable via [`LcdDecoder::new`].
13//!
14//! Each frame is the sequence of bytes shifted in between a CS falling edge
15//! and the next CS rising edge. The first byte of a frame is the opcode
16//! (LLD §7.3):
17//!
18//! | Opcode | Name | Args | Effect |
19//! |--------|------------|-----------|--------------------------------------|
20//! | `0x01` | CLEAR | — | Fill rows with spaces, cursor=(0,0). |
21//! | `0x02` | SET_CURSOR | col, row | Clamp to [0,19]×[0,1]. |
22//! | `0x03` | WRITE | char+... | Write each char at the cursor. |
23//!
24//! WRITE advances the cursor after each character, wraps at column 20, and
25//! scrolls at row 2 (row 1 copies down to row 0, row 1 cleared to spaces).
26//! Any frame whose first byte is not one of these three opcodes (including
27//! `0x00` and `0x04..=0xFF`) is silently dropped — no characters written,
28//! no state change. A zero-byte frame is likewise a no-op.
29//!
30//! The decoder is fed one sample per sim-thread quantum via [`LcdDecoder::sample`].
31//! Because it only sees the GPIO state at the end of each quantum, the
32//! firmware must hold every signal level that the decoder needs to observe
33//! for at least `2 * quantum_cycles` cycles (the contract in LLD §4.1).
34
35// ---- observable state -------------------------------------------------------
36
37const LCD_COLS: usize = 20;
38const LCD_ROWS: usize = 2;
39
40#[derive(Clone)]
41pub struct LcdState {
42 pub rows: [[u8; 20]; 2],
43 pub cursor: (u8, u8),
44}
45
46impl Default for LcdState {
47 fn default() -> Self {
48 Self {
49 rows: [[b' '; 20]; 2],
50 cursor: (0, 0),
51 }
52 }
53}
54
55// ---- decoder ----------------------------------------------------------------
56
57#[derive(Default)]
58pub struct LcdDecoder {
59 sclk_mask: u32,
60 data_mask: u32,
61 cs_mask: u32,
62 state: LcdState,
63 prev_gpio: u32,
64 have_prev: bool,
65 in_frame: bool,
66 shift: u8,
67 bit_count: u8,
68 rx_buf: Vec<u8>,
69}
70
71impl LcdDecoder {
72 pub fn new(sclk_pin: u8, data_pin: u8, cs_pin: u8) -> Self {
73 Self {
74 sclk_mask: 1u32 << sclk_pin,
75 data_mask: 1u32 << data_pin,
76 cs_mask: 1u32 << cs_pin,
77 ..Self::default()
78 }
79 }
80
81 /// Feed one post-quantum GPIO snapshot into the decoder.
82 pub fn sample(&mut self, gpio_out: u32) {
83 if !self.have_prev {
84 self.prev_gpio = gpio_out;
85 self.have_prev = true;
86 return;
87 }
88
89 let cs_now = (gpio_out & self.cs_mask) == 0;
90 let cs_prev = (self.prev_gpio & self.cs_mask) == 0;
91 let sclk_now = (gpio_out & self.sclk_mask) != 0;
92 let sclk_prev = (self.prev_gpio & self.sclk_mask) != 0;
93 let data_now = (gpio_out & self.data_mask) != 0;
94
95 let cs_falling = cs_now && !cs_prev;
96
97 // CS falling edge: start a new frame.
98 if cs_falling {
99 self.in_frame = true;
100 self.shift = 0;
101 self.bit_count = 0;
102 self.rx_buf.clear();
103 }
104
105 // SCLK rising edge inside a frame: shift in one bit (MSB first).
106 // Note: the CS-falling sample is excluded — we wait until the next
107 // sample (or later) to see a SCLK rising edge. Including the CS
108 // sample itself would produce a phantom bit when the firmware's
109 // CS-falling sample happens to have SCLK=H.
110 if self.in_frame && !cs_falling && sclk_now && !sclk_prev {
111 self.shift = (self.shift << 1) | u8::from(data_now);
112 self.bit_count += 1;
113 if self.bit_count == 8 {
114 self.rx_buf.push(self.shift);
115 self.shift = 0;
116 self.bit_count = 0;
117 }
118 }
119
120 // CS rising edge: frame is complete, interpret the buffered bytes.
121 if !cs_now && cs_prev {
122 self.in_frame = false;
123 self.apply_frame();
124 }
125
126 self.prev_gpio = gpio_out;
127 // Normalise prior SCLK state so the first in-frame SCLK=H sample is
128 // seen as a rising edge even if SCLK was already HIGH at CS-fall
129 // (e.g. leftover state from a prior firmware, or the very first
130 // sample of a run). Without this, bit 7 would be silently lost.
131 if cs_falling {
132 self.prev_gpio &= !self.sclk_mask;
133 }
134 }
135
136 pub fn state(&self) -> LcdState {
137 self.state.clone()
138 }
139
140 fn apply_frame(&mut self) {
141 let bytes = std::mem::take(&mut self.rx_buf);
142 let Some(&first) = bytes.first() else {
143 // Zero-byte frame: no opcode, nothing to do.
144 return;
145 };
146
147 match first {
148 0x01 => {
149 self.state.rows = [[b' '; LCD_COLS]; LCD_ROWS];
150 self.state.cursor = (0, 0);
151 }
152 0x02 => {
153 let col = bytes.get(1).copied().unwrap_or(0);
154 let row = bytes.get(2).copied().unwrap_or(0);
155 let col = col.min((LCD_COLS - 1) as u8);
156 let row = row.min((LCD_ROWS - 1) as u8);
157 self.state.cursor = (col, row);
158 }
159 0x03 => {
160 for &b in &bytes[1..] {
161 self.write_char(b);
162 }
163 }
164 // Unknown opcode (0x00, 0x04..=0xFF): silently drop the frame.
165 _ => {}
166 }
167 }
168
169 fn write_char(&mut self, c: u8) {
170 let (mut col, mut row) = self.state.cursor;
171 // Lazy wrap from a prior write that pushed the cursor off-row:
172 // kept as a safety net even though the post-write branch below
173 // now wraps eagerly, so external readers never see col>=LCD_COLS.
174 if col as usize >= LCD_COLS {
175 col = 0;
176 row = row.saturating_add(1);
177 }
178 if row as usize >= LCD_ROWS {
179 self.scroll_up();
180 row = (LCD_ROWS - 1) as u8;
181 }
182 self.state.rows[row as usize][col as usize] = c;
183 col += 1;
184 // Eager wrap: never leave the cursor at col==LCD_COLS. If the next
185 // character would land off the current row, advance row now and
186 // scroll if that falls off the bottom. This keeps `self.state.cursor`
187 // in-range for external readers between writes.
188 if col as usize >= LCD_COLS {
189 col = 0;
190 row = row.saturating_add(1);
191 if row as usize >= LCD_ROWS {
192 self.scroll_up();
193 row = (LCD_ROWS - 1) as u8;
194 }
195 }
196 self.state.cursor = (col, row);
197 }
198
199 fn scroll_up(&mut self) {
200 for r in 1..LCD_ROWS {
201 self.state.rows[r - 1] = self.state.rows[r];
202 }
203 self.state.rows[LCD_ROWS - 1] = [b' '; LCD_COLS];
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 const TEST_SCLK: u8 = 14;
212 const TEST_DATA: u8 = 15;
213 const TEST_CS: u8 = 16;
214
215 /// Builds a GPIO word with the three LCD pins set to the requested state.
216 fn make_gpio(cs_low: bool, sclk_high: bool, data_high: bool) -> u32 {
217 let mut g: u32 = 0;
218 if !cs_low {
219 g |= 1u32 << TEST_CS;
220 }
221 if sclk_high {
222 g |= 1u32 << TEST_SCLK;
223 }
224 if data_high {
225 g |= 1u32 << TEST_DATA;
226 }
227 g
228 }
229
230 /// Pushes one byte worth of samples through the decoder, MSB-first.
231 /// Precondition: caller has already driven CS low in a prior sample.
232 fn push_byte(dec: &mut LcdDecoder, byte: u8) {
233 for i in 0..8 {
234 let bit = (byte >> (7 - i)) & 1 != 0;
235 // Hold DATA with SCLK low (idle).
236 dec.sample(make_gpio(true, false, bit));
237 // Rising SCLK edge — decoder shifts the bit in.
238 dec.sample(make_gpio(true, true, bit));
239 // Falling SCLK edge, keep CS low.
240 dec.sample(make_gpio(true, false, bit));
241 }
242 }
243
244 fn start_frame(dec: &mut LcdDecoder) {
245 // CS high (idle) then CS low (frame start).
246 dec.sample(make_gpio(false, false, false));
247 dec.sample(make_gpio(true, false, false));
248 }
249
250 fn end_frame(dec: &mut LcdDecoder) {
251 // CS rising edge — frame is applied.
252 dec.sample(make_gpio(false, false, false));
253 }
254
255 /// Shortcut: push a whole frame's payload (opcode + args) through the
256 /// decoder. The caller is responsible for the opcode byte — we just
257 /// bracket the bytes with CS low / CS high.
258 fn push_frame(dec: &mut LcdDecoder, payload: &[u8]) {
259 start_frame(dec);
260 for &b in payload {
261 push_byte(dec, b);
262 }
263 end_frame(dec);
264 }
265
266 #[test]
267 fn clear_set_cursor_and_write_hi() {
268 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
269
270 // Frame 1: CLEAR
271 push_frame(&mut dec, &[0x01]);
272 // Frame 2: SET_CURSOR 0, 0
273 push_frame(&mut dec, &[0x02, 0, 0]);
274 // Frame 3: WRITE "Hi"
275 push_frame(&mut dec, &[0x03, b'H', b'i']);
276
277 let state = dec.state();
278 assert_eq!(state.rows[0][0], b'H');
279 assert_eq!(state.rows[0][1], b'i');
280 assert_eq!(state.rows[0][2], b' ');
281 assert_eq!(state.cursor, (2, 0));
282 }
283
284 #[test]
285 fn clear_fills_rows_with_spaces_and_homes_cursor() {
286 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
287
288 // Dirty the display first by writing an 'X' at (5, 1).
289 push_frame(&mut dec, &[0x02, 5, 1]);
290 push_frame(&mut dec, &[0x03, b'X']);
291
292 assert_eq!(dec.state().rows[1][5], b'X');
293
294 // CLEAR.
295 push_frame(&mut dec, &[0x01]);
296
297 let state = dec.state();
298 assert!(state.rows.iter().all(|row| row.iter().all(|&b| b == b' ')));
299 assert_eq!(state.cursor, (0, 0));
300 }
301
302 #[test]
303 fn wrap_and_scroll_when_row1_overflows() {
304 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
305
306 // Clear, then position cursor at (19, 1).
307 push_frame(&mut dec, &[0x01]);
308 push_frame(&mut dec, &[0x02, 19, 1]);
309 // Write two chars: 'A' at (19, 1), then 'B' should wrap and scroll.
310 push_frame(&mut dec, &[0x03, b'A', b'B']);
311
312 let state = dec.state();
313 // After wrap+scroll: row 0 now shows what row 1 used to show, which
314 // was spaces except for 'A' at column 19. 'B' landed at (0, 1).
315 assert_eq!(state.rows[0][19], b'A');
316 assert_eq!(state.rows[1][0], b'B');
317 }
318
319 // =========================================================================
320 // Edge cases from Phase 2 review (fixes 5, 6, 7)
321 // =========================================================================
322
323 #[test]
324 fn unknown_opcode_is_silently_dropped() {
325 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
326 // Prime with a known-good state.
327 push_frame(&mut dec, &[0x03, b'X']);
328 let before = dec.state();
329 assert_eq!(before.rows[0][0], b'X');
330
331 // Unknown opcode 0x00 followed by what would otherwise be "abc".
332 push_frame(&mut dec, &[0x00, b'a', b'b', b'c']);
333 // Unknown opcode 0x7F: random noise.
334 push_frame(&mut dec, &[0x7F, b'!', b'!']);
335 // Unknown opcode 0xFF: all-ones.
336 push_frame(&mut dec, &[0xFF, b'?']);
337
338 let after = dec.state();
339 assert_eq!(after.rows, before.rows, "unknown opcode must not write");
340 assert_eq!(after.cursor, before.cursor, "cursor must not move");
341 }
342
343 #[test]
344 fn zero_byte_frame_is_a_noop() {
345 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
346 // Prime with a known-good state.
347 push_frame(&mut dec, &[0x03, b'Y']);
348 let before = dec.state();
349
350 // CS low immediately followed by CS high — no bytes shifted in.
351 start_frame(&mut dec);
352 end_frame(&mut dec);
353
354 let after = dec.state();
355 assert_eq!(after.rows, before.rows, "zero-byte frame must be noop");
356 assert_eq!(after.cursor, before.cursor);
357 }
358
359 #[test]
360 fn set_cursor_missing_row_arg_defaults_to_zero() {
361 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
362 // SET_CURSOR with col=5 but no row byte.
363 push_frame(&mut dec, &[0x02, 5]);
364 assert_eq!(dec.state().cursor, (5, 0));
365 }
366
367 #[test]
368 fn set_cursor_out_of_range_args_clamp() {
369 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
370 push_frame(&mut dec, &[0x02, 25, 5]);
371 // col clamps to 19 (LCD_COLS-1), row clamps to 1 (LCD_ROWS-1).
372 assert_eq!(dec.state().cursor, (19, 1));
373 }
374
375 #[test]
376 fn col_wrap_without_scroll_keeps_cursor_in_range() {
377 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
378 // CLEAR, SET_CURSOR (19, 0), WRITE "XY".
379 push_frame(&mut dec, &[0x01]);
380 push_frame(&mut dec, &[0x02, 19, 0]);
381 push_frame(&mut dec, &[0x03, b'X', b'Y']);
382
383 let state = dec.state();
384 assert_eq!(state.rows[0][19], b'X');
385 assert_eq!(state.rows[1][0], b'Y');
386 // Cursor must be in-range (col<20, row<2) — specifically (1, 1).
387 assert_eq!(state.cursor, (1, 1));
388 assert!((state.cursor.0 as usize) < LCD_COLS);
389 assert!((state.cursor.1 as usize) < LCD_ROWS);
390 }
391
392 #[test]
393 fn cursor_never_out_of_range_after_single_row0_write() {
394 // Regression for the transient (20, 1) cursor: after one write at
395 // col=19, the cursor should already have wrapped to col=0 row=1.
396 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
397 push_frame(&mut dec, &[0x01]);
398 push_frame(&mut dec, &[0x02, 19, 0]);
399 push_frame(&mut dec, &[0x03, b'Z']);
400
401 let state = dec.state();
402 assert_eq!(state.rows[0][19], b'Z');
403 assert_eq!(state.cursor, (0, 1));
404 assert!((state.cursor.0 as usize) < LCD_COLS);
405 assert!((state.cursor.1 as usize) < LCD_ROWS);
406 }
407
408 #[test]
409 fn mid_byte_frame_abort_drops_partial_byte() {
410 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
411 // Prime the display with a known 'Q'.
412 push_frame(&mut dec, &[0x03, b'Q']);
413 let before = dec.state();
414
415 // Start a new frame, clock in 4 bits (not a full byte), end the frame.
416 start_frame(&mut dec);
417 for i in 0..4 {
418 let bit = (0b1010u8 >> (3 - i)) & 1 != 0;
419 dec.sample(make_gpio(true, false, bit));
420 dec.sample(make_gpio(true, true, bit));
421 dec.sample(make_gpio(true, false, bit));
422 }
423 end_frame(&mut dec);
424
425 let after = dec.state();
426 // Partial byte is silently dropped because rx_buf has zero bytes
427 // — apply_frame sees an empty buffer and returns early.
428 assert_eq!(after.rows, before.rows);
429 assert_eq!(after.cursor, before.cursor);
430 }
431
432 /// Set-cursor with a single-byte payload `[0x02]` — both `col` and
433 /// `row` arguments are missing, so both `bytes.get(1)` and
434 /// `bytes.get(2)` return `None` and default to 0. Existing tests
435 /// cover the missing-row case but not the missing-col case.
436 #[test]
437 fn set_cursor_no_args_defaults_to_origin() {
438 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
439 // Move cursor away from origin first so the default-to-(0,0)
440 // path is observable.
441 push_frame(&mut dec, &[0x02, 7, 1]);
442 assert_eq!(dec.state().cursor, (7, 1));
443 // SET_CURSOR with NO arg bytes at all.
444 push_frame(&mut dec, &[0x02]);
445 assert_eq!(dec.state().cursor, (0, 0));
446 }
447
448 /// Drives a row-1 overflow that scrolls TWICE within a single
449 /// WRITE frame. After the first scroll, row 0 holds the original
450 /// row 1 content (with 'A' at col 19); the second scroll then
451 /// pushes that down again. Exercises the eager-wrap +
452 /// `row >= LCD_ROWS` branch and the `scroll_up` body more than
453 /// once per frame, beyond the single scroll covered today.
454 #[test]
455 fn row1_overflow_scrolls_twice_within_one_write() {
456 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
457 // Position at (19, 1) and write 22 chars: 'A'..'V'. Each char
458 // at col 19 of row 1 forces an eager-wrap+scroll.
459 push_frame(&mut dec, &[0x01]);
460 push_frame(&mut dec, &[0x02, 19, 1]);
461 // 22 distinct chars to span two scrolls plus some tail.
462 let payload: Vec<u8> = std::iter::once(0x03)
463 .chain((b'A'..=b'V').take(22))
464 .collect();
465 push_frame(&mut dec, &payload);
466
467 let state = dec.state();
468 // Final cursor position must remain in-range.
469 assert!(
470 (state.cursor.0 as usize) < LCD_COLS,
471 "cursor col {} out of range",
472 state.cursor.0,
473 );
474 assert!(
475 (state.cursor.1 as usize) < LCD_ROWS,
476 "cursor row {} out of range",
477 state.cursor.1,
478 );
479 // 'V' is the 22nd char (1-indexed); it lands somewhere on
480 // row 1 after multiple scrolls. The exact column depends on
481 // the wrap pattern but it MUST exist on row 1 (last char
482 // written) and must NOT remain at row 1 col 19 (the 'A'
483 // origin) since at least one scroll occurred.
484 assert!(
485 state.rows[1].contains(&b'V'),
486 "row 1 should contain 'V' after multiple scrolls: {:?}",
487 std::str::from_utf8(&state.rows[1]).unwrap_or("?"),
488 );
489 }
490
491 /// Set-cursor whose col argument is exactly at the boundary
492 /// (LCD_COLS-1) should NOT clamp — it's already valid. Pairs with
493 /// `set_cursor_out_of_range_args_clamp` (which exercises the
494 /// strictly-greater-than branch) to nail down the exact `col.min(...)`
495 /// boundary.
496 #[test]
497 fn set_cursor_at_max_boundary_does_not_clamp() {
498 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
499 push_frame(&mut dec, &[0x02, 19, 1]);
500 assert_eq!(dec.state().cursor, (19, 1));
501 }
502
503 #[test]
504 fn sclk_high_before_cs_falls_still_captures_bit7() {
505 // Regression for the lost-bit-7 bug. Pre-fix: if the prior quantum
506 // had SCLK=HIGH, and the firmware drives SCLK=HIGH across the
507 // CS-falling quantum AND the next quantum (i.e. the first in-frame
508 // SCLK=H sample), the decoder misses that first rising edge because
509 // `prev_gpio`'s SCLK bit is still H from before CS fell. Bit 7 is
510 // silently dropped and every subsequent bit is off-by-one.
511 //
512 // Post-fix: the CS-falling handler clears `prev_gpio`'s SCLK bit
513 // at end-of-sample so the next quantum with SCLK=H registers as
514 // a rising edge cleanly.
515 //
516 // Payload: [0x81, 0xAA, 0x55, 0x00]
517 // Post-fix: rx_buf = [0x81, 0xAA, 0x55, 0x00]. first=0x81 is an
518 // unknown opcode -> frame dropped -> cursor stays (0,0),
519 // rows blank.
520 // Pre-fix: bit 7 of byte 0 lost. Decoder shifts in 7 bits of
521 // 0x81 (0b0000001) + bit 7 of 0xAA (1) = 0b00000011
522 // = 0x03 = WRITE opcode. Subsequent rx_buf bytes are
523 // off-by-one: [0x03, 0x54 ('T'), 0xAA]. apply_frame
524 // runs WRITE, writes 'T' at (0,0) and 0xAA at (1,0),
525 // advances cursor to (2, 0). Test fails on both the
526 // rows[0][0]=='T' and cursor==(2,0) assertions.
527 let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
528
529 // Idle state with SCLK HIGH — simulate the hazard.
530 dec.sample(make_gpio(false, true, false));
531 // CS falls, SCLK still HIGH. This is the CS-falling quantum.
532 dec.sample(make_gpio(true, true, false));
533
534 // Bit 7 of byte 0 (0x81): bit=1. SCLK stays HIGH on the next
535 // quantum. Post-fix: prev_gpio's SCLK is L, so this sample is a
536 // rising edge. Pre-fix: prev_gpio's SCLK is H, so it's missed.
537 dec.sample(make_gpio(true, true, true));
538 // SCLK drops to L.
539 dec.sample(make_gpio(true, false, true));
540
541 // Bits 6..0 of 0x81 = 0000001. Normal cadence.
542 for i in 1..8 {
543 let bit = (0x81u8 >> (7 - i)) & 1 != 0;
544 dec.sample(make_gpio(true, false, bit));
545 dec.sample(make_gpio(true, true, bit));
546 dec.sample(make_gpio(true, false, bit));
547 }
548 // Remaining bytes shifted normally.
549 push_byte(&mut dec, 0xAA);
550 push_byte(&mut dec, 0x55);
551 push_byte(&mut dec, 0x00);
552 end_frame(&mut dec);
553
554 let state = dec.state();
555 assert_eq!(
556 state.rows[0][0], b' ',
557 "bit 7 of first byte was lost: rows[0][0] = {:#x}, expected space",
558 state.rows[0][0]
559 );
560 assert_eq!(
561 state.cursor,
562 (0, 0),
563 "bit 7 of first byte was lost: unknown opcode (0x81) was misread"
564 );
565 }
566}