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>
This commit is contained in:
Shane McGovern
2026-09-19 22:15:02 +01:00
parent 2359287036
commit 93e071bcaa
11 changed files with 318 additions and 30 deletions
+3
View File
@@ -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-<v>-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"
+3
View File
@@ -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-<v>-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
+26
View File
@@ -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-<version>-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 |
+8 -1
View File
@@ -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
+26
View File
@@ -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-<version>-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 |
+43 -18
View File
@@ -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 <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 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-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker
updates/full-update.json persistent native-package requirement
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.
@@ -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**
+10 -2
View File
@@ -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()
+36 -8
View File
@@ -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 <dir>`, 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.
+28 -1
View File
@@ -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 }
+106
View File
@@ -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 <dir>` 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 <dir>) 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")
+29
View File
@@ -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,