Merge branch 'my-uncles-neighbor' into dev

# Conflicts:
#	src/import/RomImporter.lua
This commit is contained in:
bryanthaboi
2026-08-20 08:28:19 -04:00
68 changed files with 18617 additions and 466 deletions
@@ -41,12 +41,21 @@ end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
or v == "silver"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
check(ok == true, "syncAndroidShortcuts returns true on Android")
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
and capturedShortcuts[4] == "yellow",
"the rest follow GameVersion.ORDER until the cap")
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("silver")
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
@@ -78,4 +87,4 @@ end
love.system.getLaunchGame = savedGetLaunchGame
love.system.updateShortcuts = nil
print("8/8 checks passed (android_shortcuts_payload_test)")
T.finish("android_shortcuts_payload_test")
+1
View File
@@ -23,6 +23,7 @@ T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
+45 -34
View File
@@ -39,45 +39,54 @@ end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
check(has(gold, "VOID FILL"), "and VOID FILL")
for _, version in ipairs({ "gold", "silver" }) do
local model = LauncherSettings.open(hooks, version)
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
check(has(model, "VIBRATION"), version .. " and VIBRATION")
check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor")
check(has(model, "VOID FILL"), version .. " 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")
local voidFill = findRow(model, "VOID FILL")
eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
voidFill.step(1)
eq(model.opts.gold.voidFill, "water",
version .. " right stores water in the gold block")
eq(voidFill.value(), "WATER", version .. " and the row reads WATER")
voidFill.step(-1)
eq(model.opts.gold.voidFill, "fade", version .. " left restores fade")
-- 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).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
-- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls
local flatBuzz = gold.opts.haptics
-- Every write has to land in the gen2 block: the flat keys beside it are
-- Red's, and no Gen 2 boot reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gen 2
-- rows leave them exactly as they found them.
local flatPad = model.opts.touchControls
local flatBuzz = model.opts.haptics
local pad = findRow(gold, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
local pad = findRow(model, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, version .. " stepping TOUCH PAD flips it")
check(type(model.opts.gold) == "table", version .. " into the gold block")
eq(model.opts.gold.touchControls.enabled, false,
version .. " which now carries enabled")
eq(model.opts.touchControls, flatPad,
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
local buzz = findRow(gold, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
"VIBRATION stores a level the shared module knows")
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
local buzz = findRow(model, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, version .. " stepping VIBRATION moves the level")
eq(model.opts.gold.haptics,
TouchControls.normalizeHaptics(model.opts.gold.haptics),
version .. " VIBRATION stores a level the shared module knows")
eq(model.opts.haptics, flatBuzz, version .. " also without touching Red's")
findRow(gold, "TOUCH CONTROLS").action()
eq(edited, 1, "the editor row reaches the host hook")
edited = 0
findRow(model, "TOUCH CONTROLS").action()
eq(edited, 1, version .. " the editor row reaches the host hook")
end
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table.
@@ -94,6 +103,8 @@ eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
"nor on Silver")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
+41 -38
View File
@@ -155,28 +155,34 @@ modImp.mods = mods
modImp._ensureMods = function() return mods end
LauncherView.draw(modImp)
LauncherView.draw(modImp)
check((modImp._modScrollMax or 0) > 0,
"60 mods overflow the list viewport inside the panel")
local list = modImp._modListRect
check(list.x + list.w
<= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(),
"the rows stop short of the region's scrollbar gutter")
pointer(list.x + 10, list.y + 10)
local reg = modImp._tabRegionRect
check((modImp._tabScrollMax.mods or 0) > reg.h,
"60 installed mods are ONE continuous list: the region's travel spans "
.. "the whole list, not one page of it")
eq(modImp._pages.mods, nil, "and no page state is ever minted for it")
pointer(reg.x + 10, reg.y + 40)
modImp._wheelY = -1
LauncherView.draw(modImp)
check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list")
eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it")
eq(modImp._pageScroll, 0, "and not the page behind that")
check((modImp._tabScroll.mods or 0) > 0,
"a notch over the region scrolls the list like any other tab")
eq(modImp._pageScroll, 0, "without reaching the page behind it")
modImp._tabScroll.mods = modImp._tabScrollMax.mods
LauncherView.draw(modImp)
pointer(reg.x + 10, reg.y + reg.h * 0.5)
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp._pages.mods, nil,
"bottoming the list out never auto-advances any pager")
eq(modImp._tabScroll.mods, modImp._tabScrollMax.mods,
"the list just rests at its end")
modImp._modActions = "mod1"
local shielded = modImp.modScroll
local shieldedPage = modImp._pageScroll
pointer(list.x + 10, list.y + 10)
local shieldedAt = modImp._tabScroll.mods
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch")
eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim")
eq(modImp._pageScroll, shieldedPage, "and the page behind that")
eq(modImp._tabScroll.mods, shieldedAt,
"a shielded mod list ignores the notch under the scrim")
modImp._modActions = nil
modImp._wheelY = 0
LauncherView.draw(modImp)
@@ -225,32 +231,27 @@ dragMods.mods = mods
dragMods._ensureMods = function() return mods end
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
local dlist = dragMods._modListRect
local dListMax = dragMods._modScrollMax
local dreg = dragMods._tabRegionRect
local dRegionMax = dragMods._tabScrollMax.mods
check(dListMax > 0 and dRegionMax > 0,
"the mods tab has both an inner list and a region to scroll")
LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30)
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60)
eq(dragMods.modScroll, math.min(60, dListMax),
"the first pixels of the drag move the list")
eq(dragMods._tabScroll.mods or 0, 0, "and nothing else")
LauncherView.touchmoved(dragMods, 7, dlist.x + 20,
dlist.y + 30 - 60 - dListMax - dRegionMax * 2)
eq(dragMods.modScroll, dListMax, "carrying on saturates the list")
eq(dragMods._tabScroll.mods, dRegionMax,
"then the same gesture walks the region to its bottom")
check(dRegionMax > 0, "the mods region scrolls its overscan like any tab")
LauncherView.touchpressed(dragMods, 7, dreg.x + 20, dreg.y + 30)
LauncherView.touchmoved(dragMods, 7, dreg.x + 20, dreg.y + 30 - 60)
eq(dragMods._tabScroll.mods, math.min(60, dRegionMax),
"a drag moves the region by the finger's travel")
LauncherView.touchmoved(dragMods, 7, dreg.x + 20,
dreg.y + 30 - 60 - dRegionMax * 2)
eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region")
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900)
LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
for i = 2, 12 do
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
end
dragMods._ensureSkins = function() return dragMods._skins end
dragMods.modScroll = 0
local heldModScroll = dragMods.modScroll
local overList = dlist.y + 30
local heldModsPage = dragMods._pages.mods or 1
local heldModsAt = dragMods._tabScroll.mods
local overList = dreg.y + 60
dragMods:_switchTab("skins")
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
@@ -260,8 +261,10 @@ LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList)
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
check((dragMods._tabScroll.skins or 0) > 0,
"a drag on the skins tab scrolls the skins tab")
eq(dragMods.modScroll, heldModScroll,
"and leaves the mod list where the player parked it")
eq(dragMods._pages.mods or 1, heldModsPage,
"and leaves the mod list on the page the player parked it")
eq(dragMods._tabScroll.mods, heldModsAt,
"with its region offset held for the return trip")
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
love.graphics.polygon = love.graphics.polygon or function() end
@@ -308,8 +311,8 @@ local view = read("src/import/LauncherView.lua")
check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
"the panel dispatch opens a scroll region")
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
check(view:find("modListWantsWheel", 1, true) ~= nil,
"the nested mod list is asked before the region takes a notch")
check(view:find("modListWantsWheel", 1, true) == nil,
"no nested list steals the wheel from the region any more")
check(view:find("start.region", 1, true) ~= nil,
"a touch drag that began in the region scrolls the region")
check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
+12 -8
View File
@@ -32,11 +32,13 @@ do
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold",
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
"and each of them names itself")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("silver"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
@@ -56,7 +58,7 @@ end
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold",
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
@@ -67,14 +69,14 @@ end
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold", "games can name Gen 2 alone")
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold", "or both generations")
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold",
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
"an old gen2compat beside a new games list still adds its game")
end
@@ -224,10 +226,12 @@ do
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, red = "nope" } },
gold = { a = true }, silver = { a = true }, crystal = { a = true },
red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow", "gold" }) do
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
@@ -137,6 +137,8 @@ check(createdDirs["imports/saves/yellow"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
check(createdDirs["imports/saves/gold"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/gold/")
check(createdDirs["imports/saves/silver"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/silver/")
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
ri = freshImporter()
+4 -2
View File
@@ -81,7 +81,8 @@ local function importer(ready)
}, RomImporter)
end
local allReady = importer({ red = true, blue = true, yellow = true, gold = true })
local allReady = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
allReady:_queueBaseRomScan()
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
eq(listings, 0, "ready launcher does not enumerate baseroms")
@@ -123,7 +124,8 @@ eq(picks, 0, "missing detected ROM does not open the picker unexpectedly")
missing:choose("red")
eq(picks, 1, "the next import attempt falls back to the native picker")
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true })
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
rescanned:reimport("red")
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")