← Back to list

Playing Half-Life Online on an Apple Silicon Mac — A Debugging Journey Through Xash3D-FWGS

How a nostalgic “let’s just play Half-Life” turned into a masterclass in reading version strings, distinguishing a real crash from a clean…

Ercan ATAY · 2026-07-19 22:10 · 0 claps · 10.4 min read paywalled
#claude #claude-code #valve-software #valve #the-half-life
Open on Medium ↗
Wiki topics: LLM · Large Language Models 💻 · Programming 📚 · Books & Reading

Playing Half-Life Online on an Apple Silicon Mac — A Debugging Journey Through Xash3D-FWGS

How a nostalgic “let’s just play Half-Life” turned into a masterclass in reading version strings, distinguishing a real crash from a clean quit, and knowing when NOT to write a patch.

We wanted to do something that should have been trivial: play the original Half-Life 1 — GoldSrc, 1998 vintage — online, on a modern Apple Silicon Mac mini. No Boot Camp, no virtual machine, no cloud streaming. Just the game, running natively on arm64, connecting to a public multiplayer server.

What followed was a multi-day debugging journey that took us through a frozen menu, a heap-corrupting network crash, a stale fork frozen in 2023, a “missing game library” that was hiding in the wrong folder, and — the best twist of all — a crash that turned out not to be a crash at all. Along the way we learned six lessons that apply far beyond Half-Life, and we found exactly one thing genuinely worth reporting upstream.

Here’s the whole story, honestly, with the real errors and the real commands.

The setup: why Half-Life won’t just run on a modern Mac

The first wall is architectural. Valve’s original Half-Life binary is 32-bit x86. Apple dropped 32-bit support in macOS Catalina (10.15). On an M-series Mac, Valve’s binary simply cannot run — not slowly, not with a shim, not at all.

The answer is Xash3D-FWGS, an open-source, clean-room reimplementation of the GoldSrc engine that builds natively for arm64. The important thing about Xash3D is that it is the engine only. It does not ship Valve’s copyrighted game code — it needs the game’s data files, the valve folder, which you already own if you own Half-Life. The engine loads that data and runs the game.

The build we used is the one packaged by MacSourcePorts, delivered as a normal macOS app at /Applications/Xash3D-FWGS.app. Once installed, the engine looks for game data under:

~/Library/Application Support/Xash3D FWGS/

Drop your valve folder there, launch the app, and you get the Half-Life main menu running natively on Apple Silicon. Single-player worked immediately. Multiplayer is where the journey really began.

The frozen menu, and how to skip it

Our first instinct was the obvious one: open Internet Games and browse for a server. Clicking it froze the game solid.

This is not a bug in the install, and it’s worth understanding why, because the fix is to route around it, not to fix it. The classic GoldSrc “Internet Games” browser works by querying a master server — a piece of legacy Valve infrastructure that hands back the current list of public servers. That old master-server infrastructure is largely dead now, and the engine’s query blocks the main thread while it waits. No response, no timeout you’d want to sit through — the UI just hangs.

The workaround is to skip the master server entirely. Three paths do that:

  • Create Server — host your own game locally; no master-server lookup required.
  • LAN Games — discovers servers on your local network directly.
  • Direct Connect — the one we wanted: drop the console and connect by address.

Direct Connect is done from the in-game console:

connect 203.0.113.10:27015

That command talks straight to the game server’s IP and port, no master server in the loop. It worked — the client started connecting to real public servers. And that’s exactly where we hit the crash that defined this whole project.

The real crash — and the rabbit hole we didn’t need

Connecting to a public server with connect IP:27015 crashed the engine, hard:

Xash Error: Mem_FreeBlock: not allocated or double freed (free at ../engine/common/net_chan.c:1173)

Sometimes it was preceded by a more revealing line:

Error: Netchan_CopyNormalFragments: Bz2 decompression failed (-4)

