Files
gen1recomp/scripts/build_linux_arm64.sh
T
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

182 lines
7.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# Builds the aarch64 (arm64) Linux AppImage.
#
# scripts/build.sh's `linux` target only produces x86_64: it unpacks LÖVE's
# official x86_64 AppImage and re-fuses it, and no aarch64 equivalent is
# published. This script compiles LÖVE 11.5 from the official linux-src
# tarball inside a Debian bullseye arm64 container and fuses game.love into a
# type-2 AppImage, so one artifact covers Raspberry Pi OS, Armbian, Ubuntu
# arm64 and the aarch64 handhelds.
#
# Usage:
# scripts/build_linux_arm64.sh [--version X.Y.Z] [--game-love PATH]
# [--rebuild-image] [--clean-cache]
#
# Output:
# dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
# dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
#
# Requirements: docker or podman on an aarch64 host (a Raspberry Pi 5, an
# ubuntu-24.04-arm runner or Apple Silicon Docker all work). Nothing is
# cross-compiled and no qemu emulation is involved.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
. "$ROOT/scripts/linux-arm64/common.sh"
HERE="$ROOT/.bazinga"
CACHE="$HERE/cache/linux-arm64"
WORK="$HERE/work/linux-arm64"
DIST="$ROOT/dist/linux-arm64"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
GAME_LOVE=""
REBUILD_IMAGE=0
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="${2:?--version needs a value}"; shift ;;
--game-love) GAME_LOVE="${2:?--game-love needs a path}"; shift ;;
--rebuild-image) REBUILD_IMAGE=1 ;;
--clean-cache) rm -rf "$CACHE" ;;
-h|--help)
sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*) fail "unknown argument: $1" ;;
esac
shift
done
# --------------------------------------------------------------- host checks
# aarch64 only. The container is arch-native; running it under qemu-user on an
# x86_64 host "works" but takes hours and has produced miscompiled LuaJIT
# before, so refuse rather than hand back a build nobody can trust.
host_arch="$(uname -m)"
case "$host_arch" in
aarch64|arm64) ;;
*) fail "this build must run on an aarch64 host (found: $host_arch).
Use a Raspberry Pi 5 / arm64 VM / Apple Silicon, or the ubuntu-24.04-arm CI runner." ;;
esac
RUNTIME="$(container_runtime)" || fail_need_container
say "container runtime: $RUNTIME"
mkdir -p "$CACHE" "$WORK" "$DIST"
# --------------------------------------------------------------- game.love
# Shared packer, same include/exclude set and the same verification gates as
# every other platform, so this artifact can never drift from the desktop one.
if [ -n "$GAME_LOVE" ]; then
[ -f "$GAME_LOVE" ] || fail "--game-love: no such file: $GAME_LOVE"
say "using prebuilt payload: $GAME_LOVE"
else
GAME_LOVE="$WORK/game.love"
"$ROOT/scripts/pack_love.sh" \
--output "$GAME_LOVE" \
--listing "$WORK/love-listing.txt" \
--version "$VERSION"
fi
# --------------------------------------------------------------- icon
# One source of truth for every platform's launcher icon (scripts/build.sh
# resizes the same file with sips on macOS). Pillow is already a project
# dependency via tools/build_data.py; without it, ship the 1024px original
# rather than failing the build over an icon.
IN_DIR="$WORK/in"
rm -rf "$IN_DIR"; mkdir -p "$IN_DIR"
ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png"
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
if ! python3 - "$ICON_SRC" "$IN_DIR/icon.png" <<'PY' 2>/dev/null
import sys
from PIL import Image
with Image.open(sys.argv[1]) as image:
image.convert("RGBA").resize((512, 512), Image.LANCZOS).save(sys.argv[2])
PY
then
warn "Pillow not available, shipping the unresized icon"
cp "$ICON_SRC" "$IN_DIR/icon.png"
fi
cp "$GAME_LOVE" "$IN_DIR/game.love"
# --------------------------------------------------------------- downloads
# Fetched on the host and checksum-pinned here so the container never needs
# network access and every input is verified in exactly one place.
download_pinned "$LOVE_SRC_URL" "$CACHE/$LOVE_SRC_TARBALL" "$LOVE_SRC_SHA256"
download_pinned "$SDL2_URL" "$CACHE/$SDL2_TARBALL" "$SDL2_SHA256"
download_pinned "$OPENAL_URL" "$CACHE/$OPENAL_TARBALL" "$OPENAL_SHA256"
download_pinned "$THEORA_URL" "$CACHE/$THEORA_TARBALL" "$THEORA_SHA256"
download_pinned "$OGG_URL" "$CACHE/$OGG_TARBALL" "$OGG_SHA256"
download_pinned "$VORBIS_URL" "$CACHE/$VORBIS_TARBALL" "$VORBIS_SHA256"
download_pinned "$MPG123_URL" "$CACHE/$MPG123_TARBALL" "$MPG123_SHA256"
download_pinned "$APPIMAGE_RUNTIME_URL" "$CACHE/$APPIMAGE_RUNTIME_NAME" \
"$APPIMAGE_RUNTIME_SHA256"
# --------------------------------------------------------------- builder image
if [ "$REBUILD_IMAGE" = 1 ] || ! "$RUNTIME" image inspect "$BUILDER_IMAGE" >/dev/null 2>&1; then
say "building $BUILDER_IMAGE ($BUILDER_BASE_IMAGE)"
"$RUNTIME" build -t "$BUILDER_IMAGE" \
-f "$ROOT/scripts/linux-arm64/Dockerfile" "$ROOT/scripts/linux-arm64" \
|| fail "failed to build the $BUILDER_BASE_IMAGE builder image"
fi
# --------------------------------------------------------------- build
OUT_DIR="$WORK/out"
rm -rf "$OUT_DIR"; mkdir -p "$OUT_DIR"
# --user keeps the AppImage owned by the invoking user instead of root; podman
# maps root in the container to the host user already, so only docker needs it.
user_args=()
if [ "$RUNTIME" = "docker" ]; then
user_args=(--user "$(id -u):$(id -g)")
fi
say "compiling and packaging inside $BUILDER_BASE_IMAGE"
"$RUNTIME" run --rm ${user_args[@]+"${user_args[@]}"} \
-e LOVE_VERSION="$LOVE_VERSION" \
-e SDL2_VERSION="$SDL2_VERSION" \
-e SDL2_TARBALL="$SDL2_TARBALL" \
-e OPENAL_VERSION="$OPENAL_VERSION" \
-e OPENAL_TARBALL="$OPENAL_TARBALL" \
-e THEORA_VERSION="$THEORA_VERSION" \
-e THEORA_TARBALL="$THEORA_TARBALL" \
-e OGG_VERSION="$OGG_VERSION" \
-e OGG_TARBALL="$OGG_TARBALL" \
-e VORBIS_VERSION="$VORBIS_VERSION" \
-e VORBIS_TARBALL="$VORBIS_TARBALL" \
-e MPG123_VERSION="$MPG123_VERSION" \
-e MPG123_TARBALL="$MPG123_TARBALL" \
-e APP_NAME="$APP_NAME" \
-e VERSION="$VERSION" \
-v "$CACHE:/cache" \
-v "$IN_DIR:/in:ro" \
-v "$OUT_DIR:/out" \
-v "$ROOT/scripts/linux-arm64:/scripts:ro" \
"$BUILDER_IMAGE" bash /scripts/build_appimage.sh
# --------------------------------------------------------------- publish
built="$OUT_DIR/$APP_NAME-$VERSION-linux-arm64.AppImage"
[ -f "$built" ] || fail "container produced no AppImage at $built"
# The runtime is a static-pie ELF and the payload starts where its section
# headers end; a truncated cat would still be "a file", so prove both halves
# survived before shipping.
head -c 4 "$built" | od -An -tx1 | tr -d ' \n' | grep -q '^7f454c46$' \
|| fail "built AppImage is not an ELF"
e_shoff=$(od -An -j40 -N8 -tu8 "$built" | tr -d ' ')
e_shentsize=$(od -An -j58 -N2 -tu2 "$built" | tr -d ' ')
e_shnum=$(od -An -j60 -N2 -tu2 "$built" | tr -d ' ')
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
[ "$(dd if="$built" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|| fail "no squashfs payload at offset $sfs_offset (runtime/payload fusion failed)"
out="$DIST/$(basename "$built")"
rm -f "$out" "$out.sha256"
mv "$built" "$out"
chmod +x "$out"
printf '%s %s\n' "$(sha256_file "$out")" "$(basename "$out")" > "$out.sha256"
say "Linux arm64 build: $out ($(du -h "$out" | cut -f1))"
say "sha256: $(cut -d' ' -f1 "$out.sha256")"