Loading…
DOOM on Bare-Metal FPGA

DOOM on Bare-Metal FPGA

hardware riscv computer-architecture fpga FPGA DE0-Nano Cyclone IV RISC-V DOOM Hardware Bring-up UART SDRAM Computer Architecture

The core was validated in Verilator with a dual issue in order RV32IM pipeline, a hybrid predictor, a BTB, and a return address stack. A DOOM gameplay workload ran for more than 500 million instructions at a CPI of 0.727 in that simulation. I then moved the design to a Terasic DE0-Nano, where the FPGA implementation added practical constraints around arithmetic, predictor storage, SDRAM latency, serial bandwidth, and the physical board wiring.

Check Previous Post

The board: a Terasic DE0-Nano

The bench starts with the board, and this post means one specific board, not FPGAs in general: a Terasic DE0-Nano, the e-Yantra lab-in-a-box kit documented on their Cyclone IV page, which ships the board and a USB cable and not much else. The silicon at its center is an Altera Cyclone IV E, part number EP4CE22F17C6N, speed grade 6, in a 256-pin FineLine BGA. The device resources that mattered for this build were 22,320 logic elements, 66 M9K embedded RAM blocks, 66 18x18 multipliers, and up to 153 user I/O pins. The later FPGA build had to stay within those logic, memory, and I/O limits.

Terasic DE0-Nano board, front

Terasic DE0-Nano board, back

Around the die, the parts used by this project included 32 MB of SDRAM on a 16 bit bus. The game code, WAD image, heap, and framebuffer all had to fit within that memory. The board also provides a 50 MHz oscillator and an on-board USB-Blaster. The Mini-USB connection powers the board and provides JTAG programming.

The board also provides two debounced pushbuttons, eight green LEDs, and a four-position DIP switch. In this project, the LEDs showed the heartbeat, serial activity, and tester status.

The rest of the board went unused: the ADXL345 accelerometer, the 8-channel 12-bit ADC, the 2 Kb EEPROM, the 64 Mb configuration flash. Bitstreams here loaded volatile over JTAG and were never flashed, so none of those parts were touched.

Expansion is two 40-pin headers, GPIO-0 (JP1) and GPIO-1, carrying 72 user I/O between them plus 5V, two 3.3V pins, and four grounds. This project wires GPIO-0 only; GPIO-1 sits empty, which is why it gets the overview figure and no detail table.

GPIO-0 and GPIO-1 headers: all pins and numbering

Of GPIO-0’s forty pins the project touches three. Pin 2 (FPGA pin D3) takes the serial module’s transmit line, pin 4 (FPGA pin C3) returns receive, and pin 12 is ground; pins 11 and 29 carry 5V and 3.3V for daughter cards and nothing here draws from them. Numbering runs odd down one row and even down the other, as shown in the figure. The pin table also needed one clarification: pin 40 is GPIO, while pins 12 and 30 are grounds. This build used pin 12 as the shared ground for the serial module.

GPIO-0 pin configuration: signal name, FPGA pin, and function

The toolchain

Yosys was still useful for quick synthesis runs and for seeing which parts of the design were taking up space. I had hoped to use an open-source flow for the whole build, but the Cyclone IV support I found stopped short of a complete Verilog-to-bitstream flow. I checked nextpnr and the small EP4CE6 bitstream project too, but neither was something I could use for this board. I ended up using Quartus Prime Lite for synthesis and fitting, TimeQuest for timing, and Quartus Programmer to load the bitstream.

The annoying part was that Quartus had to run on Windows 11. Most of my setup was already on Linux SSD with other tools like OpenLane, ROS, and Vivado and Vitis, and I did not want to disturb that environment for one board by installing Quartus again. I kept the Linux setup and put Quartus on Windows, so the project ended up split between the two systems. It was not ideal, but it was the route that got the DE0-Nano build running.

The first board setup task was getting the USB-Blaster driver working. Quartus Programmer initially showed No Hardware, and Device Manager flagged the USB-Blaster. Pointing the wizard at the driver folder shipped with Quartus 20.1 returned Error 39 for an unverifiable signature. The package used a 2009 kernel driver, which current Windows integrity policy did not accept. I started Windows once with driver signature enforcement disabled and installed it. Quartus Programmer then showed USB-Blaster [USB-0], Auto Detect found the EP4CE22, and the programming path was ready.

With that in place, I started with a small bring-up design rather than the CPU. It divided the 50 MHz clock to a 1 Hz LED heartbeat, echoed bytes over UART, and ran an SDRAM test. This gave me a way to check the board, clock, serial link, USB-Blaster, and memory before adding the larger DOOM design.

The wire: a CP2102 USB to TTL module

The second piece of hardware is a USB to TTL serial module built on the Silicon Labs CP2102: USB 2.0 full speed on one side, 3.3 volt logic level UART on the other, appearing on the laptop as an ordinary COM port. Its role grew through the project until it carried everything off chip: bootloader handshake and game upload, console text, framebuffer frames on the way down, keystrokes on the way up. There is no VGA, no Ethernet, no SD card: display, console, and keyboard all pass through this module, multiplexed onto two signal wires.

Only three of its pins ever connect: TXD, RXD, and ground, crossed to JP1 pins 2 and 4 with ground on pin 12, exactly as the Phase 1 wiring shows. Each side feeds itself, so the module’s power pins stay unconnected, and the 3.3 volt logic meets the FPGA banks directly with no level shifting anywhere.

One table in its datasheet went unread until Phase 2 forced the issue: the baud table. It is enough to say here that the module sets the ceiling the whole link lives under. That number comes up later, when the terminal filled with garbage and forced the issue.

Connections

+-------------------------------------------------------------------------------+
|                               DE0-Nano Board                                  |
|                                                                               |
|   +---------------------+                           +---------------------+   |
|   |   USB-Blaster JTAG  |                           |   ISSI IS42S16160   |   |
|   |  (Config + Power)   |                           |     32 MB SDRAM     |   |
|   +----------+----------+                           +----------+----------+   |
|              |                                                 | 16-bit bus   |
|              v                                                 v              |
|   +-----------------------------------------------------------------------+   |
|   |                      Altera Cyclone IV E EP4CE22                      |   |
|   |                                                                       |   |
|   |   +------------------------+             +------------------------+   |   |
|   |   |  Dual-Issue RV32IM CPU |             |  SDRAM Memory Arbiter  |   |   |
|   |   |  (50 MHz Gated Clock)  | <=========> |  & 1K-word I-Cache     |   |   |
|   |   +------------------------+             +-----------+------------+   |   |
|   |                                                      |                |   |
|   |                                                      v                |   |
|   |                                          +------------------------+   |   |
|   |                                          | UART TX/RX Controller  |   |   |
|   |                                          | & Framebuffer Streamer |   |   |
|   |                                          +-----------+------------+   |   |
|   +------------------------------------------------------|----------------+   |
|                                                          |                    |
+----------------------------------------------------------|--------------------+
                                                           |
                                                           v
                                                 +-------------------+
                                                 |   Silicon Labs    |
                                                 |   CP2102 USB-TTL  |
                                                 +---------+---------+
                                                           |
                                                           v
                                              Laptop Interactive Display
                                              (640x400 Canvas @ COM7)

