From ee5c168983a64ef425566ab26a4656aabe509e6b Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 4 Aug 2026 13:34:36 +0100 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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." From 59725c0ead3c761b7e6c4fbf977e15531819f285 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 10 Aug 2026 19:01:31 +0200 Subject: [PATCH 07/11] feat(mods): add battle menu auxiliary action --- docs/modding.md | 10 ++ .../0007-battle-menu-auxiliary-actions.md | 46 ++++++++++ src/battle/BattleSafety.lua | 86 ++++++++++++++++++ src/battle/BattleState.lua | 12 +++ src/core/Checkpoint.lua | 61 +------------ tests/engine/battle_menu_auxiliary.lua | 91 +++++++++++++++++++ tests/modkit/cases/checkpoints.lua | 24 ++++- 7 files changed, 273 insertions(+), 57 deletions(-) create mode 100644 docs/rfcs/0007-battle-menu-auxiliary-actions.md create mode 100644 src/battle/BattleSafety.lua create mode 100644 tests/engine/battle_menu_auxiliary.lua diff --git a/docs/modding.md b/docs/modding.md index b13fb048..9d1149e5 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -272,6 +272,16 @@ data. See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes. +At that same settled ordinary wild/trainer decision boundary, a tool may claim +START through `battle.menu_auxiliary`. It receives `(next, game, context)`, where +`context` is the data-only `{ kind = "wild" }` or `{ kind = "trainer" }`; it +never receives the live battle controller. Return `true` to consume START after +opening source-owned UI, or call `next(game, context)` to allow lower-priority +handlers. With no handler, START remains inert. The hook is never reached for +link/Safari/ghost/demo/scripted battles, action queues, animation/messages, +forced choices, or any phase that cannot safely be checkpointed. Exceptions are +contained by normal hook isolation and fall through without advancing a turn. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/rfcs/0007-battle-menu-auxiliary-actions.md b/docs/rfcs/0007-battle-menu-auxiliary-actions.md new file mode 100644 index 00000000..422a7420 --- /dev/null +++ b/docs/rfcs/0007-battle-menu-auxiliary-actions.md @@ -0,0 +1,46 @@ +# RFC 0007: Battle menu auxiliary actions + +## Status + +Proposed. + +## Problem + +Tool mods can inspect/capture a persistent checkpoint only at a settled +ordinary wild/trainer player-decision boundary. Before this proposal, that +boundary had no public semantic input/action seam: `BattleState` consumed the +command loop directly. A mod could reach it only through private battle/input +internals, which would be unsafe and incompatible with controller/touch input. + +## Contract + +`mod.hooks:wrap("battle.menu_auxiliary", callback)` is called only when START +is pressed at the existing checkpoint-safe player-decision boundary. The +callback signature is: + +```lua +function callback(next, game, context) + -- context is { kind = "wild" } or { kind = "trainer" } + -- return true after claiming START, otherwise return next(game, context) +end +``` + +The context is data-only. No live battle controller, input object, serializer, +or restoration primitive is exposed. A `true` result consumes START for that +fixed step without selecting a battle command. With no installed handler, +START is inert exactly as before. Hook priorities and error isolation are the +existing generic wrapper semantics: a throwing handler is skipped and cannot +advance battle state. + +The engine reuses the same internal safety predicate as battle checkpoint +capture. Link, Safari, ghost/demo, unsupported origins, scripts, queues, +animations, messages, forced replacement/locked actions, and unsettled HP or +status presentation never invoke the hook. + +## Compatibility and verification + +The call is additive and no-op with no handler. ROM-free engine tests prove +wild/trainer delivery, cursor/turn preservation, and unsafe-phase refusal; +the mod-SDK fixture proves a loaded mod can consume the semantic action using +only its public hook facade. `gate_hooks` automatically includes the new call +site in no-mod parity coverage. diff --git a/src/battle/BattleSafety.lua b/src/battle/BattleSafety.lua new file mode 100644 index 00000000..305d9795 --- /dev/null +++ b/src/battle/BattleSafety.lua @@ -0,0 +1,86 @@ +-- Shared settled ordinary-player-decision predicate. Checkpoint capture and +-- the public auxiliary action deliberately use this one engine-owned rule so +-- a tool cannot open at a phase that it could not subsequently checkpoint. +-- It exposes no controller; callers receive only the result/reason. + +local BattleSafety = {} + +local BATTLE_BUSY_FIELDS = { + "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", + "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", + "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", +} + +local function nonempty(value) + return type(value) == "table" and next(value) ~= nil +end + +local function running(runner) + return runner and runner.isRunning and runner:isRunning() +end + +local function scriptsBusy(overworld) + return running(overworld and overworld.runner) + or nonempty(overworld and overworld.parallelRunners) + or nonempty(overworld and overworld.pendingScripts) + or nonempty(overworld and overworld.parallelQueue) + or nonempty(overworld and overworld.scriptMoves) +end + +function BattleSafety.inspect(game, battle) + if type(game) ~= "table" or type(game.save) ~= "table" + or type(game.save.version) ~= "string" then + return nil, "not_in_playthrough", "A checkpoint requires an identified active playthrough." + end + if type(battle) ~= "table" then + return nil, "not_battle", "No battle is active." + end + if battle.kind == "link" then + return nil, "link_battle_unsupported", "Network battles cannot be checkpointed." + end + if battle.safari or battle.ghost or battle.scopeReveal or battle.demo or battle.noCatch then + return nil, "battle_variant_unsupported", + "This battle variant does not have a checkpoint contract." + end + if battle.kind ~= "wild" and battle.kind ~= "trainer" then + return nil, "battle_variant_unsupported", + "This battle kind does not have a checkpoint contract." + end + local expectedOrigin = battle.kind == "wild" and "wild_encounter" + or "trainer_encounter" + if type(battle.checkpointOrigin) ~= "table" + or battle.checkpointOrigin.kind ~= expectedOrigin then + return nil, "battle_origin_unsupported", + "The battle completion path cannot be reconstructed safely." + end + if scriptsBusy(game.overworld) then + return nil, "script_busy", "A suspended or queued script cannot be checkpointed." + end + if battle.phase ~= "menu" or nonempty(battle.queue) then + return nil, "battle_phase_busy", + "Wait for the player command menu before creating a checkpoint." + end + for _, field in ipairs(BATTLE_BUSY_FIELDS) do + if battle[field] ~= nil and battle[field] ~= false then + return nil, "battle_phase_busy", "Wait for the current battle action to finish." + end + end + if not battle.player or not battle.enemy or not battle.player.mon + or battle.player.mon.hp <= 0 + or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then + return nil, "battle_phase_busy", + "Wait for an ordinary player decision before creating a checkpoint." + end + for _, battler in ipairs({ battle.player, battle.enemy }) do + if not battler.mon or battler.shownHP ~= battler.mon.hp + or battler.shownStatus ~= battler.mon.status + or battler.drainFloor ~= nil or battler.drainHold ~= nil + or battler.faintQueued then + return nil, "battle_phase_busy", + "Wait for battle status and HP presentation to settle." + end + end + return true +end + +return BattleSafety diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6cee785a..db7fcb7d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -21,6 +21,7 @@ local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") +local BattleSafety = require("src.battle.BattleSafety") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local Timing = require("src.core.Timing") @@ -1942,6 +1943,17 @@ function BattleState:update(dt) self:resolveTurn(locked) return end + -- START has no vanilla action at the settled ordinary player-decision + -- boundary. A tool mod may claim this semantic auxiliary action through + -- the public hook, receiving only game plus a data-only kind. The shared + -- safety predicate keeps every unsupported/forced/animated phase inert. + if input:wasPressed("start") and Runtime.wantsHook("battle.menu_auxiliary") then + local safe = BattleSafety.inspect(self.game, self) + if safe and Runtime.call("battle.menu_auxiliary", function() return false end, + self.game, { kind = self.kind }) == true then + return + end + end local col = (self.menuIndex - 1) % 2 local row = math.floor((self.menuIndex - 1) / 2) if input:wasPressed("left") then diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index e6a3b036..5625a272 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -7,6 +7,7 @@ local Version = require("src.core.Version") local BattleState = require("src.battle.BattleState") local BattleCheckpoint = require("src.core.BattleCheckpoint") local ModRuntime = require("src.mods.Runtime") +local BattleSafety = require("src.battle.BattleSafety") local Checkpoint = {} @@ -36,61 +37,9 @@ local function scriptsBusy(ow) or nonempty(ow.scriptMoves) end -local BATTLE_BUSY_FIELDS = { - "current", "afterQueue", "nextInsert", "pendingHit", "waitingUI", - "waitingSound", "waitFrames", "draining", "animPlaying", "growIn", - "introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result", -} - -local function inspectBattle(ow, battle) - if battle.kind == "link" then - return refusal("battle", "link_battle_unsupported", - "Network battles cannot be checkpointed.") - end - if battle.safari or battle.ghost or battle.scopeReveal or battle.demo - or battle.noCatch then - return refusal("battle", "battle_variant_unsupported", - "This battle variant does not have a checkpoint contract.") - end - if battle.kind ~= "wild" and battle.kind ~= "trainer" then - return refusal("battle", "battle_variant_unsupported", - "This battle kind does not have a checkpoint contract.") - end - local origin = battle.checkpointOrigin - local expectedOrigin = battle.kind == "wild" and "wild_encounter" - or "trainer_encounter" - if type(origin) ~= "table" or origin.kind ~= expectedOrigin then - return refusal("battle", "battle_origin_unsupported", - "The battle completion path cannot be reconstructed safely.") - end - if scriptsBusy(ow) then - return refusal("battle", "script_busy", - "A suspended or queued script cannot be checkpointed.") - end - if battle.phase ~= "menu" or nonempty(battle.queue) then - return refusal("battle", "battle_phase_busy", - "Wait for the player command menu before creating a checkpoint.") - end - for _, field in ipairs(BATTLE_BUSY_FIELDS) do - if battle[field] ~= nil and battle[field] ~= false then - return refusal("battle", "battle_phase_busy", - "Wait for the current battle action to finish.") - end - end - if not battle.player or not battle.enemy or battle.player.mon.hp <= 0 - or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then - return refusal("battle", "battle_phase_busy", - "Wait for an ordinary player decision before creating a checkpoint.") - end - for _, battler in ipairs({ battle.player, battle.enemy }) do - if battler.shownHP ~= battler.mon.hp - or battler.shownStatus ~= battler.mon.status - or battler.drainFloor ~= nil or battler.drainHold ~= nil - or battler.faintQueued then - return refusal("battle", "battle_phase_busy", - "Wait for battle status and HP presentation to settle.") - end - end +local function inspectBattle(game, battle) + local allowed, reason, message = BattleSafety.inspect(game, battle) + if not allowed then return refusal("battle", reason, message) end return { canCapture = true, canRestore = true, kind = "battle" } end @@ -109,7 +58,7 @@ function Checkpoint.inspect(game) end local top = game.stack and game.stack.top and game.stack:top() if getmetatable(top) == BattleState then - return inspectBattle(ow, top) + return inspectBattle(game, top) end if top ~= ow then return refusal("overworld", "screen_busy", diff --git a/tests/engine/battle_menu_auxiliary.lua b/tests/engine/battle_menu_auxiliary.lua new file mode 100644 index 00000000..d147bda7 --- /dev/null +++ b/tests/engine/battle_menu_auxiliary.lua @@ -0,0 +1,91 @@ +-- Public battle auxiliary actions are a narrow semantic entry point for tool +-- mods. They run only at the same settled ordinary decision boundary as a +-- battle checkpoint, consume no FIGHT/PKMN/ITEM/RUN action, and receive no +-- live BattleState object. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle menu auxiliary action") +local Fixtures = require("tests.modkit").fixtures +local BattleState = require("src.battle.BattleState") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") + +local Data = Fixtures.fresh() + +local function makeGame(kind) + local save = SaveData.newGame() + save.meta.playthroughId = "battle-menu-playthrough" + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local overworld = { + map = { id = save.player.map }, + player = { cellX = save.player.x, cellY = save.player.y, facing = save.player.facing }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + local game = { data = Data, save = save, stack = stack } + game.input = { wasPressed = function(_, button) return button == "start" end } + game.overworld = overworld + stack.states[1] = overworld + local battle = kind == "trainer" + and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_B", 12) + battle.phase, battle.queue = "menu", {} + battle.checkpointOrigin = kind == "trainer" + and { kind = "trainer_encounter", map = save.player.map, npcId = "TRAINER_1", + trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1" } + or { kind = "wild_encounter", map = save.player.map } + battle.onFinish = function() end + stack.states[2] = battle + return game, battle +end + +local oldHooks = Runtime.hooks +local hooks = Hooks.new() +Runtime.hooks = hooks + +local game, battle = makeGame("wild") +local calls = 0 +hooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context) + calls = calls + 1 + T.check(liveGame == game, "auxiliary action receives the live game") + T.same(context, { kind = "wild" }, "auxiliary action receives only data-only battle context") + return true +end, 0, "tool_fixture") + +local originalIndex = battle.menuIndex +battle:update(1 / 60) +T.eq(calls, 1, "START reaches the public auxiliary action at a wild decision") +T.eq(battle.phase, "menu", "handled auxiliary action does not advance the battle") +T.eq(battle.menuIndex, originalIndex, "handled auxiliary action preserves cursor") +T.eq(#battle.queue, 0, "handled auxiliary action does not enqueue a turn") + +hooks:removeOwner("tool_fixture") +local trainerGame, trainer = makeGame("trainer") +local trainerCalls = 0 +hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context) + trainerCalls = trainerCalls + 1 + T.check(liveGame == trainerGame, "trainer action receives its live game") + T.same(context, { kind = "trainer" }, "trainer context remains data-only") + return true +end, 0, "trainer_fixture") +trainer:update(1 / 60) +T.eq(trainerCalls, 1, "START reaches the public auxiliary action at a trainer decision") +hooks:removeOwner("trainer_fixture") + +local unsafeGame, unsafe = makeGame("wild") +unsafe.phase = "messages" +local unsafeCalls = 0 +hooks:wrap("battle.menu_auxiliary", function() unsafeCalls = unsafeCalls + 1 return true end, + 0, "unsafe_fixture") +unsafe:update(1 / 60) +T.eq(unsafeCalls, 0, "messages never expose the auxiliary action") +hooks:removeOwner("unsafe_fixture") + +Runtime.hooks = oldHooks +T.finish() diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index adea5f90..c5921158 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -140,7 +140,10 @@ local files = { '{"id":"probe","name":"probe","version":"1.0.0",' .. '"entry":"main.lua","api":2,"profile":"content"}', ["mods/probe/main.lua"] = [[ -return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end +return function(mod) + _G.MOD_CHECKPOINTS = mod.checkpoints + _G.MOD_HOOKS = mod.hooks +end ]], } local game, ow = makeGame() @@ -433,9 +436,28 @@ if battleSnapshot then "public battle capture/restore/capture is a normalized differential roundtrip") end +-- The mod receives the normal public hook facade, never BattleState. START +-- at the restored safe decision reaches its semantic auxiliary action without +-- selecting a native command. +local auxiliaryCalls = 0 +_G.MOD_HOOKS:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context) + auxiliaryCalls = auxiliaryCalls + 1 + T.check(liveGame == battleGame, "public battle auxiliary action receives the game") + T.same(context, { kind = "wild" }, "public auxiliary context is data-only") + return true +end) +battleGame.input = { wasPressed = function(_, button) return button == "start" end } +local boundary = battleGame.stack:top() +local originalMenuIndex = boundary.menuIndex +boundary:update(1 / 60) +T.eq(auxiliaryCalls, 1, "public mod hook receives START at the checkpoint boundary") +T.eq(boundary.phase, "menu", "public auxiliary hook does not advance the turn") +T.eq(boundary.menuIndex, originalMenuIndex, "public auxiliary hook preserves cursor") + Runtime.events, Runtime.hooks = savedEvents, savedHooks Runtime.currentMod = nil _G.MOD_CHECKPOINTS = nil +_G.MOD_HOOKS = nil love.math.getRandomState = oldGetRandomState love.math.setRandomState = oldSetRandomState From d21a0edffcef41a18d868d9b815ae8ad74deaa00 Mon Sep 17 00:00:00 2001 From: anxiousintrovert <82425472+anxiousintrovert@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:03:33 -0500 Subject: [PATCH 08/11] Fix hidden battle UI overlay backgrounds --- docs/modding.md | 9 +++++--- src/battle/BattleState.lua | 5 ++--- src/battle/UIVisibility.lua | 39 +++++++++++++++++++++++++++++++++++ src/render/TextBox.lua | 8 ++----- src/ui/ChoiceBox.lua | 2 ++ tests/mod_qol_hooks_tests.lua | 18 ++++++++++++++++ 6 files changed, 69 insertions(+), 12 deletions(-) create mode 100644 src/battle/UIVisibility.lua diff --git a/docs/modding.md b/docs/modding.md index b13fb048..7bd5df7a 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -374,9 +374,12 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`, `battle.bottom_ui_visible` and `battle.status_hud_visible` independently control the battle text/menu layer and the HP/status panels. Both receive `(next, state)` and default to `true`, so vanilla rendering is unchanged. -Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that -only owns battle presentation should return `false` only for its active battle -or text-box state. +Text boxes and YES/NO prompts pushed above a battle inherit a `false` result +for that battle, so hiding the bottom layer cannot leave their white backing +behind under another overlay. Text boxes also pass through the hook as their +own state, preserving selective control outside a battle; a wrapper that only +owns battle presentation should return `false` only for its active battle or +text-box state. `core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call (once per frame). Vanilla behavior resolves the per-category GAME SPEED diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6cee785a..7f850be0 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -27,6 +27,7 @@ local Timing = require("src.core.Timing") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local UIVisibility = require("src.battle.UIVisibility") local RomText = require("src.core.RomText") local Strings = require("src.core.Strings") local WideBattle = require("src.battle.WideBattle") @@ -134,9 +135,7 @@ function BattleState:sgbPalettes() end function BattleState:bottomUIVisible() - if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end - return Runtime.call("battle.bottom_ui_visible", function() return true end, - self) ~= false + return UIVisibility.bottomVisible(self, true) end function BattleState:statusHUDVisible() diff --git a/src/battle/UIVisibility.lua b/src/battle/UIVisibility.lua new file mode 100644 index 00000000..858acc07 --- /dev/null +++ b/src/battle/UIVisibility.lua @@ -0,0 +1,39 @@ +-- Shared visibility rules for battle-owned UI states. Text and choice +-- overlays live above BattleState on the state stack, but they are still part +-- of its bottom UI layer and must inherit that layer's visibility. + +local Runtime = require("src.mods.Runtime") + +local UIVisibility = {} + +local function enclosingBattle(state) + local stack = state and state.game and state.game.stack + local states = stack and stack.states + local found = false + for i = #(states or {}), 1, -1 do + local candidate = states[i] + if candidate == state then found = true end + if found and candidate and candidate.isBattle then return candidate end + end + return nil +end + +-- queryState keeps the existing TextBox contract: a mod may still decide +-- visibility for that individual box. ChoiceBox only inherits the enclosing +-- battle decision, so field YES/NO prompts never become battle-hook states. +function UIVisibility.bottomVisible(state, queryState) + if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end + local battle = enclosingBattle(state) + if battle and battle ~= state + and Runtime.call("battle.bottom_ui_visible", + function() return true end, battle) == false then + return false + end + if queryState or battle == state then + return Runtime.call("battle.bottom_ui_visible", + function() return true end, state) ~= false + end + return true +end + +return UIVisibility diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 290c4ecc..d2c64dd3 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -7,7 +7,7 @@ -- the text is exhausted and A is pressed, then calls onDone. local Font = require("src.render.Font") -local Runtime = require("src.mods.Runtime") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") @@ -346,11 +346,7 @@ function TextBox:update(dt) end function TextBox:draw() - if Runtime.wantsHook("battle.bottom_ui_visible") - and Runtime.call("battle.bottom_ui_visible", function() return true end, - self) == false then - return - end + if not UIVisibility.bottomVisible(self, true) then return end -- The dialogue box belongs against the bottom of the screen, not floating -- in the middle of a zoomed-out letterbox. Declared per frame; the -- renderer blits this region to the screen edge and the rest of the UI diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f8..dae0cce6 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,6 +1,7 @@ -- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right). local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Strings = require("src.core.Strings") local Timing = require("src.core.Timing") @@ -67,6 +68,7 @@ function ChoiceBox:update(dt) end function ChoiceBox:draw() + if not UIVisibility.bottomVisible(self, false) then return end local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th -- rides the same bottom anchor as the dialogue box it sits above, so the -- pair travels together (the anchor keeps each element's gap from the edge) diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index dfa425b2..da1a2392 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -12,6 +12,7 @@ local Zoom = require("src.render.Zoom") local ListMenu = require("src.ui.ListMenu") local NamingScreen = require("src.ui.NamingScreen") local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") local PartyMenu = require("src.ui.PartyMenu") local Player = require("src.world.Player") local Music = require("src.core.Music") @@ -180,6 +181,23 @@ do text:draw() check(seen == text, "pushed text boxes use the same visibility hook") unsub() + + local battle = setmetatable({ isBattle = true }, BattleState) + local game = { stack = { states = {} } } + text = setmetatable({ game = game }, TextBox) + local choice = setmetatable({ game = game }, ChoiceBox) + game.stack.states = { battle, text, choice } + local queried = {} + unsub = wrap("battle.bottom_ui_visible", function(_, state) + queried[#queried + 1] = state + return state ~= battle + end) + text:draw() + choice:draw() + check(queried[1] == battle and queried[2] == battle and #queried == 2, + "battle overlays inherit a hidden bottom layer without drawing backings") + unsub() + check(BattleState.bottomUIVisible({ phase = "moveSelect" }), "battle bottom UI returns when the hook is removed") From ccb5358aad517f67b1cbe9726ef591db15ce6997 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Tue, 11 Aug 2026 11:16:58 +0200 Subject: [PATCH 09/11] feat: expose public Pokemon icon presentation --- docs/modding.md | 15 ++++++++ src/ui/ModUI.lua | 1 + src/ui/PokemonIcon.lua | 41 ++++++++++++++++++++ tests/modkit/cases/pokemon_icon.lua | 60 +++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/ui/PokemonIcon.lua create mode 100644 tests/modkit/cases/pokemon_icon.lua diff --git a/docs/modding.md b/docs/modding.md index b13fb048..ccef7574 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -426,3 +426,18 @@ platform-bridge mod bundled only with that build's launcher, for example). Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call` already falls straight through to the vanilla function when no mod has wrapped the name, at negligible cost. + +## Detached Pokémon icon presentation + +`mod.ui.PokemonIcon.draw(game, summary, x, y, opts)` draws the same party icon +the native Party menu would resolve without exposing a live Pokémon record or +the private Party menu. `summary` is the detached data-only shape +`{ species = string, hp = integer, maxHp = integer }`; `opts.selected` and +`opts.counter` optionally request the native selected-icon animation phase. + +The engine retains icon ownership. Content registered through +`mod.content.icons`, species `icon` definitions, asset overrides, and the +public `pokemon.icon` hook therefore continue to compose. Invalid summaries +return `false, code, message` and draw nothing. The helper is presentation +only: it does not expose moves, status, checkpoint payloads, or mutable party +state. diff --git a/src/ui/ModUI.lua b/src/ui/ModUI.lua index f04c1488..aa1a75f1 100644 --- a/src/ui/ModUI.lua +++ b/src/ui/ModUI.lua @@ -12,6 +12,7 @@ local MODULES = { QuantityBox = "src.ui.QuantityBox", NamingScreen = "src.ui.NamingScreen", PicBox = "src.ui.PicBox", + PokemonIcon = "src.ui.PokemonIcon", TextBox = "src.render.TextBox", Font = "src.render.Font", Theme = "src.ui.Theme", diff --git a/src/ui/PokemonIcon.lua b/src/ui/PokemonIcon.lua new file mode 100644 index 00000000..e2cd3c28 --- /dev/null +++ b/src/ui/PokemonIcon.lua @@ -0,0 +1,41 @@ +-- Public read-only Pokemon icon presentation for detached summaries. +-- Resolution and rendering deliberately stay engine-owned: PartyMenu already +-- composes content icon registrations, per-species definitions, asset +-- overrides, and the pokemon.icon hook in one canonical path. + +local PartyMenu = require("src.ui.PartyMenu") + +local PokemonIcon = {} + +local function finite(value) + return type(value) == "number" and value == value + and value ~= math.huge and value ~= -math.huge +end + +local function integer(value, minimum) + return finite(value) and value % 1 == 0 and value >= minimum +end + +function PokemonIcon.draw(game, summary, x, y, opts) + opts = type(opts) == "table" and opts or {} + if type(game) ~= "table" or type(summary) ~= "table" + or type(summary.species) ~= "string" or summary.species == "" + or not integer(summary.hp, 0) or not integer(summary.maxHp, 1) + or summary.hp > summary.maxHp or not finite(x) or not finite(y) + or (opts.selected ~= nil and type(opts.selected) ~= "boolean") + or (opts.counter ~= nil and not finite(opts.counter)) then + return false, "invalid_pokemon_preview", + "Pokemon icon presentation needs species and valid captured HP values." + end + local ok, message = pcall(PartyMenu.drawIcon, game, { + species = summary.species, + hp = summary.hp, + stats = { hp = summary.maxHp }, + }, x, y, opts.selected == true, opts.counter or 0) + if not ok then + return false, "pokemon_icon_failed", tostring(message) + end + return true +end + +return PokemonIcon diff --git a/tests/modkit/cases/pokemon_icon.lua b/tests/modkit/cases/pokemon_icon.lua new file mode 100644 index 00000000..55687296 --- /dev/null +++ b/tests/modkit/cases/pokemon_icon.lua @@ -0,0 +1,60 @@ +-- Public read-only Pokemon icon presentation delegates to the same resolver +-- PartyMenu uses, so content registrations and pokemon.icon hooks compose. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local PartyMenu = require("src.ui.PartyMenu") + +local FIXTURE = { + ["mods/icon_probe/manifest.json"] = [[{ + "id": "icon_probe", + "name": "Icon Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/icon_probe/main.lua"] = [[ + local mod = ... + mod.exports.icon = mod.ui.PokemonIcon + ]], +} + +local run = T.sdk.loadMods({ "mods/icon_probe" }, { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, "fixture mod loads cleanly") +local icon = run.loader.exports.icon_probe.icon +T.eq(type(icon), "table", "mod.ui exposes the PokemonIcon helper") +T.eq(type(icon.draw), "function", "PokemonIcon exposes a draw operation") + +local original = PartyMenu.drawIcon +local call +PartyMenu.drawIcon = function(game, mon, x, y, selected, counter) + call = { game = game, mon = mon, x = x, y = y, + selected = selected, counter = counter } +end + +local game = { data = {} } +local drawn, code = icon.draw(game, { + species = "PIKACHU", hp = 4, maxHp = 10, +}, 8, 16, { selected = true, counter = 7 }) +T.eq(drawn, true, "valid detached Pokemon summary is drawable") +T.eq(code, nil, "valid summary has no rejection code") +T.check(call and call.game == game, "helper delegates with the live game") +T.eq(call.mon.species, "PIKACHU", "species reaches the shared party resolver") +T.eq(call.mon.hp, 4, "captured current HP reaches icon animation semantics") +T.eq(call.mon.stats.hp, 10, "captured maximum HP reaches icon animation semantics") +T.eq(call.selected, true, "selection state is presentation-only") +T.eq(call.counter, 7, "animation counter is presentation-only") + +call = nil +local bad, badCode = icon.draw(game, { + species = "PIKACHU", hp = 11, maxHp = 10, +}, 0, 0) +T.eq(bad, false, "invalid detached summary fails closed") +T.eq(badCode, "invalid_pokemon_preview", "invalid summary has a stable error") +T.eq(call, nil, "invalid summary never reaches renderer internals") + +PartyMenu.drawIcon = original +run.release() + +T.finish("pokemon_icon") From 96af652d3bff1b7f3e2ad806295330906f4878b7 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:25:07 +0200 Subject: [PATCH 10/11] Fix lingering title music after Resume Game Continue dropped the player into the overworld with the title screen's song still cross-fading into the map theme over ~1.2s (Music.MAP_FADE), audibly wrong since the player already has control. New Game never showed this because OakSpeech's own unfaded Music.play/playMap masks it before the player is ever placed in the overworld. The same bug was also reachable through F2 quickload (pressed at the title screen, or mid-session -- F2 always jumps straight to the loaded save's map/position with no walking transition, so it needs the same instant swap as Continue rather than an ordinary warp's crossfade either way) and through the checkpoint-resume mod API (RFC 0006's mod.checkpoint:resume). OverworldState:setMap now takes an opts.freshBoot flag: when set, the map's music swaps in at once instead of cross-fading, like every other map's PlayDefaultMusic. It is set by every real hard state teleport -- TitleState's onContinue, New Game's push, F2 quickload, and Game:restoreCheckpointSave (whose only caller is itself title-gated) -- and deliberately kept separate from the pre-existing opts.via == "boot" default, which dev tooling (the console's `warp` verb, hot reload's map rebuild) also reuses for unrelated reasons and must keep its ordinary crossfade. --- src/core/Game.lua | 25 +++++++++++++++++++------ src/world/OverworldController.lua | 12 +++++++++++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index 9cb05fda..0f8fb6de 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -159,14 +159,15 @@ function Game:makeTitleState() self:applyOptions(self.save.options) self.stack:push(OverworldState, self.save.player.map, self.save.player.x, self.save.player.y, - self.save.player.facing) + self.save.player.facing, + { via = "boot", freshBoot = true }) Screens.push(self, bootScreens(self).newGame or "OakSpeech", function() end) end, onContinue = function() local loaded, recovered = SaveData.load() if loaded then - self:restoreSave(loaded, recovered) + self:restoreSave(loaded, recovered, { freshBoot = true }) end end, }) @@ -639,7 +640,12 @@ function Game:keypressed(key) return elseif key == "f2" then local loaded, recovered = SaveData.load() - if loaded then self:restoreSave(loaded, recovered) end + if loaded then + -- F2 jumps straight to the loaded save's map/position, with no + -- walking transition -- a hard state teleport like Continue, not a + -- smooth warp -- whether pressed at the title screen or mid-session. + self:restoreSave(loaded, recovered, { freshBoot = true }) + end return elseif key == "-" then self:zoomStep(-1) @@ -1124,7 +1130,7 @@ function Game:applyOptions(opts) if gbcCleared then self:writeOptions() end end -function Game:restoreSave(loaded, recovered) +function Game:restoreSave(loaded, recovered, opts) if ModRuntime.wants("save.loading") then ModRuntime.emit("save.loading", { raw = loaded }) end @@ -1157,8 +1163,12 @@ function Game:restoreSave(loaded, recovered) end -- rebuild the state stack from the save while self.stack:top() do self.stack:pop() end + -- freshBoot threads through from the caller (onContinue and F2 both set + -- it); a future caller that doesn't ask for it keeps the ordinary + -- crossfade by default. self.stack:push(self.overworld, loaded.player.map, - loaded.player.x, loaded.player.y, loaded.player.facing) + loaded.player.x, loaded.player.y, loaded.player.facing, + { via = "boot", freshBoot = opts and opts.freshBoot }) self.saveReport = report if not SaveData.emptyReport(report) then -- the report screen is a Screens id so mods (or the ui milestone) own @@ -1187,9 +1197,12 @@ function Game:restoreCheckpointSave(loaded) self.save = loaded self:adoptSave(loaded) while self.stack:top() do self.stack:pop() end + -- freshBoot unconditionally: Checkpoint.resume (src/core/Checkpoint.lua) + -- is this method's only caller, and it is itself gated to the title + -- session (isTitleSession). self.stack:push(self.overworld, loaded.player.map, loaded.player.x, loaded.player.y, loaded.player.facing, - { via = "checkpoint", checkpoint = true }) + { via = "checkpoint", checkpoint = true, freshBoot = true }) end -- Install a reconstructed battle without calling BattleState:enter(), whose diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a825d37f..565908a4 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -462,8 +462,18 @@ function OverworldState:setMap(mapId, x, y, facing, opts) if not keepMusic then -- ..(home/overworld.asm ln 2346) local Music = require("src.core.Music") + -- opts.freshBoot: switch instantly instead of cross-fading, like every + -- other map's PlayDefaultMusic on real hardware -- set only by + -- Game.lua's hard state teleports (onContinue, New Game, F2, + -- restoreCheckpointSave). Deliberately separate from opts.via == + -- "boot" itself: dev tooling (src/dev/Console.lua's warp verb, + -- src/dev/HotReload.lua's reloadMap) reuses that same default for the + -- surf-restore/fresh-npc-pool branches above and must keep the + -- ordinary crossfade. + local fade = Music.MAP_FADE + if opts and opts.freshBoot then fade = nil end Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, - Music.MAP_FADE) + fade) end -- forced bike/surf tiles fire the moment the player is placed on the From 1176a269e5a68c178f034b413c9780a475654263 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:25:07 +0200 Subject: [PATCH 11/11] Add unit tests --- tests/engine/resume_boot_music_no_fade.lua | 208 +++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/engine/resume_boot_music_no_fade.lua diff --git a/tests/engine/resume_boot_music_no_fade.lua b/tests/engine/resume_boot_music_no_fade.lua new file mode 100644 index 00000000..72c48c2d --- /dev/null +++ b/tests/engine/resume_boot_music_no_fade.lua @@ -0,0 +1,208 @@ +-- Regression test for the title-music-bleeds-into-the-map bug: Continue, +-- F2 quickload, and checkpoint-resume used to drop the player into the +-- overworld while the old song (the title screen's, or F2's previous +-- location) was still cross-fading in over Music.MAP_FADE's ~1.2s, +-- audibly wrong since the player already had control. See +-- OverworldState:setMap (src/world/OverworldController.lua) for the +-- opts.freshBoot mechanism this exercises, and Game.lua for where it's +-- set (onContinue, New Game, F2, restoreCheckpointSave) and where it's +-- deliberately not (dev tooling's reuse of opts.via == "boot"). +-- +-- (A)-(A4) and (C) call the real Game:restoreSave, Game:keypressed("f2"), +-- Game:restoreCheckpointSave and Console:exec("warp ...") -- SaveData.load +-- stubbed to skip the slot/persistence format -- so a dropped freshBoot at +-- any real call site fails this test, not just a hand-built opts table. +-- (D) simulates HotReload's { via = "boot" } shape instead of calling +-- through its local, unexported reloadMap. +-- +-- ROM-free (fixture dataset -- FIX_TOWN/FIX_ROUTE, tests/fixture_data), +-- like tests/engine/warp_sprite_hidden_bug916.lua, so the CI headless +-- tier (no data/generated/) runs it. +-- luajit tests/engine/resume_boot_music_no_fade.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local check = T.check +local eq = T.eq + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true end +function Source:stop() self.playing = false end +function Source:pause() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:setLooping() end +function Source:setVolume(v) self.volume = v end +function Source:setPitch() end +function Source:setFilter() end +function Source:getDuration() return 1 end + +local made = {} -- file -> the last source built for it +love.audio = { + newSource = function(file, mode) + made[file] = setmetatable({ file = file, mode = mode }, Source) + return made[file] + end, +} + +local Data = T.fixtures.fresh() +-- fixture patches that let the overworld boot and run headlessly (same +-- set tests/engine/warp_sprite_hidden_bug916.lua needs for the same reason) +Data.tilesets.FIX_OUT.tilesPerRow = 16 +Data.field.flyWarps = Data.field.flyWarps or {} +Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" } +Data.field.waterTilesets = {} +Data.field.forcedMovement = { tiles = {} } +-- no data.audio in the fixture dataset either; synthesize just enough for +-- real Music.lua playback to run against the real FIX_TOWN/FIX_ROUTE maps +Data.audio = Data.audio or {} +Data.audio.songs = Data.audio.songs or {} +Data.audio.songs.Music_TitleScreen = { file = "title.wav" } +Data.audio.mapSongs = Data.audio.mapSongs or {} +Data.audio.mapSongs.FIX_TOWN = "Music_FixTown" +Data.audio.songs.Music_FixTown = { file = "town.wav" } +Data.audio.mapSongs.FIX_ROUTE = "Music_FixRoute" +Data.audio.songs.Music_FixRoute = { file = "route.wav" } + +local Music = require("src.core.Music") +local SaveData = require("src.core.SaveData") +local Game = require("src.core.Game") +local StateStack = require("src.core.StateStack") +local OverworldState = require("src.world.OverworldController") +local Console = require("src.dev.Console") + +Game.data = Data +Game.save = SaveData.newGame() +Game.save.player.name = "RED" +Game.save.player.map = "FIX_TOWN" +StateStack:init() +Game.stack = StateStack +Game.overworld = OverworldState -- set once at boot in the real game (Game.lua) +Game.input = { + isDown = function() return false end, + wasPressed = function() return false end, + step = function() end, state = {}, pressQueue = {}, +} +Game.renderer = { + beginWorldPass = function() end, endWorldPass = function() end, + beginUIPass = function() end, endUIPass = function() end, + worldViewSize = function() return 160, 144 end, + setSGBZones = function() end, +} + +local function playing() + for file, src in pairs(made) do + if src.playing then return file end + end + return "(silence)" +end + +local function finishFade() + for _ = 1, 7 * Music.MAP_FADE do Music.update(Data) end +end + +-- =========================================================================== +-- (A) The real Game:restoreSave, called the way onContinue calls it. +-- =========================================================================== +Music.play(Data, "Music_TitleScreen") +eq(playing(), "title.wav", "title screen music is playing before Continue") + +local loaded = SaveData.newGame() +loaded.player.map = "FIX_TOWN" +Game:restoreSave(loaded, false, { freshBoot = true }) + +eq(playing(), "town.wav", + "Continue's real restoreSave(..., {freshBoot=true}) swaps at once") + +-- =========================================================================== +-- (A2) The same real Game:restoreSave with no opts at all -- its own +-- default (e.g. for any future caller that doesn't ask for freshBoot) is +-- the safe, ordinary crossfade, not a silent hard-cut. +-- =========================================================================== +Music.play(Data, "Music_TitleScreen") -- stand-in for whatever was playing +local loaded2 = SaveData.newGame() +loaded2.player.map = "FIX_TOWN" +Game:restoreSave(loaded2, false) + +eq(playing(), "title.wav", + "restoreSave(...) with no opts still fades, not an instant swap") +finishFade() +eq(playing(), "town.wav", "...landing on the loaded save's map song") + +-- =========================================================================== +-- (A3) The real Game:keypressed("f2") handler, both ways it's reachable: +-- at the title screen and mid-session. SaveData.load is stubbed rather +-- than round-tripped through the in-memory love.filesystem, to isolate +-- this test from the slot/persistence format. +-- =========================================================================== +local realLoad = SaveData.load +local loaded3 = SaveData.newGame() +loaded3.player.map = "FIX_TOWN" +SaveData.load = function() return loaded3, false end + +StateStack:init() -- no overworld on the stack: "at the title screen" +Music.play(Data, "Music_TitleScreen") +Game:keypressed("f2") +eq(playing(), "town.wav", + "F2 from the title screen (overworld not on the stack) swaps at once") + +StateStack:init() +StateStack.states[1] = OverworldState -- overworld already active: mid-session +Music.play(Data, "Music_TitleScreen") -- stand-in for the session's own song +Game:keypressed("f2") +eq(playing(), "town.wav", + "F2 mid-session (a live overworld already on the stack) also swaps at once") + +SaveData.load = realLoad +StateStack:init() + +-- =========================================================================== +-- (A4) The real Game:restoreCheckpointSave, called the way Checkpoint.resume +-- (RFC 0006's mod.checkpoint:resume) calls it. +-- =========================================================================== +Music.play(Data, "Music_TitleScreen") +local checkpointSave = SaveData.newGame() +checkpointSave.player.map = "FIX_TOWN" +Game:restoreCheckpointSave(checkpointSave) +eq(playing(), "town.wav", + "a title-session checkpoint resume swaps at once, no lingering title music") + +StateStack:init() + +-- =========================================================================== +-- (B) An ordinary warp (e.g. walking into a house) is unaffected: it still +-- cross-fades like any other map-to-map transition. +-- =========================================================================== +OverworldState:setMap("FIX_ROUTE", 3, 3, "up", {}) +eq(playing(), "town.wav", + "an ordinary warp still fades: the old song is still playing right after setMap") +finishFade() +eq(playing(), "route.wav", + "...and lands on the new map's song once the fade completes") + +-- =========================================================================== +-- (C) The real dev console `warp` verb (src/dev/Console.lua VERBS.warp). +-- =========================================================================== +Music.play(Data, "Music_TitleScreen") -- re-arm a "stale" song to prove intent +Console.new(Game):exec("warp FIX_TOWN 5 5") + +eq(playing(), "title.wav", + "Console's real `warp` verb still fades, like an ordinary warp") +finishFade() +eq(playing(), "town.wav", "...landing on the target map's song") + +-- =========================================================================== +-- (D) src/dev/HotReload.lua's reloadMap opts shape, simulated (see header) +-- rather than called through: reloadMap is local/unexported, and +-- HotReload.run's full loader teardown is out of scope for this fix. +-- =========================================================================== +Music.play(Data, "Music_TitleScreen") +OverworldState:setMap("FIX_ROUTE", 3, 3, "up", { via = "boot" }) +eq(playing(), "title.wav", + "HotReload's { via = \"boot\" } setMap still fades, not an instant swap") +finishFade() +eq(playing(), "route.wav", "...landing on the reloaded map's song") + +T.finish("resume_boot_music_no_fade")