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
+19
View File
@@ -495,6 +495,25 @@ function RomExtractor:extractSprites()
image = "assets/generated/sprites/red_bike.png",
frames = bikeFrames, walker = bikeFrames >= 6,
}
-- Yellow-only: the surfing-Pikachu overworld sheet, loaded outside
-- SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) -- same
-- extra-extract shape as RedBikeSprite. (RFC 0001)
local surfPika = self.manifest.sprites.surfPikachu
if surfPika and self.symbols[surfPika.label] then
local spSymbol = self:symbol(surfPika.label)
self:write2bpp(
self.rom:bytes(spSymbol.bank, spSymbol.address,
surfPika.imageWidth * surfPika.imageHeight / 4),
surfPika.imageWidth, surfPika.imageHeight,
"sprites/" .. surfPika.imageBase .. ".png", true)
local spFrames = surfPika.imageHeight / 16
out.SPRITE_SURFING_PIKACHU = {
id = "SPRITE_SURFING_PIKACHU",
source = ("ROM:%s"):format(surfPika.label),
image = "assets/generated/sprites/" .. surfPika.imageBase .. ".png",
frames = spFrames, walker = spFrames >= 6,
}
end
self:write("sprites", out)
self:tick("Overworld sprites", #order + 1, #order + 1)
return out
+94 -3
View File
@@ -1727,6 +1727,9 @@ function RomImporter:gamepadaxis(_, axis, value)
end
end
-- Same gate as src/core/Input.lua: a pad SDL can map already reached
-- gamepadpressed this frame, so re-entering it from the raw event would
-- fire the virtual cursor's click twice off one A press (#620).
function RomImporter:joystickpressed(joystick, button)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
local padButton = GamepadMap.mapRawToGamepadButton(button)
@@ -1740,6 +1743,7 @@ function RomImporter:joystickreleased(joystick, button)
end
function RomImporter:joystickaxis(joystick, axis, value)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
if axis == 1 then
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
@@ -1747,7 +1751,8 @@ function RomImporter:joystickaxis(joystick, axis, value)
end
end
function RomImporter:joystickhat(_, hat, direction)
function RomImporter:joystickhat(joystick, hat, direction)
if GamepadMap.ignoreRawForJoystick(joystick) then return end
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
self._padDir[dir] = nil
end
@@ -2013,6 +2018,11 @@ function RomImporter:_resetFrameRects()
self.modRects = nil
self.modDeleteRects = nil
self.modImportRect = nil
-- Enable all / Disable all share that header and the same rule (#647): they
-- are only drawn when the row is wide enough, so a stale rect would otherwise
-- stay clickable over whatever the next tab (or the next window size) draws.
self.modEnableAllRect = nil
self.modDisableAllRect = nil
-- Same rule as the toggles above, and it started to bite once FIND MODS gave
-- the mods tab a neighbour: these two were rebuilt by the mods panel but
-- never cleared, so switching tabs left the last mod row's Update / Versions
@@ -2920,6 +2930,8 @@ function RomImporter:mousepressed(x, y, button)
self:_findInstall(c.indexEntry)
elseif c.kind == "update" then
self:_confirmModUpdate(c.id, c.release)
elseif c.kind == "enableAll" then
self:_setAllMods(true, true)
else
self:_toggleMod(c.id, true)
end
@@ -3093,6 +3105,14 @@ function RomImporter:mousepressed(x, y, button)
if inside(self.modImportRect, x, y) then
self:chooseMod(); return
end
-- Enable all / Disable all sit in that same fixed header, so they dispatch on
-- press like the import button rather than arming a drag (#647).
if inside(self.modEnableAllRect, x, y) then
self:_setAllMods(true); return
end
if inside(self.modDisableAllRect, x, y) then
self:_setAllMods(false); return
end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
if armedDelete(armed, "mod", r.id, nil) then
@@ -4325,6 +4345,49 @@ function RomImporter:_toggleMod(id, confirmed)
self:_refreshMods()
end
-- Enable all / Disable all (#647). One options write for the whole list
-- (LauncherMods.setAllEnabled) and one relist afterwards, so the count, the
-- switches and every status chip resolve together instead of per mod.
-- Enabling routes through the same experimental confirm _toggleMod arms: that
-- opt-in is the only warning an experimental mod ever gets, and a bulk button
-- must not be the way around it. Disabling needs no confirm -- it is the
-- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed)
local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do
if m.enabled ~= want then
ids[#ids + 1] = m.id
if want and m.experimental then experimental = true end
end
end
if #ids == 0 then
self.modNotice = { ok = true, text = want
and Strings("Every mod is already enabled.")
or Strings("Every mod is already disabled.") }
return
end
if want and experimental and not confirmed then
self._modConfirm = {
kind = "enableAll",
title = "Experimental mods",
yesLabel = "Enable all",
lines = {
"Some of these mods are marked experimental.",
"They may be unfinished or unstable.",
"Enable everything anyway?",
},
}
return
end
self._modConfirm = nil
LauncherMods.setAllEnabled(ids, want)
self:_refreshMods()
self.modNotice = { ok = true, text = want
and Strings("Enabled %d mods.", #ids)
or Strings("Disabled %d mods.", #ids) }
end
-- GitHub Update / Check for updates / Versions. Soft-fails into modNotice.
-- Update button: when a newer release is known, confirm then install; when
-- already current, force-refresh the 6h cache and report / offer update.
@@ -4531,8 +4594,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
love.graphics.setFont(self.hintFont)
col(PAL.warning)
love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods),
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
local countText = Strings("%d of %d enabled", enabledCount, #mods)
local countX = x + nameW + 14 * s
love.graphics.print(countText, countX,
y + (headerH - self.hintFont:getHeight()) / 2)
local btnLabel = self:_modsImportButtonLabel()
local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s)
@@ -4542,6 +4607,32 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
self.modImportRect =
self:_glassyButton(btnX, btnY, btnW, btnH, btnLabel, self.saveBtnFont, true)
-- Enable all / Disable all (#647): bulk switches beside Import mod .zip, so
-- coming back from a cable-club session (which asks for a vanilla fingerprint,
-- src/link/Fingerprint.lua) costs one click instead of one switch per mod.
-- The header is a single row shared with the count, so the chips are dropped
-- rather than overlapped when the column is too narrow to hold them (a
-- phone-width layout); the per-mod switches below are always the full path.
self.modEnableAllRect, self.modDisableAllRect = nil, nil
if #mods > 0 then
local enaLabel, disLabel = Strings("Enable all"), Strings("Disable all")
love.graphics.setFont(self.hintFont)
local bulkH = self.hintFont:getHeight() + 10 * s
local bulkY = y + (headerH - bulkH) / 2
local enaW = self.hintFont:getWidth(enaLabel) + 24 * s
local disW = self.hintFont:getWidth(disLabel) + 24 * s
local bulkGap = 8 * s
local room = btnX - (countX + self.hintFont:getWidth(countText) + bulkGap)
if room >= enaW + disW + 2 * bulkGap then
local disX = btnX - bulkGap - disW
local enaX = disX - bulkGap - enaW
self.modEnableAllRect =
self:_chipButton(enaX, bulkY, enaLabel, { w = enaW, h = bulkH })
self.modDisableAllRect =
self:_chipButton(disX, bulkY, disLabel, { w = disW, h = bulkH })
end
end
local top = y + headerH + 14 * s
-- notice line: the last install/delete result, else the platform hint