Files
gen1recomp/docs/linux-arm64-build.md
ratherDashing f92364a002 Build SDL2, OpenAL and the codecs from source for the arm64 AppImage
CI on a headless ubuntu-24.04-arm runner caught what a desktop Pi could not:
the AppImage only started on a machine that already had a full desktop stack
installed. Three distinct causes, all from bundling Debian's builds of
libraries that Debian builds for a co-versioned system, which is the opposite
of an AppImage's situation.

1. Hard-linked backends. Debian's libSDL2 lists libpulse, libasound, libX11
   and libwayland-client as DT_NEEDED rather than dlopening them, so the
   loader demanded all four at startup; the CI job failed with
   "libpulse.so.0 => not found". Debian's OpenAL does the same through
   libsndio, which itself hard-links libasound. Built from source with
   --enable-*-shared and ALSOFT_DLOPEN, both dlopen their backends, so the
   image now runs on a Wayland-only session, a KMSDRM handheld with no X
   server, or a box with ALSA and no PulseAudio.

2. A stray link. Debian's libtheoradec is linked against libcairo, which
   drags in X11, xcb, fontconfig and freetype for a video decoder.
   --disable-examples leaves it needing only libogg.

3. SONAME collision with the host. OpenAL dlopens ALSA, ALSA's config loads
   its PulseAudio hook plugin, and that plugin pulls the host's libsndfile
   into the process. libsndfile links libogg, libvorbis and libmpg123 -- the
   same three we bundle -- and since the loader resolves a SONAME once per
   process it bound to our bullseye copies. A bullseye libmpg123 has no
   mpg123_info2 (added in 1.32), so the plugin failed to relocate, ALSA
   config collapsed, and the game ran with no audio device at all. Building
   them current means our copies satisfy the host's libsndfile instead of
   starving it.

The general rule, now stated as an assertion instead of a comment: never
bundle a library the host's own stack may also load unless ours is at least
as new as theirs. build_appimage.sh fails if any shipped object hard-requires
anything beyond glibc, libstdc++ and the font stack, and CI re-checks it on
the extracted artifact.

Host requirements drop from "a working desktop" to glibc 2.29+, libstdc++,
libfreetype6 and zlib. Bundled libraries drop from 13 to 10: libcairo,
libpixman and libsndio are gone entirely.

Verified on a Raspberry Pi 5 (trixie, Wayland): boots, imports, plays, and
audio works -- SDL 2.30 now picks the native Wayland backend rather than
falling back to XWayland as bullseye's 2.0.14 did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:24:53 -04:00

9.4 KiB

Linux arm64 (aarch64) AppImage

Releases ship gen1recomp-<version>-linux-arm64.AppImage alongside the existing x86_64 gen1recomp-<version>-linux.zip. It targets 64-bit ARM desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the aarch64 handhelds that run a full distro.

The Anbernic RG34XXSP has its own PortMaster-style pack (gen1recomp-*-rg34xxsp-stockos64-mod.zip, see anbernic-rg34xxsp.md). That one bundles PortMaster's LÖVE runtime and expects the device's own SDL; this AppImage is the generic desktop-Linux artifact and shares nothing with it but the game.love.

For players

chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage

Then use Import ROM in the launcher to point it at your own legal Red / Blue / Yellow cartridge dump, exactly as on every other platform.

If your system has no FUSE (dlopen(): error loading libfuse.so.2), either install it (sudo apt install libfuse2) or run without it:

./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run

What the host has to provide

Very little, and this is enforced by an assertion in the build rather than by good intentions. The only libraries the AppImage requires at startup are:

glibc 2.29+   libstdc++   libfreetype6   zlib

Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is dlopened, so it is used when present and skipped when absent. That means one image runs on a full desktop, on a Wayland-only session, on a KMSDRM-only handheld with no X server, and on a box with ALSA but no PulseAudio, without a different build for each.

That property does not come for free from Debian's packages, and getting it is most of what the build below is doing; see Why five libraries are built from source.

For builders

scripts/build_linux_arm64.sh --version 0.1.0

Output:

dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256

Useful flags: --game-love PATH reuses an already-packed payload (CI does this so every platform ships identical bytes), --rebuild-image forces the builder container to rebuild, --clean-cache throws away the pinned downloads and the compiled LÖVE prefix.

Requirements

