diff --git a/build-linux-arm-sbc.sh b/build-linux-arm-sbc.sh index 2eac7e52..b440d11a 100755 --- a/build-linux-arm-sbc.sh +++ b/build-linux-arm-sbc.sh @@ -341,6 +341,9 @@ apply_cpu_governors() { export POKEPORT_HANDHELD=1 export HANDHELD=1 export PORTMASTER=1 +# Release-target marker for the self-updater: a full-package fallback here must +# offer gen1recomp--sbc-portmaster.zip, never a desktop AppImage. +export POKEPORT_PORTMASTER=1 export NINTENDO_LAYOUT=1 export XDG_DATA_HOME="$CONFDIR" export XDG_CONFIG_HOME="$CONFDIR" diff --git a/build-rg34xxsp.sh b/build-rg34xxsp.sh index 06b5ede7..966a026c 100755 --- a/build-rg34xxsp.sh +++ b/build-rg34xxsp.sh @@ -240,6 +240,9 @@ cd "$GAMEDIR" || exit 1 export XDG_DATA_HOME="$CONFDIR" export XDG_CONFIG_HOME="$CONFDIR" +# Release-target marker for the self-updater: a full-package fallback here must +# offer gen1recomp--rg34xxsp-stockos64-mod.zip, never a desktop package. +export POKEPORT_RG34XXSP=1 export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" # Mali / H700: prefer GLES where available diff --git a/docs/anbernic-rg34xxsp.md b/docs/anbernic-rg34xxsp.md index 916aa25d..84e949bc 100644 --- a/docs/anbernic-rg34xxsp.md +++ b/docs/anbernic-rg34xxsp.md @@ -40,6 +40,32 @@ Only the canonical 1 MiB US carts import: - Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` - Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` +## Updating + +The port updates itself from the launcher. When a newer release exists the +footer chip reads **Update vX.Y.Z**: press it to download the new version, then +press **Restart to update** to relaunch and start using it. Nothing inside the +port folder is rewritten, so an update that fails or is interrupted still boots +the version you already had. + +An update replaces the game's code and data. It cannot replace the LÖVE runtime +the port ships, so a release that needs a newer runtime is applied differently: +the chip then reads **Download port update** and saves +`gen1recomp--rg34xxsp-stockos64-mod.zip` into + +``` +Roms/PORTS/gen1recomp/conf/love/pokemon-love2d/updates/ +``` + +That case is finished on a computer — unzip the file and copy the +`Gen1recomp.sh` and `gen1recomp/` it contains over the copies on the SD card, +exactly as in [Install](#install) above. Your saves sit beside the game +(`portable.txt`) and are untouched by either kind of update. + +The launcher exports `POKEPORT_RG34XXSP=1`, which is how the updater knows this +is the RG34XXSP port and offers the matching port package rather than a desktop +AppImage. + ## Launcher controls | Input | Action | diff --git a/docs/linux-appimage.md b/docs/linux-appimage.md index 2a6797dc..e3873325 100644 --- a/docs/linux-appimage.md +++ b/docs/linux-appimage.md @@ -21,7 +21,7 @@ mismatches on immutable desktops. | Won't start / `libfuse.so.2` | No FUSE | `sudo apt install libfuse2` (or `libfuse2t64`) **or** `./app.AppImage --appimage-extract-and-run` | | Update check / mods / save sync fail | Host `curl` missing or broken by AppImage `LD_LIBRARY_PATH` | Install curl; current builds scrub `LD_LIBRARY_PATH` for **host** curl and keep it for a bundled AppDir curl | | Settings / sync reset after quit | Portable mode next to a read-only AppImage parent (`/opt`, system dir) | Remove `portable.txt` or move the AppImage to a writable folder; the game falls back to the XDG save dir when the probe write fails | -| "Download AppImage update" | Shell/`minShell` gate needs a full native package | Download the new `.AppImage`, `chmod +x`, replace the old file | +| "Download AppImage update" | Shell/`minShell` gate needs a full native package | Download the new `.AppImage`, `chmod +x`, replace the old file. Handheld ports show **Download port update** and fetch their own package instead — see [linux-arm-sbc.md](linux-arm-sbc.md) | | Steam / Game Mode weirdness after update | Overlay `LD_PRELOAD` / PID change | HostShell unsets `LD_PRELOAD` for children and `execv`s `$APPIMAGE` on restart | ## Network transport (HostShell) @@ -45,6 +45,13 @@ In-place updates download `gen1recomp-X.Y.Z.love` into the save directory. Full shell bumps open the matching AppImage (or Flatpak) download URL — there is no silent in-place AppImage replace yet. +PortMaster-style ports (`linux-arm-sbc`, `rg34xxsp`) are unpacked Linux builds +too, so they apply the same `.love` payloads in place, but they have no browser +in which to open a download URL. On a handheld the full-package prompt fetches +the port's own zip into the save directory for the player to re-extract; the +port is identified by `POKEPORT_PORTMASTER` / `POKEPORT_RG34XXSP`. See +[updater.md](updater.md) for the whole contract. + ## Building ```sh diff --git a/docs/linux-arm-sbc.md b/docs/linux-arm-sbc.md index e37bcb73..06dceda8 100644 --- a/docs/linux-arm-sbc.md +++ b/docs/linux-arm-sbc.md @@ -17,6 +17,32 @@ Canonical US cart SHA-1 values: - Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` - Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` +## Updating + +The port updates itself from the launcher. When a newer release exists the +footer chip reads **Update vX.Y.Z**: press it to download the new version, then +press **Restart to update** to relaunch and start using it. Nothing inside the +port folder is rewritten, so an update that fails or is interrupted still boots +the version you already had. + +An update replaces the game's code and data. It cannot replace the LÖVE runtime +the port ships, so a release that needs a newer runtime is applied differently: +the chip then reads **Download port update** and saves +`gen1recomp--sbc-portmaster.zip` into + +``` +gen1recomp-sbc/conf/love/pokemon-love2d/updates/ +``` + +That case is finished on a computer — unzip the file and copy the +`gen1recomp-sbc.sh` and `gen1recomp-sbc/` it contains over the copies on the SD +card, exactly as in [Install](#install) above. Your saves sit beside the game +(`portable.txt`) and are untouched by either kind of update. + +The launcher exports `POKEPORT_PORTMASTER=1`, which is how the updater knows +this is the PortMaster port and offers the port package rather than a desktop +AppImage. + ## Controls | Input | Action | diff --git a/docs/updater.md b/docs/updater.md index 82b85148..de5e3848 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -1,15 +1,24 @@ # Updater -A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love` -baked into the executable, but that bundled copy is only ever the *fallback*. -On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`) -looks in the save directory's `updates/` folder for a downloaded +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 `, 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 fused source for every subsequent +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. A dev/source checkout is never -fused, so `Boot.run` no-ops there and the working tree always runs itself. +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 @@ -86,9 +95,10 @@ every launch, including offline launches. Under the save directory (identity `pokemon-love2d`): ``` -updates/gen1recomp-.love downloaded payload(s) -updates/pending.txt crash-guard marker -updates/full-update.json persistent native-package requirement +updates/gen1recomp-.love downloaded payload(s) +updates/gen1recomp--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. @@ -101,7 +111,7 @@ bundled game, in that case. ## Update flow -1. **Boot** (every launch, fused builds only): crash-guard check, enumerate +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 @@ -138,9 +148,16 @@ bundled game, in that case. 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, - desktop, and PortMaster builds link their correctly named full package. - Switch keeps its native OTA flow. + 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 @@ -171,10 +188,18 @@ bundled game, in that case. 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.run` returns immediately when - `love.filesystem.isFused()` is false, and a working tree's `engine` is the - `"0.0.0-dev"` placeholder that always reports up to date, so a source - checkout is always "the game" itself; updating it means pulling the repo. +- **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** diff --git a/src/core/Prelaunch.lua b/src/core/Prelaunch.lua index ddede212..1c852c0b 100644 --- a/src/core/Prelaunch.lua +++ b/src/core/Prelaunch.lua @@ -29,8 +29,16 @@ local function updateAllowed(opts, fs) and not Platform.networkValidated() then return false end - if not (fs and fs.isFused and fs.isFused()) then return false end - return true + -- Fused and unpacked packaged builds both update; a dev checkout does not. + -- Boot owns the second half of that decision (the isDev check above is the + -- first) so the boot gate and this one cannot disagree. + if fs and fs.isFused and fs.isFused() then return true end + local okb, Boot = pcall(require, "src.update.Boot") + if okb and type(Boot) == "table" + and type(Boot.canUpdateInPlace) == "function" then + return Boot.canUpdateInPlace() and true or false + end + return false end local function syncSupported() diff --git a/src/update/Boot.lua b/src/update/Boot.lua index 43f0781f..15646f77 100644 --- a/src/update/Boot.lua +++ b/src/update/Boot.lua @@ -1,11 +1,21 @@ --- The boot shell: the heart of the self-updater. A fused build, before it --- runs the game bundled inside it, looks in its save directory for a newer --- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and --- runnable, mounts it over the bundled source and chainloads it -- so the --- binary shipped once can keep updating the Lua it runs without a reinstall. +-- The boot shell: the heart of the self-updater. A build that carries a fixed +-- engine, before it runs the game source it ships, looks in its save directory +-- for a newer payload (a downloaded gen1recomp-X.Y.Z.love), and if one is +-- present and runnable, mounts it over the bundled source and chainloads it -- +-- so the binary shipped once can keep updating the Lua it runs without a +-- reinstall. -- --- Only a fused build self-updates. A dev / source checkout IS the game, so --- Boot.run is a no-op there. +-- Two shapes of packaged build carry a fixed engine, and both self-update: +-- * fused -- the game is an archive inside the executable, so LÖVE reports +-- isFused() true (an AppImage, or the Flatpak's game.love file). +-- * unpacked -- the executable is handed a *directory* of game source +-- (`love `, which every PortMaster-style port does). LÖVE reports +-- isFused() false there, but the handoff below is still sound: the payload +-- is prepend-mounted over the running source and the source on disk is +-- never rewritten. +-- What must not self-update is a dev / source checkout: it IS the game, its +-- Version.engine is the "0.0.0-dev" placeholder, and its next launch already +-- runs whatever is on disk. Boot.run is a no-op there. See docs/updater.md. -- -- Three pieces, deliberately layered so the risky part is small and the -- decision part is testable: @@ -275,6 +285,24 @@ local function runInner(args) return chainload(chosen, args) end +-- Boot.canUpdateInPlace() -> boolean +-- +-- May this build hand off to a downloaded payload? True for a packaged build +-- (fused or unpacked -- see the header), false for a dev / source checkout, and +-- false whenever that cannot be established: the gate must never open by +-- accident. Boot.run and Prelaunch.updateAllowed both ask this one function, +-- so the boot gate and the --update gate cannot disagree. +function Boot.canUpdateInPlace() + local fs = love and love.filesystem + if not fs then return false end + if fs.isFused and fs.isFused() then return true end + local ok, Version = pcall(require, "src.core.Version") + if not ok or type(Version) ~= "table" or type(Version.isDev) ~= "function" then + return false + end + return not Version.isDev() +end + -- Boot.run(args) -> boolean -- -- The first line of love.load. True means a payload was mounted and @@ -282,7 +310,7 @@ end -- bundled game as normal. function Boot.run(args) -- Dev / source checkouts never self-update. - if not (love.filesystem.isFused and love.filesystem.isFused()) then + if not Boot.canUpdateInPlace() then return false end -- Switch (and any host without validated network): never probe payloads. diff --git a/src/update/Check.lua b/src/update/Check.lua index 2c7389b4..f3a5e509 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -151,6 +151,20 @@ function Check.releaseUrl() return "https://github.com/" .. Check.REPO .. "/releases/latest" end +-- The release target's "port" tag, or nil on a desktop package. The handheld +-- launchers export the marker (build-linux-arm-sbc.sh sets +-- POKEPORT_PORTMASTER, build-rg34xxsp.sh sets POKEPORT_RG34XXSP). +-- POKEPORT_HANDHELD is honoured as a fallback because the SBC launcher has +-- always exported it, so packs built before the explicit marker existed still +-- identify themselves as the PortMaster port instead of a desktop Linux build. +function Check.hostPort() + if type(os.getenv) ~= "function" then return nil end + if os.getenv("POKEPORT_PORTMASTER") then return "portmaster" end + if os.getenv("POKEPORT_RG34XXSP") then return "rg34xxsp" end + if os.getenv("POKEPORT_HANDHELD") == "1" then return "portmaster" end + return nil +end + local worker -- the love.thread, once started local cmdCh, stateCh -- the two channels local workerReady -- nil = untried, true = running, false = unavailable @@ -160,7 +174,7 @@ local cache = { status = "idle" } -- newest snapshot from the worker local function target() local osName = love and love.system and love.system.getOS and love.system.getOS() or nil local arch = jit and jit.arch or nil - local port = os.getenv("POKEPORT_PORTMASTER") + local port = Check.hostPort() return { os = osName, arch = arch, port = port } end @@ -297,6 +311,19 @@ function Check.fullUpdateAction() if name:find("%.flatpak$") or os.getenv("FLATPAK_ID") then return { label = "Download Flatpak update", url = st.full.url } end + if Check.hostPort() then + -- A handheld has no browser to open a download in, so fetch the port + -- package into the save directory instead of offering an AppImage that + -- could not run here. The player then re-extracts it over the port + -- folder (see docs/linux-arm-sbc.md). + if st.status == "full_ready" then + return { label = "Update package ready", url = Check.releaseUrl() } + end + if ensureWorker() then + return { label = "Download port update", kind = "download" } + end + return { label = "Open releases", url = Check.releaseUrl() } + end return { label = "Download AppImage update", url = st.full.url } elseif type(st.full) == "table" and type(st.full.url) == "string" then return { label = "Download full update", url = st.full.url } diff --git a/tests/engine/update_boot_host_gate.lua b/tests/engine/update_boot_host_gate.lua new file mode 100644 index 00000000..22a30b87 --- /dev/null +++ b/tests/engine/update_boot_host_gate.lua @@ -0,0 +1,106 @@ +-- The self-updater's host gate: which builds may hand off to a downloaded +-- payload. A packaged build updates in place -- fused (AppImage, Flatpak's +-- game.love) or unpacked (every PortMaster-style port, which runs +-- `love ` and so reports isFused() false). A dev / source checkout must +-- not, and an unknown host must fail closed. +-- +-- This gate is the difference between the PortMaster SBC port applying an +-- update and silently ignoring it after the launcher already said "Restart to +-- update", so the unpacked case is pinned here. +-- luajit tests/engine/update_boot_host_gate.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Boot = require("src.update.Boot") +local love_stub = require("tests.love_stub") + +local VERSION_KEY = "src.core.Version" + +-- A packaged build stamps a real engine (X.Y.Z); a working tree keeps the +-- "0.0.0-dev" placeholder, which is what Version.isDev() reports. +local function withVersion(isDev, fn) + local saved = package.loaded[VERSION_KEY] + package.loaded[VERSION_KEY] = { isDev = function() return isDev end } + local ok, err = pcall(fn) + package.loaded[VERSION_KEY] = saved + if not ok then error(err, 0) end +end + +-- Stand in for the running LÖVE process: nil (no love at all) or a filesystem +-- that reports the given fused state. +local function withLove(fs, fn) + local saved = _G.love + _G.love = fs and { filesystem = fs } or nil + local ok, err = pcall(fn) + _G.love = saved + if not ok then error(err, 0) end +end + +local function fsWith(isFused) + if isFused == nil then return {} end + return { isFused = function() return isFused end } +end + +-- no love at all (headless host): never update +withLove(nil, function() + eq(Boot.canUpdateInPlace(), false, "no love.filesystem -> no in-place update") +end) + +-- fused wins outright: a binary carrying its own game archive is a packaged +-- build whatever the engine string says +withLove(fsWith(true), function() + withVersion(false, function() + eq(Boot.canUpdateInPlace(), true, "fused packaged build updates in place") + end) + withVersion(true, function() + eq(Boot.canUpdateInPlace(), true, "fused build updates even with a dev engine string") + end) +end) + +-- the fix: an unpacked packaged build (PortMaster SBC / RG34XXSP) is not fused, +-- but it carries a released engine and its source on disk is never rewritten, +-- so a downloaded payload can be mounted over it +withLove(fsWith(false), function() + withVersion(false, function() + eq(Boot.canUpdateInPlace(), true, + "unpacked released build (love ) updates in place") + end) + withVersion(true, function() + eq(Boot.canUpdateInPlace(), false, + "unpacked dev checkout (engine 0.0.0-dev) never updates") + end) +end) + +-- a filesystem that cannot answer the fused question at all still updates when +-- the engine proves this is a packaged build +withLove(fsWith(nil), function() + withVersion(false, function() + eq(Boot.canUpdateInPlace(), true, + "released build without isFused() still updates") + end) +end) + +-- fail closed: an unreadable / malformed Version must not open the gate +withLove(fsWith(false), function() + local saved = package.loaded[VERSION_KEY] + package.loaded[VERSION_KEY] = "not a module" + eq(Boot.canUpdateInPlace(), false, "non-table Version -> no in-place update") + package.loaded[VERSION_KEY] = { engine = "1.4.2" } + eq(Boot.canUpdateInPlace(), false, "Version without isDev -> no in-place update") + package.loaded[VERSION_KEY] = nil + eq(Boot.canUpdateInPlace(), false, "unloadable Version -> no in-place update") + package.loaded[VERSION_KEY] = saved +end) + +-- Boot.run is the first line of love.load: in a dev checkout it must return +-- false immediately, leaving the bundled game to boot as normal +withLove(love_stub.filesystem, function() + withVersion(true, function() + eq(Boot.run(nil), false, "Boot.run is a no-op in a dev checkout") + end) +end) + +T.finish("update_boot_host_gate") diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index b24139e9..af016341 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -47,11 +47,40 @@ eq(Check.fullAssetName("1.4.2", "Linux", "x64"), "gen1recomp-1.4.2-linux-x86_64.AppImage", "Linux x86_64 AppImage mapping") eq(Check.fullAssetName("1.4.2", "Linux", "x64", "flatpak"), "gen1recomp-1.4.2-linux.flatpak", "Linux Flatpak package mapping") +-- Handheld ports run aarch64 Linux, so the port tag must win over the arch +-- mapping: a desktop AppImage cannot be installed on a PortMaster device. +eq(Check.fullAssetName("1.4.2", "Linux", "aarch64", "portmaster"), + "gen1recomp-1.4.2-sbc-portmaster.zip", "PortMaster SBC package mapping") +eq(Check.fullAssetName("1.4.2", "Linux", "aarch64", "rg34xxsp"), + "gen1recomp-1.4.2-rg34xxsp-stockos64-mod.zip", "RG34XXSP package mapping") eq(Check.fullAssetName("1.4.2", "iOS", "arm64"), "gen1recomp++-1.4.2-ios.ipa", "iOS package mapping") eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil, "invalid full-package version rejected") +-- The port tag comes from the launcher's environment. POKEPORT_HANDHELD is the +-- marker the SBC launcher has always exported, so a pack built before the +-- explicit POKEPORT_PORTMASTER existed is still identified as the PortMaster +-- port rather than a desktop Linux build. +do + local realGetenv = os.getenv + local function portWith(vars) + os.getenv = function(name) return vars[name] end + local port = Check.hostPort() + os.getenv = realGetenv + return port + end + eq(portWith({ POKEPORT_PORTMASTER = "1" }), "portmaster", + "PortMaster launcher marker identifies the SBC port") + eq(portWith({ POKEPORT_RG34XXSP = "1" }), "rg34xxsp", + "RG34XXSP launcher marker identifies that port") + eq(portWith({ POKEPORT_HANDHELD = "1" }), "portmaster", + "legacy handheld marker identifies the SBC port") + eq(portWith({ PORTMASTER = "1" }), nil, + "PORTMASTER alone is not the release-target marker") + eq(portWith({}), nil, "a desktop Linux build has no port tag") +end + -- A legacy Android shell needs one manual package update even when its -- downloaded payload already reports the latest engine version. eq(Check.androidNeedsInstallerBootstrap("Android", false), true,