I keep choosing hard mode (the SPI panel and me)

Let me show you a commit log. This is one day of work on the SPI panel, trimmed, in order, timestamps and all:

10:07am  SSD1283A 130x130 SPI first-light
10:34am  frame each SSD1283A transaction with CS (fixes blank panel)
11:00am  CS-frame the fill window registers (SSD1283A gray fix)
11:22am  cart mirror at 4MHz (first light achieved)
 2:40pm  back to 2MHz colour cycle (re-confirm baseline on new wiring)
 2:59pm  re-init panel every cycle so it self-heals after a reset
 3:07pm  1MHz slow static cycle to isolate wiring corruption
 3:49pm  re-init panel each cycle to hold colour on a sagging rail
 4:24pm  switch driver to ST7735 128x160 (replaces SSD1283A)
 4:48pm  ST7735 cart mirror works at 1MHz (4MHz was too fast for jumpers)
 5:18pm  ST7735 at 25MHz, ~33-48fps, audio still crisp
 7:25pm  vsync snapshot + USB gate kill flicker, cart is VM-bound

Read it like a negotiation. I open at 4 MHz, confident. By mid-afternoon I am back down to 2, then to 1, then I am writing the words "to hold colour on a sagging rail" into a permanent record of my life. Somewhere in there I taught the panel to re-initialise itself every single frame so it could "self-heal after a reset," which is a generous way of saying I made it reboot constantly and decided that was a feature. "4MHz was too fast for the jumpers" is a sentence that should not exist, and yet there it is, committed, pushed, mine.

Here is the part that makes it art. Scroll up to the top of the day and look at what I was doing at quarter to one in the morning, before any of this:

12:48am  hdmi feature - LTDC RGB666 640x480p60 first-light

That is the HDMI path. Working. Clean. 640x480 at a steady 60 Hz, the first time I tried it. I had it. It was done and sitting in the drawer. And then I slept on it, woke up, made coffee, and chose to spend the next nine hours arguing with a screen the size of a matchbook instead.

Why the little screen is hard mode

It is worth explaining why the SPI panel fought me so hard, because it is not that the panel is bad. It is that of the ways to get pixels onto glass, SPI is the one that makes the CPU do everything by hand.

The thing nobody tells you is that there are two separate "60fps," and they have nothing to do with each other.

  • Display refresh is how often the screen redraws. A TV does this 60 times a second whether or not anything changed.
  • Content rate is how often the program makes a genuinely new frame. My heavy test cart, Solais, makes about 20 to 23 of those a second and is happy doing it.

These should be independent. A console game can render 30 new frames a second and look perfectly smooth on a 60 Hz TV, because the TV just keeps scanning the last finished frame out at its own steady pace. Nobody notices. Everybody has been doing this since the 1980s.

The SPI panel smashes those two numbers into one, because of how it works. There is no scanout engine. To put a frame on the panel, the CPU picks up all 32 KB of it and shovels it out the SPI bus by hand, one blocking transfer, every frame. So three bad things happen at once:

  1. The display rate is the CPU rate. The screen only refreshes as fast as the processor can hand-deliver pixels.
  2. Every one of those pixels is time stolen from the thing actually running the game. The panel and the game are fighting over the same single core.
  3. There is no hardware double-buffer, so you are often shoveling a frame out while the next one is being drawn into the same memory. That is where the flicker, the tearing, and that horrible top-to-bottom sweep come from.

I spent the evening papering over those (a vsync snapshot so I stop drawing and reading the same buffer, a gate so the USB stream stops stealing cycles mid frame), and it helped. But papering over is the genre. The problem is structural. On SPI, you are always going to be the courier.

Why HDMI is the easy mode I keep walking past

The Geekworm board hangs off the H7's LTDC, the on-chip LCD controller, and that changes the entire shape of the problem. LTDC is a hardware scanout engine. You point it at a framebuffer in RAM and it DMAs that buffer out the RGB pins at a fixed 60 Hz pixel clock, forever, with zero help from the CPU. It is double-buffered, and it swaps buffers cleanly on the vertical blank.

Walk back through my list of suffering with that in mind:

  • Flicker, tear, sweep: gone. Not reduced, gone. The refresh is hardware and rock solid at 60 Hz, and you only ever swap buffers between frames. The whole category of problem I burned a day on literally cannot occur.
  • The CPU is freed. No more hand-delivering 32 KB a frame, which means the game gets the whole core to itself and very likely runs faster than the 20-odd frames it manages today while elbowing the SPI bus for cycles.

So on HDMI the picture is a smooth, steady 60 Hz, and the game animates at whatever the VM can produce, shown cleanly. Exactly like a console title that runs at 30 and displays on a 60 Hz set. The screen is calm. The content moves at its own pace. Nobody is couriering anything.

The one thing HDMI does not fix

I have to be honest or this turns into an advert. HDMI does not magic the game up to 60 frames a second. Making 60 genuinely new frames a second is the VM and the cart doing real work, and that is a separate problem from getting a frame on the glass. A light cart will hit 60. Heavy Solais will not, on any display, because the bottleneck there is arithmetic, not pixels. The difference is that HDMI is not blocked by that. It just shows you whatever the VM made, beautifully, sixty times a second, however often that picture happens to change.

This is the entire reason the output layer is a trait with swappable backends. I designed it so the SPI courier and the HDMI scanout engine could both exist behind the same present() call and the compositor would never know the difference. I wrote that abstraction specifically so the clean path would be ready when I wanted it. Then I wanted it, and I wired up the matchbox instead.

The moral, for next time

The 25 MHz build is genuinely fine now. The flicker is mostly beaten, the audio stays crisp, and as a screen to stare at while the real board comes together, it does the job. I am not sorry I have it.

But the lesson is the one my own commit log keeps trying to tell me, in timestamps. The fix was in the drawer at 12:48 in the morning. Everything after 10am was me choosing the hard road because it was the one in front of me. Some nights the right move is not another bug fix at 1 MHz. It is to put the soldering iron down, leave the tiny screen alone, and be patient enough to wait for the board that was built to do this properly.

I will remember that right up until the next small screen shows up on my desk.