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>
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 realX.Y.Zinto the packedgame.loveonly, 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 patterngen1recomp-<version>.love(seeisPayloadNameinBoot.luaandCheck.parseRelease).sha256sums.txt-shasum -a 256output (<hex> <filename>, bare filenames) covering at least the.lovepayload.Check.parseSumstolerates 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
- 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 whoseminShellthis shell satisfies, delete stale payloads, chainload the winner (or run the bundled game if none qualifies). - 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()reportsidle | checking | uptodate | available | downloading | ready | needs_full | full_downloading | full_ready | errorplus the latest version, download progress, and (when applicable) the selected full-package asset. - Download + verify: on
available,Check.download()tells the worker to fetch the payload, polling the growing.partfile for progress. On completion the worker re-fetchessha256sums.txt, verifies the payload's sha256, and probes it withBoot.probePayloadto gate itsminShellagainst this shell'sshell. A verified, runnable payload is renamed into place and reported asready; anything else reportserrororneeds_fulland leavesupdates/clean. - Restart to apply: a
readypayload just sits inupdates/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 beforebootGame, and onreadywrites the one-shotlaunch_update.txtmarker 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_fullis never acted on unattended; it falls through and boots the game. - Native-package requirement: when
minShellorpayloadHostis incompatible, the worker writesfull-update.jsonand surfaces a persistent launcher control. Android downloads the release APK, verifies its SHA-256 entry fromsha256sums.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, withPOKEPORT_HANDHELDhonoured as a legacy fallback), which is whatCheck.hostPort()reads.
Known limitations
love.runpersists across handoff. By the timechainloadruns, the bundledlove.runhas already returned its stepper to LOVE; redefining the globallove.runfrom the payload'smain.luadoes not affect the loop already driving the frame. A payload that must changelove.runitself needs aminShellbump 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.luapath (shell out to curl) always landed onerrorand the launcher chip's "Check for updates" tap was a no-op. The worker now talks throughHostShell, the same transport as the mod catalog: curl on desktop,love.system.httpDownloadon mobile. On Android that is the GameActivity JNI/HttpsURLConnectionbridge; on iOS it isGRPickerBridge.httpDownload(URLSession). A fused sideloaded APK or IPA can therefore check GitHub and fetch the.lovepayload in-app. If neither transport exists, the worker reportsneeds_fulland the launcher chip opensCheck.releaseUrl(). Native package-only changes still need a full reinstall (minShell/payloadHostgate →needs_full). Applying a downloaded payload on Android relaunches vialove.system.restartApp; iOS still uses in-processquit("restart"). - Android full updates are user-confirmed and certificate-bound. The app
uses a private
FileProvidercache path plusIntent.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.canUpdateInPlaceis false for a working tree -- itsengineis the"0.0.0-dev"placeholder, which also always reports up to date -- soBoot.runreturns immediately andPrelaunch's--updateshortcut 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.aarch64runtime beside alovegame/source directory. A payload layers new Lua and data over that source, so a LÖVE version bump, a new required system binary, or alove.runchange still needs a full port package: theminShellgate reportsneeds_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()isfalse, soBoot.run/Checknever download.lovepayloads. 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.