That second message is the tell. When you connect to a GoldSrc server, one of the first things it sends is its resource list — the maps, models, sounds, and sprites the server expects you to have. On busy servers that list is large, so it’s compressed (BZ2 / LZSS) and split into network fragments. The client reassembles the fragments in Netchan_CopyNormalFragments and then decompresses the result.

If that reassembly overflows its buffer or the decompression fails partway, the engine can write past the end of the fixed network message buffer. That corrupts the heap’s bookkeeping metadata — and the corruption doesn’t blow up immediately. It surfaces later, as a double-free in unrelated memory-management code (net_chan.c:1173). The confusing part of heap corruption is always this: the place that crashes is not the place that's broken.

This maps cleanly onto two known upstream references:

  • ValveSoftware/halflife#2142 — a Netchan_CopyNormalFragments buffer overflow: fragments written into the fixed 65536-byte net_message buffer without capacity checks.
  • FWGS/xash3d-fwgs#2032 — a crash after loading resources via fastdl when reconnecting.

So we did what engineers do when they find the bug: we decided to patch the engine.

Down the rabbit hole

We cloned MacSourcePorts/xash3d-fwgs — the fork that produced the app we were running — intending to build a fixed engine. We even wrote a targeted patch: a few extra bytes of physical slack on net_message_buffer, plus a post-loop bOverflow check in Netchan_CopyNormalFragments so a failed reassembly would bail out cleanly instead of scribbling over the heap.

It compiled. It ran. And installing it broke the app. First:

Host_InitError: FS_LoadProgs ... wrong version

That’s a filesystem API mismatch — the sign that we’d mixed engine modules from different revisions. Then, after we tried to sort that out:

Couldn't find game directory 'valve'

The engine could no longer even locate the game data we’d placed under ~/Library/Application Support/Xash3D FWGS/.

This is the moment where a version string stopped being noise and became the entire story. Our installed binary reported engine version 0.21. But the MacSourcePorts fork’s default branch — and even its latest tag — was frozen at a 2023 commit: engine version 0.20. The 0.20 source predated the macOS base-directory logic that knows to look for valve under ~/Library/Application Support/Xash3D FWGS/. We were compiling a two-years-stale engine and installing it over a newer one. Of course it couldn't find the game folder — that code didn't exist yet in the source we were building.

The lesson people usually draw here is “the version mismatch was a red herring.” It was the opposite. The version string — 0.20 versus 0.21 — was the answer. We were simply building from the wrong source revision.

The fix was an upgrade, not a patch

The real upstream is FWGS/xash3d-fwgs, and its HEAD reports 0.21. When we actually read the current upstream source for Netchan_CopyNormalFragments, we found something humbling: upstream 0.21 had already fixed this crash.

The current code path has an MSG_Overflow check that returns false cleanly on overflow. The BZ2 path is hardened — when decompression fails, the BZ2 decompression failed (%d) case is now a clean return false instead of a write that corrupts memory. And the LZSS path has proper bounds checks. Our crashing build was a June-14-2026 0.21-dev snapshot; sometime between that snapshot and mid-July, upstream had hardened exactly this code path. Our carefully written patch was solving a problem the maintainers had already solved.

So the real fix wasn’t to patch. It was to build vanilla, current 0.21 from source and install that. Xash3D uses the waf build system. On macOS we needed to point it at pkg-config for SDL, and we had to nudge two cosmetic modern-clang complaints:

./waf configure --sdl-use-pkgconfig
./waf build

The two build fixes were -Werror=enum-float-conversion errors from a newer clang — purely cosmetic enum-vs-float comparisons that older compilers ignored. We fixed them, built all six engine modules, installed them, and then did the step you cannot skip on Apple Silicon:

codesign --force --deep --sign - /Applications/Xash3D-FWGS.app

On arm64, any modified .app must be re-signed — even with an ad-hoc signature — or Gatekeeper refuses to launch it.

The result: we reconnected to the very server that had been crashing us minutes earlier, and it connected. The netchan crash was gone. Not because of our patch — because we finally ran the engine version that had already fixed it.

The missing game library

Upgrading to 0.21 fixed the crash and immediately introduced a new, quieter problem. On launch:

Xash3D: missing game library. Required: apple-arm64. Missing: client.
Found 32-bit x86 libraries...

The diagnosis comes from how 0.21 resolves its base directory on Apple. It uses SDL_GetPrefPath, which resolves to ~/Library/Application Support/Xash3D FWGS. From there it searches for the game libraries in valve/cl_dlls and valve/dlls. It expects to find arm64 libraries there.

But the arm64 game libraries — client_arm64.dylib and hl_arm64.dylib — were bundled inside the app, at .app/Contents/MacOS/cl_dlls. Meanwhile the valve/cl_dlls folder in Application Support only contained the Steam x86 client.dylib. The engine looked where its own logic told it to look, found only 32-bit x86 libraries, and correctly refused them.

The fix is simply to put the arm64 game libraries where 0.21 expects them — copy them into the valve directory:

cp "/Applications/Xash3D-FWGS.app/Contents/MacOS/cl_dlls/client_arm64.dylib" \
   ~/"Library/Application Support/Xash3D FWGS/valve/cl_dlls/"
cp "/Applications/Xash3D-FWGS.app/Contents/MacOS/dlls/hl_arm64.dylib" \
   ~/"Library/Application Support/Xash3D FWGS/valve/dlls/"

Before doing this we asked the natural question: are these bundled game libs even ABI-compatible with the 0.21 engine? Game libraries talk to the engine through interface-version constants — CLDLL_INTERFACE_VERSION (7), INTERFACE_VERSION (140), and friends. We confirmed those constants were unchanged, which meant the bundled arm64 game libs were ABI-compatible with the engine. No need to rebuild the game code — just place it correctly.

The crash that was never a crash

With multiplayer working, we started actually playing — and hit the strangest bug of the whole journey. In-game, pressing Ctrl (duck) + W (forward) closed the app. Every time. It looked exactly like a hard crash: you’re moving, you crouch, the window vanishes.

Everything about “the game crashes on duck+forward” screamed engine bug. But before touching a line of code, we turned on logging with the -log flag and read engine.log. What it recorded was not a crash:

Stopped with reason "caught SDL_QUIT"

That is a clean, controlled shutdown. And the evidence that it was clean was as important as the line itself. There was:

  • No Crash: signal 11 in the log.
  • No macOS .ips crash report.
  • No spindump.

Xash3D’s own crash handler lives in crash_posix.c, and on a real signal — a segfault, a bus error — it writes a Crash: signal ... line. There was none. The app hadn't crashed; it had received a normal quit event and shut itself down politely.

So where did a quit event come from mid-game? macOS. On macOS, SDL2 installs a default Cocoa application menu, and that menu comes with the standard shortcuts every Mac app has: ⌘W = Close Window and ⌘Q = Quit. Those are intercepted by macOS before the keystroke ever reaches the game.

Now look at the keyboard. The Control key (⌃) is at the far bottom-left, and it correctly maps to +duck. But the Command key (⌘) sits right next to the spacebar — physically adjacent to where your thumb and fingers live while running forward. Reaching for "Ctrl to crouch" while holding W, we were occasionally catching ⌘W instead. macOS saw "Close Window," closed the window, and SDL faithfully reported SDL_QUIT. The engine did exactly what it was told.

The fix has nothing to do with code:

  • Run fullscreen. There’s no window chrome to close, and in fullscreen SDL captures the keyboard exclusively, so macOS never steals the keystroke.
  • Use the Control key, not Command, for duck.

This was the single most valuable diagnostic lesson of the project. A perfect-looking “crash” was a clean quit, and the only tool that revealed the truth was turning on -log and actually reading engine.log. If we had trusted our eyes instead of the log, we'd have spent days hunting a bug that didn't exist.

Small cleanups the logs revealed

Once we were reading logs, we ran with -log -dev 2 and let the developer console tell us about everything else that was subtly wrong. A few userconfig.cfg cvars from older Xash builds were no longer valid in 0.21 and were logged as unknown:

Unknown command: con_enable
Unknown command: m_filter
Unknown command: hisound

