Rebuild the launcher and save editor on a small immediate-mode UI kit

The launcher spent ~9ms per frame building and drawing, and the Find Mods
tab could hang the window for minutes. Both had the same root cause: a
retained UI tree rebuilt every frame, and blocking curl calls made from the
draw path.

Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit,
Theme, Layout, Loader). The kit caches Text objects and all measurement,
allocates nothing in the steady state, and draws flat. Build+draw is now
under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF).

Move every network call off the render thread onto a love.thread pool
(src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab
stats, thumbnails and mod installs. Mod indexes prewarm at boot so the
Find Mods tab is populated before it is opened.

Paginate every list -- mods, find, save slots, settings, release notes,
versions -- with the page size derived from the real viewport height, so a
500-mod index costs what a 10-mod one does. Scrolling is gone.

Anything that waits now raises a non-dismissable loader; per-row background
work shows an inline spinner instead. The in-app updater moves to the top
right beside the settings gear and pulses when an update is waiting.

Theme is black with white outlines, no gradients or glows, and solid
colour-coded embossed buttons with bold labels. The game tabs keep their
cartridge colours. Everything is 1.3x larger. The save editor shares the
theme, and adding an item there is now a searchable pop-up like adding a
Pokemon.

Also:
- Reset rebinds, in Settings and under Touch Controls. Rebinds are additive
  (Input:applyBindings layers them over the defaults), so there was no
  in-game way to undo one.
- Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into
  a game for shortcuts and frontends, falling back to that game's tab when
  its ROM is not imported.

Fixes found while porting:
- Ellipsis and letterspacing truncated bytes, not codepoints, so a
  multi-byte mod name crashed the first frame on a Japanese index.
  Measurement no longer throws on malformed input either.
- The new font set missed UiFont's kana fallback, rendering translated
  builds as tofu.
- Fetch workers idle in Channel:demand() and LOVE waits for live threads at
  exit, so the process outlived the window; quitting mid-download also
  waited on curl's 300s ceiling. Shut the pool down in love.quit and bound
  its transfer timeouts.
- In one column the save-slot card drew below the fold, over the footer,
  with no scrollbar left to reach it.

The two FlexLove engine tests guarded a scroll manager and an auto-height
propagation bug that no longer exist; replace them with a kit suite covering
page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test
to assert the dependency is gone rather than that its perf guards are set.
This commit is contained in:
bryanthaboi
2026-08-04 15:17:09 -04:00
parent 8fbe819493
commit af47e19e1a
78 changed files with 4785 additions and 33397 deletions
+20
View File
@@ -245,6 +245,26 @@ local function coreRows(opts)
end
end
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the
-- defaults rather than replacing them), so a player who has bound
-- themselves into a corner has no in-game way back -- there is no "unbind"
-- gesture. Clearing the table restores the stock keyboard and pad layout
-- on the next Input:applyBindings, which the game does on its next start.
-- The dragged touch-overlay layout goes with it: it is the same class of
-- customisation and the same class of getting stuck.
rows[#rows + 1] = {
label = Strings("RESET REBINDS"),
actionLabel = Strings("Reset"),
danger = true,
action = function()
opts.bindings = nil
local tc = opts.touchControls
if type(tc) == "table" then tc.layouts = nil end
return true
end,
}
return rows
end
+1432 -1712
View File
File diff suppressed because it is too large Load Diff
+545 -232
View File
@@ -1049,7 +1049,13 @@ function RomImporter.new(onComplete, opts)
-- for click hit-testing inside EventHandler.
touchPollable = mobileFileBridge and love.touch ~= nil
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
-- Active launcher tab: "red"/"blue"/"yellow"/"mods"/"find". A --game
-- shortcut for a version that is not importable yet lands here, so the
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
tab = (function()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
return (okLO and LO.pendingTab) or "red"
end)(),
logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"),
ready = {}, returning = {}, romName = {},
@@ -1159,9 +1165,9 @@ function RomImporter.new(onComplete, opts)
end
-- Self-updater: the interactive launcher on a real fused build kicks off one
-- async release check as it comes up; draw() polls Check.state() to render an
-- unobtrusive banner beneath the columns. Held behind pcall so a broken or
-- absent updater can never take the launcher down with it.
-- async release check as it comes up; the top-right update control polls
-- Check.state() and glows when there is something to do. Held behind pcall
-- so a broken or absent updater can never take the launcher down with it.
if self.launcher and updaterAllowed() then
local ok, Check = pcall(require, "src.update.Check")
if ok and Check then
@@ -1170,6 +1176,26 @@ function RomImporter.new(onComplete, opts)
end
end
-- PREWARM. Start the mod-index fetch at boot rather than when the Find
-- Mods tab is first opened. The work is identical either way, but doing it
-- now means it overlaps the time the user spends looking at the game tab,
-- so the tab is already populated when they reach it instead of greeting
-- them with a loader. Nothing here blocks: the fetch pool is off-thread
-- and _pumpFindFetch collects the result whenever it lands.
--
-- Deliberately NOT behind the blocking overlay: the user did not ask for
-- this and must be able to use the launcher while it runs, so _busy is
-- cleared straight back out. An explicit Refresh press still shows one.
if self.launcher then
pcall(function()
self:_refreshFindSources()
if #(self.findSources or {}) > 0 then
self:_refreshFind(false)
self:_clearBusy()
end
end)
end
-- On Linux handhelds / NX a gamepad is usually already connected at boot;
-- arm the virtual cursor immediately so the player does not have to press a
-- button before seeing something move. Desktop keeps the cursor latent
@@ -1815,6 +1841,17 @@ function RomImporter:update(dt)
if self._flex then
require("src.import.LauncherView").update(self, dt)
end
-- Drive every in-flight async fetch. These are the operations that used to
-- run synchronously inside draw and freeze the window; each pump is a
-- non-blocking channel poll, so a frame with nothing in flight costs
-- nothing. They run whether or not the view is up, so a refresh started
-- before a tab switch still completes.
self:_pumpFindFetch()
self:_pumpModInfoFetch()
self:_pumpFindStats()
self:_pumpFindThumbs()
self:_pumpModCheck()
self:_pumpModInstall()
-- Dev harness: POKEPORT_LAUNCHER_SHOT=/path.png resizes the window from
-- POKEPORT_WIN=WxH, lets the view settle, then captures one frame and
-- quits, so a scripted run can see the real launcher at any window shape
@@ -2235,8 +2272,28 @@ end
-- it rebuilds the element tree from this importer's state every frame and
-- renders it. Required lazily so a headless test require of this module
-- never loads the UI toolkit.
-- Dev harness: POKEPORT_LAUNCHER_PROF=<frames> times the view's build+draw
-- for that many frames, prints mean/median/p95/worst to stdout and quits.
-- Pair with POKEPORT_LAUNCHER_TAB / POKEPORT_WIN to profile a specific panel.
local profN, profSamples = tonumber(os.getenv("POKEPORT_LAUNCHER_PROF") or ""), {}
function RomImporter:draw()
require("src.import.LauncherView").draw(self)
local View = require("src.import.LauncherView")
if not profN then return View.draw(self) end
local t0 = love.timer.getTime()
View.draw(self)
profSamples[#profSamples + 1] = (love.timer.getTime() - t0) * 1000
if #profSamples >= profN + 30 then
local s = {}
for i = 31, #profSamples do s[#s + 1] = profSamples[i] end -- drop warmup
table.sort(s)
local sum = 0
for _, v in ipairs(s) do sum = sum + v end
io.stderr:write(("PROF frames=%d mean=%.2fms median=%.2fms p95=%.2fms worst=%.2fms\n")
:format(#s, sum / #s, s[math.ceil(#s * 0.5)], s[math.ceil(#s * 0.95)], s[#s]))
io.stderr:flush()
love.event.quit()
end
end
-- Nothing in the launcher can undo a delete, so every Delete control asks
@@ -2430,6 +2487,12 @@ function RomImporter:keypressed(key)
return
end
if self.workState == "working" then return end
-- Keyboard focus ring: arrows move it, Enter activates it -- but only once
-- the arrows have been used, so the long-standing "Enter plays the visible
-- game" shortcut below still works for anyone who never touches the ring.
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "return" or key == "space" or key == "kpenter" then
-- Enter acts on the visible game tab: Play if its ROM is ready, otherwise
-- open its picker. The mods tab has no keyboard action.
@@ -2614,57 +2677,88 @@ end
-- Resolve cached (or freshly fetched) GitHub status for every mod that
-- declares a github field. force=true bypasses the 6h cache on every repo.
-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }.
-- ASYNC (was synchronous). This runs on every _refreshMods -- boot, and any
-- toggle or install -- and used to make one blocking curl call per mod with a
-- github field, in a loop, on the render thread. A handful of mods was a
-- multi-second freeze of the whole launcher. Now each mod gets a handle and
-- they resolve together across later frames; a mod whose cache is still fresh
-- resolves on the first pump with no network at all.
function RomImporter:_syncModUpdateInfo(force)
local ModUpdate = require("src.mods.ModUpdate")
self.modUpdateInfo = self.modUpdateInfo or {}
local pending = {}
for _, m in ipairs(self.mods or {}) do
if m.github and m.github ~= "" then
local ok, packed = pcall(function()
local releases, err, meta = ModUpdate.fetchReleases(m.github, m.id, {
force = force == true,
})
local cached = ModUpdate.readCache(m.github)
return {
releases = releases,
err = err,
meta = meta,
checkedAt = (cached and cached.checkedAt) or os.time(),
}
end)
if not ok then
self.modUpdateInfo[m.id] = {
status = "error", err = tostring(packed),
}
elseif packed.releases then
local status, best = ModUpdate.statusFor(m.version, packed.releases)
self.modUpdateInfo[m.id] = {
status = status,
latest = best and best.version or nil,
best = best,
releases = packed.releases,
downloads = ModUpdate.totalDownloads(packed.releases),
dates = ModUpdate.releaseDates(packed.releases),
err = nil,
checkedAt = packed.checkedAt or os.time(),
}
else
self.modUpdateInfo[m.id] = {
status = "error",
latest = nil,
best = nil,
releases = nil,
err = tostring(packed.err),
}
end
pending[#pending + 1] = { mod = m,
h = ModUpdate.beginFetchReleases(m.github, m.id, { force = force == true }) }
else
self.modUpdateInfo[m.id] = nil
end
end
-- Bump so the view's sorted-list cache (keyed on this revision) rebuilds
-- when release/download data actually changes, not every frame.
self._modInfoFetch = (#pending > 0) and pending or nil
-- Bump immediately so a mod that lost its github field (or a list that
-- shrank) is reflected without waiting on the network.
self._modUpdateRev = (self._modUpdateRev or 0) + 1
end
-- Drive in-flight release checks one frame at a time. Called from update().
-- Deliberately NOT behind the blocking overlay: this is background enrichment
-- of rows that are already usable, so the list stays interactive while the
-- download counts and update badges fill in. Individual rows show their own
-- inline spinner instead.
function RomImporter:_pumpModInfoFetch()
local pending = self._modInfoFetch
if not pending then return end
local ModUpdate = require("src.mods.ModUpdate")
local remaining, changed = {}, false
for _, item in ipairs(pending) do
local m = item.mod
local ok, done, releases, err = pcall(ModUpdate.pumpFetchReleases, item.h)
if not ok then
self.modUpdateInfo[m.id] = { status = "error", err = tostring(done) }
changed = true
elseif done then
changed = true
if releases then
local status, best = ModUpdate.statusFor(m.version, releases)
local cached = ModUpdate.readCache(m.github)
self.modUpdateInfo[m.id] = {
status = status,
latest = best and best.version or nil,
best = best,
releases = releases,
downloads = ModUpdate.totalDownloads(releases),
dates = ModUpdate.releaseDates(releases),
err = nil,
checkedAt = (cached and cached.checkedAt) or os.time(),
}
else
self.modUpdateInfo[m.id] = {
status = "error", latest = nil, best = nil, releases = nil,
err = tostring(err),
}
end
else
remaining[#remaining + 1] = item
end
end
self._modInfoFetch = (#remaining > 0) and remaining or nil
if changed then
-- Bump so the view's sorted-list cache (keyed on this revision) rebuilds
-- when release/download data actually changes, not every frame.
self._modUpdateRev = (self._modUpdateRev or 0) + 1
end
end
-- True while any mod's release check is still in flight, so a row can show
-- an inline spinner instead of "Not checked for updates yet".
function RomImporter:_modInfoPending(id)
for _, item in ipairs(self._modInfoFetch or {}) do
if item.mod.id == id then return true end
end
return false
end
function RomImporter:_modUpdateInfo(id)
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
end
@@ -2753,41 +2847,19 @@ function RomImporter:_modGithubAction(id, action)
text = "Remote mod download is unavailable on this platform." }
return
end
local ran, err = pcall(function()
local ModUpdate = require("src.mods.ModUpdate")
local row
for _, m in ipairs(self.mods or {}) do
if m.id == id then row = m; break end
end
if not row or not row.github then
self.modNotice = { ok = false, text = "This mod has no github field" }
return
end
local ModUpdate = require("src.mods.ModUpdate")
local row
for _, m in ipairs(self.mods or {}) do
if m.id == id then row = m; break end
end
if not row or not row.github then
self.modNotice = { ok = false, text = "This mod has no github field" }
return
end
if action == "versions" then
self.modNotice = { ok = true, text = "Loading versions..." }
local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, {})
if not releases then
self.modNotice = { ok = false, text = tostring(fetchErr) }
return
end
local status, best = ModUpdate.statusFor(row.version, releases)
self.modUpdateInfo = self.modUpdateInfo or {}
self.modUpdateInfo[row.id] = {
status = status, latest = best and best.version, best = best,
releases = releases,
downloads = ModUpdate.totalDownloads(releases),
dates = ModUpdate.releaseDates(releases),
}
self._modVersions = {
id = row.id, name = row.name, current = row.version,
releases = releases, scroll = 0,
}
self.modNotice = nil
return
end
-- update / check
-- Update, when we already know a newer release exists, needs no network:
-- confirm straight away off the cached info.
if action ~= "versions" then
local info = self:_modUpdateInfo(id)
if info and info.status == "available" and info.best then
self._modConfirm = {
@@ -2802,51 +2874,177 @@ function RomImporter:_modGithubAction(id, action)
}
return
end
end
-- Manual check (or first click when status is current/unknown/error)
self.modNotice = { ok = true, text = "Checking " .. row.github .. "..." }
local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, {
force = true,
})
if not releases then
self.modNotice = { ok = false, text = tostring(fetchErr) }
return
end
if #releases == 0 then
self.modNotice = { ok = false, text = "No .zip releases found" }
return
end
local status, best = ModUpdate.statusFor(row.version, releases)
self.modUpdateInfo = self.modUpdateInfo or {}
self.modUpdateInfo[row.id] = {
status = status, latest = best and best.version, best = best,
releases = releases, checkedAt = os.time(),
downloads = ModUpdate.totalDownloads(releases),
dates = ModUpdate.releaseDates(releases),
}
if status == "available" and best then
self.modNotice = { ok = true,
text = row.name .. ": new version available (v" .. best.version .. ")" }
self._modConfirm = {
kind = "update", id = row.id, release = best,
title = "Update available",
yesLabel = "Update",
lines = {
"Update " .. row.name .. "?",
"Installed v" .. tostring(row.version),
"Latest v" .. tostring(best.version),
},
}
else
self.modNotice = { ok = true,
text = row.name .. " is up to date (v"
.. tostring(row.version) .. ")" }
end
end)
if not ran then
-- ASYNC (was a blocking fetch). Both remaining paths -- listing versions
-- and a manual re-check -- hit the GitHub API, which is exactly the call
-- that used to freeze the launcher mid-click. One job at a time.
if self._modCheck then return end
self._modCheck = {
id = row.id, name = row.name, github = row.github,
version = row.version, action = action,
h = ModUpdate.beginFetchReleases(row.github, row.id,
{ force = action ~= "versions" }),
}
self:_setBusy(action == "versions" and Strings("Loading versions")
or Strings("Checking for updates"), row.name)
end
-- Drive the in-flight per-mod release check. Called from _pumpModInfoFetch's
-- neighbourhood in update(); kept separate because this one IS behind the
-- blocking overlay (the user pressed a button and is waiting on the answer).
function RomImporter:_pumpModCheck()
local job = self._modCheck
if not job then return end
local ModUpdate = require("src.mods.ModUpdate")
local ok, done, releases, err = pcall(ModUpdate.pumpFetchReleases, job.h)
if ok and not done then return end
self._modCheck = nil
self:_clearBusy()
if not ok then
self._modVersions = nil
self.modNotice = { ok = false,
text = "Update failed: " .. tostring(err) }
self.modNotice = { ok = false, text = "Update failed: " .. tostring(done) }
return
end
if not releases then
self.modNotice = { ok = false, text = tostring(err) }
return
end
if #releases == 0 then
self.modNotice = { ok = false, text = "No .zip releases found" }
return
end
local status, best = ModUpdate.statusFor(job.version, releases)
self.modUpdateInfo = self.modUpdateInfo or {}
self.modUpdateInfo[job.id] = {
status = status, latest = best and best.version, best = best,
releases = releases, checkedAt = os.time(),
downloads = ModUpdate.totalDownloads(releases),
dates = ModUpdate.releaseDates(releases),
}
self._modUpdateRev = (self._modUpdateRev or 0) + 1
if job.action == "versions" then
self._modVersions = {
id = job.id, name = job.name, current = job.version,
releases = releases, page = 1,
}
self.modNotice = nil
return
end
if status == "available" and best then
self.modNotice = { ok = true,
text = job.name .. ": new version available (v" .. best.version .. ")" }
self._modConfirm = {
kind = "update", id = job.id, release = best,
title = "Update available",
yesLabel = "Update",
lines = {
"Update " .. job.name .. "?",
"Installed v" .. tostring(job.version),
"Latest v" .. tostring(best.version),
},
}
else
self.modNotice = { ok = true,
text = job.name .. " is up to date (v" .. tostring(job.version) .. ")" }
end
end
-- ------- mod install / update (async download, blocking unzip)
--
-- The download is the slow half and now runs on the fetch pool behind a
-- non-dismissable loader; unzipping the finished archive is fast and stays
-- on the main thread, where love.filesystem belongs. All three entry points
-- (update a mod, install a specific version, install from an index) funnel
-- into one in-flight job, so two installs can never race for the same id.
--
-- `spec` = { modId, name, release, notice = "mod"|"find", verb, entry }
function RomImporter:_beginModInstall(spec)
if self._modInstall then return end
local ModIndex = require("src.mods.ModIndex")
local release = spec.release
-- An index entry only tells us WHERE the zip is; resolving that is
-- ModIndex's job, exactly as in the synchronous path.
if not release and spec.entry then
local resolved, why = ModIndex.releaseFor(spec.entry)
if not resolved then
self:_modInstallFailed(spec, why or "this mod cannot be installed")
return
end
release = resolved
end
if type(release) ~= "table" or not release.zip or not release.zip.url then
self:_modInstallFailed(spec, "release has no downloadable .zip")
return
end
local version = release.version or os.time()
local tmpName = ("mod_update_%s_%s.zip"):format(tostring(spec.modId),
tostring(version))
local ModUpdate = require("src.mods.ModUpdate")
self._modInstall = {
spec = spec, release = release, version = release.version,
h = ModUpdate.beginDownloadZip(release.zip.url, tmpName,
release.zip.size),
}
self:_setBusy(Strings("Downloading %s", tostring(spec.name or spec.modId)),
"v" .. tostring(release.version or "?"))
end
function RomImporter:_modInstallFailed(spec, msg)
local notice = { ok = false, text = tostring(msg) }
if spec.notice == "find" then self.findNotice = notice
else self.modNotice = notice end
self:_clearBusy()
end
function RomImporter:_pumpModInstall()
local job = self._modInstall
if not job then return end
local ModUpdate = require("src.mods.ModUpdate")
local ok, done, path, err, progress = pcall(ModUpdate.pumpDownloadZip, job.h)
if ok and not done then
-- Feed real download progress into the overlay when the size is known.
if progress and self._busy then self._busy.progress = progress end
return
end
self._modInstall = nil
local spec = job.spec
if not ok then
self:_modInstallFailed(spec, "download failed: " .. tostring(done))
return
end
if not path then
self:_modInstallFailed(spec, err or "download failed")
return
end
-- Unzip + manifest check. Fast, and it must run here: love.filesystem
-- writes are main-thread only.
self:_setBusy(Strings("Installing %s", tostring(spec.name or spec.modId)))
local LauncherMods = require("src.mods.LauncherMods")
local ran, res, resErr = pcall(LauncherMods.installDownloadedZip,
spec.modId, path, job.version)
self:_clearBusy()
if not ran then
self:_modInstallFailed(spec, "install failed: " .. tostring(res))
return
end
if not res then
self:_modInstallFailed(spec, resErr or "install failed")
return
end
-- The installed list is what the Install / Installed labels read, so it has
-- to be re-derived before the next paint or the card lies.
pcall(self._refreshMods, self)
local shown = tostring(resErr or job.version or "")
local text = ("%s %s %s"):format(spec.verb or "Installed",
tostring(spec.name or spec.modId), shown)
if spec.notice == "find" then
self.findNotice = { ok = true, text = text }
else
self.modNotice = { ok = true, text = text }
end
end
@@ -2855,45 +3053,19 @@ function RomImporter:_confirmModUpdate(modId, release)
for _, m in ipairs(self.mods or {}) do
if m.id == modId then row = m; break end
end
local name = row and row.name or modId
self.modNotice = { ok = true,
text = "Downloading " .. tostring(release and release.version or "?") .. "..." }
local ran, err = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.installFromRelease(modId, release)
if ok then
pcall(self._refreshMods, self)
self.modNotice = { ok = true,
text = "Updated " .. name .. " to " .. tostring(res) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end)
if not ran then
self.modNotice = { ok = false, text = "Update failed: " .. tostring(err) }
end
self:_beginModInstall({
modId = modId, name = row and row.name or modId,
release = release, verb = "Updated", notice = "mod",
})
end
function RomImporter:_installModVersion(modId, release)
self._modVersions = nil
self._modReleaseNotes = nil
local version = release and release.version or "?"
self.modNotice = { ok = true, text = "Downloading " .. tostring(version) .. "..." }
local ran, err = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.installFromRelease(modId, release)
if ok then
pcall(self._refreshMods, self)
self.modNotice = { ok = true,
text = "Installed " .. tostring(modId) .. " " .. tostring(res) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end)
if not ran then
self.modNotice = { ok = false,
text = "Install failed: " .. tostring(err) }
end
self:_beginModInstall({
modId = modId, name = modId, release = release,
verb = "Installed", notice = "mod",
})
end
@@ -2969,6 +3141,12 @@ end
-- there is one "nuzlocke" as far as the installer is concerned, so the panel
-- must not offer two. Per-source failures are collected rather than fatal: an
-- index that is down should cost its own rows, not everybody else's.
-- ASYNC (was synchronous). Every source used to be fetched with a blocking
-- curl call inside the draw path, so opening Find Mods froze the window for
-- as long as the slowest index took -- measured at over two minutes on a
-- cold open, with no spinner, because the frame that would have drawn one
-- never ran. The fetch now starts here and completes across later frames in
-- _pumpFindFetch; the loader overlay is up for the whole flight.
function RomImporter:_refreshFind(force)
if not Platform.networkValidated() then
self.findLoaded = true
@@ -2977,48 +3155,145 @@ function RomImporter:_refreshFind(force)
end
local ModIndex = require("src.mods.ModIndex")
self:_refreshFindSources()
local mods, seen, cats, catSeen, errs = {}, {}, {}, {}, {}
local stale, oldest = false, nil
for _, source in ipairs(self.findSources or {}) do
local ok, index, err, meta = pcall(function()
return ModIndex.fetch(source, { force = force == true })
end)
if not ok then
errs[#errs + 1] = (source.label or source.feed) .. ": " .. tostring(index)
elseif not index then
errs[#errs + 1] = (source.label or source.feed) .. ": " .. tostring(err)
else
if meta and meta.stale then stale = true end
if meta and meta.checkedAt then
oldest = math.min(oldest or meta.checkedAt, meta.checkedAt)
end
for _, entry in ipairs(index.mods or {}) do
if not seen[entry.id] then
seen[entry.id] = true
entry._source = source.label or source.feed
entry._base = source.base
mods[#mods + 1] = entry
local sources = self.findSources or {}
if #sources == 0 then
self.findIndex = { mods = {}, categories = {} }
self.findLoaded = true
return
end
-- One in-flight refresh at a time: a second Refresh press while the first
-- is running would double-count every row into the merge.
if self._findFetch then return end
local handles = {}
for i, source in ipairs(sources) do
handles[i] = { source = source,
h = ModIndex.beginFetch(source, { force = force == true }) }
end
self._findFetch = {
handles = handles, force = force == true,
mods = {}, seen = {}, cats = {}, catSeen = {}, errs = {},
stale = false, oldest = nil, at = 1,
}
self:_setBusy(Strings("Fetching mod index"),
#sources == 1 and (sources[1].label or sources[1].feed)
or Strings("%d indexes", #sources))
end
-- Drive the in-flight index fetch one frame at a time. Called from update().
function RomImporter:_pumpFindFetch()
local f = self._findFetch
if not f then return end
local ModIndex = require("src.mods.ModIndex")
-- Pump every handle each frame; they run concurrently on the fetch pool.
local allDone = true
for _, item in ipairs(f.handles) do
if not item.done then
local ok, done, index, err, meta = pcall(ModIndex.pumpFetch, item.h)
if not ok then
item.done = true
f.errs[#f.errs + 1] = (item.source.label or item.source.feed)
.. ": " .. tostring(done)
elseif done then
item.done = true
if not index then
f.errs[#f.errs + 1] = (item.source.label or item.source.feed)
.. ": " .. tostring(err)
else
item.index, item.meta = index, meta
end
end
for _, c in ipairs(ModIndex.categoriesIn(index)) do
if not catSeen[c] then catSeen[c] = true; cats[#cats + 1] = c end
else
allDone = false
end
end
end
self.findIndex = { mods = mods, categories = cats, stale = stale,
checkedAt = oldest }
self._busyCount = nil
if not allDone then return end
-- Merge in SOURCE ORDER, not completion order: first source wins on a
-- duplicate id, matching how the mod loader resolves two mods with one id,
-- and that rule has to be stable regardless of which index answered first.
for _, item in ipairs(f.handles) do
local index, meta, source = item.index, item.meta, item.source
if index then
if meta and meta.stale then f.stale = true end
if meta and meta.checkedAt then
f.oldest = math.min(f.oldest or meta.checkedAt, meta.checkedAt)
end
for _, entry in ipairs(index.mods or {}) do
if not f.seen[entry.id] then
f.seen[entry.id] = true
entry._source = source.label or source.feed
entry._base = source.base
f.mods[#f.mods + 1] = entry
end
end
for _, c in ipairs(ModIndex.categoriesIn(index)) do
if not f.catSeen[c] then
f.catSeen[c] = true
f.cats[#f.cats + 1] = c
end
end
end
end
self.findIndex = { mods = f.mods, categories = f.cats, stale = f.stale,
checkedAt = f.oldest }
self.findLoaded = true
if #errs > 0 then
self.findNotice = { ok = false, text = table.concat(errs, " - ") }
elseif force then
if #f.errs > 0 then
self.findNotice = { ok = false, text = table.concat(f.errs, " - ") }
elseif f.force then
self.findNotice = { ok = true,
text = Strings("Refreshed - %d mods listed", #mods) }
text = Strings("Refreshed - %d mods listed", #f.mods) }
end
-- A category that no longer exists after a refresh would filter everything
-- away with no way back except guessing.
if self.findCategory and not catSeen[self.findCategory] then
if self.findCategory and not f.catSeen[self.findCategory] then
self.findCategory = nil
end
self.findPage = 1
self._findFetch = nil
self:_clearBusy()
end
-- Clear every input rebind and the dragged touch-overlay layout, restoring
-- the stock keyboard/gamepad bindings. Rebinds are ADDITIVE
-- (src/core/Input.lua:applyBindings layers options.bindings over the
-- defaults instead of replacing them), so a player who has bound themselves
-- into a corner has no in-game way out; this is it. The running game reads
-- bindings on its next start, which is the same contract every other
-- launcher setting has.
function RomImporter:_resetRebinds()
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.bindings = nil
if type(opts.touchControls) == "table" then
opts.touchControls.layouts = nil
end
SaveData.saveOptions(opts)
end)
-- Its own notice slot: this button lives on the game panel, and borrowing
-- the mods or save notice would print the result on a tab the user is not
-- looking at.
if ok then
self.controlsNotice = { ok = true,
text = Strings("Controls reset to defaults. Applies on the next start.") }
else
self.controlsNotice = { ok = false,
text = Strings("Could not reset controls.") }
end
end
-- ------- busy state (drives the non-dismissable loader overlay)
-- Anything that makes the user wait sets this; LauncherView renders it as a
-- blocking overlay so no operation can ever run invisibly.
function RomImporter:_setBusy(title, detail, cancel)
self._busy = { title = title, detail = detail, cancel = cancel }
end
function RomImporter:_clearBusy()
self._busy = nil
end
function RomImporter:_ensureFind()
@@ -3070,21 +3345,50 @@ function RomImporter:_findThumb(entry)
self._findThumbs = self._findThumbs or {}
local cached = self._findThumbs[entry.id]
if cached ~= nil then return cached or nil end
if self._findThumbFetched then return nil end -- budget spent this frame
local ModIndex = require("src.mods.ModIndex")
local url = ModIndex.joinUrl(entry._base, entry.thumbnail)
if not url then
self._findThumbs[entry.id] = false
return nil
end
self._findThumbFetched = true
local ok, image = pcall(function()
local path, err = ModIndex.downloadThumbnail(url, entry.id)
if not path then error(err or "download failed", 0) end
return love.graphics.newImage(path)
end)
self._findThumbs[entry.id] = ok and image or false
return ok and image or nil
-- ASYNC (was one blocking download per frame). Only rows on the current
-- page ever ask, so pagination already bounds this to a page's worth of
-- requests; the fetch pool runs them off-thread and the card shows its
-- placeholder until the image lands.
self._findThumbFetch = self._findThumbFetch or {}
if not self._findThumbFetch[entry.id] then
local ext = url:match("%.(%a%a%a?%a?)$") or "png"
local name = ("mod_thumb_%s.%s")
:format(tostring(entry.id):gsub("[^%w%-_]", "_"), ext)
local Fetch = require("src.net.Fetch")
self._findThumbFetch[entry.id] = {
job = Fetch.download(url, name, { userAgent = "gen1recomp-mod-index" }),
}
end
return nil
end
-- Turn finished thumbnail downloads into images. Called from update(), so
-- love.graphics.newImage runs on the render thread where it belongs.
function RomImporter:_pumpFindThumbs()
local pending = self._findThumbFetch
if not pending then return end
local Fetch = require("src.net.Fetch")
for id, item in pairs(pending) do
local st = Fetch.poll(item.job)
if st.status ~= "pending" then
Fetch.release(item.job)
pending[id] = nil
local image
if st.status == "ok" and st.path then
local ok, img = pcall(love.graphics.newImage, st.path)
image = ok and img or nil
end
self._findThumbs = self._findThumbs or {}
self._findThumbs[id] = image or false
end
end
if next(pending) == nil then self._findThumbFetch = nil end
end
-- Release stats for a FIND MODS row, resolved the same way the MODS tab
@@ -3110,31 +3414,54 @@ function RomImporter:_findStats(entry)
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._findStatsCache[entry.id] = cached
return cached
end
self._findStatsFetched = true
local ModUpdate = require("src.mods.ModUpdate")
local list, fetchErr
local ok = pcall(function()
list, fetchErr = ModUpdate.fetchReleases(entry.github, entry.id, {})
end)
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 }
-- ASYNC (was a blocking fetch, one row per frame). "One per frame" bounded
-- how many stalls happened at once, not how long each one lasted: every
-- frame that started a fetch blocked for the whole round trip, so scrolling
-- a listing juddered once per row. Rows now queue a handle and fill in
-- when it lands; until then the row simply has no stats line.
self._findStatsPending = self._findStatsPending or {}
if not self._findStatsPending[entry.id] then
local ModUpdate = require("src.mods.ModUpdate")
self._findStatsPending[entry.id] = {
id = entry.id,
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
}
end
self._findStatsCache[entry.id] = cached
return cached
return nil
end
-- Drive in-flight FIND MODS stats lookups. Called from update().
function RomImporter:_pumpFindStats()
local pending = self._findStatsPending
if not pending then return end
local ModUpdate = require("src.mods.ModUpdate")
for id, item in pairs(pending) do
local ok, done, releases, err = pcall(ModUpdate.pumpFetchReleases, item.h)
if not ok or done then
pending[id] = nil
local stats = (ok and releases) and ModUpdate.statsForReleases(releases) or nil
local cached
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(ok and err or done)
:find("Not Found", 1, true) ~= nil
cached = { done = permanent, retryAt = os.time() + 60 }
end
self._findStatsCache = self._findStatsCache or {}
self._findStatsCache[id] = cached
end
end
if next(pending) == nil then self._findStatsPending = nil end
end
-- Open the "add an index" text prompt. Deliberately a typed URL rather than a
@@ -3231,24 +3558,10 @@ function RomImporter:_findConfirmInstall(entry)
end
function RomImporter:_findInstall(entry)
local name = entry.title or entry.id
self.findNotice = { ok = true, text = Strings("Downloading %s...", name) }
local ran, err = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.installFromIndex(entry)
if ok then
-- The installed list is what the Install / Installed labels read, so it
-- has to be re-derived before the next paint or the card lies.
pcall(self._refreshMods, self)
self.findNotice = { ok = true,
text = Strings("Installed %s %s", name, tostring(res)) }
else
self.findNotice = { ok = false, text = tostring(res) }
end
end)
if not ran then
self.findNotice = { ok = false, text = "Install failed: " .. tostring(err) }
end
self:_beginModInstall({
modId = entry.id, name = entry.title or entry.id, entry = entry,
verb = "Installed", notice = "find",
})
end
return RomImporter