mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
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:
@@ -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
File diff suppressed because it is too large
Load Diff
+545
-232
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user