Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. 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.

  2. Video buffer pointer: Sets I_VideoBuffer to point at VGA_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 a 0xF0 (break code) byte is received, the next scancode indicates a key release.
  • is_extended: Set when an 0xE0 (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(); }
}