Close

The Verilator Prototype in Simulation

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

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

mjagadeesh97M_Jagadeesh97 • 2 hours ago•0 Comments

DOOM running against the simulated board, before any hardware existed.

The core boots an ELF from a hex file and prints cycle counts. It has no display, no input and no clock. DOOM needs all three. There was no FPGA on the desk at this point, so the decision was to prototype the board in Verilator first, and that decision shaped everything that came after.

The method: every component the board would carry gets a testbench stand-in that presents the same interface to the core, so the guest cannot tell which world it is in.

// tb_doom.v: the oscillator and reset the board would provide
clk = 0;
forever #5 clk = ~clk;

initial begin
    rst = 1;
    #20;
    rst = 0;
end

Step 1 was the display, because without it there was no way to see anything. The port defines CMAP256 so the game renders one byte per pixel, and the resolution is left at the native 320x200. DG_DrawFrame copies the 64,000-byte screen into the framebuffer window and stores once to the commit register at 0x028. The bridge watches that store on either issue port and dumps the framebuffer to a P5 PGM file stamped with the exact cycle and retired instruction counts. The guest side of the entire display path is one line:

void DG_DrawFrame(void)
{
    MM_DUMP = 1u;   /* one MMIO write, one frame_<n>.pgm on the host */
}

An offline tool pairs the PGM bytes with PLAYPAL from the WAD and produces the PNG stills and GIFs in these logs. The first still, the E1M1 view with the status bar, was the proof that the render path worked end to end.

Step 2 was the WAD, and it produced the most expensive bug of the entire project. The IWAD is linked into data memory as a blob with the heap below it. The first boot died with W_GetNumForName: PNAMES not found even though the bytes were demonstrably present.

The cause was the data memory index slice:

// data_mem.v: the index slice is clog2(W) bits wide, so the
// reachable window is exactly 2*W bytes and nothing more.
wire [$clog2(`DATA_MEM_WORDS)-1:0] idx0 =
        offset0[$clog2(`DATA_MEM_WORDS)+1:2];

With a 40 MB heap, the WAD's lump directory landed at 0x04490B34, past the 64 MiB window, and silently wrapped to an alias that read zeroes. Nothing faulted; the bytes were simply not there. The fix is a heap size in the linker script that keeps the WAD end inside the window, plus a build-time check in doom/build.sh that fails the build if _wad_end ever crosses 0x04000000 again. That check later saved the same mistake in a different form on the board.

Step 3 was time. DOOM asks the platform for milliseconds, and the answer comes from the cycle counter, divided by the measured cycles per millisecond. The port runs in singletics mode so exactly one tic elapses per rendered frame, which keeps the frame stream, the tic stream and the key schedule in a fixed relationship: one captured frame is one tic, whether the counter is fed by a board oscillator or by the simulator.

Three inputs is all the guest actually relies on: the key register, the cycle counter and reset. Interrupts are implemented in the CSR file but never enabled, because nothing here needs them.

The milestone for this log is a rendering DOOM booting in simulation with the display path, the WAD in memory and a clock, all of it before any board work.

Discussions