Files
Shane McGovern 93e071bcaa Fix self-update on the PortMaster SBC build
The SBC and RG34XXSP ports hand LÖVE a source *directory*
(`love <dir>`), so `love.filesystem.isFused()` is false there and
`Boot.run` bailed on its first line.  The launcher still offered
"Update vX.Y.Z" and "Restart to update", so an update downloaded,
verified, and then was silently ignored forever.

- Boot.canUpdateInPlace() replaces the fused-only gate: a packaged
  build updates whether it is fused (AppImage, Flatpak game.love) or
  unpacked, and only a dev / source checkout (engine "0.0.0-dev") is
  excluded.  Fails closed when the host cannot be established.
  Prelaunch.updateAllowed now delegates to it, so the boot gate and the
  `--update` gate cannot disagree.
- Check.hostPort() reads the release-target marker, with
  POKEPORT_HANDHELD as a legacy fallback (the SBC launcher has always
  exported it, so packs predating this change still identify
  themselves).  The marker was read from the environment but never
  exported, so a full-package fallback on a handheld resolved to a
  desktop AppImage that cannot run there.
- A handheld now fetches its own package ("Download port update",
  reusing the worker's cross-platform download_full) instead of
  offering a URL it has no browser to open, and reports "Update package
  ready" once it is in the save directory for a manual re-extract.
- Launchers export POKEPORT_PORTMASTER / POKEPORT_RG34XXSP.

An in-place update only ever mounts a payload over the running source,
so relaxing the gate is less invasive than the fused path it joins.
A runtime bump still needs a full package, which the minShell gate
already reports as needs_full.

Tests: tests/engine/update_boot_host_gate.lua (new, 10 checks) pins the
gate; update_check_tests.lua pins both port asset names and hostPort().
scripts/test.sh --quick: all tiers passed; scripts/lint.sh: 0 errors.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-19 22:15:02 +01:00

12 KiB

Updater

A packaged build carries a fixed engine plus the game source it starts from, and that source is only ever the fallback. It ships in one of two shapes: fused (a game.love baked into the executable, so LÖVE reports love.filesystem.isFused() true) or unpacked (a source directory the launcher hands to the runtime, love <dir>, which is what every PortMaster-style port does and which reports isFused() false). On every launch, before anything else runs, Boot.run (src/update/Boot.lua) looks in the save directory's updates/ folder for a downloaded gen1recomp-X.Y.Z.love payload that is both strictly newer than the bundled engine version and runnable on this shell. If one qualifies, it is mounted over / (so its files win over the bundled source for every subsequent require) and chainloaded in place: the payload's main.lua and love.load run as if they had shipped in the executable. Nothing on disk is rewritten either way -- an update is a mount layered over the source -- so the handoff is sound for a fused archive and for an unpacked directory alike. Boot.canUpdateInPlace decides whether a build may update at all: a fused build always may, an unpacked build may when its engine is a released X.Y.Z, and a dev/source checkout never may (its engine is the "0.0.0-dev" placeholder, so the working tree always runs itself).

The pieces are deliberately layered so the risky part is small. Boot.select is a pure function (no love.* calls) that, given probed candidates and the bundled engine/shell, decides what to run and what stale payloads to delete. Boot.probePayload mounts one archive at an isolated mountpoint and reads its src/core/Version.lua with loadstring (never require, so it is never cached as a module) to learn its engine and minShell. Boot.run orchestrates the crash guard, enumeration, selection, and the mount + chainload, with full rollback on any failure. Checking for and fetching a new payload is a separate, slower path: src/update/Check.lua is a thin main-thread state machine the launcher screen polls, while the curl calls, JSON parsing, and sha256 verification run on a background love.thread (src/update/check_worker.lua) so a hung network call never blocks a frame.

Version.lua fields

src/core/Version.lua carries four fields the updater reads directly (the existing modApi, linkProtocol, saveFormat, and cache fields are untouched):

  • engine - the semver release, e.g. "1.4.0". The repo default is the "0.0.0-dev" placeholder; CI stamps the real X.Y.Z into the packed game.love only, never the working tree. A "0.0.0-dev" engine always reports itself up to date (it never chases a release, and it never counts as a valid payload to chainload).
  • shell - the native-shell contract this build's fused executable implements.
  • payloadHost - the native host family an in-place payload targets. Ordinary LÖVE packages use "love". A specialized native package uses a distinct, stable identifier and accepts only payloads carrying that same identifier. A missing field defaults to "love", preserving compatibility with payloads released before this field existed.
  • minShell - the lowest shell contract required to run this payload.

Bump minShell only when a payload needs something the currently-shipped native shell cannot provide, for example a LOVE version bump, a new required system binary, or a change to love.run itself (see Known limitations below). An older shell refuses to chainload a payload whose minShell exceeds the shell it provides; Boot.select keeps that payload in updates/ rather than deleting it, in case a future shell upgrade can run it, and Check's worker reports needs_full so the player is pointed at a full installer instead. Do not bump minShell for an ordinary Lua/data release; that is exactly the case the updater exists to avoid a reinstall for.

Change payloadHost only when the packaged Lua depends on a different native host family. This is separate from minShell: the host name answers which native integration the payload targets, while the shell number answers which revision of that integration it requires. A mismatched-host payload is never mounted or deleted as stale; the launcher directs the player to a full package.

Release assets

Each tagged release vX.Y.Z carries the existing per-platform archives (gen1recomp-X.Y.Z-macos.zip, -windows.zip, -linux-x86_64.AppImage, -linux-arm64.AppImage, -linux.flatpak, -android.apk, -ios.ipa, -switch.zip, Xbox and PortMaster archives) plus two assets the updater itself consumes:

  • gen1recomp-X.Y.Z.love - the payload, matched by the exact pattern gen1recomp-<version>.love (see isPayloadName in Boot.lua and Check.parseRelease).
  • sha256sums.txt - shasum -a 256 output (<hex> <filename>, bare filenames) covering at least the .love payload. Check.parseSums tolerates a leading * binary marker and a ./ prefix but expects the filename otherwise to match the asset name exactly.

A release missing either asset is treated as "no in-place update available": Check reports needs_full. It also selects the exact current platform asset from the same release and persists the requirement, so it is visible again on every launch, including offline launches.

Save-directory layout

Under the save directory (identity pokemon-love2d):

updates/gen1recomp-<X.Y.Z>.love                 downloaded payload(s)
updates/gen1recomp-<X.Y.Z>-sbc-portmaster.zip   downloaded port package (ports)
updates/pending.txt                             crash-guard marker
updates/full-update.json                        persistent native-package requirement

pending.txt holds the filename of the payload currently being chainloaded. Boot.run's chainload writes it immediately before mounting, and removes it on both a successful handoff and a clean rollback. If it is still present the next time Boot.run starts, the previous boot died mid-handoff, so that named payload is distrusted: it and the marker are deleted before candidates are enumerated. Boot may still fall back to an older valid payload, or to the bundled game, in that case.

Update flow

  1. Boot (every launch, packaged builds only): crash-guard check, enumerate and probe every updates/*.love, pick the highest engine that is strictly newer than the bundled one and whose minShell this shell satisfies, delete stale payloads, chainload the winner (or run the bundled game if none qualifies).
  2. Check (launcher screen): Check.start() kicks off an async check against the GitHub releases API; safe to call every frame, it is a no-op once a check is in flight or has reached a terminal state. Check.state() reports idle | checking | uptodate | available | downloading | ready | needs_full | full_downloading | full_ready | error plus the latest version, download progress, and (when applicable) the selected full-package asset.
  3. Download + verify: on available, Check.download() tells the worker to fetch the payload, polling the growing .part file for progress. On completion the worker re-fetches sha256sums.txt, verifies the payload's sha256, and probes it with Boot.probePayload to gate its minShell against this shell's shell. A verified, runnable payload is renamed into place and reported as ready; anything else reports error or needs_full and leaves updates/ clean.
  4. Restart to apply: a ready payload just sits in updates/ until the player relaunches; the next launch's Boot step (1) is what actually mounts and runs it. There is no in-session hot-swap. A shortcut launched with --update (src/core/Prelaunch.lua, #1657) is the one path that does this without the launcher on screen: it runs steps 2-3 before bootGame, and on ready writes the one-shot launch_update.txt marker and restarts, so the fresh boot chainloads the payload in step 1 and then boots the game. The marker is consumed on that next launch, so a payload that will not apply cannot restart-loop the shortcut. needs_full is never acted on unattended; it falls through and boots the game.
  5. Native-package requirement: when minShell or payloadHost is incompatible, the worker writes full-update.json and surfaces a persistent launcher control. Android downloads the release APK, verifies its SHA-256 entry from sha256sums.txt, then invokes Android's Package Installer. The installer asks the user for consent and enforces package, version-code, and signing-certificate compatibility. A legacy APK without the installer bridge links its full package for one manual bootstrap update, including when its downloaded payload already reports the latest engine version. iOS links the sideload repository for a re-sideload; Xbox and desktop builds link their correctly named full package. A PortMaster-style port has no browser to open a download in, so it fetches its own package (-sbc-portmaster.zip, -rg34xxsp-stockos64-mod.zip) into the save directory through the same verified download, and the player re-extracts it over the port folder — see linux-arm-sbc.md. Switch keeps its native OTA flow. Which package a port offers comes from the launcher's environment (POKEPORT_PORTMASTER, POKEPORT_RG34XXSP, with POKEPORT_HANDHELD honoured as a legacy fallback), which is what Check.hostPort() reads.

Known limitations

  • love.run persists across handoff. By the time chainload runs, the bundled love.run has already returned its stepper to LOVE; redefining the global love.run from the payload's main.lua does not affect the loop already driving the frame. A payload that must change love.run itself needs a minShell bump so an older shell refuses to chainload it rather than running with half its intended behavior.
  • Android and iOS use the native download bridge, not curl. Neither platform ships curl, so the old check_worker.lua path (shell out to curl) always landed on error and the launcher chip's "Check for updates" tap was a no-op. The worker now talks through HostShell, the same transport as the mod catalog: curl on desktop, love.system.httpDownload on mobile. On Android that is the GameActivity JNI/HttpsURLConnection bridge; on iOS it is GRPickerBridge.httpDownload (URLSession). A fused sideloaded APK or IPA can therefore check GitHub and fetch the .love payload in-app. If neither transport exists, the worker reports needs_full and the launcher chip opens Check.releaseUrl(). Native package-only changes still need a full reinstall (minShell / payloadHost gate → needs_full). Applying a downloaded payload on Android relaunches via love.system.restartApp; iOS still uses in-process quit("restart").
  • Android full updates are user-confirmed and certificate-bound. The app uses a private FileProvider cache path plus Intent.ACTION_INSTALL_PACKAGE, checks Android 8+'s per-app "install unknown apps" setting, and never requests a silent install. The release job must use the original long-lived Android signing key; a new key causes Android to reject an in-place update and requires a one-time manual reinstall. See mobile/ANDROID.md.
  • Dev/source runs never self-update. Boot.canUpdateInPlace is false for a working tree -- its engine is the "0.0.0-dev" placeholder, which also always reports up to date -- so Boot.run returns immediately and Prelaunch's --update shortcut refuses to act. A source checkout is always "the game" itself; updating it means pulling the repo.
  • An unpacked port updates the Lua, not the runtime. The PortMaster SBC and RG34XXSP packages ship a fixed love.aarch64 runtime beside a lovegame/ source directory. A payload layers new Lua and data over that source, so a LÖVE version bump, a new required system binary, or a love.run change still needs a full port package: the minShell gate reports needs_full, the chip fetches the port package into the save directory, and the player re-extracts it over the port folder.
  • Nintendo Switch does not use this LÖVE self-updater. On NX, Platform.networkValidated() is false, so Boot.run / Check never download .love payloads. In-console OTA uses the native OTA launcher (DEVKITPRO), documented in switch-install.md. Wire format: src/update/SwitchOta.lua. Manual zip install remains the fallback.