Merge branch 'dev' into feat/switch-nx

Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve
Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path
gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods
(#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L
display chords still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-02 21:36:56 -03:00
154 changed files with 11958 additions and 742 deletions
+15
View File
@@ -265,6 +265,21 @@ function LauncherMods.setEnabled(id, enabled)
return true
end
-- setAllEnabled(ids, enabled): the launcher's Enable all / Disable all buttons
-- (#647). Writes exactly the options.mods shape setEnabled does, but loads and
-- saves once for the whole list: saveOptions rewrites the whole options file per
-- call, so looping setEnabled over a big mods folder is one disk write per mod
-- and leaves a half-applied state behind if one of them fails.
function LauncherMods.setAllEnabled(ids, enabled)
local options = SaveData.loadOptions()
options.mods = options.mods or {}
for _, id in ipairs(ids or {}) do
options.mods[id] = enabled and true or false
end
SaveData.saveOptions(options)
return true
end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. Save-dir-relative paths (inbox /
+76 -8
View File
@@ -11,6 +11,7 @@ local Version = require("src.core.Version")
local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings")
local ModProfile = require("src.mods.ModProfile")
local ManagerState = {}
ManagerState.__index = ManagerState
@@ -180,6 +181,16 @@ end
function ManagerState:enter()
self:refresh()
-- #593: the setup that existed before profiles shipped becomes PROFILE 1
-- the first time the manager opens, so switching away from it is always a
-- round trip. Seeded here rather than at boot because profiles are only
-- ever read on this screen.
local seeded = ModProfile.ensureFirst(self:optionsTable(),
self.status.available, self:modOptionsTable())
if seeded then
self:persistOptions()
self:refresh()
end
if Runtime.safeMode then
self.banner = "SAFE MODE - ALL MODS OFF"
end
@@ -213,6 +224,12 @@ function ManagerState:optionsTable()
return (save and save.options) or {}
end
-- Per-mod option values as persisted (options.modOptions; setOption below is
-- the only writer). A profile captures this alongside the enable set.
function ManagerState:modOptionsTable()
return self:optionsTable().modOptions or {}
end
function ManagerState:manifestMap()
return self.byId or {}
end
@@ -283,6 +300,10 @@ function ManagerState:profileRows()
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
-- #593 sharing: EXPORT writes the focused profile to profiles/*.g1rmodlist
-- in the save dir, IMPORT reads back every file dropped in that folder.
rows[#rows + 1] = { exportProfile = true, label = Strings("EXPORT..") }
rows[#rows + 1] = { importProfile = true, label = Strings("IMPORT..") }
rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows
@@ -478,6 +499,10 @@ function ManagerState:activate()
self:applyProfile(row.profile)
elseif row.saveAs then
self:saveCurrentAs()
elseif row.exportProfile then
self:exportActiveProfile()
elseif row.importProfile then
self:importProfiles()
elseif row.adhoc then
self:optionsTable().activeProfile = nil
self:notify("AD-HOC SET ACTIVE")
@@ -712,9 +737,26 @@ function ManagerState:applyProfile(p)
end
end
self:commitToggle(combined)
-- #593: a profile is the whole setup, not just the enable set. Options go
-- through setOption so loader.modOptions and mod.options_changed stay in
-- step; save slots move only to slots that version already registered
-- (SaveData.setActiveSlot would otherwise conjure one).
for modId, bucket in pairs(p.options or {}) do
if self.byId[modId] then
for key, value in pairs(bucket) do self:setOption(modId, key, value) end
end
end
for _, move in ipairs(ModProfile.slotMoves(p)) do
require("src.core.SaveData").setActiveSlot(move[1], move[2])
end
self:optionsTable().activeProfile = p.name
self:persistOptions()
self:notify("PROFILE STAGED")
local missing = ModProfile.missingIds(p, self.byId)
if #missing > 0 then
self:notify(#missing .. " MODS MISSING")
else
self:notify("PROFILE STAGED")
end
end
function ManagerState:saveCurrentAs()
@@ -725,16 +767,15 @@ function ManagerState:saveCurrentAs()
onDone = function(name)
local opts = self:optionsTable()
opts.modProfiles = opts.modProfiles or {}
local enabled = {}
for _, m in ipairs(self.status.available or {}) do
enabled[m.id] = m.enabled and true or false
end
local snap = ModProfile.capture(self.status.available,
self:modOptionsTable())
local existing = self:findProfile(name)
if existing then
existing.enabled = enabled
existing.enabled, existing.options, existing.slots =
snap.enabled, snap.options, snap.slots
else
opts.modProfiles[#opts.modProfiles + 1] =
{ name = name, enabled = enabled }
snap.name = name
opts.modProfiles[#opts.modProfiles + 1] = snap
end
opts.activeProfile = name
self:persistOptions()
@@ -743,6 +784,33 @@ function ManagerState:saveCurrentAs()
}))
end
-- #593 sharing. Export drops the active profile (or the focused one) in the
-- save directory's profiles/ folder as <NAME>.g1rmodlist; import reads every
-- file already there. Deliberately folder-based rather than a native file
-- picker: the same shape SaveFileIO uses for exports/, and it works on the
-- mobile builds where no picker exists.
function ManagerState:exportActiveProfile()
local row = self:focusedRow()
local p = (row and row.profile)
or self:findProfile(self:optionsTable().activeProfile)
if not p then return self:notify("NO PROFILE") end
local ok = ModProfile.export(p)
self:notify(ok and ("SAVED " .. p.name) or "EXPORT FAILED")
end
function ManagerState:importProfiles()
local opts = self:optionsTable()
opts.modProfiles = opts.modProfiles or {}
local n = 0
for _, path in ipairs(ModProfile.files()) do
if ModProfile.import(path, opts.modProfiles) then n = n + 1 end
end
if n == 0 then return self:notify("NO MODLISTS") end
self:persistOptions()
self:snapCursor()
self:notify(n .. " IMPORTED")
end
function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, {
+15 -2
View File
@@ -178,9 +178,21 @@ function Manifest.validate(raw, path)
"experimental must be a boolean")
local experimental = raw.experimental == true
-- #501: a translation declares itself here. Neither the ROM's dialogue
-- nor the engine's own strings are hashed into the link surface
-- (src/link/Fingerprint.lua header), so an English install and a Spanish
-- one run the same lockstep simulation, exactly as two regional carts on
-- a real cable did. The flag is only the author's claim;
-- Handshake.onlineBlockers checks it against the ops the mod actually
-- appended before online play trusts it.
assert(raw.language == nil or type(raw.language) == "boolean",
"language must be a boolean")
local language = raw.language == true
-- overhauls and total conversions are assumed to move the link
-- fingerprint unless the manifest says otherwise; content packs are not
local affectsLink = profile ~= "content"
-- fingerprint unless the manifest says otherwise; content packs and
-- declared translations are not
local affectsLink = profile ~= "content" and not language
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
local function optionalFile(value, field)
@@ -211,6 +223,7 @@ function Manifest.validate(raw, path)
github = github,
experimental = experimental,
profile = profile,
language = language,
affects_link = affectsLink,
permissions = permissions,
permissionSet = permissionSet,
+8 -25
View File
@@ -520,36 +520,19 @@ function ModIndex.writeCache(feed, index)
return ok
end
-- ------- host I/O (curl, via ModUpdate's shell plumbing)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
-- Plain GET returning the body. No GitHub Accept header: the feed and the
-- description markdown are static files on Pages, and the public feed is
-- explicitly unauthenticated.
-- explicitly unauthenticated. The transport itself lives in
-- src/core/HostShell.lua because Android has no curl and has to fetch through
-- love.system.httpDownload instead (#597).
function ModIndex.httpGet(url)
local ModUpdate = require("src.mods.ModUpdate")
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
if not HostShell.canFetch() then
return nil, "no network transport on this platform"
end
return HostShell.httpGet(url, "gen1recomp-mod-index")
end
-- fetch(source, opts) -> index, err, meta
+206
View File
@@ -0,0 +1,206 @@
-- Mod profiles (#593): a named snapshot of one whole setup -- which mods are
-- enabled, each mod's own options, and which save slot every game version
-- plays -- plus the shareable .g1rmodlist file the manager writes and reads.
-- Pure data and filesystem: src/mods/ManagerState.lua owns the UI and the
-- staged toggles, and is the only caller.
--
-- Records live in options.modProfiles (src/core/SaveData.lua defaultOptions),
-- so a profile survives New Game exactly like the mod enable-state does:
-- { name = "PROFILE 1",
-- enabled = { [modId] = true|false }, -- missing means enabled
-- options = { [modId] = { key = value } },-- mirrors options.modOptions
-- slots = { red = "slot1", blue = "slot2", yellow = nil } }
-- Exports are SaveSerializer-encoded (data-only parse, an imported file can
-- never execute code) under profiles/<NAME>.g1rmodlist in the save dir, the
-- same shape SaveFileIO uses for exports/ (src/import/SaveFileIO.lua).
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local ModProfile = {}
ModProfile.EXT = ".g1rmodlist"
ModProfile.DIR = "profiles"
ModProfile.FORMAT = "g1rmodlist"
ModProfile.FORMAT_VERSION = 1
-- deterministic order; GameVersion.VERSIONS is a map, and a profile file has
-- to encode the same way twice for a diff to mean anything
local VERSION_ORDER = { "red", "blue", "yellow" }
local function fsOr(fs)
return fs or (love and love.filesystem) or nil
end
-- Capture the live setup. `available` is ManagerState.status.available (the
-- loader's status manifests, m.enabled = the desired set including staged
-- flips); `modOptions` is options.modOptions.
function ModProfile.capture(available, modOptions)
local enabled, options = {}, {}
for _, m in ipairs(available or {}) do
enabled[m.id] = m.enabled and true or false
local bucket = modOptions and modOptions[m.id]
if type(bucket) == "table" then
local copy = {}
for k, v in pairs(bucket) do
local t = type(v)
if t == "string" or t == "number" or t == "boolean" then copy[k] = v end
end
options[m.id] = copy
end
end
local slots = {}
for _, id in ipairs(VERSION_ORDER) do
local ok, slot = pcall(SaveData.activeSlot, id)
if ok and slot then slots[id] = slot end
end
return { enabled = enabled, options = options, slots = slots }
end
-- The slot moves a profile may actually make: a slot id has to be registered
-- for that version already, because SaveData.setActiveSlot registers an
-- unknown id and would conjure a phantom slot the player never made.
function ModProfile.slotMoves(p)
local moves = {}
for _, version in ipairs(VERSION_ORDER) do
local want = p.slots and p.slots[version]
if want then
local ok, list = pcall(SaveData.listSlots, version)
if ok then
for _, row in ipairs(list or {}) do
if row.id == want then moves[#moves + 1] = { version, want } end
end
end
end
end
return moves
end
-- ids the profile wants enabled that are not installed here. Import cannot
-- fetch them (community-index install is deferred), so the manager reports
-- them rather than silently playing a different setup than the sharer's.
function ModProfile.missingIds(p, byId)
local out = {}
for id, on in pairs(p.enabled or {}) do
if on and not (byId or {})[id] then out[#out + 1] = id end
end
table.sort(out)
return out
end
function ModProfile.encode(p)
return SaveSerializer.encode({
format = ModProfile.FORMAT,
formatVersion = ModProfile.FORMAT_VERSION,
profile = { name = p.name, enabled = p.enabled,
options = p.options, slots = p.slots },
})
end
-- Shape-validate rather than trust: a shared file is untrusted input, and the
-- manager indexes p.enabled/p.options straight into its row models.
function ModProfile.decode(body)
if type(body) ~= "string" then return nil, "EMPTY FILE" end
local ok, data = pcall(SaveSerializer.decode, body)
if not ok or type(data) ~= "table" then return nil, "BAD FILE" end
if data.format ~= ModProfile.FORMAT then return nil, "NOT A MODLIST" end
local raw = data.profile
if type(raw) ~= "table" or type(raw.name) ~= "string" or raw.name == "" then
return nil, "BAD FILE"
end
local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {} }
for id, on in pairs(type(raw.enabled) == "table" and raw.enabled or {}) do
if type(id) == "string" then p.enabled[id] = on and true or false end
end
for id, bucket in pairs(type(raw.options) == "table" and raw.options or {}) do
if type(id) == "string" and type(bucket) == "table" then
local copy = {}
for k, v in pairs(bucket) do
local t = type(v)
if type(k) == "string" and (t == "string" or t == "number" or t == "boolean") then
copy[k] = v
end
end
p.options[id] = copy
end
end
for version, slot in pairs(type(raw.slots) == "table" and raw.slots or {}) do
if GameVersion.VERSIONS[version] and type(slot) == "string" then
p.slots[version] = slot
end
end
return p
end
-- profiles/<NAME>.g1rmodlist; letters, digits and dashes only so a shared
-- name can never escape the folder
local function fileFor(name)
return ModProfile.DIR .. "/" ..
tostring(name):upper():gsub("[^%w%-]", "_") .. ModProfile.EXT
end
ModProfile.fileFor = fileFor
function ModProfile.export(p, fs)
fs = fsOr(fs)
if not (fs and fs.write) then return false, "NO FILESYSTEM" end
if fs.createDirectory then fs.createDirectory(ModProfile.DIR) end
local rel = fileFor(p.name)
local ok, err = fs.write(rel, ModProfile.encode(p))
if not ok then return false, tostring(err) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
end
-- every .g1rmodlist sitting in profiles/, sorted, as relative paths
function ModProfile.files(fs)
fs = fsOr(fs)
local out = {}
if not (fs and fs.getDirectoryItems) then return out end
if fs.getInfo and not fs.getInfo(ModProfile.DIR) then return out end
for _, name in ipairs(fs.getDirectoryItems(ModProfile.DIR) or {}) do
if name:sub(-#ModProfile.EXT) == ModProfile.EXT then
out[#out + 1] = ModProfile.DIR .. "/" .. name
end
end
table.sort(out)
return out
end
-- Read one export and land it in `profiles` (options.modProfiles) under a
-- non-colliding name, so importing twice never overwrites what the player
-- already tuned. Returns the stored record, or nil + a short reason.
function ModProfile.import(path, profiles, fs)
fs = fsOr(fs)
if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
local p, err = ModProfile.decode(fs.read(path))
if not p then return nil, err end
local taken = {}
for _, existing in ipairs(profiles) do taken[existing.name] = true end
local name, n = p.name, 1
while taken[name] do
n = n + 1
name = (p.name:sub(1, 8) .. n)
end
p.name = name
profiles[#profiles + 1] = p
return p
end
-- First run after this update: the setup the player already has becomes
-- PROFILE 1, then they can add more (#593). Seeded once and remembered by
-- options.modProfilesSeeded, so deleting every profile does not resurrect it.
function ModProfile.ensureFirst(opts, available, modOptions)
if opts.modProfilesSeeded then return nil end
opts.modProfilesSeeded = true
opts.modProfiles = opts.modProfiles or {}
if #opts.modProfiles > 0 then return nil end
local p = ModProfile.capture(available, modOptions)
p.name = "PROFILE 1"
opts.modProfiles[1] = p
opts.activeProfile = p.name
return p
end
return ModProfile
+19 -39
View File
@@ -254,39 +254,21 @@ function ModUpdate.statusFor(installedVersion, releases)
return "current", best
end
-- ------- host I/O (curl)
-- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local function curlCapture(url)
-- GitHub's API wants a User-Agent and answers the versioned Accept.
local function apiFetch(url)
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "curl read failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from GitHub" end
return out
return HostShell.httpGet(url, "gen1recomp-mod-updater",
"application/vnd.github+json")
end
-- Still just the curl probe it always was. "Can we fetch at all" is
-- HostShell.canFetch, which also knows about the Android bridge (#597); every
-- gate below asks that instead.
function ModUpdate.haveCurl()
local HostShell = require("src.core.HostShell")
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
if not pipeOk or not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
return HostShell.haveCurl()
end
-- Fetch release list. opts.force bypasses the 6h cache.
@@ -302,15 +284,16 @@ function ModUpdate.fetchReleases(repo, modId, opts)
return cached.releases, nil, { fromCache = true }
end
end
if not ModUpdate.haveCurl() then
local HostShell = require("src.core.HostShell")
if not HostShell.canFetch() then
-- Stale cache is better than nothing when offline
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil, "curl is not available on this platform"
return nil, "no network transport on this platform"
end
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
local body, curlErr = apiFetch(ModUpdate.apiReleasesUrl(repo))
if not body then
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
@@ -331,10 +314,10 @@ function ModUpdate.downloadZip(url, destName)
if not (love and love.filesystem) then
return nil, "download needs LOVE"
end
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
if not HostShell.canFetch() then
return nil, "no network transport on this platform"
end
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
name = tostring(name):gsub("[/\\]", "_")
local saveOk, saveDir = pcall(function()
@@ -344,12 +327,9 @@ function ModUpdate.downloadZip(url, destName)
return nil, "no save directory"
end
local abs = saveDir .. "/" .. name
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
.. shq(abs) .. " " .. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
-- Either transport can fail quietly; the getInfo size check below is what
-- actually decides whether we got a file (#597).
HostShell.httpDownload(url, abs, "gen1recomp-mod-updater")
local infoOk, info = pcall(love.filesystem.getInfo, name)
if not infoOk or not info or (info.size or 0) == 0 then
pcall(love.filesystem.remove, name)