This commit is contained in:
bryanthaboi
2026-08-15 10:44:20 -04:00
parent 43cbc554c3
commit 5198b35945
18 changed files with 1524 additions and 185 deletions
+4 -2
View File
@@ -325,7 +325,8 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
@@ -370,7 +371,8 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
-- BODY, and on the two services this talks to that body is the whole
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
-- tells a user to wait rather than to go hunting for a broken index.
local cmd = ("curl -sSL --connect-timeout 10 --max-time %d ")
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
.. "--connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
+50
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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
+209
View File
@@ -0,0 +1,209 @@
-- Background compute for sandboxed mods, behind the "background" permission.
--
-- mod.fetch covers work that is waiting on a server. This covers work that is
-- waiting on the CPU: a mod hands over a script from its own folder plus a
-- table of plain data, and gets the return value back through the same
-- handle/poll/release shape mod.fetch uses.
--
-- The worker (src/mods/job_worker.lua) builds the SAME Sandbox.envFor
-- environment the main thread does before it loads the mod's chunk, so this
-- is not the love.thread hole reopened: the mod's code still cannot see
-- io, os, debug, ffi, package or love.filesystem, and require is refused
-- outright inside a job.
--
-- One thread per job rather than a pool. A pooled state would carry one
-- mod's globals into the next mod's job, and resetting it properly is the
-- same work as making a new one.
local SafePath = require("src.mods.SafePath")
local Job = {}
Job.MAX_INFLIGHT = 2 -- per mod
Job.MAX_GLOBAL = 4 -- across all mods, so jobs cannot eat every core
Job.DEFAULT_SECONDS = 5
Job.MAX_SECONDS = 30
-- Depth cap on the data crossing the channel. A cycle is caught by the seen
-- set; this catches the merely absurd.
Job.MAX_DEPTH = 16
local nextId = 0
local liveGlobal = 0
-- Only plain data crosses a thread boundary: a function or userdata cannot be
-- serialised, and letting one through would fail deep inside LÖVE instead of
-- at the call the mod made.
local function plain(value, depth, seen)
local t = type(value)
if t == "nil" or t == "boolean" or t == "number" or t == "string" then
return value
end
if t ~= "table" then
return nil, ("a job cannot carry a %s, only plain data"):format(t)
end
depth = (depth or 0) + 1
if depth > Job.MAX_DEPTH then
return nil, "a job's data is nested too deeply"
end
seen = seen or {}
if seen[value] then return nil, "a job cannot carry a cycle" end
seen[value] = true
local out = {}
for k, v in pairs(value) do
local kt = type(k)
if kt ~= "string" and kt ~= "number" then
return nil, ("a job cannot carry a %s key"):format(kt)
end
local copied, err = plain(v, depth, seen)
if err then return nil, err end
out[k] = copied
end
seen[value] = nil
return out
end
Job.plain = plain
function Job.available()
return (love and love.thread and love.thread.newThread) ~= nil
end
local function bucket(loader, modId)
loader.jobs = loader.jobs or {}
local b = loader.jobs[modId]
if not b then b = {}; loader.jobs[modId] = b end
return b
end
local function inflight(b)
local n = 0
for _, job in pairs(b) do
if job.status == "pending" then n = n + 1 end
end
return n
end
-- `script` is relative to the mod's own folder, and goes through the same
-- SafePath rules mod:read does -- a job is not a way to name a path.
-- Argument checks come BEFORE the host check: a bad path or an unserialisable
-- argument is the mod author's bug and should read the same on every machine,
-- not be masked into "unavailable" on a build without threads.
function Job.run(loader, modId, modPath, script, arg, opts)
if type(script) ~= "string" or script == "" then
return nil, "a job needs a script path inside your mod"
end
-- SafePath.require raises rather than returning, so the mod's bad path
-- comes back as a value here instead of unwinding its caller.
local okPath, safe = pcall(SafePath.join, modPath, script, "a job script")
if not okPath then return nil, tostring(safe) end
local payload, dataErr = plain(arg)
if dataErr then return nil, dataErr end
if not Job.available() then return nil, "background jobs are unavailable" end
local b = bucket(loader, modId)
if inflight(b) >= Job.MAX_INFLIGHT then
return nil, ("too many jobs in flight (limit %d); poll and release the "
.. "ones you have"):format(Job.MAX_INFLIGHT)
end
if liveGlobal >= Job.MAX_GLOBAL then
return nil, "the machine is already running as many jobs as it will"
end
opts = type(opts) == "table" and opts or {}
local seconds = tonumber(opts.maxSeconds) or Job.DEFAULT_SECONDS
if seconds > Job.MAX_SECONDS then seconds = Job.MAX_SECONDS end
if seconds < 1 then seconds = 1 end
nextId = nextId + 1
local argName = "modjob_arg_" .. nextId
local resultName = "modjob_result_" .. nextId
local argCh = love.thread.getChannel(argName)
local resCh = love.thread.getChannel(resultName)
argCh:clear()
resCh:clear()
argCh:push(payload == nil and false or payload)
local okNew, thread = pcall(love.thread.newThread, "src/mods/job_worker.lua")
if not okNew or not thread then return nil, "could not start a job thread" end
local Json = require("src.link.Json")
local permissions = select(2, pcall(Json.encode,
loader.mods and loader.mods[modId]
and loader.mods[modId].manifest.permissionSet or {})) or "{}"
local started = pcall(thread.start, thread, modId, safe, argName, resultName,
permissions)
if not started then return nil, "could not start a job thread" end
liveGlobal = liveGlobal + 1
local handle = {}
b[handle] = { thread = thread, resultCh = resCh, status = "pending",
deadline = love.timer.getTime() + seconds, seconds = seconds }
return handle
end
local function settle(job, status, value, err)
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
job.status, job.value, job.err = status, value, err
end
function Job.poll(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return { status = "error", err = "unknown job" } end
if job.status == "pending" then
local msg = job.resultCh:pop()
if msg then
if msg.ok then settle(job, "ok", msg.result)
else settle(job, "error", nil, msg.err) end
else
-- A worker that died before pushing anything (an error outside its own
-- pcall) would otherwise leave the mod polling forever.
local threadErr = job.thread.getError and job.thread:getError()
if threadErr then
settle(job, "error", nil, tostring(threadErr))
elseif love.timer.getTime() > job.deadline then
-- The budget bounds how long the MOD waits, not how long the work
-- runs: there is no way to stop a LÖVE thread, and every in-worker
-- attempt made things worse (see job_worker.lua). A job that
-- overruns is reported here and its result dropped if it ever lands.
settle(job, "error", nil, ("job exceeded its %gs budget")
:format(job.seconds))
end
end
end
if job.status == "ok" then
-- A copy, so a mod cannot edit what a later poll returns.
return { status = "ok", result = (plain(job.value)) }
end
return { status = job.status, err = job.err }
end
function Job.release(loader, modId, handle)
local b = bucket(loader, modId)
local job = b[handle]
if not job then return false end
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
return true
end
-- There is no way to kill a LÖVE thread, so cancelling drops the result
-- rather than stopping the work; the worker's own time budget is what bounds
-- how long an abandoned job can run.
function Job.cancel(loader, modId, handle)
local job = bucket(loader, modId)[handle]
if not job then return false end
if job.status == "pending" then
settle(job, "cancelled")
end
return true
end
function Job.releaseAll(loader, modId)
local b = loader.jobs and loader.jobs[modId]
if not b then return end
for handle, job in pairs(b) do
if job.status == "pending" then liveGlobal = math.max(0, liveGlobal - 1) end
b[handle] = nil
end
loader.jobs[modId] = nil
end
return Job
+49
View File
@@ -23,6 +23,8 @@ local Hooks = require("src.mods.Hooks")
local LegacyCompat = require("src.mods.LegacyCompat")
local Runtime = require("src.mods.Runtime")
local Steps = require("src.mods.Steps")
local Net = require("src.mods.Net")
local Job = require("src.mods.Job")
local Loader = {}
Loader.__index = Loader
@@ -1070,6 +1072,51 @@ function Loader:_api(mod)
return { available = function() return false end,
sync = refuse, poll = refuse }
end)(),
-- Background HTTP, behind the "network" permission the player already
-- sees. This is what love.thread is NOT: the worker runs engine code in
-- an engine-owned pool, so a mod gets asynchrony without getting a Lua
-- state the sandbox cannot reach. get() hands back an opaque handle;
-- poll() is non-blocking, so nothing here can hang a frame.
fetch = (function()
if mod.manifest.permissionSet.network then
return {
available = function() return Net.available() end,
get = function(_, url, opts) return Net.get(loader, modId, url, opts) end,
poll = function(_, handle) return Net.poll(loader, modId, handle) end,
release = function(_, handle) return Net.release(loader, modId, handle) end,
cancel = function(_, handle) return Net.cancel(loader, modId, handle) end,
}
end
local function refuse()
error(('[%s] mod.fetch needs the "network" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
get = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(),
-- Background compute, behind the "background" permission. The worker
-- rebuilds this mod's sandbox before loading the script, so a job is the
-- one thing love.thread is not: off the main thread without a Lua state
-- that escapes the sandbox. Plain data in, plain data out.
job = (function()
if mod.manifest.permissionSet.background then
return {
available = function() return Job.available() end,
run = function(_, script, arg, opts)
return Job.run(loader, modId, mod.path, script, arg, opts)
end,
poll = function(_, handle) return Job.poll(loader, modId, handle) end,
release = function(_, handle) return Job.release(loader, modId, handle) end,
cancel = function(_, handle) return Job.cancel(loader, modId, handle) end,
}
end
local function refuse()
error(('[%s] mod.job needs the "background" permission in '
.. "manifest.json"):format(modId), 2)
end
return { available = function() return false end,
run = refuse, poll = refuse, release = refuse, cancel = refuse }
end)(),
-- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final
save = {
@@ -1342,6 +1389,8 @@ function Loader:_rollback(modId)
self.migrations[modId] = nil
self.modSave[modId] = nil
self.stepsQueues[modId] = nil
Net.releaseAll(self, modId)
Job.releaseAll(self, modId)
end
-- a mod that explicitly swears it stays link-compatible while writing into a
+2 -1
View File
@@ -11,7 +11,8 @@ local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true }
engine_internals = true, steps = true,
background = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
+143
View File
@@ -0,0 +1,143 @@
-- Background HTTP for sandboxed mods, behind the "network" permission.
--
-- The sandbox blocks love.thread because newThread boots a Lua state with a
-- full standard library that none of the sandbox's rules reach -- one call and
-- a mod has io back. That is correct, but it left mods with no way to do
-- anything off the main thread at all: the only reachable transports
-- (socket, http) block, so a mod that wanted to fetch something had to hang
-- the game to do it.
--
-- This is the narrow replacement. src/net/Fetch.lua already runs a pool of
-- engine-owned worker threads, and those workers run OUR code, not the mod's,
-- so handing a mod a job in that pool grants no new reach. A mod submits a
-- URL and polls for the body; it never gets a thread, a path, or a raw handle
-- into the shared job table.
--
-- WHAT THIS FILE HAS TO GET RIGHT, because Fetch itself is shared with the
-- launcher:
-- * Handles are opaque tables owned per mod. Fetch keys jobs by integer,
-- and the launcher's own ROM download and index fetches live in the same
-- table; an integer handed to a mod would let it poll (or cancel) work
-- that is not its own. A forged table simply misses the lookup.
-- * Only http and https. The transport is curl, which also speaks file://,
-- scp:// and ftp://; without this check mod.fetch would be a filesystem
-- read and the sandbox would be back to square one.
-- * A per-mod ceiling on jobs in flight, so one mod cannot fill the shared
-- three-worker pool and starve the launcher's own fetches.
local Net = {}
-- Per mod, not global: the pool is shared with the launcher and a mod should
-- never be able to monopolise it.
Net.MAX_INFLIGHT = 4
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
Net.MAX_SECONDS = 30
local function fetch()
return require("src.net.Fetch")
end
-- http/https only, and a host must actually be present -- "http://" alone
-- reaches curl as a malformed URL rather than being refused here.
function Net.urlDenial(url)
if type(url) ~= "string" or url == "" then return "url must be a string" end
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
if not scheme then return "url must start with http:// or https://" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then
return ("%s:// is not allowed; mod.fetch speaks http and https only")
:format(scheme)
end
if rest == "" or rest:match("^/") then return "url has no host" end
return nil
end
local function bucket(loader, modId)
loader.netJobs = loader.netJobs or {}
local b = loader.netJobs[modId]
if not b then b = {}; loader.netJobs[modId] = b end
return b
end
local function inflight(b)
local n = 0
for _, id in pairs(b) do
if fetch().isPending(id) then n = n + 1 end
end
return n
end
function Net.available()
local ok, F = pcall(fetch)
if not ok then return false end
local okAvail, avail = pcall(F.available)
return okAvail and avail and true or false
end
-- Returns an opaque handle, or nil plus a reason.
function Net.get(loader, modId, url, opts)
local denial = Net.urlDenial(url)
if denial then return nil, denial end
opts = type(opts) == "table" and opts or {}
local b = bucket(loader, modId)
if inflight(b) >= Net.MAX_INFLIGHT then
return nil, ("too many requests in flight (limit %d); poll and release "
.. "the ones you have"):format(Net.MAX_INFLIGHT)
end
local maxSeconds = tonumber(opts.maxSeconds) or Net.MAX_SECONDS
if maxSeconds > Net.MAX_SECONDS then maxSeconds = Net.MAX_SECONDS end
if maxSeconds < 1 then maxSeconds = 1 end
-- The mod is named in the agent string so a server operator can see which
-- mod is calling them, and a mod cannot pretend to be the launcher.
local id = fetch().get(url, {
userAgent = "gen1recomp-mod/" .. tostring(modId),
accept = type(opts.accept) == "string" and opts.accept or nil,
maxSeconds = maxSeconds,
})
local handle = {}
b[handle] = id
return handle
end
-- A copy of the job's state, never the engine's own table. An unknown or
-- forged handle reads as an error rather than nil, so a mod that lost track of
-- one cannot spin waiting on it forever.
function Net.poll(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return { status = "error", err = "unknown request" } end
local st = fetch().poll(id)
return { status = st.status, body = st.body, err = st.err,
progress = st.progress }
end
function Net.release(loader, modId, handle)
local b = bucket(loader, modId)
local id = b[handle]
if not id then return false end
fetch().release(id)
b[handle] = nil
return true
end
function Net.cancel(loader, modId, handle)
local id = bucket(loader, modId)[handle]
if not id then return false end
fetch().cancel(id)
return true
end
-- Drop everything this mod still holds. Called when a mod unloads, so a
-- disabled mod cannot leave jobs accumulating in the shared table.
function Net.releaseAll(loader, modId)
local b = loader.netJobs and loader.netJobs[modId]
if not b then return end
local F = fetch()
for handle, id in pairs(b) do
pcall(F.cancel, id)
pcall(F.release, id)
b[handle] = nil
end
loader.netJobs[modId] = nil
end
return Net
+5 -1
View File
@@ -68,7 +68,11 @@ end
-- without an edit here.
-- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = {
filesystem = "mod.storage, mod:read and mod:list", thread = true,
filesystem = "mod.storage, mod:read and mod:list",
-- newThread's state has a full standard library and none of this file's
-- rules, so it stays blocked -- but the reason mods reached for it was
-- background work, and mod.fetch is that without the escape.
thread = 'mod.fetch for background HTTP (needs the "network" permission)',
system = "mod.device:powerInfo() for battery information, mod.steps for "
.. "the step bridge", event = true,
}
+73
View File
@@ -0,0 +1,73 @@
-- Worker state behind src/mods/Job.lua. One per job, not a pool: a reused
-- state would carry one mod's globals into another mod's job.
--
-- This is the file that makes running mod Lua off the main thread safe. The
-- mod's chunk is loaded into the SAME sandbox environment the main thread
-- builds (Sandbox.envFor), so love.filesystem, io, os, debug, ffi and package
-- are as absent here as they are there -- even though this state required
-- love.filesystem to bootstrap itself.
--
-- A job is pure compute: plain data in, plain data out, no engine API, no
-- game state, no storage. require is refused outright rather than reaching
-- src.* -- an engine module loaded in a second state would be a second
-- instance writing the same files as the main thread's.
require("love.thread")
require("love.filesystem")
require("love.timer")
local modId, scriptPath, argChannel, resultChannel, permissionsJson = ...
-- Fresh love threads have no "src.*" searcher (see src/net/fetch_worker.lua),
-- so install one before Sandbox's own requires run.
table.insert(package.loaders or package.searchers, function(name)
local path = name:gsub("%.", "/") .. ".lua"
if not love.filesystem.getInfo(path) then return nil end
return love.filesystem.load(path)
end)
local resCh = love.thread.getChannel(resultChannel)
local function fail(err)
resCh:push({ ok = false, err = tostring(err) })
end
local ok, err = pcall(function()
local Sandbox = require("src.mods.Sandbox")
local Json = require("src.link.Json")
local permissions = {}
if type(permissionsJson) == "string" and permissionsJson ~= "" then
local decoded = select(2, pcall(Json.decode, permissionsJson))
if type(decoded) == "table" then permissions = decoded end
end
local env = Sandbox.envFor({ modId = modId, permissions = permissions })
-- A job cannot reach the engine. Anything it needs comes in through its
-- argument and goes back through its return value.
env.require = function(name)
error(("[%s] require(%q) is not available inside a background job; a job "
.. "takes plain data and returns plain data"):format(modId,
tostring(name)), 2)
end
local chunk, loadErr = Sandbox.loadFile(love.filesystem, scriptPath, env)
if not chunk then error(loadErr or ("could not load " .. scriptPath), 0) end
local arg = love.thread.getChannel(argChannel):pop()
-- NO in-worker time budget, deliberately. A debug count hook was the
-- obvious way to stop a runaway, and it does not work: LuaJIT swallows an
-- error raised from a hook (measured: ~5000 raises a second, the loop
-- running straight through them), and the raising itself wedged the whole
-- process -- the main thread stopped being scheduled at all. Without the
-- hook a runaway job simply spins on its own core, the game stays
-- responsive, and it quits normally. Job.poll enforces maxSeconds on the
-- main thread so the MOD is never left waiting; the work itself runs to its
-- own end.
local ranOk, result = pcall(chunk, arg)
if not ranOk then error(result, 0) end
resCh:push({ ok = true, result = result })
end)
if not ok then fail(err) end
+29 -6
View File
@@ -132,9 +132,11 @@ local UI_SCALE = 1.3
function Kit.layout(width, height)
local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE
local key = ("%dx%d"):format(math.floor(width), math.floor(height))
if Kit._fontKey ~= key then
Kit._fontKey = key
-- Two numbers, not a formatted key: this runs once per frame and the
-- string:format allocated on every one of them.
local kw, kh = math.floor(width), math.floor(height)
if Kit._fontW ~= kw or Kit._fontH ~= kh then
Kit._fontW, Kit._fontH = kw, kh
Kit.fonts = Theme.fonts(s)
clearCaches() -- every cached Text/width belongs to the old font set
end
@@ -857,6 +859,8 @@ end
-- never silently truncated. This is the ONLY way the launcher moves through
-- a long list: no scrollbars, no momentum, bounded row count per frame.
-- Returns the new page (1-based) and the row height consumed.
local pagerLabels = {}
function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local h = math.max(Kit.tapMin(), 30 * Kit.scale)
local bw = 74 * Kit.scale
@@ -876,7 +880,19 @@ function Kit.pager(x, y, w, page, total, perPage, idPrefix)
local first = total > 0 and ((page - 1) * perPage + 1) or 0
local last = math.min(total, page * perPage)
local label = ("%d-%d of %d (page %d/%d)"):format(first, last, total, page, pages)
-- One memo per pager id. The counts only change when the user pages or the
-- list does; formatting them every frame minted a new string that then
-- missed the width / ellipsis / Text caches by content.
local memo = pagerLabels[idPrefix]
if not memo then memo = {}; pagerLabels[idPrefix] = memo end
if memo.first ~= first or memo.last ~= last or memo.total ~= total
or memo.page ~= page or memo.pages ~= pages then
memo.first, memo.last, memo.total = first, last, total
memo.page, memo.pages = page, pages
memo.label = ("%d-%d of %d (page %d/%d)")
:format(first, last, total, page, pages)
end
local label = memo.label
local labelX = x + 2 * bw + 2 * gap + gap
Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)),
labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption)
@@ -942,7 +958,10 @@ end
-- region can never unclip its parent. The tracked rect also bounds Kit.hit,
-- so a widget clipped out of view is inert instead of taking taps aimed at
-- whatever is drawn where it left.
-- The stack rects are pooled by depth and fully overwritten on every push,
-- so a frame that clips a dozen lists allocates nothing.
local clipStack = {}
local clipPool = {}
local function applyClip(rect)
Kit._clipRect = rect
@@ -967,8 +986,12 @@ function Kit.pushClip(x, y, w, h)
x2 = math.min(x2, prev.x + prev.w)
y2 = math.min(y2, prev.y + prev.h)
end
local rect = { x = x, y = y, w = math.max(0, x2 - x), h = math.max(0, y2 - y) }
clipStack[#clipStack + 1] = rect
local n = #clipStack + 1
local rect = clipPool[n]
if not rect then rect = {}; clipPool[n] = rect end
rect.x, rect.y = x, y
rect.w, rect.h = math.max(0, x2 - x), math.max(0, y2 - y)
clipStack[n] = rect
applyClip(rect)
end
+29 -15
View File
@@ -29,6 +29,15 @@ Layout.BP = {
-- Build the frame's metrics. `maxAppW` caps the content column on an
-- ultrawide monitor so the UI stays a readable measure instead of stretching.
-- One metrics table, reused. Every field is a pure function of the window
-- size, the safe area and maxAppW, so the table only has to be rebuilt when
-- one of those changes; the launcher asked for a fresh one 60 times a second
-- and threw all of them away. Callers must treat `m` as read-only (nothing
-- writes to it today) -- a caller that needs a shifted field should save,
-- assign and restore it around the call, not wrap `m` in a proxy.
local M = {}
local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
@@ -36,23 +45,28 @@ function Layout.metrics(maxAppW)
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)
if W == lastW and H == lastH and ox == lastOx and oy == lastOy
and sw == lastSw and sh == lastSh and maxAppW == lastMax then
return M
end
lastW, lastH, lastOx, lastOy = W, H, ox, oy
lastSw, lastSh, lastMax = sw, sh, maxAppW
local appW = math.min(sw, (maxAppW or 1200) * s)
local m = {
W = W, H = H, s = s,
x = math.floor(ox + (sw - appW) / 2),
top = math.floor(oy),
w = math.floor(appW),
h = math.floor(sh),
pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)),
gap = math.floor(12 * s),
colGap = math.floor(16 * s),
rowH = math.max(Kit.tapMin(), math.floor(44 * s)),
btnH = math.max(Kit.tapMin(), math.floor(38 * s)),
chip = math.max(Kit.tapMin(), math.floor(40 * s)),
railH = math.max(3, math.floor(4 * s)),
logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)),
}
local m = M
m.W, m.H, m.s = W, H, s
m.x = math.floor(ox + (sw - appW) / 2)
m.top = math.floor(oy)
m.w = math.floor(appW)
m.h = math.floor(sh)
m.pad = math.floor(Theme.clamp(appW * 0.03, 10, 24))
m.gap = math.floor(12 * s)
m.colGap = math.floor(16 * s)
m.rowH = math.max(Kit.tapMin(), math.floor(44 * s))
m.btnH = math.max(Kit.tapMin(), math.floor(38 * s))
m.chip = math.max(Kit.tapMin(), math.floor(40 * s))
m.railH = math.max(3, math.floor(4 * s))
m.logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84))
m.cols = (appW >= Layout.BP.threeCol * s and 3)
or (appW >= Layout.BP.twoCol * s and 2)
or 1