Retro Gaming Console op RV32IM CPU (DE0 Nano FPGA)

Beschrijving

Dit is een retro-console gebouwd op een door mijzelf ontworpen RISC-V CPU en een Terasic DE0-Nano (Altera Cyclone IV E). Het systeem start in een launcher met 32 spellen verdeeld over drie runtimes: DOOM Episode 1 en een raycaster in de stijl van Wolfenstein (geschreven in C tegen de framebuffer van de console), CP/M 2.2-software inclusief Microsoft BASIC-80 via een bare-metal RunCPM-port, en 19 Chip-8-titels uit het COSMAC VIP-tijdperk van 1977.

De volledige stack is custom. De CPU is een dual-issue in-order RV32IM-core met een hybride branch predictor, een branch target buffer van 256 entries en een return address stack. De prestaties zijn gemeten op een CPI van 0,727 tijdens een DOOM-run van 498 miljoen instructies.

Aan de FPGA-zijde is er een SDRAM-arbiter toegevoegd met een I-cache van 1K-words, een UART TX FIFO van 4 KB met een hardware frame commit-pad, een input-decoder en een autonome I2C-master die een fysiek 128x64 OLED-scherm aanstuurt. De testomgeving draait dezelfde RTL in Verilator, wat ongeveer 4x sneller is dan de oorspronkelijke flow, voegt snapshot/restore toe (waardoor de boot-tijd van 24 seconden wordt overgeslagen) en stelt de framebuffer en het toetsenbord beschikbaar.

Technische Specificaties

ItemWaarde
BoardTerasic DE0-Nano, Altera Cyclone IV E EP4CE22F17C6N
CPUCustom dual-issue in-order RV32IM op 50 MHz, CPI 0,727 (gemeten op DOOM)
Systeemgeheugen32 MB SDRAM, 16-bit bus
Geheugenindeling158,6 KB firmware, 12 MB heap, 512 KB stack, 5,11 MB asset blob
Device utilization17.743 logic elements (79%), 6.984 flip-flops, 54 M9K, 12 DSP
Spelencatalogus32 titels: 7 native en 3D, 6 CP/M 2.2, 19 Chip-8
Primair display64x32 RGB332 framebuffer op 45 FPS via 921.600 baud UART
Hardware display0,96 inch I2C OLED, 128x64, 32,6 FPS, hardware 2x2 scaler
InputHost-toetsenbord via UART met press- en release-events, gedecodeerd in RTL
Laadtijd5,27 MB image via serieel in ca. 57 seconden, CRC32 geverifieerd
TimingSlow corner worst slack -3,998 ns; nominale silicon draait op de bench

Hardware Componenten

  • 1 × DE0-Nano FPGA board: Terasic DE0-Nano, Altera Cyclone IV E EP4CE22F17C6N, 22.320 LEs, 32 MB SDRAM, 50 MHz oscillator, USB-Blaster.
  • 1 × CP2102 USB-to-TTL serieel module: Silicon Labs CP2102, 3,3 V logica, 921.600 baud; verzorgt upload, framebuffer, console en toetsenbord.
  • 1 × 0,96 inch I2C OLED module: 128x64 monochroom, SSD1306 of SH1106 of SSD1315 class controller, adres 0x3C.
  • 1 × Mini-USB kabel: Voeding van het board en USB-Blaster JTAG programmering.
  • Jumper wires:
  • OLED: VCC naar JP1 pin 29, GND naar pin 12, SCL naar pin 6, SDA naar pin 8.
  • Serieel: module TXD naar JP1 pin 2, module RXD naar JP1 pin 4, GND naar pin 12.

Architectuur en Ontwikkeling

Het bandbreedteprobleem en de keuze voor 64x32

DOOM rendert naar een native Mode 13h-buffer van 320x200 (64.000 bytes). De frame-logica is na ongeveer 28 ms voltooid (ca. 35,7 FPS intern), maar de seriële verbinding vormt de flessenhals. Bij 921.600 baud is het theoretische maximum 92,16 KB/s, waardoor een frame van 64.000 bytes 694 ms over de lijn doet. Dit resulteerde in een display-rate van slechts 1,44 FPS.

Na experimenten met 160x100 (5,75 FPS) is er gekozen voor een uniforme framebuffer van 64x32. Dit verbruikt 2.048 bytes per frame, wat in 22,2 ms over de lijn gaat en een stabiele 45 FPS oplevert. Deze resolutie sluit bovendien aan bij de native resolutie van de Chip-8 virtual machine (1977), past bij fixed-point raycasting kolommen en schaalt zonder geometrische vervorming (2x2) naar een 128x64 monochroom OLED-scherm.

