mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 04:06:10 +02:00
bazinga
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
-- ROM extraction, off the main thread. The extractor's require closure needs
|
||||
-- only love.filesystem, love.image, love.math and love.system, none of which
|
||||
-- are main-thread-only, so it runs here instead of as a coroutine the frame
|
||||
-- loop resumed for 8ms out of every 16.7ms.
|
||||
--
|
||||
-- The caller clears the stale cache and writes the completion marker itself;
|
||||
-- this only fills the tree between those two steps, so the "marker appears
|
||||
-- last" order isReady() depends on stays on one thread.
|
||||
|
||||
require("love.filesystem")
|
||||
require("love.image")
|
||||
require("love.math")
|
||||
require("love.system")
|
||||
require("love.timer")
|
||||
|
||||
local version, prefix, romData, progressName, resultName = ...
|
||||
|
||||
local progressChannel = love.thread.getChannel(progressName)
|
||||
local resultChannel = love.thread.getChannel(resultName)
|
||||
|
||||
-- RomExtractor:tick fires per item, thousands of times per import; a channel
|
||||
-- push each would cost more than the work it reports.
|
||||
local PROGRESS_HZ = 20
|
||||
|
||||
local ok, err = pcall(function()
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
CacheFs.prefix = prefix
|
||||
|
||||
local manifest = require("src.import.RomManifest").decode(version)
|
||||
local RomExtractor = version == "gold"
|
||||
and require("src.import.RomExtractorGen2")
|
||||
or require("src.import.RomExtractor")
|
||||
|
||||
local lastPush, lastStage = 0, nil
|
||||
local extractor = RomExtractor.new(romData, manifest,
|
||||
function(progress, total, stage, current, stageTotal)
|
||||
local now = love.timer.getTime()
|
||||
-- Stage changes always go through, or the caption goes stale.
|
||||
if stage ~= lastStage or now - lastPush >= 1 / PROGRESS_HZ then
|
||||
lastPush, lastStage = now, stage
|
||||
progressChannel:push({
|
||||
progress = progress, total = total, stage = stage,
|
||||
current = current, stageTotal = stageTotal,
|
||||
})
|
||||
end
|
||||
end)
|
||||
extractor:run()
|
||||
end)
|
||||
|
||||
resultChannel:push({ ok = ok, error = ok and nil or tostring(err) })
|
||||
+200
-83
@@ -299,11 +299,6 @@ end
|
||||
|
||||
local CART_DRAG_SLOP = 8
|
||||
local TAU = math.pi * 2
|
||||
-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover
|
||||
-- scale cannot climb into the title row (or the gear) on desktop, high-DPI,
|
||||
-- or a portrait phone. Fraction of the shorter side, with a pixel floor.
|
||||
local CART_MESH_PAD = 0.07
|
||||
local CART_MESH_PAD_MIN = 8
|
||||
|
||||
local function cartridgeState(imp, version)
|
||||
imp._cartridge = imp._cartridge or {}
|
||||
@@ -548,11 +543,8 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
Theme.A.focus, 2, Theme.cardRadius() + 2)
|
||||
end
|
||||
|
||||
local meshPad = math.max(CART_MESH_PAD_MIN,
|
||||
math.floor(math.min(w, h) * CART_MESH_PAD))
|
||||
local halfW = math.max(1, w / 2 - meshPad)
|
||||
local halfH = math.max(1, h / 2 - meshPad)
|
||||
local depth = math.max(8, (halfW * 2) * 0.14)
|
||||
local halfW, halfH = w / 2, h / 2
|
||||
local depth = math.max(8, w * 0.14)
|
||||
local project = function(px, py, pz)
|
||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||
@@ -811,6 +803,49 @@ end
|
||||
-- Returns the y at which content may start. Its vertical arithmetic is
|
||||
-- mirrored by headerHeight() at the bottom of this file (the short-window
|
||||
-- scroll decision needs the height before anything draws) -- keep in sync.
|
||||
-- Header chrome is fixed: the same six tabs, the same gear and Quit, every
|
||||
-- frame. Their tab rows, opts tables and action closures are built once
|
||||
-- instead of 60 times a second -- only `active`, `image` and the queued
|
||||
-- action are written per frame.
|
||||
local HEADER_TABS = {
|
||||
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
|
||||
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
|
||||
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
|
||||
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
|
||||
{ id = "mods", key = "tab-mods" },
|
||||
{ id = "find", key = "tab-find" },
|
||||
}
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||
end
|
||||
|
||||
local QUIT_INK_HOT = { 0, 0, 0, 1 }
|
||||
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
|
||||
|
||||
-- Keyed off the launcher instance so the closures die with it.
|
||||
local function headerChrome(imp)
|
||||
local c = imp._headerChrome
|
||||
if c then return c end
|
||||
c = {
|
||||
gear = { face = "invert",
|
||||
action = function() imp:_openSettings() end },
|
||||
quit = { face = "invert",
|
||||
action = function() imp:_quitApp() end,
|
||||
drawFn = function(x, y, w, h, hot)
|
||||
local pad = math.floor(w * 0.32)
|
||||
drawCross(x + pad, y + pad, w - 2 * pad,
|
||||
hot and QUIT_INK_HOT or QUIT_INK_REST)
|
||||
end },
|
||||
tab = {},
|
||||
}
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
local id = t.id
|
||||
c.tab[id] = function() imp:_switchTab(id) end
|
||||
end
|
||||
imp._headerChrome = c
|
||||
return c
|
||||
end
|
||||
|
||||
local function buildHeader(imp, m)
|
||||
local y = m.top
|
||||
Theme.versionRail(m.x, y, m.w, m.railH)
|
||||
@@ -869,20 +904,11 @@ local function buildHeader(imp, m)
|
||||
imp._gearIcon = imp._gearIcon
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
rx = rx - gear
|
||||
btn(imp, rx, by, gear, gear, "gear", "", {
|
||||
face = "invert", image = imp._gearIcon,
|
||||
action = function() imp:_openSettings() end,
|
||||
})
|
||||
local chrome = headerChrome(imp)
|
||||
chrome.gear.image = imp._gearIcon
|
||||
btn(imp, rx, by, gear, gear, "gear", "", chrome.gear)
|
||||
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", {
|
||||
face = "invert",
|
||||
action = function() imp:_quitApp() end,
|
||||
drawFn = function(x, y, w, h, hot)
|
||||
local pad = math.floor(w * 0.32)
|
||||
drawCross(x + pad, y + pad, w - 2 * pad,
|
||||
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
|
||||
end,
|
||||
})
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", chrome.quit)
|
||||
|
||||
-- The self-update control lives in the FOOTER next to the BCG mark (small,
|
||||
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
|
||||
@@ -900,14 +926,8 @@ local function buildHeader(imp, m)
|
||||
-- the fill when active, the same rule the buttons follow. Yellow stays the
|
||||
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
|
||||
-- collide.
|
||||
local tabs = {
|
||||
{ id = "red", letter = "R", color = PAL.railRed },
|
||||
{ id = "blue", letter = "B", color = PAL.railBlue },
|
||||
{ id = "yellow", letter = "Y", color = PAL.railGold },
|
||||
{ id = "gold", letter = "G", color = PAL.railAmber },
|
||||
{ id = "mods", icon = imp._modsIcon },
|
||||
{ id = "find", icon = imp._findIcon },
|
||||
}
|
||||
local tabs = HEADER_TABS
|
||||
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
|
||||
local tabH = m.chip
|
||||
local tx = m.x + m.pad
|
||||
local ty = y + math.floor(6 * m.s)
|
||||
@@ -916,18 +936,16 @@ local function buildHeader(imp, m)
|
||||
local tabGap = math.floor(6 * m.s)
|
||||
local tabRowGap = math.floor(4 * m.s)
|
||||
for _, t in ipairs(tabs) do
|
||||
local active = imp.tab == t.id
|
||||
local key = "tab-" .. t.id
|
||||
local w = tabH
|
||||
if tx > tabLeft and tx + w > tabRight then
|
||||
tx = tabLeft
|
||||
ty = ty + tabH + tabRowGap
|
||||
end
|
||||
btn(imp, tx, ty, w, tabH, key, "", {
|
||||
face = "tab", font = "tab", color = t.color, active = active,
|
||||
image = t.icon, letter = t.letter,
|
||||
action = function() imp:_switchTab(t.id) end,
|
||||
})
|
||||
local o = t.opts
|
||||
o.active = imp.tab == t.id
|
||||
o.image = t.icon
|
||||
o.action = chrome.tab[t.id]
|
||||
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
|
||||
@@ -1477,6 +1495,20 @@ end
|
||||
-- gets whatever width the previous ones left, and the first segment that has
|
||||
-- to ellipsize ends the line. Lets the download count sit green inside an
|
||||
-- otherwise muted stats line without two competing ellipsis passes.
|
||||
-- A row's control key is a pure function of its id, but concatenating it per
|
||||
-- visible row per frame is ~1200 strings a second. Memoised on the launcher,
|
||||
-- NOT on the entry: index entries are the same tables ModIndex.writeCache
|
||||
-- persists into options.modIndexCache, and view state must not ride along.
|
||||
local function rowKeyFor(imp, prefix, id)
|
||||
local keys = imp._rowKeys
|
||||
if not keys then keys = {}; imp._rowKeys = keys end
|
||||
local byPrefix = keys[prefix]
|
||||
if not byPrefix then byPrefix = {}; keys[prefix] = byPrefix end
|
||||
local key = byPrefix[id]
|
||||
if not key then key = prefix .. tostring(id); byPrefix[id] = key end
|
||||
return key
|
||||
end
|
||||
|
||||
local function segLine(fontName, segs, x, y, maxW)
|
||||
local sx = x
|
||||
for _, seg in ipairs(segs) do
|
||||
@@ -1502,6 +1534,55 @@ local function sortDefs()
|
||||
}
|
||||
end
|
||||
|
||||
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
|
||||
-- instead of the 2*n*log(n) times a comparator that derives it would, and the
|
||||
-- comparator itself is a module-level function so no closure is allocated per
|
||||
-- comparison. Measured on a synthetic index: 500 entries went from 8,964 key
|
||||
-- computations and 4,482 closures to 500 and none.
|
||||
local sortAsc = true
|
||||
|
||||
local function decCompare(a, b)
|
||||
if a.k ~= b.k then
|
||||
if sortAsc then return a.k < b.k end
|
||||
return a.k > b.k -- data sorts newest / most popular first
|
||||
end
|
||||
return a.tie < b.tie
|
||||
end
|
||||
|
||||
-- Fill `scratch` with one { e, k, tie } slot per entry, reusing the slots.
|
||||
local function decorate(scratch, src, keyOf, tieOf)
|
||||
local n = #src
|
||||
for i = 1, n do
|
||||
local e = src[i]
|
||||
local slot = scratch[i]
|
||||
if not slot then slot = {}; scratch[i] = slot end
|
||||
slot.e, slot.tie = e, tieOf(e)
|
||||
slot.k = keyOf(e, slot.tie)
|
||||
end
|
||||
for i = #scratch, n + 1, -1 do scratch[i] = nil end
|
||||
return n
|
||||
end
|
||||
|
||||
local function undecorate(scratch, n)
|
||||
local out = {}
|
||||
for i = 1, n do out[i] = scratch[i].e end
|
||||
return out
|
||||
end
|
||||
|
||||
-- While results are still streaming in, re-ordering on every arrival re-sorts
|
||||
-- the whole list every frame and makes rows jump under the reader. Hold the
|
||||
-- current order this long and take the change in one pass.
|
||||
local RESORT_DEBOUNCE = 0.25
|
||||
|
||||
-- True when the cached order is still good. `rev` is only part of the key
|
||||
-- for a stats-dependent sort: Name order does not depend on release data, so
|
||||
-- a stats arrival used to invalidate a sort whose result could not change.
|
||||
local function sortCacheOk(cache, src, key, rev, pending)
|
||||
if not (cache and cache.src == src and cache.key == key) then return false end
|
||||
if cache.rev == rev then return true end
|
||||
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
|
||||
end
|
||||
|
||||
local function currentSort(imp)
|
||||
local sortKey = imp.modSort
|
||||
if sortKey == nil then
|
||||
@@ -1643,16 +1724,18 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
-- per frame (with lowercased-string allocations in the comparator) fed the
|
||||
-- GC for nothing. Cache the sorted array, keyed on the list identity, the
|
||||
-- sort mode, and the update-info revision the fetch pump bumps.
|
||||
local statsSort = sortKey ~= "name"
|
||||
local rev = statsSort and (imp._modUpdateRev or 0) or 0
|
||||
local cache = imp._modSortCache
|
||||
if cache and cache.src == mods and cache.n == #mods
|
||||
and cache.key == sortKey and cache.rev == (imp._modUpdateRev or 0) then
|
||||
if cache and cache.n == #mods
|
||||
and sortCacheOk(cache, mods, sortKey, rev, imp._modInfoFetch ~= nil) then
|
||||
mods = cache.list
|
||||
else
|
||||
local sorted = {}
|
||||
for i, v in ipairs(mods) do sorted[i] = v end
|
||||
table.sort(sorted, function(a, b)
|
||||
local function value(mod)
|
||||
if sortKey == "name" then return (mod.name or ""):lower() end
|
||||
local scratch = imp._modSortScratch or {}
|
||||
imp._modSortScratch = scratch
|
||||
local n = decorate(scratch, mods,
|
||||
function(mod, tie)
|
||||
if sortKey == "name" then return tie end
|
||||
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
||||
if sortKey == "popularity" then
|
||||
return info and info.downloads and info.downloads.total or -1
|
||||
@@ -1660,16 +1743,13 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local date = info and info.dates
|
||||
if sortKey == "release" then return date and date.first or "0000-00-00" end
|
||||
return date and date.latest or "0000-00-00"
|
||||
end
|
||||
local va, vb = value(a), value(b)
|
||||
if va ~= vb then
|
||||
if sortKey == "name" then return va < vb end
|
||||
return va > vb -- data sorts newest / most popular first
|
||||
end
|
||||
return (a.name or ""):lower() < (b.name or ""):lower()
|
||||
end)
|
||||
imp._modSortCache = { src = imp.mods, n = #mods, key = sortKey,
|
||||
rev = imp._modUpdateRev or 0, list = sorted }
|
||||
end,
|
||||
function(mod) return (mod.name or ""):lower() end)
|
||||
sortAsc = sortKey == "name"
|
||||
table.sort(scratch, decCompare)
|
||||
local sorted = undecorate(scratch, n)
|
||||
imp._modSortCache = { src = mods, n = #mods, key = sortKey,
|
||||
rev = rev, at = Kit.time, list = sorted }
|
||||
mods = sorted
|
||||
end
|
||||
|
||||
@@ -1692,7 +1772,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local contentH = shown * rowH + math.max(0, shown - 1) * gap
|
||||
local scrollMax = math.max(0, contentH - listH)
|
||||
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
|
||||
imp._modListRect = { x = x, y = listTop, w = w, h = listH }
|
||||
local lr = imp._modListRect
|
||||
if not lr then lr = {}; imp._modListRect = lr end
|
||||
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
|
||||
imp._modScrollMax = scrollMax
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then
|
||||
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
|
||||
@@ -1708,7 +1790,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
for i = first, last do
|
||||
local mod = mods[i]
|
||||
local ry = listTop + (i - first) * (rowH + gap) - scroll
|
||||
local rowKey = "mod-row-" .. mod.id
|
||||
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
||||
local isFullyDisabled = true
|
||||
if mod.enabledByVersion then
|
||||
for _, on in pairs(mod.enabledByVersion) do
|
||||
@@ -1900,30 +1982,30 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
|
||||
-- Same caching rule as the MODS tab: the comparator allocates, so only
|
||||
-- re-sort when the inputs actually change.
|
||||
local statsSort = sortKey ~= "name"
|
||||
local rev = statsSort and (imp._findStatsRev or 0) or 0
|
||||
local fcache = imp._findSortCache
|
||||
if fcache and fcache.src == rows and fcache.key == sortKey
|
||||
and fcache.rev == (imp._findStatsRev or 0) then
|
||||
if sortCacheOk(fcache, rows, sortKey, rev, imp._findStatsPending ~= nil) then
|
||||
rows = fcache.list
|
||||
else
|
||||
local sorted = {}
|
||||
for i, v in ipairs(rows) do sorted[i] = v end
|
||||
table.sort(sorted, function(a, b)
|
||||
local function value(entry)
|
||||
if sortKey == "name" then return (entry.title or entry.id or ""):lower() end
|
||||
local stats = imp:_findStats(entry)
|
||||
local scratch = imp._findSortScratch or {}
|
||||
imp._findSortScratch = scratch
|
||||
local n = decorate(scratch, rows,
|
||||
function(entry, tie)
|
||||
if sortKey == "name" then return tie end
|
||||
-- The CACHED read, never the requesting one: a sort must not queue a
|
||||
-- fetch for every entry in the index (see _findStatsCached).
|
||||
local stats = imp:_findStatsCached(entry)
|
||||
if sortKey == "popularity" then return stats and stats.total or -1 end
|
||||
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
|
||||
return stats and stats.latest or "0000-00-00"
|
||||
end
|
||||
local va, vb = value(a), value(b)
|
||||
if va ~= vb then
|
||||
if sortKey == "name" then return va < vb end
|
||||
return va > vb
|
||||
end
|
||||
return (a.title or a.id or ""):lower() < (b.title or b.id or ""):lower()
|
||||
end)
|
||||
imp._findSortCache = { src = rows, key = sortKey,
|
||||
rev = imp._findStatsRev or 0, list = sorted }
|
||||
end,
|
||||
function(entry) return (entry.title or entry.id or ""):lower() end)
|
||||
sortAsc = sortKey == "name"
|
||||
table.sort(scratch, decCompare)
|
||||
local sorted = undecorate(scratch, n)
|
||||
imp._findSortCache = { src = rows, key = sortKey, rev = rev,
|
||||
at = Kit.time, list = sorted }
|
||||
rows = sorted
|
||||
end
|
||||
|
||||
@@ -1952,7 +2034,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
for i = first, last do
|
||||
local entry = rows[i]
|
||||
local ry = listTop + (i - first) * (rowH + gap)
|
||||
local rowKey = "find-row-" .. entry.id
|
||||
local rowKey = rowKeyFor(imp, "find-row-", entry.id)
|
||||
-- The whole row is the control: it opens the per-mod popup where
|
||||
-- Install / Details / Source moved. The only inline signal left is a
|
||||
-- green check when the mod is already installed.
|
||||
@@ -1985,8 +2067,15 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
love.graphics.draw(image, Theme.snap(px), Theme.snap(ly), 0, s, s)
|
||||
else
|
||||
Theme.stroke(px, ly, thumb, thumb, PAL.line, Theme.A.hairline, 1)
|
||||
Kit.textCenter("micro", "MOD", px,
|
||||
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
|
||||
-- A thumbnail still downloading and one that will never arrive drew the
|
||||
-- same dead box, so a slow index looked broken. Spin while it is in
|
||||
-- flight; only fall back to the wordmark once it has resolved.
|
||||
if imp:_findThumbPending(entry.id) then
|
||||
Kit.spinner(px + thumb / 2, ly + thumb / 2, thumb * 0.28)
|
||||
else
|
||||
Kit.textCenter("micro", "MOD", px,
|
||||
ly + (thumb - Kit.textHeight("micro")) / 2, thumb, PAL.faint)
|
||||
end
|
||||
end
|
||||
|
||||
local bx = px + thumb + math.floor(10 * m.s)
|
||||
@@ -2020,10 +2109,35 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
|
||||
end
|
||||
segLine("small", segs, bx, by2, bw)
|
||||
-- The stats line used to simply be absent until the release check landed,
|
||||
-- so rows silently changed under the reader and a slow check was
|
||||
-- indistinguishable from a mod with no data. Say which it is, the way
|
||||
-- the MODS tab already does on its own rows.
|
||||
if not stats and imp:_findStatsPendingFor(entry.id) then
|
||||
local sw = Kit.textWidth("small", segs[1][1]) + math.floor(12 * m.s)
|
||||
local dh = Kit.textHeight("small")
|
||||
Loader.dot(bx + sw, by2, dh)
|
||||
Kit.text("small", Strings("Checking..."),
|
||||
bx + sw + dh + math.floor(6 * m.s), by2, PAL.muted)
|
||||
end
|
||||
end
|
||||
|
||||
local pagerY = listTop + (last - first + 1) * (rowH + gap)
|
||||
setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find"))
|
||||
|
||||
-- Aggregate progress. Enrichment happens a page at a time and each row says
|
||||
-- so for itself, but with nothing summarising it the panel looked idle while
|
||||
-- work was in flight. Only drawn while something is actually pending.
|
||||
local waiting = imp:_findStatsPendingCount()
|
||||
if waiting > 0 then
|
||||
local py = pagerY + math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||
+ math.floor(4 * m.s)
|
||||
local dh = Kit.textHeight("micro")
|
||||
Loader.dot(x, py, dh)
|
||||
Kit.text("micro", Strings("Checking %d of %d on this page...",
|
||||
waiting, last - first + 1),
|
||||
x + dh + math.floor(6 * m.s), py, PAL.muted)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ footer
|
||||
@@ -3770,11 +3884,14 @@ function LauncherView.draw(imp)
|
||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||
Kit.blockClicks = modalUp(imp)
|
||||
|
||||
local ms = m
|
||||
if scroll > 0 then
|
||||
ms = setmetatable({ top = m.top - scroll }, { __index = m })
|
||||
end
|
||||
local contentY = buildHeader(imp, ms)
|
||||
-- The header is the only block that moves with the page scroll, so shift
|
||||
-- m.top across the call and put it back rather than wrapping `m` in a
|
||||
-- proxy: the proxy cost two tables a frame and put a metatable lookup on
|
||||
-- every m.* read for the rest of the frame.
|
||||
local baseTop = m.top
|
||||
if scroll > 0 then m.top = baseTop - scroll end
|
||||
local contentY = buildHeader(imp, m)
|
||||
m.top = baseTop
|
||||
local footY, availH
|
||||
if scrollMax > 0 then
|
||||
availH = minPanelHeight(m)
|
||||
|
||||
+200
-73
@@ -316,18 +316,6 @@ function RomImporter.isReady(version)
|
||||
end
|
||||
|
||||
-- Load the import manifest for a version and confirm it matches that ROM.
|
||||
local function decodeManifest(version)
|
||||
local path = GameVersion.info(version).manifest
|
||||
local raw, readError = love.filesystem.read(path)
|
||||
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
|
||||
local Json = require("src.link.Json")
|
||||
local manifest, decodeError = Json.decode(raw)
|
||||
if not manifest then error("ROM import metadata is invalid: " .. tostring(decodeError)) end
|
||||
assert(manifest.romSha1 == GameVersion.info(version).sha1,
|
||||
"ROM import metadata version mismatch")
|
||||
return manifest
|
||||
end
|
||||
|
||||
local function sha1(data)
|
||||
local digest = love.data.hash("sha1", data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
@@ -1552,6 +1540,8 @@ function RomImporter:setError(message, version)
|
||||
self.detail = tostring(message)
|
||||
self.progress = 0
|
||||
self.worker = nil
|
||||
-- Dropping the job stops collection; the next import clears the channels.
|
||||
self._extract = nil
|
||||
self.romData = nil
|
||||
-- A headless import has no launcher to read this off: POKEPORT_IMPORT_ONLY
|
||||
-- only ever quits from onComplete, so an import that fails here would sit in
|
||||
@@ -1618,23 +1608,65 @@ function RomImporter:startData(data, displayName)
|
||||
self.detail = displayName or info.displayName
|
||||
self.progress = 0
|
||||
self.romData = data
|
||||
self.worker = coroutine.create(function()
|
||||
self.status = "Preparing private game data"
|
||||
coroutine.yield()
|
||||
-- Redirect every cache write to this version's subtree, then clear only
|
||||
-- that version's previous cache from both homes (save directory and, for
|
||||
-- a portable install, the game folder). The other version is untouched.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local prefix = info.cachePrefix
|
||||
CacheFs.prefix = prefix
|
||||
self.status = "Preparing private game data"
|
||||
|
||||
-- Clear this version's previous cache from both homes before anything
|
||||
-- writes. Stays on the main thread so delete-then-fill-then-mark keeps one
|
||||
-- owner; the prefix is restored at once because the worker sets its own.
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local prefix = info.cachePrefix
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = prefix
|
||||
local cleared, clearError = pcall(function()
|
||||
removeTree(prefix .. "data/generated")
|
||||
removeTree(prefix .. "assets/generated")
|
||||
love.filesystem.remove(prefix .. MARKER_PATH)
|
||||
CacheFs.removeTree("data/generated")
|
||||
CacheFs.removeTree("assets/generated")
|
||||
CacheFs.remove(MARKER_PATH)
|
||||
end)
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not cleared then
|
||||
self:setError(tostring(clearError), version)
|
||||
return
|
||||
end
|
||||
|
||||
local manifest = decodeManifest(version)
|
||||
if self:_startExtractThread(version, prefix, data, displayName) then return end
|
||||
self:_startExtractCoroutine(version, info, prefix, displayName)
|
||||
end
|
||||
|
||||
-- False when threads are unavailable, so the coroutine path still covers
|
||||
-- that host. POKEPORT_NO_THREAD=1 forces it, which is the only way to
|
||||
-- exercise the fallback on a desktop.
|
||||
function RomImporter:_startExtractThread(version, prefix, data, displayName)
|
||||
if os.getenv("POKEPORT_NO_THREAD") == "1" then return false end
|
||||
if not (love.thread and love.thread.newThread) then return false end
|
||||
local ok, thread = pcall(love.thread.newThread, "src/import/ExtractThread.lua")
|
||||
if not ok or not thread then return false end
|
||||
local progressName = "rom_import_progress"
|
||||
local resultName = "rom_import_result"
|
||||
love.thread.getChannel(progressName):clear()
|
||||
love.thread.getChannel(resultName):clear()
|
||||
local started = pcall(thread.start, thread, version, prefix, data,
|
||||
progressName, resultName)
|
||||
if not started then return false end
|
||||
self._extract = {
|
||||
thread = thread, version = version, prefix = prefix,
|
||||
displayName = displayName,
|
||||
progress = love.thread.getChannel(progressName),
|
||||
result = love.thread.getChannel(resultName),
|
||||
}
|
||||
-- The worker owns the bytes now; drop ours so the 1-2 MiB string can go.
|
||||
self.romData = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
|
||||
self.worker = coroutine.create(function()
|
||||
coroutine.yield()
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
CacheFs.prefix = prefix
|
||||
local manifest = require("src.import.RomManifest").decode(version)
|
||||
local RomExtractor = version == "gold"
|
||||
and require("src.import.RomExtractorGen2")
|
||||
or require("src.import.RomExtractor")
|
||||
@@ -1647,47 +1679,93 @@ function RomImporter:startData(data, displayName)
|
||||
coroutine.yield()
|
||||
end)
|
||||
extractor:run()
|
||||
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
||||
self.romData = nil
|
||||
collectgarbage("collect")
|
||||
-- Written last: the marker is what isReady() checks, so it must only
|
||||
-- appear once every required file is in place.
|
||||
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
|
||||
CacheFs.prefix = "" -- restore the default so later writes stay at the root
|
||||
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
|
||||
self.ready[version] = true
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.mobileFileBridge and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
love.filesystem.remove(displayName)
|
||||
end
|
||||
self.importing = nil
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
resetPointerCursor(self)
|
||||
if self._flex then require("src.import.LauncherView").detach(self) end
|
||||
if self.onComplete then self.onComplete(version) end
|
||||
self:_completeImport(version, prefix, displayName)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Everything after the tree is filled, shared by both worker paths. Raises
|
||||
-- on a failed marker write; the thread path calls it inside a pcall.
|
||||
function RomImporter:_completeImport(version, prefix, displayName)
|
||||
local info = GameVersion.info(version)
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
-- Written last: the marker is what isReady() checks, so it must only
|
||||
-- appear once every required file is in place.
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = prefix
|
||||
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not ok then
|
||||
error("could not finish the private cache: " .. tostring(writeError))
|
||||
end
|
||||
self.ready[version] = true
|
||||
self.returning[version] = false
|
||||
self.romName[version] = (displayName
|
||||
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
|
||||
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
|
||||
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
|
||||
if self.mobileFileBridge and type(displayName) == "string"
|
||||
and not displayName:find("[/\\]") then
|
||||
love.filesystem.remove(displayName)
|
||||
end
|
||||
self.importing = nil
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
return
|
||||
end
|
||||
self._handedOff = true
|
||||
resetPointerCursor(self)
|
||||
if self._flex then require("src.import.LauncherView").detach(self) end
|
||||
if self.onComplete then self.onComplete(version) end
|
||||
end
|
||||
|
||||
-- Drain the worker's progress and finish when it reports done. One
|
||||
-- non-blocking poll per frame, like the other _pump* collectors above.
|
||||
function RomImporter:_pumpExtract()
|
||||
local job = self._extract
|
||||
if not job then return end
|
||||
local msg = job.progress:pop()
|
||||
while msg do
|
||||
self.status = msg.stage
|
||||
self.progress = msg.progress / msg.total
|
||||
self.stageCurrent = msg.current
|
||||
self.stageTotal = msg.stageTotal
|
||||
msg = job.progress:pop()
|
||||
end
|
||||
local res = job.result:pop()
|
||||
if not res then
|
||||
-- A thread that died before pushing a result would strand the loader.
|
||||
local threadError = job.thread.getError and job.thread:getError()
|
||||
if threadError then
|
||||
self._extract = nil
|
||||
self:setError(tostring(threadError), job.version)
|
||||
end
|
||||
return
|
||||
end
|
||||
self._extract = nil
|
||||
if not res.ok then
|
||||
self:setError(tostring(res.error), job.version)
|
||||
return
|
||||
end
|
||||
local ok, err = pcall(self._completeImport, self, job.version, job.prefix,
|
||||
job.displayName)
|
||||
if not ok then self:setError(tostring(err), job.version) end
|
||||
end
|
||||
|
||||
function RomImporter:startPath(path)
|
||||
if not path then return end
|
||||
local data, readError = readExternalPath(path)
|
||||
@@ -2316,6 +2394,7 @@ function RomImporter:update(dt)
|
||||
self:_pumpFindThumbs()
|
||||
self:_pumpModCheck()
|
||||
self:_pumpModInstall()
|
||||
self:_pumpExtract()
|
||||
-- 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
|
||||
@@ -4090,11 +4169,19 @@ end
|
||||
|
||||
-- Turn finished thumbnail downloads into images. Called from update(), so
|
||||
-- love.graphics.newImage runs on the render thread where it belongs.
|
||||
-- love.graphics.newImage decodes the PNG and uploads it, both on the render
|
||||
-- thread. A page's worth of thumbnails landing in the same frame did that
|
||||
-- many times back to back and dropped the frame, so only this many are
|
||||
-- decoded per pass; the rest keep their spinner one frame longer.
|
||||
local THUMB_DECODES_PER_FRAME = 2
|
||||
|
||||
function RomImporter:_pumpFindThumbs()
|
||||
local pending = self._findThumbFetch
|
||||
if not pending then return end
|
||||
local Fetch = require("src.net.Fetch")
|
||||
local decoded = 0
|
||||
for id, item in pairs(pending) do
|
||||
if decoded >= THUMB_DECODES_PER_FRAME then break end
|
||||
local st = Fetch.poll(item.job)
|
||||
if st.status ~= "pending" then
|
||||
Fetch.release(item.job)
|
||||
@@ -4103,6 +4190,7 @@ function RomImporter:_pumpFindThumbs()
|
||||
if st.status == "ok" and st.path then
|
||||
local ok, img = pcall(love.graphics.newImage, st.path)
|
||||
image = ok and img or nil
|
||||
decoded = decoded + 1
|
||||
end
|
||||
self._findThumbs = self._findThumbs or {}
|
||||
self._findThumbs[id] = image or false
|
||||
@@ -4119,14 +4207,21 @@ end
|
||||
-- entry per frame so opening the tab cannot stall for the whole listing.
|
||||
-- The result is memoized per id for the session; a repo with no releases
|
||||
-- or a failed fetch resolves to an empty table so it is tried once.
|
||||
function RomImporter:_findStats(entry)
|
||||
-- PURE read: whatever is already known for a row, or nil. Resolving a
|
||||
-- feed-published stat or a repo-less entry is memoization, not network, so it
|
||||
-- stays here; nothing in this function can start a fetch. That matters
|
||||
-- because the sort comparator calls it for EVERY entry -- when queueing lived
|
||||
-- in here, sorting a 500-mod index by Popularity queued 500 GitHub requests
|
||||
-- on the first frame, blew the hourly rate limit, and the failures then
|
||||
-- re-queued together every 60s for as long as the tab was open.
|
||||
function RomImporter:_findStatsCached(entry)
|
||||
self._findStatsCache = self._findStatsCache or {}
|
||||
local cached = self._findStatsCache[entry.id]
|
||||
if cached then
|
||||
if cached.done or (cached.retryAt and os.time() < cached.retryAt) then
|
||||
return cached
|
||||
end
|
||||
self._findStatsCache[entry.id] = nil -- retry window open, refetch
|
||||
return nil -- retry window open; _requestFindStats decides what to do
|
||||
end
|
||||
if entry.downloads ~= nil or entry.first_release or entry.last_release then
|
||||
cached = { total = entry.downloads, first = entry.first_release,
|
||||
@@ -4139,22 +4234,54 @@ function RomImporter:_findStats(entry)
|
||||
self._findStatsCache[entry.id] = cached
|
||||
return cached
|
||||
end
|
||||
-- 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
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Queue one row's release fetch. Only rows actually on the page call this --
|
||||
-- the rule _findThumb already follows -- so the fan-out is a page, not the
|
||||
-- whole index.
|
||||
function RomImporter:_requestFindStats(entry)
|
||||
if self:_findStatsCached(entry) then return end
|
||||
if not entry.github or entry.github == "" then return end
|
||||
local cached = self._findStatsCache[entry.id]
|
||||
if cached then
|
||||
if cached.retryAt and os.time() >= cached.retryAt then
|
||||
self._findStatsCache[entry.id] = nil -- retry window open, refetch
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
self._findStatsPending = self._findStatsPending or {}
|
||||
if self._findStatsPending[entry.id] then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
self._findStatsPending[entry.id] = {
|
||||
id = entry.id,
|
||||
h = ModUpdate.beginFetchReleases(entry.github, entry.id, {}),
|
||||
}
|
||||
end
|
||||
|
||||
-- Request-and-read, for a row that is being drawn and for the detail modal.
|
||||
function RomImporter:_findStats(entry)
|
||||
self:_requestFindStats(entry)
|
||||
return self:_findStatsCached(entry)
|
||||
end
|
||||
|
||||
-- How many rows are still waiting on a release check, for the panel's
|
||||
-- progress line.
|
||||
function RomImporter:_findStatsPendingCount()
|
||||
local n = 0
|
||||
for _ in pairs(self._findStatsPending or {}) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
function RomImporter:_findStatsPendingFor(id)
|
||||
return (self._findStatsPending and self._findStatsPending[id]) ~= nil
|
||||
end
|
||||
|
||||
function RomImporter:_findThumbPending(id)
|
||||
return (self._findThumbFetch and self._findThumbFetch[id]) ~= nil
|
||||
end
|
||||
|
||||
-- Drive in-flight FIND MODS stats lookups. Called from update().
|
||||
function RomImporter:_pumpFindStats()
|
||||
local pending = self._findStatsPending
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- The per-version import metadata (symbol table + ROM hash) that drives
|
||||
-- RomExtractor. Split out of RomImporter so the extraction worker thread can
|
||||
-- decode it itself: shipping the decoded table across a love.thread channel
|
||||
-- would deep-copy every symbol for nothing.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local RomManifest = {}
|
||||
|
||||
function RomManifest.decode(version)
|
||||
local info = GameVersion.info(version)
|
||||
local raw, readError = love.filesystem.read(info.manifest)
|
||||
if not raw then
|
||||
error("ROM import metadata is missing: " .. tostring(readError))
|
||||
end
|
||||
local Json = require("src.link.Json")
|
||||
local manifest, decodeError = Json.decode(raw)
|
||||
if not manifest then
|
||||
error("ROM import metadata is invalid: " .. tostring(decodeError))
|
||||
end
|
||||
assert(manifest.romSha1 == info.sha1, "ROM import metadata version mismatch")
|
||||
return manifest
|
||||
end
|
||||
|
||||
return RomManifest
|
||||
Reference in New Issue
Block a user