## The console

Retro game console on FPGA with a custom dual-issue RV32IM superscalar CPU. DOOM is the first milestone. More retro games planned.

## The CPU

RV32IM, in-order, dual-issue. Five pipeline stages: IF, ID, EX, MEM, WB. Two slots, two ALUs, full forwarding including MEM-stage load forwarding. Tournament branch predictor with PC-indexed local, PC-xor-history global, and chooser tables of 2-bit counters. 16-entry branch target buffer with full 32-bit tags. 16-deep return address stack. Branch resolution in EX for both slots. Multiply single-cycle on DSP blocks; divide and remainder iterative at 33 cycles.

Simulated performance on 500M+ instructions of real DOOM gameplay: CPI 0.727, IPC 1.375.

## FPGA fitting

The full simulation core did not fit the Cyclone IV. Two things dominated: arithmetic and predictor tables.

Each ALU had three 64-bit multipliers and four 32-bit dividers, doubled across two slots. MUL-low stays single-cycle on DSP blocks. The other seven M ops moved to an iterative unit, 33 cycles each. Measured cost in the game: about 2% more cycles.

The predictor had three 256-entry tables plus a 256-entry BTB, written as async-read flop arrays. The FPGA build cuts these to 16 entries each. Registers fell from 23,644 to 6,984.

Final fit: 17,423 logic elements (78%), 54 M9K blocks, 12 DSP blocks, 50 MHz single clock domain.

## Timing closure

First TimeQuest pass had worst slack at -14.5 ns. The top failing paths ran from EX and control registers through the CSR read, forwarding muxes, branch compare, redirect muxes, into the PC. About 34.5 ns of logic in a 20 ns cycle.

Two changes fixed most of it. The CSR file was made synchronous: the old async read mux sat on the worst path, so it was replaced with a registered read port and the pipeline holds EX one extra cycle on CSR ops. Branch resolution moved from ID to EX, which also removed the backwards EX-to-ID forward and dropped the load-use stall from the hazard unit.

Worst slack improved to -3.998 ns on the slow corner. System clock passes with 6.5 to 6.9 ns positive slack. Timing closure on the slow corner remains open; nominal silicon carries it on the bench.

## The serial link

A CP2102 USB-to-TTL module connects to three pins on GPIO-0: TX, RX, ground. Everything goes through this one wire.

The uploader sends the ELF and WAD in segments. The bootloader writes them into SDRAM and sends a dot per 4,096 payload bytes. The uploader waits for each dot before sending the next chunk. A CRC at the end verifies the whole transfer. Upload takes 53 seconds at 921,600 baud for the 4.65 MB shareware image.

During gameplay, when the engine writes to the frame commit register, the memory controller freezes the game clock and burst-reads the framebuffer into a 4 KB UART FIFO. A watermark paces the reads so the FIFO does not overflow. The host viewer parses a 10-byte frame header (magic bytes, mode, frame number, dimensions), maps palette indices through PLAYPAL from the WAD, and draws the result in a tkinter window.

Keyboard input goes the other direction. The viewer sends DOOM keycodes with a 0xF0 prefix for key releases. The FPGA latches the release flag, and the game reads it through bit 30 of the key register.

Two display modes: 160x100 fast (sub-sampled, 5.75 FPS) and 320x200 full (1.44 FPS). Switchable live with F1/F2.

## Bugs that DOOM found

**Frame 0 hang.** The game booted, printed headers, rendered frame 0, and hung. The timestamp read -4,203 ms where the reference had 134. An M-op release coincided with a slot squash on the same edge. The old flush term cleared ID/EX and discarded slot 0 while the replay PC only re-fetched slot 1. The consumer read a stale register. Fixed by qualifying the release with the squash. The regression test that catches this is 9 instructions.

**Garbage at 2 Mbaud.** The terminal showed repeating garbage. The CP2102 divides a 48 MHz clock and has a hard ceiling of 921,600 baud. Asking for 2 Mbaud aliased to an unsupported...

Read more »