diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcb5ae92..94075d42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c6e532e..c111670d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/README.md b/README.md index cd2a3bc6..8fa851d5 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,26 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | +## 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 diff --git a/docs/linux-arm64-build.md b/docs/linux-arm64-build.md new file mode 100644 index 00000000..b02e0ced --- /dev/null +++ b/docs/linux-arm64-build.md @@ -0,0 +1,202 @@ +# Linux arm64 (aarch64) AppImage + +Releases ship `gen1recomp--linux-arm64.AppImage` alongside the +existing x86_64 `gen1recomp--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--linux-arm64.AppImage +dist/linux-arm64/gen1recomp--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`. diff --git a/docs/modding.md b/docs/modding.md index 69035af3..31721d9c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -226,5 +226,12 @@ for driving a second physical display. This is what lets a mod lay the two passes out as two stacked Game Boy screens, or push one onto a second screen, without the engine knowing the layout. +`screen.render_visible` receives `(next, state)` while the main screen is being +composed. Return `false` to omit that state from drawing, opacity selection and +palette-zone ownership. The state remains on the stack and keeps its normal +update and input ownership, so a mod can mirror a native menu on another +display without reimplementing it. The default is `true`. Treat the wrapper as +a pure predicate: the renderer may ask it more than once per frame. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/rfcs/0002-screen-render-visible.md b/docs/rfcs/0002-screen-render-visible.md new file mode 100644 index 00000000..da546bde --- /dev/null +++ b/docs/rfcs/0002-screen-render-visible.md @@ -0,0 +1,54 @@ +# RFC 0002 — Let mods hide an active screen state from the main render + +## Status + +Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests: +`screen_render_visible.lua`. + +## Motivation + +A mod can render a native menu on a companion display through +`render.compose`, but it cannot remove that menu from the main display without +also popping it. Popping transfers update and input ownership and forces the +mod to reimplement native menu behavior. + +## The decision it extends + +No prior D-number. Extends the render-hook plan in `docs/modding.md` and the +state-stack rendering contract in `docs/architecture.md`. + +## The exact API delta + +Backward-compatible, additive-only. + +### `screen.render_visible` + +New hook called with `(state) -> boolean` through the public wrapper signature +`(next, state)`. Its vanilla result is `true`. + +Returning `false` excludes the state from the main draw, from opaque-base +selection and from palette-zone ownership. It does not remove the state or +change update, input, push or pop behavior. The call sites are +`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette +walks in `Game:draw`. + +The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates +nothing. It is a pure render predicate and may be evaluated more than once per +frame. + +## Migration note for existing mods + +**Nothing.** With no subscriber every state remains visible, and the existing +state-stack, event and hook behavior is unchanged. + +## Parity tests + +- **No-mod:** the topmost opaque state still owns drawing and palette zones, + and `Runtime.wantsHook("screen.render_visible")` stays false. +- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one + opaque state and proves the state beneath draws and owns the palette while + the hidden state remains topmost and continues updating. + +## Deprecation etiquette + +Nothing deprecated. This is one additive hook with a `true` vanilla default. diff --git a/scripts/build_linux_arm64.sh b/scripts/build_linux_arm64.sh new file mode 100755 index 00000000..4df2ff00 --- /dev/null +++ b/scripts/build_linux_arm64.sh @@ -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--linux-arm64.AppImage +# dist/linux-arm64/gen1recomp--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")" diff --git a/scripts/linux-arm64/Dockerfile b/scripts/linux-arm64/Dockerfile new file mode 100644 index 00000000..ab12a3b7 --- /dev/null +++ b/scripts/linux-arm64/Dockerfile @@ -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 diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh new file mode 100755 index 00000000..53fc8fdc --- /dev/null +++ b/scripts/linux-arm64/build_appimage.sh @@ -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" < "$APPDIR/AppRun" <. 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))" diff --git a/scripts/linux-arm64/common.sh b/scripts/linux-arm64/common.sh new file mode 100755 index 00000000..39e196b4 --- /dev/null +++ b/scripts/linux-arm64/common.sh @@ -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 +)" +} diff --git a/scripts/linux-arm64/selftest_build_linux_arm64.sh b/scripts/linux-arm64/selftest_build_linux_arm64.sh new file mode 100755 index 00000000..81ac3008 --- /dev/null +++ b/scripts/linux-arm64/selftest_build_linux_arm64.sh @@ -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" diff --git a/src/core/Game.lua b/src/core/Game.lua index 11d86c5a..06856b91 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -16,6 +16,10 @@ local Screens = require("src.ui.Screens") local Game = {} +local function renderVisible(stack, state) + return state and (not stack.renderVisible or stack:renderVisible(state)) +end + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -460,7 +464,7 @@ function Game:draw() local state = self.stack.states[i] local wideState = state and state.isWideBattleLayout and state:isWideBattleLayout() - if state and state.draw then + if renderVisible(self.stack, state) and state.draw then if classicOffset ~= 0 and not wideState then love.graphics.push() love.graphics.translate(classicOffset, 0) @@ -484,7 +488,7 @@ function Game:draw() local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] - if s.sgbPalettes then + if renderVisible(self.stack, s) and s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s break diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index 898fd3dc..3a1995a7 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -39,17 +39,29 @@ function StateStack:update(dt) if top and top.update then top:update(dt) end end +local function visibleByDefault() return true end + +-- A mod may mirror a state elsewhere and hide only its main-screen render. +-- The state stays on the stack, so update and input ownership do not move. +function StateStack:renderVisible(state) + if not state then return false end + if not Runtime.wantsHook("screen.render_visible") then return true end + return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false +end + -- index of the lowest state drawn this frame (highest opaque, else 1) function StateStack:visibleBase() for i = #self.states, 1, -1 do - if self.states[i].isOpaque then return i end + local state = self.states[i] + if self:renderVisible(state) and state.isOpaque then return i end end return 1 end function StateStack:draw() for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + local state = self.states[i] + if self:renderVisible(state) and state.draw then state:draw() end end end diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 3ea51ae8..43f0fdba 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -67,8 +67,15 @@ function TrainerCard.new(game, opts) end end self.circle = tryImage("assets/generated/trainer_card/circle_tile.png") - self.pic = tryImage(require("src.pokemon.Sprites").playerPath( - game.data, "front", { kind = "trainer_card" })) + + -- Capture both return values from playerPath: path and trueColor flag. + -- The trueColor flag is set by the player.sprite hook when a mod injects + -- a custom portrait that should bypass the MEWMON palette pipeline. + local picPath, picTrueColor = require("src.pokemon.Sprites").playerPath( + game.data, "front", { kind = "trainer_card" }) + self.pic = tryImage(picPath) + self.picTrueColor = self.pic and picTrueColor or false + return self end @@ -117,7 +124,18 @@ function TrainerCard:draw() -- top card (rows 0-7): NAME / MONEY / TIME, pic upper-right self:frameBox(0, 0, 20, 8) if self.pic then + love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.pic, 104, 4) + -- True-colour portraits (e.g. mod-injected custom characters) carry their + -- own colours and must not be re-mapped by the MEWMON zone shader. + -- markTrueColor appends a colors=false zone that the Renderer splices at + -- the end of the zone list, causing it to re-blit just this rect without + -- the palette shader on top of the already-colourised frame. + -- This matches the pattern used by OakSpeech, HallOfFame and SummaryMenu. + if self.picTrueColor then + local w, h = self.pic:getDimensions() + require("src.render.PaletteFX").markTrueColor(104, 4, w, h) + end end love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("NAME/%s", save.player.name or "RED"), 16, 16) diff --git a/tests/modkit/cases/screen_render_visible.lua b/tests/modkit/cases/screen_render_visible.lua new file mode 100644 index 00000000..eb8d246a --- /dev/null +++ b/tests/modkit/cases/screen_render_visible.lua @@ -0,0 +1,101 @@ +-- screen.render_visible through the public mod API: a mirrored native screen +-- may leave the main render without leaving the active state stack. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Game = require("src.core.Game") +local Runtime = require("src.mods.Runtime") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local TouchControls = require("src.core.TouchControls") + +local FIXTURE = { + ["mods/fix_screen_mirror/manifest.json"] = [[{ + "id": "fix_screen_mirror", + "name": "Fixture Screen Mirror", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_screen_mirror/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("screen.render_visible", function(nextFn, state) + if state.screenId == "BagMenu" then return false end + return nextFn(state) + end) + ]], +} + +local savedSetUISize, savedBegin, savedEnd, savedTouch = + Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame, + TouchControls.draw +local presentedZones +Renderer.setUISize = function() end +Renderer.beginFrame = function() end +Renderer.endFrame = function(_, zones) + presentedZones = zones + return {} +end +TouchControls.draw = function() end + +local function scene() + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local base = { + isOpaque = true, + draws = 0, + draw = function(self) self.draws = self.draws + 1 end, + sgbPalettes = function() return "base zones" end, + } + local menu = { + screenId = "BagMenu", + isOpaque = true, + draws = 0, + updates = 0, + draw = function(self) self.draws = self.draws + 1 end, + update = function(self) self.updates = self.updates + 1 end, + sgbPalettes = function() return "menu zones" end, + } + stack:push(base) + stack:push(menu) + return { stack = stack, overworld = base, save = { options = {} } }, + base, menu +end + +-- no-mod parity +do + local run = T.sdk.loadNone({}) + local game, base, menu = scene() + T.eq(Runtime.wantsHook("screen.render_visible"), false, + "no subscriber leaves the render hook cold") + Game.draw(game) + T.eq(base.draws, 0, "the opaque menu still covers the state beneath") + T.eq(menu.draws, 1, "the opaque menu still draws") + T.eq(presentedZones, "menu zones", "the visible menu still owns palettes") + run.release() +end + +-- subscribed path, registered by a real fixture mod +do + local run = T.sdk.loadMods({ "mods/fix_screen_mirror" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + local game, base, menu = scene() + Game.draw(game) + T.eq(base.draws, 1, "the state beneath the hidden menu draws") + T.eq(menu.draws, 0, "the mirrored menu is omitted from the main draw") + T.eq(presentedZones, "base zones", + "a hidden state cannot own the main-screen palette") + T.check(game.stack:top() == menu, + "the hidden menu remains the active top state") + game.stack:update(1 / 60) + T.eq(menu.updates, 1, "the hidden menu keeps its update ownership") + run.release() +end + +Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame, + TouchControls.draw = savedSetUISize, savedBegin, savedEnd, savedTouch + +T.finish("screen_render_visible")