Resolutie vs. overdrachtstijd bij 921.600 baud:

  • 320x200 (64.000 bytes): 694 ms per frame → 1,44 FPS
  • 160x100 (16.000 bytes): 174 ms per frame → 5,75 FPS
  • 64x32 (2.048 bytes): 22 ms per frame → 45,00 FPS

In de memory map neemt de framebuffer een vast venster van 2 KB in van 0xFFF00000 tot 0xFFF007FF.

#define FB_W        64
#define FB_H        32
#define FB          ((volatile uint8_t *)0xFFF00000u)
#define MMIO_BASE   0xFFFFF000u
#define MM_KEY      (*(volatile uint32_t *)(MMIO_BASE + 0x014u))
#define MM_DUMP     (*(volatile uint32_t *)(MMIO_BASE + 0x028u))

void dump_tiny(void) {
    MM_DUMP = 2u; /* Trigger 64x32 tiny mode dump in mem_top_fpga */
}

Frame commit path

Wanneer een frame gereed is, schrijft het spel de waarde 2 naar MMDUMP (0xFFFFF028). In memtopfpga.v zorgt deze schrijfactie ervoor dat de SDRAM-arbiter 512 words (2.048 bytes) leest vanuit de framebuffer backing store op FBSDR_WORD (0x7FC000).

Bij 50 MHz duurt het lezen van 512 words ongeveer 153,6 microseconden. De FPGA beschikt over een TX FIFO van 4.096 bytes, waardoor het hele frame in één burst past. De CPU wordt gedurende ongeveer 154 microseconden per frame gestopt (stalled).

---

Projectlogboeken

Fixed Point Ray Casting en de Finale Versie

De grootste uitdagingen waren real-time 3D in software en de virtuele machine uit 1977.

De Raycaster: Het eerste prototype draaide op 3 FPS. Dit werd opgelost door:

  1. Fixed point rekenen: Floating point operaties vertraagden de dual-issue core. Alle berekeningen zijn omgezet naar 22.10 fixed point (FPSHIFT op 10, FPONE op 1024). Trigonometrie wordt nu afgehandeld via een lookup-tabel van 256 entries, gegenereerd vanuit een Bhaskara rationele benadering.
  2. Fisheye-correctie: Om vervorming langs muren te voorkomen, wordt de loodrechte afstand tot het cameravlak gebruikt:

int32t perpwalldist = (side == 0) ? (sidedistx - deltadistx) : (sidedisty - deltadisty); int lineheight = (int)(((SCREENH) << FPSHIFT) / perpwalldist);

De FPGA's iteratieve divider doet er 33 cycli over. Met 64 kolommen kost de column pass minder dan 4.000 divider cycli. De volledige pass (raycasting, texture mapping en sprite sorting) draait in minder dan 400.000 cycli per frame, wat ruim binnen het budget voor 45 FPS bij 50 MHz past.

Chip-8: De interpreter implementeert alle 35 opcodes, inclusief 16 8-bit registers, een 16-bit index register, een call stack van 16 niveaus en XOR sprite drawing met collisiedetectie. Sommige ROM's (zoals Guess the Number en Kaleidoscope) zijn naar een "vault" verplaatst omdat ze geschreven zijn voor een specifieke 4x4 hex-keypad uit 1977, wat op een modern toetsenbord onbruikbaar is.

Native spellen: Er zijn diverse native titels toegevoegd: Snake, 2048, Tetris, Minesweeper en Flappy Bird. Deze tekenen direct in de framebuffer en pollen het hardware key register.

RunCPM Prompt Support

De console draait originele CP/M 2.2-software via een port van RunCPM naar bare metal. De Z80-decoder levert het registerbestand, een virtuele adresruimte van 64 KB en de zero page vectors.

Omdat er geen POSIX-bestandssysteem is, wordt de disk vooraf gepakt in een blob. De BDOS-translatielaag routeert calls als volgt:

  • Console output (functies 1, 2, 6, 9): gaat naar de hardware UART en een virtuele terminal van 4x16 tekens in de 64x32 framebuffer.
  • Bestandsacces (functies 14, 15, 16, 20, 21): leest vanuit de in-memory RAM disk arena.

Een specifieke bug werd ontdekt bij bestanden groter dan 16 KB. De record-offset werd foutief berekend, waardoor de leesactie bij record 128 terugsprong naar het begin van het bestand en in een oneindige loop terechtkwam. De correcte lineaire offset is: offset = ((fcb->ex 128) + fcb->cr) 128

