Blog

Quake3 modding quickstart

NOTE: WORK IN PROGRESS!

Found some mistakes or are some instructions unclear? Please feel free to write me to:
me <at> pythno <dot> org
Thank you!

Install ioquake3

We are going to use the ioquake3 sourceport. And we’re going to compile it ourselfes. You will need the development libraries for SDL2. Consult your package manager how to install them onto your system.

Clone and install ioquake3:

git clone git@github.com:ioquake/ioq3.git
cd ioq3
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug

We make a Debug build so that we can step through the code.


Note

For a release build type Release instead of Debug.


Then, compile and link the programs:

cmake --build build

Done.

You should have a ioquake3 binary in ./build/Debug/. Notice the baseq3 folder. This folder contains all the game-data (textures, models, …) of the Quake 3 Arena game. If you make your stand-alone game some day you will have another folder, such as myCoolGame where all of the gamedata specific to your game go into.

To test our compiled binary out, we can use the Demo-version of Quake 3 Arena, so you don’t have to buy it (althout it doesn’t hurt to do so).
Here ist the data for the Quake 3 Arena demo: Demo.

Notice the fileending of pak0.pk3. This is just a regular zip file in disguise. You can inspect its contents with any ZIP tool on your OS.
Copy the pak0.pk3 into you repos’ ./build/Debug/baseq3 directory.

Now, we also need some more pk3 files, namely, the ones that id-Software published as patches. You can get them from: https://ioquake3.org/extras/patch-data/.
Copy the extracted pk3 files and also place them into your ./build/Debug/baseq3 directory.

Now try to launch ioquake3 from the command line. If everything went well you should now be able to play the demo version of Quake 3 Arena. Take some time and shoot some bots. You deserved it.

Map Editors

Netradiant Custom

Netradiant Custom is a derivative version of id Softwares original mapping tool, called Q3radiant. It runs on all OSes. I had trouble getting the precompiled binary to run on my linux box (it could not load a shared lib). Thankfully, compiling it yourself is not a big deal!
I did find it a bit hard to get used to NetRadiant’s userinterface. GitHub: https://github.com/Garux/netradiant-custom


IMPORTANT

Even if you don’t want to use Netradiant Custom and decide to use TrenchBroom (see below) instead, you still need some tools that come with Netradiant Custom but not with TrenchBroom. Thus, downloading it is required.
For the following steps the program q3map2 is needed. Make sure it is in your system-path so it is callable from anywhere within your terminal!


TrenchBroom

Trenchbroom is a joy to use as its UI is so clean and most of the functions can be figured out by just guessing. However, there is a very good documentation available: https://trenchbroom.github.io/manual/latest/
You can download it from: https://trenchbroom.github.io/.
You will need to tell TrenchBroom where your gamedata lives. For now, point it to the repo’s directory that has the baseq3 directory in it, that is the Game Path:
trenchbroom setup
TrenchBroom reads all assets from the pk3 files inside baseq3. For now, ignore the other two paths you can set (q3map2 and bspc).

If you navigate to the Face tab inside TrenchBroom you should now see all the available textures inside the pk3 files that lie in baseq3.


IMPORTANT

If you are using TrenchBroom on Wayland then start it with:

QT_QPA_PLATFORM=xcb ./trenchbroom

Compiling the Map

Create a new folder called pak10.pk3dir inside baseq3. Inside pak10.pk3dir create a folder named maps. Save your map from TrenchBroom or Netradiant Custom inside baseq3/pak10.pk3dir/maps/ and make it your cwd in your terminal. Type:

q3map2 -bsp <yourmapname>.map
q3map2 -vis <yourmapname>.bsp
q3map2 -light <yourmapname>.bsp

The first command compiles your map into BSP-tree. BSP stands for Binary Space Partition. It is a nifty datastructure that works really well for collision detection and hidden-surface-removal determination of static geometry.
The second command calculates the PSV, which stands for Potentially Visible Set. This is another optimization pass that lets the engine discard all triangles that cannot be seen from the current viewers camera position inside the gameworld. Thus, those triangles never get passed to the renderer. Quite frankly, if you just render Quake 3 Maps with relatively new hardware (maybe 2014+) it probably is faster to just render all of the world’s triangles every frame. GPUs have just become so fast in rasterizing triangles that the cost of figuring out if you can discard 50k triangles is higher than just feeding them into your GPU and let it render them. However, I do believe that the PSV is also used for spatial Audio playback. But I am not sure yet, I will have to dig into the code more to be sure.
The third command, finally, creates the lightmap. A lightmap essentially is a texture that contains diffuse precomputed illumination. It will get blended with the texture you have assigned to geometry inside your Map-Editor.

Starting your Map

In order to start your map inside ioquake3 you go into your build/debug/ folder and type:

./ioquake3 +devmap <yourmapname> +set sv_pure 0

