Compare commits

...

6 Commits

Author SHA1 Message Date
bryanthaboi 5ba49bdf3f Merge pull request #1524 from bryanthaboi/dev
squishing some fun ones
2026-08-18 11:55:28 -04:00
bryanthaboi abf95ce1c4 CLOSES #1519 2026-08-18 11:43:23 -04:00
bryanthaboi bd1046f398 CLOSES #1393 2026-08-18 11:29:50 -04:00
bryanthaboi d5ad830fb8 CLOSES #1418 2026-08-18 10:49:04 -04:00
bryanthaboi c2c7fdafcf CLOSES #1430 2026-08-18 10:36:44 -04:00
github-actions c01bda3570 chore(ios): update app-repo.json [skip ci] 2026-08-18 10:28:41 -04:00
27 changed files with 1119 additions and 66 deletions
+1 -1
View File
@@ -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) [Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION] > [!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.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p> <p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
+1
View File
@@ -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 * **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 * **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** * **Pokédex diploma and printer image exports**
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
## Gen 2 Specifics ## Gen 2 Specifics
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.2.5",
"date": "2026-08-18",
"size": 13586158,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.2.4", "version": "0.2.4",
"date": "2026-08-18", "date": "2026-08-18",
+1
View File
@@ -1968,6 +1968,7 @@ function Game2:applyOptions()
}) })
require("src.core.VideoMode").applyOptions(options) require("src.core.VideoMode").applyOptions(options)
require("src.core.FrameCap").applyOptions(options) require("src.core.FrameCap").applyOptions(options)
require("src.world.gen2.BorderFill").applyOptions(options)
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level. -- applyOptions returns true when it had to clear an unsupported level.
+3
View File
@@ -274,6 +274,9 @@ Save.DEFAULT_OPTIONS = {
color = "gbc", color = "gbc",
videoMode = "windowed", videoMode = "windowed",
fpsCap = 60, fpsCap = 60,
-- VOID FILL: fade | water | trees | black. fade is each map header's own
-- border block with the dissolve across a boundary (#1418).
voidFill = "fade",
musicVol = 7, -- 0-7, like the GB's NR50 master volume musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7 sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off musicFilter = 0, -- low-pass steps, 0 = off
+18 -3
View File
@@ -529,9 +529,9 @@ end
-- Gold reads NONE of the rows above. Its OPTION screen writes a different -- Gold reads NONE of the rows above. Its OPTION screen writes a different
-- set of names, several of which collide with Gen 1's at a different TYPE -- set of names, several of which collide with Gen 1's at a different TYPE
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and -- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
-- its renderer has no battle layout, no SGB palette packs and no void fill -- -- its renderer has no battle layout and no SGB palette packs -- so a gear
-- so a gear opened on the Gold tab used to offer a dozen controls that did -- opened on the Gold tab used to offer a dozen controls that did nothing
-- nothing and hide the seven that the cart itself has. -- and hide the seven that the cart itself has.
-- --
-- The block lives in options.lua under `gold`, which is exactly where -- The block lives in options.lua under `gold`, which is exactly where
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the -- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
@@ -617,6 +617,21 @@ local function gen2Rows(opts, hooks)
end) end)
end end
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
if okFill and BorderFill.VOID_FILLS then
add(Strings("VOID FILL"),
function() return BorderFill.voidFillLabel(opts.voidFill) end,
function(dir)
local modes = BorderFill.VOID_FILLS
local cur, idx = opts.voidFill or "fade", 1
for i, m in ipairs(modes) do
if m == cur then idx = i break end
end
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
return true
end)
end
-- Same #136 gate as the Gen 1 row and the in-game one. -- Same #136 gate as the Gen 1 row and the in-game one.
local okFx, GBCFX = pcall(require, "src.render.GBCFX") local okFx, GBCFX = pcall(require, "src.render.GBCFX")
if okFx and GBCFX.isSupported() then if okFx and GBCFX.isSupported() then
+51 -23
View File
@@ -1626,13 +1626,17 @@ end
-- The persisted sort choice both mod panels share. The chooser itself is a -- The persisted sort choice both mod panels share. The chooser itself is a
-- popup (buildSortModal); panels just read the current key and offer 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. -- "Sort" button, which is what freed the chip row's two lines of space.
local function sortDefs() local function sortDefs(scope)
return { local defs = {
{ key = "name", label = Strings("Name") }, { key = "name", label = Strings("Name") },
{ key = "popularity", label = Strings("Popularity") }, { key = "popularity", label = Strings("Most downloaded") },
{ key = "release", label = Strings("Release date") },
{ key = "updated", label = Strings("Last updated") },
} }
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 end
-- Sorting is decorate-sort-undecorate: the key is computed once per entry -- 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 return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
end end
local function currentSort(imp) local function currentSort(imp, scope)
local sortKey = imp.modSort local sortKey = imp.modSort
if sortKey == nil then if sortKey == nil then
local ok, opts = pcall(require("src.core.SaveData").loadOptions) local ok, opts = pcall(require("src.core.SaveData").loadOptions)
@@ -1694,6 +1698,7 @@ local function currentSort(imp)
sortKey = sortKey or "popularity" sortKey = sortKey or "popularity"
imp.modSort = sortKey imp.modSort = sortKey
end end
if sortKey == "trending" and scope ~= "find" then return "popularity" end
return sortKey return sortKey
end end
@@ -1760,7 +1765,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:_syncModUpdateInfo(true) end }) action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small", font = "small",
action = function() imp._sortPopup = true end }) action = function() imp._sortPopup = "mods" end })
elseif medReq <= w then elseif medReq <= w then
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly -- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, { 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 }) action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small", 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..."), { btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small", font = "small",
action = function() imp._modHeaderActionsPopup = true end }) action = function() imp._modHeaderActionsPopup = true end })
@@ -1787,7 +1792,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:chooseMod() end }) action = function() imp:chooseMod() end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), { btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small", 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..."), { btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small", font = "small",
action = function() imp._modHeaderActionsPopup = true end }) action = function() imp._modHeaderActionsPopup = true end })
@@ -1819,7 +1824,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
return return
end end
local sortKey = currentSort(imp) local sortKey = currentSort(imp, "mods")
-- Immediate mode paints this panel every frame; re-sorting the whole list -- Immediate mode paints this panel every frame; re-sorting the whole list
-- per frame (with lowercased-string allocations in the comparator) fed the -- 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) local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), { btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
font = "small", 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, -- The Filter button carries its state: blue while a category is active,
-- so a filtered-down list never reads as "the index shrank". -- so a filtered-down list never reads as "the index shrank".
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s) 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 return
end end
local sortKey = currentSort(imp) local sortKey = currentSort(imp, "find")
-- Same caching rule as the MODS tab: the comparator allocates, so only -- Same caching rule as the MODS tab: the comparator allocates, so only
-- re-sort when the inputs actually change. -- 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). -- fetch for every entry in the index (see _findStatsCached).
local stats = imp:_findStatsCached(entry) local stats = imp:_findStatsCached(entry)
if sortKey == "popularity" then return stats and stats.total or -1 end 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 if sortKey == "release" then return stats and stats.first or "0000-00-00" end
return stats and stats.latest or "0000-00-00" return stats and stats.latest or "0000-00-00"
end, end,
@@ -2336,13 +2342,14 @@ local function buildFindPanel(imp, x, y, w, availH, m)
bx, ly, PAL.heading) bx, ly, PAL.heading)
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s) local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
-- meta and stats on one line, the download count first (and green) -- 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. -- narrow window ellipsizes the tail, and the count must survive that.
local stats = imp:_findStats(entry) local stats = imp:_findStats(entry)
local baseCol = note and PAL.green or PAL.detail local baseCol = note and PAL.green or PAL.detail
local lead = "v" .. tostring(ModIndex.displayVersion(entry)) local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if note then lead = lead .. " - " .. note end 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) local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
or nil or nil
local rest = {} local rest = {}
@@ -2352,11 +2359,13 @@ local function buildFindPanel(imp, x, y, w, availH, m)
end end
if dates then if dates then
rest[#rest + 1] = dates 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 rest[#rest + 1] = entry.summary
end end
local segs = { { lead, baseCol } } 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 if #rest > 0 then
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol } segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
end end
@@ -2944,7 +2953,7 @@ local function buildModHeaderActionsModal(imp, m)
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, { 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("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("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) local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad + #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 -- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
-- persisted key, so one popup serves both). -- persisted key, so one popup serves both).
local function buildSortModal(imp, m) local function buildSortModal(imp, m)
local defs = sortDefs() local scope = imp._sortPopup
local defs = sortDefs(scope)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s) local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s) local gap = math.floor(8 * m.s)
@@ -2982,7 +2992,7 @@ local function buildSortModal(imp, m)
local cy = py + pad local cy = py + pad
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading) Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) 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 for _, s in ipairs(defs) do
local key = s.key local key = s.key
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, { 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 gap = math.floor(8 * m.s)
local nBtns = 3 -- install row, details/source row, close row 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 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) 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 + nBtns * (m.btnH + gap) - gap + pad
local px, py, pw = modalPanel(m, w, h) local px, py, pw = modalPanel(m, w, h)
local cy = py + pad local cy = py + pad
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id, Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
pw - 2 * pad), px + pad, cy, PAL.heading) pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
local stats = imp:_findStats(entry)
local lead = "v" .. tostring(ModIndex.displayVersion(entry)) local lead = "v" .. tostring(ModIndex.displayVersion(entry))
if entry.author then lead = lead .. " - " .. entry.author end if entry.author then lead = lead .. " - " .. entry.author end
if entry.categories and entry.categories[1] then if entry.categories and entry.categories[1] then
lead = lead .. " - " .. entry.categories[1] lead = lead .. " - " .. entry.categories[1]
end 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 } } 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) segLine("small", segs, px + pad, cy, pw - 2 * pad)
cy = cy + Kit.textHeight("small") 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 if note then
cy = cy + math.floor(4 * m.s) cy = cy + math.floor(4 * m.s)
Kit.text("small", note, px + pad, cy, PAL.green) Kit.text("small", note, px + pad, cy, PAL.green)
+8 -3
View File
@@ -4449,9 +4449,14 @@ function RomImporter:_findStatsCached(entry)
end end
return nil -- retry window open; _requestFindStats decides what to do return nil -- retry window open; _requestFindStats decides what to do
end end
if entry.downloads ~= nil or entry.first_release or entry.last_release then local ModIndex = require("src.mods.ModIndex")
cached = { total = entry.downloads, first = entry.first_release, local dl = ModIndex.downloadStats(entry)
latest = entry.last_release, done = true } 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 self._findStatsCache[entry.id] = cached
return cached return cached
end end
+42 -2
View File
@@ -26,6 +26,7 @@ local ModIndex = {}
-- single repo's releases; a whole index is heavier and changes more slowly. -- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60 ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1 ModIndex.SCHEMA_VERSION = 1
ModIndex.CACHE_VERSION = 2
-- ------- pure: source resolution -- ------- pure: source resolution
@@ -149,6 +150,43 @@ local function parseLatest(raw)
} }
end 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) local function parseEntry(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end if type(raw) ~= "table" or not str(raw.id) then return nil end
return { return {
@@ -175,11 +213,11 @@ local function parseEntry(raw)
conflicts = raw.conflicts, conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail), thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url), 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, -- across all releases plus first/last release dates. Additive-only,
-- so a feed that carries them stays readable by every build that -- so a feed that carries them stays readable by every build that
-- predates them (and one that does not still renders fine here). -- 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), first_release = str(raw.first_release),
last_release = str(raw.last_release), last_release = str(raw.last_release),
latest = parseLatest(raw.latest), latest = parseLatest(raw.latest),
@@ -516,6 +554,7 @@ function ModIndex.cacheFresh(entry, now, ttl)
now = now or os.time() now = now or os.time()
ttl = ttl or ModIndex.CACHE_TTL ttl = ttl or ModIndex.CACHE_TTL
return entry ~= nil and type(entry.checkedAt) == "number" return entry ~= nil and type(entry.checkedAt) == "number"
and entry.version == ModIndex.CACHE_VERSION
and (now - entry.checkedAt) < ttl and (now - entry.checkedAt) < ttl
end end
@@ -527,6 +566,7 @@ function ModIndex.writeCache(feed, index)
opts.modIndexCache = opts.modIndexCache or {} opts.modIndexCache = opts.modIndexCache or {}
opts.modIndexCache[feed] = { opts.modIndexCache[feed] = {
checkedAt = os.time(), checkedAt = os.time(),
version = ModIndex.CACHE_VERSION,
generatedAt = index.generatedAt, generatedAt = index.generatedAt,
categories = index.categories, categories = index.categories,
mods = index.mods, mods = index.mods,
+34 -1
View File
@@ -265,9 +265,42 @@ function ModUpdate.downloadsLine(total)
ModUpdate.formatCount(total)) ModUpdate.formatCount(total))
end 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) function ModUpdate.datesLine(first, latest)
if not (first or latest) then return nil end 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 end
function ModUpdate.statsLine(total, first, latest) function ModUpdate.statsLine(total, first, latest)
+33 -16
View File
@@ -24,14 +24,18 @@
-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and -- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and
-- PlaceMoneyTextbox writes the amount at -- PlaceMoneyTextbox writes the amount at
-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1) -- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1)
-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 box holding 4 -- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 rect holding 4
-- entries of two rows. ScrollingMenu_UpdateDisplay -- entries of two rows. BuyMenuLoop copies that
-- starts at (2,4); PlaceMenuItemName prints the name -- header and calls ScrollingMenu; it never calls
-- there and .PrintBCDPrices is handed that origin -- InitScrollingMenu or MenuBox, so the rect is NOT
-- plus the menu's own width (8) plus SCREEN_WIDTH, so -- a framed window. ScrollingMenu_UpdateDisplay
-- the price lands at (10,5). The ▲ sits on the box's -- calls ClearWholeMenuBox on it (spaces, no border)
-- top right corner (19,3) and the ▼ on its bottom -- and starts printing at (2,4); PlaceMenuItemName
-- right (19,11). -- prints the name there and .PrintBCDPrices is
-- handed that origin plus the menu's own width (8)
-- plus SCREEN_WIDTH, so the price lands at (10,5).
-- The ▲ sits on the rect's top right (19,3) and
-- the ▼ on its bottom right (19,11).
-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with -- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with
-- the description at (1,14) -- the description at (1,14)
-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and -- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and
@@ -75,7 +79,9 @@ local SFX_TRANSACTION = "Sfx_Transaction"
local MartMenu = {} local MartMenu = {}
MartMenu.__index = MartMenu MartMenu.__index = MartMenu
-- engine/items/mart.asm:54 -- The top menu overlays the mart (StandardMart .HowMayIHelpYou /
-- .AnythingElse: LoadStandardMenuHeader + PrintText). BuyMenu is
-- FadeToMenu + BlankScreen, so enterBuy shadows this for the list.
MartMenu.isOpaque = false MartMenu.isOpaque = false
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte -- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
@@ -563,6 +569,10 @@ function MartMenu:enterBuy()
self.phase = "buy" self.phase = "buy"
self.index = 1 self.index = 1
self.scroll = 0 self.scroll = 0
-- BuyMenu (engine/items/mart.asm): `call FadeToMenu / farcall BlankScreen`.
-- BlankScreen fills the tilemap with spaces and the palettes with white, so
-- the mart must not keep drawing in the letterbox under the list.
self.isOpaque = (self.phase == "buy")
end end
function MartMenu:total() function MartMenu:total()
@@ -618,6 +628,8 @@ end
-- BuyMenu returns into StandardMart .Buy, which falls through to -- BuyMenu returns into StandardMart .Buy, which falls through to
-- .AnythingElse; the other three dialog kinds end on their come-again line. -- .AnythingElse; the other three dialog kinds end on their come-again line.
function MartMenu:leaveBuy() function MartMenu:leaveBuy()
-- CloseSubmenu restores the map before .AnythingElse / the come-again line.
self.isOpaque = nil
if self.martType == "STANDARD" then if self.martType == "STANDARD" then
self:enterTop(self.text.askMore) self:enterTop(self.text.askMore)
else else
@@ -885,11 +897,10 @@ function MartMenu:description()
return def and def.description or nil return def and def.description or nil
end end
-- The bottom visible entry's price row IS the box's bottom border row -- The bottom visible entry's price row is MenuHeader_Buy's last line
-- (entry row 10 + SCREEN_WIDTH = 11, MenuHeader_Buy's own last line), and a -- (entry row 10 + SCREEN_WIDTH = 11). A GB glyph REPLACES the tile it
-- GB glyph REPLACES the tile it prints over, border and all. White under -- prints over, so white under the seven money tiles first is that
-- the seven money tiles first is that replacement; on the three rows clear -- replacement. On a BlankScreen field it is white over white.
-- of the border it repaints white over white.
local function printPriceOpaque(amount, ty) local function printPriceOpaque(amount, ty)
local G = love.graphics local G = love.graphics
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
@@ -899,8 +910,10 @@ local function printPriceOpaque(amount, ty)
end end
function MartMenu:drawBuyList() function MartMenu:drawBuyList()
-- engine/items/mart.asm:542 -- ScrollingMenu_UpdateDisplay (engine/menus/scrolling_menu.asm) calls
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) -- ClearWholeMenuBox, not MenuBox: the MenuHeader_Buy rect is a cleared
-- field, not a framed window. BlankScreen already filled the tilemap
-- with spaces, so the names just land on white.
for row = 1, VISIBLE_ROWS do for row = 1, VISIBLE_ROWS do
local i = row + self.scroll local i = row + self.scroll
local ty = LIST_Y + (row - 1) * LIST_SPACING local ty = LIST_Y + (row - 1) * LIST_SPACING
@@ -960,6 +973,10 @@ function MartMenu:drawUnder()
self:drawTopMenu() self:drawTopMenu()
self:drawTextBox(self.topLines) self:drawTextBox(self.topLines)
elseif phase == "buy" or phase == "buyQuantity" then elseif phase == "buy" or phase == "buyQuantity" then
-- BlankScreen (engine/overworld/player_object.asm): the whole tilemap
-- is spaces before PlaceMoneyTopRight / the scrolling list / the
-- description textbox go down. Only those last two are framed.
Chrome.clear()
self:drawMoneyBox() self:drawMoneyBox()
self:drawBuyList() self:drawBuyList()
self:drawDescription() self:drawDescription()
+13
View File
@@ -134,6 +134,19 @@ local ROWS = {
text = function(options) text = function(options)
return require("src.render.Zoom").offsetLabel(options.zoom or 0) return require("src.render.Zoom").offsetLabel(options.zoom or 0)
end }, end },
-- VOID FILL: FADE is each map's own border block with the dissolve across
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
-- is Gold's default because that is already what the maps call for).
{ label = "VOID FILL", key = "voidFill", port = true,
cycle = function(options, delta)
local BorderFill = require("src.world.gen2.BorderFill")
BorderFill.setVoidFill(options.voidFill or "fade")
options.voidFill = BorderFill.cycle(delta)
end,
text = function(options)
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
end },
{ label = "TILT", key = "tilt", port = true, { label = "TILT", key = "tilt", port = true,
cycle = function(options, delta) cycle = function(options, delta)
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
+7
View File
@@ -329,6 +329,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
MapLoader.invalidateAll() MapLoader.invalidateAll()
end end
self.map = MapLoader.load(Game.data, mapId) self.map = MapLoader.load(Game.data, mapId)
-- EnterMap -> ClearVariablesOnEnterMap zeroes wStepCounter
-- (engine/overworld/clear_variables.asm:7), but a connection crossing
-- never reaches EnterMap (home/overworld.asm:675 .loadNewMap)
if not (opts and opts.seamless) then Game.save.poisonSteps = 0 end
-- STRENGTH deactivates on every real map load (home/overworld.asm -- STRENGTH deactivates on every real map load (home/overworld.asm
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE -- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
-- of wStatusFlags1). setMap is the single choke point for every map-id -- of wStatusFlags1). setMap is the single choke point for every map-id
@@ -4296,6 +4300,9 @@ end
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the -- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty. -- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
function OverworldState:afterBattle(result, battle) function OverworldState:afterBattle(result, battle)
-- .battleOccurred tail-jumps to EnterMap (home/overworld.asm:353), so
-- ClearVariablesOnEnterMap zeroes wStepCounter on every battle return
Game.save.poisonSteps = 0
if battle and battle.kind == "wild" then if battle and battle.kind == "wild" then
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
end end
+83 -4
View File
@@ -152,16 +152,95 @@ function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame)
return img return img
end end
-- VOID FILL (#1418): the beyond-edge scenery under survey zoom. FADE (the
-- default) is each map header's own border block with the dissolve below;
-- WATER / TREES force one block on outdoor tilesets that actually have that
-- scenery; BLACK is a flat void. Indoor and cave tilesets have no tree wall
-- or water metatile, so water/trees fall through to the map's own border
-- rather than painting a random block.
--
-- Canonical ids are the wMapBorderBlock values already used as those fills
-- in data/maps/attributes.asm: New Bark / Route 30 $05 trees, Cherrygrove
-- $35 water, Pallet $0f trees, Cinnabar $43 water, Ilex Forest $05 trees.
BorderFill.VOID_FILLS = { "fade", "water", "trees", "black" }
BorderFill.voidFill = "fade"
local FILL_BLOCKS = {
TILESET_JOHTO = { trees = 0x05, water = 0x35 },
TILESET_JOHTO_MODERN = { trees = 0x05, water = 0x35 },
TILESET_KANTO = { trees = 0x0f, water = 0x43 },
TILESET_FOREST = { trees = 0x05 },
}
function BorderFill.setVoidFill(mode)
local ok = false
for _, name in ipairs(BorderFill.VOID_FILLS) do
if name == mode then ok = true; break end
end
BorderFill.voidFill = ok and mode or "fade"
end
function BorderFill.cycle(delta)
local cur = BorderFill.voidFill or "fade"
local at = 1
for i, name in ipairs(BorderFill.VOID_FILLS) do
if name == cur then at = i; break end
end
local n = #BorderFill.VOID_FILLS
at = (at - 1 + (delta or 1)) % n + 1
BorderFill.setVoidFill(BorderFill.VOID_FILLS[at])
return BorderFill.voidFill
end
function BorderFill.applyOptions(opts)
BorderFill.setVoidFill(opts and opts.voidFill or "fade")
end
function BorderFill.voidFillLabel(mode)
mode = mode or BorderFill.voidFill or "fade"
if mode == "water" then return "WATER" end
if mode == "trees" then return "TREES" end
if mode == "black" then return "BLACK" end
-- trailing space blanks the 5-char WATER/TREES/BLACK from the value column
return "FADE "
end
-- The metatile the void should bake, or false when BLACK skips tiling.
function BorderFill.fillBlock(def)
local mode = BorderFill.voidFill or "fade"
if mode == "black" then return false end
if (mode == "water" or mode == "trees") and def then
local fills = FILL_BLOCKS[def.tileset]
local block = fills and fills[mode]
if block ~= nil then return block end
end
return def and def.borderBlock or 0
end
-- Crossfade identity: FADE dissolves per map (Cherrygrove water -> Route 30
-- trees). WATER/TREES dissolve only when the forced block itself changes
-- (Johto water -> Kanto water), so walking two Johto routes does not fade
-- identical water against itself. BLACK is one sheet everywhere.
function BorderFill.fillKey(def)
local mode = BorderFill.voidFill or "fade"
if mode == "black" then return "black" end
local block = BorderFill.fillBlock(def)
if mode == "water" or mode == "trees" then
return mode .. "|" .. tostring(def and def.tileset) .. "|" .. tostring(block)
end
return "fade|" .. tostring(def and def.id)
end
-- Each map header carries its OWN border block, so crossing a boundary swaps -- Each map header carries its OWN border block, so crossing a boundary swaps
-- the whole void from one block to another: Cherrygrove's water becomes Route -- the whole void from one block to another: Cherrygrove's water becomes Route
-- 30's trees between one frame and the next. On a 20x18 viewport that is a few -- 30's trees between one frame and the next. On a 20x18 viewport that is a few
-- pixels at the screen edge and nobody sees it; under survey zoom the void is -- pixels at the screen edge and nobody sees it; under survey zoom the void is
-- most of the window, and the swap reads as the background popping. -- most of the window, and the swap reads as the background popping.
-- --
-- So the swap is dissolved rather than cut. `key` is the map the image belongs -- So the swap is dissolved rather than cut. `key` is the fill identity
-- to, not the image itself: the same block gets re-baked by the daytime -- (BorderFill.fillKey), not the image itself: the same block gets re-baked by
-- rollover, the COLOR option and the two-frame cave flicker, and a dissolve on -- the daytime rollover, the COLOR option and the two-frame cave flicker, and a
-- any of those would smear the flicker into mush. -- dissolve on any of those would smear the flicker into mush.
BorderFill.CROSSFADE_FRAMES = 20 BorderFill.CROSSFADE_FRAMES = 20
-- The bookkeeping half, love-free so it can be checked without a canvas. -- The bookkeeping half, love-free so it can be checked without a canvas.
+23 -5
View File
@@ -7900,8 +7900,10 @@ function World:borderWaterFrame(def, tileset)
end end
end end
if not tile then return nil end if not tile then return nil end
local fill = BorderFill.fillBlock(def)
if fill == false then return nil end
local block = tileset.blocks local block = tileset.blocks
and tileset.blocks[BorderFill.blockFor(0, def.borderBlock) + 1] and tileset.blocks[BorderFill.blockFor(0, fill) + 1]
if not block then return nil end if not block then return nil end
local found = false local found = false
for i = 1, 16 do for i = 1, 16 do
@@ -7933,11 +7935,19 @@ function World:borderImageFor(mapId)
if not def then return nil end if not def then return nil end
local tileset = self.tilesets and self.tilesets[def.tileset] local tileset = self.tilesets and self.tilesets[def.tileset]
if not tileset then return nil end if not tileset then return nil end
-- VOID FILL black skips the tiled bake; drawGround paints a flat void.
local fill = BorderFill.fillBlock(def)
if fill == false then return nil end
local blockId = BorderFill.blockFor(0, fill)
-- A border block made of water animates with the rest of the map, so this -- A border block made of water animates with the rest of the map, so this
-- frame's row joins the key: four bakes per map instead of one. -- frame's row joins the key: four bakes per map instead of one. The VOID
-- FILL mode is in the key so switching FADE/WATER/TREES does not keep a
-- stale bake (#1418).
local waterFrame = self:borderWaterFrame(def, tileset) local waterFrame = self:borderWaterFrame(def, tileset)
local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime)
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker)
.. "|" .. tostring(BorderFill.voidFill or "fade")
.. "|" .. tostring(blockId)
.. "|" .. tostring(waterFrame and waterFrame.row or 0)) .. "|" .. tostring(waterFrame and waterFrame.row or 0))
local cached = self.mapImages[cacheKey] local cached = self.mapImages[cacheKey]
if cached ~= nil then return cached or nil end if cached ~= nil then return cached or nil end
@@ -7949,7 +7959,7 @@ function World:borderImageFor(mapId)
bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1) bgSet = Palettes.withCaveFlicker(bgSet, flicker or 1)
end end
local ok, img = pcall(BorderFill.bake, atlas, tileset, local ok, img = pcall(BorderFill.bake, atlas, tileset,
BorderFill.blockFor(0, def.borderBlock), bgSet, waterFrame) blockId, bgSet, waterFrame)
-- `false` rather than nil: a bake that cannot be made (a headless run with -- `false` rather than nil: a bake that cannot be made (a headless run with
-- no canvas support) must not be retried once per frame forever. -- no canvas support) must not be retried once per frame forever.
self.mapImages[cacheKey] = (ok and img) or false self.mapImages[cacheKey] = (ok and img) or false
@@ -9719,8 +9729,16 @@ function World:drawGround(s)
else else
bw, bh = GameViewport.dimensions() bw, bh = GameViewport.dimensions()
end end
BorderFill.draw(self, self:borderImageFor(self.map.id), if BorderFill.fillBlock(self.map.def) == false then
cam.x, cam.y, bw, bh, s, self.map.id) -- BLACK: World:draw clears to a brown letterbox, so the void itself
-- has to be an actual black sheet or the map sits on that colour.
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, bw, bh)
G.setColor(1, 1, 1, 1)
else
BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, BorderFill.fillKey(self.map.def))
end
end end
for _, nb in ipairs(self.neighbors) do for _, nb in ipairs(self.neighbors) do
G.draw(nb.image, G.draw(nb.image,
@@ -0,0 +1,79 @@
-- Gold VOID FILL (#1418): FADE (each map's own border, dissolving across a
-- boundary), WATER, TREES, or BLACK. Parks you on Cherrygrove zoomed out so
-- the void is most of the window, cycles the four modes, then hands the pad
-- over. Do not add POKEPORT_SPEED: the water anim and the dissolve both run
-- on the real clock.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_void_fill_bug1418_test.lua love .
local U = require("tests.drivers.util")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-void-fill"
local BorderFill = require("src.world.gen2.BorderFill")
local Tilt = require("src.render.Tilt")
local Zoom = require("src.render.Zoom")
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[void] ok " .. label)
else
failures = failures + 1
print("[void] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Cherrygrove's header border is water ($35). FADE shows that water,
-- TREES replaces it, BLACK drops the sheet. attributes.asm:123.
world:setMap("CHERRYGROVE_CITY", 22, 16, "down")
U.wait(10)
Zoom.offset = -3
world:rebuildNeighbors()
world:rebuildPeople({ seamless = true })
Tilt.setLevel(3)
for _ = 1, 40 do
Tilt.update(1 / 60)
U.wait(1)
end
local function show(mode, file, label)
BorderFill.setVoidFill(mode)
if game.options then game.options.voidFill = mode end
if game.save and game.save.options then
game.save.options.voidFill = mode
end
U.wait(8)
ok(label, BorderFill.voidFill == mode, BorderFill.voidFill)
U.shot(game, out .. "/" .. file)
end
show("fade", "01-fade.png", "FADE is the live fill")
show("water", "02-water.png", "WATER forces the water block")
show("trees", "03-trees.png", "TREES forces the tree wall")
show("black", "04-black.png", "BLACK drops the tiled void")
show("fade", "05-fade-again.png", "and FADE restores the map's own border")
Tilt.setLevel(0)
Zoom.offset = 0
world:rebuildNeighbors()
world:rebuildPeople({ seamless = true })
if failures > 0 then
print(("[driver] FAIL gold void fill: %d check(s)"):format(failures))
return
end
print("[driver] PASS gold void fill in " .. out)
U.log("cycled fade/water/trees/black on cherrygrove (#1418).")
U.log("OPTION > VOID FILL, zoom out (-) to see the void.")
while true do
coroutine.yield()
end
end
@@ -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
@@ -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
@@ -43,6 +43,15 @@ local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD") check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION") check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor") check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
check(has(gold, "VOID FILL"), "and VOID FILL")
local voidFill = findRow(gold, "VOID FILL")
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
voidFill.step(1)
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
eq(voidFill.value(), "WATER", "and the row reads WATER")
voidFill.step(-1)
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
-- Every write has to land in the gold block: the flat keys beside it are -- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299). -- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
+125
View File
@@ -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")
+93 -6
View File
@@ -114,23 +114,110 @@ do
"a feed without release stats parses them as absent") "a feed without release stats parses them as absent")
end 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 -- ride along additively, so a feed carrying them stays readable by every
-- build that predates them -- build that predates them
do do
local withStats = {} local withStats = {}
for k, v in pairs(NUZLOCKE) do withStats[k] = v end 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.first_release = "2024-05-31"
withStats.last_release = "2026-07-01" withStats.last_release = "2026-07-01"
local index = ModIndex.parse(feed({ withStats })) local index = ModIndex.parse(feed({ withStats }))
local m = index.mods[1] 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.first_release, "2024-05-31", "first release date is kept")
eq(m.last_release, "2026-07-01", "last release date is kept") eq(m.last_release, "2026-07-01", "last release date is kept")
withStats.downloads = "9999" end
m = ModIndex.parse(feed({ withStats })).mods[1]
eq(m.downloads, 9999, "numeric-string downloads are coerced") -- ------- 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 end
-- schema_version is a contract, not a hint: an unknown one is refused rather -- schema_version is a contract, not a hint: an unknown one is refused rather
+26
View File
@@ -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(nil), "0", "nil formats as zero")
eq(ModUpdate.formatCount("garbage"), "0", "garbage 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 -- statsLine: the shared launcher row line, part by part
eq(ModUpdate.statsLine(1234567, "2024-05-31", "2026-07-01"), 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", "1,234,567 downloads across all releases - Released 2024-05-31 - Updated 2026-07-01",
+57
View File
@@ -84,6 +84,63 @@ end
local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0) local _, _, vw0, vh0 = BorderFill.viewport(0, 0, 160, 144, 0)
check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing") check(vw0 > 0 and vh0 > 0, "scale 0 falls back to 1 rather than collapsing")
-- ---------------------------------------------------------------------------
-- VOID FILL (#1418): FADE / WATER / TREES / BLACK
-- ---------------------------------------------------------------------------
eq(BorderFill.voidFill, "fade", "the live fill defaults to FADE")
eq(BorderFill.voidFillLabel(), "FADE ", "and prints with a trailing space")
local johto = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
borderBlock = 0x35 }
local route = { id = "ROUTE_30", tileset = "TILESET_JOHTO", borderBlock = 0x05 }
local kanto = { id = "PALLET_TOWN", tileset = "TILESET_KANTO",
borderBlock = 0x0f }
local cave = { id = "UNION_CAVE_1F", tileset = "TILESET_CAVE", borderBlock = 0x09 }
local house = { id = "PLAYERS_HOUSE_1F", tileset = "TILESET_PLAYERS_HOUSE",
borderBlock = 0x00 }
eq(BorderFill.fillBlock(johto), 0x35, "FADE keeps Cherrygrove's water")
eq(BorderFill.fillBlock(route), 0x05, "and Route 30's trees")
eq(BorderFill.fillKey(johto), "fade|CHERRYGROVE_CITY",
"FADE dissolves per map")
eq(BorderFill.fillKey(route) ~= BorderFill.fillKey(johto), true,
"so the two maps are different fills")
BorderFill.setVoidFill("water")
eq(BorderFill.fillBlock(johto), 0x35, "WATER uses Cherrygrove's water block")
eq(BorderFill.fillBlock(route), 0x35, "on Route 30 too")
eq(BorderFill.fillBlock(kanto), 0x43, "and Cinnabar's water on Kanto")
eq(BorderFill.fillBlock(cave), 0x09, "but a cave keeps its own border")
eq(BorderFill.fillBlock(house), 0x00, "and so does a house")
eq(BorderFill.fillKey(johto), BorderFill.fillKey(route),
"two Johto maps share one WATER fill")
eq(BorderFill.fillKey(johto) ~= BorderFill.fillKey(kanto), true,
"Kanto water is a different sheet")
BorderFill.setVoidFill("trees")
eq(BorderFill.fillBlock(johto), 0x05, "TREES uses New Bark's tree wall")
eq(BorderFill.fillBlock(kanto), 0x0f, "and Pallet's on Kanto")
eq(BorderFill.fillBlock(cave), 0x09, "caves still keep their own")
BorderFill.setVoidFill("black")
eq(BorderFill.fillBlock(johto), false, "BLACK skips the tiled bake")
eq(BorderFill.fillBlock(house), false, "indoors too")
eq(BorderFill.fillKey(johto), "black", "and is one sheet everywhere")
BorderFill.setVoidFill("nope")
eq(BorderFill.voidFill, "fade", "an unknown mode falls back to FADE")
BorderFill.setVoidFill("fade")
eq(BorderFill.cycle(1), "water", "cycle steps FADE to WATER")
eq(BorderFill.cycle(1), "trees", "then TREES")
eq(BorderFill.cycle(1), "black", "then BLACK")
eq(BorderFill.cycle(1), "fade", "and wraps to FADE")
eq(BorderFill.cycle(-1), "black", "left wraps the other way")
BorderFill.applyOptions({ voidFill = "trees" })
eq(BorderFill.voidFill, "trees", "applyOptions pushes the saved mode")
BorderFill.applyOptions({})
eq(BorderFill.voidFill, "fade", "and a missing key restores FADE")
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- The real cache: the map the bug was reported on -- The real cache: the map the bug was reported on
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
+16
View File
@@ -213,6 +213,22 @@ previous, alpha = BorderFill.crossfade(owner, trees, "ROUTE_30")
check("then it is over", previous, nil) check("then it is over", previous, nil)
check("and the new block owns the void", alpha, 1) check("and the new block owns the void", alpha, 1)
-- Forced WATER on two Johto maps shares a fill key, so the dissolve does
-- not run identical water against itself.
BorderFill.setVoidFill("water")
local johtoWater = { id = "CHERRYGROVE_CITY", tileset = "TILESET_JOHTO",
borderBlock = 0x35 }
local routeTrees = { id = "ROUTE_30", tileset = "TILESET_JOHTO",
borderBlock = 0x05 }
check("WATER fill keys match across Johto",
BorderFill.fillKey(johtoWater), BorderFill.fillKey(routeTrees))
local forced = {}
BorderFill.crossfade(forced, "water", BorderFill.fillKey(johtoWater))
local fromForced = BorderFill.crossfade(forced, "water",
BorderFill.fillKey(routeTrees))
check("so walking Route 30 does not start a fade", fromForced, nil)
BorderFill.setVoidFill("fade")
-- No key at all (an old caller) is the plain single draw. -- No key at all (an old caller) is the plain single draw.
local bare = {} local bare = {}
previous, alpha = BorderFill.crossfade(bare, trees, nil) previous, alpha = BorderFill.crossfade(bare, trees, nil)
+29 -2
View File
@@ -358,7 +358,7 @@ local options = OptionsMenu.new(optionsGame, {
}) })
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display, -- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL. -- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
check("twenty-two rows", #OptionsMenu.ROWS, 22) check("twenty-three rows", #OptionsMenu.ROWS, 23)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
@@ -861,10 +861,15 @@ check("GBC leaves a palette alone",
1)[1], 1) 1)[1], 1)
check("and has no present pass", GbcPalette.presentColors(), nil) check("and has no present pass", GbcPalette.presentColors(), nil)
local gbcfxIndex local zoomIndex, gbcfxIndex
for i, row in ipairs(OptionsMenu.ROWS) do for i, row in ipairs(OptionsMenu.ROWS) do
if row.label == "ZOOM" then zoomIndex = i end
if row.label == "GBC FX" then gbcfxIndex = i end if row.label == "GBC FX" then gbcfxIndex = i end
end end
check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label,
"VOID FILL")
check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label,
"TILT")
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label, check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
"VIDEO MODE") "VIDEO MODE")
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label, check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
@@ -897,6 +902,28 @@ scrollOptions:cycle(videoRow, -1)
check("left toggles back to windowed", check("left toggles back to windowed",
scrollOptions.options.videoMode, "windowed") scrollOptions.options.videoMode, "windowed")
local voidRow = select(2, rowNamed("VOID FILL"))
check("VOID FILL is a row", voidRow ~= nil, true)
check("and it defaults to FADE", Save.DEFAULT_OPTIONS.voidFill, "fade")
scrollOptions.options.voidFill = "fade"
local BorderFill = require("src.world.gen2.BorderFill")
BorderFill.setVoidFill("fade")
scrollOptions:cycle(voidRow, 1)
check("right steps to WATER", scrollOptions.options.voidFill, "water")
check("and the live fill tracks it", BorderFill.voidFill, "water")
scrollOptions:cycle(voidRow, 1)
check("then TREES", scrollOptions.options.voidFill, "trees")
scrollOptions:cycle(voidRow, 1)
check("then BLACK", scrollOptions.options.voidFill, "black")
scrollOptions:cycle(voidRow, 1)
check("and wraps back to FADE", scrollOptions.options.voidFill, "fade")
check("FADE blanks the longer labels", voidRow.text(scrollOptions.options),
"FADE ")
scrollOptions.options.voidFill = "water"
check("WATER fits the value column", voidRow.text(scrollOptions.options),
"WATER")
BorderFill.setVoidFill("fade")
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED") check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
scrollOptions.options.videoMode = "borderless" scrollOptions.options.videoMode = "borderless"
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options), check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),
+59
View File
@@ -179,6 +179,65 @@ check("the cursor is the column-1 arrow on the label row",
check("the cursor starts on the first row", cursorAt and cursorAt.y, 2) check("the cursor starts on the first row", cursorAt and cursorAt.y, 2)
check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true) check("FRAME still prints its literal TYPE", findPrint(":TYPE") ~= nil, true)
-- ------------------------------------------------------- mart buy list
--
-- BuyMenu (engine/items/mart.asm) is FadeToMenu + BlankScreen, then
-- PlaceMoneyTopRight and ScrollingMenu_UpdateDisplay's ClearWholeMenuBox.
-- The list is not MenuBox'd; only the money box and UpdateItemDescription's
-- textbox are. The top menu still overlays the mart, so it must not blank.
local MartMenu = require("src.ui.gen2.MartMenu")
local Save = require("src.core.gen2.Save")
local martBoxes, martClears = {}, 0
local martClear, martBox = Chrome.clear, Chrome.box
local martPrint, martCursor = Chrome.print, Chrome.cursor
Chrome.clear = function() martClears = martClears + 1 end
Chrome.box = function(x, y, w, h)
martBoxes[#martBoxes + 1] = { x = x, y = y, w = w, h = h }
end
Chrome.print = function() end
Chrome.cursor = function() end
local function martHasBox(x, y, w, h)
for _, b in ipairs(martBoxes) do
if b.x == x and b.y == y and b.w == w and b.h == h then return true end
end
return false
end
local martSave = Save.newGame()
local martItems = {
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", price = 300,
description = "Restores HP\nby 20." },
}
local mart = MartMenu.new({ save = martSave, data = { items = martItems } }, {
save = martSave,
items = martItems,
marts = { lists = { { "POTION" } } },
})
mart:draw()
check("the top menu does not blank the tilemap", martClears, 0)
check("and frames BUY/SELL/QUIT", martHasBox(0, 0, 12, 9), true)
check("and the welcome speech box", martHasBox(0, 12, 20, 6), true)
check("the top menu is not opaque", mart.isOpaque, false)
martClears, martBoxes = 0, {}
mart:enterBuy()
mart:draw()
check("BUY blanks the screen (BlankScreen)", martClears > 0, true)
check("the money box is framed", martHasBox(11, 0, 9, 3), true)
check("the description box is framed", martHasBox(0, 12, 20, 6), true)
check("the item list is not (ClearWholeMenuBox, not MenuBox)",
martHasBox(1, 3, 19, 9), false)
check("BlankScreen shadows isOpaque so the letterbox is not the mart",
mart.isOpaque, true)
mart:leaveBuy()
check("leaving BUY unshadows isOpaque", mart.isOpaque, false)
Chrome.clear, Chrome.box = martClear, martBox
Chrome.print, Chrome.cursor = martPrint, martCursor
-- ------------------------------------------------------- one blit scale -- ------------------------------------------------------- one blit scale
-- --
-- Every Gold screen paints its 160x144 panel through the same helper. A -- Every Gold screen paints its 160x144 panel through the same helper. A
+87
View File
@@ -0,0 +1,87 @@
-- Parity test (#1519): wStepCounter is zeroed by ClearVariablesOnEnterMap
-- (engine/overworld/clear_variables.asm:7), reached from EnterMap on a warp
-- and from the post-battle tail jump (home/overworld.asm:353), but never from
-- CheckMapConnections (home/overworld.asm:675), so a seam crossing keeps phase.
-- Self-contained; run via `luajit tests/parity_field_poison_phase.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity field poison phase")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 12) }
Game.stack:push(OW, "PALLET_TOWN", 5, 6, "down")
local ow = Game.stack:top()
Game.overworld = ow
local mon = Game.save.party[1]
mon.status = "PSN"
-- Walk until the poison bites, and report which step it was.
local function stepsToTick(limit)
for i = 1, limit or 12 do
local before = mon.hp
ow:applyFieldPoison()
if mon.hp < before then return i end
end
return nil
end
local function reheal()
mon.hp = mon.stats.hp
mon.status = "PSN"
end
-- ---- the free-running four-step cycle ------------------------------------
Game.save.poisonSteps = 0
eq(stepsToTick(), 4, "a cleared counter bites on the fourth step")
reheal()
eq(stepsToTick(), 4, "and every fourth step after that")
reheal()
Game.save.poisonSteps = 2
eq(stepsToTick(), 2, "a counter two steps into the cycle bites two steps later")
-- ---- a real map load re-phases it ----------------------------------------
reheal()
Game.save.poisonSteps = 2
ow:setMap("PALLET_TOWN", 5, 6, "down", { via = "warp" })
eq(Game.save.poisonSteps, 0, "setMap clears the counter")
eq(stepsToTick(), 4, "so the first tick after a warp is four steps out")
-- ---- a connection crossing does not --------------------------------------
reheal()
Game.save.poisonSteps = 2
ow:setMap("ROUTE_1", 9, 33, "up", { seamless = true, keepMusic = true })
eq(Game.save.poisonSteps, 2, "a seamless crossing leaves the counter alone")
eq(stepsToTick(), 2, "so the seam does not delay the tick")
-- ---- a battle return re-phases it ----------------------------------------
reheal()
Game.save.poisonSteps = 2
ow:afterBattle("win", { kind = "wild" })
eq(Game.save.poisonSteps, 0, "afterBattle clears the counter")
eq(stepsToTick(), 4, "so poison always bites four steps after a battle")
reheal()
Game.save.poisonSteps = 3
ow:afterBattle("win", { kind = "trainer", oppClass = "OPP_YOUNGSTER" })
eq(stepsToTick(), 4, "trainer battles re-phase it the same way")
S.finish()