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
+24
View File
@@ -726,6 +726,30 @@ function LauncherMods.installFromRelease(modId, release)
return result, err
end
-- The install half of installFromRelease, split out so the launcher can run
-- the DOWNLOAD half asynchronously (src/net/Fetch.lua) and still land in the
-- same place. `localPath` is a love.filesystem-relative path to an already
-- downloaded zip; it is consumed (removed) either way.
-- Returns true, version | nil, errString.
function LauncherMods.installDownloadedZip(modId, localPath, version)
local ok, result, err = pcall(function()
if type(modId) ~= "string" or modId == "" then
return nil, "missing mod id"
end
if type(localPath) ~= "string" or localPath == "" then
return nil, "missing downloaded archive"
end
local installed, res = LauncherMods.installZip(localPath, {
replace = true, expectId = modId,
})
pcall(love.filesystem.remove, localPath)
if not installed then return nil, res end
return true, version or res
end)
if not ok then return nil, "install failed: " .. tostring(result) end
return result, err
end
-- Install a mod listed in a community index (src/mods/ModIndex.lua).
-- The index only ever tells us WHERE the zip is; resolving that URL is
-- ModIndex's job and installing it is installFromRelease's, so this is the
+95
View File
@@ -592,6 +592,101 @@ function ModIndex.fetch(source, opts)
return index, nil, { fromCache = false, checkedAt = os.time() }
end
-- ------- async fetch (the launcher's path; ModIndex.fetch above stays as the
-- synchronous one for tests and non-UI callers)
--
-- ModIndex.fetch blocks on curl, which on the render thread froze the Find
-- Mods tab for as long as the server took. These three functions are the
-- same state machine driven a frame at a time over src/net/Fetch.lua:
-- local h = ModIndex.beginFetch(source, { force = true })
-- -- every frame:
-- local done, index, err, meta = ModIndex.pumpFetch(h)
-- pumpFetch returns done=false while the request is in flight. The cache
-- rules are identical to the sync path: a fresh cache short-circuits the
-- network entirely (so the handle completes on its first pump), a failed
-- live fetch falls back to stale cache, and the fallback mirror gets one try
-- before the feed counts as an outage.
function ModIndex.beginFetch(source, opts)
opts = opts or {}
local h = { source = source, opts = opts, stage = "start" }
if type(source) ~= "table" or type(source.feed) ~= "string" then
h.stage, h.err = "done", "missing index source"
return h
end
return h
end
-- Shared with the sync path's `cached` closure: read whatever is in the
-- options cache and shape it like a parsed index.
local function cachedIndex(feed, stale)
local entry = ModIndex.readCache(feed)
if not entry then return nil end
return {
schemaVersion = ModIndex.SCHEMA_VERSION,
generatedAt = entry.generatedAt,
categories = entry.categories or {},
mods = entry.mods or {},
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
end
-- Returns done, index, err, meta.
function ModIndex.pumpFetch(h)
if not h then return true, nil, "no handle" end
local Fetch = require("src.net.Fetch")
local feed = h.source and h.source.feed
if h.stage == "done" then
return true, h.index, h.err, h.meta
end
if h.stage == "start" then
if not h.opts.force then
local entry = ModIndex.readCache(feed)
if ModIndex.cacheFresh(entry) then
h.index, h.err, h.meta = cachedIndex(feed, false)
h.stage = "done"
return true, h.index, h.err, h.meta
end
end
h.job = Fetch.get(feed, { userAgent = "gen1recomp-mod-index" })
h.stage = "feed"
return false
end
local st = Fetch.poll(h.job)
if st.status == "pending" then return false end
Fetch.release(h.job)
if st.status == "ok" and st.body then
local index, parseErr = ModIndex.parse(st.body)
if index then
ModIndex.writeCache(feed, index)
h.index, h.meta = index, { fromCache = false, checkedAt = os.time() }
h.stage = "done"
return true, h.index, nil, h.meta
end
-- A feed that parses badly is an outage as far as the UI is concerned.
h.parseErr = parseErr
end
-- Pages deploys trail a push; the raw mirror is the same file, so a feed
-- that fails right after a release is worth one retry elsewhere.
if h.stage == "feed" and h.source.fallback then
h.job = Fetch.get(h.source.fallback, { userAgent = "gen1recomp-mod-index" })
h.stage = "fallback"
return false
end
local index, _, meta = cachedIndex(feed, true)
h.stage = "done"
if index then
h.index, h.meta = index, meta
return true, index, nil, meta
end
h.err = h.parseErr or st.err or "index fetch failed"
return true, nil, h.err
end
-- Fetch a description_url / any index-relative text file. Returns the raw
-- markdown; callers run it through ModUpdate.cleanBody for display.
function ModIndex.fetchText(url)
+111
View File
@@ -416,6 +416,117 @@ function ModUpdate.fetchReleases(repo, modId, opts)
return list, nil, { fromCache = false }
end
-- ------- async siblings (the launcher's path; the sync functions above stay
-- for tests and non-UI callers)
--
-- Same cache and fallback rules as fetchReleases, driven one frame at a time
-- over src/net/Fetch.lua so a release check never stalls the render thread.
-- local h = ModUpdate.beginFetchReleases(repo, modId, { force = true })
-- local done, releases, err, meta = ModUpdate.pumpFetchReleases(h)
function ModUpdate.beginFetchReleases(repo, modId, opts)
opts = opts or {}
local h = { repo = repo, modId = modId, opts = opts, stage = "start" }
if type(repo) ~= "string" or repo == "" then
h.stage, h.err = "done", "missing github repo"
end
return h
end
local function staleReleases(repo)
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil
end
-- Returns done, releases, err, meta.
function ModUpdate.pumpFetchReleases(h)
if not h then return true, nil, "no handle" end
if h.stage == "done" then return true, h.releases, h.err, h.meta end
local Fetch = require("src.net.Fetch")
if h.stage == "start" then
if not h.opts.force then
local cached = ModUpdate.readCache(h.repo)
if ModUpdate.cacheFresh(cached) and ModUpdate.cacheUsable(cached) then
h.releases, h.meta = cached.releases, { fromCache = true }
h.stage = "done"
return true, h.releases, nil, h.meta
end
end
h.job = Fetch.get(ModUpdate.apiReleasesUrl(h.repo), {
userAgent = "gen1recomp-mod-updater",
accept = "application/vnd.github+json",
})
h.stage = "fetching"
return false
end
local st = Fetch.poll(h.job)
if st.status == "pending" then return false end
Fetch.release(h.job)
h.stage = "done"
if st.status == "ok" and st.body then
local list, parseErr = ModUpdate.parseReleases(st.body, h.modId)
if list then
ModUpdate.writeCache(h.repo, list)
h.releases, h.meta = list, { fromCache = false }
return true, list, nil, h.meta
end
h.err = parseErr
return true, nil, parseErr
end
-- Offline or a failed call: stale cache beats an empty list.
local rel, _, meta = staleReleases(h.repo)
if rel then
h.releases, h.meta = rel, meta
return true, rel, nil, meta
end
h.err = st.err or "release check failed"
return true, nil, h.err
end
-- Async download of a mod zip into the save directory. Returns a handle;
-- pump it for done, savePath, err.
function ModUpdate.beginDownloadZip(url, destName, size)
local h = { stage = "done" }
if type(url) ~= "string" or url == "" then
h.err = "missing download url"
return h
end
if not (love and love.filesystem) then
h.err = "download needs LOVE"
return h
end
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
name = tostring(name):gsub("[/\\]", "_")
local Fetch = require("src.net.Fetch")
h.name = name
h.stage = "fetching"
h.job = Fetch.download(url, name, {
size = size, userAgent = "gen1recomp-mod-updater" })
return h
end
function ModUpdate.pumpDownloadZip(h)
if not h then return true, nil, "no handle" end
if h.stage == "done" then return true, h.path, h.err end
local Fetch = require("src.net.Fetch")
local st = Fetch.poll(h.job)
if st.status == "pending" then return false, nil, nil, st.progress end
Fetch.release(h.job)
h.stage = "done"
if st.status == "ok" then
h.path = st.path or h.name
return true, h.path
end
h.err = st.err or "download failed"
return true, nil, h.err
end
function ModUpdate.downloadZip(url, destName)
if type(url) ~= "string" or url == "" then
return nil, "missing download url"