← articles

Porting GoldSrc Engine to x64

Theoretical depicition of porting a GoldSrc engine to x64.


Hello there! In this article we'll go through the potential and theoretical process of porting GoldSrc from x86 to x64. To be more clear, x64 and x86 are the terms used when describing the processor architectures. These are types of the Intel ISA (Instruction Set Architecture).

x64 vs x86

In x86 architecture, register sizes and memory addressing is done by using 32-bit integers (hence the 32 bits). Therefore, this also limits the maximal potential physical memory a computer can address, since the pointer size is only 32-bit, there are 2^32 addresses, which is about 4 GB of RAM. Now, there are strategies that can go around this problem such as implementing Virtual Memory1 however, this is not the topic of today's article.

In x64 architecture, which was originally introduced and developed by AMD and, some years later, adopted by Intel, makes a fundamental difference because the pointer size (and registers and more) is now not 32-bit but 64-bit. This makes a huge difference, since for example the maximum theoretical memory space a computer can have is now extremely large compared to the 32-bit architecture.2

All modern processors predominantly use 64-bit architectures. They still support 32-bit operations, but if you are developing a game or any other software, there is no reason for you to target it to the 32-bit architecture other than for compatibility reasons.

GoldSrc is, as you may know, very old. It is based on the Quake engine from 1996 originally developed by Id Software. Now, the engine is heavily modified and introduces many new things such as rebranded networking, studio model rendering, modding support, and so on. However, it is still built for 32 bits.

Running 32-bit process on a 64-bit machine is unfortunate, because 64-bit has many benefits over 32-bit. So now let's discuss the theoretical porting process!

What's the Problem Here?

As already said, GoldSrc is targeted for the x86 architecture. That means that also the code was targeted for the x86 architecture, and the x64 support wasn't in minds of the engineeres who developed it. Yes, sadly, this is the case for GoldSrc, and I mean, why even would they? It didn't even exist until early 2000s! Moreover, the code is overall a big, big mess. So porting it to x64 might not be as easy as it may look!

Memory Addressing Problems

Some of the problems may arise, most commonly with pointer arithmetic. You see, when I said that GoldSrc was built for x86, I meant it. In the code, Valve devs have deliberately used types such as int or unsigned int for pointers! And this obviously breaks on x64 because on x64 the size of int remains 32-bit but addressing is 64-bit. Therefore, this is rather an unfortunate problem.

This problem is not so easy to fix either, because the compiler won't often tell you. If you're lucky, you might get a warning. But in may cases you'll need to debug your way through.

An Example from the Code

Another problem may be just with code that works with memory and uses int datatype as well. You see, in 1996, all people had was about 8-16 MB of physical memory. Not bad—in fact, when Half-Life released, the specs were at least 24 MB of RAM - 1998 (can be found in original readme file).

So originally, engineers would just use int for describing the amount of memory a system had, because it was well enough. And also, types like unsigned long long were not even present in the official C standard at the time until the C99 standardization. However, as you might know, int's limit is about 2 GB (in terms of memory). Well, this raises a problem; for example, consider this code:

// the function returns amount of physical memory in BYTES!
int memsize = get_computer_physical_memory();

// if you have 32 GB of memory, what's the value of memsize? :D

Well, it just overflows. Yikes. Not good.

A fix for x64 would be something like this:

// ok
size_t memsize = get_computer_physical_memory();

Nice, this no longer overflows.

Assembler

Also, another kind of annoying thing is that if you ever dare to write __asm in x64, it breaks. Yes, this is not supported on x64. Well, why you might ask?

You get an answer on stackoverflow here:

"Visual C++ does not support inline assembly for x64 (or ARM) processors, because generally using inline assembly is a bad idea."

Hear that? It is a bad idea, so just don't use it... 😄

But this isn't a solution. There are situations where there is no other way, usually in the embedded space or simply in low-level applications. There are obviously go-arounds for this, such as compiler intristics but I will not cover them here.

So we will just stick to not using assembly in x64. For GoldSrc it should be good enough. And also, the whole assembly code would have to be rewritten line by line, which is just too much work! :^)

The Porting Process

Now that we have covered some of the biggest caviats, we can go to the actual porting process and go through all the details.

tier0

It depends on where you want to start the porting process, but let's say that you want to start with tier0.dll. This DLL acts as sort of a utility module, serving various architecture-dependent functions and other code. It was created by Valve in the early 2000s (AFAIK), and it was also used in steam. Maybe, it was first used in steam, and then used in GoldSrc—I'm not completely sure about that.

Inside this module, there is a lot of architecture-dependent code, such as all of the assembly code. Well, if we take into account assembly code inside source files, for sure, in the engine there is the most assembly code. But here, in tier0, there is platform0.h—a header containing all kinds of stuff, but also many architecture-dependent things.

One of them being, for example, macro DebuggerBreak(), which now cannot be defined as:

#define DebuggerBreak()  __asm { int 3 }

Because as we said earlier, all __asm code is discarded. So, luckily, there is a solution for us—MSVC built-in function __debugbreak(), which is however not present on Linux or macOS, but for Windows, it'll do.

Threading

Among other things, there are also these very low-level functions that manipulate FPU precision modes (only on Intel&ARM processors), rouding modes, and exception handling modes. They also use the __asm directive, so they just get disabled on x64—as an easy fix. These functions are also less relevant on modern hardware, since they are highlighy specialized for specific tasks, which was not uncommon at the time.

Moreover, other __asm code needs to be rewritten as well. For instance, the function that is exposed by tier0 in the GoldSrc engine, ThreadPause is used to introduce a short pause or a hint to the processor when implementing spinlocks or busy-wait loops. It is used in the low-level synchronization tools developed by Valve when they were working on Steam/Half-Life 2. The compatible function may look like this for x64 and multiple platforms:

