Skip to content
Kevin Masterson edited this page Apr 15, 2025 · 11 revisions

QVM

A QVM file is a mod file designed to run in the Quake Virtual Machine.

It is a binary bytecode format that is created by the id-provided q3asm assembler, after being compiled by the q3lcc compiler, which is a modified version of the public lcc compiler.

A QVM mod can only utilize external functions that are specifically provided by the game engine, and as such, QVM mods is considered "safe" to run.

I have written a small basic disassembler for QVM files called qvmops, which can be found here.

Currently, the only QVM-enabled engines that QMM supports are:

  • Quake 3 Arena
  • Jedi Knight 2: Jedi Outcast
  • Star Trek Voyager: Elite Force (Multiplayer)
  • Soldier of Fortune II: Double Helix

Other games that use QVM that are not supported by QMM:

  • I don't know

File Format

The format of the QVM file itself is relative simple. It starts with a header that looks like:

Field Size (bytes) Meaning
magic 4 0x44 0x14 0x72 0x12
opcount 4 number of opcodes in code segment
codeoffset 4 offset into file where code segment starts
codelength 4 length of code segment in file
dataoffset 4 offset into file where data segment starts
datalen 4 length of the initialized-data part of the data segment
litlen 4 length of the literal-data part of the data segment (this is for string literals)
bsslen 4 amount of uninitialized-data space required when the QVM is loaded into memory (this is not stored in the file)

The code segment contains sequential 1-byte opcodes and their associated parameters, with no padding. The following opcodes contain parameters, which are the bytes immediately following the opcode:

Opcode Parameter size in bytes
OP_ENTER 4
OP_LEAVE 4
OP_CONST 4
OP_LOCAL 4
OP_EQ 4
OP_NE 4
OP_LTI 4
OP_LEI 4
OP_GTI 4
OP_GEI 4
OP_LTU 4
OP_LEU 4
OP_GTU 4
OP_GEU 4
OP_EQF 4
OP_NEF 4
OP_LTF 4
OP_LEF 4
OP_GTF 4
OP_GEF 4
OP_BLOCK_COPY 4
OP_ARG 1

Loading the file

Loading the QVM file into memory is relatively straight-forward:

  1. Calculate total size of memory needed and allocate
  2. Copy code segment into memory
  3. Copy data segment (including initialized and literal sections) into memory

Calculate total size

The total size will generally be determined from the following calculation:

header->numops * sizeof(qvmop_t) + header->datalen + header->litlen + header->bsslen + stacksize

Since the OP_CALL, OP_JUMP, etc. instructions operate by moving a specific number of instructions (rather than bytes) forward or backward in memory, the simplest way* to handle the code segment is to have each instruction take the same space, even if the opcode doesn't actually use a param. So in our case, we treat an instruction in memory as a simple struct of opcode and param that is always the same size:

struct qvmop_t {
    int op;
    int param;
};

The code segment is then accessed using a qvmop_t*, so pointer arithmetic makes jumping and incrementing simple.

* Note: The original Quake 3 engine loads ops into memory exactly as they are in the file (no padding, no space for unused param, etc). It then goes through and makes a separate array to track instruction index vs code segment offset. This allows them to easily jump to a specific instruction while still keeping the instructions at variable length. I opted for ease of code and understanding.

Copy code segment into memory

Now with that out of the way, you can load the code segment into memory by looping through each byte of the code segment in the file and copying it to each qvmop_t->op, followed by a 4 or 1 byte param in the file (based on the above Parameter Size table) being copied into the associated qvmop_t->param.

Copy data segment into memory

The data segment is copied directly from the file's data segment into memory, for a total length of header->datalen + header->litlen (the bss segment is not actually in the file since it is all uninitialized anyway).

The stack segment (the final memory segment) stays empty for now.

Executing the QVM

Before we begin, we should go over once again how it is laid out in memory:

Segment Size Notes
Code header->numops * sizeof(qvmop_t) Never changes once loaded
Data (initialized) header->datalen All initialized global/static variables
Data (literal) header->litlen All string literals from source
Data (bss) header->bsslen All uninitialized global/static variables
Stack segment stacksize (1MB default)

The stack segment itself is really in two parts: the argument stack, and the operation stack. The argument stack is used for passing arguments to functions, as well as storing function-local variables. The operation stack is used for all math, comparison, branching, etc. operations. Both of these stacks are simply ints, and as such a single push/pop will be a 4 byte change.

The argument stack is managed primarily by OP_ENTER and OP_LEAVE, the first and last instructions inside a function. OP_ENTER will grow the argument stack by its param and store the current instruction pointer on the top. OP_LEAVE will pull the return instruction pointer from the top and shrink the argument stack by the same amount (also stored in its param). The new argument stack space from OP_ENTER will be enough to store the function's local variables, the max amount of arguments needed when calling other functions, and 2 additional values (one of which is where the return instruction pointer is stored).

Before execution begins, the argument stack is grown and the vmMain arguments are placed on the stack, along with a sentinel return instruction offset. Execution begins at the start of the code segment, which should be vmMain's OP_ENTER if compiled correctly. When the mod needs to call the engine's syscall, a negative instruction offset is loaded onto the stack with OP_CONST and then followed by OP_CALL. OP_CALL handles negative instruction pointers specially, and knows to call syscall. Otherwise, they jump to the given instruction offset.

Once vmMain's OP_LEAVE is executed, the sentinel instruction pointer is loaded, and the interpreter knows to end execution.

QMM adds some safety checks when executing code, including making sure the code pointer is within the code segment, and making sure any data read/write is within the data segment or stack segment. It also makes sure the stacks don't grow into each other or outside of the stack segment.

Note: The data segment check unfortunately has to be disabled in Soldier of Fortune II, as that engine provides syscalls that return pointers to the engine's memory.

You can view src/qvm.cpp to see how QMM implements the QVM, and it is fairly heavily-commented. The file is loaded into memory in qvm_load, and the execution is in qvm_exec. If you have any questions, please feel free to reach out via email or open a Discussion.

Clone this wiki locally