The Receiver Was Innocent

Super Mario World was emulating at 35.6 frames a second in PAL, and I was sure I knew why. I was wrong in an instructive way, then right in a more expensive way, and the difference between the two is the whole point of this post: I nearly built the wrong thing, and the only reason I did not is that I made the system name its own bottleneck before I picked what to fix.

If you are new here: this project is a bare-metal fantasy console on a Nucleo-H753ZI that grew a real SNES core, cycle-exact against its reference emulator. The H7 emulates and streams changed frame rows over a single 7.8 MHz SPI wire to a Feather RP2350, which reconstructs the picture, runs the SNES sound chip, and scans out HDMI. Three suspects in every slow frame: the emulator, the receiver, the wire between them.

Hypothesis one: the receiver is stalling us

The evidence for blaming the receiver looked damning. The H7 was burning 2.5 to 3.3 million cycles per frame waiting for the remote sound chip to answer a synchronous port read. The reads travel to the receiver and come back; the receiver must be slow to answer. Meanwhile the receiver's own counters swore every read was answered in microseconds. Someone was lying, so I stopped arguing with counters and timestamped every phase the receiver executes, plus a 200-entry capture of every synchronous read: when it was admitted, when it committed, when the reply left.

STALL hash      n=15 max=11033us
STALL expand    n=15 max=18759us
STALL parsegap  n=21374 p50<=4us p99<=128us max=29931us
STALL read      admit_to_commit max=71us  commit_to_done max=14us

The sound engine answered in 71 plus 14 microseconds, every time. The receiver's engine was innocent. But its scheduler was not: before a read could reach the engine, its bytes had to come out of the DMA ring, and the parser draining that ring was starved for up to 29.9 ms at a stretch. An 11.0 ms whole-frame hash and an 18.8 ms presentation expansion ran back-to-back, both reading every byte one at a time from uncached PSRAM, and while they ran, nothing was parsed. The H7's waits were ring residency. The receiver's reply counters started their stopwatch after admission, so they had never seen the queue outside their own front door.

So hypothesis one was half right, and the fixes were real: stage each row through SRAM with one burst copy and process eight rows per loop, parse unconditionally instead of gating on an empty queue, keep one persistent packed image instead of copying a 46 KiB base for every snapshot, and defer presentation work while a read is owed. Parser starvation collapsed from 34 ms to under 2.3 ms, and steady PAL emulation climbed from 35.6-36.8 to 39.1-40.0 fps. Same frame hashes, zero transport errors, reproduced twice from reset.

Hypothesis two: now the publisher is the limit

With the receiver's latency bounded, presentation was still poor: the emulator completed about 40 frames a second and the screen saw 13 unique ones. The publisher only sent one record per emulated frame, so I rebuilt it to keep sending as long as it had data and a free slot, put a size budget on each record, and priced every point of the design space on hardware. That produced the most useful chart this project has generated:

Measured trade curve: bigger records raise completed sources per second but cost steady emulation fps

Read the two panels together. Big 14.5 KiB records nearly double presentation, 21.6 unique frames a second, and pay six to ten fps of emulation for it, because the wire DMA cannot be interrupted and every synchronous sound read that lands behind a big record waits out its full 14.9 ms. Small 3.9 KiB records give the best emulation the project has ever measured, 40.5 to 42.7 fps, and presentation falls back to where it started. There is no good point on this curve. On this wire, every byte of smoothness is bought with emulation speed.

I tried to cheat the curve twice, and both attempts died honestly. Releasing the receiver's frame buffer early, to shave the serial hold per frame, drove the HDMI scanline DMA into a documented fault mode: 310 "frames" a second, picture gone. Raising the wire clock to 10 MHz, a rung that used to soak clean for thousands of frames, now threw parser and row errors, because the rebuilt receiver loads its bus in a way the old one never did. Dense scenes need roughly 1.5 to 2.3 MB/s. The wire carries 0.975, and it is done growing.

That is the verdict: the receiver was innocent, the publisher was innocent, and the transport was convicted by a curve with error bars. Which led to replacing the serial link entirely.

The pinout had the replacement in it all along

The H753 ships a QUADSPI controller: a flash interface that shifts four bits per clock and frames every transaction with a hardware chip select. It is completely unused in this build, and a flash controller does not care that the device on the far end is not a flash chip. Four lanes at the existing clock is 3.87 MB/s.