Results up front

  • Altera Cyclone IV E EP4CE22F17C6 (22,320 logic elements, 66 M9K blocks, 66 18x18 multipliers), 50 MHz single clock domain, glitch free gating through altclkctrl.
  • Fit: 17,423 logic elements (78 percent) and 6,984 flip-flops, down from 29,143 nodes and 23,644 flops that overflowed the device; 54 M9K segments, 12 DSP blocks.
  • IS42S16160 32 MB SDRAM on a 16 bit bus, 4.65 MB game image uploaded in 53 seconds at 921,600 baud with CRC32 verification.
  • The core renders at about 35.7 frames per second internally; the serial wire delivers 160x100 at 5.75 and 320x200 at 1.44.

Phase 1: power, clock, and an echo

The bench was a DE0-Nano, a CP2102 USB to serial module, and a Windows 11 laptop, with two USB cables live at once: Mini-USB to the Nano for power and JTAG, a second cable to the serial module, both through an external hub to keep both connections available during reconfiguration. Three wires joined the boards with power off:

Module pinJP1 pin (GPIO-0)FPGA pinPurpose
TXD2D3Serial into the FPGA (module TX, FPGA RX)
RXD4C3Serial out of the FPGA (FPGA TX, module RX)
GND12Board groundShared reference

The module power pins stayed unconnected.

With the programming path ready, the Phase 1 design was deliberately small: the 50 MHz clock divided to a 1 Hz heartbeat on LED0, a UART echo at 115,200 baud, and an SDRAM tester walking patterns across the four banks. The serial link itself ran on a pair of already proven UART modules, uart_rx and uart_tx: parametrized 8N1 blocks with a clean clock/reset/data interface, verified in loopback simulation before the board arrived, and the same two files Phase 2 reuses with new parameters. The echo is a handful of registers; a received byte parks in echo_data until the transmitter goes idle:

always @(posedge clk) begin
    if (rst) begin
        echo_pend <= 1'b0;
        tx_valid  <= 1'b0;
        tx_data   <= 8'd0;
        echo_data <= 8'd0;
    end else begin
        tx_valid <= 1'b0;
        if (rx_valid) begin
            echo_data <= rx_data;
            echo_pend <= 1'b1;
        end
        if (echo_pend && !tx_busy) begin
            tx_data   <= echo_data;
            tx_valid  <= 1'b1;
            echo_pend <= 1'b0;
        end
    end
end

The same design had passed the simulation checks, including UART loopback at 115,200 and 2 Mbaud with zero errors and the full SDRAM suite against a behavioral IS42S16160 model. On the board, programming the .sof took seconds. LED0 blinked at 1 Hz, the tester finished in about 50 ms with the expected status indications, and PuTTY on COM7 at 115,200 8N1 echoed typed characters while the activity LEDs flashed. Together, these checks covered the board clock, JTAG programming, serial link, and SDRAM.

Phase 2: connecting the core and fitting the FPGA

Before the fitting story, the exact CPU being fitted. This is the downscaled FPGA variant; the simulation numbers follow in brackets where they differ:

  • RV32IM only: 32-bit instructions, PC advances 4 at a time, no compressed, atomic, or fence instructions.
  • Dual-issue in-order core, two slots (slot 0 and slot 1), five pipeline stages: IF, ID, EX, MEM, WB.
  • One ALU and one branch comparator per slot, with full forwarding that includes MEM-stage forwarding for loads and CSR reads.
  • No load-use stall; the only pipeline stalls are the M-unit busy stall and the one-cycle CSR read settle stall.
  • Tournament branch predictor: PC-indexed local, PC-xor-history global, and chooser tables of 2-bit counters, 16 entries each (256 in simulation), 8-bit global history, one prediction per fetch pair on slot 0.
  • 16-entry branch target buffer (256 in simulation) with valid bits and full 32-bit tags and targets, plus a 16-deep return address stack.
  • Branch resolution in EX for both slots with a single-cycle registered redirect.
  • Multiply and divide: MUL single cycle on the DSP blocks; MULH, MULHSU, MULHU, DIV, DIVU, REM, and REMU iterative at 33 cycles each, one unit per slot inside each ALU.
  • Register file: 32 registers of 32 bits, 4 async read ports, 2 clocked write ports, x0 hardwired zero on both reads and writes.
  • Control and status registers: a 16-entry file indexed by the low 4 address bits, synchronous read, CSRRW/CSRRS/CSRRC operations.
  • System calls and stops: ecall and halt retire through writeback to output signals; no interrupt inputs and no privilege modes.
  • Clocking: a single 50 MHz domain; the core clock is the system clock gated through altclkctrl, frozen during SDRAM waits and stalls.
  • Instruction fetch: 1,024-word direct-mapped I-cache in true dual-port M9K, one port per slot, 2-cycle hits.
  • Data: no data cache; two data ports share the SDRAM arbiter at about 15 cycles a transaction.
  • Measured in simulation on the full core: CPI 0.727 over 500 million-plus instructions of real DOOM; the FPGA diet adds about two percent in cycles; steady state about two million cycles per frame.

Phase 2 connects the core to the SDRAM hierarchy inside mem_top_fpga.v: a 1,024 word direct mapped I-cache in dual port M9K with both fetch slots looking up in parallel, 2 cycle hits, an arbiter for data at about 15 cycles a transaction, the framebuffer mapped to the top 64 KB, the MMIO registers at the offsets the port already uses, and a bootloader that owns the SDRAM until the PC finishes the upload. The core runs on a clock gated through an altclkctrl cell so the pipe pauses cleanly on SDRAM latency:

`ifdef FPGA_SYNTH
    altclkctrl #(
        .clock_type("Global Clock"),
        .ena_register_mode("falling edge"),
        .intended_device_family("Cyclone IV E"),
        .lpm_type("altclkctrl")
    ) core_clk_buf (
        .inclk(sys_clk),
        .ena(core_run_d),
        .outclk(core_clk)
    );
`else
    reg core_run_q;
    always @(negedge sys_clk) core_run_q <= core_run_d;
    assign core_clk = sys_clk & core_run_q;