+devmap tells the engine you load your map in debug-mode. That means you can issue console commands inside the engine (more on that later) to make yourself invincible, fly around, etc.
+set sv_pure 0 sets the in-engine variable sv_pure to 0 (=false). That means that the engine loads maps, texture, models, etc. from within folders. They don’t have to be inside a pk3 archive. It would be pretty annoying if you had to first pack everything before testing it, wouldn’t it?


NOTE

In order for the engine to use a folder as an assets-resource that folder must have the extension .pk3dir! +set sv_pure 0 is not enough!


When some “textures” just don’t work.

You might have assigned some textures to surfaces in your map editor such as clang_floorshiny2. Then, after compiling your map successfully (q3map2 threw new errors or warnings) that texture you saw and assigned in the editor appears as a greyish color with white stripes. That is the engine’s default-texture. Essentially the texture that will be assigned when it cannot find necessary data to texture a surface correctly: tb shows clang_floorshiny2 texture assigned to geometry engine cannot display clang_floorshiny2 texture The reason for this is that clang_floorshiny2 is not just a simple texture. It is supposed to be a texture that is tied to a shader-script. Shader-scripts are simple textfiles that lie inside baseq3/scripts.
If we search for clang_floorshiny2 inside those files we can see that it appears in base_floor.shader. grepping for string clang_floorshiny2 inside scripts dir Let’s open it and look for that ominously clang_floorshiny2:

// The shader name. Used by the editor and the 
// game engine.
textures/base_floor/clang_floorshiny2
{
	// What the editor uses and reads. NOT used by 
	// anything else. This is handy and can be very
	// powerful. You can use this to help with 
	// layout and texture alignment.
	qer_editorimage textures/base_floor/clang_floor.tga

	// What the game engine reads. These are rendering 
	// rules.
	{
		map $lightmap
		tcGen environment
		tcmod scale .25 .25 
		rgbgen identity 
	}

	{
		// This texture was never shipped with the game 
		// and is the reason for the default tetxure
		// used in the engine.
		map textures/base_floor/clang_floorshiny_alpha2.tga
		blendFunc GL_ONE GL_SRC_ALPHA
		alphagen wave triangle .98 .02 0 10
		rgbGen identity
	}
	{
		map $lightmap
		rgbgen identity
		blendFunc GL_DST_COLOR GL_ZERO
	}
}

We can see that two texture-files are referenced: clang_floor.tga and clang_floorshiny_alpha2.tga. Let’s search for both of them inside the unpacked pk3 files.
Let’s look for clang_floor.tga:
clang_floor exists, albeit as jpg and not tga
What about clang_floorshiny_alpha2.tga?
clang_floorshiny_alpha2 does not exist Well, at least clang_floor exists as a JPG and not as a TGA. That is fine though, the engine is able to handle multiple image fileformats! The problem starts (and ends) with the clang_floorshiny_alpha2 file. It just is nowhere to be found! Why is that? Apparently, Quake III Arena has some leftover shader-scripts that were not used in the final game. As such, the required textures were stripped from the release. Precious CD-ROM space, ya know? At least that is what is rumored.
I want to take the time and thank @Tig @themuffinator and especially @xage from the Quake Map-Center Discord for taking the time and helping me with how these things are tied together and why some textures (or rather: shader-textures) won’t work in-engine.

@Tig actually took the time to clean up those shader-files so that you won’t see any shader-based textures in your editor that will lead to a missing-texture surprise in ioQuake3: https://github.com/Tigger-oN/lvl-q3a-clean-shaders/. Thanks a lot!

Debugging the code with Visual Studio Code

It is straightforward to setup VSCode for debugging ioQuake3. Install the CMake Tools from Microsoft: cmake tools from microsoft in vscode


NOTE

If you have created a build directory already like in the beginning of the article, I recommend to delete the CMakeCache.txt inside it as it might confuse VSCode when configuring the project.


If you now open ioQuake3’s root folder in VSCode, CMake should automatically run and configure the project, that is, a Makefile on UNIX by default.
opening ioquake3 root dir inside vscode triggers cmake to generate the build files
Now, have a look on the left toolbar and click onto CMake.
clicking on the cmake button on the left hand side of vscode opens the cmake settings

You can select the build type (Debug, Release, etc.) and your toolchain (clang, gcc, …) which will reconfigure the project. Then, when you’re happy with the settings click the build icon next to the Dropdown menu that says Build. Building the first time might take a while. Further build will be significantly faster, though as only the compilation units that you change will need a recompilation. Linking is generally fast for ioQuake3. Now, start the game by clicking on the play button, either down at the menu-bar or next to Launch:
VScode launch UI buttons
Of course, if you made a Debug-build you can step through the code and learn a lot about the engine internals :) Isn’t that great? For example open the file:

./code/sys/sys_main.c

