Merge pull request #871 from ratherDashing/linux-arm64-appimage

This commit is contained in:
bryanthaboi
2026-08-06 05:17:55 -04:00
committed by GitHub
9 changed files with 1384 additions and 1 deletions
+111
View File
@@ -260,6 +260,117 @@ jobs:
if-no-files-found: error
retention-days: 7
linux-arm64-changes:
name: detect Linux arm64 changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
linux-arm64-selftest:
name: Linux arm64 offline selftest
needs: linux-arm64-changes
if: needs.linux-arm64-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Deliberately on x86_64: everything this gate checks (pins, the
# host-arch guard, the dependency exclude list, the AppRun fusion
# contract) is answerable without a container or an aarch64 machine,
# so the slow native job below only ever starts on a sane tree.
- name: Linux arm64 offline selftest
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
linux-arm64-build:
name: Linux arm64 AppImage build
needs: [linux-arm64-changes, linux-arm64-selftest]
if: |
always()
&& needs.linux-arm64-changes.outputs.changed == 'true'
&& needs.linux-arm64-selftest.result == 'success'
# No fork restriction, unlike switch-build: this needs no secrets and no
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
# so contributors get the same coverage on their own PRs.
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- name: Build the aarch64 AppImage
run: |
set -euo pipefail
scripts/build_linux_arm64.sh --version 0.0.0
- name: Verify the AppImage is self-contained and bullseye-compatible
run: |
set -euo pipefail
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
# --appimage-extract needs no FUSE, so this works on a runner
# without /dev/fuse and still exercises the real payload.
"$image" --appimage-extract >/dev/null
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
[ -e "squashfs-root/$required" ] \
|| { echo "::error::AppImage is missing $required"; exit 1; }
done
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
# applied; an unresolved soname here is a user-visible launch crash.
#
# This runs on a HEADLESS runner on purpose, and that is the point.
# The first version of this build bundled Debian's SDL2, which
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
# started on a full desktop -- a bare runner is what exposed it.
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep 'not found' || true)"
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
# Nothing may hard-link a driver, session or audio-stack library:
# those must be reached through dlopen so the AppImage runs on a box
# with only ALSA, only Wayland, or only KMSDRM.
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
[ -z "$linked" ] \
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
# The whole point of compiling on bullseye. If a future change moves
# the builder to a newer base, the glibc floor silently rises and
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
# it here instead of in a release.
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
echo "highest required glibc symbol version: $floor"
[ -n "$floor" ] \
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
[ "$highest" = "GLIBC_2.31" ] \
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
- name: Upload the AppImage
uses: actions/upload-artifact@v7
with:
name: gen1recomp-linux-arm64
path: |
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
if-no-files-found: error
retention-days: 7
headless:
name: headless suites (no ROM)
runs-on: ubuntu-latest
+48 -1
View File
@@ -140,6 +140,36 @@ jobs:
if-no-files-found: error
retention-days: 1
linux-arm64:
name: build Linux arm64 AppImage
needs: [version, love-payload]
# GitHub's free arm64 runner for public repos. It has to be arm64: the
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
# container, and the qemu-emulated alternative takes hours.
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-release-love
path: .bazinga/work
- name: Build Linux arm64 AppImage
run: |
set -euo pipefail
scripts/build_linux_arm64.sh \
--version "${{ needs.version.outputs.version }}" \
--game-love .bazinga/work/game.love
- name: Upload Linux arm64 release
uses: actions/upload-artifact@v7
with:
name: gen1recomp-linux-arm64-release
path: |
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
if-no-files-found: error
retention-days: 1
xbox-uwp:
name: build Xbox UWP release
needs: [version, love-payload]
@@ -222,7 +252,7 @@ jobs:
}
release:
needs: [version, xbox-uwp]
needs: [version, xbox-uwp, linux-arm64]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
@@ -367,6 +397,13 @@ jobs:
name: gen1recomp-xbox-uwp-release
path: dist/xbox-uwp
- name: Download Linux arm64 release
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/download-artifact@v8
with:
name: gen1recomp-linux-arm64-release
path: dist/linux-arm64
- name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp'
id: assets
@@ -379,6 +416,15 @@ jobs:
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
# its own runner because LÖVE publishes no aarch64 binary and the
# AppImage has to be compiled natively; ships as a runnable
# AppImage rather than a zip so `chmod +x && ./it` just works.
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
@@ -512,6 +558,7 @@ jobs:
"dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
+19
View File
@@ -205,6 +205,25 @@ even on a different computer, as long as the same folder comes along.
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## Linux on arm64 (Raspberry Pi)
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
```sh
chmod +x gen1recomp-*-linux-arm64.AppImage
./gen1recomp-*-linux-arm64.AppImage
```
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
dlopened, so the same image runs on a full desktop, a Wayland-only session or
a KMSDRM handheld with no X server. Build instructions and the reasoning are
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
+202
View File
@@ -0,0 +1,202 @@
# 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](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
```sh
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:
```sh
./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](#why-five-libraries-are-built-from-source).
## For builders
```sh
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.
- **release** — `linux-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](https://github.com/AppImage/type2-runtime/releases).
The selftest fails the build if this ever points at `continuous`.
+181
View File
@@ -0,0 +1,181 @@
#!/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")"
+45
View File
@@ -0,0 +1,45 @@
# Build environment for the aarch64 Linux AppImage.
#
# Debian bullseye on purpose: it ships glibc 2.31, the oldest runtime we
# promise to support. Everything linked here therefore runs on bullseye and
# every later distro (glibc is backward compatible, not forward), which is
# what makes the resulting AppImage portable across Raspberry Pi OS, Armbian,
# Ubuntu 20.04+, and the aarch64 handheld distros.
#
# This image is arch-native: build it on an aarch64 host (Raspberry Pi 5,
# ubuntu-24.04-arm runner, Apple Silicon Docker) — no qemu emulation.
FROM debian:bullseye
ENV DEBIAN_FRONTEND=noninteractive
# build-essential/autoconf: LÖVE 11.5's linux-src tarball is autotools.
# squashfs-tools: packs the AppDir into the AppImage payload.
#
# Note what is deliberately ABSENT: libsdl2-dev, libtheora-dev and
# libopenal-dev. All three are built from source instead (see common.sh for
# why), and having Debian's copies installed would let pkg-config hand LÖVE's
# configure the system ones and silently undo it.
#
# The remaining lib*-dev set is LÖVE's optional-module surface. A missing one
# does not fail configure, it silently drops a module (love.sound decoders,
# love.font, love.video), so they are pinned here deliberately and asserted
# after the build.
#
# The X11/Wayland/audio -dev packages are here for SDL2's *build*, not for
# runtime linkage: SDL detects each backend at compile time and then dlopens
# it, so these headers decide which backends exist at all while adding no
# DT_NEEDED entry to the shipped library.
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \
build-essential pkg-config autoconf automake libtool cmake \
ca-certificates curl file xz-utils bzip2 zip unzip squashfs-tools \
libogg-dev libvorbis-dev \
libmodplug-dev libmpg123-dev libfreetype6-dev libluajit-5.1-dev \
zlib1g-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev \
libasound2-dev libpulse-dev libudev-dev libdbus-1-dev \
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev \
libxinerama-dev libxss-dev libxkbcommon-dev \
libwayland-dev wayland-protocols libdrm-dev libgbm-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env bash
# Compiles LÖVE for aarch64 and fuses game.love into a self-contained
# AppImage. Runs INSIDE the Debian bullseye container from Dockerfile --
# scripts/build_linux_arm64.sh is the entry point on the host.
#
# Mounts the host provides:
# /cache pinned downloads + the compiled LÖVE prefix (persists between runs)
# /in read-only inputs: game.love, icon.png
# /out the finished AppImage lands here
#
# Environment:
# LOVE_VERSION, APP_NAME, VERSION passed through from the host script
# JOBS make -j (defaults to nproc)
set -euo pipefail
LOVE_VERSION="${LOVE_VERSION:?}"
SDL2_VERSION="${SDL2_VERSION:?}"
SDL2_TARBALL="${SDL2_TARBALL:?}"
OPENAL_VERSION="${OPENAL_VERSION:?}"
OPENAL_TARBALL="${OPENAL_TARBALL:?}"
THEORA_VERSION="${THEORA_VERSION:?}"
THEORA_TARBALL="${THEORA_TARBALL:?}"
OGG_VERSION="${OGG_VERSION:?}"
OGG_TARBALL="${OGG_TARBALL:?}"
VORBIS_VERSION="${VORBIS_VERSION:?}"
VORBIS_TARBALL="${VORBIS_TARBALL:?}"
MPG123_VERSION="${MPG123_VERSION:?}"
MPG123_TARBALL="${MPG123_TARBALL:?}"
APP_NAME="${APP_NAME:?}"
VERSION="${VERSION:?}"
JOBS="${JOBS:-$(nproc)}"
CACHE="/cache"
IN="/in"
OUT="/out"
WORK="/tmp/build"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
mkdir -p "$WORK"
# --------------------------------------------------------------- prefix
# Everything we compile lands in one prefix, cached because the compiling is
# the only slow part (~5 min cold on a Pi 5) and is identical for every game
# version. The key includes every source version, so bumping any of them
# invalidates the cache instead of silently reusing a stale mix.
PREFIX="$CACHE/prefix-love$LOVE_VERSION-sdl$SDL2_VERSION-al$OPENAL_VERSION-theora$THEORA_VERSION-ogg$OGG_VERSION-vorbis$VORBIS_VERSION-mpg$MPG123_VERSION"
export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig"
# Our own libraries must win over the system ones during LÖVE's configure and
# link, or the whole point of building them is lost.
export LD_LIBRARY_PATH="$PREFIX/lib"
# ------------------------------------------------------ compile audio codecs
# Ordered by dependency: vorbis needs ogg, and theora needs ogg too. All three
# are small, plain autotools builds -- well under a minute each.
build_autotools() { # $1 = label $2 = version $3 = tarball $4 = probe lib $5.. = configure args
local label="$1" version="$2" tarball="$3" probe="$4"; shift 4
if [ -f "$PREFIX/lib/$probe" ]; then
say "reusing cached $label $version"
return 0
fi
say "compiling $label $version"
local src="$WORK/$label-src"
rm -rf "$src"; mkdir -p "$src"
case "$tarball" in
*.tar.bz2) tar -xjf "$CACHE/$tarball" -C "$src" --strip-components=1 ;;
*) tar -xzf "$CACHE/$tarball" -C "$src" --strip-components=1 ;;
esac
(
cd "$src"
# Several of these tarballs predate aarch64's entry in config.guess; the
# distro's copies know about it, so refresh them or configure bails out
# with "cannot guess build type".
for helper in config.guess config.sub; do
[ -f "$helper" ] && cp "/usr/share/misc/$helper" . 2>/dev/null
done
./configure --prefix="$PREFIX" --disable-static "$@" >/dev/null
make -j"$JOBS" >/dev/null
make install >/dev/null
)
}
build_autotools ogg "$OGG_VERSION" "$OGG_TARBALL" libogg.so.0
build_autotools vorbis "$VORBIS_VERSION" "$VORBIS_TARBALL" libvorbis.so.0
# mpg123's ports/ tree and the command-line player are irrelevant here; only
# libmpg123 gets linked, and --disable-modules keeps the output-backend
# plugins (and their dlopen of ALSA/pulse) out of the shipped library.
build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \
--disable-modules --with-audio=dummy --disable-lfs-alias
# The symbol that was missing when this was bullseye's copy. Assert it, so a
# version bump that quietly regresses below the host's expectations fails the
# build instead of silently killing audio again.
objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \
|| fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate"
# ------------------------------------------------------------ compile SDL2
# --enable-*-shared (the defaults, made explicit so a future SDL release
# cannot flip them under us) is the entire reason this is built from source:
# each backend is dlopened at runtime rather than becoming a DT_NEEDED entry,
# so the AppImage starts on a host with only ALSA, or only Wayland, or only
# KMSDRM, instead of demanding all of them at once the way Debian's build does.
if [ -f "$PREFIX/lib/libSDL2-2.0.so.0" ]; then
say "reusing cached SDL2 $SDL2_VERSION"
else
say "compiling SDL2 $SDL2_VERSION (jobs: $JOBS)"
rm -rf "$WORK/sdl-src"; mkdir -p "$WORK/sdl-src"
tar -xzf "$CACHE/$SDL2_TARBALL" -C "$WORK/sdl-src" --strip-components=1
(
cd "$WORK/sdl-src"
./configure --prefix="$PREFIX" --disable-static \
--enable-alsa --enable-alsa-shared \
--enable-pulseaudio --enable-pulseaudio-shared \
--enable-video-x11 --enable-x11-shared \
--enable-video-wayland --enable-wayland-shared \
--enable-video-kmsdrm --enable-kmsdrm-shared \
--enable-libudev --disable-sndio --disable-jack --disable-esd \
--disable-arts --disable-nas --disable-oss >/dev/null
make -j"$JOBS" >/dev/null
make install >/dev/null
)
fi
# Prove the dlopen intent actually took. If SDL ever hard-links an audio or
# video backend again, the AppImage silently regains a startup dependency on
# the host having that exact stack -- which is the bug this replaced.
sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0"
[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0"
for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do
if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then
fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)"
fi
done
# ---------------------------------------------------- compile openal-soft
# ALSOFT_DLOPEN keeps the ALSA and PulseAudio backends behind dlopen, and
# sndio is switched off outright -- Debian enables it, which is what chained
# libopenal -> libsndio -> libasound into a mandatory startup dependency.
if [ -f "$PREFIX/lib/libopenal.so.1" ]; then
say "reusing cached openal-soft $OPENAL_VERSION"
else
say "compiling openal-soft $OPENAL_VERSION (jobs: $JOBS)"
rm -rf "$WORK/openal-src"; mkdir -p "$WORK/openal-src"
tar -xzf "$CACHE/$OPENAL_TARBALL" -C "$WORK/openal-src" --strip-components=1
(
cd "$WORK/openal-src"
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DALSOFT_DLOPEN=ON \
-DALSOFT_BACKEND_SNDIO=OFF \
-DALSOFT_BACKEND_OSS=OFF \
-DALSOFT_BACKEND_JACK=OFF \
-DALSOFT_EXAMPLES=OFF \
-DALSOFT_UTILS=OFF \
-DALSOFT_TESTS=OFF \
-DLIBTYPE=SHARED >/dev/null
cmake --build build -j"$JOBS" >/dev/null
cmake --install build >/dev/null
)
fi
openal_lib="$PREFIX/lib/libopenal.so.1"
[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1"
for forbidden in libsndio libasound libpulse libjack; do
if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then
fail "openal hard-links $forbidden; backends must stay behind dlopen"
fi
done
# --------------------------------------------------- compile libtheora
# --disable-examples is what drops Debian's libcairo link (and with it libX11,
# libxcb, libfontconfig and libfreetype as startup dependencies). The encoder
# is dead weight for a player, but libtheoradec is what LÖVE actually links.
if [ -f "$PREFIX/lib/libtheoradec.so.1" ]; then
say "reusing cached libtheora $THEORA_VERSION"
else
say "compiling libtheora $THEORA_VERSION"
rm -rf "$WORK/theora-src"; mkdir -p "$WORK/theora-src"
tar -xjf "$CACHE/$THEORA_TARBALL" -C "$WORK/theora-src" --strip-components=1
(
cd "$WORK/theora-src"
# theora 1.1.1 predates the aarch64 config.guess, so refresh the autotools
# helper scripts or configure rejects the host outright.
for helper in config.guess config.sub; do
cp "/usr/share/misc/$helper" . 2>/dev/null || true
done
./configure --prefix="$PREFIX" --disable-static \
--disable-examples --disable-spec --disable-doc >/dev/null
make -j"$JOBS" >/dev/null
make install >/dev/null
)
fi
theora_lib="$PREFIX/lib/libtheoradec.so.1"
[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1"
if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then
fail "libtheoradec still links libcairo (--disable-examples stopped working)"
fi
# ------------------------------------------------------------ compile LÖVE
if [ -x "$PREFIX/bin/love" ] && [ -f "$PREFIX/lib/liblove-$LOVE_VERSION.so" ]; then
say "reusing cached LÖVE $LOVE_VERSION aarch64 build"
else
say "compiling LÖVE $LOVE_VERSION for aarch64 (jobs: $JOBS)"
rm -rf "$WORK/love-src"
mkdir -p "$WORK/love-src"
tar -xzf "$CACHE/love-$LOVE_VERSION-linux-src.tar.gz" \
-C "$WORK/love-src" --strip-components=1
(
cd "$WORK/love-src"
# No --disable-* flags on purpose: configure silently drops a love module
# when its -dev package is absent, so the Dockerfile pins the full set and
# the assertions below prove each one actually linked. CPPFLAGS/LDFLAGS
# point at our prefix so the SDL2 and theora just built above win over
# anything the base image might still provide.
./configure --prefix="$PREFIX" --disable-static \
CPPFLAGS="-I$PREFIX/include" LDFLAGS="-L$PREFIX/lib" >/dev/null
make -j"$JOBS" >/dev/null
make install >/dev/null
# Keep LÖVE's license inside the cached prefix: the unpacked source tree
# is thrown away, so a later cache-hit run would otherwise have nothing
# to ship and the AppImage would go out without its engine license.
cp license.txt "$PREFIX/license.txt"
)
fi
love_bin="$PREFIX/bin/love"
love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so"
[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love"
[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so"
file "$love_bin" | grep -q 'ARM aarch64' \
|| fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))"
# A configure run that lost an optional dependency still exits 0 and still
# builds -- the loss only shows up as a missing love module at runtime, i.e.
# in a shipped artifact. Assert the decoder/font/video libs really linked.
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \
libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \
libtheoradec.so.1 libluajit-5.1.so.2; do
objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \
|| fail "liblove is not linked against $soname (a -dev package went missing)"
done
# --------------------------------------------------------------- AppDir
# Layout mirrors LÖVE's own x86_64 AppImage exactly (bin/ lib/ share/ at the
# AppDir root, not usr/-prefixed), so the AppRun contract below -- and the
# FUSE_PATH fusion scripts/build.sh performs on the x86_64 image -- stay the
# same idea on both architectures.
APPDIR="$WORK/AppDir"
rm -rf "$APPDIR"
mkdir -p "$APPDIR/bin" "$APPDIR/lib" "$APPDIR/share"
cp "$love_bin" "$APPDIR/bin/love"
chmod +x "$APPDIR/bin/love"
# ------------------------------------------------------ bundle dependencies
# Walk the DT_NEEDED graph from love + liblove, copying in everything that is
# not host-provided. Recursion stops at excluded libraries, so the driver and
# session subtrees behind SDL2 are never pulled in.
#
# Three reasons a library MUST come from the host, and every entry below is
# one of them:
#
# 1. Driver/session coupled. A bundled libGL would bypass Mesa's V3D driver
# on the Pi; a bundled libpulse/libdbus would fight the user's running
# session. GL/EGL/gbm/drm, X11/xcb/wayland/xkbcommon, dbus, pulse, alsa,
# systemd/udev. Note that after the source builds above, none of these are
# DT_NEEDED of anything we ship -- SDL2 and OpenAL dlopen them, so they are
# used when present and skipped when absent.
#
# 2. Loader coupled. glibc's pieces cannot be mixed with the host's ld.so at
# all, and libstdc++/libgcc_s must be at least as new as the compiler --
# bullseye's gcc 10 is older than any supported host's, so the host copy
# always satisfies us.
#
# 3. The font/compression stack: freetype, fontconfig, libpng, brotli, zlib.
# These are shared with whatever the host's own graphics libraries have
# already loaded, and mixing vintages inside one process breaks the older
# copy. Bundling a bullseye freetype 2.10.4 is what made a host cairo fail
# to find FT_Get_Transform (added in 2.11) and killed the game at startup.
# Leaving the whole stack to the host keeps it self-consistent, and
# liblove -- compiled against 2.10.4 -- only ever asks for symbols every
# supported host already has.
EXCLUDE_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libresolv\.so\.2|libutil\.so\.1|libanl\.so\.1|libnsl\.so\.[0-9]+|libstdc\+\+\.so\.6|libgcc_s\.so\.1|lib(GL|GLX|GLdispatch|OpenGL|EGL|GLESv[12]|glapi|gbm|drm)\..*|libX[a-z0-9]*\..*|libxcb.*|libwayland-.*|libxkbcommon.*|libdbus-1\..*|libpulse.*|libasound\..*|libsndfile\..*|libFLAC\..*|libopus\..*|libsystemd\..*|libudev\..*|libselinux\..*|libcap\..*|libgcrypt\..*|libgpg-error\..*|liblzma\..*|libzstd\..*|liblz4\..*|libffi\..*|libexpat\..*|libbsd\..*|libmd\..*|libuuid\..*|libg(lib|object|module|thread)-2\..*|libfontconfig\..*|libfreetype\..*|libpng[0-9]*\..*|libbrotli.*|libz\.so\..*|libwrap\..*|libasyncns\..*|libtirpc\..*|lib(gssapi_krb5|krb5|k5crypto|com_err|krb5support|keyutils)\..*|libpcre.*)$'
# soname -> absolute path, harvested from the full ldd closure of both roots.
declare -A RESOLVED=()
while read -r soname _arrow path _addr; do
[ -n "${path:-}" ] || continue
[ -e "$path" ] || continue
RESOLVED["$soname"]="$path"
done < <(ldd "$love_bin" "$love_lib" | awk '/=>/ {print $1, $2, $3, $4}')
declare -A BUNDLED=()
bundle_needed() { # $1 = ELF whose DT_NEEDED entries to walk
local soname target
while read -r soname; do
[ -n "$soname" ] || continue
if [[ "$soname" =~ $EXCLUDE_RE ]]; then continue; fi
if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi
target="${RESOLVED[$soname]:-}"
[ -n "$target" ] || fail "cannot resolve $soname (needed by $(basename "$1"))"
# Copy dereferenced and under the soname: the AppDir must not depend on
# the builder's libSDL2-2.0.so.0 -> libSDL2-2.0.so.0.14.0 symlink chain.
cp -L "$target" "$APPDIR/lib/$soname"
chmod 0644 "$APPDIR/lib/$soname"
BUNDLED["$soname"]=1
bundle_needed "$APPDIR/lib/$soname"
done < <(objdump -p "$1" | awk '/NEEDED/ {print $2}')
}
say "bundling shared libraries"
cp "$love_lib" "$APPDIR/lib/liblove-$LOVE_VERSION.so"
chmod 0644 "$APPDIR/lib/liblove-$LOVE_VERSION.so"
BUNDLED["liblove-$LOVE_VERSION.so"]=1
bundle_needed "$APPDIR/bin/love"
bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so"
say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')"
# ------------------------------------------------- host dependency contract
# The portability promise, stated as an assertion instead of a paragraph in a
# README: these are the ONLY sonames the shipped objects may require from the
# host. Everything driver-, session- or audio-related has to be reached
# through dlopen, so the AppImage starts on a box with no PulseAudio, no X11
# or no ALSA and simply uses whatever it does find.
#
# The original build failed exactly here and nobody noticed until CI ran on a
# headless runner: Debian's SDL2 hard-links libpulse/libasound/libX11/
# libwayland, so the image only ever started on a full desktop.
HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$'
unexpected=""
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do
while read -r soname; do
[ -n "$soname" ] || continue
# Satisfied from inside the AppDir, so not a host requirement at all.
if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi
if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then continue; fi
unexpected="$unexpected $(basename "$object") -> $soname"$'\n'
done < <(objdump -p "$object" | awk '/NEEDED/ {print $2}')
done
[ -z "$unexpected" ] || fail "$(printf '%s\n%s' \
"these objects hard-require host libraries outside the allowed set (they must be dlopened, not linked):" \
"$unexpected")"
say "host dependency contract holds (glibc, libstdc++ and the font stack only)"
# LÖVE loads jit.* (jit.status, the profiler) through LUA_PATH; without these
# the modules are simply absent, so ship them the way upstream's image does.
jit_share="$(ls -d /usr/share/luajit-* 2>/dev/null | head -1)"
[ -n "$jit_share" ] || fail "luajit jit/*.lua modules not found under /usr/share"
LUAJIT_SHARE_DIR="$(basename "$jit_share")"
mkdir -p "$APPDIR/share/$LUAJIT_SHARE_DIR" "$APPDIR/share/lua/5.1" "$APPDIR/lib/lua/5.1"
cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/"
# --------------------------------------------------------------- branding
cp "$IN/game.love" "$APPDIR/game.love"
# The .desktop's Icon= resolves against the AppDir root by basename, and
# .DirIcon is what appimaged and file-manager thumbnailers read.
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
cp "$IN/icon.png" "$APPDIR/.DirIcon"
cat > "$APPDIR/$APP_NAME.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=gen1recomp
Comment=Pokémon Gen 1 recompilation
Exec=$APP_NAME
Icon=$APP_NAME
Categories=Game;
Terminal=false
EOF
# AppRun follows LÖVE's own, with FUSE_PATH committed to instead of shipped
# commented out: this image is a game, not the engine, so it must never fall
# through to LÖVE's "no game" screen.
cat > "$APPDIR/AppRun" <<EOF
#!/bin/sh
# gen1recomp aarch64 AppImage launcher.
if [ -z "\$APPDIR" ]; then
APPDIR="\$(dirname "\$(readlink -f "\$0")")"
fi
export LD_LIBRARY_PATH="\$APPDIR/lib/:\$LD_LIBRARY_PATH"
if [ -z "\$XDG_DATA_DIRS" ]; then
XDG_DATA_DIRS="/usr/local/share/:/usr/share/"
fi
export XDG_DATA_DIRS="\$APPDIR/share/:\$XDG_DATA_DIRS"
if [ -z "\$LUA_PATH" ]; then
LUA_PATH=";"
fi
export LUA_PATH="\$APPDIR/share/$LUAJIT_SHARE_DIR/?.lua;\$APPDIR/share/lua/5.1/?.lua;\$LUA_PATH"
if [ -z "\$LUA_CPATH" ]; then
LUA_CPATH=";"
fi
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
EOF
chmod +x "$APPDIR/AppRun"
[ -f "$PREFIX/license.txt" ] || fail "LÖVE license.txt missing from the build prefix"
cp "$PREFIX/license.txt" "$APPDIR/license.love2d.txt"
# --------------------------------------------------------------- fuse image
# An AppImage is just <runtime ELF><squashfs>. gzip at 128K blocks matches what
# LÖVE's official image uses and what every type-2 runtime can read; zstd would
# be smaller but is not universally supported by older runtimes users may have
# registered through appimaged.
say "packing squashfs"
sfs="$WORK/payload.squashfs"
rm -f "$sfs"
mksquashfs "$APPDIR" "$sfs" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
out="$OUT/$APP_NAME-$VERSION-linux-arm64.AppImage"
rm -f "$out"
cat "$CACHE/runtime-aarch64" "$sfs" > "$out"
chmod +x "$out"
say "AppImage: $(basename "$out") ($(du -h "$out" | cut -f1))"
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Shared helpers and pins for the aarch64 Linux AppImage build.
# Source from other scripts: . "$(dirname "$0")/common.sh"
# shellcheck disable=SC2034
if [ -z "${ROOT:-}" ]; then
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fi
export ROOT
# ---------------------------------------------------------------- pins
# LÖVE ships no aarch64 binary of any kind -- the 11.5 release has win32/win64,
# macOS, Android, iOS and an x86_64 AppImage, and that is the whole list. So
# this port compiles the official linux-src tarball instead of unpacking a
# prebuilt image the way scripts/build.sh does for x86_64.
LOVE_VERSION="11.5"
LOVE_SRC_TARBALL="love-$LOVE_VERSION-linux-src.tar.gz"
LOVE_SRC_URL="https://github.com/love2d/love/releases/download/$LOVE_VERSION/$LOVE_SRC_TARBALL"
LOVE_SRC_SHA256="066e0843f71aa9fd28b8eaf27d41abb74bfaef7556153ac2e3cf08eafc874c39"
# SDL2 is built from source rather than taken from bullseye, and this is a
# correctness requirement, not a version preference. Debian's libSDL2 lists
# libpulse, libasound, libX11 and libwayland-client as DT_NEEDED -- hard links
# resolved by the loader at startup -- so an AppImage bundling it refuses to
# launch unless the host has ALL FOUR installed. That is wrong for an artifact
# whose whole job is to run on arbitrary arm64 systems: an ALSA-only handheld
# or a minimal Wayland box would die before main(). Built from source, SDL
# defaults to dlopening every audio and video backend (--enable-*-shared), so
# it loads whichever the host actually has and degrades gracefully.
# The newer version is a bonus: 2.30 has a far better controller database and
# real KMSDRM support, both of which matter on Pi-class and handheld hardware.
SDL2_VERSION="2.30.12"
SDL2_TARBALL="SDL2-$SDL2_VERSION.tar.gz"
SDL2_URL="https://github.com/libsdl-org/SDL/releases/download/release-$SDL2_VERSION/$SDL2_TARBALL"
SDL2_SHA256="ac356ea55e8b9dd0b2d1fa27da40ef7e238267ccf9324704850d5d47375b48ea"
# libtheora likewise. Debian's libtheoradec.so.1 is linked against libcairo --
# a packaging artifact, since a video decoder has no business drawing vector
# graphics -- and cairo drags in libX11, libxcb, libfontconfig and libfreetype
# as hard dependencies. LOVE needs theora for love.video, so that link would
# put the entire X11 and font stack on the critical path at startup, and it is
# what caused the FT_Get_Transform crash this build hit on a trixie host.
# Upstream's tarball with --disable-examples produces a libtheoradec that
# needs only libogg.
THEORA_VERSION="1.1.1"
THEORA_TARBALL="libtheora-$THEORA_VERSION.tar.bz2"
THEORA_URL="https://downloads.xiph.org/releases/theora/$THEORA_TARBALL"
THEORA_SHA256="b6ae1ee2fa3d42ac489287d3ec34c5885730b1296f0801ae577a35193d3affbc"
# OpenAL for the same reason as SDL2, one level down. Debian's libopenal is
# openal-soft built with the sndio backend enabled, so it hard-links
# libsndio, which itself hard-links libasound -- reintroducing exactly the
# mandatory-ALSA dependency the SDL2 source build exists to remove. Upstream
# openal-soft dlopens its backends, so building it here leaves the shipped
# library with no audio-stack dependency at all.
OPENAL_VERSION="1.23.1"
OPENAL_TARBALL="openal-soft-$OPENAL_VERSION.tar.gz"
OPENAL_URL="https://github.com/kcat/openal-soft/archive/refs/tags/$OPENAL_VERSION.tar.gz"
OPENAL_SHA256="dfddf3a1f61059853c625b7bb03de8433b455f2f79f89548cbcbd5edca3d4a4a"
# The audio codecs are built from source for a third, different reason: SONAME
# collision with the host's audio stack.
#
# 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 once per process, so the host's libsndfile binds to OUR
# copies, and a bullseye libmpg123 has no mpg123_info2 (added in 1.32):
#
# openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host)
# `-> mpg123_info2 -> libmpg123 (ours, bullseye)
#
# which failed to relocate and left the game with no audio device at all.
# Not bundling them instead would make libogg/libvorbis/libmpg123 mandatory
# host packages; building them current means our copies satisfy the host's
# libsndfile rather than starving it. libvorbisfile ships in the vorbis
# tarball.
OGG_VERSION="1.3.5"
OGG_TARBALL="libogg-$OGG_VERSION.tar.gz"
OGG_URL="https://downloads.xiph.org/releases/ogg/$OGG_TARBALL"
OGG_SHA256="0eb4b4b9420a0f51db142ba3f9c64b333f826532dc0f48c6410ae51f4799b664"
VORBIS_VERSION="1.3.7"
VORBIS_TARBALL="libvorbis-$VORBIS_VERSION.tar.gz"
VORBIS_URL="https://downloads.xiph.org/releases/vorbis/$VORBIS_TARBALL"
VORBIS_SHA256="0e982409a9c3fc82ee06e08205b1355e5c6aa4c36bca58146ef399621b0ce5ab"
MPG123_VERSION="1.32.10"
MPG123_TARBALL="mpg123-$MPG123_VERSION.tar.bz2"
MPG123_URL="https://www.mpg123.de/download/$MPG123_TARBALL"
MPG123_SHA256="87b2c17fe0c979d3ef38eeceff6362b35b28ac8589fbf1854b5be75c9ab6557c"
# AppImage type-2 runtime: the ~900 KB static-pie ELF that gets prepended to
# the squashfs payload. Pinned to a dated tag, never "continuous", so a
# rebuild months from now produces the same bytes.
APPIMAGE_RUNTIME_TAG="20251108"
APPIMAGE_RUNTIME_NAME="runtime-aarch64"
APPIMAGE_RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME"
APPIMAGE_RUNTIME_SHA256="00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444"
# Debian bullseye (glibc 2.31) is the compile environment, NOT a statement
# about where the artifact runs. glibc is backward compatible but not forward
# compatible, so linking against the oldest glibc we support is what lets one
# AppImage cover Raspberry Pi OS bullseye/bookworm/trixie, Ubuntu 20.04+ and
# the aarch64 handheld distros. Building on a newer base would silently
# restrict the artifact to that base and newer.
BUILDER_BASE_IMAGE="debian:bullseye"
BUILDER_IMAGE="${GEN1_LINUX_ARM64_IMAGE:-gen1recomp-linux-arm64-builder}"
APP_NAME="gen1recomp"
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# Print SHA-256 hex digest of PATH. Prefers sha256sum, falls back to shasum
# (same order-agnostic pair scripts/switch/common.sh uses).
sha256_file() {
local path="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$path" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$path" | awk '{print $1}'
else
fail "need sha256sum or shasum (install coreutils)"
fi
}
# download_pinned URL DEST EXPECTED_SHA256
#
# A cache hit is only trusted if it still hashes to the pin: a download
# truncated by a network drop would otherwise be reused forever, which is the
# same trap scripts/build.sh guards for the win64 zip and the x86_64 AppImage.
download_pinned() {
local url="$1" dest="$2" want="$3" got=""
if [ -f "$dest" ]; then
got="$(sha256_file "$dest")"
if [ "$got" = "$want" ]; then
return 0
fi
warn "cached $(basename "$dest") has the wrong digest, re-downloading"
rm -f "$dest"
fi
say "downloading $(basename "$dest")"
curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url"
got="$(sha256_file "$dest.tmp")"
[ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \
"checksum mismatch for $(basename "$dest")" "$want" "$got")"
mv "$dest.tmp" "$dest"
}
# Echo the container runtime to use: docker, else podman.
container_runtime() {
if [ -n "${GEN1_CONTAINER_RUNTIME:-}" ]; then
printf '%s' "$GEN1_CONTAINER_RUNTIME"
return 0
fi
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
printf 'docker'
elif command -v podman >/dev/null 2>&1; then
printf 'podman'
else
return 1
fi
}
fail_need_container() {
fail "$(cat <<'EOF'
the aarch64 AppImage is compiled inside a Debian bullseye container and needs
docker or podman on an aarch64 host.
Raspberry Pi OS / Debian / Ubuntu: sudo apt install docker.io && sudo usermod -aG docker "$USER"
Fedora / Asahi: sudo dnf install podman
macOS (Apple Silicon): brew install --cask docker
Override the runtime with GEN1_CONTAINER_RUNTIME=podman.
See docs/linux-arm64-build.md.
EOF
)"
}
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# Offline checks for the aarch64 Linux AppImage build.
#
# Runs anywhere -- no container, no network, no aarch64 host -- so PR CI can
# gate the parts of this build that do not need three minutes of compiling.
# The real build is exercised separately by the linux-arm64-build job.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
. "$SCRIPT_DIR/common.sh"
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
}
require_command unzip
require_command zip
say "checking shell entry points"
bash -n "$ROOT/scripts/build_linux_arm64.sh" "$SCRIPT_DIR"/*.sh
help="$(bash "$ROOT/scripts/build_linux_arm64.sh" --help)"
printf '%s' "$help" | grep -q -- '--version X.Y.Z' \
|| fail "build help does not document --version"
printf '%s' "$help" | grep -q 'linux-arm64\.AppImage' \
|| fail "build help does not name the artifact it produces"
say "checking the host-architecture guard"
# The guard is what stops someone from kicking off a qemu-emulated build that
# takes hours and miscompiles LuaJIT. Prove it fires rather than trusting it.
# The guard is what stops someone from kicking off a qemu-emulated build that
# takes hours and has miscompiled LuaJIT before. Prove it fires by shadowing
# uname, rather than trusting the branch is reachable.
fake_bin="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-fake-uname.XXXXXX")"
printf '#!/bin/sh\necho x86_64\n' > "$fake_bin/uname"
chmod +x "$fake_bin/uname"
guard_out="$(PATH="$fake_bin:$PATH" \
bash "$ROOT/scripts/build_linux_arm64.sh" --version 0.0.0 2>&1 || true)"
rm -rf "$fake_bin"
printf '%s' "$guard_out" | grep -q 'aarch64 host' \
|| fail "build script does not refuse to run on a non-aarch64 host"
say "checking pinned inputs"
# Pins must be real digests, and the AppImage runtime must come from a dated
# tag: "continuous" is a moving target and would make rebuilds unreproducible.
for pin_name in LOVE_SRC_SHA256 SDL2_SHA256 OPENAL_SHA256 THEORA_SHA256 \
OGG_SHA256 VORBIS_SHA256 MPG123_SHA256 APPIMAGE_RUNTIME_SHA256; do
pin_value="${!pin_name}"
printf '%s' "$pin_value" | grep -Eq '^[0-9a-f]{64}$' \
|| fail "$pin_name is not a sha256 digest: $pin_value"
done
if printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q '/continuous/'; then
fail "the AppImage runtime is pinned to the moving 'continuous' tag"
fi
printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q "/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME\$" \
|| fail "APPIMAGE_RUNTIME_URL does not match the pinned tag/asset"
printf '%s' "$LOVE_SRC_URL" | grep -q "/$LOVE_VERSION/$LOVE_SRC_TARBALL\$" \
|| fail "LOVE_SRC_URL does not match LOVE_VERSION/LOVE_SRC_TARBALL"
say "checking the builder base image"
# Building on anything newer than bullseye silently raises the glibc floor and
# strands every user on an older distro, with no symptom until they run it.
grep -q '^FROM debian:bullseye$' "$SCRIPT_DIR/Dockerfile" \
|| fail "Dockerfile no longer builds on debian:bullseye (that raises the glibc floor)"
[ "$BUILDER_BASE_IMAGE" = "debian:bullseye" ] \
|| fail "BUILDER_BASE_IMAGE disagrees with the Dockerfile"
say "checking the dependency exclude list"
# Extract the live regex from the build script and classify known sonames
# through it, so a future edit cannot quietly start bundling glibc or stop
# bundling the engine's own dependencies.
EXCLUDE_RE="$(
# shellcheck disable=SC1090
grep -m1 "^EXCLUDE_RE=" "$SCRIPT_DIR/build_appimage.sh" | sed "s/^EXCLUDE_RE='//; s/'\$//"
)"
[ -n "$EXCLUDE_RE" ] || fail "could not read EXCLUDE_RE out of build_appimage.sh"
must_exclude=(libc.so.6 ld-linux-aarch64.so.1 libstdc++.so.6 libgcc_s.so.1
libGL.so.1 libEGL.so.1 libgbm.so.1 libdrm.so.2 libX11.so.6
libwayland-client.so.0 libpulse.so.0 libasound.so.2
libfreetype.so.6 libfontconfig.so.1 libpng16.so.16 libz.so.1)
must_bundle=(libSDL2-2.0.so.0 libopenal.so.1 libluajit-5.1.so.2 libmodplug.so.1
libmpg123.so.0 libogg.so.0 libvorbis.so.0 libvorbisfile.so.3
libtheoradec.so.1 liblove-11.5.so)
for soname in "${must_exclude[@]}"; do
[[ "$soname" =~ $EXCLUDE_RE ]] \
|| fail "$soname must be host-provided but the exclude list would bundle it"
done
for soname in "${must_bundle[@]}"; do
if [[ "$soname" =~ $EXCLUDE_RE ]]; then
fail "$soname is an engine dependency but the exclude list drops it"
fi
done
say "checking AppRun and the fusion contract"
# The AppImage must boot straight into the game. If AppRun ever loses --fused,
# users get vanilla LÖVE's "no game" screen instead, and nothing else catches
# that before someone downloads a release.
grep -qF -- '--fused "\$APPDIR/game.love"' "$SCRIPT_DIR/build_appimage.sh" \
|| fail "AppRun no longer launches game.love with --fused"
grep -qF 'LD_LIBRARY_PATH="\$APPDIR/lib/' "$SCRIPT_DIR/build_appimage.sh" \
|| fail "AppRun no longer puts the bundled lib directory on LD_LIBRARY_PATH"
grep -qF 'comp gzip -b 131072' "$SCRIPT_DIR/build_appimage.sh" \
|| fail "squashfs payload is no longer gzip/128K (older type-2 runtimes cannot read it)"
say "checking the linked-module assertions"
# configure exits 0 when an optional -dev package is missing and just drops the
# module, so these assertions are the only thing standing between a missing
# build dependency and a release that cannot play sound.
for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 libmodplug.so.1 \
libmpg123.so.0 libvorbisfile.so.3 libtheoradec.so.1; do
grep -qF "$soname" "$SCRIPT_DIR/build_appimage.sh" \
|| fail "build_appimage.sh no longer asserts liblove links $soname"
done
say "checking the dlopen guarantees"
# SDL2, OpenAL and libtheora are compiled from source for correctness, not for
# a newer version number: Debian's builds hard-link libpulse/libasound/libX11/
# libwayland (SDL2), libsndio (OpenAL) and libcairo (libtheora), each of which
# turns an optional runtime capability into a mandatory startup dependency.
# If a future edit drops the source build and reaches for the -dev package
# again, the AppImage silently stops starting on lean systems.
for forbidden_pkg in libsdl2-dev libtheora-dev libopenal-dev; do
if grep -qE "^ +.*\b$forbidden_pkg\b" "$SCRIPT_DIR/Dockerfile"; then
fail "Dockerfile installs $forbidden_pkg; that library is built from source on purpose"
fi
done
grep -qF -- '--enable-alsa-shared' "$SCRIPT_DIR/build_appimage.sh" \
|| fail "SDL2 is no longer configured to dlopen its audio backends"
grep -qF -- '--enable-x11-shared' "$SCRIPT_DIR/build_appimage.sh" \
|| fail "SDL2 is no longer configured to dlopen its video backends"
grep -qF 'ALSOFT_DLOPEN=ON' "$SCRIPT_DIR/build_appimage.sh" \
|| fail "openal-soft is no longer configured to dlopen its backends"
grep -qF -- '--disable-examples' "$SCRIPT_DIR/build_appimage.sh" \
|| fail "libtheora is no longer built with --disable-examples (it regains the libcairo link)"
say "checking the host dependency contract"
# The shipped objects may require nothing from the host beyond glibc,
# libstdc++ and the font stack. Everything driver-, session- or audio-related
# has to be dlopened. This is the invariant a headless CI runner proved was
# broken the first time round.
HOST_ALLOWED_RE="$(
grep -m1 "^HOST_ALLOWED_RE=" "$SCRIPT_DIR/build_appimage.sh" \
| sed "s/^HOST_ALLOWED_RE='//; s/'\$//"
)"
[ -n "$HOST_ALLOWED_RE" ] || fail "could not read HOST_ALLOWED_RE out of build_appimage.sh"
for soname in libpulse.so.0 libasound.so.2 libX11.so.6 libwayland-client.so.0 \
libGL.so.1 libcairo.so.2 libsndio.so.7.0 libdbus-1.so.3; do
if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then
fail "$soname is allowed as a hard host dependency; it must be dlopened"
fi
done
for soname in libc.so.6 libstdc++.so.6 libfreetype.so.6 libz.so.1; do
[[ "$soname" =~ $HOST_ALLOWED_RE ]] \
|| fail "$soname must be allowed as a host dependency but the contract rejects it"
done
say "checking the shared game.love payload"
temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-linux-arm64-selftest.XXXXXX")"
trap 'rm -rf "$temp_dir"' EXIT
"$ROOT/scripts/pack_love.sh" \
--output "$temp_dir/game.love" \
--listing "$temp_dir/love-listing.txt" \
--version 1.2.3 \
--dry-run >/dev/null
unzip -p "$temp_dir/game.love" src/core/Version.lua \
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|| fail "shared payload version was not stamped"
say "Linux arm64 self-test passed"