Close

A Decent Working Prototype on FPGA

A project log for Retro Gaming Console on RV32IM CPU (DE0 Nano FPGA)

A 32-game console on a custom dual-issue RV32IM CPU and a DE0-Nano FPGA, running DOOM, CP/M 2.2 and Chip-8 bare-metal

mjagadeesh97M_Jagadeesh97 • 2 hours ago•0 Comments

DOOM on the board, frames arriving over the serial link.

The game was alive and the only output was tic lines scrolling past in a terminal. A VGA DAC board would have solved the display in an afternoon, but none was on the desk, and the serial wire was already there. DOOM renders into a contiguous 64 KB buffer of palette indices. The question was whether the frames could simply come down the wire.

The first calculation was what 64,000 bytes at 92,160 bytes per second does to a game loop. The answer is 0.7 seconds per frame, and the question underneath is what the game sees during those 0.7 seconds. DOOM reads time from its tick counter, and the tick counter is the cycle counter divided by a thousand. Letting the core run while the UART drains would inject two dozen phantom tics between renders: physics advancing in large steps, controls sampled with stale timing, monster thinkers skipping animation frames. The frames would arrive, and the game logic would be wrong.

So the frame commit freezes the game clock for the duration of the transfer. When the engine writes MM_DUMP, the memory system drops the core clock enable, and the game clock, which divides the gated cycle counter, reads zero elapsed milliseconds:

uint32_t DG_GetTicksMs(void) { return MM_CYC_LO / 1000u; }

Zero cycles elapse and zero milliseconds pass, and the engine resumes with its state exactly as it left it. Momentum, monster thinkers and sound timers all freeze for the transfer and continue where they stopped. That is the same mechanism the console later replaced with the FIFO burst in the final build, and this log is where it was invented.

While frozen, a readout engine takes over the SDRAM controller and burst reads the framebuffer into the 4 KB synchronous UART FIFO the console already uses. Burst reads outrun the wire by orders of magnitude, so a watermark paces them, pausing before any overflow while the UART drains the FIFO at line rate:

wire fifo_has_space = (fifo_count < 13'd4090);

Next is framing. Console bytes and pixels share one stream with no side channel, so the host has to find frame starts inside what looks like a text log. Every frame opens with a 10-byte header: four magic bytes (0x55, 0xAA, 0x5A, 0xA5), a mode byte, a 16-bit little-endian frame number, a 16-bit width and an 8-bit height. The viewer scans for the magic, prints everything ahead of it as console text, and only treats a header as real after the width and height sanity check passes, so a stray 0x55 in a log line costs one skipped byte instead of a torn frame. It keeps the last three bytes of each read unflushed for the same reason: a magic split across two USB packets must still be found.

while len(self.rx_buf) >= 10:
    idx = self.rx_buf.find(self.MAGIC)
    if idx < 0:
        # No magic in buffer: all is console/terminal text
        # Keep last 3 bytes in case magic is split across reads
        text_bytes = bytes(self.rx_buf[:-3])
        self.rx_buf = self.rx_buf[-3:]

Two modes trade clarity for rate. Full 320x200 streams all four bytes of every word on all 200 rows, 64,000 bytes, which is 1.44 FPS. Fast 160x100 keeps bytes 0 and 2 of each word on even rows only, 16,000 bytes, a quarter of the pixels for four times the rate, about 5.75 FPS. Both modes are live switchable from the viewer with single command bytes, along with a pause command.

Input travels the same wire back, and the first movement test failed in a familiar-feeling way: the player ran into the nearest wall and stayed there, stride animation looping, ignoring every further key. The cause is that terminals send an event on press and nothing on release, so the key register held each code until something replaced it and the engine treated every key as held forever. The fix is a break protocol: the viewer watches its own key table and, on release, sends 0xF0 ahead of the code.

def on_key_release(self, event: tk.Event):
    key = event.keysym
    keycode = DOOM_KEYS.get(key) or DOOM_KEYS.get(event.char)
    if keycode is not None and keycode in self.pressed_keys:
        self.pressed_keys.remove(keycode)
        try:
            self.ser.write(bytes([CMD_RELEASE_PREFIX, keycode]))

The FPGA latches the release flag on the prefix, and the game reads it through bit 30 of the key register while bit 31 says an event is waiting and the low byte carries the code. One mapping detail is easy to get wrong: the wire speaks doomkeys, not PC codes. The engine indexes its key table by KEY_FIRE (0xA3) and KEY_USE (0xA2), so those are the bytes the viewer must send. Raw terminal codes land in the wrong slots and the game ignores them.

The viewer is a Python program built on tkinter, PIL and numpy. A background thread owns the serial port and parses the mixed stream; a queue of depth 3 hands complete frames to the UI, dropping the oldest when the window lags rather than falling further behind. At startup it opens the WAD, walks the lump directory to PLAYPAL, reads the authentic 256-colour table the DOS release used, and maps each frame through it, upscaling nearest neighbour into a 640x400 window with a measured frames-per-second figure in the title bar.

Frame 0 on the board is pixel identical to the frame Verilator produced from the same input, which is the check that makes the whole serial display path trustworthy. The ceiling is honest: at 921,600 baud, the fast mode plays at about 5.75 FPS and the full mode at 1.44. That is a wire limit, not a core limit, and it is the reason the console work that follows moves to a 64x32 framebuffer, which crosses the same wire in 22 ms and plays in real time.

The system now runs DOOM on hardware with display, console and keyboard on one serial cable. The next logs are about turning that single-title demo into a console with a launcher, a physical display and other machines inside it.

Discussions