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

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:

  1. main() calls doomgeneric_Create() with -iwad doom1.wad arguments
  2. DOOM’s WAD system parses the IWAD parameter and calls fopen("doom1.wad", "rb")
  3. The pseudo-filesystem’s fopen matches the .wad extension and returns a valid handle
  4. All subsequent reads go through fread/fseek, which operate on the _binary_doom1_wad_start array
  5. 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.