Porting DOOM to my WebAssembly VM
Jan 26, 2026Recap
Around this time last year I wrote about my experience writing a WebAssembly VM in C (for fun). At that point in time it was effectively a prototype. It could decode the WebAssembly binary format and execute some simple hello world programs. I’ve continued to work on the project in my free time, and I’m happy to share that it has come far enough that it passes the full WebAssembly 2.0 core specification test suite (minus the SIMD instructions).
Getting this far involved a full rewrite in Rust. This allowed me to move much faster and
restructure my code with confidence. The overall architecture of the C prototype and the
Rust port is largely similar. For the most part my code directly mirrors the semantics in
the core specification. I’m not doing any kind of lowering, register allocation, or optimization —
besides precomputing relative jump offsets and other stack metadata during module validation to
aid in the handling of control flow. I am using a pretty liberal amount of unsafe, but I haven’t
experienced a single segfault after the switch to Rust.
In retrospect, starting with a minimal language like C and then graduating to Rust was an effective strategy for avoiding premature abstraction. The minimalism of C helped me focus on what the program is physically doing without going through layers of indirection. In the past, when working in Rust, I’ve ended up bogging myself down with trait soup trying to introduce abstractions before I fully understood the problem.
But can it run DOOM?
Anyways… I’ve reached the point that my VM is fully compliant with version 2.0 of the WebAssembly core specification, and I need a large program to stress test it with. The obvious choice for this sort of thing is a DOOM port. For those unaware, DOOM runs on just about every computing environment under the sun. For every calculator, satellite, or turing-complete system, there’s likely a software engineer theorizing about how to get DOOM running on it.
Others have written a lot about the structure of the DOOM codebase itself, so I wont focus on that too much. I will be using the doomgeneric fork as my starting point since it’s designed to be easily portable.
Porting to WebAssembly
There are two primary concerns to address when cross-compiling a large existing C codebase to WebAssembly.
The first is how we’ll emit wasm bytecode: any C compiler with an LLVM backend should support some kind of wasm32 target
— emcc, clang, and zig cc are all viable choices here.
The second concern is how our wasm module will communicate with the outside world: WebAssembly is a sandbox, there is no built-in ability for reading or writing files, receiving keyboard input, or rendering pixels — any IO capabilities need to be explicitly provided to the module by the embedder through function imports. There are a few prebuilt solutions for this, but we can also establish our own host interface.
Emscripten
Emscripten is the OG WebAssembly compiler (wasm itself evolved out of the Emscripten project).
The catch (for me) is that emcc explicitly targets
web browsers. This means it compiles your C code down to a .wasm binary plus an accompanying
Javascript bundle that provides any of the IO capabilities that your C code needs, like a
virtualized filesystem on top of the IndexedDB API. This makes it a non-starter for my purposes since my
WebAssembly VM is not on the Web, I do not have a Javascript runtime.
Clang + wasi-sdk
The emerging standard for a preestablished POSIX-like host interface is the WebAssembly System Interface (WASI).
You build your C code against the wasi-sdk and your wasm module will call out
to wasi:fs/open-at in order to open files, etc. There are various advantages to sticking
to a standard interface, and this is probably the best choice for many projects, but on the engine implementation
side it’s a good bit of work. WASI is defined in terms of the
WebAssembly Component Model which is itself a rather complex specification layered on
top of the WebAssembly core specification. I currently don’t have the time to add component model support to my engine, but I may revisit
this in the future. Arguably I could implement the necessary wasi imports at the core abstraction level,
but I’d like to go through the learning exercise of designing my own interface.
Raw Clang
The remaining option is to define my own host interface. This will involve building a custom embedder in Rust that implements the hostcalls, and then on the guest side I will need a bespoke libc implementation that can employ this interface to perform IO. Sounds like fun, let’s get to it!
An Environment for DOOM
Doomgeneric is structured so that you only need to implement 6 platform-specific functions. We need to be able to render raw pixel data to the screen, we need some basic timers, and we need to be able to read input from the keyboard.
//Implement below functions for your platformvoid DG_Init();void DG_DrawFrame();void DG_SleepMs(uint32_t ms);uint32_t DG_GetTicksMs();int DG_GetKey(int* pressed, unsigned char* key);void DG_SetWindowTitle(const char * title);The only dependency beyond that is the C standard library. We will need to be able to read resources (textures, level data, etc) at runtime from a .wad file. Let’s sketch out which libc functions we’ll need by attempting to compile, and just declare any functions that are missing. Eventually we’ll have declared enough that we’ll start seeing linker errors instead of compile errors.
int isspace(int c);int toupper(int c);int tolower(int c);double fabs(double f);int snprintf(char *str, size_t size, const char *format, ...);int fprintf(FILE *f, const char *format, ...);int vfprintf(FILE *stream, const char *format, va_list arg);int vsnprintf(char *buffer, size_t size, const char *format, va_list argptr);int printf(const char *format, ...);int puts(const char *str);int putchar(int c);FILE *fopen(const char *path, const char *mode);size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);int fseek(FILE *stream, long int offset, int whence);size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);int fclose(FILE *f);int fflush(FILE *f);long ftell(FILE *f);int remove(const char *path);int rename(const char *src, const char *dst);int sscanf(const char *str, const char *format, ...);int atoi(const char *str);double atof(const char *str);int abs(int num);void *malloc(int size);void *realloc(void *ptr, size_t size);void *calloc(size_t num, size_t size);void free(void *mem);void exit(int status);int system(const char *command);void *memset(void *str, int c, size_t n);void *memcpy(void *dst, const void *src, size_t len);void *memmove(void *dst, const void *src, size_t len);size_t strlen(const char *s);char *strncpy(char *dest, const char *src, size_t n);int strcmp(const char *s1, const char *s2);int strncmp(const char *s1, const char *s2, size_t n);int strcasecmp(const char *s1, const char *s2);int strncasecmp(const char *s1, const char *s2, size_t len);char *strrchr(const char *s, int c);char *strdup(const char *s);char *strchr(const char *s, int c);char *strstr(const char *haystack, const char *needle);I’ve redacted some typedefs and macros, plus these are split across various libc header files, but these 43 functions are all we need to run DOOM.
Host Calls
We have enough information now that we can start sketching out the host interface. This evolved over time as I implemented things, but ultimately here is what I ended up with:
This macro tells the linker that we are purposefully not defining an implementation for the annotated symbol, and that we want to produce an entry for it in the imports section of our .wasm binary. These functions will all be implemented in Rust by the embedder and passed into our module when it is instantiated.
#define WASM_IMPORT(module, name) \ __attribute__((import_module(module), import_name(name)))These functions allow us to terminate the process with an error code, or panic with an error message — this was useful for me during debugging to be able to crash with a message for myself when something unexpected ocurred within the guest.
// ----- proc -------- //
WASM_IMPORT("semblance", "exit")extern void semblance_syscall_exit(int) __attribute__((noreturn));
WASM_IMPORT("semblance", "panic")extern void semblance_syscall_panic(const char *msg) __attribute__((noreturn));The following establishes an interface for buffered IO. This will allow us to write to stdout and stderr, and read from our .wad file. We can let the Rust embedder handle the buffering since our Wasm hostcalls aren’t as expensive as a full OS kernel context switch.
// ----- io -------- //
WASM_IMPORT("semblance", "fopen")extern int32_t semblance_syscall_fopen(const char *path, const char *mode);
WASM_IMPORT("semblance", "fwrite")extern int64_t semblance_syscall_fwrite(int fd, const void *data, size_t len);
WASM_IMPORT("semblance", "ftell")extern int64_t semblance_syscall_ftell(int fd);
WASM_IMPORT("semblance", "fseek")extern int32_t semblance_syscall_fseek(int fd, int64_t offset, int32_t whence);
WASM_IMPORT("semblance", "fflush")extern int32_t semblance_syscall_fflush(int fd);
WASM_IMPORT("semblance", "fread")extern int32_t semblance_syscall_fread(int fd, void *dst, size_t size);
WASM_IMPORT("semblance", "fclose")extern int32_t semblance_syscall_fclose(int fd);Here we allow the guest module to open a GUI window, set the title, and render raw pixel data to it.
// ------ gfx --------- //
WASM_IMPORT("semblance", "init_window")extern void semblance_syscall_init_window( const char *title, int32_t width, int32_t height);
WASM_IMPORT("semblance", "set_window_title")extern void semblance_syscall_set_window_title(const char *title);
WASM_IMPORT("semblance", "render")extern void semblance_syscall_render( const uint32_t *pixels, int32_t width, int32_t height);These functions let the guest know how long it has been running, and allow the guest to put the thread to sleep for some specified amount of time.
// ------ timers --------- //
WASM_IMPORT("semblance", "get_ticks_ms")extern size_t semblance_syscall_get_ticks_ms();
WASM_IMPORT("semblance", "sleep_ms")extern void semblance_syscall_sleep_ms(size_t ms);Finally, this allows the guest to read keyboard events.
// ------ keyboard input --------- //
typedef struct read_key_result_t { int read; int pressed; int key_code;} read_key_result_t;
WASM_IMPORT("semblance", "read_key")extern read_key_result_t semblance_syscall_read_key();Entrypoint
The last part of the host interface we need to define is the entrypoint
of the program. Conventionally this is a function called _start that
will do some secret initial prep (eg opening file descriptors for stdin/stdout/stderr, reading program arguments)
and subsequently call into main(argc, argv).
We’re going to diverge from that here a bit. Since I want the embedder to own the UI event loop,
we’re going to use a dual entrypoint _start which will be called once to initialize the
module and _tick which will be called repeatedly to drive the game loop. This will free up
the embedder to be able to handle windowing events (like the user requesting to close the application)
without involving the guest at all.
The Guest
We’ve fully specified how our embedder and .wasm guest will communicate with each other. Let’s dig into the libc implementation.
Entrypoint
We’ll start with the entrypoint. We’re hardcoding our command line arguments,
but it is interesting to peek behind the curtain to see what happens before main
(or in our case init). Namely we define the global stdout and stderr variables
by calling fopen to acquire a file descriptor to "/dev/stdout" and "/dev/stderr".
extern void init(int argc, char **argv);extern void tick();
#define WASM_EXPORT(name) __attribute__((export_name(name)))
static char *__argv[1] = { "/doomgeneric.wasm" };
WASM_EXPORT("_start")void _start() { int stdio_err = __stdio_init(); if (stdio_err) semblance_syscall_panic("failed to initialize stdio");
return init(1, __argv);}
WASM_EXPORT("_tick")void _tick() { tick();}FILE* stderr = NULL;FILE* stdout = NULL;
int __stdio_init() { stderr = fopen("/dev/stderr", "w"); if (stderr == NULL) return 1; stdout = fopen("/dev/stdout", "w"); if (stderr == NULL) return 2; return 0;}Allocator
I may have been able to get away with a super simple bump allocator here, since DOOM implements it’s own zoned memory allocator, and doesn’t rely too heavily on the libc allocator once the game loop has started. But for completeness’ sake I opted to pull in a full memory allocator. I’m using Andy Wingo’s walloc which is designed specifically for use within WebAssembly.
I forked it slightly in order to extend it with basic support for calloc and realloc.
static size_t walloc_size(void *ptr) { if (!ptr) return 0; struct page *page = get_page(ptr); unsigned chunk = get_chunk_index(ptr); uint8_t kind = page->header.chunk_kinds[chunk]; if (kind == LARGE_OBJECT) { struct large_object *obj = get_large_object(ptr); return obj->size; } else { size_t granules = kind; return granules * GRANULE_SIZE; }}
void *realloc(void *ptr, size_t size) { if (size == 0) { free(ptr); return NULL; } if (ptr == NULL) return malloc(size); size_t old_size = walloc_size(ptr); if (old_size == 0) return malloc(size); size_t min_size = old_size < size ? old_size : size; void *new_ptr = malloc(size); if (new_ptr == NULL) return NULL; __builtin_memcpy(new_ptr, ptr, min_size); free(ptr); return new_ptr;}
void *calloc(size_t num, size_t size) { size_t bytes = num * size; void *ptr = malloc(bytes); if (ptr == NULL) return NULL; __builtin_memset(ptr, 0, bytes); return ptr;}Stdio
This was by far the most involved portion to implement. DOOM makes heavy use
of snprintf to format strings as it decodes various named sections of the .wad
file. This means that our libc’s fwrite(..., FILE *f) needs to be able to write to
file descriptors (through our established host interface) but also directly to in-memory buffers.
In order to enable this, I define the FILE struct as a tagged union which either holds a
file descriptor or a reference to a contiguous region of memory.
typedef enum stream_kind_t { stream_kind_fd, stream_kind_buf} stream_kind_t;
typedef struct buf_stream_t { void *start; void *end;} buf_stream_t;
struct FILE { stream_kind_t kind; union { buf_stream_t buf_state; int fd; } data;};The implementation of fwrite uses this tag to decide how to perform the
data transfer (hostcall or memcpy).
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream) { size_t written = 0; if (stream == NULL) return 0; switch (stream->kind) { case stream_kind_fd: { written = semblance_syscall_fwrite( stream->data.fd, ptr, size * nmemb ); break; } case stream_kind_buf: { written = bufwrite(&stream->data.buf_state, ptr, size * nmemb); break; } } return written / size;}Implementing printf was a fun experience. I started by grepping through the DOOM
source to find all the format strings ("%s", "%p", "%.3d", etc) and just
implemented enough of a parser and formatter to handle each of them. printf
had always felt like a primitive component of the language, but it is just a regular
C function after all. Try printf debugging your broken printf implementation, it’s a bit trippy!
int vfprintf(FILE *stream, const char *format, va_list args) { int written = 0; char *format_end = strchr(format, '\0'); while (format < format_end) { char *pat = strchr(format, '%'); if (pat == NULL) { written += fwrite( format, sizeof(char), format_end - format, stream ); break; } else { written += fwrite(format, sizeof(char), pat - format, stream); printf_specifier_t specifier; format = parse_printf_specifier(pat, &specifier); written += fwrite_printf_specifier(stream, &specifier, &args); } } return written;}Getting to this point was a huge milestone because I could finally see log output and error messages which informed me what was going wrong when the guest crashed. I was flying blind up to here.
Z_Init: Init zone memory allocation daemon.zone memory: 0x190108, 600000 allocated for zoneUsing . for configuration and savesV_Init: allocate screens.M_LoadDefaults: Load system defaults.saving config in .default.cfg-iwad not specified, trying a few iwad namesTrying IWAD file:doom2.wadTrying IWAD file:plutonia.wadTrying IWAD file:tnt.wadTrying IWAD file:doom.wadTrying IWAD file:doom1.wadW_Init: Init WADfiles. adding doom1.wadUsing ./.savegame/ for savegames=========================================================================== DOOM Shareware=========================================================================== Doom Generic is free software, covered by the GNU General Public License. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. You are welcome to change and distribute copies under certain conditions. See the source for more information.===========================================================================I_Init: Setting up machine state.M_Init: Init miscellaneous info.R_Init: Init DOOM refresh daemon - ...................P_Init: Init Playloop state.S_Init: Setting up sound.D_CheckNetGame: Checking network game status.startskill 2 deathmatch: 0 startmap: 1 startepisode: 1player 1 of 1 (1 nodes)Emulating the behavior of the 'Doom 1.9' executable.HU_Init: Setting up heads up display.ST_Init: Init status bar.I_InitGraphics: framebuffer: x_res: 640, y_res: 400, x_virtual: 640, y_virtual: 400, bpp: 32I_InitGraphics: framebuffer: RGBA: 8888, red_off: 16, green_off: 8, blue_off: 0, transp_off: 24I_InitGraphics: DOOM screen size: w x h: 320 x 200I_InitGraphics: Auto-scaling factor: 2Doomgeneric
Now we’re getting out of the libc implementation, let’s take a look at those six DG_* functions we
need to implement.
Init & Tick
Here’s where we finally define our init and tick functions that get called by our libc entrypoint. We’re effectively just passing the calls along to doomgeneric.
void init(int argc, char** argv) { doomgeneric_Create(argc, argv);}
void tick() { doomgeneric_Tick();}Graphics & Timers
Again, we’re basically just adapting our hostcall interface to the doomgeneric interface.
void DG_Init() { semblance_syscall_init_window("DOOM", DOOMGENERIC_RESX, DOOMGENERIC_RESY);}
void DG_SetWindowTitle(const char *title) { semblance_syscall_set_window_title(title);}
void DG_DrawFrame() { semblance_syscall_render( DG_ScreenBuffer, DOOMGENERIC_RESX, DOOMGENERIC_RESY );}
void DG_SleepMs(uint32_t ms) { semblance_syscall_sleep_ms(ms);}
void DG_SleepMs(uint32_t ms) { semblance_syscall_sleep_ms(ms);}
uint32_t DG_GetTicksMs() { return semblance_syscall_get_ticks_ms();}Keyboard Input
Same idea, with an intermediate step to translate our host keycode (which is an SDL2 keycode) to the encoding that DOOM expects.
int DG_GetKey(int* pressed, unsigned char* doomKey) { read_key_result_t result = semblance_syscall_read_key(); if (!result.read) return 0; *pressed = result.pressed; *doomKey = translate_key_code(result.key_code); return 1;}Build
As a result we end up with a relatively hefty 426k WebAssembly module ready to be instantiated by my meager little VM.
ls -lh guest/doomgeneric/target/doomgeneric.wasm# -rwxr-xr-x@ 1 taylor staff 426K Jan 24 15:16 guest/doomgeneric/target/doomgeneric.wasmThe Embedder
My project is setup as a cargo workspace with three different crates:
ls crates/# semblance semblance-mars semblance-wastsemblance is the core crate that implements the WebAssembly engine, this depends only on the Rust standard library.
semblance-wast is a test harness for running WebAssembly scripts (.wast files which define the core specification test suite) against semblance.
semblance-mars is our embedder for DOOM, it depends on semblance and sdl2 for UI.
This is where all of our hostcalls will be implemented.
Main
There’s a lot going on here, but the gist is that the embedder is going to load our wasm module,
instantiate it with our syscalls available to be imported, grab references to the exported
_start and _tick functions from the guest, and then invoke the guest’s _start function.
At this point the guest will have read the .wad file and opened our SDL2 window, it’s now
ready to enter the game loop.
fn main() -> Result<(), Box<dyn std::error::Error>> { let module_path = std::env::args().nth(1).expect("missing module path"); let module_path = PathBuf::from(module_path); let mut linker = WasmLinker::new(); syscalls::add_to_linker(&mut linker); let wmod = WasmModule::read(&module_path).expect("unable to load module"); let (mut store, externvals) = linker.link(&wmod) .expect("unable to resolve imports"); let winst_id = store .instantiate(Rc::new(wmod), &externvals) .expect("failed to instantiate"); let winst = store.instances.resolve(winst_id); let initfunc = winst .resolve_export_fn_by_name("_start") .expect("no _start func exported"); let tickfunc = winst .resolve_export_fn_by_name("_tick") .expect("no _tick func exported"); store .invoke(initfunc, Box::new([]), WasmInvokeOptions::default()) .expect("guest trapped during init");
// ...}Once the guest is fully instantiated we’ll start handling events on the SDL event loop.
Each iteration of this loop, we’ll call into the guest’s _tick function to run a frame
of the game.
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ...
let mut event_pump = guest_gfx::use_sdl_context(|ctx| ctx.event_pump()) .expect("failed to get event pump");
'running: loop { for event in event_pump.poll_iter() { match event { Event::Quit { .. } => { break 'running; } Event::KeyDown { keycode: Some(keycode), .. } => { guest_input::enqueue_key(QueuedKeyEvent { pressed: true, keycode, }); } Event::KeyUp { keycode: Some(keycode), .. } => { guest_input::enqueue_key(QueuedKeyEvent { pressed: false, keycode, }); } _ => {} } } store .invoke(tickfunc, Box::new([]), WasmInvokeOptions::default()) .expect("guest trapped during _tick"); } Ok(())}Hostcalls
Let’s see how the embedder allows the guest to perform IO.
Keep in mind that I could make the embedding API much more ergonomic by allowing
the WasmStore to carry some embedder specific runtime state through a generic
WasmStore<T>. But I haven’t gotten there yet, for now any embedder state that
needs to be accessed by a hostcall needs to be available statically, it’s a bit
messy but it works for now.
Our IO hostcalls need access to a table which maps file descriptors to the underlying reader / writer.
pub trait ReadSeek: Read + Seek {}
impl<T: Read + Seek> ReadSeek for T {}
pub enum IoTableEntry { Read(Box<dyn ReadSeek>), Write(Box<dyn Write>), Closed,}
pub struct IoTable(Vec<IoTableEntry>);
thread_local! { static IO_TABLE: RefCell<IoTable> = RefCell::new(IoTable::new());}The fopen hostcall is implemented by pushing a new entry into this table,
the index at which our entry is inserted is returned to the guest as the
file descriptor.
pub fn fopen(path: &str, mode: &str) -> i32 { if mode.contains("w") { // we only allow writes to stdout/stderr match path { "/dev/stdout" => { let idx = with_io_table_mut( |io| io.push_writer(Box::new(std::io::stdout())) ); idx as i32 } "/dev/stderr" => { let idx = with_io_table_mut( |io| io.push_writer(Box::new(std::io::stderr())) ); idx as i32 } _ => todo!("fopen write {}", path), } } // ...}You’ll notice that this function is accepting &str arguments, but the hostcall is dealing
with raw pointers into the WebAssembly guest’s linear memory. Here’s the actual hostcall
that’s imported by the guest which marshals the data types:
static SYSCALL_FOPEN_TYPE: LazyLock<WasmFuncType> =LazyLock::new(|| WasmFuncType { input_type: WasmResultType(Box::new([ WasmValueType::Num(WasmNumType::I32), // char *path WasmValueType::Num(WasmNumType::I32), // char *mode ])), output_type: WasmResultType(Box::new([ WasmValueType::Num(WasmNumType::I32) // int fd ])),});
fn syscall_fopen( store: &mut WasmStore, winst_id: WasmInstanceAddr, args: &[WasmValue],) -> Box<[WasmValue]> { let path = unsafe { args[0].num.i32 }; let path = guest_resolve_cstr(store, winst_id, path); let mode = unsafe { args[1].num.i32 }; let mode = guest_resolve_cstr(store, winst_id, mode); let fd = guest_io::fopen(path, mode); Box::new([WasmValue { num: WasmNumValue { i32: fd }, }])}
pub fn add_to_linker(linker: &mut WasmLinker) { linker.add_host_module( "semblance".to_string(), &[ // ... ("fopen", &SYSCALL_FOPEN_TYPE, &syscall_fopen), // ... ], );}You can imagine how the other hostcalls are implemented, they all effectively stick to this basic pattern.
Outcome
Let’s run it!
./target/release/semblance-mars guest/doomgeneric/target/doomgeneric.wasmI’ve paid very little attention to performance as I’ve implemented the VM, but out of the box it’s able to run DOOM at a playable framerate which is awesome!
Notably, I didn’t run into a single bug in my core VM implementation during this whole process of porting DOOM. I guess that’s a testament to how comprehensive the core test suite is.
Aside
I did have to fix a Wasm trap because of an issue in the DOOM source code itself.
void D_DoomMain() { // ... I_AtExit((atexit_func_t) G_CheckDemoStatus, true); // ...}This function cast is unsafe when targeting WebAssembly (it’s casting a function that returns an int into a void function).
On exit, the call_indirect instruction hits this and traps because the function types don’t match (the core specification
requires that indirect calls are typechecked at runtime). The fix is to wrap G_CheckDemoStatus in a separate
function that returns no value.
void D_DoomMain() { // ... I_AtExit(G_CheckDemoStatus_AtExit, true); // ...}
void G_CheckDemoStatus_AtExit(void) { G_CheckDemoStatus();}We can see in the compiled WebAssembly we’re now explicitly dropping the unused value from the operand stack. If that dynamic typecheck were not in place, the operand stack would be silently corrupted and we’d end up seeing undefined behavior.
(func $G_CheckDemoStatus_AtExit (;155;) (type 0) call $G_CheckDemoStatus drop)Conclusion
I have learned an incredible amount during this whole process. I had a lot of fun working on this. Pursuing a large project for a sustained period of time has allowed me to really push outside my comfort zone. Admittedly I’ve worked on it for a lot longer than I initially thought I would, but I kept finding interesting problems to solve, and seeing myself make real progress over time was incredibly satisfying.