#C #OSDev #Low Level

Creating a x86_64 OS named latveria to run DOOM

I just finished my sophomore year and decided the best way to spend summer break was building an operating system. Not a real one. Just one that runs DOOM.

The project is called Latveria. If you know your Marvel lore, Latveria is the fictional kingdom ruled by Doctor Doom. It felt right.


How it started

I have been into Linux since around 8th or 9th grade. Not just using it but actually going deep on it. I went through the usual progression: Ubuntu, then Arch, then NixOS, Void, Gentoo, a brief detour into FreeBSD. At some point I even put together my own Arch-based setup with a bunch of bash scripts, basically a personal distro.

So operating systems have always been interesting to me, and I eventually got curious about what it would take to build one from scratch. I started reading the OSDev wiki and pretty quickly realised the first stretch of osdev is mostly reading documentation and transcribing it into code. Not a lot of room to actually think. I dropped it.

Then during my end semester exams last semester, the idea came back in a better shape: what if the OS only needs to do one thing? Porting DOOM to unusual targets is a classic engineering side quest, and it gave the project an actual finish line. A single-purpose OS is way more tractable than a general one. So I picked it back up, named it Latveria, and got to work.


Every shortcut I took

I was upfront with myself early: I wanted to build and learn, not spend three weeks on scaffolding. So I made deliberate tradeoffs.

Limine instead of a custom bootloader. Writing a bootloader is a whole project by itself. Limine is a well-documented modern bootloader and there is an official limine-c-template on the OSDev wiki that gets you a booting kernel in an afternoon. I started there.

Clang instead of a cross-compiled GCC. The standard advice for osdev is to build a GCC cross-compiler targeting your architecture. On Arch with up-to-date packages, I kept running into dependency issues. Clang handles cross-compilation natively without any of that, so I switched and never looked back.

doomgeneric instead of the original DOOM source. The original DOOM source is portable but you still have to do a fair bit of work to integrate it. doomgeneric is a fork specifically designed to make porting easy. You implement a handful of functions, hand it a framebuffer and some input, and it handles the rest.

A linear allocator instead of a page allocator. This one deserves more explanation because it sounds like a lazy choice but it actually was the right one. A page allocator is the correct thing to build if you are writing a real OS. But Latveria is not a real OS. It runs exactly one program, DOOM, and it runs it forever. Nothing ever needs to be freed. So I used an arena allocator: a fixed block of memory, a pointer that only moves forward, and a bump-on-allocate. The whole thing is about 20 lines. It cannot handle a real workload but for this specific case it is perfect.

DOOM1.WAD loaded via Limine modules. Implementing a filesystem driver was out of scope. Limine has a module system that lets you embed arbitrary files and load them into RAM at boot. I used that to get the WAD file in memory without touching a disk driver.

Only the keys DOOM actually uses. A full keyboard driver handles scancodes for every key and manages state correctly across all of them. I only implemented the keys you need to actually play DOOM: the arrow keys, ctrl, space, and a couple others. Everything else is ignored.


Learning GNU C extensions

Before this project I had only written standard C, ANSI or C99/C11. I knew GNU extensions existed and had a rough idea of what they could do, but I had never needed them so I never learnt them properly.

Working in a freestanding environment (no OS, no libc, nothing) pushed me into them quickly. A few that came up a lot:

__attribute__((packed)) for structs that need to map exactly to hardware layouts without padding. If you are writing a struct that represents a GDT entry or a multiboot header, packed is not optional.

__attribute__((noreturn)) for functions like your kernel panic handler that never return. Without this the compiler generates unnecessary cleanup code at call sites.

__attribute__((section(...))) for placing specific code or data at exact memory addresses. The linker script controls the overall layout but sometimes you need finer control over where a particular symbol lands.

These are not complicated but they are also not things you encounter in userspace C. The OSDev wiki documents them well, it is just a matter of sitting down and learning them when you need them.


The linker errors script

One of the bigger problems when porting DOOM was figuring out which libc functions it depended on. DOOM is written assuming libc is available. strlen, memcpy, memset, sprintf, and a bunch of others are called throughout.

In a freestanding kernel you have none of that. The options are to port a libc implementation or to implement only what you need. Porting a full libc is way more work than this project warranted.

So I wrote a small script that tried to compile doomgeneric against my kernel, parsed the linker errors, and extracted the list of missing symbols. That gave me an exact list of what I needed to implement. Most of them were trivial: memory functions, string functions, a few math utilities. I used an LLM to generate those implementations since they are mechanical and well-specified, checked them, and moved on. The whole thing took maybe an hour.

The insight here is that the linker is actually giving you precise information. You do not need to read through DOOM's source and manually audit every include. Just try to link it and let the toolchain tell you what is missing.


Things I actually learnt

Going in I expected to learn about the boot process and memory management. I did. But a few other things stuck more than I expected.

Working in a freestanding environment changes how you think about code. There is no printf to debug with by default, no stack trace, no error messages. You are writing to a framebuffer or a serial port and if something goes wrong before that is set up, the machine just hangs. It forces you to be more careful about the order of operations and to think harder before running things.

Assembly is not as scary as it looks from the outside. You need a small amount of it for things like setting up the GDT, handling interrupts, and switching privilege levels. Once you stop trying to understand every instruction and just focus on what the block of code is doing at a high level, it clicks pretty fast.

The boot process is mostly just a contract. The firmware hands off to the bootloader, the bootloader sets up a known environment and jumps to your kernel entry point. Once you know what state the CPU and memory are in when your code starts running, the rest follows from there.


What I would do differently

The keyboard driver is rough. Implementing only the keys I needed got DOOM playable but the code is not clean. A proper scancode table would have taken maybe two extra hours and would have been much easier to reason about.

The arena allocator is fine for this but I want to go back and write a proper page allocator at some point. It is the one part of the project where I feel like I skipped something I should understand.


Wrapping up

Latveria does one thing: it boots and runs DOOM. No filesystem, no userspace, no multitasking. Just the kernel, an arena, a framebuffer, and DOOM.

For a summer project going into junior year, that felt like plenty. The goal was never to write a real OS. It was to understand enough of how one works to stop finding it mysterious. That part worked.