inline void ThreadPause()
{
#ifdef PLATFORM_64BIT // the x64 version uses compiler intristics
    _mm_pause();
#elif defined( _WIN32 ) && !defined( _X360 )
    __asm pause; // 32-bit version - still using __asm directive
#elif _LINUX
    __asm __volatile("pause");
#if defined( _X360 )
#else
#error "implement me" // templeOS, maybe?
#endif
}

An example use case for such function would be:

while (!LockAcquired())
{
    ThreadPause(); // hint to the CPU during busy waiting
}

L2Cache

GoldSrc engine (and Source, too) uses some ancient code for working with L2 caches in old processors. This is used to e.g. detect cache misses/hits.

It's really cool to look at actually. The source can be found publicly available on github here (pme.cpp), and also here (K8PerformanceCounters.h). I haven't dug deeply into this implementation, but it's interesting nonetheless!

The Dependencies

GoldSrc uses third-party libraries such as SDL for cross-platform system interaction or cef for in-game browser rendering. Both of these are DLLs (dynamic/shared libraries, basically), and dynamic libraries need to be architecture-dependent. Meaning you will have to have a one version of SDL2.dll for 32-bit and another one for 64-bit.

This is a problem, because if you want to support both 32 and 64 bits, you will need to make some decision of how these files will be stored on disk. Modern games, for example cs2, implements this in such a way that it creates separate directories for both 32-bit and 64-bit: win64/ and, presumably, win32/ for 32-bit.

Then, the launcher needs to be built in such a mode that will be supported for users with 64-bit computer and for users with 32-bit processors. For example, you could build a 32-bit loader that is supported on both computers, that would then spawn the second loader, which would be architecture-dependent, and that launcher would be inside the specific directory with all of the binaries prebuilt for that target architecture.

You need to do this, because if you don't have source code to some proprietary software that only provides you with a static library and a DLL that you cannot rename, and you have to have two versions of these DLLs with the same name—you are in trouble, hence the two separate directories.

GoldSrc also uses (but also does not use) directx73, which was never built for 64-bit! Ouch. Solution? Upgrade to newer version or not use. That's it.

Pointer Casts

Then, obviously there are a lot of places where there is straight up the use of uint32 when doing pointer arithmetic, so this straight up breaks. For example, in the client DLL, there there is a lot of places like that.

Also the engine file pe_export.cpp needs to be reworked, because it uses hardcoded DWORDs and ints for virtual addresses, which is hilarious.

Another example could be this:

typedef struct
{
    // uint32   pFunction; <- was
    void*   pFunction; // new
    char*   pFunctionName;
} functiontable_t;

VPANEL

GoldSrc uses pointers for VGUI panel identification. But Valve devs hardcoded these values to be uint32s, so that obviously broke. Therefore, all such places where uint32 is used, we need to use the VPANEL type instead, which is the correct size—e.g. 8 bytes on x64.

These are also stored inside KeyValues as ints, so they need to be stored as type pointer in the KeyValue container, like this:

// msg->SetInt("VPanel", GetVPanel());
msg->SetPtr("VPanel", (void *)GetVPanel());

The Engine

The original GoldSrc engine uses a lot of __asm directives—these get all disabled. And then it also uses the aforementioned data type int for deducing the available physical memory, which obviously overflows on modern hardware. Let's consider this example:

// original struct declaration
struct quakeparms_t
{
    char* basedir;
    char* cachedir;
    int argc;
    char** argv;
    void* membase;
    int memsize; // <- is `int`
};
quakeparms_t host_parms;

// then in sys_dll.cp
MEMORYSTATUS st;
st.dwLength = sizeof(MEMORYSTATUS);
::GlobalMemoryStatus(&st); // winAPI function

host_parms.memsize = memorySt.dwTotalPhys; // (this line is an example code)
// gets overflowed on x64!

Why it works on x86 and breaks on x64? Why when you computer already have more than 2 GB of memory, even though this is a 32-bit process it still does work?

This comes to what was mentioned before—the 32-bit process can only occupy theoretically 4 GB of physical RAM, because of the 2^32 limit. And therefore it does not make sense for the GlobalMemoryStatus WinAPI function to return more memory. This can be also seen in the MSDN documentation in the remarks section:

"On Intel x86 computers with more than 2 GB and less than 4 GB of memory, the GlobalMemoryStatus function will always return 2 GB in the dwTotalPhys member of the MEMORYSTATUS structure."

So as you can see, it works on x86, but as soon as you go to x64, it breaks, because the integer will overflow.

Conclusion

As you can see, it is not that straightforward as it might look like. A lot of code needs to be changed and tested, in order to make the port happen.

However, in this text, a brief summary can be seen as to what it theoretically takes to port the GoldSrc engine from 32-bit to 64-bit.


Notes

  1. Virtual memory is a memory management technique that provides an abstraction of a larger, continuous memory space by using both physical RAM and disk storage to efficiently execute programs larger than the available physical memory.

  2. This increase has lead to an incredible amount of available physical memory - 2^64 bytes, which is not expected to be used at all. Rather than consuming all the bits, only some are consumed by the actual address, but the others are left for other specific things such as PAC (Pointer Authentication Code) where some of the high-order bits of the virtual address are left for a cryptographic key.

  3. Goldsrc uses this for Direct-input code, that is never called. There is a file called dinput.cpp (presumably, the filename is not publicly known, IIRC), that contains direct-input code that was used before the SDL update in 2013, but since then, it was disabled. It also contains some obsolete Launcher directx rendering code, but that is not used as well. Oh, but the directx rendering pipeline is used for software renderer (at least in 8684), so that is the only place where it is being actively used.