From ee5c168983a64ef425566ab26a4656aabe509e6b Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 4 Aug 2026 13:34:36 +0100 Subject: [PATCH 1/6] Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them A FIND MODS row now shows download/date stats even when its feed publishes none: the row fetches the mod's own GitHub releases through the same cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache, so an installed mod's repo is instant). Feed-published stats still win when present; otherwise one repo is fetched per frame -- the thumbnail budget pattern -- so opening the tab never stalls for the whole listing. ModUpdate.statsForReleases is the shared resolver. --- docs/new-features.md | 9 +++++--- src/import/LauncherView.lua | 12 ++++++---- src/import/RomImporter.lua | 38 +++++++++++++++++++++++++++++++ src/mods/ModUpdate.lua | 14 ++++++++++++ tests/engine/mod_update_tests.lua | 19 ++++++++++++++++ 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/docs/new-features.md b/docs/new-features.md index 70d15d2a..ea28584b 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -461,9 +461,12 @@ one; paste an index URL or its `owner/repo` and it is remembered in A feed author can publish per-mod release stats by adding three optional fields to an entry -- `downloads` (total across every release), and `first_release` / `last_release` (ISO days) -- which the listing shows in -the same gold line the MODS tab uses. The fields are additive: feeds that -carry them stay readable by every build that predates them, and feeds that -do not render exactly as before. +the same gold line the MODS tab uses. When a feed does not carry them, +the row fetches the mod's own GitHub releases instead -- the same cached +`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats +appear for any mod with a `github` field regardless of feed maintenance. +The fields are additive: feeds that carry them stay readable by every +build that predates them, and feeds that do not render exactly as before. ## Soft reset (all versions) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 3f92176b..ed864f3f 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1291,6 +1291,7 @@ end local function buildFindPanel(imp, parent, m) imp._findThumbFetched = false + imp._findStatsFetched = false imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") @@ -1437,12 +1438,13 @@ local function buildFindPanel(imp, parent, m) local btnH = math.ceil(textHeight(chipSize)) + 14 for _, entry in ipairs(rows) do local action, note = findActionFor(entry, installed[entry.id]) - -- Feed-published release stats (downloads, first/last release date) in - -- the same gold line the MODS tab uses; absent until a feed carries them. + -- Release stats for the row: feed-published when the feed carries + -- them, otherwise fetched from the mod's GitHub repo (one per frame, + -- cached six hours) exactly like the MODS tab does. + local stats = imp:_findStats(entry) local statsLine - if entry.downloads ~= nil or entry.first_release or entry.last_release then - statsLine = ModUpdate.statsLine(entry.downloads, - entry.first_release, entry.last_release) + if stats and (stats.total ~= nil or stats.first or stats.latest) then + statsLine = ModUpdate.statsLine(stats.total, stats.first, stats.latest) end local bodyH = math.ceil(textHeight(titleSize)) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 429ceb3e..364105d0 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3025,6 +3025,44 @@ function RomImporter:_findThumb(entry) return ok and image or nil end +-- Release stats for a FIND MODS row, resolved the same way the MODS tab +-- does it: the mod's own GitHub releases through ModUpdate's cached fetch, +-- so an installed mod's repo is instant and every result lands in +-- options.modUpdateCache for six hours. A feed that publishes stats wins +-- outright (fresher, zero network); otherwise the repo is fetched, one +-- entry per frame so opening the tab cannot stall for the whole listing. +-- The result is memoized per id for the session; a repo with no releases +-- or a failed fetch resolves to an empty table so it is tried once. +function RomImporter:_findStats(entry) + self._findStats = self._findStats or {} + local cached = self._findStats[entry.id] + if cached then return cached end + if entry.downloads ~= nil or entry.first_release or entry.last_release then + cached = { total = entry.downloads, first = entry.first_release, + latest = entry.last_release, done = true } + self._findStats[entry.id] = cached + return cached + end + if self._findStatsFetched then return nil end -- budget spent this frame + if not entry.github or entry.github == "" then + cached = { done = true } + self._findStats[entry.id] = cached + return cached + end + self._findStatsFetched = true + local ModUpdate = require("src.mods.ModUpdate") + local ok, releases = pcall(function() + local list, err = ModUpdate.fetchReleases(entry.github, entry.id, {}) + if not list then error(tostring(err), 0) end + return list + end) + local stats = ok and ModUpdate.statsForReleases(releases) or nil + cached = { total = stats and stats.total, first = stats and stats.first, + latest = stats and stats.latest, done = true } + self._findStats[entry.id] = cached + return cached +end + -- Open the "add an index" text prompt. Deliberately a typed URL rather than a -- picked-from-a-list affair: there is no blessed index, and presenting one -- would make the launcher's choice look like an endorsement. diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua index 774e1a6c..d9051663 100644 --- a/src/mods/ModUpdate.lua +++ b/src/mods/ModUpdate.lua @@ -229,6 +229,20 @@ function ModUpdate.releaseDates(releases) return { first = first, latest = latest } end +-- One resolver over a release list: { total, first, latest } or nil when +-- the list carries neither counts nor dates. The FIND MODS rows use this +-- on the repo's fetched releases, the same source the MODS tab trusts. +function ModUpdate.statsForReleases(releases) + local dl = ModUpdate.totalDownloads(releases) + local d = ModUpdate.releaseDates(releases) + if not dl and not d then return nil end + return { + total = dl and dl.total or nil, + first = d and d.first or nil, + latest = d and d.latest or nil, + } +end + -- Thousands-separated count for the launcher ("12,345"), plain for small -- numbers. Never throws; garbage in, "0" out. function ModUpdate.formatCount(n) diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua index 1e3bb9c3..d6ff3239 100644 --- a/tests/engine/mod_update_tests.lua +++ b/tests/engine/mod_update_tests.lua @@ -253,4 +253,23 @@ do HostShell.canFetch, HostShell.httpGet = realCanFetch, realHttpGet end +-- statsForReleases: one resolver over a release list, the FIND MODS path +do + local stats = ModUpdate.statsForReleases({ + { version = "1.0.0", downloads = 41, published = "2024-05-31" }, + { version = "1.1.0", downloads = 9, published = "2025-11-02" }, + }) + eq(stats.total, 50, "total downloads across releases") + eq(stats.first, "2024-05-31", "first release date") + eq(stats.latest, "2025-11-02", "latest release date") + check(ModUpdate.statsForReleases({ { version = "1.0.0" } }) == nil, + "a list with neither counts nor dates resolves to nil") + check(ModUpdate.statsForReleases(nil) == nil, "nil resolves to nil") + local datesOnly = ModUpdate.statsForReleases({ + { version = "1.0.0", published = "2024-05-31" }, + }) + eq(datesOnly.total, nil, "dates without counts keep total nil") + eq(datesOnly.first, "2024-05-31", "but keep the date") +end + print("ok mod_update_tests") From d35b9e5c3eca6cbebcb94310ab3f184a5cb30d3c Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 4 Aug 2026 13:36:30 +0100 Subject: [PATCH 2/6] Fix crash opening the Find Mods tab: rename the stats cache field The resolver stored results in self._findStats, which collides with the method of the same name: self._findStats resolves through the metatable to the function, so the or {} guard never fired and indexing it crashed the launcher the moment the panel built. State now lives in _findStatsCache. --- src/import/RomImporter.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 364105d0..5320085f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3034,19 +3034,19 @@ end -- The result is memoized per id for the session; a repo with no releases -- or a failed fetch resolves to an empty table so it is tried once. function RomImporter:_findStats(entry) - self._findStats = self._findStats or {} - local cached = self._findStats[entry.id] + self._findStatsCache = self._findStatsCache or {} + local cached = self._findStatsCache[entry.id] if cached then return cached end if entry.downloads ~= nil or entry.first_release or entry.last_release then cached = { total = entry.downloads, first = entry.first_release, latest = entry.last_release, done = true } - self._findStats[entry.id] = cached + self._findStatsCache[entry.id] = cached return cached end if self._findStatsFetched then return nil end -- budget spent this frame if not entry.github or entry.github == "" then cached = { done = true } - self._findStats[entry.id] = cached + self._findStatsCache[entry.id] = cached return cached end self._findStatsFetched = true @@ -3059,7 +3059,7 @@ function RomImporter:_findStats(entry) local stats = ok and ModUpdate.statsForReleases(releases) or nil cached = { total = stats and stats.total, first = stats and stats.first, latest = stats and stats.latest, done = true } - self._findStats[entry.id] = cached + self._findStatsCache[entry.id] = cached return cached end From 0f24105686ba446a3aa484e05079c570754a058e Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 4 Aug 2026 13:37:48 +0100 Subject: [PATCH 3/6] Fix Find Mods crash: require ModUpdate in the find panel buildFindPanel called ModUpdate.statsLine without a local require -- only buildModsPanel had one -- so opening the tab indexed a nil global. --- src/import/LauncherView.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index ed864f3f..0fcb6da1 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1295,6 +1295,7 @@ local function buildFindPanel(imp, parent, m) imp:_ensureFind() imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") + local ModUpdate = require("src.mods.ModUpdate") local sources = imp.findSources or {} local rows = imp:_findRows() local total = #((imp.findIndex and imp.findIndex.mods) or {}) From 1ef0d8c2c05630b40ac8fc6f28061cbf25dde898 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 4 Aug 2026 13:39:36 +0100 Subject: [PATCH 4/6] Retry Find Mods stats after failed repo fetches A failed repo fetch (hourly GitHub API rate limit, transient network error) was memoized as resolved, so a rate-limited first visit left those rows empty for the whole session. Failures now schedule a 60s retry; a 404 is still permanent so a renamed or vanished repo is fetched once. --- src/import/RomImporter.lua | 39 +++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5320085f..e1516012 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2220,7 +2220,16 @@ end -- renders it. Required lazily so a headless test require of this module -- never loads the UI toolkit. function RomImporter:draw() - require("src.import.LauncherView").draw(self) + local ok, err = pcall(require("src.import.LauncherView").draw, self) + if not ok then + local f = io.open("/tmp/launcher-crash.log", "a") + if f then + f:write(os.date("%H:%M:%S") .. " " .. tostring(err) .. "\n") + f:write(debug.traceback("", 2) .. "\n") + f:close() + end + error(err, 0) + end end -- Nothing in the launcher can undo a delete, so every Delete control asks @@ -3036,7 +3045,12 @@ end function RomImporter:_findStats(entry) self._findStatsCache = self._findStatsCache or {} local cached = self._findStatsCache[entry.id] - if cached then return cached end + if cached then + if cached.done or (cached.retryAt and os.time() < cached.retryAt) then + return cached + end + self._findStatsCache[entry.id] = nil -- retry window open, refetch + end if entry.downloads ~= nil or entry.first_release or entry.last_release then cached = { total = entry.downloads, first = entry.first_release, latest = entry.last_release, done = true } @@ -3051,14 +3065,21 @@ function RomImporter:_findStats(entry) end self._findStatsFetched = true local ModUpdate = require("src.mods.ModUpdate") - local ok, releases = pcall(function() - local list, err = ModUpdate.fetchReleases(entry.github, entry.id, {}) - if not list then error(tostring(err), 0) end - return list + local list, fetchErr + local ok = pcall(function() + list, fetchErr = ModUpdate.fetchReleases(entry.github, entry.id, {}) end) - local stats = ok and ModUpdate.statsForReleases(releases) or nil - cached = { total = stats and stats.total, first = stats and stats.first, - latest = stats and stats.latest, done = true } + local stats = list and ModUpdate.statsForReleases(list) or nil + if stats then + cached = { total = stats.total, first = stats.first, + latest = stats.latest, done = true } + else + -- A repo that does not exist is permanent; every other failure (the + -- hourly API rate limit, a hiccup) is retried in a minute so rows can + -- recover without restarting the launcher. + local permanent = tostring(fetchErr):find("Not Found", 1, true) ~= nil + cached = { done = permanent, retryAt = os.time() + 60 } + end self._findStatsCache[entry.id] = cached return cached end From eec396e38824467ba485de314ba34431a9e54d3f Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 4 Aug 2026 15:11:55 +0100 Subject: [PATCH 5/6] Add the MODS tab sort options to the Find Mods tab --- src/import/LauncherView.lua | 74 +++++++++++++++++++++++++++++++++++++ src/import/RomImporter.lua | 11 +----- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 0fcb6da1..98ecd6ec 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1426,6 +1426,80 @@ local function buildFindPanel(imp, parent, m) return end + -- Sort row: Name / Popularity / Release date / Last updated, the same + -- options the MODS tab offers, sharing its persisted choice + -- (options.modSort). Data comes from the same _findStats resolution the + -- cards use (feed-published, else the repo fetch); rows whose stats have + -- not resolved yet sink to the bottom of data sorts and rise as the + -- one-per-frame fetches complete. + local sortKey = imp.modSort or "name" + if imp.modSort == nil then + local ok, opts = pcall(require("src.core.SaveData").loadOptions) + if ok and type(opts) == "table" and type(opts.modSort) == "string" then + sortKey = opts.modSort + imp.modSort = sortKey + end + end + local sortRow = mk({ parent = parent, width = "100%", + positioning = "flex", flexDirection = "horizontal", + flexWrap = "wrap", alignItems = "center", gap = 6 * m.s }) + label(sortRow, Strings("Sort:"), 11 * m.s + 2, C("detail"), { textWrap = false }) + local sorts = { + { key = "name", label = Strings("Name") }, + { key = "popularity", label = Strings("Popularity") }, + { key = "release", label = Strings("Release date") }, + { key = "updated", label = Strings("Last updated") }, + } + for _, s in ipairs(sorts) do + local active = sortKey == s.key + local key = "find-sort-" .. s.key + mk({ + parent = sortRow, text = s.label, + textColor = active and C("green") + or (imp._hot[key] and C("white") or C("detail")), + textSize = 11 * m.s + 2, textAlign = "center-center", autoScaleText = false, + backgroundColor = active and C("green", 0.18) or C("border", 0.10), + border = 1, + borderColor = active and C("green", 0.6) or C("border", 0.35), + cornerRadius = 999, + padding = { horizontal = 10, vertical = 4 }, + onEvent = handler(imp, key, function() + imp.modSort = s.key + pcall(function() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + opts.modSort = s.key + SaveData.saveOptions(opts) + end) + end), + }) + end + + local sorted = {} + for i, v in ipairs(rows) do sorted[i] = v end + table.sort(sorted, function(a, b) + local function value(entry) + if sortKey == "name" then + return (entry.title or entry.id or ""):lower() + end + local stats = imp:_findStats(entry) + if sortKey == "popularity" then + return stats and stats.total or -1 + end + if sortKey == "release" then + return stats and stats.first or "0000-00-00" + end + return stats and stats.latest or "0000-00-00" + end + local va, vb = value(a), value(b) + if va ~= vb then + if sortKey == "name" then return va < vb end + return va > vb -- data sorts newest / most popular first + end + return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower() + end) + rows = sorted + local installed = imp:_findInstalledMap() local thumbW = 64 * m.s -- Explicit measured widths AND heights, same reasoning as the mods card: diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index e1516012..e67ae314 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2220,16 +2220,7 @@ end -- renders it. Required lazily so a headless test require of this module -- never loads the UI toolkit. function RomImporter:draw() - local ok, err = pcall(require("src.import.LauncherView").draw, self) - if not ok then - local f = io.open("/tmp/launcher-crash.log", "a") - if f then - f:write(os.date("%H:%M:%S") .. " " .. tostring(err) .. "\n") - f:write(debug.traceback("", 2) .. "\n") - f:close() - end - error(err, 0) - end + require("src.import.LauncherView").draw(self) end -- Nothing in the launcher can undo a delete, so every Delete control asks From 9f54734f62d7683f04af8e1ced1909867d1cddee Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 10 Aug 2026 00:19:52 +0100 Subject: [PATCH 6/6] Add generic Linux ARM SBC PortMaster build Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 27 ++- build-linux-arm-sbc.sh | 366 ++++++++++++++++++++++++++++++++++ docs/linux-arm-sbc.md | 54 +++++ install-linux-arm-sbc.sh | 93 +++++++++ 4 files changed, 537 insertions(+), 3 deletions(-) create mode 100755 build-linux-arm-sbc.sh create mode 100644 docs/linux-arm-sbc.md create mode 100755 install-linux-arm-sbc.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8568f37f..1671a324 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ name: Release # Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS -# IPA, a Nintendo Switch SD-ready zip (experimental), and the Anbernic RG34XXSP -# (Stock OS 64-bit MOD / PortMaster) port on the self-hosted Mac runner, and -# publishes them as a GitHub Release. +# IPA, a Nintendo Switch SD-ready zip (experimental), the Anbernic RG34XXSP +# (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster +# handheld ports on the self-hosted Mac runner, and publishes them as a +# GitHub Release. # # Versioning: # - First ever release is 0.1.0. @@ -215,6 +216,20 @@ jobs: # runtime from PortMaster-GUI, so it needs no signing/notarization. ./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}" + - name: Build Linux ARM SBC PortMaster port + env: + # The release workflow must package the commit being released. The + # script defaults to the latest published release for standalone + # builds, while this explicit local override keeps CI source-aligned. + GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }} + GEN1RECOMP_RELEASE_TAG: v${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + # Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build + # keeps its own cache because the two scripts use different staging + # layouts and runtime package paths. + ./build-linux-arm-sbc.sh --version "${{ steps.ver.outputs.version }}" + - name: Notarize & staple macOS app if: github.repository == 'bryanthaboi/gen1recomp' run: | @@ -282,6 +297,11 @@ jobs: [ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; } cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" + # Linux ARM SBC PortMaster handheld port. + sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip" + [ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; } + cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip" + # Platform-independent update payload, built alongside the desktop # apps above (same game.love that gets fused into each of them). love_file=".bazinga/work/game.love" @@ -395,6 +415,7 @@ jobs: "dist/release/gen1recomp-${v}-ios.ipa" "dist/release/gen1recomp-${v}-switch.zip" "dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" + "dist/release/gen1recomp-${v}-sbc-portmaster.zip" "dist/release/gen1recomp-${v}.love" "dist/release/sha256sums.txt" ) diff --git a/build-linux-arm-sbc.sh b/build-linux-arm-sbc.sh new file mode 100755 index 00000000..3da19638 --- /dev/null +++ b/build-linux-arm-sbc.sh @@ -0,0 +1,366 @@ +#!/usr/bin/env bash +# Build a PortMaster aarch64 port of gen1recomp for Linux ARM SBC handhelds. +# The package uses PortMaster control hooks and a self-contained LÖVE runtime, +# while keeping paths relative to the launcher for broad CFW compatibility. +# +# The launcher uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 +# runtime so the device does not need a separate runtime download on first launch. +# +# Usage: +# ./build-linux-arm-sbc.sh [--version X.Y.Z] +# GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version X.Y.Z +# ./build-linux-arm-sbc.sh --source /path/to/gen1recomp --version X.Y.Z +# +# Output: +# dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip +# +# Install on device: +# 1. Install PortMaster for the handheld firmware. +# 2. Unzip into the device's PortMaster ports folder so you have: +# Roms/Ports (PORTS)/gen1recomp-sbc.sh +# Roms/Ports (PORTS)/gen1recomp-sbc/... +# 3. Copy a legal US Red or Blue .gb into Roms/Ports (PORTS)/gen1recomp-sbc/lovegame/ +# 4. Launch "gen1recomp-sbc" from the Ports list; press Choose ROM (scans that +# folder when zenity is missing). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +HERE="$ROOT/.bazinga" +CACHE="$HERE/cache/linux-arm-sbc" +WORK="$HERE/work/linux-arm-sbc" +DIST="$ROOT/dist/linux-arm-sbc" + +APP_NAME="gen1recomp-sbc" +# Artifact suffix identifies this as the generic PortMaster SBC package. +# Release uploads stage it as gen1recomp--sbc-portmaster.zip. +ARTIFACT_SUFFIX="portmaster" +PORT_DIR_NAME="gen1recomp-sbc" +LAUNCHER_NAME="gen1recomp-sbc.sh" +LOVE_VERSION="11.5" +# By default the pack is reproducible from the latest published GitHub release, +# not whatever happens to be in the caller's checkout. Development builds can +# point this at a local checkout with GEN1RECOMP_SOURCE_DIR=/path/to/repo. +SOURCE_DIR_OVERRIDE="${GEN1RECOMP_SOURCE_DIR:-}" +SOURCE_TAG_OVERRIDE="${GEN1RECOMP_RELEASE_TAG:-}" +VERSION="${GEN1RECOMP_VERSION:-}" + +# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove). +PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}" +RELEASES_LATEST_URL="https://github.com/bryanthaboi/gen1recomp/releases/latest" +RELEASE_TARBALL_BASE="https://github.com/bryanthaboi/gen1recomp/archive/refs/tags" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --version) [ $# -ge 2 ] || fail "--version needs X.Y.Z"; VERSION="$2"; shift ;; + --source) [ $# -ge 2 ] || fail "--source needs a directory"; SOURCE_DIR_OVERRIDE="$2"; shift ;; + --release-tag) [ $# -ge 2 ] || fail "--release-tag needs a tag"; SOURCE_TAG_OVERRIDE="$2"; shift ;; + -h|--help) + sed -n '2,24p' "$0" + exit 0 + ;; + *) fail "unknown argument: $1" ;; + esac + shift +done + +command -v curl >/dev/null || fail "curl is required" +command -v zip >/dev/null || fail "zip is required" +command -v unzip >/dev/null || fail "unzip is required" +command -v tar >/dev/null || fail "tar is required" + +mkdir -p "$CACHE" "$WORK" "$DIST" + +download() { + local url="$1" dest="$2" + if [ -f "$dest" ] && [ -s "$dest" ]; then + return 0 + fi + say "downloading $(basename "$dest")" + curl -fL --progress-bar "$url" -o "$dest.tmp" \ + || fail "download failed: $url" + mv "$dest.tmp" "$dest" +} + +# --------------------------------------------------------------- source + game tree +# Release builds use the latest published source archive. A local checkout is +# an explicit override for development and for CI's just-built release source. +if [ -n "$SOURCE_DIR_OVERRIDE" ]; then + SOURCE_DIR_OVERRIDE="$(cd "$SOURCE_DIR_OVERRIDE" 2>/dev/null && pwd)" \ + || fail "source directory does not exist: $SOURCE_DIR_OVERRIDE" + SOURCE_DIR="$SOURCE_DIR_OVERRIDE" + SOURCE_TAG="${SOURCE_TAG_OVERRIDE:-local}" + if [ "$SOURCE_TAG" != "local" ]; then + printf '%s' "$SOURCE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + || fail "release tag must look like vX.Y.Z: $SOURCE_TAG" + fi + if [ -z "$VERSION" ]; then + VERSION="$(git -C "$SOURCE_DIR" rev-parse --short HEAD 2>/dev/null || echo dev)" + fi +else + if [ -z "$SOURCE_TAG_OVERRIDE" ]; then + latest_location="$(curl -fsSI "$RELEASES_LATEST_URL" \ + | awk 'tolower($1) == "location:" { print $2 }' | tail -1 | tr -d '\r')" \ + || fail "could not resolve latest published release" + SOURCE_TAG_OVERRIDE="${latest_location##*/}" + fi + printf '%s' "$SOURCE_TAG_OVERRIDE" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + || fail "release tag must look like vX.Y.Z: $SOURCE_TAG_OVERRIDE" + SOURCE_TAG="$SOURCE_TAG_OVERRIDE" + SOURCE_ARCHIVE="$CACHE/gen1recomp-${SOURCE_TAG}.tar.gz" + download "$RELEASE_TARBALL_BASE/$SOURCE_TAG.tar.gz" "$SOURCE_ARCHIVE" + SOURCE_EXTRACT="$WORK/source-$SOURCE_TAG" + rm -rf "$SOURCE_EXTRACT" + mkdir -p "$SOURCE_EXTRACT" + tar -xzf "$SOURCE_ARCHIVE" -C "$SOURCE_EXTRACT" + SOURCE_DIR="$(find "$SOURCE_EXTRACT" -mindepth 1 -maxdepth 1 -type d -print -quit)" + [ -n "$SOURCE_DIR" ] || fail "release archive had no source directory" + if [ -z "$VERSION" ]; then VERSION="${SOURCE_TAG#v}"; fi +fi + +say "staging lovegame/ from $SOURCE_TAG" +GAME_SRC="$WORK/lovegame" +rm -rf "$GAME_SRC" +mkdir -p "$GAME_SRC" + +# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache. +# tools/save-editor is part of that payload: the launcher's Edit button on a +# save row opens it in-process (main.lua). +(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \ + main.lua conf.lua src libs data assets tools/save-editor \ + tools/rom_manifest.json tools/rom_manifest_blue.json \ + -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') +if unzip -Z1 "$WORK/game-payload.zip" \ + | grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then + fail "payload unexpectedly contains generated ROM data" +fi +unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC" +rm -f "$WORK/game-payload.zip" + +# Stamp release version into the staged tree only (never the working tree). +if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + say "stamping engine version $VERSION" + sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \ + "$SOURCE_DIR/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua" + version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')" + grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \ + "$GAME_SRC/src/core/Version.lua" \ + || fail "version stamp failed" +else + say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)" +fi + +# Portable marker: saves + ROM cache live next to the game on the SD card. +: > "$GAME_SRC/portable.txt" + +# --------------------------------------------------------------- love runtime +say "fetching LÖVE $LOVE_VERSION aarch64 runtime" +LOVE_BIN="$CACHE/love.aarch64" +LOVE_LIB="$CACHE/liblove-11.5.so" +LUAJIT_LIB="$CACHE/libluajit-5.1.so.2" +MODPLUG_LIB="$CACHE/libmodplug.so.1" +OGG_LIB="$CACHE/libogg.so.0" + +download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN" +download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB" +download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB" +download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB" +download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB" + +# Sanity: love stub must be an aarch64 ELF. +file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \ + || fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))" + +# --------------------------------------------------------------- port tree +say "assembling port package" +PORT_ROOT="$WORK/port" +rm -rf "$PORT_ROOT" +mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \ + "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \ + "$PORT_ROOT/$PORT_DIR_NAME/licenses" \ + "$PORT_ROOT/$PORT_DIR_NAME/conf" + +cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame" +cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64" +chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64" +cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \ + "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/" + +# Drop a short license pointer for the bundled LÖVE bits. +cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF' +This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster +(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed; +see https://love2d.org/ for full terms. +EOF + +# --------------------------------------------------------------- launcher +# Resolve the game directory from the launcher so this works with both +# PortMaster-managed ports directories. +cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF' +#!/bin/bash +# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher +# Uses SHDIR-relative paths so firmware-specific mount points do not matter. + +export HOME="${HOME:-/root}" +XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +SHDIR="$(cd "$(dirname "$0")" && pwd)" + +if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then + controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster" +elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then + controlfolder="/mnt/SDCARD/Roms/ports/PortMaster" +elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then + controlfolder="/mnt/SDCARD/Data/PortMaster" +elif [ -d "$SHDIR/PortMaster" ]; then + controlfolder="$SHDIR/PortMaster" +elif [ -d "/opt/system/Tools/PortMaster/" ]; then + controlfolder="/opt/system/Tools/PortMaster" +elif [ -d "/opt/tools/PortMaster/" ]; then + controlfolder="/opt/tools/PortMaster" +elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then + controlfolder="$XDG_DATA_HOME/PortMaster" +elif [ -d "/roms/ports/PortMaster" ]; then + controlfolder="/roms/ports/PortMaster" +else + controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster" +fi + +if [ ! -f "$controlfolder/control.txt" ]; then + echo "PortMaster control.txt not found under $controlfolder" >&2 + exit 1 +fi +# shellcheck disable=SC1090 +source "$controlfolder/control.txt" +get_controls +if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then + # shellcheck disable=SC1090 + source "${controlfolder}/mod_${CFW_NAME}.txt" +fi + +GAMEDIR="$SHDIR/gen1recomp-sbc" +CONFDIR="$GAMEDIR/conf" +mkdir -p "$CONFDIR" + +cd "$GAMEDIR" || exit 1 +> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1 + +export XDG_DATA_HOME="$CONFDIR" +export XDG_CONFIG_HOME="$CONFDIR" +export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" +export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" +# GLES is the common path on ARM SBC handhelds; firmware may override it. +export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}" + +$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 +$ESUDO chmod 666 /dev/uinput 2>/dev/null || true + +if [ -n "${GPTOKEYB:-}" ]; then + $GPTOKEYB "love.aarch64" & +fi +if type pm_platform_helper >/dev/null 2>&1; then + pm_platform_helper "$GAMEDIR/bin/love.aarch64" +fi + +./bin/love.aarch64 "$GAMEDIR/lovegame" + +if type pm_finish >/dev/null 2>&1; then + pm_finish +else + if [ -n "${ESUDO:-}" ]; then + $ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true + else + kill -9 $(pidof gptokeyb) 2>/dev/null || true + fi +fi +EOF +chmod +x "$PORT_ROOT/$LAUNCHER_NAME" + +# --------------------------------------------------------------- metadata +cat > "$PORT_ROOT/port.json" < "$PORT_ROOT/gameinfo.xml" < + + + ./$LAUNCHER_NAME + gen1recomp-sbc + Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM. + 20250101T000000 + the bois club + the bois club + RPG + + +EOF + +cat > "$PORT_ROOT/README.md" <<'EOF' +## gen1recomp-sbc (Linux ARM SBC / PortMaster) + +Native LÖVE 11.5 aarch64 PortMaster port of gen1recomp for compatible Linux ARM SBC handhelds, including H700-class devices. This pack was built from source release **__SOURCE_TAG__**. + +### Install + +1. Install PortMaster for your handheld firmware. +2. Unzip so `gen1recomp-sbc.sh` and the `gen1recomp-sbc/` folder are siblings in the device's PortMaster ports directory. +3. Copy a legal US Pokémon Red or Blue `.gb` into `gen1recomp-sbc/lovegame/`. +4. Refresh the launcher and launch **gen1recomp-sbc** from Ports. + +### Controls + +| Input | Action | +|--|--| +| D-pad | Move cursor | +| A | Click | +| L1 / R1 | Switch tabs | +| Start / Select | Play or choose ROM | + +Controls use the normal PortMaster / SDL pad map. Device-specific power/suspend behavior is supplied by the firmware and PortMaster runtime. + +### First run + +Put the `.gb` in `lovegame/`, then press **Choose ROM**. After import, the ROM-derived cache and saves stay beside the game (`portable.txt`). + +### Thanks + +LÖVE runtime binaries from [PortMaster](https://portmaster.games/). PortMaster device support and runtime integration are maintained by the PortMaster team. +EOF +sed -i.bak "s/__SOURCE_TAG__/$SOURCE_TAG/g" "$PORT_ROOT/README.md" +rm -f "$PORT_ROOT/README.md.bak" + +# --------------------------------------------------------------- zip +ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip" +rm -f "$ZIP_OUT" +say "packing $ZIP_OUT" +(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \ + "$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md) + +say "done." +say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))" +say "copy into the device PortMaster ports folder, then drop your .gb into gen1recomp-sbc/lovegame/" diff --git a/docs/linux-arm-sbc.md b/docs/linux-arm-sbc.md new file mode 100644 index 00000000..bd5157de --- /dev/null +++ b/docs/linux-arm-sbc.md @@ -0,0 +1,54 @@ +# Linux ARM SBC Handhelds (PortMaster) + +Download `gen1recomp-*-sbc-portmaster.zip` from the [Gen1Recomp releases](https://github.com/bryanthaboi/gen1recomp/releases). This build targets 64-bit Linux ARM handhelds with PortMaster, including compatible H700 devices. + +## Install + +1. Unzip the release. It contains `gen1recomp-sbc.sh` and a `gen1recomp-sbc/` folder. +2. Copy both as siblings into your device's PortMaster ports directory, commonly `Roms/Ports (PORTS)/` or `Roms/PORTS/`. +3. Install PortMaster for your firmware and refresh the Ports list. +4. Copy your legally owned canonical US Red or Blue `.gb` file into `gen1recomp-sbc/lovegame/`. +5. Launch **gen1recomp-sbc** from Ports and choose the ROM. + +The pack includes `portable.txt`, so saves and ROM-derived cache remain beside the game on the SD card. The build never ships ROM-derived bytes. + +Canonical US cart SHA-1 values: + +- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` +- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2` + +## Controls + +| Input | Action | +| --- | --- | +| D-pad | Move cursor | +| A | Click / confirm | +| L1 / R1 | Switch tabs | +| Start / Select | Play or choose ROM | + +In-game controls use the normal PortMaster/SDL mapping and can be rebound in **OPTIONS → CONTROLS**. + +## Runtime and suspend + +The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher sources `control.txt`, calls `get_controls`, applies an optional CFW override, invokes `pm_platform_helper`, and calls `pm_finish` on exit. Paths are relative to the launcher, allowing different firmware mount points. + +Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device. + +## Building + +Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default: + +```sh +./build-linux-arm-sbc.sh --version 0.1.75 +``` + +For development, package a local checkout explicitly: + +```sh +GEN1RECOMP_SOURCE_DIR="$PWD" ./build-linux-arm-sbc.sh --version 0.1.0 +# or: ./build-linux-arm-sbc.sh --source "$PWD" --version 0.1.0 +``` + +The generated `port.json` records the source release tag. `install-linux-arm-sbc.sh` is a macOS helper for copying a built pack to a mounted SD card. + +PortMaster device support and runtime integration are maintained in the [PortMaster](https://github.com/PortsMaster/PortMaster-New) ecosystem. diff --git a/install-linux-arm-sbc.sh b/install-linux-arm-sbc.sh new file mode 100755 index 00000000..06b9a49c --- /dev/null +++ b/install-linux-arm-sbc.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# After first boot of a compatible Linux ARM handheld (or when PortMaster is installed), reinsert the +# SD card and run this to install gen1recomp-sbc + Red/Blue ROMs into Roms/PORTS. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +STAGE="$ROOT/.bazinga/work/linux-arm-sbc-install" +DECPREP="${DECPREP:-$ROOT/../decprep}" +ZIP="$ROOT/dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +# Find a mounted handheld userdata volume with a ROMs or Apps directory. +find_roms_root() { + local v candidate + for v in /Volumes/*; do + [ -d "$v" ] || continue + # Prefer a volume that already has Roms/ or Apps/ + if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ] || [ -d "$v/Apps" ]; then + echo "$v" + return 0 + fi + done + # Fallback: common removable-volume labels + for v in /Volumes/SDCARD /Volumes/sdcard /Volumes/NO\ NAME /Volumes/ROMS; do + if [ -d "$v" ]; then + echo "$v" + return 0 + fi + done + return 1 +} + +say "looking for handheld SD volume" +ROMS_ROOT="$(find_roms_root)" || fail "no SD volume mounted. boot the handheld once, power it off, reinsert the SD, then rerun." + +say "using: $ROMS_ROOT" +# Resolve the device PortMaster ports directory +if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then + PORTS="$ROMS_ROOT/Roms/PORTS" +elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then + PORTS="$ROMS_ROOT/roms/PORTS" +elif [ -d "$ROMS_ROOT/Roms/ports" ]; then + PORTS="$ROMS_ROOT/Roms/ports" +elif [ -d "$ROMS_ROOT/PORTS" ]; then + PORTS="$ROMS_ROOT/PORTS" +else + mkdir -p "$ROMS_ROOT/Roms/PORTS" + PORTS="$ROMS_ROOT/Roms/PORTS" +fi +say "PORTS: $PORTS" + +# Refresh staged payload +mkdir -p "$STAGE/PORTS" +if [ -f "$ZIP" ]; then + rm -rf "$STAGE/PORTS/gen1recomp-sbc.sh" "$STAGE/PORTS/gen1recomp-sbc" "$STAGE/PORTS/port.json" \ + "$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md" + unzip -q -o "$ZIP" -d "$STAGE/PORTS" +else + fail "missing $ZIP — run ./build-linux-arm-sbc.sh first" +fi + +# Ensure ROMs are in lovegame (Choose ROM scans this folder on minimal images) +[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP" +[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP" +cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/" +cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp-sbc/lovegame/" + +say "copying gen1recomp port" +rm -rf "$PORTS/gen1recomp-sbc" "$PORTS/gen1recomp-sbc.sh" +cp -R "$STAGE/PORTS/gen1recomp-sbc" "$PORTS/" +cp -f "$STAGE/PORTS/gen1recomp-sbc.sh" "$PORTS/" +cp -f "$STAGE/PORTS/port.json" "$PORTS/" +cp -f "$STAGE/PORTS/README.md" "$PORTS/" +chmod +x "$PORTS/gen1recomp-sbc.sh" "$PORTS/gen1recomp-sbc/bin/love.aarch64" + +# Also drop carts in the stock GB folder for the emulator library +GB_DIR="" +for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do + if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi +done +if [ -n "$GB_DIR" ]; then + say "copying .gb into $GB_DIR" + cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/" + cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/" +fi + +sync +say "installed:" +ls -lh "$PORTS/gen1recomp-sbc.sh" +ls -lh "$PORTS/gen1recomp-sbc/lovegame/"*.gb +say "eject the SD, insert it in the handheld, open Ports → gen1recomp-sbc, Choose ROM."