Kevin Coughlin. Blog

Running a 32-bit Counter-Strike 1.6 server on Apple silicon (without Rosetta)

I run cs-server, a containerized Counter-Strike 1.6 ScoutzKnivez server on the ReHLDS stack. It lives on a Linux box under Podman and Quadlet. I wanted it to run on a Mac too, with Apple's new container CLI, and the obvious plan was --platform linux/amd64 --rosetta.

That plan failed on the first command.

Rosetta for Linux is x86_64 only

$ container run --rm --platform linux/amd64 --rosetta \
    --entrypoint /hlds/hlds_linux ghcr.io/kevintcoughlin/cs-server -help
Error: failed to exec [/hlds/hlds_linux -help] ... "Exec format error"

The same image's /bin/sh ran fine under Rosetta. The difference is byte 4 of the ELF header:

$ od -An -tx1 -j4 -N1 /hlds/hlds_linux
 01

01 means ELFCLASS32. HLDS, the 2003-era Half-Life Dedicated Server, is a 32-bit i386 binary, and so are its engine, game DLL, Metamod and AMX Mod X modules. Even SteamCMD, which installs the server, is 32-bit. Rosetta for Linux translates 64-bit x86 only. No flag combination changes that.

Tip: before you try any game server on Apple silicon, check the ELF class of its binaries. One od call saves an afternoon.

Plan B: qemu-user inside an arm64 image

Rosetta translates at the CPU level. QEMU's user-mode emulator, qemu-i386, translates a single Linux process instead, so it doesn't care what the host is. The image therefore becomes linux/arm64, ships the i386 libraries through Debian multiarch (libc6:i386 and friends install fine on arm64), and launches the server as qemu-i386 /hlds/hlds_linux ….

Two details kept that small:

One trick kept the health check working. The health check looks for a process named hlds_linux in /proc/*/comm. Under qemu, the process name is the emulator's. Running qemu through a symlink named hlds_linux fixes that, because the kernel takes the process name from the path you exec.

Valve's libraries vs. qemu-user

The engine and AMX Mod X ran on the first try. Steam's client library did not:

Fatal error: futex robust_list not initialized by pthreads
qemu: uncaught target signal 11 (Segmentation fault)

qemu-user doesn't implement the set_robust_list / get_robust_list syscalls. steamclient.so checks that the calling thread has a robust futex list and aborts when it can't read one back.

The fix is a ~100-line LD_PRELOAD shim, cross-compiled with gcc-i686-linux-gnu. It intercepts libc's syscall() and answers get_robust_list with an empty, glibc-shaped list head. "Glibc-shaped" mattered: my first version left futex_offset at 0, and the error changed to "robust_list not pthreads-compatible", which at least showed the check was reading my answer. glibc uses offsetof(__lock) - offsetof(__list.__next), which is −20 on i386. With that value the check passes. Under emulation nothing in the kernel walks that list, so this only satisfies the check.

SteamCMD, which runs at build time, needed three more workarounds:

  1. /proc/cpuinfo. SteamCMD exits(1) without an x86 flags line (it wants SSE). Under qemu-user the file still describes the ARM host. The build sandbox won't let you bind-mount over /proc, and unshare is blocked in container build. So the same shim redirects open("/proc/cpuinfo") to a fake x86 one. Include cpu MHz: without it SteamCMD still bails.
  2. STEAM_PLATFORM=linux32. After self-updating, steamcmd.sh notices uname -m is aarch64 and looks for a linuxarm64 build that isn't there.
  3. DEBUGGER=run-i386. steamcmd.sh already launches its binary through $DEBUGGER and keeps its restart-after-update loop. That makes it the perfect hook for an emulator wrapper.

Finally, the server came up "disconnected from Steam3" until the runtime image got ca-certificates. steamclient.so couldn't open TLS without them. (Worth checking on the x86 image too.)

One Containerfile, two architectures

I didn't want a separate Mac Containerfile. Everything architecture-specific lives in one script, i386-compat.sh:

amd64arm64
i386 librarieslib32* biarch packages (unchanged)multiarch :i386 + ca-certificates
Build-time i386 tools (run-i386)exec "$@"qemu-i386 + shim + fake cpuinfo
Server launchernot installedqemu symlink named hlds_linux

The Containerfile calls run-i386 ./amxxpc and DEBUGGER=run-i386 steamcmd.sh. On x86 those are a no-op exec, so the Linux image builds the same way as before (CI agrees). One gotcha: Apple's builder doesn't set TARGETARCH, so FROM stage-${TARGETARCH} fails with invalid ref: stage-. Branch inside a script instead.

Does it hold up?

On an M-series Mac with 2 vCPUs, 20 slots and 10 bots, at sys_ticrate 1000 with pingboost 2:

For a busy public server I'd still use Linux on x86. For a Mac in the living room on LAN night, it's more than enough. A small launchd agent makes it start at login and restart on crashes, and the in-game shutdown countdown still runs on stop.

Takeaways

The full change is cs-server#56. The interesting files are scripts/i386-compat.sh and scripts/i386-qemu-shim.c.