Playing Half-Life 2 on OpenBSD
A few months ago I played through Half-Life 1 and enjoyed it a lot. This was only possible because openhl supports OpenBSD. I wanted to play Half-Life 2 next, but the open-source version of Valve's Source engine didn't work on OpenBSD yet. I gave up after a halfhearted attempt to port the engine, but recently tried again, and succeeded. In this post I'll explain how I did it. This isn't meant to be a meticulous port that I'll actually maintain, but more of an "it just works" type of thing. The intention is that, if you use OpenBSD, you'll be able to play Half-Life 2 by following this process.
It Starts with One
Start out by cloning the source-engine repo:
git clone --recursive https://github.com/nillerusr/source-engine
The project uses a tool called Waf for its build process. To set it up, run python3 ./waf configure -T release. A lot of the build configuration is specified in a file called wscript. You can try to build the engine by running python3 ./waf configure -T release, and see how far you get. In my case it failed because it didn't detect the dependency bz2, which is a library associated with the tool bunzip2. To get around this build error, you can just set mandatory=False on the libbz dependency check in wscript; it should be near line 335.
Next, the compiler will complain that it's unable to find certain headers, especially <malloc.h> and <iconv.h>. It turns out that malloc.h is something of a vestigial dependency, because the things the project expects from that file are really defined in stdlib.h. You can just comment out the malloc.h #includes, but there are a lot of them. I tried this at first, got impatient, and ended up creating a blank malloc.h in /usr/include to placate the compiler. You can't do this for iconv.h, though. There should be a more elegant way to tell Waf where to find iconv.h, but I just copied /usr/local/include/iconv.h to the directories where it's needed.
Then there are a couple more trivial changes: in public/vallocator.h, you may need to add #include <stdlib.h> to make the definition of size_t visible. I added this on line 54. In filesystem/filesystem_stdio.cpp, at around line 980, you'll need to replace a call to fileno_unlocked with just plain fileno.
Sorting Things Out
After these changes, it gets more complicated. In the file tier1/strtools.cpp, the function V_qsort_s invokes a function called qsort_r. The problem here is that OpenBSD's qsort function looks like this:
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
But Source engine invokes qsort_r, which is available on FreeBSD but not OpenBSD. This is its signature:
void qsort_r(void *base, size_t nmemb, size_t size, void *thunk, int (*compar)(void *, const void *, const void *));
So we can't just replace qsort_r with the OpenBSD implementation of qsort. A quick and dirty fix is to paste in the definition of qsort_r from FreeBSD's standard library, which I grabbed from here. You'll need to clean stuff up - there's a variable called a which you should cast to a (char *) to appease the compiler, and then some #ifdefs which you can evaluate yourself, keeping the I_AM_QSORT_R branches and pruning the others.
Unusable Size
The function malloc_usable_size, or something analagous, is available in the standard library on most Unix-like systems. It used to exist on OpenBSD, but was removed at some point.
malloc_usable_size basically lets you obtain the actual size of a block of allocated memory, which may be larger than the size you requested with malloc. I guess you can use it to avoid unnecessary reallocations, which is what Source appears to do.
Now, since this function is unavailable on OpenBSD, the question is what to replace it with. I tried implementing my own version of malloc_usable_size by wrapping malloc et al. so that a size variable was stored before the chunk of memory that the caller saw. But this didn't work, because Source seems to use a similar hack already.
So the solution is this: comment out the method GetSize defined at public/tier1/utlvector.h:353. The project will compile fine without it. In fact, there are many methods called GetSize, but there's only one more problematic one, at tier0/memstd.cpp:1600. Comment out the return statement and just return 0.
Is that all? No. In many cases that previous GetSize method is only used for debug info, but in a couple select places, its return value actually matters, and the engine will crash if it encounters an incorrect value. One of the most difficult parts of this project was finding which calls actually needed to be fixed.
It turns out the significant calls to GetSize occur in the function MemAlloc_ReallocAligned, defined in public/tier0/memalloc.h and external/vpc/public/tier0/memalloc.h. (Aside: a lot of things have duplicates living in external/vpc and I'm unsure if it's even necessary to edit them.) MemAlloc_ReallocAligned is a hacky, specialized kind of realloc, and calls GetSize only once. But this one call to GetSize must return the actual size, otherwise you'll encounter some kind of glitch or segfault. Looking at MemAlloc_ReallocAligned, there's no obvious way to discern the actual size of the memory, so we have to go up another level and look for calls to MemAlloc_ReallocAligned.
MemAlloc_ReallocAligned gets called in two places: CUtlMemoryAligned::Grow and CUtlMemoryAligned::EnsureCapacity (both found in utlmemory.h). It looks like the class CUtlMemoryAligned<T, n> represents a growable, aligned buffer containing elements of type T, with alignment n. This class inherits a member variable called m_nAllocationCount from its base class, CUtlMemory<T>, which tracks the number of elements allocated. Therefore, the minimum size of the buffer that a CUtlMemoryAligned instance manages is CUtlMemory<T>::m_nAllocationCount * sizeof(T).
All we have to do is add an extra parameter to MemAlloc_ReallocAligned, which we can call old_size, to replace the value returned by GetSize. Then in our CUtlMemoryAligned, we obtain the old size using the previous calculation, and pass it in to MemAlloc_ReallocAligned. Technically, this doesn't equal the actual value that malloc_usable_size returns, but it does represent the smallest possible value that it could return. Basically, the solution works, it's just not optimal in the sense that you may realloc when you don't necessarily have to. But I think a game engine from 21 years ago, running on semi-modern hardware, can afford to forgo some optimizations without a serious impact on performance.
Getting the Half-Life 2 Game Files
Remember that we're trying to compile Source Engine, not Half-Life 2. To actually play Half-Life 2, you need the game files. I bought Half-Life 2 on Steam and then had to use a tool called steamctl to download the files, because the official Steam app is unavailable on OpenBSD. You can use steamctl like so: steamctl depot download --app 220 --depot 221 --manifest 5134805552204684547 -o ./hl2-data/ It should ask you to authenticate, and if that succeeds, it will then download the game files.
The app, depot and (especially) manifest number can be tricky to get
right. Game files have multiple releases and the manifest number
denotes a specific release. I looked
on steamdb.info to find the right
manifest, and initially used one from 2025. Unfortunately the
textures weren't working on that release:
I needed to find an older
manifest. steamdb.info only shows the newest manifests by default,
and you have to click some other links to view older ones. But I kept
getting blocked by Cloudflare, which gave me an infinite series of
"check this box to prove you're human" challenges. Cloudflare has its
tentacles everywhere and is slowly rendering the internet unusable.
In the end, I was able to find
an old
snapshot of steamdb on archive.org, which had a manifest that
actually worked.
Wake Up and Smell the Ashes
If you make all the right changes (and you won't, the first time) it should take 30-45 minutes to compile, maybe more or less depending on your hardware. The point is that it takes a long time.
Then you can run the game in various ways. There's an option to install the engine system-wide, but I didn't want to do that in fear of clobbering my system directories. Instead, I cd'd into build/launcher_main, and copied all .so files from build/ into a folder named bin/. I also copied the hl2/ and platform/ directories from the HL2 game data into this directory. Then to run, I ran this ridiculous command:
LD_PRELOAD=bin/libGameUI.so:bin/libServerBrowser.so:bin/libclient.so:bin/libdatacache.so:bin/libengine.so:bin/libfilesystem_stdio.so:bin/libinputsystem.so:bin/liblauncher.so:bin/libmaterialsystem.so:bin/libscenefilecache.so:bin/libserver.so:bin/libshaderapidx9.so:bin/libsoundemittersystem.so:bin/libstdshader_dx9.so:bin/libsteam_api.so:bin/libstudiorender.so:bin/libtier0.so:bin/libtogl.so:bin/libvaudio_minimp3.so:bin/libvgui2.so:bin/libvguimatsurface.so:bin/libvideo_services.so:bin/libvphysics.so:bin/libvstdlib.so:bin/libvtex_dll.so ./hl2_launcher
Don't ask me why all those preloads are necessary; they're probably not, but that's what made it work for me. Playing the game was my first priority and anything else was of secondary concern.
From there on out, everything just worked. I was elated. Enjoy
this screenshot from the start of the game:
Epilogue
Getting this to work was an arduous and stressful process, particularly because it took so long to compile. I only had time for a few recompiles on weekdays, so I would spend the whole day thinking of what to change, and each build cost me a lot of time. I spent virtually all my time outside of work on this project. After a few days of this I was at the end of my rope, and I'm fortunate to have resolved things before I burned out.
Half-Life 2 is a fun game, but I will say that I have a similar problem with video games as I did working on the Source port. Once I start, I find it difficult to leave things unfinished, and am drawn to beat the game (or get the port working) even when it's no longer enjoyable. It becomes a compulsion. Video games can be a novel medium for storytelling and so on, but as with any form of technology, there's a need for caution.
Compared to the original Half-Life, Half-Life 2 is far more immersive. In the Half-Life 2 documentary the developers explain the lengths they went to make things more realistic, and they had people play-test it extensively. My main criticism of the game is that it's actually too easy. Once you've gotten through the first few levels (and especially if you've played Half-Life 1) the mechanics and enemies don't change a whole lot. The hardest parts of the game I only had to replay a couple times before I got through, and the final boss battle was nothing - so the ending felt abrupt. Nevertheless, it's easy to see how Half-Life 2 revolutionized game design, and it deserves its reputation as one of the best video games in history.