
Building a Multi-System Retro Gaming Console on a Bare-Metal RISC-V FPGA
The previous post ended with a dual-issue RV32IM core running on a Terasic DE0-Nano FPGA, booting DOOM from SDRAM and streaming frames over a serial cable. That prototype validated the processor pipeline, branch predictor, and SDRAM memory arbiter on silicon. In Verilator simulation with ideal single-cycle memory, the core achieved a CPI of 0.727; on physical hardware, data transactions incurred SDRAM arbiter latency (~15 cycles on misses). While functional, it remained a single-title demo tied to host scripts and limited by serial wire throughput.
When the Hackaday Retro Gaming and FPGA competition was announced, the project shifted toward building a complete, standalone, multi-system retro gaming console on the DE0-Nano.
The design choices centered on practical standalone execution: 45 FPS real-time interactive responsiveness, universal keyboard mapping with an in-game escape mechanism, vintage 8-bit software virtualization, 3D raycasting, a modular game vault, and physical hardware display support using an autonomous I2C OLED controller.
This post documents how each subsystem was designed, how failure modes were diagnosed on the bench, and how the system evolved into a 32-game console running bare-metal on the Cyclone IV FPGA.
Summary of Results
| Metric / Feature | Measurement / Implementation |
|---|---|
| Silicon Target | Altera Cyclone IV E EP4CE22F17C6N on Terasic DE0-Nano |
| Processor Core | Dual-issue in-order RV32IM @ 50 MHz (17,743 LEs total) |
| System Memory | 32 MB SDRAM (158.6 KB firmware, 12 MB heap, 512 KB stack, 5.11 MB asset blob) |
| Primary Display | Uniform 64x32 RGB332 Framebuffer @ 45 FPS over 921,600 baud UART |
| Hardware Display | 0.96" Monochrome I2C OLED (128x64) via autonomous RTL master @ 32.6 FPS |
| Total Game Catalog | Exactly 32 titles (7 Native/3D, 6 CP/M 2.2, 19 Chip-8 Classics) |
| Asset Packaging | Single 5.11 MB aligned image (menu.blob) via mkmenuwad.py |
| Input & Navigation | Universal break-code decoder (0xF0) + bare-metal setjmp/longjmp ESC unwinding |
The Framing Physics and Bandwidth Bottleneck
The first constraint appeared in the serial transmission numbers.
DOOM’s native Mode 13h resolution (320x200, 64,000 bytes) computed frame logic in 28 milliseconds (~35.7 FPS internally). However, pushing 64,000 raw indexed bytes over the CP2102 serial link at 921,600 baud (92.16 KB/s theoretical max) took 694 milliseconds per frame. The game logic ran at full speed, but wire throughput limited the display to 1.44 FPS. The serial link consumed more than 96 percent of the total frame time.
Sub-sampling to 160x100 reduced the frame payload to 16,000 bytes (174 ms per frame, ~5.75 FPS), but gameplay still lagged behind human reflex speed.
Resolution vs Wire Transmission Time (at 921,600 baud / 92.16 KB/s):
320x200 (64,000 bytes): 694 ms per frame -> 1.44 FPS (Wire-bound slideshow)
160x100 (16,000 bytes): 174 ms per frame -> 5.75 FPS (Sluggish input lag)
64x32 (2,048 bytes): 22 ms per frame -> 45.00 FPS (Real-Time Interactive)
Scaling the console down to a uniform 64x32 framebuffer (2,048 bytes per frame) solved the bandwidth bottleneck. At 2,048 bytes, an entire frame travels across the wire in 22.2 milliseconds. This delivers a steady 45 FPS.
Furthermore, 64x32 is the native resolution of the 1977 Chip-8 virtual machine, maps cleanly to fixed-point 3D raycasting columns, and scales integer-perfect (2x2) to physical 128x64 monochrome OLED displays without geometric distortion.
In the memory map, the framebuffer occupies a fixed 2 KB window at 0xFFF00000..0xFFF007FF. In software, each byte represents an 8-bit RGB332 pixel for serial streaming (host/tinyview.py), while the hardware OLED driver thresholds any non-zero byte (pixel != 0) as a lit monochrome pixel.
When a frame is ready, the game writes 2 to the MM_DUMP register (0xFFFFF028), signaling tiny mode to the hardware streaming engine. This replaces the multi-byte host command protocol (0xFC/0xFD/0xFE) with a single hardware MMIO trigger:
// games/common/console.h: Memory map and core definitions
#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
}
Core Clock Gating and the 4 KB TX FIFO
In the initial DOOM implementation, committing a 320x200 frame gated the core clock for the entire 694 ms duration of the UART transfer to prevent tearing. For a 64x32 tiny frame, the behavior is fundamentally different.
In mem_top_fpga.v, asserting MM_DUMP = 2 causes the SDRAM arbiter to read 512 32-bit words (2,048 bytes) from the SDRAM framebuffer backing at FB_SDR_WORD (0x7FC000). Reading 512 words from SDRAM takes approximately 7,680 system clock cycles (~153.6 µs at 50 MHz).
Because the FPGA contains a 4,096-byte hardware FIFO (txfifo), the entire 2,048-byte frame fits directly into the buffer in one burst. As soon as the last word is written to the FIFO, the memory arbiter returns to A_TICK, releasing the gated core clock (core_run_d = 1).
The CPU stalls for only 154 microseconds per frame, while the autonomous UART feeder drains the FIFO out to the physical pin in the background over the next 22 ms. Because the CPU is not frozen during wire transmission, game logic runs at full speed, and software timers do not drift.
+-------------------------------------------------------------------------+
| 64x32 Frame Dump Pipeline Timing |
+-------------------------------------------------------------------------+
| [ CPU Frame Commit ] -> MM_DUMP = 2 |
| | |
| v (154 µs: 512 words read from SDRAM to 4 KB FIFO) |
| [ Core Clock Resumes ] ===> CPU continues executing next game frame |
| | |
| v (22.2 ms: Autonomous UART feeder transmits 2 KB to wire) |
| [ Host Python Viewer ] <=== 45 FPS Interactive Stream |
+-------------------------------------------------------------------------+
Downsampling DOOM to 64x32
Running DOOM at 64x32 required downsampling the native Mode 13h buffer (320x200).
In games/doom/doom_native.c, the software renderer generates the 320x200 composite scene in memory, and downsamples into the 64x32 framebuffer by sampling column indices (x * 320) / 64 and row indices (y * 200) / 32, translating DOOM palette indices into 8-bit RGB332 values:
// Downsampling 320x200 DOOM framebuffer to 64x32 RGB332
void I_FinishTinyFrame(const uint8_t *src320) {
volatile uint8_t *dst = FB;
for (int y = 0; y < 32; y++) {
int src_y = (y * 200) / 32;
for (int x = 0; x < 64; x++) {
int src_x = (x * 320) / 64;
uint8_t pal_idx = src320[src_y * 320 + src_x];
dst[y * 64 + x] = doom_pal_to_rgb332[pal_idx];
}
}
dump_tiny();
}
On the host side, host/tinyview.py unpacks the 8-bit RGB332 byte stream (3 bits Red, 3 bits Green, 2 bits Blue), scales the 64x32 matrix up to window coordinates, and displays real-time frame rate metrics.
The Hardware Input Pipeline
The 0xF0 break-code protocol originally designed for the full-screen DOOM viewer was carried directly into the console viewer (host/tinyview.py).
Standard serial terminal emulators only emit ASCII characters on keydown. Without release events, polling the key register treats every keypress as held down permanently. In tinyview.py, native windowing events capture both press and release. On key release, the host emits an 0xF0 prefix byte immediately before the scancode:
# host/tinyview.py: Key release event handler with break-code prefix
def on_key_release(self, event: tk.Event):
key = event.keysym
keycode = CONSOLE_KEYS.get(key) or CONSOLE_KEYS.get(event.char)
if keycode is not None and keycode in self.pressed_keys:
self.pressed_keys.remove(keycode)
# Send 0xF0 release prefix followed by keycode
try:
self.ser.write(bytes([0xF0, keycode]))
except Exception:
pass
Input Transport Pipeline:
[ Host Keyboard ] -> [ Python Viewer ] -> [ UART Wire (0xF0 Prefix) ]
|
v
[ RISC-V Game ] <--- [ KEY Register (0xFFFFF014) ] <--- [ FPGA UART RX ]
On the FPGA, mem_top_fpga.v decodes the incoming UART byte stream. When 0xF0 arrives, it asserts the internal key_rel_prefix flag. When the subsequent keycode byte arrives, it latches the scancode into the pending key register key_reg, sets bit 30 (key_rel), asserts bit 31 (key_valid), and clears the prefix flag:
// fpga/rtl/mem_top_fpga.v: Keyboard MMIO register logic
always @(posedge sys_clk) begin
if (core_rst) begin
key_reg <= 8'd0;
key_rel <= 1'b0;
key_valid <= 1'b0;
key_rel_prefix <= 1'b0;
end else if (key_ack) begin
key_valid <= 1'b0;
end else if (booted && rx_valid) begin
if (rx_data == 8'hF0) begin
key_rel_prefix <= 1'b1;
end else begin
key_reg <= rx_data;
key_rel <= key_rel_prefix;
key_valid <= 1'b1;
key_rel_prefix <= 1'b0;
end
end
end
The game software polls MM_KEY at 0xFFFFF014. Bit 31 confirms a pending event, bit 30 reports whether it was a press (0) or release (1), and bits 7:0 contain the keycode. Any write to MM_KEY asserts key_ack to clear key_valid:
// games/common/console.c: Polling hardware key register
int key_poll(int *pressed, uint8_t *code) {
uint32_t k = MM_KEY;
if (!(k & 0x80000000u)) return 0; // Bit 31 = valid event
*pressed = (k & 0x40000000u) ? 0 : 1; // Bit 30 = release flag
*code = (uint8_t)(k & 0xFFu); // Low byte = keycode
MM_KEY = 0u; // Any write acknowledges and clears key_valid
return 1;
}
The Unified 32-Game Launcher
Initial tests ran games as standalone ELF binaries uploaded individually to SDRAM. However, switching from DOOM to Snake or Chip-8 required restarting the PC upload script, transferring hundreds of kilobytes over serial, and waiting 10 to 30 seconds between games.
To create a standalone console, all games, virtual machines, and asset blobs were consolidated into a single bare-metal firmware image governed by an interactive launcher (games/menu/).
The launcher boots immediately upon FPGA power-up or SDRAM upload, rendering an interactive grid of available titles with active selection cursors, categories, and controls.


The catalog consists of exactly 32 games divided into three distinct runtime categories:
// games/menu/menu.c: Catalog definition (32 games total)
#define TOTAL_GAMES 32
static const struct game_entry games[TOTAL_GAMES] = {
/* 1 - 7: Native Games & 3D Engines */
{"DOOM (EP.1)", "[3D] ", 0, 0},
{"WOLFENSTEIN", "[3D] ", 1, 0},
{"MINESWEEPER", "[PUZ]", 7, 0},
{"FLAPPY BIRD", "[ARC]", 8, 0},
{"SNAKE", "[ARC]", 2, 0},
{"2048", "[PUZ]", 3, 0},
{"TETRIS", "[PUZ]", 4, 0},
/* 8 - 13: CP/M 2.2 Retro Computing & Games */
{"STAR TREK", "[CPM]", 5, "MBASIC STARTRK"},
{"LADDER", "[CPM]", 5, "MBASIC LADDER"},
{"HAMURABI", "[CPM]", 5, "MBASIC HAMURABI"},
{"LUNAR LANDER", "[CPM]", 5, "MBASIC LUNAR"},
{"HUNT WUMPUS", "[CPM]", 5, "MBASIC WUMPUS"},
{"RUNCPM OS", "[CPM]", 5, "DIR"},
/* 14 - 32: Chip-8 Virtual Console Games */
{"INVADERS", "[CH8]", 6, "INVADERS"},
{"PONG AI", "[CH8]", 6, "PONG"},
{"BRIX BREAK", "[CH8]", 6, "BRIX"},
{"TANK ARENA", "[CH8]", 6, "TANK"},
{"BLITZ BOMB", "[CH8]", 6, "BLITZ"},
{"MISSILE DEF", "[CH8]", 6, "MISSILE"},
{"UFO SHOOTER", "[CH8]", 6, "UFO"},
{"CAVE ESCAPE", "[CH8]", 6, "CAVE"},
{"LUNAR LAND", "[CH8]", 6, "LANDING"},
{"AIRPLANE", "[CH8]", 6, "AIRPLANE"},
{"CONNECT 4", "[CH8]", 6, "CONNECT4"},
{"TIC-TAC-TOE", "[CH8]", 6, "TICTAC"},
{"15 PUZZLE", "[CH8]", 6, "15PUZZLE"},
{"LOGIC PUZ", "[CH8]", 6, "PUZZLE"},
{"MERLIN MEM", "[CH8]", 6, "MERLIN"},
{"HIDDEN PAIR", "[CH8]", 6, "HIDDEN"},
{"BLINKY PAC", "[CH8]", 6, "BLINKY"},
{"MAZE WALK", "[CH8]", 6, "MAZE"},
{"WIPEOFF", "[CH8]", 6, "WIPEOFF"}
};
The launcher dispatch loop branches directly into the appropriate subsystem:
// games/menu/menu.c: Universal dispatch loop
static void launch_game(int sel)
{
static char *dargv[] = { (char *)"doom", 0 };
const struct game_entry *g = &games[sel];
key_flush();
switch (g->type) {
case 0: doom_main(1, dargv); break;
case 1: wolf3d_main(); break;
case 2: snake_main(); break;
case 3: g2048_main(); break;
case 4: tetris_main(); break;
case 5: cpm_auto_cmd = g->arg; cpm_entry(); break;
case 6:
menu_rom_ptr = 0;
menu_rom_len = 0;
if (g->arg && romtab && romcount > 0) {
for (uint32_t i = 0; i < romcount; i++) {
int match = 1;
for (int k = 0; g->arg[k]; k++) {
if (romtab[i].name[k] != g->arg[k]) {
match = 0; break;
}
}
if (match) {
menu_rom_ptr = (const uint8_t *)_wad_start + romtab[i].off;
menu_rom_len = romtab[i].len;
break;
}
}
}
chip8_main();
break;
case 7: minesweeper_main(); break;
case 8: flappy_main(); break;
default: break;
}
key_flush();
}
Memory Partitioning and the Linker Architecture
Consolidating multiple runtimes into a single binary created immediate memory layout conflicts. Before menu.ld was created, DOOM assumed dynamic heap could grow indefinitely above .bss, which clobbered the upper memory regions where ROMs and CP/M disk images resided.
The DE0-Nano provides a 32 MB SDRAM chip (IS42S16160G, organized as 4 banks of 4M x 16 bits). The memory layout in games/menu/menu.ld binds every section to fixed physical boundaries:
+-------------------------------------------------------------------------+
| Physical 32 MB SDRAM Layout (DE0-Nano) |
+-------------------------------------------------------------------------+
| Address Range | Size | Contents |
+---------------------------+---------+-----------------------------------+
| 0x00000000 - 0x00027FFF | 158.6 KB| Console Firmware Binary |
| | | (.text, .rodata, .data, .bss) |
| 0x00028000 - 0x00C27FFF | 12 MB | Dynamic Heap (__heap_start) |
| 0x00C28000 - 0x00CA7FFF | 512 KB | Execution Stack (_stack_top) |
| 0x00CA8000 - 0x014A7FFF | 8 MB | Unified Asset Blob (menu.blob) |
| | | (_wad_start to _wad_end, 5.11 MB) |
| 0x014A8000 - 0x01FEFFFF | ~11.3 MB| Unallocated Expansion Headroom |
| 0x01FF0000 - 0x01FFFFFF | 64 KB | Physical Framebuffer SDRAM Backing|
+-------------------------------------------------------------------------+
+-------------------------------------------------------------------------+
| Hardware MMIO & Virtual Apertures |
+-------------------------------------------------------------------------+
| Address Range | Access | Hardware Peripheral / Target |
+---------------------------+---------+-----------------------------------+
| 0xFFF00000 - 0xFFF007FF | R/W | 64x32 Framebuffer Aperture (2 KB) |
| 0xFFFFF000 | W | Exit Register (Halts core clock) |
| 0xFFFFF004 / 0xFFFFF008 | R | Cycle Counter (CYC_LO / CYC_HI) |
| 0xFFFFF00C / 0xFFFFF010 | R | Retired Instructions (INS_LO/HI) |
| 0xFFFFF014 | R/W | Keyboard Event Register |
| 0xFFFFF018 | R | Hardware Frame Counter |
| 0xFFFFF01C | R/W | Framebuffer Base Register |
| 0xFFFFF020 | R | System Status (Bit 0 = Exit Req) |
| 0xFFFFF024 | W | UART TX FIFO Register (4 KB FIFO) |
| 0xFFFFF028 | W | Frame Commit Trigger (MM_DUMP) |
+-------------------------------------------------------------------------+
The Linker Script: games/menu/menu.ld
The GNU ld script enforces the 32 MB boundary (ASSERT(_wad_end < 0x02000000)), replacing the 64 MB simulation boundary inherited from Verilator:
/* games/menu/menu.ld: Linker script for the unified menu firmware */
OUTPUT_ARCH(riscv)
ENTRY(_start)
MEMORY
{
RAM (rwx) : ORIGIN = 0x00000000, LENGTH = 32M
}
SECTIONS
{
. = 0x00000000;
.text : {
KEEP(*(.text.startup))
KEEP(*(.text.init))
*(.text .text.*)
} > RAM
. = ALIGN(4);
.rodata : { *(.rodata .rodata.*) *(.srodata .srodata.*) } > RAM
. = ALIGN(4);
.data : { *(.data .data.*) *(.sdata .sdata.*) } > RAM
. = ALIGN(4);
__global_pointer$ = . + 0x800;
.bss (NOLOAD) : {
. = ALIGN(4);
__bss_start = .;
*(.bss .bss.*) *(.sbss .sbss.*) *(COMMON)
. = ALIGN(4);
__bss_end = .;
} > RAM
/* Dynamic runtime heap for DOOM zone memory and CP/M RAM */
. = ALIGN(16);
__heap_start = .;
. += 12M;
__heap_end = .;
/* Execution stack: 512 KB reserved */
. = ALIGN(16);
_stack_bottom = .;
. += 512K;
_stack_top = .;
/* MENUWAD asset blob: CP/M disk.blob + DOOM IWAD + Chip-8 ROMs */
. = ALIGN(4);
_wad_start = .;
. += 8M;
_wad_end = .;
ASSERT(_wad_end < 0x02000000, "Firmware and assets exceed 32 MB SDRAM window")
/DISCARD/ : { *(.comment) *(.note.*) *(.eh_frame*) }
}
Firmware binaries (menu.elf, 158.6 KB) and data assets (menu.blob, 5.11 MB) total 5.27 MB. Transmitting 5.27 MB over UART at 921,600 baud takes approximately 57 seconds during initial board upload.
Asset Packaging: mkmenuwad.py
To pack all secondary data into a single continuous image, games/menu/mkmenuwad.py compiles assets into menu.blob:
Unified menu.blob Structure:
+-------------------------------------------------------------+
| struct menuwad_hdr (32 bytes, magic "MENUWAD1") |
+-------------------------------------------------------------+
| struct menuwad_rom[rom_count] (20 bytes per Chip-8 ROM) |
+-------------------------------------------------------------+
| CP/M Virtual Disk Image (`disk.blob`, 4-byte aligned) |
+-------------------------------------------------------------+
| DOOM Shareware IWAD (`doom1.wad`, 4-byte aligned) |
+-------------------------------------------------------------+
| Raw Chip-8 ROM Byte Arrays (4-byte aligned) |
+-------------------------------------------------------------+
// games/menu/menu_blob.h: Blob header definition
#define MENUWAD_MAGIC 0x31444157554E454DULL /* "MENUWAD1" little-endian */
#define MENUWAD_VERSION 1
struct menuwad_hdr {
uint64_t magic;
uint16_t version;
uint16_t rom_count;
uint32_t disk_off, disk_len;
uint32_t doom_off, doom_len;
uint32_t reserved;
};
_Static_assert(sizeof(struct menuwad_hdr) == 32, "menuwad hdr layout");
struct menuwad_rom {
char name[12];
uint32_t off, len;
};
_Static_assert(sizeof(struct menuwad_rom) == 20, "menuwad rom layout");
The One-Way Launch Trap and Bare-Metal setjmp/longjmp
Once multiple games were launched from the menu, returning to the launcher became a critical problem. Once a game loop began executing, the only way to return to the menu was pressing KEY1 on the DE0-Nano board. Resetting cleared the SDRAM controller state and halted the CPU, forcing a 57-second re-upload over serial.
Because bare-metal RISC-V runs without an operating system process model (fork, exec, exit), game loops run deep inside nested call stacks. Normal function returns cannot unwind mutated stacks safely.
To allow instant returns to the menu, a freestanding implementation of setjmp and longjmp was written in assembly (games/common/crt0.S). The jump buffer allocates 14 words (56 bytes) to save callee-saved registers (ra, sp, s0 through s11):
# games/common/crt0.S: Non-local jump implementations for RV32I
.globl setjmp
setjmp:
sw ra, 0(a0)
sw sp, 4(a0)
sw s0, 8(a0)
sw s1, 12(a0)
sw s2, 16(a0)
sw s3, 20(a0)
sw s4, 24(a0)
sw s5, 28(a0)
sw s6, 32(a0)
sw s7, 36(a0)
sw s8, 40(a0)
sw s9, 44(a0)
sw s10, 48(a0)
sw s11, 52(a0)
li a0, 0
ret
.globl longjmp
longjmp:
lw ra, 0(a0)
lw sp, 4(a0)
lw s0, 8(a0)
lw s1, 12(a0)
lw s2, 16(a0)
lw s3, 20(a0)
lw s4, 24(a0)
lw s5, 28(a0)
lw s6, 32(a0)
lw s7, 36(a0)
lw s8, 40(a0)
lw s9, 44(a0)
lw s10, 48(a0)
lw s11, 52(a0)
mv a0, a1
bnez a0, 1f
li a0, 1
1: ret
In menu.c, the execution wrapper establishes the jump target before branching to a selected title:
// games/menu/menu.c: Safe execution wrapper
g_in_game = 1;
if (setjmp(menu_jmp_buf) == 0) {
launch_game(sel);
}
g_in_game = 0;
key_flush();
draw_menu(sel, top);
Re-entrancy and Key Flushes
Early tests with setjmp revealed a re-entrancy issue: entering DOOM, pressing ESC to return to the menu, and selecting DOOM again crashed with heap corruption.
The crash occurred because DOOM’s zone allocator had retained stale allocation pointers from the previous run. To achieve clean re-entrancy, doom_main() resets its memory zone via Z_Init() upon every entry, resets static player state, and calls key_flush() to clear the pending key register before drawing the first frame.
In DOOM, the internal ESC game menu was disabled so that pressing ESC consistently triggers longjmp(menu_jmp_buf, 1) across all 32 games in the catalog.
Vintage 8-Bit Computing: Porting RunCPM
To bring vintage computing to the console, RunCPM was ported to bare-metal RISC-V (games/cpm/).
In the late 1970s and early 1980s, Gary Kildall’s CP/M was the standard operating system for microcomputers running Intel 8080 and Zilog Z80 microprocessors. Bringing CP/M binaries to the RISC-V core allows the console to execute original 8-bit software, from text adventures to interactive programming environments like Microsoft BASIC-80.
+-------------------------------------------------------------------------------+
| RISC-V RV32IM Core |
| |
| +-----------------------------------------------------------------------+ |
| | RunCPM Virtual Engine | |
| | - Z80 Instruction Decoder (cpu2.h: AF, BC, DE, HL, IX, IY, SP, PC) | |
| | - 64 KB Virtual Address Space (cpm_ram[65536]) | |
| | - Zero Page Vectors: 0x0000 (Warm Boot), 0x0005 (BDOS Call Vector) | |
| +-----------------------------------+-----------------------------------+ |
| | |
| Trap BDOS Function | (Call 0x0005) |
| v |
| +-----------------------------------------------------------------------+ |
| | shim.c / abstraction_retro.h | |
| +-----------------------------------+-----------------------------------+ |
| | | |
| Console I/O (Fn 1, 2, 6, 9) File / Disk Access (Fn 14, 15, 20) |
| v v |
| +-------------------------------+ +-------------------------------+ |
| | Dual Terminal Engine | | In-Memory RAM Disk Driver | |
| | - UART TX FIFO (0xFFFFF024) | | - Linear Sector Translator | |
| | - 4x16 Phosphor Font on FB | | - Direct disk.blob Lookup | |
| +-------------------------------+ +-------------------------------+ |
+-------------------------------------------------------------------------------+
The Missing Filesystem and Extent Handling
Standard RunCPM implementations rely on hosted POSIX filesystem calls (fopen, fread, opendir) to expose host directories as virtual disk drives. On the bare-metal DE0-Nano, there is no underlying operating system or SD card controller. Compiling standard RunCPM failed with missing libc filesystem symbols.
To solve this, games/cpm/mkdisk.py scans games/cpm/diskfiles/, converts filenames into 11-byte CP/M format (8.3 space padded), and packs them into disk.blob.
The BDOS translation layer (games/cpm/shim.c and games/cpm/abstraction_retro.h) traps BDOS Call 5 (Functions 14, 15, 16, 20, 21):
- When a CP/M program opens a file, the driver scans header entries inside
disk.blob. - For files larger than 16 KB (such as
MBASIC.COMat 24 KB), CP/M splits storage across 16 KB extents (fcb->ex). Naive implementations using onlyfcb->cr * 128loop infinitely on record 128. The driver computes the true linear offset using both extent and record:offset = ((fcb->ex * 128) + fcb->cr) * 128. - In-memory file writes (Function 21
_WriteSeq) update the RAM-disk memory arenard_arenadynamically, persisting file modifications during the active session. - For console output (Functions 1, 2, 6, 9), every character is pushed to the hardware UART TX register at
0xFFFFF024. Simultaneously,abstraction_retro.hmaintains a 4-line by 16-character virtual green-phosphor terminal rendered directly into the 64x32 framebuffer using a 4x6 bitmap font (cpm_term_render()). - Auto-command execution (
cpm_auto_cmd) writes anAUTOEXEC.TXTbatch file into the RAM disk upon boot, allowing the internal CCP to launch titles like"MBASIC STARTRK"automatically. - ESC detection during blocking console input:
_getch()pollskey_poll()in a 22 ms loop, checking for keycode 27 to unwind back to the launcher.
At 50 MHz on the RV32IM core with SDRAM access latencies, the Z80 interpreter executes at approximately 2–4 MHz equivalent Z80 clock rate, matching classic microcomputers of that era:
| Star Trek | Ladder | Hamurabi |
|---|---|---|
![]() | ![]() | ![]() |
| Lunar Lander | Hunt the Wumpus | DevStudio |
|---|---|---|
![]() | ![]() | ![]() |
- Star Trek (1971): Navigate the galaxy, manage energy, fire photon torpedoes, and eliminate Klingon battlecruisers.
- Ladder (1982): Yahoo Software’s classic ASCII platformer running on VT100 escape sequences on the host terminal viewer, dodging falling rocks and climbing ladders.
- Hamurabi (1968): Foundational resource management simulation balancing land, grain bushels, and population over a 10-year reign.
- Lunar Lander: Apollo descent simulation calculating retro-rocket fuel burn rates against gravitational acceleration.
- Hunt the Wumpus: Navigate 20 dodecahedron cave rooms avoiding bottomless pits and super bats.
- RunCPM Dev Studio: Full interactive CP/M 2.2 command prompt with
DIR,TYPE,ASM,DDT, and MicrosoftMBASIC 5.29.
Fixed-Point 3D Raycasting: Wolfenstein 3D
To test the core on real-time 3D geometry without dedicated GPU hardware or floating-point acceleration, a custom raycaster based on the Wolfenstein 3D rendering pipeline was built (games/wolf3d/wolf3d.c).

Fisheye Distortion and Float Overhead
The initial raycasting prototype suffered from two problems:
- Using floating-point
sin()andcos()calls through soft-float emulation choked the dual-issue core, dropping framerate to ~3 FPS. - Calculating Euclidean distance directly from the player to the wall intersection resulted in severe fisheye distortion along wall edges.
To resolve both issues:
- All floating-point math was converted to 22.10 fixed-point arithmetic (
FP_SHIFT = 10,FP_ONE = 1024). - Trigonometric functions were replaced with a 256-entry precomputed lookup table generated via Bhaskara rational approximation at startup.
- Fisheye distortion was eliminated by calculating the perpendicular distance from the camera plane rather than direct line-of-sight distance:
perp_wall_dist = (side == 0) ? (side_dist_x - delta_dist_x) : (side_dist_y - delta_dist_y). - The division budget per frame involves casting 64 column rays. With wall heights requiring reciprocal calculations, the FPGA iterative divider (33 cycles per division) processes the entire column pass in under 4,000 divider cycles.
// games/wolf3d/wolf3d.c: Fixed-point DDA column step
void render_column(int col, int32_t ray_dir_x, int32_t ray_dir_y) {
int map_x = (int)(player_x >> FP_SHIFT);
int map_y = (int)(player_y >> FP_SHIFT);
int32_t delta_dist_x = (ray_dir_x == 0) ? 0x7FFFFFFF : abs((FP_ONE << FP_SHIFT) / ray_dir_x);
int32_t delta_dist_y = (ray_dir_y == 0) ? 0x7FFFFFFF : abs((FP_ONE << FP_SHIFT) / ray_dir_y);
int step_x, step_y;
int32_t side_dist_x, side_dist_y;
if (ray_dir_x < 0) {
step_x = -1;
side_dist_x = ((player_x - (map_x << FP_SHIFT)) * delta_dist_x) >> FP_SHIFT;
} else {
step_x = 1;
side_dist_x = ((((map_x + 1) << FP_SHIFT) - player_x) * delta_dist_x) >> FP_SHIFT;
}
if (ray_dir_y < 0) {
step_y = -1;
side_dist_y = ((player_y - (map_y << FP_SHIFT)) * delta_dist_y) >> FP_SHIFT;
} else {
step_y = 1;
side_dist_y = ((((map_y + 1) << FP_SHIFT) - player_y) * delta_dist_y) >> FP_SHIFT;
}
// DDA stepping loop
int hit = 0, side = 0;
while (!hit) {
if (side_dist_x < side_dist_y) {
side_dist_x += delta_dist_x;
map_x += step_x;
side = 0;
} else {
side_dist_y += delta_dist_y;
map_y += step_y;
side = 1;
}
if (world_map[map_y][map_x] > 0) hit = 1;
}
int32_t perp_wall_dist = (side == 0) ?
(side_dist_x - delta_dist_x) : (side_dist_y - delta_dist_y);
int line_height = (int)(((SCREEN_H) << FP_SHIFT) / perp_wall_dist);
draw_wall_slice(col, line_height, world_map[map_y][map_x], side);
}
The rendering loop writes directly to the 64x32 framebuffer. The dual-issue pipeline processes the 64-column raycast, texture mapping, and sprite sorting in less than 400,000 cycles per frame, delivering smooth 3D motion at 45 FPS.
Native C Arcade Classics
Alongside 3D raycasting and CP/M, a collection of native C arcade and puzzle games was written directly against the framebuffer API:
| DOOM Tiny (64x32) | Snake | 2048 | Tetris |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |

Each game implements focused mechanics tuned for the 64x32 display:
- Snake: Circular buffer tail management, collision detection, and autonomous attract-mode AI.
- 2048: Matrix shifting, tile merging logic, and pseudo-random tile spawning.
- Tetris: 4x4 tetromino rotation matrices, line clear detection, and wall kicks.
- Minesweeper & Flappy Bird: High-contrast bitmap sprites, real-time jump physics, and unconstrained board navigation.
The Chip-8 Virtual Machine
The Chip-8 virtual machine is a classic standard in retro computing. The interpreter implements all 35 Chip-8 opcodes: 16 8-bit general-purpose registers (V0 through VF), a 16-bit index register (I), a 16-level call stack, 60 Hz delay and sound timers tied to ticks_ms(), and XOR sprite drawing with collision detection.
The 19 Active Chip-8 Games
| Space Invaders | Pong | Brix | Blinky Pac |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| Tank | Blitz | Missile | UFO |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| Cave | Landing | Airplane | Connect 4 |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| Tic-Tac-Toe | 15 Puzzle | Puzzle | Merlin |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| Hidden | Maze | Wipe Off |
|---|---|---|
![]() | ![]() | ![]() |
The Hex Keypad Mismatch and the Game Vault
During early testing across the full catalog, several Chip-8 ROMs presented usability problems. ROMs like Guess the Number and Kaleidoscope were designed around the hex keypad of the 1977 COSMAC VIP computer (keys 0 through F arranged in a 4x4 matrix). Without explicit on-screen instructions, pressing standard PC keys resulted in zero reaction, confusing gameplay, or unhandled inputs.
To ensure that every title in the active 32-game console is immediately playable:
- Confusing ROMs (Guess, Kaleidoscope), duplicate variants (Pong 2, Vertical Brix), and system test ROMs were moved to the reserve vault in favor of native C arcade games (Minesweeper, Flappy Bird) with clear keyboard layouts.
- All reserve ROMs and extra CP/M adventures (such as ELIZA and Oregon Trail) were placed into
games/vault/along withgames/VAULT.md. If a user wants to customize their 32-game lineup, they can swap any title and rebuild the image withbash games/menu/build.sh.
Complete Console Control Mapping (32 Games)
| Category | Game | Primary In-Game Controls | Escape / Menu |
|---|---|---|---|
| 3D Engines | DOOM | Arrows/WASD move/turn, Ctrl fire, Space use, Shift run, 1-7 weapons | ESC |
| Wolfenstein 3D | Arrows/WASD move/turn, Space fire weapon, M toggle mini-map | ESC | |
| Native Arcade | Minesweeper | Arrows/WASD move cursor, Space uncover tile, F flag mine | ESC |
| Flappy Bird | Space / Up Arrow jump flap | ESC | |
| Snake | Arrows or W/A/S/D to steer snake; touch any key to take over from AI | ESC | |
| 2048 | Arrows or W/A/S/D to slide tiles in 4 directions | ESC | |
| Tetris | Left/Right move, Up/W rotate, Down/S soft drop, Space hard drop | ESC | |
| CP/M 2.2 | Star Trek | 1=Short Range Scan, 2=Long Range Scan, 3=Phasers, 4=Torpedo, 5=Warp, 6=Shields | ESC |
| Ladder | 4=Left, 6=Right, 8=Climb Up, 2=Climb Down | ESC | |
| Hamurabi | Numerical inputs for grain, acres to buy/sell, citizen feeding | ESC | |
| Lunar Lander | Enter retro-rocket fuel burn rate (0-30 lbs/sec) to control descent | ESC | |
| Hunt the Wumpus | 1=Move to adjacent cave, 2=Shoot crooked magic arrow, 3=Quit | ESC | |
| RunCPM Dev Studio | Standard CP/M console: DIR, TYPE, MBASIC, ASM, DDT, PIP | ESC | |
| Chip-8 Virtual | Space Invaders | Q/E move cannon, W fire missile | ESC |
| Pong AI | W paddle up, S paddle down | ESC | |
| Brix (Breakout) | Q move left, E move right | ESC | |
| Tank Arena | W/S/A/D drive and aim, Space fire shell | ESC | |
| Blitz (Bomber) | W drop bomb | ESC | |
| Missile Defense | Q/E move crosshair, W launch interceptor | ESC | |
| UFO Shooter | Q/E move cannon, W fire laser | ESC | |
| Cave Explorer | W thruster up, Q/E steer left/right | ESC | |
| Lunar Descent | W main thrust, Q/E retro-jets | ESC | |
| Airplane | W climb, S dive, Q/E roll | ESC | |
| Connect 4 | Keys 1-7 to select drop column | ESC | |
| Tic-Tac-Toe | Keys 1-9 for 3x3 grid position | ESC | |
| 15 Puzzle | Q/W/E/A/S/D to slide adjacent tile | ESC | |
| Logic Puzzle | Keypad numbers 1-9 | ESC | |
| Merlin (Simon) | Keys 1, 2, 4, 5 for flashing quadrants | ESC | |
| Hidden Pairs | Q/W/E/S move cursor, Space flip card | ESC | |
| Blinky (Pac-Man) | W up, S down, Q left, E right | ESC | |
| Maze Explorer | W/S/A/D navigate labyrinth | ESC | |
| Wipeoff | Q paddle left, E paddle right | ESC |
How to Add, Swap, and Customize Games
Adding a new title or swapping games does not require modifying the CPU or FPGA bitstream.
Modular Game Extension Workflow:
Native C Game:
[ games/mygame/mygame.c ] ---> [ games/menu/build.sh ] ---> [ menu.elf ]
^
| (Register in games[] table)
[ games/menu/menu.c ]
Chip-8 ROM:
[ games/chip8/roms/ROM.ch8 ] -> [ mkmenuwad.py ] ---------> [ menu.blob ]
^
| (Type 6 + ROM Key in games[])
[ games/menu/menu.c ]
CP/M Program:
[ games/cpm/diskfiles/PROG ] -> [ mkdisk.py ] -------------> [ disk.blob ] -> [ menu.blob ]
^
| (Type 5 + Auto-Cmd in games[])
[ games/menu/menu.c ]
Adding or Replacing a Native C Game
Create the game source in games/mygame/mygame.c implementing an entry function void mygame_main(void). Draw directly to the framebuffer array FB[y * 64 + x] and call dump_tiny() after each frame. Input is polled using key_poll(&pressed, &code), checking for code == 27 (ESC) to break out.
Add the compilation step to games/menu/build.sh:
mcc games/mygame/mygame.c games/menu/obj/mygame.o
In games/menu/menu.c, register the game in the games[] table and dispatch switch:
// In games/menu/menu.c:
static const struct game_entry games[] = {
{ "MY GAME", "ARCADE", 9, 0 },
/* ... */
};
// In launch_game():
case 9:
mygame_main();
break;
Adding or Swapping a Chip-8 ROM
Place the .ch8 file into games/chip8/roms/ (for example, games/chip8/roms/PACMAN.ch8). Register the title in games/menu/menu.c:
{ "PAC-MAN", "CHIP-8", 6, "PACMAN" },
Run bash games/menu/build.sh. mkmenuwad.py automatically scans the directory, calculates offsets, and packages the ROM into menu.blob.
Adding or Swapping a CP/M Program
Drop any .COM, .BAS, or data files into games/cpm/diskfiles/. Regenerate the virtual disk image:
python3 games/cpm/mkdisk.py games/cpm/diskfiles games/cpm/disk.blob
Register the title in games/menu/menu.c with the command line string to execute:
{ "MY CPM GAME", "CP/M 2.2", 5, "MBASIC MYGAME" },
Rebuild the menu blob with bash games/menu/build.sh.
Adding a Physical Hardware Display: 0.96" I2C OLED
While viewing games over UART on a laptop screen verified functionality, a true console needs its own physical display. The DE0-Nano was expanded with an on-board 0.96-inch monochrome I2C OLED display (SSD1306 / SH1106).
Physical Pin Connections
The display connects directly to the DE0-Nano JP1 header (GPIO-0):
| OLED Pin | DE0-Nano Pin (JP1) | FPGA Pin | Electrical Spec | Purpose |
|---|---|---|---|---|
VCC | JP1 Pin 29 | N/A | 3.3V Power | Display Power Supply |
GND | JP1 Pin 12 | Board GND | 0V Ground | Shared System Ground |
SCL | JP1 Pin 6 (GPIO_0[2]) | PIN_A3 | 3.3-V LVTTL (Pull-up) | I2C Serial Clock |
SDA | JP1 Pin 8 (GPIO_0[4]) | PIN_B4 | 3.3-V LVTTL (Pull-up) | I2C Serial Data (Bidirectional) |
In Quartus (de0_nano_retro.qsf), weak pull-up resistors were enabled on both PIN_A3 and PIN_B4 to assist the 4.7 kΩ pull-ups present on the physical OLED breakout board:
set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON -to OLED_SCL
set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON -to OLED_SDA
The I2C Physical Layer in RTL
The OLED module uses 7-bit I2C slave address 0x3C (transmitted as 0x78 for writes). I2C uses two bidirectional, open-drain signal lines: SCL (Serial Clock) and SDA (Serial Data). Neither the master nor the slave actively drives the bus high; lines float high via pull-up resistors and are pulled low to assert a logical zero:
// Open-drain physical drive: 1 = high-Z (pull-up), 0 = active low
reg scl_out, sda_out;
assign OLED_SCL = scl_out ? 1'bz : 1'b0;
assign OLED_SDA = sda_out ? 1'bz : 1'b0;
An autonomous I2C controller (fpga/rtl/ssd1306_i2c.v) was written to run on the 50 MHz clock. A clock divider generates a ~312.5 kHz Fast-Mode I2C clock (40 system clocks per quarter-phase):
localparam CLK_DIV = 7'd40;
reg [6:0] clk_cnt;
reg [1:0] i2c_phase;
reg tick_phase;
always @(posedge clk) begin
if (rst) begin
clk_cnt <= 7'd0;
i2c_phase <= 2'd0;
tick_phase <= 1'b0;
end else begin
if (clk_cnt == CLK_DIV - 1) begin
clk_cnt <= 7'd0;
i2c_phase <= i2c_phase + 2'd1;
tick_phase <= 1'b1;
end else begin
clk_cnt <= clk_cnt + 7'd1;
tick_phase <= 1'b0;
end
end
end
The I2C state machine implements the standard 4-phase protocol:
- START Condition: SDA transitions from high to low while SCL remains high (
i2c_phase == 1). - Byte Transmission: 8 data bits shifted out MSB first. SCL is pulled low during data transitions (
i2c_phase == 0) and held high while data is sampled (i2c_phase == 1, 2). - ACK Bit: On the 9th clock cycle, the master releases SDA (
1'bz). The FSM samples ACK and proceeds unconditionally so that disconnecting the display never locks up the memory arbiter. - STOP Condition: SDA transitions from low to high while SCL remains high (
i2c_phase == 2).
Hardware 2x2 Pixel Scaler
The display resolution is 128x64 pixels, organized into 8 vertical pages ($P_0$ through $P_7$). Each page spans 128 columns and covers 8 vertical rows:
SSD1306 / SH1106 Display Memory Map:
Column 0 Column 127
Page 0 (Rows 0-7) [D0..D7] [D0..D7] ... [D0..D7] (128 bytes)
Page 1 (Rows 8-15) [D0..D7] [D0..D7] ... [D0..D7] (128 bytes)
...
Page 7 (Rows 56-63) [D0..D7] [D0..D7] ... [D0..D7] (128 bytes)
Total Memory: 8 pages x 128 columns = 1024 bytes
The console outputs a 64x32 framebuffer (2,048 pixels, thresholded to 1 bit). To fill the 128x64 OLED, the hardware performs an exact 2x2 integer scale.
// Pixel coordinate transformation:
// Console width = 64, OLED width = 128 (col_x = col_idx / 2)
wire [5:0] col_x = col_idx[6:1];
wire [10:0] target_addr0 = {page_idx, 2'b00, col_x}; // Row P*4 + 0
wire [10:0] target_addr1 = {page_idx, 2'b01, col_x}; // Row P*4 + 1
wire [10:0] target_addr2 = {page_idx, 2'b10, col_x}; // Row P*4 + 2
wire [10:0] target_addr3 = {page_idx, 2'b11, col_x}; // Row P*4 + 3
// 2x vertical duplicate: each console row bit is duplicated twice
wire [7:0] oled_scaled_byte = {px3_r, px3_r, px2_r, px2_r, px1_r, px1_r, px0_r, px0_r};
During byte transmission, a pipelined read fetches 4 vertical console rows from the internal framebuffer stream tap. Each row bit is duplicated twice vertically ({px3, px3, px2, px2, px1, px1, px0, px0}), and each column byte is transmitted twice horizontally (col_x = col_idx >> 1), filling the 128x64 panel with crisp pixels.
Console Framebuffer (64x32) OLED Display (128x64)
+-------------------------+ +-----------------------------------+
| Pixel (X, Y) | 2x2 | Pixel (2X, 2Y) Pixel (2X+1, 2Y) |
| | ======> | |
| | Scaling | Pixel (2X, 2Y+1) Pixel (2X+1, 2Y+1)|
+-------------------------+ +-----------------------------------+
Debugging the Display: The Page Addressing Trap
When the initial OLED driver was synthesized and programmed to the board, the display exhibited a physical failure:
At power-up, the OLED lit up with random turquoise-blue static snow and black dots across 7/8 of the screen. When the bootloader finished and a game began running, only the top 8-pixel horizontal band flickered with game graphics, while the remaining 56 vertical rows stayed permanently frozen in static.
Initial Display Failure Mode:
+-------------------------------------------------------------+
| [ Flickering Top 8 Rows (Page 0) ] | <- Only Page 0 updated
| . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * |
| * . * . * . * Uninitialized Power-On Static * . * . * . * . | <- Pages 1-7 frozen
| . * . * . * . * . * (Turquoise Snow / Dots) . * . * . * . * |
+-------------------------------------------------------------+
Diagnosing the Controller Incompatibility
Testing with an oscilloscope and verifying the panel markings revealed the root cause:
The original driver was written assuming standard SSD1306 Horizontal Addressing Mode (0x20 0x00). In Horizontal Mode, streaming 1024 continuous data bytes automatically wraps from column 127 of Page 0 to column 0 of Page 1, advancing through all 8 pages.
However, the physical module mounted on the board was an SH1106 / SSD1315 clone controller. The SH1106 does not implement Horizontal Addressing Mode (0x20) or column/page range registers (0x21, 0x22); it is locked permanently in Page Addressing Mode.
In Page Addressing Mode, when 1024 bytes were streamed in a single burst, the controller wrote the first 128 bytes to Page 0, wrapped around to column 0 of Page 0, and continued overwriting Page 0 repeatedly eight times. Pages 1 through 7 never received page address increments and remained in their random power-on RAM state.
The 7-bit Counter Overflow Bug
When the FSM was updated to send page commands between bursts, another bug appeared: the display froze completely after transmitting Page 0.
Inspecting register declarations in ssd1306_i2c.v revealed that col_idx had been declared as a 7-bit vector (reg [6:0] col_idx). In MODE_PAGE_DATA, after transmitting the 128th column (index 127), the counter incremented to 128. Because a 7-bit register can only represent values from 0 to 127, the addition 7'd127 + 7'd1 overflowed and wrapped silently back to 0. The terminal state check if (col_idx == 8'd128) was never satisfied, trapping the FSM in an infinite loop inside Page 0.
Expanding col_idx to reg [7:0] allowed the value 128 to be captured cleanly, triggering the transition to S_STOP and advancing page_idx.
The Universal Page-by-Page Streaming Fix
The FSM was restructured to enforce explicit Page-by-Page streaming:
// S_ACK_BIT handler: Explicit Page-by-Page FSM
case (op_mode)
MODE_INIT: begin
if (cmd_idx <= INIT_LEN) begin
tx_byte <= get_init_cmd(cmd_idx - 5'd1);
cmd_idx <= cmd_idx + 5'd1;
state <= S_SEND_BYTE;
end else begin
oled_ready <= 1'b1;
state <= S_STOP; // Move to page streaming
end
end
MODE_PAGE_CMD: begin
case (cmd_idx)
5'd0: begin tx_byte <= 8'h00; cmd_idx <= 5'd1; state <= S_SEND_BYTE; end
5'd1: begin tx_byte <= {4'hB, 1'b0, page_idx}; cmd_idx <= 5'd2; state <= S_SEND_BYTE; end // Set Page
5'd2: begin tx_byte <= 8'h00; cmd_idx <= 5'd3; state <= S_SEND_BYTE; end // Lower Col 0
5'd3: begin tx_byte <= 8'h10; cmd_idx <= 5'd4; state <= S_SEND_BYTE; end // Higher Col 0
default: state <= S_STOP;
endcase
end
MODE_PAGE_DATA: begin
if (cmd_idx == 5'd0) begin
tx_byte <= 8'h40; // Data stream control byte
cmd_idx <= 5'd1;
col_idx <= 8'd0;
state <= S_SEND_BYTE;
end else if (col_idx < 8'd128) begin
tx_byte <= oled_scaled_byte;
col_idx <= col_idx + 8'd1;
state <= S_SEND_BYTE;
end else begin
if (page_idx == 3'd7) begin
page_idx <= 3'd0;
oled_frame_cnt <= oled_frame_cnt + 32'd1;
end else begin
page_idx <= page_idx + 3'd1;
end
state <= S_STOP;
end
end
endcase
For every frame, the controller now iterates explicitly through all 8 pages ($P \in [0..7]$):
- Sends
0xB0 | P(Set Page Address). - Sends
0x00and0x10(Reset Column to 0). - Streams 128 bytes of scaled framebuffer data.
At 312.5 kHz Fast-Mode I2C clock rate, transmitting 8 pages of 133 bytes each takes 30.6 milliseconds per frame, delivering a steady 32.6 FPS on the hardware display. Adding ssd1306_i2c.v consumed 320 logic elements and 0 additional M9K blocks, bringing total FPGA utilization to 17,743 LEs (79% of the EP4CE22).
Hardware Verification: Live Dual-Display Operation

System Architecture Summary
+-------------------------------------------------------------------------------+
| DE0-Nano FPGA |
| |
| +-----------------------------------------------------------------------+ |
| | Altera Cyclone IV E EP4CE22 | |
| | | |
| | +--------------------------+ +--------------------------+ | |
| | | Dual-Issue RV32IM CPU | | SDRAM Memory Arbiter | | |
| | | - 50 MHz Gated Clock | <=====> | - 1K-Word I-Cache | | |
| | | - 16-Entry Hybrid Branch| | - 32 MB SDRAM Controller| | |
| | | - 33-Cycle Mul/Div Unit | | - Framebuffer Tap (64x32| | |
| | +--------------------------+ +-------------+------------+ | |
| | | | |
| | v | |
| | +--------------------------+ +--------------------------+ | |
| | | Autonomous I2C Master | | UART TX/RX & FIFO | | |
| | | - SSD1306/SH1106 Driver | | - 921,600 Baud Link | | |
| | | - 2x2 Pixel Scaler | | - Real-time 45 FPS Tiny | | |
| | +-------------+------------+ +-------------+------------+ | |
| +-----------------|------------------------------------|----------------+ |
| | | |
+---------------------|------------------------------------|--------------------+
| I2C (SCL/SDA) | UART (TX/RX)
v v
+-----------------------+ +-------------------+
| 0.96" I2C OLED (128x64| | CP2102 Serial USB |
| Hardware Screen | | to Host PC Viewer |
+-----------------------+ +-------------------+
The system evolved from an isolated DOOM benchmark into an integrated retro gaming console:
- 32-Game Multi-Tier Catalog: 3D raycasting, native arcade games, vintage CP/M software, and 19 Chip-8 classics.
- Dual Display Pipeline: Simultaneous physical 0.96" I2C OLED output at 32.6 FPS and interactive PC viewer at 45 FPS.
- Modular Codebase: Clean vault abstraction for swapping ROMs and building custom game configurations without modifying FPGA RTL.
All design files, RTL modules, games, and build scripts are maintained in the repository:
fpga/rtl/ssd1306_i2c.v: Autonomous I2C controller and 2x2 pixel scaler.fpga/rtl/mem_top_fpga.v: Memory arbiter, I-cache, and framebuffer streaming tap.games/menu/: Unified 32-game launcher and build automation.games/wolf3d/: 3D fixed-point DDA raycaster.games/cpm/: RunCPM virtual environment and disk packing tools.games/vault/: Modular game repository and swap documentation.





























