Porting Doom to a tiny teaching operating system running on an emulated 32-bit RISC-V CPU.
▶ Watch the full demo on YouTube
egos-2k+ is a ~2,000-line microkernel-style OS (earth / grass / apps layers)
built for a graduate Operating Systems course. Out of the box it boots to a
shell over a serial console — no graphics, no input beyond the UART, no sound,
and a read-only filesystem. This project adds the OS-level machinery a
general-purpose platform needs and uses Doom as the end-to-end proof that it
all works together: a QEMU ramfb framebuffer driver, a virtio-input
keyboard, a virtio-sound audio pipeline, a demand-paged heap, a read/write
filesystem with POSIX file syscalls, and the syscalls that tie them to
user space.
Result: a full first-level Doom playthrough at ~28 FPS average with working graphics, keyboard, OPL3-emulated music + SFX, and on-disk savegames — all on an emulated RV32 machine with 32 MiB of RAM.
Course: Operating Systems Implementation (CS6640). This was an open-ended final project built on top of the semester's lab work.
canitrundoom.org catalogs the absurd range of platforms that have been made to run Doom. Getting there is a genuinely good systems exercise: Doom needs to draw pixels, read input, play sound, allocate memory dynamically, keep time, and persist files. A platform that can run Doom has, almost by definition, implemented the core of a general-purpose OS.
Doom is ported here via doomgeneric, which reduces a platform port to five callbacks:
DG_Init DG_DrawFrame DG_SleepMs DG_GetTicksMs DG_GetKey
Standing those five up required building out the rest of the OS underneath them.
| Area | Files | What it does |
|---|---|---|
| Framebuffer | earth/dev_ramfb.c, earth/boot.c |
QEMU ramfb driver: negotiates an XRGB8888 framebuffer over the fw_cfg interface and blits pixels from a user backbuffer by page-walking the caller's page table. Boot splash + restore-on-exit. |
| Keyboard | grass/virtio_kbd.c |
virtio-input driver: sets up the event/status virtqueues, decodes evdev (code, value) events, filters auto-repeat, and buffers discrete down/up events (Doom needs key-release events, which stock egos couldn't detect). |
| Audio | grass/virtio_snd.c, doomgeneric/i_egossound.c, doomgeneric/i_oplmusic.c, doomgeneric/opl/* |
virtio-sound TX pipeline (ring buffer + timer-driven drain) plus a cut-down Chocolate Doom OPL3/MIDI layer, so MUS music and SFX play without a real OPL chip or SDL_mixer. 44.1 kHz stereo S16. |
| Memory | library/libc/malloc.c, grass/ipc.c |
SYS_MAP_HEAP_PAGE demand-paged heap extension wired into newlib _sbrk, so Doom's zone allocator and malloc can grow. |
| Filesystem (RW) | library/file/rwfs.c, fs.c, apps/system/sys_file.c, library/libc/syscalls.c |
Create / unlink / rename on top of the rwfs primitive; a fix so indirect-block pointers survive writes; from-scratch newlib POSIX stubs (_open, _read, _write, _lseek, _unlink, rename, mkdir) that transparently span the read-only treedisk and the read/write filesystem. Enables Doom savegames and config files. |
| Syscalls | library/syscall/syscall.{h,c}, grass/ipc.c, grass/kernel.c |
New syscalls: SYS_SLEEP, SYS_RAMFB_DRAW, SYS_RAMFB_RESET, SYS_MAP_HEAP_PAGE, SYS_KBD_GET_EVENT, SYS_AUDIO_WRITE. Timer-interrupt hooks keep the keyboard and audio rings drained even when no app is polling. |
| Image / build | tools/mkfs.c, library/elf/app.lds, Makefile |
Packs DOOM1.WAD + boot splash + boot chime into the disk image at fixed inodes; rebalances the user ELF layout to 512 K code / 512 K data so the Doom binary fits; bumps QEMU RAM 8 → 32 MiB and wires up the ramfb / virtio-kbd / virtio-snd devices. |
| Doom backend | doomgeneric/doomgeneric_egos.c |
Implements the five DG_* callbacks against the new syscalls and maps evdev key codes to Doom key values. |
Host-side helpers scripts/convert_xr24.py and scripts/wav2dmx.py generate
the boot-splash image (assets/egos_logo.bin) and DMX boot chime
(assets/boot_sfx.lmp) that mkfs packs into the image. The converted assets
are committed, so the build itself needs no Python — re-run the scripts (no
arguments) only after editing the source assets/egos.png or assets/oxp.wav.
earth/ # hardware abstraction layer (drivers, boot, CPU/MMU)
grass/ # kernel: scheduler, IPC, syscalls, virtio drivers
apps/ # system servers + user programs (shell, benchmarks, ...)
library/ # libc/newlib glue, filesystem, ELF loader, syscall wrappers
doomgeneric/ # vendored doomgeneric + egos backend + OPL music (see NOTICE)
tools/ # mkfs (builds the on-disk image)
scripts/ # host-side asset converters (png -> XR24, wav -> DMX)
assets/ # boot logo + chime: sources (egos.png, oxp.wav) + converted output
Makefile # top-level build + `make qemu`
setup.sh # downloads the RISC-V toolchain + QEMU for your platform
env.sh # puts the RISC-V toolchain + QEMU on your PATH
Requirements: macOS (Intel or Apple Silicon) or Linux/WSL (x64 or arm64).
You need git, make, a C compiler for host tools, and curl (or wget).
The build uses a prebuilt RISC-V cross-compiler and a RISC-V build of QEMU from
xpack-dev-tools; setup.sh fetches the
right build for your platform automatically.
git clone <this-repo> egos-doom && cd egos-doom
./setup.sh # detects your OS/arch, downloads + verifies the toolchains
source ./env.sh # once per shell, from the repo root (puts them on PATH)
make qemu # cross-compile the OS + apps + Doom, then boot in QEMUAt the egos shell prompt, type doomgeneric to launch Doom. Quit QEMU with
Ctrl-a then x.
setup.sh pins the exact toolchain versions this project was built with
(RISC-V GCC 14.2.0-3,
RISC-V QEMU 8.2.2-1),
verifies their SHA-256 checksums, and unpacks them into the repo root (they're
git-ignored). env.sh discovers them with a glob, so there's nothing to edit by
hand. Windows users: run everything inside WSL.
macOS may block the toolchain binaries on first run (Gatekeeper). Allow them under System Settings → Privacy & Security, or run
xattr -dr com.apple.quarantine xpack-*.
Measured on a 2019 Intel MacBook Pro (numbers from final_proj.txt):
Doom — first-level playthrough (~5 min of real gameplay, 8,640 frames):
| Metric | Value |
|---|---|
| Average FPS | 28.21 |
| 1% low FPS | 8.38 (worst frame 592 ms, the level load) |
Close to Doom's 35 Hz target and comfortably playable. The playtest is the real
benchmark — it exercises every subsystem at once (framebuffer draws, keyboard
events, OPL3 + SFX audio, sys_sleep timing, demand-paged heap, and rwfs
savegame writes) for sustained minutes with no dropped events and no filesystem
asserts firing even after thousands of writes past the direct-block boundary.
Microbenchmarks (in apps/user/, run from the egos shell):
| Benchmark | Result |
|---|---|
bench_fb — framebuffer throughput |
1,174 frames/s, 286 MiB/s (320×200 XRGB8888) |
bench_kbd — keyboard events |
20/20 events captured (10 down, 10 up), none lost |
bench_audio — audio TX |
100% of submitted bytes accepted at 44.1 kHz stereo S16, no ring overflow |
bench_heap — demand-paged heap |
~90 µs/malloc, ~1 µs/page first-touch, ~1.5 GiB/s memset |
bench_rwfs — read/write FS |
314 KiB/s write, 441 KiB/s read (IPC-bound, not bandwidth-bound) |
The repo ships a .gdbinit for source-level kernel debugging over QEMU's gdb
stub (target remote 127.0.0.1:6640, RV32 architecture, symbols from
build/release/egos.elf).
This repository combines code under two licenses:
- egos-2k+ / egos — the base operating system, MIT licensed. See
LICENSE. Derived from Cornell's egos-2000 (Yunhao Zhang, Robbert van Renesse et al.) and the CS6640 course variant (Cheng Tan). My contributions are the drivers, syscalls, filesystem, and Doom integration described above. - doomgeneric / Doom — the
doomgeneric/directory is vendored from ozkl/doomgeneric and the id Software Doom source, distributed under the GNU GPL v2. The OPL/MIDI code is adapted from Chocolate Doom (also GPL v2). Seedoomgeneric/NOTICE.md.DOOM1.WADis the freely redistributable shareware IWAD.
