diff --git a/README.md b/README.md
index 0083d402..d3657bf4 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION]
-> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
+> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.

diff --git a/docs/new-features.md b/docs/new-features.md
index 64615d10..4207c3d5 100644
--- a/docs/new-features.md
+++ b/docs/new-features.md
@@ -13,6 +13,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Pokédex diploma and printer image exports**
+* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
## Gen 2 Specifics
diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua
index 9d5c67e4..cd41d034 100644
--- a/src/import/LauncherView.lua
+++ b/src/import/LauncherView.lua
@@ -1626,13 +1626,17 @@ end
-- The persisted sort choice both mod panels share. The chooser itself is a
-- popup (buildSortModal); panels just read the current key and offer a
-- "Sort" button, which is what freed the chip row's two lines of space.
-local function sortDefs()
- return {
+local function sortDefs(scope)
+ local defs = {
{ key = "name", label = Strings("Name") },
- { key = "popularity", label = Strings("Popularity") },
- { key = "release", label = Strings("Release date") },
- { key = "updated", label = Strings("Last updated") },
+ { key = "popularity", label = Strings("Most downloaded") },
}
+ if scope == "find" then
+ defs[#defs + 1] = { key = "trending", label = Strings("Trending") }
+ end
+ defs[#defs + 1] = { key = "release", label = Strings("Release date") }
+ defs[#defs + 1] = { key = "updated", label = Strings("Last updated") }
+ return defs
end
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
@@ -1684,7 +1688,7 @@ local function sortCacheOk(cache, src, key, rev, pending)
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
end
-local function currentSort(imp)
+local function currentSort(imp, scope)
local sortKey = imp.modSort
if sortKey == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
@@ -1694,6 +1698,7 @@ local function currentSort(imp)
sortKey = sortKey or "popularity"
imp.modSort = sortKey
end
+ if sortKey == "trending" and scope ~= "find" then return "popularity" end
return sortKey
end
@@ -1760,7 +1765,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
- action = function() imp._sortPopup = true end })
+ action = function() imp._sortPopup = "mods" end })
elseif medReq <= w then
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
@@ -1771,7 +1776,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
- action = function() imp._sortPopup = true end })
+ action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
@@ -1787,7 +1792,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:chooseMod() end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
- action = function() imp._sortPopup = true end })
+ action = function() imp._sortPopup = "mods" end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
@@ -1819,7 +1824,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
return
end
- local sortKey = currentSort(imp)
+ local sortKey = currentSort(imp, "mods")
-- Immediate mode paints this panel every frame; re-sorting the whole list
-- per frame (with lowercased-string allocations in the comparator) fed the
@@ -2210,7 +2215,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
font = "small",
- action = function() imp._sortPopup = true end })
+ action = function() imp._sortPopup = "find" end })
-- The Filter button carries its state: blue while a category is active,
-- so a filtered-down list never reads as "the index shrank".
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
@@ -2230,7 +2235,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
return
end
- local sortKey = currentSort(imp)
+ local sortKey = currentSort(imp, "find")
-- Same caching rule as the MODS tab: the comparator allocates, so only
-- re-sort when the inputs actually change.
@@ -2249,6 +2254,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
-- 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 == "trending" then return stats and stats.recent 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,
@@ -2336,13 +2342,14 @@ local function buildFindPanel(imp, x, y, w, availH, m)
bx, ly, PAL.heading)
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- meta and stats on one line, the download count first (and green)
- -- because it is what the default Popularity sort is ordering by: a
+ -- because it is what the default Most-downloaded sort is ordering by: a
-- narrow window ellipsizes the tail, and the count must survive that.
local stats = imp:_findStats(entry)
local baseCol = note and PAL.green or PAL.detail
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if note then lead = lead .. " - " .. note end
- local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
+ local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
+ local hasCount = stats ~= nil and stats.total ~= nil
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
or nil
local rest = {}
@@ -2352,11 +2359,13 @@ local function buildFindPanel(imp, x, y, w, availH, m)
end
if dates then
rest[#rest + 1] = dates
- elseif not dl and (entry.summary or "") ~= "" then
+ elseif not hasCount and (entry.summary or "") ~= "" then
rest[#rest + 1] = entry.summary
end
local segs = { { lead, baseCol } }
- if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
+ if dl then
+ segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
+ end
if #rest > 0 then
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
end
@@ -2944,7 +2953,7 @@ local function buildModHeaderActionsModal(imp, m)
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
- { label = Strings("Sort mods..."), action = function() imp._sortPopup = true end },
+ { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
}
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad
@@ -2972,7 +2981,8 @@ end
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
-- persisted key, so one popup serves both).
local function buildSortModal(imp, m)
- local defs = sortDefs()
+ local scope = imp._sortPopup
+ local defs = sortDefs(scope)
local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s)
@@ -2982,7 +2992,7 @@ local function buildSortModal(imp, m)
local cy = py + pad
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
- local cur = currentSort(imp)
+ local cur = currentSort(imp, scope)
for _, s in ipairs(defs) do
local key = s.key
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, {
@@ -3362,25 +3372,43 @@ local function buildFindEntryModal(imp, m)
local gap = math.floor(8 * m.s)
local nBtns = 3 -- install row, details/source row, close row
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
+ local stats = imp:_findStats(entry)
+ local trend = {}
+ local trendLine = stats and ModUpdate.trendingLine(stats.recent, stats.windowDays)
+ if trendLine then trend[#trend + 1] = trendLine end
+ if stats and stats.asOf then
+ trend[#trend + 1] = Strings("counts approximate, as of %s",
+ tostring(stats.asOf):match("^%d%d%d%d%-%d%d%-%d%d") or stats.asOf)
+ end
+ trend = (#trend > 0) and table.concat(trend, " - ") or nil
+ local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
- + Kit.textHeight("small") + noteH + math.floor(12 * m.s)
+ + Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
- local stats = imp:_findStats(entry)
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if entry.author then lead = lead .. " - " .. entry.author end
if entry.categories and entry.categories[1] then
lead = lead .. " - " .. entry.categories[1]
end
- local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
+ local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
+ local hasCount = stats ~= nil and stats.total ~= nil
local segs = { { lead, PAL.detail } }
- if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
+ if dl then
+ segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
+ end
segLine("small", segs, px + pad, cy, pw - 2 * pad)
cy = cy + Kit.textHeight("small")
+ if trend then
+ cy = cy + math.floor(2 * m.s)
+ Kit.text("small", Kit.ellipsize("small", trend, pw - 2 * pad),
+ px + pad, cy, PAL.muted)
+ cy = cy + Kit.textHeight("small")
+ end
if note then
cy = cy + math.floor(4 * m.s)
Kit.text("small", note, px + pad, cy, PAL.green)
diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua
index 05a7643a..ab9af931 100644
--- a/src/import/RomImporter.lua
+++ b/src/import/RomImporter.lua
@@ -4449,9 +4449,14 @@ function RomImporter:_findStatsCached(entry)
end
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,
- latest = entry.last_release, done = true }
+ local ModIndex = require("src.mods.ModIndex")
+ local dl = ModIndex.downloadStats(entry)
+ local dates = ModIndex.releaseDates(entry)
+ if dl or dates then
+ cached = { total = dl and dl.total, recent = dl and dl.recent,
+ windowDays = dl and dl.window_days, asOf = dl and dl.as_of,
+ first = dates and dates.first, latest = dates and dates.latest,
+ done = true }
self._findStatsCache[entry.id] = cached
return cached
end
diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua
index 2cd39f06..b136297d 100644
--- a/src/mods/ModIndex.lua
+++ b/src/mods/ModIndex.lua
@@ -26,6 +26,7 @@ local ModIndex = {}
-- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1
+ModIndex.CACHE_VERSION = 2
-- ------- pure: source resolution
@@ -149,6 +150,43 @@ local function parseLatest(raw)
}
end
+local function parseDownloads(raw)
+ if type(raw) == "number" or type(raw) == "string" then
+ local n = tonumber(raw)
+ return n and { total = n } or nil
+ end
+ if type(raw) ~= "table" then return nil end
+ local out = {
+ total = tonumber(raw.total),
+ recent = tonumber(raw.recent),
+ window_days = tonumber(raw.window_days),
+ as_of = str(raw.as_of),
+ }
+ if out.total == nil and out.recent == nil then return nil end
+ return out
+end
+
+-- downloadStats(entry) -> { total, recent, window_days, as_of } | nil
+function ModIndex.downloadStats(entry)
+ if type(entry) ~= "table" then return nil end
+ return parseDownloads(entry.downloads)
+end
+
+local function isoDay(v)
+ if type(v) ~= "string" then return nil end
+ return v:match("^(%d%d%d%d%-%d%d%-%d%d)")
+end
+
+-- releaseDates(entry) -> { first, latest } | nil
+function ModIndex.releaseDates(entry)
+ if type(entry) ~= "table" then return nil end
+ local first = isoDay(entry.first_release)
+ local latest = isoDay(entry.last_release)
+ or isoDay(entry.latest and entry.latest.published_at)
+ if not (first or latest) then return nil end
+ return { first = first, latest = latest }
+end
+
local function parseEntry(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end
return {
@@ -175,11 +213,11 @@ local function parseEntry(raw)
conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url),
- -- Optional release stats a feed author can publish: total downloads
+ -- Optional release stats a feed author can publish: download counts
-- across all releases plus first/last release dates. Additive-only,
-- so a feed that carries them stays readable by every build that
-- predates them (and one that does not still renders fine here).
- downloads = tonumber(raw.downloads),
+ downloads = parseDownloads(raw.downloads),
first_release = str(raw.first_release),
last_release = str(raw.last_release),
latest = parseLatest(raw.latest),
@@ -516,6 +554,7 @@ function ModIndex.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModIndex.CACHE_TTL
return entry ~= nil and type(entry.checkedAt) == "number"
+ and entry.version == ModIndex.CACHE_VERSION
and (now - entry.checkedAt) < ttl
end
@@ -527,6 +566,7 @@ function ModIndex.writeCache(feed, index)
opts.modIndexCache = opts.modIndexCache or {}
opts.modIndexCache[feed] = {
checkedAt = os.time(),
+ version = ModIndex.CACHE_VERSION,
generatedAt = index.generatedAt,
categories = index.categories,
mods = index.mods,
diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua
index 4768bf70..d839a0f7 100644
--- a/src/mods/ModUpdate.lua
+++ b/src/mods/ModUpdate.lua
@@ -265,9 +265,42 @@ function ModUpdate.downloadsLine(total)
ModUpdate.formatCount(total))
end
+ModUpdate.NO_COUNT = "?"
+
+function ModUpdate.abbrevCount(n)
+ n = tonumber(n)
+ if not n or n ~= n or n < 0 then return ModUpdate.NO_COUNT end
+ n = math.floor(n)
+ if n < 1000 then return tostring(n) end
+ local units, idx, value = { "k", "M", "B" }, 0, n
+ repeat
+ value = value / 1000
+ idx = idx + 1
+ until idx >= #units or math.floor(value * 10 + 0.5) / 10 < 1000
+ local scaled = math.floor(value * 10 + 0.5) / 10
+ local shown = (scaled % 1 == 0) and tostring(math.floor(scaled))
+ or string.format("%.1f", scaled)
+ return shown .. units[idx]
+end
+
+function ModUpdate.downloadsShort(total)
+ return Strings("%s downloads", ModUpdate.abbrevCount(total))
+end
+
+function ModUpdate.trendingLine(recent, windowDays)
+ if recent == nil then return nil end
+ if not windowDays then
+ return Strings("%s recently", ModUpdate.abbrevCount(recent))
+ end
+ return Strings("%s in the last %d days", ModUpdate.abbrevCount(recent),
+ math.floor(windowDays))
+end
+
function ModUpdate.datesLine(first, latest)
if not (first or latest) then return nil end
- return Strings("Released %s - Updated %s", first or "?", latest or "?")
+ if not first then return Strings("Updated %s", latest) end
+ if not latest then return Strings("Released %s", first) end
+ return Strings("Released %s - Updated %s", first, latest)
end
function ModUpdate.statsLine(total, first, latest)
diff --git a/tests/drivers/launcher_find_downloads_shot.lua b/tests/drivers/launcher_find_downloads_shot.lua
new file mode 100644
index 00000000..695a35df
--- /dev/null
+++ b/tests/drivers/launcher_find_downloads_shot.lua
@@ -0,0 +1,121 @@
+-- Driver: renders the launcher's FIND MODS tab against a canned index whose
+-- listings carry the feed's `downloads` object, so the card line, the sort
+-- popup and the per-mod popup can be eyeballed with real numbers, real
+-- unknowns and a real zero in the same list.
+-- SHOT_DIR=/tmp/finddl POKEPORT_DRIVER=tests/drivers/launcher_find_downloads_shot.lua love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local RomImporter = require("src.import.RomImporter")
+
+ local dir = os.getenv("SHOT_DIR") or "/tmp/finddl"
+ os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
+ love.window.setMode(1024, 768, { resizable = true, highdpi = true })
+ U.wait(2)
+
+ local imp = RomImporter.new(function() end, { launcher = true })
+
+ local function mod(id, title, downloads)
+ return {
+ id = id, title = title, author = "someone", version = "1.0.0",
+ summary = "A canned listing for the screenshot.",
+ categories = { "GAMEPLAY" }, tags = {}, games = {},
+ update_check = "ok",
+ downloads = downloads,
+ latest = { version = "1.0.0", zip = {
+ url = "https://example.invalid/" .. id .. ".zip" } },
+ }
+ end
+
+ -- the canned listing IS the fixture: never let the tab go and fetch the
+ -- player's real sources over it
+ -- new() prewarms a real fetch; drop the in-flight handle and the pump
+ imp._refreshFindSources = function() end
+ imp._refreshFind = function() end
+ imp._pumpFindFetch = function() end
+ imp._findFetch = nil
+ imp.findSources = { { feed = "https://example.invalid/data/index.json",
+ base = "https://example.invalid/",
+ label = "example/index" } }
+ imp.findIndex = { schemaVersion = 1, categories = { "GAMEPLAY" }, mods = {
+ mod("busy", "Busy Mod",
+ { total = 1578, recent = 388, window_days = 30,
+ as_of = "2026-08-18T05:17:00.000Z" }),
+ mod("huge", "Huge Mod",
+ { total = 1234567, recent = 24000, window_days = 30,
+ as_of = "2026-08-18T05:17:00.000Z" }),
+ mod("young", "Young Mod",
+ { total = 42, as_of = "2026-08-18T05:17:00.000Z" }),
+ mod("counted-zero", "Counted Zero Mod", { total = 0 }),
+ mod("unknown", "Unknown Mod", nil),
+ } }
+ imp.findLoaded = true
+ imp:_switchTab("find")
+ U.wait(3)
+
+ local pending = nil
+ love.draw = function()
+ imp:draw()
+ if pending then
+ local path = pending
+ pending = nil
+ love.graphics.captureScreenshot(function(imagedata)
+ local f = io.open(path, "wb")
+ if f then f:write(imagedata:encode("png"):getString()) f:close() end
+ end)
+ end
+ end
+ local function shot(name)
+ pending = dir .. "/" .. name
+ for _ = 1, 90 do
+ if not pending then break end
+ imp:update(1 / 60)
+ coroutine.yield()
+ end
+ U.wait(3)
+ local f = io.open(dir .. "/" .. name, "rb")
+ U.log(f and "shot" or "FAIL shot", name)
+ if f then f:close() end
+ end
+
+ local ModUpdate = require("src.mods.ModUpdate")
+ for _, e in ipairs(imp.findIndex.mods) do
+ local s = imp:_findStats(e)
+ U.log((" %-13s %s | trending=%s"):format(e.id,
+ ModUpdate.downloadsShort(s and s.total),
+ tostring(s and ModUpdate.trendingLine(s.recent, s.windowDays))))
+ end
+
+ for _, key in ipairs({ "popularity", "trending", "name" }) do
+ imp.modSort = key
+ imp._findSortCache = nil
+ U.wait(2)
+ shot("find_sort_" .. key .. ".png")
+ local order = {}
+ for _, e in ipairs(imp._findSortCache.list) do order[#order + 1] = e.id end
+ U.log("sort " .. key .. ":", table.concat(order, ", "))
+ end
+
+ imp.modSort = "popularity"
+ imp._sortPopup = "find"
+ U.wait(2)
+ shot("find_sort_popup.png")
+ imp._sortPopup = nil
+
+ imp._findEntry = imp.findIndex.mods[1]
+ U.wait(2)
+ shot("find_entry_counted.png")
+ imp._findEntry = imp.findIndex.mods[5]
+ U.wait(2)
+ shot("find_entry_unknown.png")
+ imp._findEntry = nil
+
+ -- the installed-mods tab shares the persisted sort key; Trending there has
+ -- nothing to sort by, so it must degrade rather than reorder on nothing
+ imp.modSort = "trending"
+ imp:_switchTab("mods")
+ U.wait(3)
+ shot("mods_tab_trending_fallback.png")
+
+ U.log("done")
+ love.event.quit()
+end
diff --git a/tests/drivers/launcher_find_real_index.lua b/tests/drivers/launcher_find_real_index.lua
new file mode 100644
index 00000000..430fab66
--- /dev/null
+++ b/tests/drivers/launcher_find_real_index.lua
@@ -0,0 +1,93 @@
+-- Driver: opens FIND MODS against the player's real index sources, lets the
+-- prewarm fetch land, and reports what the panel resolved without asking
+-- GitHub anything -- the check behind "sorting only covers the pages I
+-- visited". Shoots the first and last page of the download sort.
+-- SHOT_DIR=/tmp/findreal POKEPORT_DRIVER=tests/drivers/launcher_find_real_index.lua love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local ModUpdate = require("src.mods.ModUpdate")
+
+ local fetched = 0
+ local realBegin = ModUpdate.beginFetchReleases
+ ModUpdate.beginFetchReleases = function(...)
+ fetched = fetched + 1
+ return realBegin(...)
+ end
+
+ local RomImporter = require("src.import.RomImporter")
+ local dir = os.getenv("SHOT_DIR") or "/tmp/findreal"
+ os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
+ love.window.setMode(1024, 768, { resizable = true, highdpi = true })
+ U.wait(2)
+
+ local imp = RomImporter.new(function() end, { launcher = true })
+ imp:_switchTab("find")
+ for _ = 1, 900 do
+ imp:update(1 / 60)
+ coroutine.yield()
+ if imp.findLoaded and not imp._findFetch then break end
+ end
+
+ local mods = (imp.findIndex and imp.findIndex.mods) or {}
+ U.log("index mods:", #mods)
+ U.log("stale copy:", tostring(imp.findIndex and imp.findIndex.stale))
+
+ local pending = nil
+ love.draw = function()
+ imp:draw()
+ if pending then
+ local path = pending
+ pending = nil
+ love.graphics.captureScreenshot(function(imagedata)
+ local f = io.open(path, "wb")
+ if f then f:write(imagedata:encode("png"):getString()) f:close() end
+ end)
+ end
+ end
+ local function shot(name)
+ pending = dir .. "/" .. name
+ for _ = 1, 90 do
+ if not pending then break end
+ imp:update(1 / 60)
+ coroutine.yield()
+ end
+ U.wait(3)
+ local f = io.open(dir .. "/" .. name, "rb")
+ U.log(f and "shot" or "FAIL shot", name)
+ if f then f:close() end
+ end
+
+ local counted, dated, unknown = 0, 0, 0
+ for _, e in ipairs(mods) do
+ local s = imp:_findStatsCached(e)
+ if s and s.total then counted = counted + 1 else unknown = unknown + 1 end
+ if s and s.latest then dated = dated + 1 end
+ end
+ U.log(("counts=%d dated=%d unknown=%d github requests=%d")
+ :format(counted, dated, unknown, fetched))
+
+ imp.modSort = "popularity"
+ imp._findSortCache = nil
+ U.wait(3)
+ shot("real_page1.png")
+ local sorted = imp._findSortCache and imp._findSortCache.list or {}
+ for i = 1, math.min(3, #sorted) do
+ local s = imp:_findStatsCached(sorted[i])
+ U.log((" #%d %s %s"):format(i, sorted[i].title,
+ ModUpdate.downloadsShort(s and s.total)))
+ end
+ for i = math.max(1, #sorted - 2), #sorted do
+ local s = imp:_findStatsCached(sorted[i])
+ U.log((" #%d %s %s"):format(i, sorted[i].title,
+ ModUpdate.downloadsShort(s and s.total)))
+ end
+
+ -- the last page is the one a page-by-page resolve would have left unsorted
+ imp._pages["find"] = 99
+ U.wait(3)
+ U.log("github requests after sorting:", fetched)
+ shot("real_last_page.png")
+
+ U.log("done")
+ love.event.quit()
+end
diff --git a/tests/engine/launcher_mod_downloads.lua b/tests/engine/launcher_mod_downloads.lua
new file mode 100644
index 00000000..7792f59f
--- /dev/null
+++ b/tests/engine/launcher_mod_downloads.lua
@@ -0,0 +1,125 @@
+-- FIND MODS download counts: the index feed publishes them per listing, so
+-- the panel reads the one fetch it already made instead of asking GitHub per
+-- mod (that fan-out is what blew the hourly API limit in _findStatsCached's
+-- own comment). This pins the resolver's precedence, the unknown-is-not-zero
+-- rule the card and the sorts both depend on, and the Trending wiring.
+-- luajit tests/engine/launcher_mod_downloads.lua
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+if not _G.love then _G.love = require("tests.love_stub") end
+
+local T = require("tests.harness")
+local check, eq = T.check, T.eq
+
+-- No launcher test may reach the network: beginFetchReleases is the only door
+-- out of _requestFindStats, and counting the calls is the assertion.
+local ModUpdate = require("src.mods.ModUpdate")
+local fetched = {}
+ModUpdate.beginFetchReleases = function(repo, id)
+ fetched[#fetched + 1] = id
+ return { stage = "done" }
+end
+
+local RomImporter = require("src.import.RomImporter")
+local function launcher()
+ return setmetatable({ tab = "find" }, RomImporter)
+end
+
+local function entry(id, downloads, github)
+ return { id = id, title = id, downloads = downloads, github = github }
+end
+
+-- ------- the feed's counts win, and cost nothing
+
+do
+ local ri = launcher()
+ local e = entry("counted", { total = 1578, recent = 388, window_days = 30,
+ as_of = "2026-08-18T05:17:00.000Z" },
+ "someone/counted")
+ local stats = ri:_findStats(e)
+ eq(stats.total, 1578, "the feed's total is what the card shows")
+ eq(stats.recent, 388, "the trailing-window count rides along")
+ eq(stats.windowDays, 30, "so does the window it covers")
+ eq(stats.asOf, "2026-08-18T05:17:00.000Z", "and when the index read it")
+ eq(#fetched, 0, "a listing with a published total asks GitHub for nothing")
+end
+
+-- A bare number is what a cache written before the object shipped holds; it
+-- is read back through the same resolver rather than migrated.
+do
+ local ri = launcher()
+ eq(ri:_findStats(entry("legacy", 4321, "someone/legacy")).total, 4321,
+ "a cached scalar count still resolves")
+ eq(#fetched, 0, "and still costs no request")
+end
+
+-- Whole-list, not page-by-page: sorting calls the cached read for every entry
+-- in the index, so all of them have to resolve from the feed alone. A
+-- listing the feed dates but does not count still resolves, off `latest`.
+do
+ local ri = launcher()
+ local dated = { id = "dated", title = "Dated", github = "someone/dated",
+ latest = { version = "1.0.0",
+ published_at = "2026-08-11T17:19:00Z" } }
+ local stats = ri:_findStats(dated)
+ eq(stats.latest, "2026-08-11", "the feed's own release blob dates the row")
+ eq(#fetched, 0, "with no repo fetch to wait on")
+ eq(ModUpdate.datesLine(stats.first, stats.latest), "Updated 2026-08-11",
+ "and the card states what it knows instead of a Released ?")
+end
+
+-- ------- unknown is unknown: null falls through to the repo, as any other
+-- missing feed stat does, and a listing with no repo resolves to no counts
+-- rather than to zero.
+
+do
+ local ri = launcher()
+ check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil,
+ "a null count is not an answer; the repo is still consulted")
+ eq(#fetched, 1, "which is the fetch the panel already made for dates")
+
+ local bare = launcher()
+ local stats = bare:_findStats(entry("no-repo", nil, nil))
+ check(stats ~= nil and stats.total == nil,
+ "a listing with neither counts nor a repo is resolved-but-unknown")
+ check(stats.recent == nil, "and has nothing to trend on")
+ eq(#fetched, 1, "and queues nothing of its own")
+end
+
+-- A real zero is not unknown: the index has seen the releases and counted
+-- none. The card prints it and the sort ranks it above the unknowns.
+do
+ local ri = launcher()
+ local stats = ri:_findStats(entry("zero", { total = 0 }, "someone/zero"))
+ eq(stats.total, 0, "a counted zero survives as a number")
+ eq(ModUpdate.downloadsShort(stats.total), "0 downloads",
+ "and prints as zero rather than as a dash")
+end
+
+-- ------- the panel wiring around those numbers
+
+local view = (function()
+ local f = assert(io.open("src/import/LauncherView.lua", "r"))
+ local src = f:read("*a")
+ f:close()
+ return src
+end)()
+
+check(view:find('key = "trending"', 1, true) ~= nil,
+ "a Trending sort exists")
+check(view:find('sortKey == "trending" then return stats and stats.recent or %-1'),
+ "Trending orders by the trailing-window count, unknown last")
+check(view:find('sortKey == "popularity" then return stats and stats.total or %-1'),
+ "Most downloaded orders by the total, unknown last")
+-- Trending has no meaning on the installed-mods tab, and the persisted key is
+-- shared, so that panel has to degrade rather than sort on nothing.
+check(view:find('if scope == "find" then', 1, true) ~= nil,
+ "Trending is offered only where the feed's counts exist")
+check(view:find('sortKey == "trending" and scope ~= "find"', 1, true) ~= nil,
+ "the MODS tab falls back when the shared sort key is FIND-only")
+check(view:find('sortKey or "popularity"', 1, true) ~= nil,
+ "the default sort is unchanged")
+check(view:find("downloadsShort", 1, true) ~= nil,
+ "the browse card prints the abbreviated count")
+
+T.finish("launcher_mod_downloads")
diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua
index 87e96222..93e1a455 100644
--- a/tests/engine/mod_index_tests.lua
+++ b/tests/engine/mod_index_tests.lua
@@ -114,23 +114,110 @@ do
"a feed without release stats parses them as absent")
end
--- release stats a feed can publish: total downloads and first/last dates
+-- release stats a feed can publish: download counts and first/last dates
-- ride along additively, so a feed carrying them stays readable by every
-- build that predates them
do
local withStats = {}
for k, v in pairs(NUZLOCKE) do withStats[k] = v end
- withStats.downloads = 1234
+ withStats.downloads = { total = 1578, recent = 388, window_days = 30,
+ as_of = "2026-08-18T05:17:00.000Z" }
withStats.first_release = "2024-05-31"
withStats.last_release = "2026-07-01"
local index = ModIndex.parse(feed({ withStats }))
local m = index.mods[1]
- eq(m.downloads, 1234, "total downloads are kept")
+ eq(m.downloads.total, 1578, "total downloads are kept")
+ eq(m.downloads.recent, 388, "the trailing-window count is kept")
+ eq(m.downloads.window_days, 30, "the window length is kept")
+ eq(m.downloads.as_of, "2026-08-18T05:17:00.000Z", "the read time is kept")
eq(m.first_release, "2024-05-31", "first release date is kept")
eq(m.last_release, "2026-07-01", "last release date is kept")
- withStats.downloads = "9999"
- m = ModIndex.parse(feed({ withStats })).mods[1]
- eq(m.downloads, 9999, "numeric-string downloads are coerced")
+end
+
+-- ------- download counts: unknown is not zero
+--
+-- The feed's `downloads` object has three ways of saying "not known" -- the
+-- field absent, the field null, and a null count inside it -- and every one
+-- of them has to stay distinguishable from a real zero, because the browse
+-- card prints one and sorts the other.
+do
+ local function jsonWith(downloads)
+ local raw = {}
+ for k, v in pairs(NUZLOCKE) do raw[k] = v end
+ raw.downloads = downloads
+ return feed({ raw })
+ end
+ local function statsForJson(text)
+ return ModIndex.downloadStats(ModIndex.parse(text).mods[1])
+ end
+ local function statsFor(downloads)
+ return statsForJson(jsonWith(downloads))
+ end
+
+ check(statsFor(nil) == nil, "an absent downloads field is unknown")
+ -- Json.encode has no null of its own, so the literal the feed actually
+ -- sends is patched into the text.
+ local nulled = jsonWith({}):gsub('"downloads":%[%]', '"downloads":null', 1)
+ check(nulled:find('"downloads":null', 1, true) ~= nil,
+ "the null feed fixture really contains a null")
+ check(statsForJson(nulled) == nil, "a null downloads field is unknown")
+ check(statsFor({}) == nil, "an object with no counts is unknown")
+ eq(statsFor({ total = 0 }).total, 0, "a real zero total survives")
+
+ -- recent / window_days stay null until the index has more than a day of
+ -- history, even once total is a real number.
+ local young = statsFor({ total = 12, as_of = "2026-08-18T05:17:00.000Z" })
+ eq(young.total, 12, "a total with no window yet is still a total")
+ check(young.recent == nil and young.window_days == nil,
+ "no trailing window means no trending figure, not a zero one")
+
+ -- A cache written before the object shipped stored a bare number; it is
+ -- read back through the same door rather than migrated.
+ local legacy = ModIndex.downloadStats({ downloads = 4321 })
+ eq(legacy.total, 4321, "a bare number reads as the total")
+ check(legacy.recent == nil, "and carries no trending figure")
+ check(ModIndex.downloadStats({}) == nil, "a row with no counts is unknown")
+ check(ModIndex.downloadStats(nil) == nil, "no entry is unknown")
+end
+
+-- ------- release dates: the feed already dates every listing it can install
+--
+-- Sorting must span the whole index, not the pages a reader happened to
+-- visit, so the "last updated" date comes off the feed's own `latest` blob
+-- rather than out of a per-mod repo fetch.
+do
+ local raw = {}
+ for k, v in pairs(NUZLOCKE) do raw[k] = v end
+ local d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
+ eq(d.latest, "2026-07-31", "latest release date comes from latest.published_at")
+ check(d.first == nil, "the feed cannot date a first release from that alone")
+
+ raw.first_release = "2024-05-31"
+ raw.last_release = "2026-07-01"
+ d = ModIndex.releaseDates(ModIndex.parse(feed({ raw })).mods[1])
+ eq(d.first, "2024-05-31", "an explicit first_release wins")
+ eq(d.latest, "2026-07-01", "an explicit last_release beats the latest blob")
+
+ local bare = {}
+ for k, v in pairs(NUZLOCKE) do bare[k] = v end
+ bare.latest, bare.update_check = nil, "no installable release"
+ check(ModIndex.releaseDates(ModIndex.parse(feed({ bare })).mods[1]) == nil,
+ "a listing with no releases has no dates")
+ check(ModIndex.releaseDates(nil) == nil, "no entry has no dates")
+end
+
+-- ------- cache version: a copy written before a field existed cannot answer
+-- for it, and the TTL is a whole day
+do
+ local now = os.time()
+ check(ModIndex.cacheFresh({ checkedAt = now,
+ version = ModIndex.CACHE_VERSION }), "a current cache is fresh")
+ check(not ModIndex.cacheFresh({ checkedAt = now }),
+ "an unstamped cache is refetched rather than trusted for a day")
+ check(not ModIndex.cacheFresh({ checkedAt = now,
+ version = ModIndex.CACHE_VERSION - 1 }), "so is an older stamp")
+ check(not ModIndex.cacheFresh({ checkedAt = now - ModIndex.CACHE_TTL - 1,
+ version = ModIndex.CACHE_VERSION }), "and an expired one")
end
-- schema_version is a contract, not a hint: an unknown one is refused rather
diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua
index a0b3306e..6c081ca8 100644
--- a/tests/engine/mod_update_tests.lua
+++ b/tests/engine/mod_update_tests.lua
@@ -208,6 +208,32 @@ eq(ModUpdate.formatCount("12345"), "12,345", "numeric strings are accepted")
eq(ModUpdate.formatCount(nil), "0", "nil formats as zero")
eq(ModUpdate.formatCount("garbage"), "0", "garbage formats as zero")
+-- abbrevCount: what a browse card has room for
+eq(ModUpdate.abbrevCount(0), "0", "zero abbreviates to itself")
+eq(ModUpdate.abbrevCount(999), "999", "below 1000 stays exact")
+eq(ModUpdate.abbrevCount(1000), "1k", "a round thousand drops its decimal")
+eq(ModUpdate.abbrevCount(1578), "1.6k", "thousands round to one decimal")
+eq(ModUpdate.abbrevCount(24000), "24k", "a round figure keeps no .0")
+eq(ModUpdate.abbrevCount(999999), "1M", "no count ever reads as 1000k")
+eq(ModUpdate.abbrevCount(1234567), "1.2M", "millions abbreviate too")
+eq(ModUpdate.abbrevCount("1578"), "1.6k", "numeric strings are accepted")
+eq(ModUpdate.abbrevCount(nil), "?", "an unknown count is not a zero")
+eq(ModUpdate.abbrevCount("garbage"), "?", "garbage is unknown, not a zero")
+
+-- downloadsShort / trendingLine: the card and detail forms
+eq(ModUpdate.downloadsShort(1578), "1.6k downloads", "the card form abbreviates")
+eq(ModUpdate.downloadsShort(0), "0 downloads", "a real zero is still stated")
+eq(ModUpdate.downloadsShort(nil), "? downloads",
+ "an unknown count says so rather than vanishing or reading as zero")
+eq(ModUpdate.trendingLine(388, 30), "388 in the last 30 days",
+ "the trending line names its window")
+eq(ModUpdate.trendingLine(388, 12), "388 in the last 12 days",
+ "a short window says so rather than claiming a month")
+eq(ModUpdate.trendingLine(388, nil), "388 recently",
+ "a count with no window still reads")
+check(ModUpdate.trendingLine(nil, 30) == nil,
+ "no trailing-window count means no trending line")
+
-- statsLine: the shared launcher row line, part by part
eq(ModUpdate.statsLine(1234567, "2024-05-31", "2026-07-01"),
"1,234,567 downloads across all releases - Released 2024-05-31 - Updated 2026-07-01",