The pin audit turned up a coincidence that made me distrust my own board for an evening. QUADSPI bank 1 on this package lands on PF6, PF7, PF8, PF9 and PF10, and four of those five are already in use by the existing one-bit link: its clock, its data, its frame select, and the sound return UART. The bus I needed had been interleaved with the wires I soldered months ago. The free alternates formed the set: PF10 clock, PD11/PD12/PD13 plus PE2 as the four data lanes, PB10 as the hardware select. Most of them sit in one five-row cluster on the CN12 morpho header, and the Feather's A0 to A3 are GPIO26 to 29, four consecutive GPIOs, exactly what a PIO input program wants.

Wiring diagram: six new wires from the Nucleo morpho header to the Feather RP2350

The entire receive frontend is four PIO instructions, same discipline as the one-bit link (check the frame line before every sample, arm the state machine only at an idle boundary):

.program quad_rx
.wrap_target
    wait 0 gpio 10        ; record frame active
    wait 0 gpio 23        ; falling edge: nibble being driven
    wait 1 gpio 23        ; rising edge: nibble stable
    in pins, 4
.wrap

To validate the physical layer I did not send video. I sent records that describe themselves: magic, sequence, pattern kind, length, then a payload the receiver regenerates locally from the header alone (walking ones and zeros, every nibble value, a PRBS, ramps, lengths from 5 to 3,904 bytes including deliberately odd ones), then a checksum. Every received byte is compared against what that byte should be, so a swapped lane, a wrong sampling edge or a dropped nibble each produce distinctive wreckage with the offset of the first wrong byte.

Two bugs, neither of them wiring

First light was silence. The generator printed its banner and hung forever inside its first write. The QUADSPI config declared the attached "flash" as size class zero, which the peripheral reads as a two-byte device, and a 25-byte write to a two-byte device raises a transfer error my polling loop never checked. Declaring a comfortably enormous imaginary flash fixed it. A flash controller keeps its worldview even when you lie to it about being a flash.

Then it ran, and the receiver logged 3.02 MB/s of perfectly framed garbage. Zero records validated, every error counter alight at once, which usually means alignment, so I made the receiver dump the first 96 raw bytes and lined them up against the walking pattern it should have contained:

expected: 01 02 04 08 10 20 40 80 fe fd fb f7 ...
received: 10 20 40 81 02 04 08 0f ef df bf 7e ...

The received stream is the expected stream shifted by exactly one nibble. Every high half is the previous byte's low half. The receiver caught one phantom clock edge at arming and then stayed coherently, beautifully, half a byte out of phase forever after.

One phantom edge means ringing. The H7's pins defaulted to their fastest slew class, on a clock line that is a 15 cm jumper. At 7.7 MHz you do not need fast edges, you need clean ones. Dropping the drive strength to the slowest class removed the phantom edge entirely and the stream snapped into phase.

Retiring the transport as a suspect

The acceptance soak ran ten minutes, both boards started clean, HDMI live, and the receiver's destructive PSRAM stress test running the whole time:

MetricResult
Records validated byte-exactly2,010,518
Data transferred~1.86 GB
Bad records / header / mismatch / checksum errors0 / 0 / 0 / 0
Resyncs / sequence gaps / ring overwrites0 / 0 / 0
Usable payload rate3.069 MB/s
HDMI during the soakexact 60.000 Hz, zero FIFO overflow

3.07 MB/s usable against the old wire's 0.975 raw, and the current limiter is my own CPU spoon-feeding the QUADSPI FIFO a byte at a time. From here on, the physical transport is retired as a source of uncertainty: if a byte is wrong above this layer, the bug is mine, not the wire's.

Next comes the part that earns a post of its own: carrying the real SNES stream over the four lanes and proving, byte for byte, that the receiver cannot tell the difference, then letting DMA feed the FIFO and seeing what the trade curve looks like when the denominator quadruples. The quad link was never the interesting part. The interesting part is that the measurements, not the intuition, chose what got built. My intuition would still be optimising the receiver.

Reference reading: QUADSPI is chapter 24 of ST's RM0433, the morpho pin tables are in UM2407, the receiver is an Adafruit Feather RP2350 HSTX, and PIO lives in the RP2350 datasheet.