and find the int main( int argc, char **argv) function, which is the entrypoint of the engine. Create a breakpoint somewhere in that function by clicking left to the line-number you want the debugger to halt the program and start by clicking the play button with the little bug:
VScode debug UI buttons

If you get an error message that the assets are missing then you need to copy the PK3 files again into the build/Debug/ directory. This might be the case if you deleted the build/Debug folder as I recommended above. In this case just copy all the PK3 files into baseq3 and you’re good to go! In my case, the full path is:

/Users/me/repos/ioq3/build/Debug/baseq3

Shared libraries vs. QVM

If you set a breakpoint in eg. g_missile.c inside the fire_rocket function start a game, pick up the rocket launcher (the map Q3DM1 has a rocket launcher right in front of your nose when you start it up), and then shoot a rocket you will notice that the beakpoint is not being hit. This is because, by default, the engine starts using the QVM version of the gamecode.

QVM stands for Quake Virtual Machine. This is the scripting system in idTech3. But wait? g_missile.c is a regular C sourcefile, isn’t it? Correct! The scripting language is regular C-Code. That C-Code is compiled to QVM Bytecode. Check out the directory: build/Debug/baseq3/vm/. Inside it you will find 3 files, namely: cgame.qvm, qagame.qvm and ui.qvm. Your game-code lives inside those files that contain the bytecode. At runtime, this bytecode is interpreted by the Virtual Machine inside the engine. Conveniently, this bytecode is platform independent. Any platform that implements the Quake Virtual Machine can run that code. That means, if you would ship a mod that doesn’t touch the engine’s internals you just have to hand out those QVM files (along with the assets, of course) to your players. I don’t know exactly why John Carmack decided to go that route, using C as the scripting language, but it has a significant advantage over another language: You can decide not to compile your C-code to QVM bytecode and instead compile it to native machine-code: Look at the directory: build/Debug/baseq3. You’re going to find the files: cgame.so/DLL, qagame.so/DLL and ui.so/DLL. They look very similar to what we’ve seen inside the vm/ subfolder, don’t they? Well, that is because they are the machine-code native versions of those QVM files. You now can decide to load the .so/.DLL files when starting up the engine and use your regular Debugging tools that you would use when debugging the rest of the codebase. No extra tooling needed to debug your game-code. Genius!

Starting the engine with machine-native libraries

Pass the following parameters to the ioquake3 executable in order to use machine-native code instead of the QVM-Bytecode:

./ioquake3 +set vm_cgame 0 +set vm_game 0 +set vm_ui 0

That’s it! Now, in order to make use of this inside VSCode, we have to tell it. You will need a launch.json file for that.
Here is how to set it up:

  1. From the sidebar, select Run and Debug
  2. Click Create a launch.json file
  3. Select C/C++ (gdb) Lauch
    Creating launch.json for vscode
    A launch.json template file will be created. Change the program and args entries to:
...
"program": "${workspaceFolder}/build/Debug/ioquake3,
"args": ["+set", "vm_cgame", "0", "+set", "vm_game", "0", "+set", "vm_ui", "0", "+set", "sv_pure", "0"],
...

program tells VSCodw what to start. In that case it should run the engine ioquake3*. As discussed above we also need to pass some arguments to ioquake3 in order to run it using the shared machine-native libraries instead of the QVM-bytecode so that we can debug it. That is achieved by passing the arguments as an array to args. And be careful: You have to pass each string of text delimited by a space as its own element to the array! As you can have many ways of launching the engine by adding more configurations to launch.json I also give it distinctive names that tell me exactly what they do:

"name": "Debug ioquake3 (gdb, shared libs)",

The name will appear in the dropdown menu:
Selecting launch config in vscode

I added another config that lets me launch the game using the QVM-bytecode. Of course, debugging, won’t be possible then, but it doesn’t hurt to test running your game using the QVM-files if you intend to ship those.

For completeness, this is what my launch.json looks like:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug ioquake3 (gdb, shared libs)",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/build/Debug/ioquake3",
            "args": ["+set", "vm_cgame", "0", "+set", "vm_game", "0", "+set", "vm_ui", "0", "+set", "sv_pure", "0"],
            "stopAtEntry": false,
            "cwd": "${fileDirname}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                },
                {
                    "description": "Set Disassembly Flavor to Intel",
                    "text": "-gdb-set disassembly-flavor intel",
                    "ignoreFailures": true
                }
            ]
        },
        {
            "name": "Debug ioquake3 (gdb, QVM)",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/build/Debug/ioquake3",
            "args": ["+set", "vm_cgame", "2", "+set", "vm_game", "2", "+set", "vm_ui", "2", "+set", "sv_pure", "0"],
            "stopAtEntry": false,
            "cwd": "${fileDirname}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                },
                {
                    "description": "Set Disassembly Flavor to Intel",
                    "text": "-gdb-set disassembly-flavor intel",
                    "ignoreFailures": true
                }
            ]
        },
    ]
}