From 190c03cfca50dd5e80bfafffa113f2c14c6674b4 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Mon, 24 Aug 2026 15:24:10 +0100 Subject: [PATCH] perf(launcher): keep mod index navigation responsive --- src/import/LauncherView.lua | 17 +++-- src/import/RomImporter.lua | 91 +++++++++++++++++++---- src/mods/LauncherMods.lua | 14 ++++ tests/engine/launcher_mod_downloads.lua | 5 +- tests/engine/launcher_navigation_perf.lua | 88 ++++++++++++++++++++++ 5 files changed, 190 insertions(+), 25 deletions(-) create mode 100644 tests/engine/launcher_navigation_perf.lua diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index fe8b77e9..dbe4778e 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -3056,8 +3056,8 @@ local function buildFindKindRow(imp, x, y, w, m) end local function buildFindPanel(imp, x, y, w, availH, m) + imp._findVisibleEntries = nil imp:_ensureFind() - imp:_ensureMods() local ModIndex = require("src.mods.ModIndex") local ModUpdate = require("src.mods.ModUpdate") local sources = imp.findSources or {} @@ -3126,7 +3126,9 @@ local function buildFindPanel(imp, x, y, w, availH, m) if #rows == 0 then local empty - if carts then + if imp._findFetch then + empty = Strings("Loading mod index...") + elseif carts then empty = (total == 0) and Strings("This index lists no carts yet.") or Strings("No carts match that search.") else @@ -3191,6 +3193,11 @@ local function buildFindPanel(imp, x, y, w, availH, m) local listTop = cy setPage(imp, "find", Kit.wheelPage(x, listTop, w, listH, cur, #rows, perPage)) + local visible = imp._findVisibleEntries or {} + for i = #visible, 1, -1 do visible[i] = nil end + for i = first, last do visible[#visible + 1] = rows[i] end + imp._findVisibleEntries = visible + for i = first, last do local entry = rows[i] local ry = listTop + (i - first) * (rowH + gap) @@ -5764,12 +5771,6 @@ local function loaderSpec(imp) return { title = b.title, detail = b.detail, progress = b.progress, onCancel = b.cancel } end - -- The boot prewarm runs without an overlay (the user did not ask for it and - -- must be able to use the launcher meanwhile), but if they reach the Find - -- Mods tab before it lands, THEN they are waiting on it and it earns one. - if imp.tab == "find" and imp._findFetch and not imp.findLoaded then - return { title = Strings("Loading mod index") } - end return nil end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d0671ad7..89853925 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1390,8 +1390,9 @@ function RomImporter.new(onComplete, opts) -- player's index list from options; findIndex is the merged listing; -- _findThumbs caches one image per mod id (false = fetched and failed). findLoaded = false, findSources = nil, findIndex = nil, - findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, - _findSearchFocus = false, _findThumbs = nil, + findInstalled = nil, findScroll = 0, findNotice = nil, findQuery = "", + findCategory = nil, _findSearchFocus = false, _findThumbs = nil, + _findVisibleEntries = nil, -- Which half of the feed the panel is browsing. Mods by default: carts -- are the newer, much shorter list, and a feed may carry none at all. findKind = "mods", findBase = nil, @@ -2589,6 +2590,7 @@ function RomImporter:update(dt) -- before a tab switch still completes. self:_pumpFindFetch() self:_pumpModInfoFetch() + self:_queueFindEnrichment() self:_pumpFindStats() self:_pumpFindThumbs() self:_pumpSkinFetch() @@ -3226,6 +3228,7 @@ end -- offset persists inside the view's per-tab scroll container. function RomImporter:_switchTab(id) self.tab = id + if id ~= "find" then self._findVisibleEntries = nil end self._findSearchFocus = false self._skinUrlFocus = false self:_disarmTextInput() @@ -3823,6 +3826,7 @@ function RomImporter:_toggleSafeMode() SaveData.saveOptions(options) self.safeMode = enabled self.mods = nil + self.findInstalled = nil self._modSortCache = nil self._modInfoFetch = nil self.modNotice = nil @@ -4798,6 +4802,7 @@ function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") local SaveData = require("src.core.SaveData") self._cartPlan = nil + self.findInstalled = nil self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) -- Once per session, ahead of the first listing: pull in any mod the player -- unzipped beside the executable, which an ordinary (non-portable) install @@ -5718,7 +5723,8 @@ end -- 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. +-- _pumpFindFetch. Only an explicit Refresh is blocking; boot prewarm and the +-- first visit keep the launcher interactive while the listing arrives. function RomImporter:_refreshFind(force) -- The notice is the fix, not the gate (#876). This branch used to return an -- empty listing silently, and because the player had by then added a source, @@ -5756,9 +5762,11 @@ function RomImporter:_refreshFind(force) carts = {}, cartSeen = {}, bases = {}, baseSeen = {}, 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)) + if force == true then + self:_setBusy(Strings("Fetching mod index"), + #sources == 1 and (sources[1].label or sources[1].feed) + or Strings("%d indexes", #sources)) + end end -- Drive the in-flight index fetch one frame at a time. Called from update(). @@ -5958,9 +5966,17 @@ end function RomImporter:_findInstalledMap() if self:findingCarts() then return self:_findInstalledCarts() end - local map = {} - for _, m in ipairs(self.mods or {}) do map[m.id] = m.version or true end - return map + if self.findInstalled then return self.findInstalled end + local LauncherMods = require("src.mods.LauncherMods") + if self.mods then + self.findInstalled = {} + for _, m in ipairs(self.mods) do + self.findInstalled[m.id] = m.version or true + end + else + self.findInstalled = LauncherMods.installedVersions() or {} + end + return self.findInstalled end -- id -> installed version for every cart on disk, whatever game it plays as. @@ -5978,10 +5994,9 @@ function RomImporter:_findInstalledCarts() return map end --- One thumbnail per frame, and only for a card actually on screen: the fetch --- is a blocking curl, so downloading a whole listing's worth on open would --- stall the launcher for as many seconds as there are mods. A failure is --- remembered as `false` so a broken URL is tried once, not every frame. +-- Read the cached thumbnail for a card. Starting a download is deliberately +-- separate: immediate-mode draw may call this for every visible row, but it +-- must not mutate the fetch queue or perform network work. function RomImporter:_findThumb(entry) self._findThumbs = self._findThumbs or {} local cached = self._findThumbs[entry.id] @@ -5992,6 +6007,20 @@ function RomImporter:_findThumb(entry) self._findThumbs[entry.id] = false return nil end + return nil +end + +-- Queue one thumbnail after draw has recorded the visible rows. The fetch +-- pool runs off-thread; only the finished image decode stays in update(). +function RomImporter:_startFindThumb(entry) + self._findThumbs = self._findThumbs or {} + if self._findThumbs[entry.id] ~= nil then return end + local ModIndex = require("src.mods.ModIndex") + local url = ModIndex.joinUrl(entry._base, entry.thumbnail) + if not url then + self._findThumbs[entry.id] = false + return + end -- 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 @@ -6112,9 +6141,9 @@ function RomImporter:_requestFindStats(entry) } end --- Request-and-read, for a row that is being drawn and for the detail modal. +-- Read-only accessor for a row being drawn or shown in the detail modal. +-- Network work is scheduled by _queueFindEnrichment from update(). function RomImporter:_findStats(entry) - self:_requestFindStats(entry) return self:_findStatsCached(entry) end @@ -6134,6 +6163,38 @@ function RomImporter:_findThumbPending(id) return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil end +-- Draw records the visible page in _findVisibleEntries. Queue only a small +-- batch from that snapshot during update(), keeping network scheduling out of +-- the immediate-mode render path and preventing a large index from creating a +-- burst of thumbnail/GitHub work in one frame. +local FIND_ENRICH_PER_FRAME = 2 + +function RomImporter:_queueFindEnrichment() + if self.tab ~= "find" or not self.findLoaded then return end + local visible = self._findVisibleEntries + if not visible then return end + local thumbnails, stats = 0, 0 + for _, entry in ipairs(visible) do + if thumbnails < FIND_ENRICH_PER_FRAME + and self:_findThumb(entry) == nil + and not self:_findThumbPending(entry.id) then + self:_startFindThumb(entry) + thumbnails = thumbnails + 1 + end + if stats < FIND_ENRICH_PER_FRAME + and self:_findStatsCached(entry) == nil + and entry.github and entry.github ~= "" + and not self:_findStatsPendingFor(entry.id) then + self:_requestFindStats(entry) + stats = stats + 1 + end + if thumbnails >= FIND_ENRICH_PER_FRAME + and stats >= FIND_ENRICH_PER_FRAME then + break + end + end +end + -- Drive in-flight FIND MODS stats lookups. Called from update(). function RomImporter:_pumpFindStats() local pending = self._findStatsPending diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 48f989a9..627f7ce2 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -471,6 +471,20 @@ discover = function() return out end +-- installedVersions() -> id -> installed version. MOD INDEX only needs to +-- know whether a listing is already present; it does not need enablement, +-- dependency/conflict status, required-import validation, or migration. Keep +-- that cheap read separate from list(), whose richer work belongs to MODS. +function LauncherMods.installedVersions() + local out = {} + local ok, manifests = pcall(discover) + if not ok then return out end + for _, manifest in ipairs(manifests or {}) do + out[manifest.id] = manifest.version or true + end + return out +end + -- list([version]) -> the mods-panel rows for the current install. Reads the -- same enable-state the loader persists, so a toggle here is what the game -- sees on its next boot; `version` narrows that to one game's answers. diff --git a/tests/engine/launcher_mod_downloads.lua b/tests/engine/launcher_mod_downloads.lua index 7792f59f..90275de7 100644 --- a/tests/engine/launcher_mod_downloads.lua +++ b/tests/engine/launcher_mod_downloads.lua @@ -75,7 +75,8 @@ end do local ri = launcher() check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil, - "a null count is not an answer; the repo is still consulted") + "a null count is not an answer") + ri:_requestFindStats(entry("nulled", nil, "someone/nulled")) eq(#fetched, 1, "which is the fetch the panel already made for dates") local bare = launcher() @@ -83,7 +84,7 @@ do check(stats ~= nil and stats.total == nil, "a listing with neither counts nor a repo is resolved-but-unknown") check(stats.recent == nil, "and has nothing to trend on") - eq(#fetched, 1, "and queues nothing of its own") + eq(#fetched, 1, "and the explicit scheduler queues nothing without a repo") end -- A real zero is not unknown: the index has seen the releases and counted diff --git a/tests/engine/launcher_navigation_perf.lua b/tests/engine/launcher_navigation_perf.lua new file mode 100644 index 00000000..d394bb69 --- /dev/null +++ b/tests/engine/launcher_navigation_perf.lua @@ -0,0 +1,88 @@ +-- Launcher navigation performance seams. MOD INDEX must not pay the full +-- MODS validation pass, background index prefetch must not create a blocking +-- overlay, and visible-row enrichment must be scheduled from update state +-- rather than from immediate-mode draw calls. +-- luajit tests/engine/launcher_navigation_perf.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local LauncherMods = require("src.mods.LauncherMods") +local ModIndex = require("src.mods.ModIndex") +local RomImporter = require("src.import.RomImporter") + +check(type(LauncherMods.installedVersions) == "function", + "LauncherMods exposes a lightweight installed-version scan") + +do + local old = LauncherMods.installedVersions + local called = 0 + LauncherMods.installedVersions = function() + called = called + 1 + return { alpha = "1.2.3" } + end + local imp = setmetatable({}, RomImporter) + local installed = imp:_findInstalledMap() + eq(called, 1, "MOD INDEX asks for the lightweight scan when MODS is cold") + eq(installed.alpha, "1.2.3", + "the lightweight scan supplies installed versions") + LauncherMods.installedVersions = old +end + +do + local imp = setmetatable({ tab = "find", findLoaded = false }, RomImporter) + imp._refreshFindSources = function(self) + self.findSources = { { feed = "https://example.invalid/index.json" } } + end + local oldBegin = ModIndex.beginFetch + ModIndex.beginFetch = function() return { test = true } end + imp:_refreshFind(false) + check(imp._findFetch ~= nil, "background index refresh starts asynchronously") + eq(imp._busy, nil, + "background index refresh does not block navigation with a loader") + imp:_clearBusy() + imp._findFetch = nil + ModIndex.beginFetch = oldBegin +end + +do + local requests = 0 + local imp = setmetatable({ tab = "find", findLoaded = true, + _findVisibleEntries = { + { id = "one", thumbnail = "one.png", github = "a/one" }, + { id = "two", thumbnail = "two.png", github = "a/two" }, + { id = "three", thumbnail = "three.png", github = "a/three" }, + } }, RomImporter) + imp._findThumb = function() return nil end + imp._findThumbPending = function() return false end + imp._findStatsCached = function() return nil end + imp._startFindThumb = function() requests = requests + 1 end + imp._requestFindStats = function() requests = requests + 1 end + imp:_queueFindEnrichment() + eq(requests, 4, + "update schedules a bounded thumbnail and stats batch for visible rows") +end + +do + local requests = 0 + local imp = setmetatable({}, RomImporter) + imp._findStatsCached = function() return nil end + imp._requestFindStats = function() requests = requests + 1 end + imp:_findStats({ id = "draw-only", github = "a/draw-only" }) + eq(requests, 0, "reading row stats during draw never starts a request") +end + +do + local f = assert(io.open("src/import/LauncherView.lua", "rb")) + local src = f:read("*a") + f:close() + local start = assert(src:find("local function buildFindPanel", 1, true)) + local finish = assert(src:find("\nlocal function ", start + 1, true)) + local panel = src:sub(start, finish - 1) + check(not panel:find("imp:_ensureMods()", 1, true), + "MOD INDEX panel does not force the full MODS list") +end + +T.finish("launcher_navigation_perf")