Skip to main content

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}