OLED Display en Hardware Debugging

Het systeem is uitgebreid met een 0,96-inch 128x64 monochroom I2C OLED-scherm. Dit wordt aangestuurd door een RTL-master in plaats van de CPU.

Ontdekte bugs:

  1. Protocol-mismatch: Het scherm vertoonde ruis omdat de driver uitging van de horizontal addressing mode (SSD1306), terwijl het board een SH1106-controller had die alleen page addressing mode ondersteunt. De fix was om frame-by-frame per pagina te streamen.
  2. Counter overflow: De display bevroor op pagina 0 omdat de kolomteller 7-bit was. Na kolom 127 wrapte de teller terug naar 0 voordat de check tegen 128 kon vuren. Het verbreden van de teller naar 8-bit loste dit op.

Er is een hardware 2x2 scaler geïmplementeerd zodat de 64x32 framebuffer direct op het 128x64 scherm past zonder geometrische vervorming.

De Launcher en Geheugenbeheer

Om van een demo naar een echte console te gaan, is een launcher ontwikkeld met 32 titels in drie categorieën: Native/3D, CP/M 2.2 en Chip-8.

Geheugenindeling (32 MB SDRAM):

  • 0x00000000 - 0x00027FFF: Firmware (158,6 KB)
  • 0x00028000 - 0x00C27FFF: Dynamische heap (12 MB)
  • 0x00C28000 - 0x00CA7FFF: Executie stack (512 KB)
  • 0x00CA8000 - 0x014A7FFF: Asset window (8 MB, waarvan 5,11 MB gebruikt)
  • 0x01FF0000 - 0x01FFFFFF: Framebuffer SDRAM backing (64 KB)

Om vanuit een spel terug te keren naar het menu zonder de console te resetten (wat 57 seconden uploadtijd kost), is een freestanding setjmp en longjmp implementatie in crt0.S gebruikt. Dit bewaart de return address, stack pointer en de twaalf registers.

Prototype en UART Communicatie

Om DOOM op het board te krijgen via de seriële lijn, is een mechanisme bedacht om de game-clock te bevriezen tijdens de frame-overdracht. Wanneer de engine MM_DUMP schrijft, schakelt het geheugensysteem de core-clock enable uit. Hierdoor ervaart de game geen tijdverlies tijdens de overdracht (geen "phantom tics"), waardoor physics en animaties correct blijven lopen.

Framing: Elk frame begint met een 10-byte header: vier magic bytes (0x55, 0xAA, 0x5A, 0xA5), een mode byte, een 16-bit frame nummer, een 16-bit breedte en een 8-bit hoogte. De viewer scant naar deze magic bytes om frames te scheiden van console-tekst.

Pipeline Debugging en FPGA Bring-up

Tijdens de hardware bring-up trad een bug op waarbij de game hingen bij frame 0. Een Python cycle tracer wees op een corruptie in de timestamp berekening (DG_GetTicksMs).

De oorzaak was een specifieke coincidentie in de pipeline: een M (multiply) operatie die vrijkwam op exact hetzelfde moment dat een afhankelijke pair squashte. De oude flush-term verwijderde slot 0, terwijl de replay PC alleen slot 1 opnieuw ophaalde. De consument las hierdoor een verouderd register. Dit werd opgelost door de release te kwalificeren met de squash: wire flushexidex = hzflushex || exrvalid || (mdrelease && !squash_s1);

Simulatie Optimalisatie

De oorspronkelijke simulatie was traag (12 minuten wall-time voor 30 seconden gameplay). Dit is verbeterd door:

  • Timing-mode te droppen.
  • Gebruik van een handgeschreven C++ loop (sim_main.cpp).
  • Implementatie van snapshots, waardoor de boot-tijd van 24 seconden per run wordt overgeslagen.

Dit verhoogde de snelheid van 1,62 MHz naar 6,24 MHz (een factor 3,9x).

Verilator Prototype

Voordat de hardware beschikbaar was, werd het volledige board geprototypeerd in Verilator. Elk component (oscillator, reset-knop, flash, keyboard, display) kreeg een testbench stand-in.

Een kritieke bug werd hier ontdekt in de data memory index slice: bij een heap van 40 MB landde de WAD lump directory buiten het 64 MiB window en wrapte deze silent, wat resulteerde in het lezen van nullen. Dit werd opgelost door de heap-grootte in het linker script aan te passen en een build-time check toe te voegen in doom/build.sh.