`endif

The first compile failed in elaboration. Quartus refused the ID/EX stage registers with Error 10200 and Error 12152, pointing at if (rst || flush_ex) under always @(posedge clk or posedge rst). Verilator compiles that into sequential C++ without error, but synthesis must map the block to a flip-flop with a single async clear pin, and flush_ex has no edge in the sensitivity list, so the tool inferred latches and failed. Separating reset from flush into branches with identical values produced a clean async clear plus a synchronous mux, zero latches, and the benches confirmed cycle identical behavior.

With elaboration passing, quartus_map ran over 17 minutes with the host pegged before the fitter refused: 29,143 combinational nodes against 22,320 on the device. A Yosys pass over the submodules ranked the consumers, and two structures dominated the area, both invisible in simulation where a * b is one host instruction and tables are free arrays:

Module / BlockCombinational NodesEstimated LEsShare of Design
core_top logic (predictors, forwarding forest, ALU muxes)22,359~12,10042%
mem_top_fpga (SDRAM arbiter, bootloader, cache ctrl)14,643~8,00027%
registers (4-read, 2-write asynchronous register file)6,144~3,30011%
csr_reg (Control and Status Register file)4,080~2,2008%
Core arithmetic (muldiv, SDRAM controller, UARTs)N/A~3,50012%

Each ALU carried three 64 bit multipliers and four 32 bit dividers, doubled across the two issue slots, and the predictor carried three 256 entry tables plus a 256 entry BTB with full tags and targets, written as async read flop arrays costing about 19.5K registers in core_top alone.

Arithmetic was trimmed first. MUL-low, the op the integer heavy render loops depend on, stays single cycle combinational on the embedded multiplier blocks. The seven wide ops moved into muldiv.v, an iterative unit per EX slot, whose header comment describes the protocol:

// Protocol (self-starting, no start port):
//   IDLE: if alu_op is one of the seven M ops, latch a/b and run.
//   RUN:  32 iterations, md_busy high. If alu_op stops being an M op
//         (the pipe flushed ID/EX to a bubble), abort back to IDLE.
//   DONE: md_busy low, result valid combinationally for one cycle. The
//         next state is always IDLE; a back-to-back M op starts from
//         there with the core still holding ID/EX, so no operand gap.
// The core freezes IF/ID/ID/EX/PC while md_busy is high and feeds bubbles
// to EX/MEM, so nothing samples the unit's outputs mid-run.

Shift and add multiply, non-restoring divide on magnitudes with sign fixup, 33 cycles an op. The render math that motivated keeping MUL fast is FixedMul, the fixed point scale used by the column renderer, and the measured cost of the trimming on the full game is about two percent in cycles. Proof came from vectors plus suite: gen_md_vectors.py covers the corners (division by zero, INT_MIN over -1, unsigned wrap) and micro_gen.py emits assembly tests M1 through M7, writeback and forwarding, loops and calls across divides, producer capture, chaining, signed corners, the intra pair M dependency, every one checked to exact register values by check_micro.py.

Even trimmed, the next compile still overflowed, registers stuck near 19.5K in core_top. The Yosys model of core_top with full tables showed 19,623 registers, matching Quartus within 0.3 percent. That match identified the cause: the fitter was synthesizing the full 256 entry predictor and PRED_SMALL was having no effect. The QSF held one combined macro line, and Quartus 20.1 takes only the first token of a VERILOG_MACRO string and silently drops the rest, so the clock buffer showed the first macro active while the predictor stayed at simulation scale:

set_global_assignment -name VERILOG_MACRO "FPGA_SYNTH=1 PRED_SMALL=1"

Splitting the line in two fixed it. Full design registers fell from 23,644 to 6,984, logic to 17,423 elements, and compile time from 17 minutes to 70 seconds:

set_global_assignment -name VERILOG_MACRO "FPGA_SYNTH=1"
set_global_assignment -name VERILOG_MACRO "PRED_SMALL=1"

One synthesis note still needed attention before moving on: Info 17036, nine MSB address nodes removed from the I-cache RAM. inst_addr0 and inst_addr1 were declared and wired into mem_top_fpga but never driven in doom_top, so Quartus grounded them and every fetch would have hit address zero on silicon. Simulation never caught it because the testbench wrapper carried the assigns missing from the FPGA top:

assign inst_addr0 = pc_out;
assign inst_addr1 = pc_out + 32'd4;

To state it plainly: the full simulation core never fit this device. What runs on the board is the downscaled variant, with 16-entry predictor tables in place of 256-entry ones and an iterative M unit in place of wide combinational arithmetic, fitting in 17,423 logic elements where the original needed 29,143 nodes. The game runs identically on it; only the area changed.

Timing: a 34 nanosecond path in a 20 nanosecond cycle

Fitting the device is only half the job; the design still has to meet timing. The first TimeQuest pass reported core clock worst slack -14.5 ns, but the constraint itself was broken: warnings 332174 and 332049 showed the generated clock pattern matching zero pins, leaving core_clk effectively unconstrained. A Tcl walk of the post fit netlist found three clock buffers, the input, the reset synchronizer, and the gated core clock five levels deep, and since one star matches one hierarchy level, only the full path resolves:

create_clock -name sys_clk -period 20.000 [get_ports CLOCK_50]
derive_clock_uncertainty

create_generated_clock -name core_clk -source [get_ports CLOCK_50] \
    -divide_by 1 [get_pins {mem|core_clk_buf|auto_generated|clkctrl1|outclk}]

With the SDC corrected, sys_clk passed clean at about +6.5 and +6.9 ns across corners while core_clk stayed at -14.5 ns, now a real number with total negative slack -19,379 ns.

The top failing paths all showed the same structure: EX and control registers into the PC and fetch: an async CSR read feeding forwarding muxes feeding the ALU branch compare feeding redirect and target muxes into the PC, about 34.5 ns of logic in a 20 ns cycle. The CSR file went synchronous first. The old async read mux sat on the worst path in the design, so the registered port below is the only read left, settling a cycle after the address, with the pipeline holding EX one extra cycle on CSR ops:

// CSR file with a REGISTERED read port.
//
// Timing: the old async read mux sat on the worst path in the design
// (id_ex_csr -> ... -> pc, ~35 ns). Only rdata_q exists now: it settles a
// cycle after the address is presented, and the pipeline holds EX for one
// extra cycle on CSR ops (csr_stall in core_top) so the value is always
// correct when consumed.
module csr_reg (
    input         clk,
    input         rst,
    input         we,
    input  [11:0] addr,
    input  [31:0] wdata,
    input  [2:0]  funct3,
    input  [31:0] rs1_data,
    output reg [31:0] rdata_q
);

Then branch resolution moved from ID to EX for both slots behind a registered redirect, sampled exactly once on the pair’s exit cycle:

// Both slots resolve control in EX; the redirect takes effect a cycle
// later through exr_valid/exr_pc, and the flush is extended by the same
// cycle (see flush_id/flush_ex below). Capture is blocked while EX is
// held so a resolving pair is sampled exactly once, on its exit cycle.
wire ex_redirect = ex_redirect0 || ex_redirect1;
wire [31:0] ex_redirect_pc = ex_redirect0 ? ex_actual_pc0 : ex_actual_pc1;

always @(posedge clk or posedge rst) begin
    if (rst) begin
        exr_valid <= 1'b0;
        exr_pc    <= 32'b0;
    end else if (!pipe_halt && !md_stall && !csr_stall) begin
        exr_valid <= ex_redirect;
        exr_pc    <= ex_redirect_pc;
    end
end

That move had two benefits. The backwards EX to ID forward vanished with it, and with nothing consuming forwarded data in ID anymore, the classic load-use stall fell out of the hazard unit altogether. The unit’s own comment describes the core that remained:

// NOTE: there is no load-use stall in this core. Slot-0 control resolves
// in EX (not ID), so nothing consumes forwarded data in ID anymore and
// every producer, ALU, load (via MEM forward), CSR (via MEM forward),
// is visible to EX consumers in time without stalling. The only stalls
// left are md_stall (M-unit) and csr_stall (CSR read settle), both of
// which freeze the whole front end (IF/ID/EX) directly in core_top.

Worst slack improved from -14.5 ns to -3.998 ns on the slow 1200mV, 85C corner. I also traced the remaining TimeQuest paths through the post-fit netlist instead of treating the slack value in isolation. The top paths ran from EX and control registers through the registered CSR read and forwarding muxes, into the branch comparator, redirect and target muxes, and finally the PC and fetch path. This matched the roughly 34.5 ns path seen in the initial report.

The system clock passed with about 6.5 to 6.9 ns of positive slack, and hold, recovery, removal, and pulse-width checks passed across the reported corners. The remaining setup issue was on the gated core clock at the slow corner, where total negative slack was reported as about -19,379 ns. The board was tested at nominal room conditions and the design operated there. Timing closure across all specified corners remains a separate task.

The frame 0 hang

With the retimed core the game booted, printed its headers, rendered frame 0, and hung. A Python cycle tracer sampling the pipe every edge plus a full SDRAM dump at the hang pointed at the cause: the game timestamp lasttime read -4,203 ms where the reference read 134. The tic computation produced nonsense and the game never got past the frame 0 wipe.

Tracing back from the corrupted timestamp led to DG_GetTicksMs, a load into a multiply-high into a shift, the exact sequence the game runs to turn cycle counts into milliseconds. The tracer showed the mechanism: the load and the mulhu issued as a pair, the dependency squashed slot 1 for replay, and on the same edge an older M op released. The old flush term cleared ID/EX and discarded slot 0 while the replay PC only re-fetched slot 1, so the consumer read a stale register and the timestamp came out corrupted:

// before: release flushes even across a squash, dropping slot 0
assign flush_ex = hz_flush_ex || md_release;

The replacement qualifies the release with the squash, so ID/EX advances slot 0 in exactly that case while redirect flushes still win:

// NOTE (squash/release fix): ID/EX must still ADVANCE slot 0 when a
// squash coincides with md_release. Flushing here drops the slot-0
// instr (it never executes and the replay PC only re-fetches slot 1),
// so its rd goes stale for the consumer. Redirect flushes still win.
wire flush_ex_idex = hz_flush_ex || exr_valid || (md_release && !squash_s1);

The regression test that captures this coincidence is nine instructions. A divide stalls the pipe, a load plus add pair arrives as the next pair and squashes at the release edge, and the test demands the load survive with exact values:

elif which=='M15':  # squash@release REGRESSION: divu stalls, Q=(lw,add) squashes at release; lw must survive
    dmem=[0]*2048; dmem[0x100]=1000
    prog=li(5,100)+li(6,7)+li(27,0x400)+[nop()]       # idx0-3 (pad: divu must be even)
    prog+=[divu(7,5,6),nop()]                          # idx4-5: M1 pair (divu slot0)
    prog+=[lw(8,27,0),add(9,8,6)]                      # idx7-8: Q pair, squashes at release
    prog+=[ecall()]                                    # idx9
    emit(prog,[(7,14),(8,1000),(9,1007)])

Tellingly, the pre fix core fails it with the exact drop signature, x8 reading 0 for 1000 and x9 reading 7 for 1007. The hazard predated the retiming and went unnoticed in simulation by timing luck; DOOM just happened to be the program that lined up the coincidence. After the change the game runs ten frames end to end at a steady two million cycles a frame, frame 0 pixel identical to the reference still, the full instruction suite matching the reference 46 for 46 with identical halts, and all six C programs halting identically. Heap level memory comparison answered the last question the long runs raised: 156 words differed, and every one traced to thinker state pointers and a small time skew between the runs, with the state records themselves byte identical and the fixed run simply further ahead in tick AI, which means the game was advancing normally rather than the core misbehaving.

The handoff that failed on six ports

The retimed core had to move from the verification tree to the Windows Quartus tree, and the first attempt moved one file. Quartus reported six Error 12002s, one per new port the retiming batch had added:

Error (12002): Port "csr_stall" does not exist in macrofunction "hz"
Error (12002): Port "rdata_q" does not exist in macrofunction "csr_file"

plus s0_ex_redirect, s0_id_csr_any, s0_id_csr_read, and s1_id_csr_any. The receiving tree predated the batch: the new core_top was talking to an old hazard and an old csr_reg. Those ports are the new signals the retiming added: the redirect out of EX, the CSR settle handshake, the registered read:

module hazard (
    ...
    input         s0_ex_redirect,
    ...
    input         s0_id_csr_read,
    input         s0_id_csr_any,
    input         s1_id_csr_any,
    ...
    input         csr_stall,
    ...

From then on the 12 core files plus defines.v moved as one atomic package, never a single file. Compilation resumed with zero errors at about 16K logic cells, 54 RAM segments, and 12 DSP blocks, and the timing report read exactly as expected from the retiming chapter: system clock passing, core clock short on the slow corners, hold clean everywhere. The bitstream was programmed into the board.

The board that answered in garbage

Programming succeeded, but the uploader got no reply. The PC side looked fine: pyserial installed, COM7 present, both files on disk, and info reporting a clean layout:

ELF: doom\doom.elf
  load @0x00000000 len  0xa626c (664.6 KB)
WAD: doom\freedoom1.wad (27.46 MB) -> @0x00d26270
total upload 28.11 MB, ~147 s at 2000000 baud
layout OK: everything inside 64 MB, clear of the fb reserve

But upload got no answer to its handshake:

COM7 @ 2000000: waiting for bootloader (HELLO)...
no bootloader answer (is the bitstream running?)

Two symptoms mattered. LEDs 0 and 1 were blinking in turn, heartbeat plus transmit activity, which on this top means the bootloader is running and sending its unsolicited ready beacon every second. And a raw terminal at 2 Mbaud showed this, repeating:

--- transcript (type to tap keys, Ctrl-C quits) ---
P¶�Z�P¶�Z�P¶�Z�P¶�Z�P¶�Z�P¶�Z�P¶�Z�

A repeating roughly six byte pattern where BLRDY1, six bytes, belongs. The FPGA was running and transmitting; the bytes were corrupted between the pin and the PC. That split the fault in half: not the bitstream, not the wiring continuity, but the sampling itself, baud accuracy or signal integrity at 2 Mbaud.

Attention turned to the adapter next. Device Manager named it a classic CP2102, VID 10C4 and PID EA60, and the datasheet gave the answer: the part divides an internal 48 MHz clock with a hard ceiling of 921,600 baud. Asking Windows for 2 Mbaud aliased to an unsupported divider, so the two ends never agreed on a bit period and every byte framed wrong. The original 2 Mbaud plan had been written against the assumption that the module could do it, and it could not.

Both ends retuned to 921,600. The receiver is parametrized oversampling, each bit sampled at its middle after a two flop synchronizer:

module uart_rx #(
    parameter CLK_FREQ = 50_000_000,
    parameter BAUD     = 115_200,
    parameter OVR      = 16,                    // oversampling ratio
    parameter DIVT     = CLK_FREQ / BAUD / OVR  // clocks per tick
) (

With oversample 6 the ideal divider is 50,000,000 over 921,600 times 6, about 9.042, so integer 9 gives exactly 54 system clocks per bit:

$$Divider = \frac{50,000,000}{921,600 \times 6} = \frac{50,000,000}{5,529,600} \approx 9.042$$$$ClocksPerBit = 9 \times 6 = 54\ system\ cycles$$$$Synthesized = \frac{50,000,000}{54} \approx 925,926\ baud$$

That sits 0.47 percent above nominal, and the CP2102 side synthesizes $48,000,000 / 52 \approx 923,077\ baud$, so the ends disagree by 0.31 percent, inside the usual 2 percent budget for 8N1. The transmitter needed no new logic; it uses the same 54 clocks per bit from its own divisor. One recompile later the terminal showed clean text: HELLO answered by BLRDY1, the six bytes landing in order:

3'd0: begin   // BLRDY1
    send_len = 3'd6;
    case (b_sidx)
        3'd0: send_byte = "B";
        3'd1: send_byte = "L";
        3'd2: send_byte = "R";
        3'd3: send_byte = "D";
        3'd4: send_byte = "Y";
        default: send_byte = "1";
    endcase
end

Ten bytes of header

The handshake finally green, the next upload printed its banner and froze. The progress bar never drew a single block; within two seconds the script gave up on the very first chunk:

lost ack at offset 0

Failing at offset zero means the transfer did not degrade, it never started, and the natural suspect is the bootloader itself, crashed or wedged on the first bytes. But the bootloader had just answered the handshake, and the handshake exercises the same receive path, the same FIFO, the same transmitter the dots would use. So the receive path worked. What, then, was the FPGA waiting for?

Reading the two sides against each other showed the cause. The bootloader emits its dot per 4,096 payload bytes retired into SDRAM, counted on write acknowledges as words land in memory:

b_cnt   <= b_cnt - 32'd4;
b_total <= b_total + 32'd4;
...
end else if (((b_total + 32'd4) & 32'h00000FFF)
        == 32'd0) begin
    b_msg <= 3'd1; b_sidx <= 3'd0;   // dot
    b_send_next <= B_DATA;
    b_state <= B_SEND;
end

The host, though, counted wire bytes. It packed headers and payload into one blob, cut it into 4,096 byte slices, and demanded a dot per slice. The blob opens with a 10 byte segment header, a 2 byte count plus address and length, so the first slice carried 10 bytes of framing and only 4,086 bytes of payload. The counter sat at 4,086 against a 4,096 threshold, the dot never fired, and the host sat waiting for an acknowledgement the hardware was not scheduled to send, holding back the very next slice that contained the missing 10 bytes. A deadlock on chunk zero, and every boundary after it would have skewed the same way, since headers never touch the counter at all.

The uploader as it stands today keeps framing and payload on separate tracks. Headers go out as their own writes, and the loop measures only payload against the dot interval, asking for the dot exactly where the hardware sends it:

ser.write(struct.pack('<H', len(loads)))
all_segs = list(loads) + [(wad_addr, wad_pad)]
for addr, data in all_segs:
    ser.write(struct.pack('<II', addr, len(data)))
    data_off = 0
    while data_off < len(data):
        rem_to_dot = ACK_EVERY - (sent_payload % ACK_EVERY)
        chunk_size = min(len(data) - data_off, rem_to_dot)
        ser.write(data[data_off:data_off + chunk_size])
        data_off += chunk_size
        sent_payload += chunk_size
        if sent_payload % ACK_EVERY == 0:
            ack = ser.read(1)
            if ack != b'.':
                raise SystemExit(f'lost ack at payload offset {sent_payload} (got {ack!r})')

The same pass also hardened the handshake: the input buffer is flushed before every HELLO, so a stale beacon from the previous second can never be mistaken for an answer to this one:

for _ in range(30):
    ser.reset_input_buffer()
    ser.write(b'HELLO')
    if ser.read(6) == b'BLRDY1':
        break

With the boundaries lined up the dots streamed and the progress bar ran for the first time. It ran all the way through the executable, reached the WAD image, and halted on three new letters: RNG.

That failure was more frustrating, because the layout checker had passed this exact image. info had printed layout OK back when nothing worked, and the overflow checker had approved an image the hardware now refused. The two sides disagreed about the size of memory. The checker measures against 64 MB, a window inherited from the simulation data memory:

SDRAM_BYTES = 64 * 1024 * 1024
FB_RESERVE_LO = SDRAM_BYTES - 64 * 1024   # top 64 KB holds the framebuffer

while the hardware guard measures against the 32 MB device on the board, word aligned and clear of the framebuffer reserve:

wire [32:0] b_end33 = {1'b0, b_addr_r} + {1'b0, b_len};
wire b_range_ok = (b_addr_r[1:0] == 2'b0) && (b_len[1:0] == 2'b0)
    && (b_end33 <= 33'h02000000)
    && !(b_addr_r < FB_BYTE_TOP && b_end33[31:0] > FB_BYTE_LO);

The Freedoom image loads at 0x00d26270 and runs 28.8 MB, ending at 0x0289C528, about 42.6 MB, roughly 9 MB past the IS42S16160, four banks of four million 16 bit words. The checker saw 42.6 inside 64 and approved; the guard saw it past 32 and aborted. Both computations were correct; they just assumed different memory sizes.

Swapping to the shareware doom1.wad (4,196,020 bytes, MD5 f0cefca49926d00903cf57551d901abe) brought the whole package, ELF at zero, WAD blob at _wad_start, heap above, framebuffer in the top 64 KB, to 4.65 MB. The full upload takes 53 seconds at 921,600 baud with the CRC echoing OK, leaving about 27 MB of heap headroom, and the uploader can now exit entirely: a no-term flag exits after the CRC match instead of opening the terminal, freeing COM7 for the viewer that takes the port next. DOOM booted into E1M1 with console tics streaming over the wire.

Video over a serial wire

Running but headless: the game alive, progress visible only as tic lines scrolling past. A VGA DAC board would have solved that in an afternoon, but no DAC was on the desk, and the serial wire already on the bench was the obvious candidate. DOOM renders into a contiguous 64 KB buffer of palette indices. What if the frames just came down the wire?

The first thing to work out was what 64,000 bytes at 92,160 bytes a 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. Let the core run while the UART drains and every frame injects 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 but the game logic would break.

So the frame commit freezes the game clock for the transfer. When the engine writes MM_DUMP, the memory system drops the core clock enable, and the game clock divides the gated cycle counter:

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, sound timers, all frozen for the transfer, continuing exactly where they stopped.

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:

// fifo_sync.v: single-clock sync FIFO, block-RAM friendly.
module fifo_sync #(
    parameter WIDTH = 8,
    parameter DEPTH = 4096,
    parameter AW = $clog2(DEPTH)
) (

Bursts outrun the wire by orders of magnitude, so a watermark paces the readout, pausing burst reads before any overflow while the UART drains the FIFO at line rate:

wire fifo_has_space = (fifo_count < 13'd4090);

Next is the framing problem. 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: 4 magic bytes (0x55, 0xAA, 0x5A, 0xA5), a mode byte, a 16 bit little endian frame number, a 16 bit little endian width, and an 8 bit height. The viewer scans the incoming bytes 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 3 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 4 bytes of every word on all 200 rows, 64,000 bytes. 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.

Input travels the same wire back, and the first movement test ran the player into the nearest wall and kept him there, stride animation looping, ignoring every further key. The cause was easy to find: 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 it 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)
        # Send 0xF0 release prefix followed by 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:

12'h014: mmio_read = {key_valid, key_rel, 22'd0, key_reg};
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;
        dump_half <= 1'b1; dump_enable <= 1'b1;
    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 if (rx_data == 8'hFC) begin
            dump_half <= 1'b0;    // 320x200 full resolution command
        end else if (rx_data == 8'hFD) begin
            dump_half <= 1'b1;    // 160x100 fast mode command
        end else if (rx_data == 8'hFE) begin
            dump_enable <= ~dump_enable;
        end else begin
            key_reg <= rx_data;
            key_rel <= key_rel_prefix;
            key_valid <= 1'b1;
            key_rel_prefix <= 1'b0;
        end
    end
end

0xFC, 0xFD, and 0xFE switch full and fast resolution and pause the stream live, and the viewer sends one on startup to set the opening mode. The guest side of the whole protocol is three lines. Bit 31 says a key waits, bit 30 says it is a release, the low byte is the code:

int DG_GetKey(int *pressed, unsigned char *key)
{
    uint32_t k = MM_KEY;
    if (!(k & 0x80000000u)) return 0;      /* nothing new */
    *pressed = (k & 0x40000000u) ? 0 : 1;  /* bit30 = release */
    *key = (unsigned char)(k & 0xFFu);
    MM_KEY = 0u;                           /* ack clears key_valid */
    return 1;
}

One mapping detail matters here and 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 would land in the wrong slots and the game would ignore them.

The viewer itself 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 instead of falling further behind:

if self.frame_queue.full():
    try:
        self.frame_queue.get_nowait()
    except queue.Empty:
        pass
self.frame_queue.put_nowait(frame_data)

At startup it opens the WAD, walks the lump directory to PLAYPAL, and reads out the authentic 256 color table the DOS release used:

if name_str == "PLAYPAL":
    raw_pal = data[filepos : filepos + 768]
    palette = np.frombuffer(raw_pal, dtype=np.uint8).reshape((256, 3))

Each frame maps its indices through that palette and upscales nearest neighbor into the 640x400 window, with a measured frames per second figure in the title bar and the controls printed underneath.

ActionLaptop KeyDOOM Keycode
Walk ForwardUp Arrow / W0xAD
Walk BackwardDown Arrow / S0xAF
Turn LeftLeft Arrow / A0xAC
Turn RightRight Arrow / D0xAE
Fire WeaponLeft Ctrl / Right Ctrl0xA3
Open Doors / UseSpace0xA2
Run / SpeedShift0xB6
Strafe ModifierAlt0xB8
Weapon SelectionKeys 1 through 70x31 to 0x37
Enter / Menu SelectEnter13
Game Menu / EscapeEscape27
Automap ToggleTab9
Full Resolution (320x200)F10xFC command
Fast Mode (160x100)F20xFD command
Pause / Resume StreamF30xFE command

Why the display tops out at six frames per second

Five frames per second is not fast enough to feel smooth, and this section will not pretend otherwise. The interesting question is where, exactly, the time goes, because the profiler and the wire disagree, and only one of them limits the game.

Profiling puts a typical frame’s render work near 1.4 million cycles:

$$T_{render} = \frac{1,400,000\ cycles}{50,000,000\ cycles/second} = 0.028\ seconds = 28\ ms$$$$Internal\ Render\ Rate = \frac{1}{0.028\ s} \approx 35.7\ FPS$$

The core computes the scene in 28 milliseconds. Then it stays frozen while the wire moves the pixels. At 921,600 baud with 8N1 framing, every byte costs 10 bits on the wire:

$$Bandwidth = \frac{921,600\ bits/second}{10\ bits/byte} = 92,160\ bytes/second\;(\approx 90\ KB/s)$$

Full 320x200 carries 64,000 bytes per frame:

$$Transmission\ Time = \frac{64,000\ bytes}{92,160\ bytes/second} \approx 0.694\ seconds$$$$Max\ Throughput = \frac{1}{0.694\ s} \approx \mathbf{1.44\ FPS}$$

Fast 160x100 carries 16,000 bytes per frame:

$$Transmission\ Time = \frac{16,000\ bytes}{92,160\ bytes/second} \approx 0.174\ seconds$$$$Max\ Throughput = \frac{1}{0.174\ s} \approx \mathbf{5.75\ FPS}$$

So the breakdown is 28 milliseconds of compute followed by 174 milliseconds of wire, per frame, in fast mode. The observed rates land on those lines, which confirms the model matches reality. A parallel VGA DAC at a 25.175 MHz pixel clock would do 60 frames per second; a single async pin is limited to what a single async pin can do, and the frame rate is set by the wire, not the core.

Live visual verification

Frames captured directly from the DE0-Nano running live on the desk.

Fast mode (160x100 at about 5.8 FPS), sub-sampled in both dimensions for responsive continuous play:

DOOM E1M1 rendered on DE0-Nano FPGA in 160x100 fast mode

High resolution mode (320x200 at about 1.4 FPS), full native Mode 13h with status bar numbers and texture detail:

DOOM E1M1 rendered on DE0-Nano FPGA in 320x200 full resolution

Terminal Screenshot

Terminal SS

Making the board a standalone game

The current setup uses the laptop for several parts of the system. The USB-Blaster loads the FPGA design, the CP2102 uploads the ELF and WAD, and the same serial link carries console output, keyboard input, and framebuffer data. A standalone version would move those functions onto the board so that, after programming, it could run from its own power and connect directly to a display and controls.

Booting without the laptop

Programming the FPGA over JTAG is only part of that change. The current bitstream is loaded into volatile configuration memory, and the game image is copied into SDRAM by the serial bootloader. SDRAM contents are lost when the board is powered down, so a standalone version needs both the FPGA configuration and the game image to come from local non-volatile storage.

The DE0-Nano has a 64 Mb configuration flash. The bitstream can be stored there so the FPGA configures itself at power-up. In Quartus, that means converting the .sof into the appropriate serial-flash image and programming the configuration device, rather than loading only the .sof into volatile FPGA configuration memory. The 4.65 MB shareware game image could also fit in the remaining space in principle, but the actual bitstream size and flash layout would need to be checked. The bootloader would then need a flash read path to copy the ELF and WAD into SDRAM before releasing the CPU. The configuration flash is not automatically available as a normal CPU memory window just because it contains the bitstream, so that read interface is part of the standalone work.

A separate SPI flash or an SD-card interface would be another option. That would keep the FPGA bitstream in the configuration flash and place the game files in storage intended for application data. For this board, the simplest self-contained arrangement is likely configuration flash for the bitstream and game image, provided the capacity and flash interface are checked first.

Replacing the serial display with VGA

The serial framebuffer path is useful for bring-up, but a physical game would be easier to use with a local display controller. A VGA adapter could be connected to GPIO-0 or GPIO-1 through a small resistor DAC or a VGA add-on board. The controller would generate RGB, HSYNC, and VSYNC, with a PLL providing a pixel clock close to 25.175 MHz for 640x480 at 60 Hz.

The existing framebuffer is 320x200 bytes of palette indices. A simple output mode would scale each pixel by two in both directions, producing a 640x400 image with black borders in a 640x480 frame. The 160x100 mode could use four-pixel scaling if a lower memory bandwidth mode is useful. The VGA path would read each palette index, look up its RGB value from PLAYPAL, and send the resulting colour to the DAC. A 3:3:2 digital RGB output or a small external resistor ladder would be enough for a basic version.

The display reader and the CPU would access the framebuffer at the same time, so the memory path would need an additional arbitration point. A line buffer in M9K memory would let the display controller read a line in advance while the CPU continues rendering into SDRAM. A double buffer would give cleaner frame changes: the CPU renders into one buffer, then the display controller switches to it at vertical blank. For an initial version, a single framebuffer with a swap at the end of a frame would be simpler, although it could show tearing during updates.

With a local VGA reader, the CPU would not need to stop while a frame is transmitted over UART. The game clock could continue normally, and the serial stream could be kept for debugging rather than used as the main display path.

Buttons and controller input

The four onboard pushbuttons are enough for a small test interface, but they do not provide a comfortable DOOM control layout by themselves. A more useful controller would provide at least forward, backward, left, right, fire, use, and run inputs. A small GPIO button board, a PS/2 keyboard interface, or a simple external microcontroller would all be reasonable options. A USB gamepad is possible too, but it needs a USB host interface and a suitable protocol implementation, which is more work than a GPIO or PS/2 input path.

Each button input needs a synchronizer, debounce logic, and held-state tracking. At 50 MHz, a debounce interval in the low-millisecond range is enough for ordinary pushbuttons. The input block should generate a press event and a release event, then hold the current state so that a button can remain active across several game tics. A small event FIFO is useful because two buttons can change close together and the CPU should not lose one while it is polling the key register.

The current guest interface already has the key values needed for a mapper:

  • Forward: 0xAD
  • Backward: 0xAF
  • Turn left: 0xAC
  • Turn right: 0xAE
  • Fire: 0xA3
  • Use: 0xA2
  • Run: 0xB6
  • Strafe modifier: 0xB8
  • Weapon keys: 0x31 through 0x37

The mapper should produce these DOOM key values rather than forwarding PC scan codes or terminal key codes. With direct GPIO buttons, the FPGA can translate a button number to a DOOM key value and place the result in the same key FIFO that DG_GetKey reads. The FIFO entry can contain the key byte and a pressed or released bit.

The 0xF0 release prefix used by the current serial viewer is only a transport convention. A direct hardware controller does not need to send that byte. It can set the release bit directly. If a small microcontroller is used as the controller, it can send packets such as key value plus pressed state over a 3.3 volt UART or SPI connection, and the FPGA can convert those packets into the internal FIFO format.

Powering it from a battery

Once the bitstream and game image are stored locally, the board can be started without the laptop. The practical supply is a regulated 5 V battery pack, a USB power bank, or a single-cell battery followed by a 5 V boost converter connected to the board’s intended power input. A raw battery connection should not be made to the FPGA’s 3.3 V rail or to an arbitrary GPIO pin. The supply should have enough current capacity for the FPGA, SDRAM, display interface, and controller, with some margin for startup and activity.

A power switch can sit between the battery pack and the board input. If the VGA adapter or controller is powered from the same pack, use the appropriate regulated voltage for each device and connect their grounds together. The board’s actual current draw should be measured at the intended clock and workload before estimating battery life. Once the board has configured from flash and copied the game image into SDRAM, the USB-Blaster and CP2102 can be disconnected.

What happens to the CP2102

The CP2102 is not required for the standalone display or input path. It can be unplugged after the FPGA has been configured and the game image has been loaded from local storage. The cleanest arrangement is to remove its TX, RX, and ground connections from the board and keep the module separate for development.

It can also remain as an optional debug port. In that case, the FPGA TX line connects to CP2102 RX, FPGA RX connects to CP2102 TX, and the two systems share a signal ground. The CP2102 power pins should remain unconnected to the FPGA power rail. If the board is running from a battery while the CP2102 is attached to a computer, the two power paths must be kept separate so the USB connection does not back-power the board or conflict with the battery supply.

For an external controller, the CP2102 is usually the wrong permanent interface because it still depends on a host computer. A GPIO, PS/2, SPI, or UART connection to a small controller powered from the same regulated supply is a better standalone input path. The input device and the FPGA should share a ground, and the signal levels should match the FPGA bank voltage.

Choosing between RV32IM and a CPU-free design

For this project, RV32IM is not excessive if the goal is to run the existing doomgeneric code. The CPU handles the game loop, WAD access, BSP traversal, collision checks, thinker updates, weapon state, and renderer. It also makes it possible to change the game in C, load other levels, and reuse the same platform code. The M extension is useful for the fixed-point and geometry calculations, although the current FPGA build uses an iterative unit for most M operations to reduce area.

A CPU-free version is possible, but it would be a different project. It would replace the software engine with hardware blocks for the game tick, player state, input handling, collision, map access, texture and sprite storage, rendering, and framebuffer output. For a fixed E1M1 demo, the map and assets could be stored in ROM or flash, and a specialised raycaster or column renderer could draw directly into the framebuffer. That design could be smaller and more deterministic for one fixed game scenario, but changes to game logic, levels, weapons, or enemies would require changes to the hardware implementation.

The important distinction is that removing the CPU does not leave the same DOOM engine intact. It changes the system from a general-purpose processor running DOOM software into a hardware implementation of a particular game. A CPU is the more practical choice for running the existing source. A CPU-free design makes more sense for a fixed hardware demo where the game behaviour and content are intentionally limited.

A smaller middle ground would keep the RV32IM core, add the VGA controller and local input mapper, and move only the game image into flash. That would remove the serial display bottleneck without requiring a complete hardware rewrite of the game engine.

Summary: the optimisations that made it fit

Neither the core nor the game on the board is the full original. The simulation core needed 29,143 nodes against 22,320 cells, and the Freedoom image needed about 42.6 MB against 32 MB of SDRAM, so both were downscaled to fit: a smaller core running a smaller game, with every downscale verified to change nothing the game computes.

  • Predictor tables cut from 256 entries to 16 (PRED_SMALL): full design registers fell from 23,644 to 6,984 and logic to 17,423 elements (78 percent of the device).
  • Wide arithmetic replaced by an iterative M unit: MUL-low stays single cycle on the DSP blocks while the other seven M ops run 33 cycles each, at a measured cost of about two percent in game cycles.
  • CSR file made synchronous and branch resolution moved from ID to EX: worst slack improved from -14.5 ns to -3.998 ns on the slow corner, and the load-use stall dropped out of the hazard unit.
  • One combined VERILOG_MACRO line split in two so PRED_SMALL takes effect: compile time fell from 17 minutes to 70 seconds.
  • Freedoom (27.46 MB WAD, 28.11 MB total image) replaced by the shareware doom1.wad (4,196,020 bytes, 4.65 MB total): the upload fits the 32 MB SDRAM with about 27 MB of heap headroom.
  • Serial link retuned from an unsupported 2 Mbaud to 921,600 with ack chunking aligned to payload bytes: the full image uploads in 53 seconds with CRC32 verification.
  • Display done over the same wire with the game clock frozen during transfer: 10 byte frame headers, 160x100 fast and 320x200 full modes, and a break protocol for key releases.

Frame 0 stays pixel identical to the reference, the instruction suite matches 46 for 46, and the M1 to M18 micros all pass, so the downscaled system computes the same game.

Conclusion

In simulation, wide arithmetic, predictor tables, memory latency, and the serial link all cost nothing. On the board, each had to be solved as hardware: a 33 cycle M unit, 16 entry predictor tables, a retimed pipeline, a 921,600 baud link with aligned framing, a smaller WAD, and a display path that freezes the game clock during transfer. The whole core fits an entry level Cyclone IV and plays real game code with nothing but a USB serial cable for display, console, and input. Timing remains open on the slow corner; nominal silicon carries it on the bench.

Files

  • fpga/rtl/bringup_top.v, uart_tx.v, uart_rx.v, sdram_ctrl.v, sdram_tester.v: phase 1 top, the 8N1 serial pair, the SDRAM controller and its self test.
  • fpga/rtl/doom_top.v, mem_top_fpga.v, fifo_sync.v: phase 2 top, the SDRAM backed memory with gated clock, I-cache, bootloader, MMIO, framebuffer streamer, and key decoder, plus the 4 KB UART FIFO.
  • fpga/quartus/de0_nano_doom.qpf/.qsf/doom.sdc: device, pins, file list and timing for the EP4CE22 build.
  • fpga/uploader.py: info, upload and term at 921,600 baud with payload aligned chunking, CRC verified boot, and a no-term handoff that frees the port for the viewer.
  • fpga/doom_viewer.py: threaded serial demux, PLAYPAL render into a 640x400 window, key capture with break codes and mode commands.
  • fpga/tb, run_tb.sh, run_doom.sh, mkimage.py: UART, SDRAM and boot benches, the two frame equivalence check and the preload image builder.
  • rtl/core: the muldiv diet unit, synchronous CSR with settle stalls, retimed EX hazards, the squash/release fix, and the reset/flush branch split for Quartus.
  • micro_gen.py, check_micro.py, tb/gen_md_vectors.py: the M1 to M18 assembly micros and their checkers.