Introduction
Project Tartarus is the effort to port the original DOOM (1993) to the HaDes-V architecture, an educational, modular, pipelined 32-bit RISC-V processor developed at the Embedded Architectures & Systems Group at Graz University of Technology. The project builds upon the base implementation created during the Microcontroller Design lab course.
DOOM is known for its portability, having run on countless platforms, but it still poses significant challenges on a soft-core RISC-V CPU implemented in FPGA logic. The game requires substantial memory (the WAD alone is over 4 MB), sustained graphics throughput, precise input handling, and real-time audio, all while running at a modest 75 MHz clock frequency with no floating-point hardware (relying on 16.16 fixed-point arithmetic).
The base HaDes-V system was designed for the Digilent Basys 3 board (XC7A35T FPGA, 256 KB BRAM, no external memory). To meet DOOM’s requirements, the system was ported to the Nexys 4 DDR (XC7A100T FPGA, 128 MB DDR2 SDRAM) and extended with numerous hardware and software additions.
Hardware Additions
- Port to the Nexys 4 DDR: board bringup, pin constraints, clock generation, synthesis infrastructure
- M-Extension: hardware multiplier (3-stage pipelined, DSP-based) and divider (radix-2 non-restoring)
- SD Card Controller & Bootloader: SPI-based SD card interface, BootROM v2 loading programs from SD
- BRAM Refactoring & Clock Optimization: improved BRAM model enabling 75 MHz CPU clock
- DDR2 Memory Subsystem: MIG IP integration, 128-bit Wishbone bridge, memory arbiter
- Instruction Cache: reduces DRAM read latency for code fetches
- Data Cache: write-through cache reducing DRAM pressure for data accesses
- PS/2 Keyboard Controller: PS/2 receiver with hardware FIFO, Wishbone interface
- VGA Controller Rework: double-buffered framebuffer, DOOM-native resolution and color depth, DMA-style framebuffer writes
- Audio Subsystem: PWM audio output module with Wishbone control interface
- Cycle Counter: hardware performance counter for profiling
Software Additions
- Build System & Toolchain: Makefile refactoring, compiler optimization flags (
-O2 (partially),-mstrict-align) - Standard Library: DDR2-backed
malloc/free/realloc, utility functions - Hardware Abstraction Layer: the
hades_v_doom.cglue layer connecting DOOM’s backend to custom hardware drivers - Audio Implementation: sound queue management, PCM sample feeding to the PWM output
- WAD Integration: DOOM WAD linked directly into the binary, custom memory-mapped I/O layer replacing file system access
- Performance Optimizations: optimized column rendering in
r_draw.c, fixed-point math fixes, cycle counter-driven profiling
Result
DOOM is running on the Nexys 4 DDR with full graphics and sound (no music). Depending on graphical settings, the game achieves between 10 and 30 FPS.
Document Structure
This documentation is divided into two main parts:
- Hardware: covers all custom hardware modules, peripheral bringup, and architectural changes
- Software: covers the build system, DOOM HAL, drivers, and performance optimizations
Each chapter describes the motivation, design decisions, and implementation details of the respective change.
Acknowledgements
The Game Engine Black Book: DOOM by Fabien Sanglard was an invaluable resource for understanding the inner workings of the DOOM engine. Its detailed exploration of the rendering pipeline, WAD format, and game subsystems greatly aided both the porting effort and the writing of this documentation.
Use of Large Language Models
During the development of Project Tartarus, several LLM-based tools were used:
- GitHub Copilot was occasionally used for single-line code completions during software development, providing suggestions for boilerplate and repetitive code patterns.
- Gemini (Chat) was used as a sounding board for architectural decisions. Design ideas were discussed in a “tell me why my idea is stupid” style to identify flaws and consider alternatives before implementation. Furthermore, it was used to generate testcases for testbenches without being given prior knowledge of the system, in the hopes of finding edge cases I would be too biased to consider, before running synthesis which takes more than an hour on my machine.
- DeepSeek V4 Pro and DeepSeek Flash provided the primary assistance with writing this documentation, including chapter planning, content drafting, and diagram creation.
Porting to the Nexys 4 DDR
Motivation
The base HaDes-V system targets the Digilent Basys 3 board, built around the Xilinx Artix-7 XC7A35T FPGA. While sufficient for the educational microcontroller lab, this platform is fundamentally inadequate for running DOOM:
| Resource | Basys 3 (XC7A35T) | Nexys 4 DDR (XC7A100T) |
|---|---|---|
| Logic Cells | 33,280 | 101,440 |
| BRAM | 225 KB | 486 KB |
| DSP Slices | 90 | 240 |
| External Memory | None | 128 MB DDR2 SDRAM |
The most critical limitation is the lack of external memory. DOOM’s WAD file alone is just over 4 MB, far exceeding the BRAM available. The Nexys 4 DDR’s 128 MB DDR2 SDRAM provides more than enough capacity for the game’s code, WAD data, framebuffers, and runtime state. The larger FPGA also provides the logic headroom needed for caches, additional peripherals, and the M-Extension.
Changes
Constraints File (synth/nexys4ddr.xdc)
A new constraint file maps all the top-level ports to the Nexys 4 DDR’s physical pins:
- Clock: 100 MHz primary oscillator at pin E3, configured with a
create_clockconstraint at 10 ns period. - MMCM placement: The MMCM is locked to location
MMCME2_ADV_X1Y1and the input clock is set withCLOCK_DEDICATED_ROUTE FALSEto avoid routing restrictions. - Switches: 16 switches mapped to bank 14/15/34/35 pins.
- LEDs: 16 LEDs mapped to bank 14/15 pins.
- 7-Segment Display: 8 segments and 4 anode selects mapped.
- Buttons: 5 buttons (center, up, left, right, down).
- VGA: 4-bit R/G/B channels, H-sync, V-sync.
- UART: RX/TX on the USB-RS232 interface.
- SD Card (SPI): CS, SCLK, MOSI, MISO, and reset on the micro SD connector.
- PS/2: Clock and data pins with pull-up enabled.
- Audio PWM: PWM output and audio shutdown on the audio amplifier header.
- DDR2: Full physical interface pins (DQ, DQS, address, bank, control, clock, etc.).
The XDC file header notes that the port is “very lackluster”; some switch, button, and display pins are not correctly mapped because the focus was on getting DOOM operational rather than full board coverage.
Synthesis Script (synth/synth.tcl)
The synthesis script was adapted from the Basys 3 version:
Dynamic board selection:
if {[info exists env(TARGET_BOARD)]} {
set TARGET_BOARD $env(TARGET_BOARD)
} elseif {[llength $argv] > 0} {
set TARGET_BOARD [lindex $argv 0]
} else {
set TARGET_BOARD "basys3"
}
Part and constraints selection:
if {$TARGET_BOARD == "nexys4ddr"} {
set FPGA_PART "xc7a100tcsg324-1"
set CONSTRAINTS_FILE "$ROOT/synth/nexys4ddr.xdc"
} else {
set FPGA_PART "xc7a35tcpg236-1"
set CONSTRAINTS_FILE "$ROOT/synth/basys3.xdc"
}
MIG IP integration: For the Nexys target, the script locates the DDR2 MIG IP (.xci), reads it, generates all targets, and synthesizes the IP:
if {$TARGET_BOARD == "nexys4ddr"} {
set MIG_XCI "$ROOT/ip/ddr2_mig/mig_7series_0.xci"
if {[file exists $MIG_XCI]} {
read_ip $MIG_XCI
generate_target all [get_ips mig_7series_0]
synth_ip [get_ips mig_7series_0]
}
}
Enhanced optimization directives: The original Basys 3 flow (synth_design + basic opt_design + place_design + route_design) was replaced with a much more aggressive flow to meet timing at 75 MHz:
synth_design -top top -part $FPGA_PART -retiming
opt_design -directive Explore
place_design -directive ExtraNetDelay_high
phys_opt_design -directive AggressiveExplore
route_design -directive Explore
phys_opt_design -directive AggressiveFanoutOpt
Top Module (synth/top.sv)
The top module was expanded to accommodate the Nexys 4 DDR’s richer peripheral set and the new DDR2 memory subsystem:
Ports: increased from the original 4-bit switches/LEDs to 16-bit vectors, added 7-segment display, audio PWM, PS/2 keyboard, and DDR2 physical interface signals.
Clock generation: the system uses a two-stage clocking architecture:
-
MMCME2_BASE generates the CPU and memory clocks from the 100 MHz input:
- 200 MHz for the MIG DDR2 controller
- 75 MHz for the CPU core (the operating frequency)
-
PLLE2_BASE × 2 generate the VGA pixel clock:
- First PLL multiplies to 106 MHz (100 MHz / 5 × 53 / 10)
- Second PLL divides to 25.175 MHz (106 MHz / 2 × 19 / 40)
The CPU runs on ui_clk — the clock output from the MIG’s user interface — eliminating a clock domain crossing between the CPU and the DDR2 controller.
DDR2 integration: the DDR2 MIG IP is instantiated with its native application interface (app_addr, app_cmd, app_rd_data, etc.). A custom wishbone_128_ddr2_bridge translates between the 128-bit Wishbone bus and the MIG’s native interface. The bridge is connected to the system’s Wishbone memory arbiter.
Build System (Makefile)
The Makefile gained a BOARD parameter, defaulting to basys3:
BOARD ?= basys3
synthesis: $(BUILD_DIR)/$(C_DIR)/bootloader/init.mem
cd $(BUILD_DIR)/$(SYNTH_DIR) && $(VIVADO) -mode $(MODE) \
-source $(CURDIR)/$(SYNTH_DIR)/synth.tcl -tclargs $(BOARD)
This allows building for either board:
make synthesis BOARD=nexys4ddr # Build for Nexys 4 DDR
make synthesis # Build for Basys 3 (default)
Summary
The port to the Nexys 4 DDR involved creating board-specific constraints, adapting the synthesis infrastructure for dual-board support, integrating the DDR2 MIG IP, and redesigning the top-level module to support all required peripherals. The dynamic board selection mechanism allows the same repository to target both the original Basys 3 and the Nexys 4 DDR, which proved useful during development and testing.
M-Extension
The RISC-V M-Extension (Standard Extension for Integer Multiplication and Division) provides native hardware support for multiplication and division operations. The original DOOM engine does not use floating-point arithmetic but relies heavily on 16.16 fixed-point arithmetic for its 3D rendering pipeline, BSP tree traversal, and physics calculations. Without the M-Extension, these operations would be implemented with software emulation routines (__mulsi3 and __divsi3), which would cost hundreds of clock cycles per operation and catastrophically reduce performance.
Supported Operations
| Category | Instructions | Use in DOOM |
|---|---|---|
| Multiply | MUL, MULH, MULHSU, MULHU | Fixed-point multiplication, angle/trig lookup scaling |
| Divide | DIV, DIVU | Aspect ratio correction, column/span calculation |
| Remainder | REM, REMU | Modulo operations, buffer index wrapping |
Architecture
The M-Extension is implemented as two separate execution units instantiated within the Execute stage, running in parallel with the standard ALU:
flowchart LR
ALU["ALU (Single-Cycle)"]
MUL["Multiplier<br/>(3-Stage Pipelined)"]
DIV["Divider<br/>(Radix-2 State Machine)"]
MUX["Result MUX"]
ALU --> MUX
MUL --> MUX
DIV --> MUX
Both units are independent and can operate concurrently. The Execute stage monitors their busy/valid signals and only stalls the pipeline when at least one unit is actively computing without a valid result available.
Pipeline Integration
Stall Logic: The Execute stage asserts stall_req_exe when either the Multiplier or Divider is busy but does not yet have a valid_out signal. This completely freezes the Fetch and Decode stages, ensuring no instructions are dropped:
assign stall_req_exe = ((mul_start || mul_busy) && !mul_valid_out) ||
((div_start || div_busy) && !div_valid_out);
Forwarding: The forwarding unit treats MUL and DIV identically to standard ALU R-type instructions. Once the multi-cycle math finishes, the Execute stage outputs valid = 1 and the resulting data is instantly forwarded to subsequent instructions, preventing data hazards.
Busy Tracking: Each unit tracks its own busy state with separate start/busy/valid_out signals. A unit is started by asserting its start signal; it remains busy until it produces a valid output and the pipeline is not stalled.
Verification
The M-Extension was verified at three levels:
- Unit-level testbenches (
tb_multiplier.sv,tb_divider.sv) using Verilator with a dynamic scoreboard queue to handle multi-cycle latency - Assembly tests exercising hazard chains (MUL feeding DIV, feeding MUL) and edge cases (divide-by-zero, signed overflow)
- Hardware acceptance tests (
test_acc.c) that run on the FPGA, printing results over UART and setting LED patterns for quick visual pass/fail confirmation
The Multiplier and Divider are each documented in detail in their own subchapters.
Multiplier
Design
The multiplier is a 3-stage pipelined 32×32-bit signed/unsigned multiplier. Instead of implementing a custom shift-and-add state machine, the module relies on the synthesizer to infer DSP slices from the native multiplication expression.
sequenceDiagram
participant Stage1 as Stage 1<br/>(Sign-Extend)
participant Stage2 as Stage 2<br/>(Multiply)
participant Stage3 as Stage 3<br/>(Select Output)
Note over Stage1: rs1_in, rs2_in, multiplier_op_in
Stage1->>Stage1: ext_rs1 = {sign_rs1, rs1_in}<br/>ext_rs2 = {sign_rs2, rs2_in}
Stage1->>Stage2: reg1 values valid
Stage2->>Stage2: product = rs1_reg1 * rs2_reg1<br/>(DSP inference)
Stage2->>Stage3: reg2 values valid
Stage3->>Stage3: case(mul_op):<br/>MUL → product[31:0]<br/>default → product[63:32]
Note over Stage3: result + valid_out
Stage 1: Sign Extension
The 33-bit sign extension depends on the opcode:
| Op | rs1 | rs2 |
|---|---|---|
| MUL | Signed | Signed |
| MULH | Signed | Signed |
| MULHSU | Signed | Unsigned |
| MULHU | Unsigned | Unsigned |
For unsigned operands, the MSB is extended with 0 instead of the sign bit. The extended values are stored in pipeline registers rs1_reg1 and rs2_reg1.
Stage 2: Multiplication
The actual multiplication is expressed natively in HDL:
(* use_dsp = "yes" *) logic signed [65:0] product;
product <= rs1_reg1 * rs2_reg1;
The use_dsp attribute guides the synthesizer to map the multiplication onto DSP48E1 slices. On the XC7A100T, three DSP blocks are inferred for this operation. The multiplication is performed on 33-bit signed values (one bit wider than the inputs to accommodate the sign extension), producing a 66-bit result. The intermediate values are retained in flip-flops for the pipeline.
Stage 3: Output Selection
The final stage selects either the lower or upper 32 bits of the product:
- MUL: returns
product[31:0](the standard 32-bit multiplication result) - MULH / MULHSU / MULHU: returns
product[63:32](the high half, implementing multiply-high)
The 3-cycle latency means the result of a multiplication is available on the third clock cycle after issue. During these cycles, the pipeline is stalled to prevent dependent instructions from reading stale register values.
Verification
The multiplier testbench (rtl/tb_multiplier.sv) uses a dynamic scoreboard queue to handle the pipeline latency. Test vectors include:
- Basic signed and unsigned multiplications with known results
- Edge inputs (zero, one, maximum values, sign flips)
- Hazard chains where MUL results feed directly into subsequent operations
- Full coverage of all four MUL/MULH/MULHSU/MULHU variants
Divider
Design
The divider implements a radix-2 non-restoring division algorithm that computes one quotient bit per cycle.
flowchart LR
IDLE([IDLE])
CALC([CALC])
DONE([DONE])
IDLE -->|valid_in & rs2 != 0| CALC
IDLE -->|valid_in & rs2 == 0<br/>div-by-zero| DONE
CALC -->|count == 1| DONE
CALC -->|count > 1<br/>next AQ| CALC
DONE -->|implicit next valid_in| IDLE
While a higher-radix algorithm (like SRT Radix-4) could halve the latency, a classic 1-bit-per-cycle state machine was chosen. DOOM performs divisions relatively infrequently, mostly during setup, scaling, and initialization, not in inner-loop rendering, making the reduced implementation complexity favorable over the modest performance gain of a higher-radix design.
Core Algorithm
The non-restoring division operates on a 65-bit register pair AQ (33-bit accumulator + 32-bit quotient) and a 33-bit divisor register M:
assign shifted_AQ = AQ << 1;
assign next_A = AQ[64] ? (shifted_AQ[64:32] + M) : (shifted_AQ[64:32] - M);
assign next_Q0 = ~next_A[32];
At each iteration:
AQis shifted left by one- If
AQ[64]is set (remainder negative), the divisor is added; otherwise it is subtracted - The new quotient bit is the inverse of the sign of the adjusted remainder
- The updated values are written back to
AQ
After 32 iterations, the final remainder may need correction if it is negative (non-restoring property).
Signed & Special-Case Handling
Signed operations: For DIV and REM, operands are converted to absolute values at the start. The sign of the quotient and remainder is computed and applied at the output:
quotient_neg <= is_signed & (rs1_in[31] ^ rs2_in[31]) & (rs2_in != 0);
rem_neg <= is_signed & rs1_in[31];
Divide-by-zero: The RISC-V specification requires graceful handling rather than exceptions. When a divide-by-zero is detected, the hardware returns:
- Quotient (DIV):
0xFFFFFFFF(all ones) - Remainder (REM): the original dividend value
This is implemented by bypassing the iterative computation and going directly to the DONE state with AQ loaded with the dividend.
Output Selection
The module returns either the quotient or remainder depending on the opcode:
assign result = is_rem_op ? final_remainder : final_quotient;
The is_rem_op signal is registered when the instruction is issued, so the output mux is set correctly by the time the result is ready.
Latency
- Setup: 1 cycle (IDLE → CALC)
- Iteration: 32 cycles (one per bit)
- Pipeline stall overhead: variable, depending on when results are consumed
- Total: approximately 33 clock cycles per operation
Verification
The divider testbench (rtl/tb_divider.sv) uses the same scoreboard architecture as the multiplier to handle the multi-cycle latency. Tests include:
- Both signed (DIV, REM) and unsigned (DIVU, REMU) operations with random operands
- Edge cases: division by zero, division with large/small dividends and divisors
- Negative dividend and divisor combinations
- Remainder overflow conditions
- Hazard chains interleaving MUL and DIV operations
SD Card Controller & Bootloader
The original HaDes-V system loaded programs exclusively via UART using a simple Intel HEX loader in the BootROM. This required a host PC to be tethered to the FPGA for every program upload. To make the system self-sufficient and make loading the large files needed for DOOM realistic, an SD card controller was added, allowing DOOM (and other programs) to be stored permanently on a micro SD card and loaded automatically at startup.
SPI Controller (wishbone_spi.sv)
The SD card is accessed through SPI mode 0 (CPOL=0, CPHA=0), implemented as a Wishbone-mapped SPI master peripheral.
Register Map
| Offset | Name | Access | Description |
|---|---|---|---|
| 0x00 | DATA | R/W | Write to transmit a byte, read to receive |
| 0x04 | STATUS | R | Bit 0: busy (1 = transaction in progress) |
| 0x08 | CTRL | R/W | Bit 0: CS (1 = active), Bits 15:8: clock divider |
State Machine
stateDiagram-v2
IDLE --> SHIFT: start_tx & !busy
SHIFT --> SHIFT: sclk_tick & bit_counter > 0<br/>(toggle SCLK, shift data)
SHIFT --> DONE: sclk_tick & bit_counter == 0
DONE --> IDLE: sclk_tick<br/>(capture rx data)
IDLE: SCLK held low, waits for a write to the DATA register. On start, loads the TX byte into the shift register, sets the first bit on MOSI, transitions to SHIFT.
SHIFT: On each SCLK tick (generated from a configurable clock divider), toggles the SCLK line. On the falling edge of SCLK, the next data bit is driven onto MOSI. On the rising edge, the MISO bit is shifted into the shift register. After 8 bits, transitions to DONE.
DONE: The received byte is captured from the shift register, busy is de-asserted, and the state returns to IDLE.
The clock divider defaults to 0x20 (32), providing a ~2.34 MHz SPI clock from the 75 MHz system clock. The software can reconfigure this to 0x02 (2) for ~37.5 MHz during high-speed data transfers after initialization.
BootROM V2.0 (boot_internal.c)
The BootROM was redesigned to support loading programs from SD card as the primary mechanism, with UART as a fallback.
Boot Flow
flowchart TD
A[Start BootROM] --> B[Initialize SPI]
B --> C[Detect SD Card]
C --> D{SD Ready?}
D -- Yes --> E[Read Sector 1]
E --> F{"Intel HEX marker ( : )?"}
F -- Yes --> G[Load program from SD]
F -- No --> H[Fallback to UART]
D -- No --> H
G --> I[Program DDR2]
I --> J[jump to entry]
H --> K[Receive Intel HEX over UART]
K --> I
SD detection: The bootloader duplicates the SD init sequence (CMD0 → CMD8 → ACMD41 polling) to keep the boot code self-contained, separate from the sd_card.c driver used after boot.
Sector-buffered loading: To avoid re-initializing the SD card on every byte, a 512-byte sector buffer is maintained. The bootloader reads one full sector at a time via CMD17, then serves bytes from the buffer until exhausted, at which point the next sector is fetched.
Intel HEX parser: Supports record types 0 (data), 1 (EOF), 2 (segment base address), 3 (start address, SEG), 4 (linear base address), and 5 (start address, LINEAR). Data is written directly to the target address using __program_byte(), which validates the address range:
static int __program_byte(uint32_t address, char data) {
if (&__ram_start <= adr && adr < &__boot_start) {
*adr = data; // BRAM
} else if (address >= DDR2_START && address < DDR2_START + DDR2_SIZE) {
*adr = data; // DDR2
} else {
return -1; // Invalid range
}
}
This allows programs to span both BRAM (for the bootloader and critical sections) and DDR2 (for the main program, WAD data, and framebuffers).
Fallback mode: If no SD card is detected or the card does not contain valid Intel HEX data in sector 1, the bootloader falls back to the original UART-based Intel HEX loading, printing a prompt for the user to send the HEX file over the serial connection.
SD Card Driver (sd_card.c)
The software driver provides two public functions used by the main application (including DOOM):
Initialization (sd_init)
The init sequence follows the standard SPI-mode SD card initialization:
- Wake-up: Send 80 dummy clock cycles with CS de-asserted to ensure the card is ready
- CMD0 (GO_IDLE_STATE, CRC 0x95): Reset the card to SPI mode. Must respond with
0x01(IDLE) - CMD8 (SEND_IF_COND, arg 0x1AA, CRC 0x87): Check voltage range and verify the card supports the required 2.7-3.6V range. The 0x1AA pattern confirms SDv2 compatibility
- ACMD41 (SD_SEND_OP_COND, arg 0x40000000): The HCS bit indicates host supports SDHC/SDXC (>2GB) cards. The command is polled repeatedly (CMD55 + ACMD41) until the card responds with
0x00(ready) or a timeout occurs - Switch to high speed: On success, configure the SPI clock divider from
0x40to0x02and release CS
The driver only supports SDHC/SDXC cards (capacity ≥ 2 GB) via the HCS bit. Legacy SDv1 cards are rejected during init.
Block Read (sd_read_block)
Reading a single 512-byte block follows the CMD17 protocol:
- Select the card and set high-speed clock
- Send CMD17 with the 32-bit block number and dummy CRC (0xFF)
- Wait for the start token (0xFE), with a 10000-iteration timeout
- Read 512 bytes into the provided buffer
- Read and discard the 2-byte CRC
- De-select the card and send one final dummy clock to release MISO
BRAM Refactoring & Clock Speed Optimization
Motivation
The original HaDes-V BRAM model used a dual-port memory design that supplied data on the falling edge of the clock. This forced the entire read path, BRAM address decode, memory array access, and the Wishbone ACK/data multiplexing, to complete within a single half-cycle. The synthesizer could not pipeline this path, limiting the achievable system frequency to approximately 50 MHz on the Basys 3 target.
For DOOM, every additional MHz directly translates into more instructions per second, which means more rendering throughput and a smoother frame rate. Increasing the clock speed to 75 MHz was therefore a key performance goal.
BRAM Refactoring
The wishbone_ram.sv module was redesigned to remove the falling-edge behavior and instead operate as a conventional single-cycle memory:
always_ff @(posedge clk) begin
if (rst) begin
port_a.ack <= 0;
port_a.err <= 0;
port_a.dat_miso <= 0;
end else begin
port_a.ack <= 0;
port_a.err <= 0;
if (port_a.cyc && port_a.stb && !port_a.ack) begin
if (port_a.adr >= ADDRESS && port_a.adr < ADDRESS + SIZE) begin
port_a.ack <= 1;
if (!port_a.we) begin
port_a.dat_miso <= memory[port_a.adr - ADDRESS];
end else begin
// Byte-level writes via sel mask
end
end else begin
port_a.err <= 1;
end
end
end
end
The key changes were:
- Synchronous reads: data is now registered and available on the next rising edge, giving the toolchain a full clock cycle for the memory access path
- Simplified control:
ackis asserted unconditionally on the next cycle for valid accesses, rather than using the half-clock scheme - Byte-level writes preserved: write masking via
selremains unchanged for correct sub-word writes
Clock Speed Increase
With the BRAM timing path resolved, the MMCM configuration in clk_params.sv was adjusted to raise the CPU clock from 50 MHz to 75 MHz:
localparam real MMCM_MUL = 6.000; // VCO: 100 MHz * 6 = 600 MHz
localparam int MMCM_DIV = 1; // Input divider
localparam real MMCM_DIV_75 = 8.000; // 600 MHz / 8 = 75 MHz (CPU)
localparam int MMCM_DIV_200 = 3; // 600 MHz / 3 = 200 MHz (MIG refclk)
The 600 MHz VCO output drives both the 75 MHz system clock (for the CPU, caches, and peripherals) and the 200 MHz reference clock (for the DDR2 MIG controller).
Pipeline Adjustments
The instruction fetch stage (fetch_stage.sv) and memory stage (memory_stage.sv) were updated to account for the single-cycle BRAM access model. The memory stage’s BRAM read path now correctly expects data on the next cycle, and the stall/forwarding logic around memory operations was tightened to prevent mis-speculations.
Result
The CPU core now runs at a stable 75 MHz on the Nexys 4 DDR, a 50% increase over the original 50 MHz Basys 3 design. This clock speed was maintained throughout all subsequent additions (caches, M-Extension, peripherals), and the timing analysis consistently passes with the Explore-directive place-and-route flow used in the synthesis script.
DDR2 Memory Subsystem
Motivation
DOOM requires far more memory than the HaDes-V’s on-chip BRAM can provide. The WAD file alone is 4 MB, and the game requires additional space for the code, runtime data structures (zone allocator), and two full-resolution framebuffers. The Nexys 4 DDR’s 128 MB DDR2 SDRAM provides ample capacity, but integrating it requires bridging a significant interface complexity gap between the CPU’s 32-bit Wishbone bus and the DDR2’s high-speed, pipelined native interface.
Memory Interface Generator (MIG)
The DDR2 subsystem is built around Xilinx’s MIG (Memory Interface Generator) 7-Series IP, configured for the Nexys 4 DDR’s specific DDR2 SDRAM:
| Parameter | Value |
|---|---|
| Memory Type | DDR2 SDRAM |
| Capacity | 128 MB |
| Data Width | 16 bits (physical) / 128 bits (user interface) |
| Reference Clock | 200 MHz |
| User Clock | 75 MHz (ui_clk) |
The MIG abstracts the complex DDR2 physical-layer protocol, including DLL calibration, DQS strobe alignment, write leveling, and periodic refresh, behind a simpler application interface. The 200 MHz reference clock is generated from the system MMCM, and the MIG outputs a synchronous 75 MHz ui_clk that serves as the main system clock for the CPU and caches.
No-CDC Architecture
The CPU, caches, and all peripherals run directly on ui_clk, the MIG’s user interface clock. This eliminates any clock domain crossing between the memory controller and the CPU core:
// No CDC: The CPU runs on the clock generated by the memory controller
assign sys_clk = ui_clk;
assign sys_rst = ui_clk_sync_rst;
This design choice simplifies the bridge logic substantially: the Wishbone protocol operates in a single clock domain with the MIG’s native interface, removing the need for asynchronous FIFOs or handshake synchronizers on the data path.
MIG Native Interface
The MIG exposes a synchronous application port with the following key signals:
- Command:
app_cmd(3-bit),app_addr(27-bit),app_en(strobe) - Write Data:
app_wdf_data(128-bit),app_wdf_mask(16-bit byte enables),app_wdf_wren,app_wdf_end - Read Data:
app_rd_data(128-bit),app_rd_data_valid - Flow Control:
app_rdy(command ready),app_wdf_rdy(write data ready)
The interface is pipelined: commands and write data can be issued on different cycles as long as the respective ready signals are asserted. Reads complete multiple cycles later with app_rd_data_valid.
128-bit Wishbone-to-MIG Bridge (wishbone_128_ddr2_bridge.sv)
The bridge translates between the 128-bit Wishbone master (from the cache subsystem) and the MIG’s native application interface:
flowchart LR
subgraph FPGA
direction TB
WB["128-bit Wishbone<br/>Master (Cache)"] --> BR["Bridge"]
BR --> MIG["MIG 7-Series"]
MIG --> DDR2["DDR2 SDRAM<br/>128 MB"]
BR --> WB
end
Address Translation
The conversion from 32-bit word addresses to the MIG’s byte addresses follows this path:
logic [31:0] relative_word_addr = port.adr - ADDRESS;
logic [31:0] relative_byte_addr = relative_word_addr << 2;
assign app_addr = {relative_byte_addr[26:4], 4'b0000};
The 128-bit data path means each MIG access covers 16 bytes (4 * 32-bit words). The lower 4 bits of the byte address are cleared and the 27-bit app_addr selects 16-byte aligned blocks within the 128 MB space.
Bridge State Machine
The bridge operates in three states:
stateDiagram-v2
IDLE --> WAIT_WRITE: Write & (!app_rdy or !app_wdf_rdy)
IDLE --> WAIT_READ: Read & app_rdy
IDLE --> IDLE: Valid access, both ready
WAIT_WRITE --> IDLE: Both cmd & data accepted
WAIT_READ --> IDLE: app_rd_data_valid
Write path: The bridge issues the command (app_cmd = 3'b000) with write data on the same cycle. If the MIG is not ready for either, it transitions to WAIT_WRITE and retries until both cmd_accepted and data_accepted are true.
Read path: The bridge issues the command and waits for app_rd_data_valid in the WAIT_READ state, then captures the data, asserts Wishbone ack, and returns to IDLE.
Byte masking: The wishbone_sel signal is inverted to produce app_wdf_mask (MIG masks are active-low), ensuring only the targeted bytes are written.
128-bit Wishbone Arbiter (wishbone_128_arbiter.sv)
The arbiter manages access to the DDR2 from two 128-bit masters: the Instruction Cache and the Data Cache.
// Priority: Data Bus > Instruction Bus
if (m1.cyc) begin
grant_m1 <= 1; // Data bus has priority
grant_m0 <= 0;
end else if (m0.cyc) begin
grant_m0 <= 1;
grant_m1 <= 0;
end
Data bus accesses are prioritized because a stalled data access (e.g., during a load or store) freezes the pipeline immediately. Prioritizing data accesses minimizes the time the CPU spends waiting on data hazards. Instruction fetches can generally tolerate longer latencies due to the ICache’s prefetch behavior.
Memory Map
The DDR2 occupies the upper portion of the 32-bit address space:
| Region | Base | Size | Description |
|---|---|---|---|
| BRAM | 0x00000000 | 8 KB | Boot code, stack, critical data |
| DDR2 | 0x80000000 | 128 MB | Program code, WAD data, framebuffers, heap |
The linker script and bootloader cooperate to ensure that code and data are placed in the appropriate memory regions. The WAD is stored in DDR2, framebuffers are allocated from DDR2, and the DOOM zone allocator manages DDR2-backed heap memory. It is to be noted, that further improvements could have been possible by placing certain pieces of code or data in BRAM for faster access, but evaluating this would have been challenging due to the limited BRAM size and the complexity of the memory access patterns in DOOM.
Cache Subsystem
Motivation
Accessing the DDR2 SDRAM incurs a significant latency, even with the pipelined MIG interface, a single 128-bit read takes on the order of 20 clock cycles. Since the CPU issues a memory access nearly every cycle (for instruction fetches and data loads/stores), directly accessing DDR2 would stall the pipeline almost constantly, reducing performance to a small fraction of the 75 MHz clock’s potential.
The cache subsystem bridges this gap by keeping frequently accessed data in fast on-chip BRAM. With an 8 KB (later increased to 32K for optimization), 2-way set-associative cache, the hit rate for instruction fetches is extremely high (code is dense and exhibits excellent spatial/temporal locality), and the data cache captures frequently accessed variables and rendering data.
Shared Memory Arrays (cache_memory_subsystem.sv)
Both the Instruction Cache and the Data Cache use the same parameterized memory array module. This module implements the tag, data, and metadata storage for a 2-way set-associative cache.
Organization
flowchart TD
ADDR["CPU Address<br/>[31:0]"] --> SLICE{"Address Slice"}
SLICE --> TAG["TAG Bits [31:index+offset]"]
SLICE --> IDX["INDEX Bits"]
SLICE --> OFF["OFFSET Bits [1:0]"]
IDX --> WAY0["Way 0"]
IDX --> WAY1["Way 1"]
subgraph WAY0["Way 0"]
TA0["Tag Array"] --> TC0["Tag Compare"]
DA0["Data Array<br/>128-bit wide BRAM"]
end
subgraph WAY1["Way 1"]
TA1["Tag Array"] --> TC1["Tag Compare"]
DA1["Data Array<br/>128-bit wide BRAM"]
end
TC0 --> HIT0{"Hit?"}
TC1 --> HIT1{"Hit?"}
HIT0 --> MUX["Output MUX"]
HIT1 --> MUX
MUX --> CPU["CPU"]
Geometry
| Parameter | Value | Description |
|---|---|---|
| Total size | 32768 bytes | 32 KB |
| Line size | 16 bytes | 128 bits per line |
| Ways | 2 | 2-way set-associative |
| Sets | 1024 | 32768 / 16 / 2 |
| Offset bits | 2 | 4 words per line (adr[1:0]) |
| Index bits | 8 | 256 sets (adr[10:2]) |
| Tag bits | 22 | adr[31:11] |
Address Slicing
localparam int OFFSET_BITS = $clog2(LINE_SIZE_BYTES / 4); // 2
localparam int INDEX_BITS = $clog2(SETS); // 8
localparam int TAG_BITS = 32 - OFFSET_BITS - INDEX_BITS; // 22
assign index_in = addr_in[OFFSET_BITS + INDEX_BITS - 1 : OFFSET_BITS];
assign tag_in = addr_in[31 : 32 - TAG_BITS];
Memory Arrays
Each way contains three arrays indexed by the set index:
- Data array:
logic [127:0] data_array_way0 [SETS], inferred as BRAM, 128 bits per line - Tag array:
logic [TAG_BITS-1:0] tag_array_way0 [SETS], stores the upper address bits - Valid array:
logic valid_array_way0 [SETS], single bit per line, reset to 0 on startup - LRU array:
logic lru_array [SETS], 0 = way 0 was least recently used, 1 = way 1 was LRU
Read Operation
On each cycle, both ways’ data and tag arrays are read speculatively using the current index. The tags are compared against the incoming address tag in parallel:
assign hit_way0 = valid_out_way0 && (tag_out_way0 == current_tag);
assign cache_hit = hit_way0 | hit_way1;
Both ways’ data are available, and the outer cache controller selects the correct one based on the hit result and the word offset (adr[1:0]).
Write Operation
When a cache line fill occurs (on a read miss), the controller asserts we_way0 or we_way1 to write the incoming 128-bit line into the appropriate way. The data array is written with byte-level masking via write_mask:
for (int i = 0; i < LINE_SIZE_BYTES; i++) begin
if (write_mask[i]) data_array_way0[index_in][i*8 +: 8] <= write_data[i*8 +: 8];
end
LRU Replacement
On a cache hit, the LRU bit for the current set is updated to mark the other way as LRU:
if (update_lru) begin
lru_array[index_in] <= (accessed_way == 1'b0) ? 1'b1 : 1'b0;
end
On a miss, the eviction candidate is the way indicated by lru_array[index_in] for that set.
Performance Impact
The caches dramatically reduce the average memory access latency:
| Access type | DDR2 (no cache) | Cache hit | Cache miss |
|---|---|---|---|
| Instruction fetch | ~20 cycles | 1 cycle | ~22 cycles (miss + fill) |
| Data load | ~20 cycles | 1 cycle | ~22 cycles |
| Data store | ~20 cycles (write-through) | 1 cycle (DDR2 write ongoing) | ~20 cycles |
For DOOM’s code, the instruction cache hit rate is very high; the game’s inner render loop fits almost entirely within the instruction cache. The data cache hit rate is lower but still provides a significant improvement for frequently accessed globals and rendering intermediate values.
The Instruction Cache and Data Cache controllers are each documented in their own subchapters.
Instruction Cache
Controller Architecture
The instruction cache controller (instruction_cache_controller.sv) manages the fetch path between the CPU and the DDR2 memory via the shared 128-bit Wishbone arbiter. It wraps the cache_memory_subsystem module and adds the hit/miss evaluation, miss handling, and pipeline handshake logic.
flowchart LR
subgraph CPU
FETCH["Fetch Stage"]
end
subgraph ICACHE["ICache Controller"]
EVAL["Hit/Miss Eval"]
MEM["Cache Memory Arrays"]
FSM["FSM<br/>IDLE→EVAL→FETCH"]
end
subgraph DDR2_PATH["Memory Subsystem"]
ARB["128-bit Arbiter"]
BRIDGE["MIG Bridge"]
DDR2["DDR2 SDRAM"]
end
FETCH --> EVAL
EVAL --> MEM
MEM --> FETCH
EVAL --> FSM
FSM --> ARB
ARB --> BRIDGE
BRIDGE --> DDR2
DDR2 --> BRIDGE
BRIDGE --> ARB
ARB --> FSM
State Machine
The controller operates in three states:
stateDiagram-v2
IDLE --> EVALUATE: cpu_bus.cyc && cpu_bus.stb
EVALUATE --> IDLE: !cpu_bus.cyc (CPU abort)
EVALUATE --> IDLE: cache_hit (update LRU)
EVALUATE --> FETCH_DDR: cache_miss
FETCH_DDR --> EVALUATE: mem_bus.ack (line fetched)
FETCH_DDR --> IDLE: mem_bus.err (error)
IDLE
Waits for a valid CPU request. The Wishbone signals cyc and stb indicate the fetch stage is requesting an instruction at the current adr. On receiving both, the controller transitions to EVALUATE.
EVALUATE
In this state, the memory arrays have already been read (they are combinational/inferred-BRAM on the previous cycle). The hit/miss logic compares the tag from cpu_bus.adr[31:11] against both ways’ stored tags:
assign current_tag = { {(32-TAG_BITS){1'b0}}, cpu_bus.adr[31 : 32-TAG_BITS] };
assign hit_way0 = valid_out_way0 && (tag_out_way0 == current_tag);
assign hit_way1 = valid_out_way1 && (tag_out_way1 == current_tag);
assign cache_hit = hit_way0 | hit_way1;
On hit: The controller selects the correct 32-bit word from the 128-bit line based on cpu_bus.adr[1:0], asserts cpu_bus.ack, updates the LRU state, and returns to IDLE for the next request:
case (cpu_bus.adr[1:0])
2'b00: cpu_bus.dat_miso = active_line[31:0];
2'b01: cpu_bus.dat_miso = active_line[63:32];
2'b10: cpu_bus.dat_miso = active_line[95:64];
2'b11: cpu_bus.dat_miso = active_line[127:96];
endcase
On miss: The controller initiates a 128-bit read on the memory bus, aligned to the 16-byte boundary of the requested address:
mem_bus.adr <= {cpu_bus.adr[31:2], 2'b00};
mem_bus.cyc <= 1'b1;
mem_bus.stb <= 1'b1;
mem_bus.we <= 1'b0;
mem_bus.sel <= 16'hFFFF;
FETCH_DDR
Waits for the DDR2 access to complete. The memory bus is monitored for ack (success) or err (error):
- On
ack: The 128-bit data from DDR2 is written into the evicted cache way (determined bylru_evict_way), the tag is updated, and the valid bit is set. The controller returns to EVALUATE to re-read from cache (which now contains the requested line). - On
err: The error is forwarded to the CPU, and the controller returns to IDLE.
The fill writes the full 128-bit line:
write_data <= mem_bus.dat_miso;
if (lru_evict_way == 1'b0) we_way0 <= 1'b1;
else we_way1 <= 1'b1;
state <= STATE_EVALUATE;
Interrupt Handling
Between IDLE and EVALUATE, if the CPU de-asserts cpu_bus.cyc (indicating an interrupt or pipeline flush), the controller aborts the pending access and returns to IDLE. This prevents stale cache operations after a control flow change, which otherwise led to hard faults, as the first instruction of the context switch code within the interrupt handler was being dropped.
Hit Latency
A cache hit completes in a single cycle: the address is presented in EVALUATE, the data is available at the end of the same cycle, and ack is returned to the CPU.
Data Cache
Controller Architecture
The data cache controller (data_cache_controller.sv) handles both load and store operations between the CPU and DDR2. Like the instruction cache, it wraps the shared cache_memory_subsystem module. However, the data cache has additional complexity because it must handle writes, byte-level write masks, and a write-through policy.
flowchart LR
subgraph CPU
LSU["Load/Store Unit<br/>(Memory Stage)"]
end
subgraph DCACHE["Data Cache Controller"]
EVAL["Hit/Miss Eval"]
MEM["Cache Memory Arrays"]
FSM["FSM<br/>Read/Write Handling"]
end
subgraph DDR2_PATH["Memory Subsystem"]
ARB["128-bit Arbiter"]
BRIDGE["MIG Bridge"]
DDR2["DDR2 SDRAM"]
end
LSU --> EVAL
EVAL --> MEM
MEM --> LSU
EVAL --> FSM
FSM --> ARB
ARB --> BRIDGE
BRIDGE --> DDR2
DDR2 --> BRIDGE
BRIDGE --> ARB
ARB --> FSM
Write-Through Policy
The data cache uses a write-through policy: every store is immediately forwarded to the DDR2 memory. The cache is updated in parallel only if the address is already present (a write hit). On a write miss, only the DDR2 is written, no write-allocate is performed.
This design was chosen for simplicity: it avoids the need for a dirty-bit tracking mechanism and simplifies the coherence model (DDR2 always has the latest data). The latency cost is acceptable because store operations do not stall the pipeline in the HaDes-V architecture (the store buffer in the memory stage can absorb the latency as long as it is not immediately followed by a load to the same address).
State Machine
The controller uses a 6-state FSM:
stateDiagram-v2
IDLE --> READ_EVAL: cpu_bus.cyc & cpu_bus.stb & !cpu_bus.we
IDLE --> WRITE_EVAL: cpu_bus.cyc & cpu_bus.stb & cpu_bus.we
READ_EVAL --> IDLE: !cpu_bus.cyc (abort)
READ_EVAL --> IDLE: cache_hit (read, update LRU)
READ_EVAL --> READ_DDR: cache_miss
READ_DDR --> READ_WAIT_BRAM: mem_bus.ack (line fetched)
READ_DDR --> IDLE: mem_bus.err
READ_WAIT_BRAM --> READ_EVAL: fill written to BRAM
WRITE_EVAL --> WRITE_DDR: always (write-through)
WRITE_DDR --> IDLE: mem_bus.ack / mem_bus.err
Read Path
READ_EVAL: Evaluates the cache hit/miss, identically to the instruction cache. On hit, the data is extracted from the correct 128-bit lane based on cpu_bus.adr[1:0] and returned with ack. On miss, the controller issues a 128-bit read to DDR2 and transitions to READ_DDR.
READ_DDR: Waits for the DDR2 access to complete. On ack, the fetched line is written into the evicted cache way, and the controller transitions to READ_WAIT_BRAM. On err, the error is returned.
READ_WAIT_BRAM: A single-cycle wait state to ensure the BRAM write from the fill has completed before the next read attempt. The controller then returns to READ_EVAL, where the data is now present in the cache.
Write Path
WRITE_EVAL: Prepares the write data by aligning cpu_bus.dat_mosi to the correct 128-bit lane based on cpu_bus.adr[1:0]:
case (cpu_bus.adr[1:0])
2'b00: begin lane_write_data[31:0] = cpu_bus.dat_mosi; lane_write_mask[3:0] = cpu_bus.sel; end
2'b01: begin lane_write_data[63:32] = cpu_bus.dat_mosi; lane_write_mask[7:4] = cpu_bus.sel; end
2'b10: begin lane_write_data[95:64] = cpu_bus.dat_mosi; lane_write_mask[11:8] = cpu_bus.sel; end
2'b11: begin lane_write_data[127:96] = cpu_bus.dat_mosi; lane_write_mask[15:12] = cpu_bus.sel; end
endcase
If the address is a cache hit, the cache line is also updated (write-through + cache update):
if (cache_hit) begin
bram_write_data <= lane_write_data;
bram_write_mask <= lane_write_mask;
if (hit_way0) we_way0 <= 1'b1;
if (hit_way1) we_way1 <= 1'b1;
update_lru <= 1'b1;
accessed_way <= hit_way1;
end
The DDR2 write is also initiated in this state. The controller transitions to WRITE_DDR.
WRITE_DDR: Waits for the DDR2 write acknowledgment. On ack, the store is complete, Ack is returned to the CPU, and the state returns to IDLE.
Interrupt Handling
On a read access, if the CPU de-asserts cpu_bus.cyc while in READ_EVAL, the controller aborts and returns to IDLE. During a miss fill (READ_DDR), the cycle continues regardless; the fill completes and the line is written to the cache. This ensures cache consistency even if the original requestor has moved on.
Key Differences from the Instruction Cache
| Aspect | ICache | DCache |
|---|---|---|
| Access type | Read-only | Read + Write |
| Write policy | N/A | Write-through |
| Write-miss allocation | N/A | No (write to DDR2 only) |
| Data alignment | Always 32-bit aligned | Byte/halfword/word via sel |
| States | 3 | 6 |
| Lane extraction | Fixed (instructions are 32-bit) | Write lane alignment per addr |
Hit Latency
Same as the instruction cache: a read hit completes in a single cycle. A write hit takes two cycles (one to issue the DDR2 write, one to wait for acknowledgment), during which the cache is updated on the first cycle.
PS/2 Keyboard Controller
Motivation
DOOM requires real-time keyboard input to play: movement (arrow keys), weapon switching, menu navigation, and action commands (door opening). The original HaDes-V provided switches and buttons, but these are insufficient for the full range of DOOM controls (albeit technically playable with creative mapping up the included switches, though that would not be a very enjoyable playing experience). A PS/2 keyboard interface provides the full keyboard matrix, enabling standard FPS controls (for the era).
PS/2 Receiver (ps2_keyboard.sv)
The PS/2 receiver decodes the asynchronous PS/2 protocol and buffers received scancodes in a hardware FIFO.
Clock Domain Crossing
The PS/2 clock (typically 10–16 kHz) and data signals are asynchronous to the 75 MHz CPU clock. A two-stage flip-flop synchronizer brings both signals into the system clock domain:
synchronizer sync_clk (
.clk(clk),
.async_in(ps2_clk),
.sync_out(ps2_clk_sync)
);
The synchronized clock is fed into a falling-edge detector using a one-cycle delayed version:
assign ps2_clk_falling_edge = (ps2_clk_sync_prev == 1'b1) && (ps2_clk_sync == 1'b0);
Shift Register & Frame Decoding
On each falling edge of the synchronized PS/2 clock, the data bit is shifted into an 11-bit register:
shift_reg <= {ps2_data_sync, shift_reg[10:1]};
PS/2 frames consist of 11 bits: 1 start bit (0), 8 data bits (LSB-first), 1 odd parity bit, and 1 stop bit (1). After 11 bits, the receiver validates the frame:
if (shift_reg[1] == 1'b0 && ps2_data_sync == 1'b1) begin
scancode <= shift_reg[9:2]; // Extract the 8 data bits
frame_valid <= 1'b1;
end
The start bit is checked to be 0 (shift_reg[1]) and the stop bit (ps2_data_sync at bit count 10) to be 1. The 8 data bits are extracted from bits [9:2] of the shift register.
Watchdog Timer
Some keyboards can enter a state where they stop toggling the clock line mid-frame, leaving the receiver in a partial shift state. A watchdog timer monitors for clock inactivity:
if (idle_timer < 16'd50000) begin
idle_timer <= idle_timer + 1;
end else begin
bit_count <= 0; // Force sync reset!
end
If no falling edge is detected for 50,000 clock cycles (~0.67 ms at 75 MHz), the bit counter is reset, allowing the receiver to re-synchronize on the next frame.
Hardware FIFO
A 16-entry FIFO buffers received scancodes, preventing data loss if the CPU is busy when keystrokes arrive:
if (push) begin
fifo_mem[wr_ptr] <= scancode;
wr_ptr <= wr_ptr + 1;
end
if (pop && !empty) begin
rd_ptr <= rd_ptr + 1;
end
The FIFO is configured with depth 16, sufficient to absorb burst input even during rendering-heavy frames.
Wishbone Interface (wishbone_ps2.sv)
The PS/2 receiver is wrapped with a simple Wishbone slave providing two registers:
| Offset | Name | Access | Description |
|---|---|---|---|
| 0x00 | DATA | R | Scancode byte (auto-pops FIFO on read) |
| 0x04 | STATUS | R | Bit 0: empty (1 = FIFO empty) |
Reading the DATA register when the FIFO is non-empty pops the oldest scancode and returns it. The STATUS register reports the FIFO’s empty flag for polling-based drivers.
Software Driver (keyboard.c)
The software layer provides simple functions for the DOOM HAL:
keyboard_has_data(): Polls the STATUS register’s empty flagkeyboard_get_scancode(): Reads the DATA register (non-blocking)keyboard_wait_for_scancode(): Busy-waits for a keystrokekeyboard_scancode_to_ascii(): Translates PS/2 Set 2 scan codes to ASCII characters
The scancode-to-ASCII table covers all letters (A–Z), digits (0–9), and common control keys (space, enter, escape, backspace, tab). Special keys (arrows, modifiers) have their own defines in keyboard.h and are handled by DOOM’s input layer in hades_v_doom.c.
VGA Controller Rework
Motivation
The original HaDes-V VGA controller was a simple bitmap display designed for the Basys 3’s limited resources. For DOOM, three fundamental changes were needed:
-
Double-buffering: DOOM draws every pixel each frame by writing to a framebuffer. If the VGA reads the same buffer that the CPU writes, tearing and visual artifacts are unavoidable. A double-buffered design allows the CPU to render to an off-screen buffer while the VGA displays the completed frame.
-
Native resolution and color format: DOOM’s internal renderer produces 320×200 pixels with 8-bit palette-indexed color. The VGA controller must accept this format directly to avoid software format conversion.
-
DMA-style framebuffer copy: DOOM’s architecture expects a single, unchanging framebuffer after
I_FinishUpdate()is called, it assumes the new frame appears atomically. The hardware blitter bridges this expectation by copying the freshly rendered inactive buffer to the now-inactive bank after a swap, preserving the complete frame content regardless of CPU rendering progress in the next frame.
Memory Layout
The VGA controller maps into the Wishbone address space in a single 32 KB window:
| Address Range | Size | Usage |
|---|---|---|
| 0x0000 – 0x3FFF | 16 KB | Inactive framebuffer: CPU writes into this bank while rendering |
| 0x4000 – 0x7FFF | 16 KB | Active framebuffer: displayed on screen; CPU reads for copy |
| 0x8000 – 0x80FF | 256 B | Palette: 256 entries × 12-bit RGB (4 bits per channel) |
| 0x8100 | 4 B | Control register: bit 0: front buffer select |
Double-Buffering
The two framebuffer banks are stored in a 32,768 * 32-bit BRAM array. The mapping between CPU-visible addresses and physical BRAM addresses is transparently remapped based on the current front buffer selection:
always_comb begin
if (wb_offset[14] == 1'b0)
mapped_fb_addr = {~cpu_front_buffer, wb_offset[13:0]};
else
mapped_fb_addr = {cpu_front_buffer, wb_offset[13:0]};
end
The CPU always writes to the lower 16 KB (offset 0x0000) which is remapped to the inactive bank. The upper 16 KB (offset 0x4000) always reads from the active bank and is used by the hardware copy logic.
Hardware Blitter
After a buffer swap is asserted by the CPU writing to the control register, the swap request is synchronized to the VGA clock domain. The VGA applies the swap at the start of vblank, and the acknowledgment is synchronized back to the CPU domain.
Once the VGA confirms the swap, a hardware copy engine sequentially copies all 16,384 pixels from the active bank to the now-inactive bank. This ensures that the newly inactive bank contains the complete previous frame, so the CPU can begin drawing the next frame without partial-content artifacts:
if (!is_copying && (vga_fb_clk_domain != last_vga_fb_state)) begin
is_copying <= 1;
copy_idx <= 0;
end else if (is_copying) begin
if (copy_read_phase) begin
copy_read_phase <= 0; // Read: framebuffer[active, copy_idx]
end else begin
if (copy_idx == 14'h3FFF) is_copying <= 0;
else begin
copy_idx <= copy_idx + 1;
copy_read_phase <= 1; // Write: framebuffer[inactive, copy_idx]
end
end
end
During the copy, the CPU is stalled if it attempts to write to the framebuffer region (wb_offset < 0x8000) — the ack is withheld until the copy completes. This prevents write-after-write hazards to the buffer being copied from.
VGA Timing Generator
The timing generator produces standard 640×480 at 60 Hz VGA signals:
| Parameter | Pixels |
|---|---|
| Horizontal visible | 640 |
| Horizontal front porch | 16 |
| Horizontal sync | 96 |
| Horizontal back porch | 48 |
| Vertical visible | 480 |
| Vertical front porch | 10 |
| Vertical sync | 2 |
| Vertical back porch | 33 |
DOOM Rendering Pipeline
DOOM’s 320×200 internal resolution is scaled to 640×480 through pixel doubling and letterboxing:
assign doom_x = vga_x[9:1]; // Horizontal: divide by 2
assign doom_y = (vga_y - 10'd40) >> 1; // Vertical: -40 letterbox, divide by 2
Each DOOM pixel occupies a 2×2 block on the 640×480 display, centered vertically with a 40-pixel letterbox at the top and bottom.
Byte Address Calculation
The DOOM pixel address uses a multiply-by-320 layout:
assign doom_byte_addr = (doom_y << 8) + (doom_y << 6) + doom_x; // y*320 + x
The address is then mapped to the active framebuffer:
assign fb_vga_addr = doom_byte_addr[15:2] + (vga_front_buffer ? 15'd16384 : 15'd0);
Pixel Pipeline
The rendering pipeline is 3 stages deep to meet VGA pixel clock timing:
- Byte select: Extract the correct byte (0–3) from the 32-bit BRAM word based on
doom_byte_addr[1:0] - Palette lookup: Index the 256 × 12-bit palette BRAM with the byte value
- Output: Drive R/G/B (4 bits each) to the VGA DAC, gated by the active area signal
The pipeline also delays H-sync and V-sync by the same 3 cycles to maintain alignment with the pixel data.
Wishbone Interface
| Offset | Access | Description |
|---|---|---|
| 0x0000 – 0x3FFF | R/W | Inactive framebuffer (remapped) |
| 0x4000 – 0x7FFF | R | Active framebuffer (read-only) |
| 0x8000 – 0x80FF | R/W | Palette registers (12-bit RGB) |
| 0x8100 | R/W | Control: bit 0 = buffer select, bit 1 = busy flag |
Writing to the control register triggers the buffer swap sequence. The busy flag can be polled to determine when the hardware copy has completed.
Audio Subsystem
Motivation
Sound is an integral part of the DOOM experience: weapon noises, monster growls, door sounds, and pickup effects provide crucial gameplay feedback. While the base HaDes-V system had no audio output, the Nexys 4 DDR provides an onboard audio amplifier (connected to a 3.5 mm jack) driven by a single PWM-capable GPIO pin. This makes a simple PWM audio output feasible with minimal external hardware.
PWM Audio Principle
Pulse-width modulation (PWM) converts a digital sample value into a variable-duty-cycle square wave. When passed through a low-pass filter (the audio amplifier circuit on the Nexys 4 DDR has an integrated RC filter), the average voltage corresponds to the sample value. An 8-bit PWM operating at ~293 kHz (75 MHz / 256) provides adequate audio quality for 8-bit mono sound effects.
Wishbone Audio Interface (wishbone_audio.sv)
The audio peripheral is a single self-contained module combining a FIFO buffer, a sample-rate timer, and a PWM modulator.
Circular Buffer
An 8 KB BRAM buffer holds up to 8192 8-bit PCM samples in a circular configuration:
logic [7:0] fifo_ram [0:8191];
logic [13:0] wr_ptr;
logic [13:0] rd_ptr;
Write and read pointers use 14-bit values: the lower 13 bits address the BRAM, while the 14th bit distinguishes between a full and empty FIFO. The fill level is:
assign fifo_count = wr_ptr - rd_ptr;
assign fifo_free = 14'd8192 - fifo_count;
Sample Rate Generation
DOOM’s sound engine operates at 11,025 Hz with 8-bit unsigned PCM samples (center value 128 = silence). The sample rate is derived from the 75 MHz system clock:
localparam int CLK_DIV_11KHZ = 6801; // 75,000,000 / 11,025 ≈ 6801
A 13-bit counter generates a tick every 6801 clock cycles, advancing the read pointer and loading the next sample into the PWM modulator:
if (tick_11k) begin
if (fifo_count > 0) begin
current_sample <= fifo_ram[rd_ptr[12:0]];
rd_ptr <= rd_ptr + 1;
end else begin
current_sample <= 8'd128; // Silence when starved
end
end
PWM Modulator
The modulator compares the current sample against a free-running 8-bit counter:
pwm_counter <= pwm_counter + 1;
aud_pwm <= (current_sample > pwm_counter) ? 1'b1 : 1'b0;
This produces a PWM signal at ~293 kHz (75 MHz / 256). The duty cycle is proportional to the sample value (0 = always low, 255 = always high, 128 = 50% duty = center voltage).
The audio amplifier shutdown signal is permanently asserted high:
assign aud_sd = 1'b1;
Wishbone Register Map
| Offset | Access | Description |
|---|---|---|
| 0x00 | W | Push an 8-bit PCM sample into the FIFO |
| 0x04 | R | Read: free space in the FIFO (14-bit value) |
The CPU polls the free space register and writes batches of samples. A write to offset 0 pushes one byte into the FIFO if there is space. The FIFO automatically drains at 11,025 Hz.
Integration
The audio peripheral is connected to the Wishbone bus alongside the other peripherals. The MCU instantiation in defines/constants.sv assigns it an address in the peripheral range. The PWM output (aud_pwm) and amplifier shutdown (aud_sd) are routed to the Nexys 4 DDR’s audio amplifier header pins (A11 and D12 respectively) via the constraints file.
The software-side audio driver, which feeds PCM samples from DOOM’s sound mixer into the Wishbone FIFO, is documented in the Software chapter.
Build System & Toolchain
Compiler Toolchain
The HaDes-V CPU targets the RV32IM instruction set (RV32I base + M-Extension). The toolchain used is the standard RISC-V GNU toolchain:
CC = /opt/riscv32i/bin/riscv32-unknown-elf-gcc
OBJCOPY = /opt/riscv32i/bin/riscv32-unknown-elf-objcopy
OBJDUMP = /opt/riscv32i/bin/riscv32-unknown-elf-objdump
The cross-compiler targets rv32im with no operating system (bare-metal), producing ELF binaries that are converted to Intel HEX format for loading.
Essentially, this is the exact same toolchain as used for the original HaDes-V lab exercises, with the addition of the -mstrict-align flag to enforce natural alignment of memory accesses.
Optimization Flags
Standard -O2 optimization caused spurious, hard-to-trace runtime issues on the HaDes-V platform. Instead, individual optimization flags were selectively enabled and tested on hardware:
OPT_FLAGS = -fbit-tests
OPT_FLAGS += -finline
OPT_FLAGS += -finline-functions-called-once
OPT_FLAGS += -fcprop-registers
OPT_FLAGS += -fif-conversion
OPT_FLAGS += -fif-conversion2
OPT_FLAGS += -fforward-propagate
OPT_FLAGS += -fdefer-pop
OPT_FLAGS += -fdce
OPT_FLAGS += -fdse
OPT_FLAGS += -fauto-inc-dec
OPT_FLAGS += -fcompare-elim
OPT_FLAGS += -fcombine-stack-adjustments
OPT_FLAGS += -fbranch-count-reg
-fbit-tests is particularly important for DOOM, as the engine makes heavy use of bitfield flags for entity state, door status, and level geometry properties. -finline and -finline-functions-called-once are critical for the rendering hot path, eliminating function call overhead in the inner column-rendering loops.
The -mstrict-align flag is required because the HaDes-V pipeline does not implement hardware support for misaligned loads and stores.
Linker Script (std/hades-v.ld)
The linker script defines two memory regions:
MEMORY {
BRAM (rwx) : ORIGIN = 0x40000, LENGTH = 32K
DDR (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
}
All program sections (.text, .rodata, .data, .bss, .sdata, .sbss) are allocated in DDR2. The BRAM region is reserved for the bootloader and critical interrupt vectors but is not used by the main program. The heap starts at _end:
PROVIDE( _end = . );
The __global_pointer$ symbol is calculated to enable GP-relative addressing for small data sections (.sdata/.sbss), reducing the instruction count for frequently accessed global variables:
__global_pointer$ = MAX((ADDR(.rodata) + 0x800), (ADDR(.sbss) + SIZEOF(.sbss) - 0x800));
The bootloader itself is still built with the original HaDes-V lab template, which places it in BRAM. The bootloader loads the main DOOM binary from the SD card into DDR2 and jumps to its entry point.
WAD Integration
The DOOM WAD file is embedded into the binary at link time using the linker’s binary-to-object capability:
$(BUILD_DIR)/$(C_DIR)/doom/doom1_wad.o: test/c/doom/doom1.wad
$(OBJCOPY) -I binary -O elf32-littleriscv -B riscv \
--rename-section .data=.rodata \
$< $@
This produces an object file containing the WAD data with three exported symbols:
extern const unsigned char _binary_doom1_wad_start[];
extern const unsigned char _binary_doom1_wad_end[];
extern const unsigned char _binary_doom1_wad_size;
The WAD data is placed in .rodata and thus ends up in DDR2 alongside the code and constants.
Makefile Structure
The Makefile was extended from the original lab template:
- Board parameter:
BOARD ?= basys3selects the synthesis target - DOOM target: builds the main DOOM binary combining the DOOM source, the HaDes-V HAL, and the embedded WAD
doom: $(BUILD_DIR)/$(C_DIR)/doom/doom.hex
$(BUILD_DIR)/$(C_DIR)/doom/doom.elf: ... $(DOOM_SOURCES)
$(CC) $(CFLAGS) $(OPT_FLAGS) $(DOOM_SOURCES) -o $@
%.hex: %.elf
$(OBJCOPY) -O ihex $< $@
Synthesis Integration
When building the full bitstream with make synthesis BOARD=nexys4ddr, the DOOM hex file is placed on the SD card and loaded by the BootROM. The FPGA bitstream is targeted at the XC7A100T part with the Nexys 4 DDR constraint file.
Standard Library
The base HaDes-V architecture provides only a minimal runtime: basic UART output, LED control, and a timer. DOOM requires a substantial subset of the C standard library, including string functions, memory allocation, formatted output, and file I/O. These were implemented in hades_stdlib.c.
Memory Allocation
DOOM uses a zone-based allocator (Z_Malloc/Z_Free) internally, but the zone allocator itself relies on a lower-level malloc/free/realloc/calloc to obtain its initial memory pool. A simple bump allocator was implemented:
static unsigned char *heap_ptr = NULL;
void *malloc(size_t size) {
if (heap_ptr == NULL) {
heap_ptr = (unsigned char *)&_end;
}
uint32_t aligned_addr = ((uint32_t)heap_ptr + 7) & ~7;
heap_ptr = (unsigned char *)aligned_addr;
void *allocated = heap_ptr;
heap_ptr += size;
return allocated;
}
The _end symbol is defined by the linker script and points to the first address after the BSS section in DDR2. The allocator always aligns to 8 bytes and grows upward into DDR2 memory. There is no real free(), as DOOM’s zone allocator manages its own freed blocks internally, and the bump allocator’s main purpose is to provide the initial pool. realloc is implemented by allocating new memory and copying the old data; calloc wraps malloc with memset to zero the block.
String & Memory Functions
All standard string and memory functions were implemented from scratch:
- Memory:
memset,memcpy,memmove - String length/comparison:
strlen,strcmp,strncmp,strcasecmp,strncasecmp - String search:
strchr,strrchr - String copy/duplicate:
strncpy,strdup - Conversion:
atoi,toupper,tolower,isspace
int strcasecmp(const char *s1, const char *s2) {
while (1) {
unsigned char c1 = *s1, c2 = *s2;
if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
if (c1 != c2) return c1 - c2;
if (c1 == '\0') return 0;
s1++; s2++;
}
}
DOOM uses case-insensitive string comparisons for WAD lump name lookups (strcasecmp/strncasecmp), making these particularly important for correct WAD loading.
Formatted Output
A custom format-string parser provides the printf/sprintf/snprintf/fprintf family. The format parser supports:
| Specifier | Description |
|---|---|
%s | Null-terminated string |
%d / %i | Signed decimal integer |
%x / %X | Lowercase/uppercase hexadecimal |
%c | Single character |
%% | Literal percent sign |
%0Nd / %Nx | Zero-padded width specifier |
The printf implementation routes output through the UART (uart_print_str). sprintf/snprintf write to a provided buffer. The implementation handles negative values, width padding, and edge cases such as null pointer strings (“(null)”).
Pseudo-Filesystem
DOOM’s startup code opens the WAD file using standard C stdio functions. Since there is no filesystem, the stdio functions were overridden to operate on the embedded WAD data:
FILE *fopen(const char *filename, const char *mode) {
if (/* filename ends in ".wad" */) {
virtual_file_offset = 0;
return (FILE *)1; // Dummy non-null handle
}
return NULL;
}
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream) {
size_t total_bytes = size * nmemb;
memcpy(ptr, (void*)(_binary_doom1_wad_start + virtual_file_offset), total_bytes);
virtual_file_offset += total_bytes;
return nmemb;
}
fseek, ftell, and fclose are implemented to manipulate a single virtual_file_offset counter. fwrite, remove, rename, mkdir are no-ops or error stubs. This approach avoids modifying DOOM’s underlying file-reading code while transparently redirecting all WAD access to the linked-in binary data.
Compiler Stubs
The RISC-V GCC toolchain expects certain symbols that are not provided by a bare-metal environment:
static int dummy_impure[256];
void* _impure_ptr = dummy_impure;
int __errno = 0;
_impure_ptr is required by newlib-compatible libraries for reentrancy support. On bare metal without threading, a static dummy structure suffices. __errno provides errno storage used by DOOM’s error handling paths.
Hardware Abstraction Layer & DOOM Porting
Overview
The DOOM source tree used for this project is based on doomgeneric, a platform-abstraction layer that extracts DOOM’s game logic from platform-specific code (SDL, video, audio, input) into a set of callback functions. The port to HaDes-V implements these callbacks in hades_v_doom.c, connecting DOOM’s engine to the custom hardware peripherals described in the Hardware section.
flowchart TD
DG["doomgeneric API"] --> HAL["hades_v_doom.c"]
HAL --> VGA["VGA Controller"]
HAL --> PS2["PS/2 Keyboard"]
HAL --> FIFO["Audio FIFO"]
HAL --> MCYCLE["mcycle CSR"]
HAL --> UART["UART Debug"]
Initialization (DG_Init)
DG_Init() is called once at startup after the BSS has been zeroed and the trap handler installed. It sets up:
-
VGA palette: Writes a default gray-scale palette (256 entries, 12-bit RGB) to the VGA controller’s palette registers at address
VGA_PALETTE_START_ADDRESS. The palette entries map DOOM’s 8-bit playpal indices to 4-bit-per-channel hardware values. -
Video buffer pointer: Sets
I_VideoBufferto point atVGA_START_WORD_ADDRESS, the Wishbone address of the inactive framebuffer bank. DOOM writes all pixel data through this pointer; the address remapping in the VGA controller directs writes to the off-screen buffer.
void DG_Init() {
volatile uint32_t* vga_pal = VGA_PALETTE_START_ADDRESS;
for (int i = 0; i < 256; i++) {
uint32_t r = (i >> 5) & 0x07; r = (r << 1) | (r >> 2);
uint32_t g = (i >> 2) & 0x07; g = (g << 1) | (g >> 2);
uint32_t b = (i >> 0) & 0x03; b = (b << 2) | (b >> 0);
vga_pal[i] = (r << 8) | (g << 4) | b;
}
I_VideoBuffer = VGA_START_WORD_ADDRESS;
}
Frame Completion (I_FinishUpdate)
Called at the end of each rendered frame. This function is the heart of the frame timing loop:
void I_FinishUpdate(void) {
static uint8_t active_buffer = 0;
active_buffer = !active_buffer;
*vga_ctrl = active_buffer; // Flip VGA buffer
volatile uint32_t dummy = vga_fb[0]; // Wait for copy to start
(void)dummy;
update_audio_mixer(); // Feed audio FIFO
// FPS profiling via mcycle CSR
uint32_t current_mcycle = read_mcycle();
if (last_mcycle != 0) {
total_frame_cycles += (current_mcycle - last_mcycle);
frame_count++;
}
last_mcycle = current_mcycle;
if (frame_count == 60) {
uint32_t avg_cycles = total_frame_cycles / 60;
uint32_t fps = 75000000 / avg_cycles;
printf("... ~%d FPS\n", fps);
frame_count = 0;
total_frame_cycles = 0;
}
}
The buffer flip triggers the hardware blitter. The dummy read from vga_fb[0] serves as a barrier: it forces the Wishbone bus to stall until the blitter completes the copy, ensuring the rendered frame is fully visible before the next frame starts. The audio mixer is then called to refill the audio FIFO. Finally, the cycle counter measures frame time for the rolling 60-frame FPS log.
Palette Update (I_SetPalette)
DOOM calls I_SetPalette() when the game palette changes (e.g., for the pain palette flash or the invulnerability effect). The function converts DOOM’s 24-bit RGB palette to the VGA’s 12-bit (4 bits per channel) format:
void I_SetPalette(byte* palette) {
for (int i = 0; i < 256; i++) {
uint8_t r = gammatable[usegamma][*palette++] >> 4;
uint8_t g = gammatable[usegamma][*palette++] >> 4;
uint8_t b = gammatable[usegamma][*palette++] >> 4;
hw_palette[i] = (r << 8) | (g << 4) | b;
}
}
Gamma correction is applied via DOOM’s built-in gammatable lookup, using the current usegamma setting (adjustable in-game).
Timing (DG_SleepMs, DG_GetTicksMs)
Busy-wait sleep: DG_SleepMs() uses a calibrated NOP loop based on the 75 MHz clock frequency. Each iteration of the loop body takes approximately 3 cycles on the HaDes-V pipeline:
void DG_SleepMs(uint32_t ms) {
uint32_t loops = (75000 / 3) * ms;
while (loops--) { asm volatile ("nop"); }
}
Cycle counter: DG_GetTicksMs() reads the RISC-V mcycle/mcycleh CSRs using the standard double-read verification pattern to handle the 64-bit counter on a 32-bit CPU:
uint32_t DG_GetTicksMs() {
uint32_t mcycle_lo, mcycle_hi, mcycle_hi_verify;
do {
asm volatile ("csrr %0, mcycleh" : "=r"(mcycle_hi));
asm volatile ("csrr %0, mcycle" : "=r"(mcycle_lo));
asm volatile ("csrr %0, mcycleh" : "=r"(mcycle_hi_verify));
} while (mcycle_hi != mcycle_hi_verify);
uint64_t total_cycles = ((uint64_t)mcycle_hi << 32) | mcycle_lo;
return (uint32_t)(total_cycles / 75000);
}
These functions could have been potentially improved using timer interrupts, but the busy-wait approach is sufficient for DOOM’s timing needs and keeps the implementation simple.
Input (DG_GetKey)
The keyboard input handler processes PS/2 scancodes from the hardware FIFO and translates them to DOOM’s internal key codes. It tracks two protocol state flags:
is_break: Set when a0xF0(break code) byte is received, the next scancode indicates a key release.is_extended: Set when an0xE0(extended code) byte is received — the next scancode is from the extended set (arrows, keypad).
int DG_GetKey(int* pressed, unsigned char* doomKey) {
while (keyboard_has_data()) {
uint8_t scancode = keyboard_get_scancode();
if (scancode == PS2_CODE_EXTENDED) { is_extended = 1; continue; }
if (scancode == PS2_CODE_BREAK) { is_break = 1; continue; }
*pressed = !is_break;
is_break = 0;
// Map scancode to DOOM key
unsigned char mapped_key = 0;
switch (scancode) {
case PS2_KEY_LCTRL: mapped_key = KEY_FIRE; break;
case PS2_KEY_SPACE: mapped_key = KEY_USE; break;
case PS2_KEY_UP: mapped_key = KEY_UPARROW; break;
// ... more key mappings
}
if (mapped_key != 0) {
*doomKey = mapped_key;
return 1;
}
}
return 0;
}
The key mapping translates typical FPS controls: WASD → movement arrows, Ctrl → fire, Space → use/open, arrow keys → strafe/look, Shift → run, number keys → weapon switching.
Trap Handler
An assembly-installed interrupt handler at mtvec catches all exceptions (machine faults, illegal instructions, ecalls) and interrupts. Machine-level interrupts (timer, external) are acknowledged and returned from silently. All other traps trigger a full register dump to UART, light all LEDs, and halt:
__attribute__((interrupt))
void trap_handler() {
uint32_t mcause;
asm volatile("csrr %0, mcause" : "=r"(mcause));
if (mcause & (1 << 31)) { ... return; } // Interrupt
dump_cpu_state(); // Exception
*LEDS_ADDRESS = 0xFFFF;
while(1) { asm volatile ("wfi"); }
}
Entry Point
The main() function arms the trap handler, clears the BSS, prints a startup banner, creates a fake argv with -iwad doom1.wad arguments, and enters the DOOM main loop:
int main(int argc, char **argv) {
asm volatile("csrw mtvec, %0" :: "r"(trap_handler));
// Clear BSS
char *dummy_argv[] = {"doom", "-iwad", "doom1.wad", NULL};
doomgeneric_Create(3, dummy_argv);
while (1) { doomgeneric_Tick(); }
}
Audio Implementation
Sound Module Architecture
DOOM’s sound system is exposed to the port through the sound_module_t structure, which is populated with HaDes-V-specific callback functions in hades_v_doom.c. The audio subsystem supports 8 simultaneous sound channels with software mixing feeding into the hardware PWM FIFO.
flowchart LR
subgraph SOUND["DOOM Sound Engine"]
START["HW_StartSound"] --> LUMP["W_CacheLumpNum"]
LUMP --> PCM["Extract PCM from lump"]
end
subgraph MIXER["Software Mixer"]
CH["8 Channels"] --> MIX["update_audio_mixer"]
MIX --> CLIP["Clamp [-128,127]"]
end
subgraph HW["Hardware"]
CLIP --> FIFO["8 KB PWM FIFO<br/>(11,025 Hz drain)"]
FIFO --> PWM["PWM Modulator"]
PWM --> JACK["3.5mm Jack"]
end
Channel Management
Eight sound channels are defined as a static array of sound_channel_t structures:
#define NUM_CHANNELS 8
typedef struct {
const uint8_t* data; // Pointer to PCM sample data in WAD
uint32_t length; // Number of samples
uint32_t position; // Current playback position
uint8_t active; // 1 = currently playing
} sound_channel_t;
static sound_channel_t audio_channels[NUM_CHANNELS];
Playing a Sound (HW_StartSound)
When DOOM wants to play a sound effect, HW_StartSound is called with the sound’s sfxinfo_t structure. The function:
- Caches the sound lump from the WAD using DOOM’s
W_CacheLumpNum(the lump number is pre-resolved byHW_GetSfxLumpNum) - Parses the DOOM sound lump header: byte 4–7 contain the number of samples (little-endian 32-bit), and byte 8 onwards are the raw 8-bit unsigned PCM data
- Sets up the channel with the PCM data pointer and length
static int HW_StartSound(sfxinfo_t *sfxinfo, int channel, int vol, int sep) {
void* raw_lump = W_CacheLumpNum(sfxinfo->lumpnum, PU_STATIC);
uint32_t num_samples = *((uint32_t*)((uint8_t*)raw_lump + 4));
uint8_t* pcm_data = (uint8_t*)raw_lump + 8;
audio_channels[channel].data = pcm_data;
audio_channels[channel].length = num_samples;
audio_channels[channel].position = 0;
audio_channels[channel].active = 1;
return channel;
}
Sound Query & Control
HW_StopSound(channel): Sets the channel’s active flag to 0, stopping playbackHW_SoundIsPlaying(channel): Returns the active flagHW_UpdateSoundParams(channel, vol, sep): No-op — volume and panning are applied during mixing
Software Mixer (update_audio_mixer)
The mixer is called once per frame from I_FinishUpdate(). It polls the hardware FIFO’s free space and fills it with mixed samples:
void update_audio_mixer(void) {
uint32_t free_space = AUDIO_ADDRESS[AUDIO_FREE_SPACE_OFFSET];
if (free_space == 0) return;
for (uint32_t i = 0; i < free_space; i++) {
int32_t mixed_sample = 0;
for (int c = 0; c < NUM_CHANNELS; c++) {
if (audio_channels[c].active) {
int8_t sample = (int8_t)(audio_channels[c].data[audio_channels[c].position] - 127);
mixed_sample += sample;
audio_channels[c].position++;
if (audio_channels[c].position >= audio_channels[c].length) {
audio_channels[c].active = 0; // Auto-stop
}
}
}
// Clamp and convert back to unsigned
if (mixed_sample > 127) mixed_sample = 127;
if (mixed_sample < -128) mixed_sample = -128;
uint8_t final_sample = (uint8_t)(mixed_sample + 128);
AUDIO_ADDRESS[AUDIO_DATA_OFFSET] = final_sample;
}
}
The mixing process:
- Each sample is converted from unsigned (0–255, center=128) to signed (-128–127, center=0) by subtracting 128
- All active channels’ current samples are summed (simple additive mixing without attenuation)
- The sum is clamped to the 8-bit signed range
- Converted back to unsigned and pushed into the hardware FIFO
- Each channel’s position is advanced; completed sounds are auto-deactivated
The mixer fills exactly as many slots as the FIFO has available, ensuring it never blocks. The FIFO drains autonomously at 11,025 Hz, so the mixer is called at frame rate (~10–30 Hz) and refills whatever has been consumed since the last frame.
Lump Name Resolution (HW_GetSfxLumpNum)
DOOM sound lumps in the WAD follow the naming convention DSxxxxx (e.g., DSPISTOL for the pistol sound). The function constructs this name from the sound’s internal name, converts lowercase to uppercase, and looks it up in the WAD directory:
static int HW_GetSfxLumpNum(sfxinfo_t *sfx) {
char namebuf[9];
namebuf[0] = 'D';
namebuf[1] = 'S';
int i = 0;
while (sfx->name[i] != '\0' && i < 6) {
char c = sfx->name[i];
if (c >= 'a' && c <= 'z') c -= 32;
namebuf[2 + i] = c;
i++;
}
namebuf[2 + i] = '\0';
return W_GetNumForName(namebuf);
}
Music Module
The music module is fully stubbed out. DOOM’s music playback (MUS format → MIDI → synthesized audio) would require a significantly more complex audio pipeline, including a MIDI synthesizer or a MUS-to-PCM converter. Since the hardware only supports simple 8-bit PCM output via PWM, music was disabled. All music callback functions return immediately with no-ops or dummy success values. The game plays sound effects without background music, providing adequate gameplay feedback.
Hardware FIFO Integration
The hardware FIFO is accessed through Wishbone-mapped registers at peripheral address offsets. The mixer writes to AUDIO_DATA_OFFSET (offset 0), which pushes a single byte into the 8 KB circular buffer. The free space is read from AUDIO_FREE_SPACE_OFFSET (offset 4), returning the number of samples that can be written without overflow. The hardware independently drains the FIFO at 11,025 Hz and feeds samples into the PWM modulator.
WAD Embedding & Memory-Mapped I/O
Motivation
DOOM loads all game data (levels, graphics, sounds, textures) from a WAD file at runtime. On a conventional platform, this file is read from the filesystem. On the HaDes-V system, there is no filesystem, the program and all its data must be embedded in the binary before loading. The challenge is that DOOM’s code is designed around fopen/fread/fseek/fclose calls, and modifying every file-accessing code path would be impractical.
The solution has two layers: WAD linking at build time to embed the data, and pseudo-filesystem stubs to redirect stdio calls to the embedded data.
WAD Linking
The 4 MB doom1.wad is converted into a linkable object file using objcopy:
$(BUILD_DIR)/$(C_DIR)/doom/doom1_wad.o: test/c/doom/doom1.wad
$(OBJCOPY) -I binary -O elf32-littleriscv -B riscv \
--rename-section .data=.rodata \
$< $@
This produces a relocatable ELF object containing the raw WAD bytes in the .rodata section. Three symbols are automatically generated:
extern const unsigned char _binary_doom1_wad_start[]; // First byte
extern const unsigned char _binary_doom1_wad_end[]; // Last byte
extern const unsigned char _binary_doom1_wad_size; // Size as address
At link time, the WAD object is linked into the final DOOM ELF binary alongside the game code and the HaDes-V HAL. Since .rodata is placed in DDR2 by the linker script, the WAD data ends up in the 128 MB DDR2 region, where it can be accessed via normal memory reads.
Pseudo-Filesystem in hades_stdlib.c
Without a filesystem, the C stdio functions that DOOM uses to open and read the WAD must be intercepted. The approach is to override fopen, fread, fseek, ftell, and fclose with implementations that operate on the linked-in WAD data:
static size_t virtual_file_offset = 0;
FILE *fopen(const char *filename, const char *mode) {
// Only accept .wad files
if (/* filename ends in "wad" or "WAD" */) {
virtual_file_offset = 0;
return (FILE *)1; // Non-null dummy handle
}
return NULL;
}
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream) {
size_t total_bytes = size * nmemb;
memcpy(ptr, (void*)(_binary_doom1_wad_start + virtual_file_offset), total_bytes);
virtual_file_offset += total_bytes;
return nmemb;
}
int fseek(FILE *stream, long offset, int whence) {
if (whence == 0) virtual_file_offset = offset; // SEEK_SET
else if (whence == 1) virtual_file_offset += offset; // SEEK_CUR
return 0;
}
long ftell(FILE *stream) {
return virtual_file_offset;
}
int fclose(FILE *stream) { return 0; }
The single virtual_file_offset variable tracks the current read position within the WAD. Since DOOM only opens one WAD file at a time, there is no need for per-file offset storage. The FILE* handle returned by fopen is a dummy non-null pointer, the actual data access goes directly to the linked-in WAD array.
Memory-Mapped I/O (memio.c)
In addition to the pseudo-filesystem approach, the DOOM source also provides a memio.c module that implements a more complete memory-based I/O abstraction with MEMFILE objects:
typedef struct {
unsigned char *buf;
size_t buflen;
size_t alloced;
unsigned int position;
memfile_mode_t mode;
} MEMFILE;
mem_fopen_read() and mem_fopen_write() create read-mode and write-mode memory file objects respectively. The read-mode variant wraps an existing buffer; the write-mode variant starts with a 1 KB buffer that auto-grows by doubling when full. All memory is allocated through DOOM’s zone allocator (Z_Malloc/Z_Free).
This module was used to replace DOOM’s file-based WAD reading in w_wad.c. Instead of calling fopen/fread on the WAD file, the modified w_wad.c calls mem_fopen_read on the linked-in _binary_doom1_wad_start data:
// In modified w_wad.c:
// Open the WAD as a memory file instead of a filesystem file
wad_file = mem_fopen_read((void*)_binary_doom1_wad_start, (size_t)&_binary_doom1_wad_size);
Combined Data Flow
At boot, the entire WAD is already present in DDR2 memory (loaded as part of the program binary). DOOM’s startup sequence:
main()callsdoomgeneric_Create()with-iwad doom1.wadarguments- DOOM’s WAD system parses the IWAD parameter and calls
fopen("doom1.wad", "rb") - The pseudo-filesystem’s
fopenmatches the.wadextension and returns a valid handle - All subsequent reads go through
fread/fseek, which operate on the_binary_doom1_wad_startarray - Lump data is cached in the zone allocator (backed by DDR2) as needed during gameplay
This approach required zero modifications to DOOM’s core WAD reading logic, only the stdio layer was redirected.
Performance Optimizations
Overview
DOOM on HaDes-V runs at 75 MHz with a sizable memory hierarchy (caches, DDR2), but the CPU is still substantially slower than the original target hardware of 1993 (486 at 33–66 MHz). Every cycle counts. The accumulated optimizations described here brought the game from an unplayable slideshow to a playable 10–30 FPS experience. A rolling 60-frame FPS average using the mcycle CSR validated each optimization on hardware.
Column Rendering (r_draw.c)
The single most impactful change was in the wall and sprite column rendering. DOOM draws surfaces one vertical span at a time. The original doomgeneric implementation wrote each pixel individually through the generic video buffer interface:
for (i = 0; i < pixel_count; i++) {
I_VideoBuffer[pixel_start + i] = color;
}
The optimized version writes directly to the framebuffer using 32-bit word-aligned stores. Since DOOM’s 320×200 framebuffer is byte-packed, a single 32-bit Wishbone transaction covers four consecutive pixels. The inner loop packs four color values into a word and writes them in one bus transaction, converting the rendering path from a per-pixel bottleneck to a per-word data movement:
uint32_t *fb_words = (uint32_t *)I_VideoBuffer;
uint32_t word_offset = pixel_start >> 2;
uint32_t packed = (color << 24) | (color << 16) | (color << 8) | color;
fb_words[word_offset] = packed;
This change alone accounted for the majority of the frame-time reduction.
Video Layer Integration (v_video.c)
The VGA controller (described in the Hardware section) expects a specific framebuffer layout: word-aligned 32-bit entities where each pixel occupies one byte of a 4-byte word. DOOM’s internal I_VideoBuffer pointer was redirected to point at the inactive framebuffer bank in the VGA controller’s address space. The pixel write routines were rewritten to match the VGA’s byte-packed, word-aligned memory layout, and palette updates were connected to the hardware palette registers rather than a software lookup. The double-buffering is transparent to the renderer, it always writes to the same buffer address, and the hardware remaps it to the off-screen bank.
Fixed-Point Adjustments (m_fixed.c)
DOOM’s 16.16 fixed-point math relies on correct handling of signed overflow, multiplication of fractional values, and conversion between integer and fixed-point representations. Several edge cases required adjustment for the RV32IM target:
- MULH return values: The high-word multiplication (
FixedMul) must correctly handle negative products, which the hardware MULH instruction does natively once the compiler emits the correct opcode. - Division accuracy: The fixed-point divide (
FixedDiv) implementation was adjusted to prevent overflow when dividing large numerators by small denominators. - Saturation: Division results that exceed the 16.16 representable range are clamped to prevent wraparound artifacts in the renderer.
Compiler Optimization Flags
Rather than enabling -O2 as a whole (which caused unreproducible failures), individual optimization flags were tested on hardware and enabled selectively:
-fbit-tests: DOOM uses bitfields extensively for entity flags, door states, and level geometry — this flag compiles bit tests into singleandi/beqsequences rather than multi-instruction extract-and-compare.-finlineand-finline-functions-called-once: The rendering hot path calls numerous small helper functions; inlining eliminates call overhead in inner loops.-fcprop-registers,-fif-conversion,-fforward-propagate, and others were similarly validated.
A full list is provided in the Build System chapter. Each flag was deployed incrementally and tested for correctness on hardware before the next was added.
Cache Impact
The instruction and data caches (documented in the Cache Subsystem chapter) reduce the average memory access latency from approximately 20 cycles (bare DDR2) to 1 or 2 cycles for the hot paths. The instruction cache achieves a hit rate above 99% in DOOM’s render loop — the inner rendering code fits almost entirely within the 8 KB cache. The data cache captures frequently accessed globals, player state, and framebuffer pointers. The write-through policy avoids coherence complexity while still accelerating reads.
Combined Effect
Individually, none of these changes would have been sufficient. The rendering rewrite eliminated the per-pixel overhead, the caches hid the DDR2 latency, the compiler flags tightened the generated code, and the fixed-point fixes prevented correctness bugs that would have required slower software fallbacks. Together, they made the difference between an unresponsive display and a playable DOOM running at 10–30 FPS.