In honor of Doom’s 30th birthday, I decided I would try to compile the Doom source code on GitHub. Since I had no idea what I was doing, I searched around and found the this incredible video by hot_coffee_guy. It has no sound, but by just watching it, and with a little trial and error, I was able to get the game up and running.
I encountered some challenges along the way, and want to fill in explanations where I can, both as a record for anyone who might experience the same issue, and for my own edification so let’s get to it!
The Setup
The first step is to get the source code. It is available in the id-software repository here. You can clone it, or fork it, whichever you desire. I decided to make a fork and named it DOOMLEARN.
Using CMake and building with an IDE
The first time I did this, I used Make
and the Makefile
that came with the project. However, on my second round for this article, I wanted to make code edits using CLion, so I opted to create a CMake
file. I’ve added a CMakeLists.txt
file to my version of the project, and if you want to use it, you can find it here.
Using Make
If you use Make
the setup is already done for you. Just navigate to where you downloaded the DOOM source code, cd
into linuxdoom-1.10
, and run…
# the executable automatically writes to a folder called linux
mkdir linux
make
You can expedite the compilation process by specifying the -j
parameter with the desired number of parallel jobs
make -j8
Getting Started
Now that the prep work is out of the way, we need to build the project. You can build it with either Make
or CMake
. I'll demonstrate the Make
method first before transitioning to the CMake
build for the rest of the article.
deca@pop-os:~/programming/c_programming/DOOMLEARN/linuxdoom-1.10$ make -j8
gcc -g -Wall -DNORMALUNIX -DLINUX -c doomdef.c -o linux/doomdef.o
gcc -g -Wall -DNORMALUNIX -DLINUX -c doomstat.c -o linux/doomstat.o
gcc -g -Wall -DNORMALUNIX -DLINUX -c dstrings.c -o linux/dstrings.o
gcc -g -Wall -DNORMALUNIX -DLINUX -c i_system.c -o linux/i_system.o
gcc -g -Wall -DNORMALUNIX -DLINUX -c i_sound.c -o linux/i_sound.o
gcc -g -Wall -DNORMALUNIX -DLINUX -c i_video.c -o linux/i_video.o
gcc -g -Wall -DNORMALUNIX -DLINUX -c i_net.c -o linux/i_net.o
gcc -g -Wall -DNORMALUNIX -DLINUX -c tables.c -o linux/tables.o
dstrings.c:25:1: warning: ‘rcsid’ defined but not used [-Wunused-const-variable=]
25 | rcsid[] = "$Id: m_bbox.c,v 1.1 1997/02/03 22:45:10 b1 Exp $";
| ^~~~~
Assembler messages:
Fatal error: can't create linux/dstrings.o: No such file or directory
make: *** [Makefile:91: linux/dstrings.o] Error 1
make: *** Waiting for unfinished jobs....
doomdef.c:26:1: warning: ‘rcsid’ defined but not used [-Wunused-const-variable=]
26 | rcsid[] = "$Id: m_bbox.c,v 1.1 1997/02/03 22:45:10 b1 Exp $";
| ^~~~~
Assembler messages:
After building the project we get a lot of warnings, which is to be expected for a game that came out in 1993, but eventually we hit our first fatal error.
i_video.c:49:10: fatal error: errnos.h: No such file or directory
49 | #include <errnos.h>
| ^~~~~~~~~~
In CLion, since I setup my CMakeLists.txt
file, I can build the program in one click by pressing the run button.
To make finding errors easier, I recommend suppressing most of the warnings to focus on compilation errors that stop the build. I did this by adding the following commands to my CMakeLists.txt
file
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-unused-but-set-variable")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-unused-but-set-variable")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-pointer-to-int-cast")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-pointer-to-int-cast")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wno-unused-const-variable")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-unused-const-variable")
These commands effectively suppress most of the warnings the project generates. Now, let's address the initial problem…
i_video.c:49:10: fatal error: errnos.h: No such file or directory
49 | #include <errnos.h>
| ^~~~~~~~~~
Thankfully our first error is simple. There is no header file called errnos.h
. It is errno.h
and it exists in the C standard library. To fix this we just have to open the i_video.c
file and remove the “s” from the header.
#include <errno.h>
Now lets recompile the project again. This time we get a different error
gcc -Wno-unused-but-set-variable -Wno-pointer-to-int-cast -Wno-unused-const-variable -g -Wall -DNORMALUNIX -DLINUX -g -fdiagnostics-color=always -MD -MT CMakeFiles/linuxxdoom.dir/m_misc.c.o -MF CMakeFiles/linuxxdoom.dir/m_misc.c.o.d -o CMakeFiles/linuxxdoom.dir/m_misc.c.o -c /home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/m_misc.c
/home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/m_misc.c:257:48: error: initializer element is not constant
257 | {"sndserver", (int *) &sndserver_filename, (int) "sndserver"},
| ^
/home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/m_misc.c:257:48: note: (near initialization for ‘defaults[14].defaultvalue’)
/home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/m_misc.c:264:35: error: initializer element is not constant
264 | {"mousedev", (int*)&mousedev, (int)"/dev/ttyS0"},
| ^
/home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/m_misc.c:264:35: note: (near initialization for ‘defaults[16].defaultvalue’)
/home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/m_misc.c:265:37: error: initializer element is not constant
265 | {"mousetype", (int*)&mousetype, (int)"microsoft"},
| ^
The issue is related to a cast on the defaultvalue
field in the default_t struct in our m_misc.c
file.
typedef struct
{
char* name;
int* location;
int defaultvalue;
int scantranslate; // PC scan code hack
int untranslated; // lousy hack
} default_t;
I think what is happening is we are using the pointer to the string as an number, but this won’t work on 64 bit systems. A pointer is 8 bytes on 64bit systems and and an int holds four. We can fix this by changing the defaultvalue
to a long long
(or long int
whichever you prefer) which will be large enough to hold the address of our pointer
typedef struct
{
char* name;
int* location;
long long defaultvalue;
int scantranslate; // PC scan code hack
int untranslated; // lousy hack
} default_t;
This means we need to make a change on every cast to defaultvalue
in the file to be a long long
instead of an int. The first is on line 257
// UNIX hack, to be removed.
#ifdef SNDSERV
{"sndserver", (int *) &sndserver_filename, (long long) "sndserver"},
{"mb_used", &mb_used, 2},
The next are on lines 264 and 265
#ifdef LINUX
{"mousedev", (int*)&mousedev, (long long)"/dev/ttyS0"},
{"mousetype", (int*)&mousetype, (long long)"microsoft"},
and finally 288-297
{"chatmacro0", (int *) &chat_macros[0], (long long) HUSTR_CHATMACRO0 },
{"chatmacro1", (int *) &chat_macros[1], (long long) HUSTR_CHATMACRO1 },
{"chatmacro2", (int *) &chat_macros[2], (long long) HUSTR_CHATMACRO2 },
{"chatmacro3", (int *) &chat_macros[3], (long long) HUSTR_CHATMACRO3 },
{"chatmacro4", (int *) &chat_macros[4], (long long) HUSTR_CHATMACRO4 },
{"chatmacro5", (int *) &chat_macros[5], (long long) HUSTR_CHATMACRO5 },
{"chatmacro6", (int *) &chat_macros[6], (long long) HUSTR_CHATMACRO6 },
{"chatmacro7", (int *) &chat_macros[7], (long long) HUSTR_CHATMACRO7 },
{"chatmacro8", (int *) &chat_macros[8], (long long) HUSTR_CHATMACRO8 },
{"chatmacro9", (int *) &chat_macros[9], (long long) HUSTR_CHATMACRO9 }
Now let’s build the project again.
/usr/bin/ld: errno: TLS definition in /lib/x86_64-linux-gnu/libc.so.6 section .tbss mismatches non-TLS reference in CMakeFiles/linuxxdoom.dir/i_sound.c.o
/usr/bin/ld: /lib/x86_64-linux-gnu/libc.so.6: error adding symbols: bad value
collect2: error: ld returned 1 exit status
ninja: build stopped: subcommand failed.
Cool a linker error! That means that we were able to compile our code to obj files. It looks like a thread local storage error in the way we are using errno
. errno
is a global variable used in C to indicate the error status of many library functions, particularly those involving system calls. When these functions fail, they typically set errno
to an integer value representing what kind of error occurred. Not totally unexpected as we changed the way we handled errno
earlier on in the program. The issue is in the i_sound.c
so let’s go there.
errno
is thread safe, but we were using a non standard version of errno
in our program. Searching for it in our file we see the offending code on line 166. We have to delete the line extern int errno
…
myioctl
( int fd,
int command,
int* arg )
{
int rc;
extern int errno;
rc = ioctl(fd, command, arg);
if (rc < 0)
{
fprintf(stderr, "ioctl(dsp,%d,arg) failed\n", command);
fprintf(stderr, "errno=%d\n", errno);
exit(-1);
}
}
and then include the errno.h
header file like we’ve done before. Then we will be using the errno value that comes with the standard library.
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/errno.h>
Building it again and we get an executable!
Buuut it will segfault when we try to run it
What is the error saying? No files found? Let’s look where this error is called
void W_InitMultipleFiles (char** filenames)
{
int size;
// open all the files, load headers, and count lumps
numlumps = 0;
// will be realloced as lumps are added
lumpinfo = malloc(1);
for ( ; *filenames ; filenames++)
W_AddFile (*filenames);
if (!numlumps)
I_Error ("W_InitFiles: no files found");
// set up caching
size = numlumps * sizeof(*lumpcache);
lumpcache = malloc (size);
if (!lumpcache)
I_Error ("Couldn't allocate lumpcache");
memset (lumpcache,0, size);
}
Line 292 of w_wad.c
is where this function is defined, but where is it used? Using our IDE we find its first usage in d_main.c
in line 1021
W_InitMultipleFiles (wadfiles);
The variable wadfiles
is an array of wadfiles that are defined earlier in our d_main.c
on line 37. We set the array of wadfiles in our function called D_AddFile
on line 543
void D_AddFile (char *file)
{
int numwadfiles;
char *newfile;
for (numwadfiles = 0 ; wadfiles[numwadfiles] ; numwadfiles++)
;
newfile = malloc (strlen(file)+1);
strcpy (newfile, file);
wadfiles[numwadfiles] = newfile;
}
And our first usage of our D_AddFile
comes on line 621
if (M_CheckParm ("-shdev"))
{
gamemode = shareware;
devparm = true;
D_AddFile (DEVDATA"doom1.wad");
D_AddFile (DEVMAPS"data_se/texture1.lmp");
D_AddFile (DEVMAPS"data_se/pnames.lmp");
strcpy (basedefault,DEVDATA"default.cfg");
return;
}
Hmm seems like we have a bunch of parameters which tell Doom where the wadfile is. Looking further up the lines of source code, we get our indication of what we need to do
#ifdef NORMALUNIX
char *home;
char *doomwaddir;
doomwaddir = getenv("DOOMWADDIR");
if (!doomwaddir)
doomwaddir = ".";
// Commercial.
doom2wad = malloc(strlen(doomwaddir)+1+9+1);
sprintf(doom2wad, "%s/doom2.wad", doomwaddir);
// Retail.
doomuwad = malloc(strlen(doomwaddir)+1+8+1);
sprintf(doomuwad, "%s/doomu.wad", doomwaddir);
// Registered.
doomwad = malloc(strlen(doomwaddir)+1+8+1);
sprintf(doomwad, "%s/doom.wad", doomwaddir);
It appears that we can set an environment variable to tell DOOM where to look for our wad. If we don’t set this variable, it will assume it is in whatever directory the wadfile is called. That’s useful information, but before we can do anything with that, we need to get an actual wad file.
Getting a DOOM WAD
There are two ways you can get this.
If you don’t own DOOM you can go to the doomworld website and download the shareware version ( this is a downloadable link)
If you own a copy of DOOM, get the wad from the game files
If you download the shareware version you will want to rename the version from DOOM1.wad to doom1.wad (that’s the string the code looks for), and then follow the steps in the next section. I own DOOM on GOG so I will outline how do get the wad file from there because it requires a few steps. First go to your GOG library and find your DOOM Files
Clicking on the 18Mb Ultimate Doom Link will download the game. In my downloads I now have a file called setup_the_ultimate_doom_1.9_(28044).exe
. To get the wad from the .exe, I need to extract it using a tool called innoextract
. Make a folder called doom_unpack, and move the setup.exe
to it. Once that is done, in the terminal run
innoextract setup_the_ultimate_doom_1.9_(28044).exe
Doing this will extract all the files in the game including our DOOM.WAD
Remember! In the code, doom.wad
is all lowercase. Make sure to change the name!
// looking for lowercase doom
D_AddFile (DEVDATA"doom.wad");
Once you have the doom1.wad or doom.wad
After moving our doom.wad
into our linuxdoom-1.10/linux
folder (where the executable is), lets start running our code again in our IDE.
V_Init: allocate screens.
M_LoadDefaults: Load system defaults.
Z_Init: Init zone memory allocation daemon.
W_Init: Init WADfiles.
Error: W_InitFiles: no files found
What gives! The error is still there? Before you freak out, it’s because of the way the IDE is calling the executable and the paths. If you manually cd
into the linux
folder, and run the executable (with the doom1 or doom wad in the same folder) you will see two things.
V_Init: allocate screens.
M_LoadDefaults: Load system defaults.
Z_Init: Init zone memory allocation daemon.
W_Init: Init WADfiles.
adding ./doom.wad
===========================================================================
Commercial product - do not distribute!
Please report software piracy to the SPA: 1-800-388-PIR8
===========================================================================
M_Init: Init miscellaneous info.
R_Init: Init DOOM refresh daemon - [.Segmentation fault (core dumped)
One is that the game successfully added the wad, and two, that we have a new error. If you remember back in the code that deals with the DOOM wad, there was a section where it checked the DOOMWADDIR environment variable, which sets the path to our DOOMWADDIR.
doomwaddir = getenv("DOOMWADDIR");
if (!doomwaddir)
doomwaddir = ".";
Let’s try setting that environment variable. Since this happens during the “run” phase of the program, we have to make sure the environment variable is set during that phase in our IDE
Since I’m using Clion this requires clicking the drop down next to the hammer icon and clicking “Edit Configurations”. This will open up a window that will allow me to set an environment variable.
If you are following along using Make and the terminal the same effect can be had by just running this code before running the executable
export DOOMWADDIR=/path/to/folder/where/doom.wad/is
After making this change, you should see a new erro when you build the project.
M_LoadDefaults: Load system defaults.
Z_Init: Init zone memory allocation daemon.
W_Init: Init WADfiles.
adding /home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/doom.wad
===========================================================================
Commercial product - do not distribute!
Please report software piracy to the SPA: 1-800-388-PIR8
===========================================================================
M_Init: Init miscellaneous info.
R_Init: Init DOOM refresh daemon - [.
Process finished with exit code 139 (interrupted by signal 11:SIGSEGV)
Progress! Now it’s crashing on the R_Init DOOM step. Which is called on line 1095 in d_main.c
printf ("R_Init: Init DOOM refresh daemon - ");
R_Init ();
The definition of this function is defined in line 773 in r_main.c
void R_Init (void)
{
R_InitData ();
printf ("\nR_InitData");
R_InitPointToAngle ();
printf ("\nR_InitPointToAngle");
R_InitTables ();
// viewwidth / viewheight / detailLevel are set by the defaults
Considering that I didn’t see the printf ("\nR_InitData")
in the terminal, I’m assuming the crash was in R_InitData. That is defined on line 654 of r_data.c
void R_InitData (void)
{
R_InitTextures ();
printf ("\nInitTextures");
R_InitFlats ();
printf ("\nInitFlats");
R_InitSpriteLumps ();
printf ("\nInitSprites");
R_InitColormaps ();
printf ("\nInitColormaps");
}
More printfs that didn’t print to the terminal, The errors must be in R_InitTextures
.
I_Init: Setting up machine state.
Could not start sound server [sndserver]
D_CheckNetGame: Checking network game status.
startskill 2 deathmatch: 0 startmap: 1 startepisode: 1
player 1 of 1 (1 nodes)
S_Init: Setting up sound.
S_Init: default sfx volume 8
HU_Init: Setting up heads up display.
ST_Init: Init status bar.
Error: xdoom currently only supports 256-color PseudoColor screens
I admit this is where I got stuck, but hot_coffee_guy’s video we see that we have to make a few changes in r_data.c
including…
Adding and include for <stdint.h> at the top of the file.
static const char
rcsid[] = "$Id: r_data.c,v 1.4 1997/02/03 16:47:55 b1 Exp $";
#include <stdint.h>
#include "i_system.h"
This allows the use of fixed width integer types like intptr_t
. These types have a consistent size across different platforms. Next we have to change our maptexture_t
struct to be an int columndirectory
instead of an void **columndirectory
typedef struct
{
char name[8];
boolean masked;
short width;
short height;
int columndirectory;
short patchcount;
mappatch_t patches[1];
} maptexture_t;
Next we remove the hard coding of the pointer sizes to the textures on line 484-487 in our r_data.c
. We can do this and make the code more flexible using the sizeof
operator
textures = Z_Malloc (numtextures*sizeof(*textures), PU_STATIC, 0);
texturecolumnlump = Z_Malloc (numtextures*sizeof(*texturecolumnlump), PU_STATIC, 0);
texturecolumnofs = Z_Malloc (numtextures*sizeof(*texturecolumnofs), PU_STATIC, 0);
texturecomposite = Z_Malloc (numtextures*sizeof(*texturecomposite), PU_STATIC, 0);
On line 644 in our R_InitColormaps
function, we want to change the cast from int
to an intptr_t
. This concludes are changed to r_data.c
void R_InitColormaps (void)
{
int lump, length;
// Load in the light tables,
// 256 byte align tables.
lump = W_GetNumForName("COLORMAP");
length = W_LumpLength (lump) + 255;
colormaps = Z_Malloc (length, PU_STATIC, 0);
colormaps = (byte *)( ((intptr_t)colormaps + 255)&~0xff);
W_ReadLump (lump,colormaps);
}
The next file we wanto to change is r_draw.c
. In r_draw.c
we want to add the #include <stdint.h>
to the top again. Then we want to jump down to line 459 with the function R_InitTranslationTables
and change our cast from an int
to an intptr_t
for the translationtables
variable on line 464
translationtables = Z_Malloc (256*3+255, PU_STATIC, 0);
translationtables = (byte *)(( (intptr_t)translationtables + 255 )& ~255);
Moving back to r_data.c
, We then cast the variable colormaps
in the function R_InitColormaps
to an intptr_t
//
void R_InitColormaps (void)
{
int lump, length;
// Load in the light tables,
// 256 byte align tables.
lump = W_GetNumForName("COLORMAP");
length = W_LumpLength (lump) + 255;
colormaps = Z_Malloc (length, PU_STATIC, 0);
colormaps = (byte *)( ((intptr_t)colormaps + 255)&~0xff);
W_ReadLump (lump,colormaps);
}
and again we add sizeof
to make our code more flexible and allocate the right amount of memory.
texturecolumnlump = Z_Malloc (numtextures*sizeof(*texturecolumnlump), PU_STATIC, 0);
texturecolumnofs = Z_Malloc (numtextures*sizeof(*texturecolumnofs), PU_STATIC, 0);
texturecomposite = Z_Malloc (numtextures*sizeof(*texturecomposite), PU_STATIC, 0);
Now with all of that done we compile and run our executable again. No linking errors or segfaults this time! So everything was successful. Let’s see what happens when we run our program
Could not start sound server [/home/deca/programming/c_programming/DOOMLEARN/linuxdoom-1.10/sndserver]
S_Init: default sfx volume 8
Error: xdoom currently only supports 256-color PseudoColor screens
A new error! We are making more progress! This one is because the original DOOM was designed to run in an 8-bit color mode, which is not natively supported by most modern systems. Modern displays and graphics interfaces usually operate at much higher color depths (like 24-bit or 32-bit). When you try to run an 8-bit application on a system that doesn't support this color depth natively, you can get color distortion and errors.
I can check what my current color depth is by using xdpyinfo.
This is a commandline utility on Linux that displays information about an X server. The X server is part of the X Window System which provides the framework for creating and managing graphical user interfaces on Linux desktops.
xdpyinfo
provides various details like screen dimensions, supported visuals, and color depth. Since we are only interested in the color depth we can grep
for it in the output.
xdpyinfo | grep 'depth of root window'
depth of root window: 24 planes
So my X server is running with a 24-bit color depth and I need to run at 8-bit. What do I do? Thankfully I can get around this by starting a Separate X Session. By starting a new session with specific parameters, I can configure it to use an 8-bit color depth, which will allow DOOM to display correctly. Xephyr
is the tool I will use to do this. It is a nested X server that operates as a client to a host X server. By using Xephyr
, I can construct a display with the characteristics I need. Type this into the terminal
sudo Xephyr :2 -ac -screen 640x480x8
What this is doing, is executing Xephyr as a superuser, without access control (-ac), with a screen resolution of 640x480 pixels and a color depth of 8-bits (x8). The result is an empty display.
Now we need to make sure the game launches in this second display. If you are following along in the IDE, you can set the DISPLAY
environment variable to :2
, which is the first argument we passed to Xephyr
. If you are in a terminal, just type this , before launching the DOOM executable
DISPLAY=:2
When we run our executable it should pop up in the Xephyr
window. But all the colors will be messed up.
Googling this error, leads to this stack overflow post that highlights the solution. We want to add a call to XInstallColormap near line 820 before the call to XDefineCursor
in the i_video.c
file. The XInstallColormap
function installs the specified colormap into the X server's colormap database. Once installed, the colormap becomes available for use by windows on the specified X server. This means that any window that is using this colormap will be able to display colors from that colormap, fixing our messed up colors.
XInstallColormap( X_display, X_cmap );
Finally we have to change this line in p_setup.c
on line 536
linebuffer = Z_Malloc (total*sizeof(*linebuffer), PU_LEVEL, 0);
Now we run the executable for one final time and…. It works! The game is running!
On my 4k monitor the screen is waay too small, but I’m just happy it’s running at all.
While this was an interesting exercise, and a good way to see how to organize large C projects, If you want to actually play vanilla Doom on modern hardware, you should use Chocolate Doom ( a pun on Vanilla Doom). The project aims to accurately reproduce the game so that it can run on new hardware, but without altering gameplay. They are so dedicated to this that they have a NOT-BUGS.md, for bugs that you would experience in the original game, that they won’t fix.
As a fun fact this is the DOOM source port that Modern Vintage Gamer used to port DOOM to the Nintendo Switch. It’s simpler than other source ports like GZDoom, which while much more feature packed, is much more difficult ot port
Also if you’d like to look at the changes I made for this project you can find then on my GitHub. You can see the specific changes I made compared to the original code using this link.
And if you want to know more about the DOOM source code from someone who actually knows what there doing, might I point you to Fabien Sanglard and his Game Engine Black Book: DOOM. It goes over the history of Id Software, the hardware in computers at the time, and of course lots of information about the DOOM Engine internals. It is well worth the read and price.
Call To Action 📣
Hi 👋 my name is Diego Crespo and I like to talk about technology, niche programming languages, and AI. I have a Twitter and a Mastodon, if you’d like to follow me on other social media platforms. If you liked the article, consider liking and subscribing. And if you haven’t why not check out another article of mine listed below! Thank you for reading and giving me a little of your valuable time. A.M.D.G