perf(launcher): keep mod index navigation responsive

This commit is contained in:
Shane McGovern
2026-08-24 15:24:10 +01:00
parent 07c828cab0
commit 190c03cfca
5 changed files with 190 additions and 25 deletions
+9 -8
View File
@@ -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
+73 -12
View File
@@ -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,10 +5762,12 @@ function RomImporter:_refreshFind(force)
carts = {}, cartSeen = {}, bases = {}, baseSeen = {},
stale = false, oldest = nil, at = 1,
}
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().
function RomImporter:_pumpFindFetch()
@@ -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
+14
View File
@@ -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.
+3 -2
View File
@@ -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
+88
View File
@@ -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")