mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
93e071bcaa
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>
208 lines
12 KiB
Markdown
208 lines
12 KiB
Markdown
# 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](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](../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](switch-install.md). Wire
|
|
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
|