Loading…
Can It Run DOOM? YES!!

Can It Run DOOM? YES!!

hardware riscv computer-architecture rtl Superscalar DOOM Can It Run DOOM Retro Game CoreMark Performance Analysis Benchmarking OpenSource Optimisation

This blog is the complete record of a project that runs DOOM on a superscalar RISC-V core. The core and its peripheral bridge are plain synthesizable Verilog targeting an FPGA board with memory, a display, a keyboard and a serial console, and at that clock rate the game runs in real time. This project works with the same RTL and the same guest binary in a Verilator prototype of that board, where each board component is replaced by a testbench stand-in presenting the same interface, so every capture in this document is cycle accurate behaviour of the design itself.

All capture runs are deterministic given the ELF, the WAD and the key schedule, so every GIF in this document is reproducible by re-running the listed simulation.

The game

DOOM is id Software’s 1993 first person shooter. Its source was released in 1997 and has been ported to essentially every platform that can draw pixels. This project uses the doomgeneric port of the classic Linux Doom 1.10 lineage source tree: the game is unmodified except for a thin platform file that supplies six hooks (frame display, key read, tick counter, sleep, init, window title). The content is FreeDOOM Phase 1, a free, BSD licensed replacement IWAD, level E1M1.

Understanding the captures later in this blog needs five facts about the engine.

One, the game runs at a fixed 35 Hz tick called a tic. Every tic the main loop samples the keyboard into a ticcmd structure: a forward move, a side move, an angle turn and a button word. Nothing moves between tics.

Two, world simulation walks linked lists of thinkers: player movement and weapon state in p_user and p_pspr, enemies in p_enemy, moving geometry such as doors and platforms in p_spec. A door is a linedef with a special type; type 1 is a manual door that opens when the player uses it, stays open a few seconds, then closes, implemented as a thinker that raises the door sector’s ceiling a little per tic.

Three, the renderer is BSP based. It walks the map’s binary space partition tree front to back, clips wall segments against a per column visibility table, and draws walls, flats and sprites as vertical spans into a 320x200 byte per pixel framebuffer. A fixed 256 entry palette (the PLAYPAL lump) maps bytes to colours. That framebuffer and that palette are exactly what the testbench captures and what the GIFs are assembled from.

Four, weapons are animated sprite pairs called psprites, one for the weapon and one for the muzzle flash, driven by a state machine with lower, raise, ready and fire states. Weapon selection writes pendingweapon and the state machine performs the visible lower and raise.

Five, and this one cost real debugging time, the use action is not sector based in this code base. P_UseLines casts a 64 unit trace from the player along the facing direction (USERANGE in p_local.h) and offers the first special line it hits to P_UseSpecialLine via PTR_UseTraverse in p_map.c. To open a door you must be within 64 units and facing it.

The core

The core under test is the most optimised variant of a small superscalar RV32I design family; this blog calls it the superscalar core. Everything in this section comes from the previous post. FIND IT HERE

Pipeline and issue

The core is an in-order, two-way superscalar, five stage pipeline: IF, ID, EX, MEM, WB. Each cycle it fetches an aligned pair of instructions, instr0 at PC and instr1 at PC+4, and can issue both into slot 0 and slot 1. Slot 1 is squashed to a replay when the pair cannot co-execute, so the core degrades gracefully to single issue. The register file has 32 entries of 32 bits with two write ports and four read ports, enough for two instructions reading two sources each and two instructions retiring per cycle.

ISA and ALU

The ISA is RV32IM in hardware. The ALU implements the full M extension combinationally: MUL via explicit 64-bit product wires, and MULH, MULHSU, MULHU via separately named signed and unsigned 64-bit products, plus DIV, DIVU, REM, REMU with the spec corner cases written out (division by zero returns all ones for DIV and the dividend for REM; INT_MIN divided by -1 returns INT_MIN for DIV and 0 for REM) instead of relying on simulator defined behaviour. The RTL comments record two real bugs found here during development: an inline 32 bit product shifted right by 32 that silently returned 0 for MULHU and MULHSU, and a Verilator width inference problem that made the fix miscompile until the products were named as explicit wires. FENCE is treated as a no-op and ECALL produces the halt and trap path. The shape of the fix, straight from alu.v:

// Named, explicitly widened products. The inline form
//   ({1'b0, a} * {1'b0, b}) >> 32
// miscompiled on this toolchain; naming the wires removes
// every width inference from the expression.
wire signed [63:0] a_ss   = {{32{a[31]}}, a};
wire signed [63:0] b_su   = {32'b0, b};
wire signed [63:0] prod_su = a_ss * b_su;
wire        [63:0] prod_uu = {32'b0, a} * {32'b0, b};

wire div_by_zero = (b == `ZERO_WORD);
wire div_ovf     = (a == {1'b1, {`DATA_WIDTH-1{1'b0}}})
                && (b == {`DATA_WIDTH{1'b1}});

Branch prediction

Prediction runs on the slot 0 fetch stream. It is a McFarling style hybrid predictor with three 256 entry tables of 2-bit saturating counters: a local PHT indexed by pc[9:2] xor pc[15:8], a global PHT indexed by the same value xor an 8 bit global history register, and a chooser PHT that selects between them per branch. Taken branches get their target from a 256 entry, direct mapped BTB with a full 32 bit tag and a 32 bit target per entry; on a BTB miss the target falls back to PC plus the decoded immediate. JAL is decoded in IF and predicted unconditionally with its computed target. JALR is predicted through the BTB, except returns (jalr with rs1 of x1 or x5 and zero immediate) which are predicted through a 16 entry return address stack with a saturating count. Control redirects that survive to EX flush ID and EX, a two cycle penalty; slot 0 branches and JALRs are additionally resolved early, in ID, so an ID stage redirect also squashes slot 1 of the same pair.

Hazards, forwarding and the memory pair

The hazard unit implements what the design family calls hzopt: a load-use stall is only taken when the consumer needs its operand in the ID stage, which in this core means a slot 0 branch or JALR reading a value that a load in EX has not yet produced; every other consumer is covered by forwarding. The forwarding network supplies each slot from slot 0 and slot 1 EX/MEM results, from MEM/WB, and, for slot 1 only, from slot 0’s same cycle EX result, which is what makes dependent pairs like add-then-use issue back to back.

Both slots can perform memory operations, so the core exposes two complete load/store ports. The inter-slot memory rule (luopt) squashes slot 1 only on a real conflict: a slot 0 store followed in the pair by a slot 1 load of the same width whose low address bits alias (word compares imm[1:0], half compares imm[1], byte always aliases), a slot 0 load whose rd is read by slot 1, or a slot 0 control transfer. Everything else dual-issues.

CSRs

A 16 entry CSR file implements the machine mode registers mstatus, mie, mtvec, mscratch, mepc, mcause, mtval and mip plus the ID registers, with CSRRW, CSRRS and CSRRC semantics. mtvec resets to 0x100.

Memories and the MMIO map

The instruction and data memories are plain synchronous arrays with no caches; every access is a one cycle array read, which is what makes the core’s CPI numbers a property of the core rather than of a cache. Sizes are parameters: the stock testbenches build with 4,096 instruction words and 2,048 data words, and the DOOM build overrides to 524,288 instruction words (2 MiB of code) and 16,777,216 data words. The data memory’s index slice is clog2(W) bits wide, so the reachable window is 2 times W bytes, 64 MiB with the DOOM override; addresses above that wrap silently, a property that produced the single most expensive bug of the project and gets its own section below.

The DOOM build adds a peripheral bridge, mem_top_dg.v, on the same two memory ports. Its map, identical on both ports:

  • read 0x004 and 0x008: cycle counter, low and high 32 bits
  • read 0x00C and 0x010: retired instruction counter, low and high
  • read 0x014: keyboard state, key byte plus press/release and valid bits
  • read 0x018: frame counter
  • read 0x01C: framebuffer base register
  • read 0x020: exit request status
  • write 0x000: exit request with exit code
  • write 0x014: keyboard acknowledge
  • write 0x01C: framebuffer base
  • write 0x024: UART byte
  • write 0x028: frame commit, which dumps the framebuffer on the testbench

Measured performance

The core retires two instructions per cycle whenever the fetch stream cooperates, and the predictors keep it fed on real code. Measured in this simulation, with instruction counts taken from the core’s own retired instruction counter:

workloadcyclesinstructionsCPIIPC
CoreMark, O2, 1 iteration235,625322,9780.72951.3707
CoreMark, O2, 10 iterations2,267,4643,102,2750.73091.3682
CoreMark, O3, 100 iterations21,484,38729,546,3080.72711.3753
binary_search20230.86961.1500
fibonacci4264640.91811.0892
hello2392161.10650.9038
DOOM E1M1 gameplay window362,341,398498,514,1790.72681.3758

The DOOM row is the one that matters for this blog: a 500 million instruction boot of a real game runs at CPI 0.727, the same number CoreMark gets, which says the hybrid predictor, the BTB, the RAS and the dual issue memory paths are holding up on branch heavy, pointer chasing code, not just on a benchmark. The core also passes the project’s 31/31 instruction regression, and its CoreMark cycle counts were the same after every integration change, confirming the bridge work never perturbed the core.

The board, and what stands in for it here

The design’s natural home is an FPGA board. Published RISC-V DOOM ports of this class run five stage cores at 100 to 125 MHz on mid-range parts, and at the cycle cost per tic measured here, 100 MHz delivers about 60 tics per second of silicon time against the 35 the game needs. This project does not place and route the design; it prototypes the board in Verilator, and 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. The mapping:

  • crystal oscillator: forever #5 clk = ~clk; in tb_doom.v
  • reset button: an initial block holding rst for 20 time units
  • program flash or BRAM: the inst_mem.v array, $readmemh from hex
  • data DDR or BRAM: the data_mem.v array, 64 MiB window in the DOOM build
  • keyboard controller, PS/2 or USB on a board: the schedule driven injector latching presses and releases into the KEY register
  • VGA or DVI display controller scanning the framebuffer: the framebuffer array in the bridge plus the commit handler writing one PGM per frame
  • UART to a serial console: a 1 MiB accumulation buffer flushed at exit
  • microsecond timer: the cycle counter registers
// 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
// mem_top_dg.v: the keyboard stand-in. The guest sees a real key
// register: byte plus press/release and valid bits, cleared by ack.
always @(posedge clk) begin
    if (inject_valid) begin
        key_reg   <= inject_key[7:0];
        key_rel   <= inject_key[8];
        key_valid <= 1'b1;
    end
end
// mem_top_dg.v: the display stand-in. One commit store, one dump.
12'h028: begin
    $sformat(fname, "frame_%0d.pgm", frame_cnt);
    fd = $fopen(fname, "wb");
    $fwrite(fd, "P5\n320 200\n255\n");
    for (i = 0; i < FB_WORDS; i = i + 1)
        $fwrite(fd, "%c%c%c%c", fb[i][7:0],   fb[i][15:8],
                                fb[i][23:16], fb[i][31:24]);
    $fclose(fd);
end

Inputs and interrupts

Which interrupts does the game rely on? None. The CSR file implements mie, mip, mtvec and the rest of the machine mode trap registers, but the port never enables them and the testbench never asserts an IRQ line. Everything DOOM needs is polled: the keyboard through DG_GetKey reading the KEY register, time through DG_GetTicksMs reading the cycle counter, and the display is push, one commit store per frame. The only trap path event in the system is ECALL, which the port uses as a clean exit at the end of a run. In practice the guest relies on exactly three inputs, the key register, the cycle counter and reset, and on a board those three come from the keyboard controller, the timer and the reset button instead of the testbench.

/* dg_platform.c: the whole input path. Poll, decode, acknowledge. */
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;
}

uint32_t DG_GetTicksMs(void) { return MM_CYC_LO / 1000u; }

The polling model is also why the singletics flag matters: with the clock derived from the cycle counter, one doomgeneric tick advances exactly one tic, which keeps the frame stream, the tic stream and the key schedule in a fixed relationship whether the counter is fed by a board oscillator or by the prototype.

Adding a screen, a keyboard and a clock

The stock core boots an ELF from hex files and prints cycle counts. It has no display, no input and no wall clock. DOOM needs all three. This part is the order in which they were added, and each step ends at a capture.

Step 1: the framebuffer

The port defines CMAP256 so the game renders one byte per pixel, and DOOMGENERIC_RESX/RESY of 320 and 200 so the game’s native Mode 13h layout is used directly. DG_DrawFrame copies the 64,000 byte screen into the framebuffer window and then stores to the frame commit register, 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. An offline tool pairs the PGM bytes with PLAYPAL from the WAD and produces PNG stills and GIFs. The whole guest side of the display path is two lines:

/* dg_platform.c: DG_Init points DG_ScreenBuffer at FB_ADDR so the
 * renderer draws straight into the bridge's framebuffer page. */
void DG_DrawFrame(void)
{
    MM_DUMP = 1u;   /* one MMIO write, one frame_<n>.pgm on the host */
}

On a board the same commit store would instead kick a display controller that scans the framebuffer page out over VGA or DVI; the guest code is identical either way. The first still, the E1M1 3D view with status bar, was the proof that the render path worked end to end.

Step 2: the WAD in memory, and the 64 MiB bug

The IWAD is linked into the data memory as a blob, with the heap placed below it. The first boot died with W_GetNumForName: PNAMES not found even though the bytes were present. The cause was the data memory index slice, visible in data_mem.v:

// 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. The fix was 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.

Step 3: time

DOOM asks the platform for milliseconds. The port answers from the cycle counter MMIO (0x004/0x008), dividing cycles by the measured cycles per millisecond, and runs in singletics mode so exactly one tic elapses per rendered frame. That keeps the frame stream, the tic stream and the key schedule in a fixed relationship: one captured frame is one tic.

Step 4: the keyboard, and the dual port bug

The testbench injects keys from a schedule file of cycle and keycode pairs (+keyfile), pressing and releasing through a 9 bit inject path where bit 8 means release; the guest acknowledges each key by writing 0x014. The first version of the bridge decoded every MMIO write except the UART on port 0 only. The core dual-issues stores, so an acknowledge landing in slot 1 was silently dropped, key_valid never cleared, and the guest re-read the same key on every poll. Movement half worked by luck of pairing; fire never registered at all. The fix decodes both ports in the MMIO task, servicing the older instruction first so paired UART stores also keep their order:

// mem_top_dg.v, after the fix: an MMIO store may arrive on either
// issue port, because the core dual-issues stores.
always @(posedge clk) begin
    if (dg_enable && mem_we0 && mmio0)
        mmio_write(mem_addr0[11:0], mem_wdata0);
    if (dg_enable && mem_we1 && mmio1)
        mmio_write(mem_addr1[11:0], mem_wdata1);
end

On a board the same rule applies: a peripheral bridge must accept stores from both issue ports or the second one vanishes. Every action GIF after this point depends on that fix.

Step 5: seeing what the game sees

Route debugging later needed the player’s own state, so the port prints a per-tic trace over the UART: leveltime, ready weapon, ammo, x, y, angle and use button. On a board this is ordinary printf to the serial console; here the UART is accumulated in a 1 MiB buffer in the bridge and flushed at simulation exit. The hook, in doom/port/d_main.c:

printf("[tk%05d] rw=%d ammo=%d x=%d y=%d ang=%d u=%d\n",
       leveltime, (int)pl->readyweapon, (int)pl->ammo[am_clip],
       pl->mo ? (int)(pl->mo->x >> FRACBITS) : 0,
       pl->mo ? (int)(pl->mo->y >> FRACBITS) : 0,
       pl->mo ? (int)(pl->mo->angle >> 24) : 0,
       pl->usedown);

This trace is what turned the door work from guesswork into measurement.

One action at a time

With the platform in place, each game action was exercised, captured and checked frame by frame. The captures below come from two long runs, a movement run and a combat run, plus a door run; the slicing tool cuts each action out of its run using the cycle stamps.

01 Raising the pistol

Pistol raise

The level opens by lowering then raising the starting pistol through the psprite state machine. No input is needed, so this clip doubled as the frame path smoke test. The bug it exposed was in the recording plan: the first capture schedule started walking at tic zero, so the raise overlapped forward movement and could not be shown alone. The fix was an idle opening, a couple of seconds with no keys pressed, at the start of the combat run, with the raise cut from there. The first slicer version also crashed indexing an empty frame list when computing this clip’s bounds; deriving the bounds from the following movement segment fixed it.

02 Walking forward

Walk forward

The up arrow held for about a second of game time. This is the clip that made the dual port acknowledge bug visible: before the fix the same schedule produced a garbled key stream and the player moved by accident. After the fix, pressed means pressed and released means released, and the corridor scrolls as it should.

03 Running forward

Run forward

Running is walking with shift held, so two keys must be down together. The schedule generator originally serialised every hold, which turned run into walk, pause, walk. It was reworked so several hold pairs inside one action string start together, while a serialiser keeps the actual MMIO injections 20,000 cycles apart because the bridge latches one pending key. The clearly faster motion in the clip is the run segment following the walk segment in the same capture.

04 Walking backward

Walk backward

The down arrow, completing the movement set and confirming the backward ticcmd path in p_user. Cut from the same run as 02 and 03, a few seconds later in the cycle log.

05 and 06 Turning

Turn rightTurn left
Turn rightTurn left

Turning exposed the calibration bug. The first schedules assumed 35,714 cycles per tic, the value you get from 35 ms at 1.25 MHz. The measured core spends about 1.62 million cycles per tic, so every press landed tens of tics off and the turns overlapped the walk segments. Regenerating schedules from the measured cycle cost fixed the alignment, and the captures show the expected roughly three degrees per tic of rotation, the number later used to aim the player at a door.

07 and 08 Strafing

Strafe rightStrafe left
Strafe rightStrafe left

Side move without angle turn: the view translates while the facing stays fixed, which is also evidence that the sidemove field of the ticcmd is wired correctly, a separate code path from forward movement. By this point the input bridge, the calibration and the frame capture were all behaving, so these clips came out on the first cut.

09 Firing the pistol

Fire pistol

Fire was the action the dual port bug killed outright: before the fix no press registered and ammo never changed. After the fix the clip shows the flash sprite, the recoil pose and the ammo counter stepping from 50 to 49 at the expected tic, matching the trace. Before and after, the same schedule: no shot, then a shot.

10, 11 and 12 Switching weapons and punching

Switch to fistsPunchSwitch back to pistol
Switch to fistsPunchSwitch back to pistol

The number keys set pendingweapon; the current weapon lowers, the fists raise, the ready state returns. The first clip shows the pistol disappearing, the second the fist lunge, the third the pistol returning. The fix here was again in the slicer: cutting at the number key’s release trimmed the clips to a fraction of a second, while the lower and raise animations run about thirty tics; cutting from one press to the next produced complete animations.

13 Opening a door

Open door

The longest fight in the project. The goal: walk up to a closed door, press use, watch it slide open.

The spawn room has no door; a 360 degree scout of stills showed only windows, crates and an open doorway. Parsing the WAD lumps (THINGS, LINEDEFS, SIDEDEFS, VERTEXES) produced a top down map and a list of type 1 doors, and picked one north of the eastern corridor the player reaches by walking straight out of the spawn area.

Route planning then went through wrong models of use. The first, borrowed from older Doom documentation, was that use fires on any special line of the player’s sector; several runs ended with the player standing in the door recess pressing use at a door that never moved, because in this code base use is the 64 unit hitscan described in the game section, and the player had been facing away from the door. The second problem was reaching the recess at all: the dirt area in front of it holds live zombie troopers whose hits add damage thrust, so small schedule changes moved the player a lot. The per-tic trace described in the bring-up notes turned that into measurement. The final schedule walks east, takes the diagonal lane, fires a burst at the trooper blocking the lane, pushes into the shallow recess, turns to face the door line, and taps use. With the player ten units from the door and facing it, the trace shows usedown set and the next frames show the door rising and the room beyond, including one of its inhabitants. About half a dozen full runs ended at walls, at the wrong sector, or facing the wrong way; each failure corrected the model the route was built on.

Board time and prototype time

Nothing in the guest or in the RTL is simulation specific. Fitted to an FPGA board, the same ELF drives the real peripherals, and at 100 MHz the measured cycle cost per tic works out to about 60 tics per second of silicon time, above the 35 the game needs; prior public RISC-V DOOM boards run this class of core at 100 to 125 MHz, so the design sits comfortably inside what a mid-range part provides. The simulation in this project is a prototype of that board, and a prototype is slower than the thing it prototypes.

One tic costs about 1.62 million cycles on this core, so one second of game time is about 57 million cycles. The Verilator build sustained about 1.7 million cycles per second of wall time on this host during the capture runs, so one second of gameplay costs about thirty seconds of wall time: the prototype runs the game at roughly a thirtieth of board time, about one tic per second. Live play against the prototype is therefore slow motion, and the practical deliverable is offline recordings driven by precomputed key schedules, which is exactly what the rest of this blog shows. The gap between prototype time and board time is a property of the verifier, not of the design; the core is not the bottleneck anywhere in this project.

Longer takes

First gameplay recording

The first continuous recording made for this project: 200 tics, about six seconds of game time, walking, turning and firing out of the spawn area.

Extended single take

The action clips were sliced out of runs scripted to exercise one move at a time. The capture above is a single uninterrupted run instead: 934 rendered frames, about 26 seconds of game time at 35 fps, produced by exactly the schedule the door section describes. It walks out of the spawn room, runs the eastern corridor, turns into the dirt area, drops the trooper blocking the lane, pushes into the door recess, faces the door, opens it and walks through into the imp on the other side, so the earlier clips appear in context: weapon raise, walk, run, turns, gunfire and the door sequence all flowing out of one boot and one input file.

The take is also the cleanest picture of how input works here. There is no live keyboard; the whole run is driven by a text schedule of cycle and keycode pairs that the testbench injects through the keyboard MMIO, at cycle numbers the schedule compiler computes from the measured cycle cost of each route segment. The guest renders a frame, the testbench dumps it to a PGM stamped with the cycle count, and the offline tool places every frame at its true time, so the playback rate is measured, not assumed.

The schedule is a plain file you can read and diff, and the simulation is deterministic given the ELF, the WAD and the schedule, so re-running the capture reproduces the clip. It stops where it stops because of wall time: 26 seconds of game time is about twelve minutes of simulation. A clear run of E1M1 remains its own project and is not recorded.

Appendix: What was changed

  • rtl/mem/mem_top_dg.v: the dual port peripheral bridge: MMIO decode on both issue ports with in-order servicing, 1 MiB UART accumulation buffer, 9 bit key injection, framebuffer dump on commit.
  • tb/tb_doom.v: keyfile schedule parser, frame capture, console flush, exit and timeout handling.
  • doom/port/d_main.c and companions: the doomgeneric platform hooks, WAD blob handling, per-tic trace print.
  • doom/doom.ld and doom/build.sh: heap sizing against the 64 MiB data memory window, with a build time check.
  • doom/gen_keys.py: the schedule compiler (hold, tap, gap, absolute reposition, overlapping holds).
  • doom/frames2video.py: PGM plus palette to PNG and GIF assembly with cycle accurate delays and range slicing.

The core’s RTL was not modified for this project. Every fix lived in the integration layer, and the core’s own regression results and CoreMark cycle counts were unchanged after each integration change.