Harmless, but noise — we removed them. The more interesting one was our console toggle. We had bound the console to the numpad * key using KP_MULTIPLY, and Xash rejected it:

"KP_MULTIPLY" isn't a valid key

Xash3D simply has no numpad-multiply key in its key table. The valid numpad keys are KP_SLASH, KP_MINUS, and KP_PLUS, so we rebound the console toggle to one of those. Again: the log told us exactly what was wrong, in plain language, the moment we bothered to read it.

Six lessons from the rabbit hole

The Half-Life specifics fade quickly; the debugging lessons don’t. These are the six we’ll carry into the next project.

  1. Build from the right source revision. A popular fork can be years stale. The MacSourcePorts fork was frozen at a 2023 / 0.20 commit while upstream had moved to 0.21 and already fixed our bug. Before patching a dependency, check whether you’re even looking at current code.

  2. The version string is diagnostic data, not noise. 0.20 versus 0.21 wasn't a detail — it was the whole story. When two builds behave differently, their version strings are the first clue, not the last.

  3. Distinguish a real crash from a clean quit. A real crash leaves fingerprints: a signal, crash-handler output, an OS crash report. A clean quit leaves SDL_QUIT in the log and nothing else. -log plus reading engine.log was the decisive tool that told the two apart — and saved us from debugging a nonexistent bug.

  4. macOS SDL2 apps can be quit mid-play by ⌘W / ⌘Q. This is a genuine cross-platform gotcha. SDL2’s default Cocoa menu wires up window-close and quit shortcuts that the OS intercepts before the game sees them — and ⌘ sits inconveniently close to keys you actually use. Fullscreen input capture is the clean defense.

  5. Game-library ABI compatibility is governed by interface-version constants, not file dates. We didn’t need to rebuild the arm64 game libs because CLDLL_INTERFACE_VERSION, INTERFACE_VERSION, and the rest were unchanged. The contract is the numbers, not the timestamps.

  6. On Apple Silicon, re-sign anything you modify. Any changed .app must be ad-hoc re-signed with codesign --force --deep --sign - or Gatekeeper blocks it. It's easy to forget and easy to misread the resulting failure as something more exotic.

Conclusion

We set out to play a 1998 shooter online and ended up with a compact tour of everything that makes systems debugging hard and satisfying: heap corruption that surfaces far from its cause, a stale fork masquerading as the real thing, a version number that quietly held the answer, and a “crash” that was really the operating system politely doing what we accidentally asked.

The honest arc matters here. We wrote a patch, then discovered upstream 0.21 had already fixed the netchan crash — so the real solution was an upgrade, not our code. We did not open or merge a pull request, and it would be dishonest to imply otherwise. The one thing in this journey that seems genuinely worth reporting upstream is the macOS ⌘W-quits-mid-game behavior: an SDL2-on-macOS interaction that will bite other players reaching for a nearby Control key, and that isn’t obvious from any error message because it produces no error at all.

If you take one habit from all of this, take the third lesson: turn on logging and read the log before you trust your eyes. The most expensive bug we chased was the one that was never there.

Building your own arm64 Xash3D-FWGS? Start from current upstream, read your engine.log, and if a fullscreen ⌘W ever eats your session — you know where to look. Happy fragging.

Tags: #HalfLife #AppleSilicon #Xash3D #macOS #Debugging


메타데이터
post_id
1facfdb2c6b1
slug
playing-half-life-online-on-an-apple-silicon-mac-a-debugging-journey-through-xash3d-fwgs-1facfdb2c6b1
url
https://medium.com/@ercanataycom/playing-half-life-online-on-an-apple-silicon-mac-a-debugging-journey-through-xash3d-fwgs-1facfdb2c6b1
canonical_url
https://medium.com/@ercanataycom/playing-half-life-online-on-an-apple-silicon-mac-a-debugging-journey-through-xash3d-fwgs-1facfdb2c6b1
author_url
https://medium.com/@ercanataycom
status
ok
fetched_at
2026-08-15 22:12:07