Clover’s Toy Box development: running QVM and WebAssembly game code.
My virtual machine interpreter now supports QVM format in addition to WebAssembly. I’ve also successfully hooked it up to a Quake 3 engine and run the Quake 3 game code in both formats using my interpreter.
What is QVM?
Quake 3’s game logic and menu code can be replaced by game modifications. This allowed for a prolific game modding community that spawned probably hundreds of game mods and many new games / total conversions. Quake 3 modding is still (somewhat) active 25 years later.
Code written in C89 is compiled to a custom format called QVM (Quake 3 Virtual Machine). This is a platform independent instruction set that can be compiled once and run on any platform. It also safer to run than platform specific code.
QVM is similar to the format’s used by Java and the C# programming languages but they depend on additional library functionality. It was exciting to see WebAssembly (WASM) develop as it is standalone like QVM and has a large ecosystem of software/tooling.
I’m not aware of a reason for new games to use QVM instead of WASM for supporting game modifications. However I was interested in implementing QVM support to better understand it.
Running QVM
I wrote a WebAssembly interpreter in November 2024 though March 2025 that passed 15,000 tests. I added reading QVM file structure and printing instructions in July 2025.
I resumed working on QVM support a year later in July 2026. I implemented the QVM instructions and added ability to run it. Easier said than done.
I got it to load the Quake 3 ui.qvm and call a function to get the UI version number, 4! The QVM depends on a lot of event function calls into the VM and import functions from the engine for drawing, getting the server list, etc to actually run. In order to focus on implementing the QVM interpreter, I replacing the entire VM subsystem of a Quake 3 engine with a shim that calls my interpreter but it uses the Quake 3 import/syscall functions.
Initially there was a black screen but it was running (yay). It kept calling “set menu” because the UI wasn’t started. Unlike WASM, I treated QVM as one function as there is only one entry point. However my interpreter aborted the “function” when leaving a function. It would call into several functions but the first time it reached the end of a function it prematurely exited the whole VM call instead of jumping back to the calling location.
I had some difficulty with the memory stack that holds arguments passed to functions and intermediates for instruction operations.
For WebAssembly I had a single memory stack with “stack-frame” (local variables and arguments) and variables pushed by instructions that is not directly accessibly by the VM code. QVM “local” instruction gets the address of the stack by hard coded offsets so it needs the stack-frames to be accessible address space. After moving the whole stack into VM space I found that stack-frames can’t be interleaved with the instruction stack.
It was stuck in an infinite loop due to the interleaved stack throwing things off that were expected at specific offset in the stack-frames. (Specifically reading the function return address; it kept jumping execution to address 0—the vmMain entry point.) Figuring out the issue involved a lot of reviewing the instructions and memory stack as text output. Over and over again with adding more debug messages.
The “enter” and “leave” instructions were kind of confusing for allocating/freeing space for local variables/args on the stack-frame. Though I eventually figured out that WASM has the same concept but it’s handled by the code directly to move the global index 0 “__stack_pointer”.
Eventually I got QVMs working and it just worked without any obscure issues.
Running WASM
I decided to compile Quake 3’s game code to WebAssembly since I had QVM working and technically had a WASM interpreter hooked up. It turned out to be sort of more difficult to run as WASM than run as QVM.
I had to work through some issues compiling Quake 3’s game logic to WASM and then fix loading WASM imports in my interpreter (functions, tables for function references). It was able to draw the menu! However it failed when loading a game level. It reported “NULL” map name and/or player model name; text formatting was broken.
I extracted the text formatting specific code from Quake 3’s bg_lib.c as a test case I could run directly and spent time reviewing the debug output. I was struggling to understand what my interpreter was doing wrong. The code was copying variadic arguments but reading the wrong values in nonsensical ways such as working for second text format but not the first. I could see it was passing an argument for “…” variadic arguments which doesn’t exist in the C code.
This is the part where I turned to AI. I explained the problem to Anthropic’s Claude large language model via duck.ai. After I sent a couple messages Claude suggested the issue was Clang’s handling of variadic arguments and that it needs to use Clang’s va_start() instead of the custom va_start() macro in Quake 3’s bg_lib.c. This indeed solved the issue after adjusting my build of Quake 3’s game code to WASM.
I had seen part of the problem while reviewing the WASM bytecode instructions and had seen code comments in the past relating to Clang varidic argument handling but I didn’t understand why Clang needs to use (it’s own) va_start() in order to put it together.
Traditionally the text format argument for variadic arguments is copied to local stack memory and the supplied arguments are stored after that. This allows using the address of the text format to find the variadic arguments after it. However Clang references the original text format argument in static memory and passes a second argument pointing to the variadic arguments stored in local stack memory. This needs Clang’s va_start() for the compiled code to access this second argument generated by Clang.
I defined “double” data type to “float” in the WASM build of Quake 3’s game code to match QVM behavior. However this broke text formatting as Clang converted float to double for variadic arguments but it only read 4 bytes. (The compiler also complained about using float in va_arg().) I limited defining double to float for the math function import declarations to work around this while still matching QVM import functions.
It could now load the map! However adding bot players to the game failed. I spent more time reviewing the debug information for the instructions.
Eventually I found something that stood out. It was using the return value from an implicit memset(). This optimization has come up before as crashing LCC (the QVM compiler) with it’s custom memset() that always returned NULL instead of the destination address. Conveniently I happened to know that the QVM imports for memset() and memcpy()—that were used by the WASM module—also return NULL. (I previously fixed this in Spearmint but it’s technically an ABI change to fix ioquake3 / Lilium Arena.)
I was able to override the return value for memset() and memcpy() in my WASM interpreter to return the destination address instead of NULL and this fixed bots! (There is also compiler flags to disable Clang apply optimizations that assume this return values and it might be worth looking into to be compatible with Quake 3’s original import functions / syscalls.)
I think I ended up spending more time reviewing the instructions and stack for running WASM than QVM but the issues were mainly related to the WASM build of the game code using Clang and not my interpreter. However in the end it worked!
Cool
It kind of hit me of how cool this was after I had both QVM and WASM builds of Quake 3’s game code running on the Quake 3 engine using my virtual machine interpreter and my reimplementation of the Quake 3 renderer. I was happy; I managed to impress myself by doing this.
My focus is to just run WASM and QVM at this stage, not be fast. Running WASM on my interpreter is slower than QVM. (I think I need to convert some things to be more direct like QVM to reduce overhead.) My QVM interpreter is slower than the original in Quake 3 itself and my renderer is missing some features and slower in some cases.
Why am I happy that I’ve objectively made a worse Quake 3 experience? It is satisfying to solve difficult problems and I can see a path to continue improving it.