The Red Square Before Sonic
The first thing my Mega Drive emulator displayed was nothing.
Then it displayed a large red square. After that came blue vertical bars with a
dark centre, then the Rusty Nail intro with a full yellow loading bar, then a
cheerful READY! that sat there forever. Eventually Sonic appeared, accepted
input, and played music perfectly while the picture tore itself into horizontal
slices whenever the scenery moved.
That sequence sounds like five separate bugs. It was also a useful ladder. Each screen proved one more layer of a two-chip video system, from TMDS signal to palette expansion to content loading to emulator scheduling to frame publication. By the time Sonic was moving, the emulator was no longer the hard part. The hard part was deciding what the word "frame" meant on a link too slow to carry one in a single tick.
This is the complete bring-up log.
The machine I was adding it to
Rusty Nail is a fantasy-console OS running bare-metal on an STM32H753. The H7 is the computer: a 480MHz Cortex-M7 with the launcher, cartridge store, audio, controller input and emulators. An Adafruit Feather RP2040 DVI is the display chip. The H7 sends frames over an 8MHz SPI link and the RP2040 scans them out through PicoDVI at 640x480p60.

The existing console screen is 128x128 indexed colour. One complete wire frame is 16,524 bytes, including a checksum byte per row. That already takes about 20.7ms at 8MHz, so the physical link tops out below 49 complete frames per second. The RP2040 repeats each source pixel exactly 3x in both directions, putting a crisp 384x384 image inside the 640x480 signal.
A Mega Drive frame is not 128x128. It is normally 320x224 or 256x224, with PAL variants reaching 240 lines. A raw 320x224 indexed frame is 71,680 bytes before framing. Sending one 60 times a second would need more than 4.3MB/s. The jumper wires provide 1MB/s on a generous day.
Replacing the link was outside this job. The constraint was to make Mega Drive video fit the machine that already existed.
Getting gwenesis onto the H7
I started with gwenesis, pinned at commit
168e4666. It was designed for single-core Cortex-M microcontrollers and had
the right broad shape: Musashi for the 68000, a Z80 core, VDP, YM2612 and
SN76489. It was not a Rust crate and it expected a more conventional host, so I
wrapped it in a new no_std library with one fixed-state MdCore.
The ownership boundary is deliberately boring:
let mut core = MdCore::new(preswapped_rom, framebuffer)?;
core.set_pad(pad::A | pad::RIGHT, 0);
core.run_frame(true);
let pixels = core.framebuffer(); // 320 x 240 caller-owned storage
let cram = core.cram(); // 64 VDP colour entries
let audio = core.audio(); // interleaved stereo i16
No allocator sits under the emulator. The caller owns the ROM and native frame buffer. The ROM is adjacent-byte swapped once when it is installed into flash, which removes a byte swap from every 68000 fetch while retaining a logical CRC over the original big-endian image.
Each output pixel is one byte: six bits of CRAM index plus two bits of shadow/highlight state. I deliberately left RGB conversion out of the core. The wire encoder can resolve only the colours used by a frame, keep normal-intensity colours exact, and spend its remaining 64-entry dictionary on shadow and highlight variants.
The less glamorous work was memory placement. Musashi builds a 256KiB opcode jump table and a 64KiB cycle table. Those moved into dedicated D2 and SRAM4 sections at startup. The native 76,800-byte frame went into AXI RAM. Audio and mixer state went high in ITCM. Buffers belonging to runtimes that cannot coexist were allowed to share physical regions.
Code size needed the same kind of negotiation. Compiling all of gwenesis at
-O2 made m68kcpu.c grow by roughly 60KiB and overflowed the H7's 768KiB
executable bank. The final build keeps Musashi at -Os and compiles the rest of
the core at -O2. That costs about 11.1KiB over the all-small build and removed
the last dense-scene timing overruns.
Audio found its own precise little trap. The rounded nominal YM2612 sample rate under-produced about 92 interleaved words per second. A 120-frame regression made the drift obvious. The mixer now derives output from the exact NTSC frame clock instead of accumulating the rounded rate. That is why, when the first playable picture finally arrived, the music was already right.
A frame that does not fit
The video protocol uses a stable 64-colour RGB565 dictionary, hashes every
native row, and sends only rows that changed. Four 6-bit dictionary indices pack
into three bytes. A row is sent raw or as (run, value) pairs, whichever is
smaller. Palette records and every row carry checksums.
Most importantly, a transaction is capped at 14,500 bytes. That fits inside one 60Hz tick at 8MHz. Emulation, input and audio never wait for a complete picture to cross the wire.
The first implementation treated the RP2040's visible image as the destination. Every fragment updated whichever rows it contained. This was fast enough after some work: collecting palette usage while hashing rows, resolving each used VDP code once, and packing four pixels directly brought the emulator to native cadence at roughly 842-859KiB/s of SPI traffic.
It also tore badly.
The reason was structural, not a missed lock. A busy picture took several SPI transactions. Row 20 might belong to emulated frame N, row 100 to frame N+1, and row 180 to frame N+2. Each row was internally correct. The displayed surface was not one frame at all.
The fix was to freeze both ends of the promise.
On the H7, I reused the otherwise idle 76,800-byte cartridge staging buffer as
an immutable video snapshot. The sender selects changed rows, copies one exact
native frame, and spends as many bounded transactions as necessary sending
those rows. START and END mark the transaction. If a queued fragment is
replaced before the SPI task claims it, the sender restores the frozen row mask
and replays from START; it never quietly continues with a hole.
The central rule in the publisher is visible in the comment because it is the whole design:
// Compare against the last committed snapshot, not against every
// emulated frame. Rows selected here are packed immediately into the
// CART_STORE scratch and therefore remain from one exact source frame
// while their fragments spend several ticks crossing the wire.
let snapshot_rows = rows.pending_mask();
md_wire_snapshot().copy_from_slice(fb);
On the RP2040, two 320x240 source banks split the job. START clones the active
bank into the inactive bank. Fragments update only that staging bank. END
makes it eligible for display, and core 1 swaps it at the DVI frame boundary:
static bool begin_snapshot(uint32_t width, uint32_t height) {
int32_t active = __atomic_load_n(&link_md_active, __ATOMIC_ACQUIRE);
staging_bank = (uint32_t)active ^ 1u;
memcpy(link_md_frame[staging_bank], link_md_frame[active],
sizeof link_md_frame[staging_bank]);
snapshot_open = true;
return true;
}
static void finish_delta(void) {
if (delta_flags & SNAPSHOT_END) {
__atomic_store_n(&link_md_ready, (int32_t)staging_bank,
__ATOMIC_RELEASE);
}
}
Bandwidth can now reduce the number of complete pictures presented per second, but it cannot create half a picture. Sonic's movement became clean immediately.
No signal, red square, bars, READY
The physical bring-up was a satisfyingly literal progression:
- No HDMI signal meant the fault was still below the emulator.
- A large red square proved the DVI timing and pixel scanout were alive.
- Blue and multicolour vertical bars with a dark centre proved the expanded-row and palette path.
- The Rusty Nail intro and a full yellow loading bar proved content transport
and installation, but
READY!never advanced. The Mega Drive-only H7 image still selected a PICO-8 launch arm that had been compiled out. Giving that image its own initial selector fixed the stall. - Sonic reached title, intro and gameplay with working input and perfect music. Rolling row updates exposed the final horizontal tearing problem.
- Frozen source snapshots plus atomic receiver publication removed it.
The final pair sustained Sonic's demo loop through the former worst scene at native NTSC cadence, with 60-61 presentation ticks per second, zero audio underruns, zero dropped samples, and at least 2.1 per cent measured H7 headroom. A host soak ran 107,862 emulated frames, about 30 minutes of NTSC time, at 1,097.7 frames per second on the development machine. That host result is not a substitute for the still-open 30-minute physical soak, but it exercises the core, geometry changes, CRAM and audio for far longer than a quick title-screen test.
No game image is in either repository. The compatibility run used my own cartridge dump from an ignored local library.
Why this is two firmware pairs
This did not fit as one universal image, on either chip.
The final Mega Drive H7 image loads 739,192 bytes into the 768KiB bank and leaves 47,240 bytes of flash slack. The default/Game Boy image loads 777,904 bytes and leaves 8,528. The emulator cores cannot coexist there without taking the flash sector reserved for the crash journal.
The RP2040 has the opposite pressure. Its 264KiB SRAM cannot hold the standard expanded console and handheld surfaces, Mega Drive's two native source banks, and PicoDVI's working memory together. The Mega Drive receiver therefore gets its own UF2 and uses exact 2x scanout: 320x224 becomes 640x448, centred inside 640x480. The standard receiver keeps exact 3x for the 128x128 shell and Game Boy.
The deployment command now understands the pair:
# Switch both chips to Mega Drive.
./firmware/build-deploy.sh --megadrive --with-receiver --release
# Return both chips to the default/Game Boy family.
./firmware/build-deploy.sh --with-receiver --release
Same-family H7 rebuilds do not need another Feather BOOTSEL cycle. Crossing the family boundary does.
The receiver that had apparently worked
Returning from Mega Drive exposed one last bug in the standard receiver. The
old UF2 linked cleanly but produced no HDMI after a fresh build. Its BSS left
only 4,768 bytes of real heap, while dvi_init dynamically requested 11,520
bytes for three TMDS lines. A nominal PICO_HEAP_SIZE definition did not move
the actual heap boundary. The firmware panicked before DVI initialisation, so
the only symptom was no signal.
I moved those three TMDS lines into static storage and set
DVI_N_TMDS_BUFFERS=0, turning the allocation into a link-time SRAM check. I
also reduced the SPI DMA ring from 16KiB to 8KiB. That still stores more than
twice the measured worst polling gap. The corrected receiver has 9,632 bytes of
real margin below the main-RAM heap limit and no large boot allocation.
That change raised the obvious uncomfortable question: had I altered the standard picture while fixing its memory?
I compared the old and new source paths first. Both generate 640x480p60 at 252MHz, repeat every 128x128 shell pixel as the same nearest-neighbour 3x3 block, use the same full-resolution RGB TMDS palette encoder, and publish the same double-buffered surface. There is no filtering stage to differ.
Then I did the needlessly reassuring hardware test anyway. I built the exact parent commit, put its H7 image back on the board, and tried its matching RP2040. The old receiver reproduced the no-signal heap failure. Keeping the old H7 but using the corrected standard receiver restored the picture. Side by side in memory and then on the same panel, the pixels were the same.

Any slight bleeding visible on the physical panel is downstream monitor scaling or picture processing, not a different pixel from Rusty Nail. That was worth proving before packing the bench away.
Where it stands
There are twelve host tests around the new core, seventeen runtime detection tests in both feature configurations, forty-nine cartridge protocol tests, 124 launcher tests, and the existing shell golden images. Both production H7 images link, all three RP2040 receiver targets build, and the physical standard and Mega Drive pairs have now been flashed in both directions.
There are honest limits left. PAL content is presented through a 60Hz receiver, so cadence judder remains until there is a 50Hz mode. Interleaved SMD dumps are detected only to reject them clearly. A broader compatibility sweep and the 30-minute physical soak still need doing. The gwenesis dependency stack also includes non-commercial licensing conditions, so this integration is explicitly for personal, non-commercial use.
But the milestone itself is real: a Mega Drive cartridge runs on the H7 at native cadence, its YM2612 and PSG come out of the console DAC, its picture crosses an 8MHz jumper-wire link, and the RP2040 presents complete frames over HDMI without tearing.
The route there was no signal, red square, blue bars, READY!, perfect music,
bad video, then one small architectural rule: if a frame takes several trips to
arrive, do not show any of it until all of it is home.