An aarch64 host with docker or podman. A Raspberry Pi 5 is the reference machine (a cold build takes about 10 minutes on one — six libraries plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker Desktop and GitHub's ubuntu-24.04-arm runner both work too.

The script refuses to run on x86_64 rather than falling back to qemu-user emulation: that path takes hours and has produced miscompiled LuaJIT.

Why this is not just another scripts/build.sh target

scripts/build.sh linux downloads LÖVE's official love-11.5-x86_64.AppImage, unpacks its squashfs, drops game.love in, and glues it back together. That trick is not available here — LÖVE publishes no aarch64 binary at all. The 11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage, and that is the entire list.

So this build compiles LÖVE 11.5 from the official linux-src tarball and assembles the AppImage from scratch. Every pinned input — the LÖVE source, the five libraries built alongside it, and the AppImage type-2 runtime — is SHA-256 verified on the host before the container ever sees it, and the container itself runs with no network access.

Why the build happens in a Debian bullseye container

glibc is backward compatible but not forward compatible: a binary linked against glibc 2.41 will not start on a system with 2.31, and there is no way to fix that after the fact. Compiling on the oldest base we support is therefore the only thing that makes one artifact work everywhere.

Bullseye (glibc 2.31) is that base. The resulting binaries actually come out needing only glibc 2.29 and GLIBCXX_3.4.21, so the AppImage covers everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current trixie.

This is a statement about the compile environment, not about where the artifact runs — building on your own newer distro would silently raise that floor and strand every user on an older one, with no symptom until they download it. CI enforces the floor: linux-arm64-build fails if the highest required glibc symbol version climbs above 2.31.

Why five libraries are built from source

SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather than installed from bullseye. In every case the reason is correctness, not a newer version number — Debian builds these for a system where every dependency is installed and co-versioned, which is the opposite of an AppImage's situation. Each one broke the build in a different way, and all three failure modes are now assertions that fail the build instead of shipping.

1. Hard-linked backends (SDL2, OpenAL). Debian's libSDL2 lists libpulse, libasound, libX11 and libwayland-client as DT_NEEDED — resolved by the loader at startup, not dlopened. An AppImage bundling it refuses to start unless the host has all four. It appeared to work in testing only because a desktop Pi has all four; a headless CI runner is what exposed it. Debian's OpenAL does the same via libsndio, which itself hard-links libasound. Built from source with --enable-*-shared and ALSOFT_DLOPEN, both dlopen their backends instead.

2. A stray link (libtheora). Debian's libtheoradec.so.1 is linked against libcairo.so.2 — a packaging artifact, since a video decoder has no business drawing vector graphics — and cairo drags in X11, xcb, fontconfig and freetype. --disable-examples produces a libtheoradec needing only libogg.

3. SONAME collision with the host (ogg, vorbis, mpg123). The subtle one. OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that plugin pulls the host's libsndfile into our process. libsndfile links libogg, libvorbis and libmpg123 — the same three we bundle. The loader resolves a SONAME exactly once per process, so the host's libsndfile binds to our copies:

openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
                                                         `-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)

mpg123_info2 arrived in mpg123 1.32, so the plugin failed to relocate, ALSA config collapsed, and the game ran with no audio device at all. Not bundling these instead would make libogg/libvorbis/libmpg123 mandatory host packages; building them current means our copies satisfy the host's libsndfile rather than starving it.

The same collision is why the font stack — freetype, fontconfig, libpng, brotli, zlib — is left to the host entirely. Bundling a bullseye freetype 2.10.4 meant a host libcairo could not find FT_Get_Transform (added in 2.11) and the game died at startup. Leaving the whole stack to the host keeps it self-consistent, while liblove — compiled against 2.10.4 — only ever asks for symbols every supported host already has.

The general rule this all reduces to: never bundle a library the host's own stack may also load, unless yours is at least as new as theirs.

CI

Three jobs, path-gated on scripts/build_linux_arm64.sh, scripts/linux-arm64/, scripts/pack_love.sh and this document:

  • linux-arm64-selftest (ubuntu-latest, x86_64) — offline gate. Checks the pins are real digests on a dated tag rather than the moving continuous one, that the Dockerfile still builds on bullseye, that the exclude list still classifies known sonames correctly, that AppRun still launches game.love with --fused, and that the host-arch guard actually fires. Needs no container and no arm64 machine.
  • linux-arm64-build (ubuntu-24.04-arm) — the real build, then extracts the artifact and asserts the layout, that every bundled object resolves under AppRun's LD_LIBRARY_PATH, and that the glibc floor is still ≤ 2.31. Uploads the AppImage for 7 days.
  • releaselinux-arm64 runs on ubuntu-24.04-arm, reuses the shared game.love from the love-payload job, and the AppImage is staged and published like every other release asset.

Unlike the Switch job, none of this needs secrets or self-hosted hardware, so it runs on fork PRs too.

Updating the pins

Both pins live in scripts/linux-arm64/common.sh:

  • LOVE_VERSION / LOVE_SRC_SHA256 — bumping any version invalidates the cached prefix automatically (its name is keyed by every source version at once, so a partial rebuild cannot mix vintages). Check that bullseye still has -dev packages new enough for the new release; build_appimage.sh asserts every optional module actually linked, because LÖVE's configure exits 0 and silently drops a module when one is missing.
  • SDL2_*, OPENAL_*, THEORA_*, OGG_*, VORBIS_*, MPG123_* — the source-built libraries. Bumping these is usually safe and occasionally necessary: libmpg123 in particular must stay at least as new as what a target host's libsndfile expects, which is asserted for mpg123_info2.
  • APPIMAGE_RUNTIME_TAG / APPIMAGE_RUNTIME_SHA256 — always a dated tag from AppImage/type2-runtime. The selftest fails the build if this ever points at continuous.