From 1ec33b1374e91721e4d872a9ecce46de93c1d653 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Sat, 1 Aug 2026 03:20:09 -0300 Subject: [PATCH] refactor(import): separate NX capabilities from Android flags Co-authored-by: Cursor --- src/import/RomImporter.lua | 170 +++++++++++++++++++++++---- tests/rom_importer_nx_flags_test.lua | 47 ++++++++ 2 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 tests/rom_importer_nx_flags_test.lua diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 690046a0..6048de01 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -328,6 +328,114 @@ local function commandOutput(command) return result ~= "" and result or nil end +local IMPORTS_DIR = "imports" +local ROM_BYTES = 1024 * 1024 + +-- Strip only a validated sdmc:/ prefix for OpenMTP/DBI relative paths. +function RomImporter.mtpHintPath(saveDir) + if type(saveDir) ~= "string" then return "" end + if saveDir:sub(1, 6) == "sdmc:/" then return saveDir:sub(7) end + return saveDir +end + +function RomImporter:ensureImportsDir() + local info = love.filesystem.getInfo(IMPORTS_DIR) + if info and info.type == "directory" then return true end + if info then return false end + if love.filesystem.createDirectory then + return love.filesystem.createDirectory(IMPORTS_DIR) + end + return false +end + +function RomImporter:_setNxInboxNotice(version) + version = version or self.tab or "red" + local saveDir = love.filesystem.getSaveDirectory() + local rel = RomImporter.mtpHintPath(saveDir) + if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end + self.notice = { + version = version, + status = Strings("Copy your .gb/.gbc into:"), + detail = Strings("%s/imports/\nDBI MTP → 1: SD Card/%simports/", saveDir, rel), + } +end + +local function listRomPaths(dir) + local paths = {} + for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do + local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) + if name:lower():match("%.gbc?$") + and love.filesystem.getInfo(path, "file") then + paths[#paths + 1] = path + end + end + return paths +end + +function RomImporter:scanInbox(ready) + ready = ready or self.ready + local paths = {} + for _, path in ipairs(listRomPaths(IMPORTS_DIR)) do + paths[#paths + 1] = path + end + for _, path in ipairs(listRomPaths("")) do + -- Root scan is second; imports/ entries were already collected above. + paths[#paths + 1] = path + end + return paths +end + +function RomImporter:rescanAction(version) + if self.workState == "working" then return end + version = version or self.tab or "red" + self.chooseVersion = version + self:ensureImportsDir() + local ready = self.ready + local candidates = self:scanInbox(ready) + local sawReadyOnly = false + for _, path in ipairs(candidates) do + local data = love.filesystem.read(path) + local displayName = path:match("[^/\\]+$") or path + if type(data) ~= "string" then + self:setError("The file could not be read: " .. displayName, version) + return + end + if #data ~= ROM_BYTES then + self:startData(data, displayName) + return + end + local romVersion = GameVersion.forSha1(sha1(data)) + if not romVersion then + self:startData(data, displayName) + return + end + if ready[romVersion] then + sawReadyOnly = true + else + self:startData(data, displayName) + return + end + end + if sawReadyOnly and #candidates > 0 then + self.notice = { + version = version, + status = Strings("No new ROM found."), + detail = Strings("Already-imported dumps are ignored. Add another version or " + .. "delete the copy when finished."), + } + return + end + self:_setNxInboxNotice(version) +end + +function RomImporter:_romAction(version) + if self.isNX then + if self.ready[version] then self:reimport(version) + else self:rescanAction(version) end + elseif self.ready[version] then self:reimport(version) + else self:choose(version) end +end + -- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog -- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so -- neither desktop path below works there. conf.lua points the Android save @@ -567,8 +675,13 @@ function RomImporter.new(onComplete, opts) -- pending-file scan plus love.system.pickFile / createFile, provided -- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag -- keeps its historical name so every Android call site stays untouched. + -- NX uses a separate save-directory inbox (isNX / romImportMode) and must + -- never set android or take the mobile delete-after-import path. local mobileOS = love.system.getOS() - local android = mobileOS == "Android" or mobileOS == "iOS" + local isNX = Platform.isNX() + local romImportMode = Platform.romImportMode() + local mobileFileBridge = mobileOS == "Android" or mobileOS == "iOS" + local android = mobileFileBridge local CacheFs = require("src.import.CacheFs") local self = setmetatable({ onComplete = onComplete, @@ -576,6 +689,9 @@ function RomImporter.new(onComplete, opts) forceImport = opts.forceImport or false, onEditSave = opts.onEditSave, onEditTouchControls = opts.onEditTouchControls, + isNX = isNX, + romImportMode = romImportMode, + mobileFileBridge = mobileFileBridge, android = android, ios = mobileOS == "iOS", -- One startup poll pass on both mobiles. iOS: files dropped through the @@ -587,14 +703,14 @@ function RomImporter.new(onComplete, opts) -- pick never arrives. The file is sitting in the save dir either way, so -- boot armed and let the first poll tick consume it, rather than making the -- player tap Import a second time to trigger the scan by hand (#553). - pickPending = android or nil, + pickPending = mobileFileBridge or nil, -- Android drag: the launcher is handed no move events at all (main.lua -- forwards neither touchmoved nor mousemoved while it is up), and its mouse -- emulation is what "no reliable pointer polling" below refers to. -- love.touch IS pollable, so where it exists a touch drag can be resolved -- inside draw the same way the desktop mouse is. Where it does not, every -- Android path stays exactly as it was: act on press, never arm. - touchPollable = android and love.touch ~= nil + touchPollable = mobileFileBridge and love.touch ~= nil and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil, tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods" logo = love.graphics.newImage("assets/logo/logo.png"), @@ -674,7 +790,7 @@ function RomImporter.new(onComplete, opts) for _, version in ipairs(GameVersion.ORDER) do if not self.ready[version] then needRom = true; break end end - if android and needRom then + if mobileFileBridge and needRom then local name, data = findPendingRom(self.ready) if name then self:startData(data, name) @@ -683,6 +799,9 @@ function RomImporter.new(onComplete, opts) -- is up, so a rejected pick can outlive the focus handler (#442). consumePickedRomError(self) end + elseif self.isNX and self.launcher then + self:ensureImportsDir() + self:_setNxInboxNotice() end -- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits) @@ -901,10 +1020,14 @@ function RomImporter:startData(data, displayName) and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version] -- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy) -- so the next Choose / focus cannot treat it as a fresh pending ROM. - if self.android and type(displayName) == "string" + if self.mobileFileBridge and type(displayName) == "string" and not displayName:find("[/\\]") then love.filesystem.remove(displayName) end + if self.isNX and type(displayName) == "string" then + self.detail = Strings("%s imported. You may delete the copy from " + .. "imports/ when finished.", displayName) + end self.importing = nil self.workState = "complete" self.completeVersion = version @@ -1157,15 +1280,12 @@ end function RomImporter:choose(version) if self.workState == "working" then return end self.chooseVersion = version or "red" - if Platform.isNX() then - self.notice = { - version = self.chooseVersion, - status = "Copy your .gb/.gbc into:", - detail = love.filesystem.getSaveDirectory() .. "/imports/", - } + if self.isNX then + self:ensureImportsDir() + self:_setNxInboxNotice(self.chooseVersion) return end - if self.android then + if self.mobileFileBridge or self.android then -- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or -- a fresh SAF pick). Never reuse an already-imported cart's file -- that -- was the #167 failure mode (second Choose just re-extracted Red). @@ -1407,7 +1527,7 @@ function RomImporter:gamepadpressed(_, button) if self.workState == "working" then return end local version = self.tab if GameVersion.VERSIONS[version] then - if self.ready[version] then self:play(version) else self:choose(version) end + if self.ready[version] then self:play(version) else self:_romAction(version) end end end end @@ -2702,8 +2822,7 @@ function RomImporter:mousepressed(x, y, button) self:play(self.panelVersion); return end if inside(self.romButtonRect, x, y) then - local version = self.panelVersion - if self.ready[version] then self:reimport(version) else self:choose(version) end + self:_romAction(self.panelVersion) return end -- SAVE FILES card: Import save / Export save, and the open-folder affordance @@ -2908,7 +3027,7 @@ function RomImporter:keypressed(key) -- open its picker. The mods tab has no keyboard action. local version = self.tab if GameVersion.VERSIONS[version] then - if self.ready[version] then self:play(version) else self:choose(version) end + if self.ready[version] then self:play(version) else self:_romAction(version) end end end end @@ -3204,8 +3323,14 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) local rightX = twoCol and (x + colW + colGap) or x -- ROM card contents by state (rehomes the existing import flow) - local dropHint = self.android and "Copy the .gb/.gbc via USB." - or Strings("Or drop the .gb/.gbc file here.") + local dropHint + if self.isNX then + dropHint = Strings("Copy your .gb/.gbc into the imports folder (see path above).") + elseif self.android then + dropHint = "Copy the .gb/.gbc via USB." + else + dropHint = Strings("Or drop the .gb/.gbc file here.") + end local accent = version == "yellow" and PAL.gold or (version == "red" and PAL.red or PAL.blue) local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress @@ -3227,11 +3352,13 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) elseif erroring then romState = "Import failed" romDetail = self.detail or Strings("That ROM could not be imported.") - romBtnLabel, romBtnEnabled = "Import ROM", true + romBtnLabel = self.isNX and "Procurar novamente" or "Import ROM" + romBtnEnabled = true elseif notice then romState = "No ROM imported" romDetail = trim((notice.status or "") .. " " .. (notice.detail or "")) - romBtnLabel, romBtnEnabled = "Import ROM", true + romBtnLabel = self.isNX and "Procurar novamente" or "Import ROM" + romBtnEnabled = true elseif self.returning[version] then romState = "Update required" romDetail = "This build needs a few more things from your " @@ -3240,7 +3367,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) else romState = "No ROM imported" romDetail = "The ROM is verified before any files are created. " .. dropHint - romBtnLabel, romBtnEnabled = "Import ROM", true + romBtnLabel = self.isNX and "Procurar novamente" or "Import ROM" + romBtnEnabled = true end end diff --git a/tests/rom_importer_nx_flags_test.lua b/tests/rom_importer_nx_flags_test.lua new file mode 100644 index 00000000..ca4c77ec --- /dev/null +++ b/tests/rom_importer_nx_flags_test.lua @@ -0,0 +1,47 @@ +-- NX RomImporter must not inherit Android mobile-file-bridge semantics (SWNX-03). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer NX flags") +local eq = S.eq +local check = S.check + +love.system = love.system or {} +love.filesystem = love.filesystem or {} +local saved = { + getOS = love.system.getOS, + getSaveDirectory = love.filesystem.getSaveDirectory, + createDirectory = love.filesystem.createDirectory, +} + +love.system.getOS = function() return "NX" end +love.filesystem.getSaveDirectory = function() return "sdmc:/switch/gen1recomp/pokemon-love2d" end +love.filesystem.createDirectory = function() return true end + +package.loaded["src.core.Platform"] = nil +package.loaded["src.import.RomImporter"] = nil +local RomImporter = require("src.import.RomImporter") + +local ri = RomImporter.new(function() end, { launcher = true }) +eq(ri.isNX, true, "NX importer sets isNX") +eq(ri.android, false, "NX importer does not set android") +eq(ri.mobileFileBridge, false, "NX importer does not set mobileFileBridge") +eq(ri.romImportMode, "save-directory", "NX importer exposes save-directory mode") +check(ri.pickPending == nil, "NX importer does not arm mobile pick polling") + +love.system.getOS = function() return "Android" end +package.loaded["src.core.Platform"] = nil +package.loaded["src.import.RomImporter"] = nil +RomImporter = require("src.import.RomImporter") +ri = RomImporter.new(function() end, { launcher = true }) +eq(ri.isNX, false, "Android importer is not NX") +eq(ri.android, true, "Android importer keeps android mobile path") +eq(ri.mobileFileBridge, true, "Android importer sets mobileFileBridge") + +love.system.getOS = saved.getOS +love.filesystem.getSaveDirectory = saved.getSaveDirectory +love.filesystem.createDirectory = saved.createDirectory +package.loaded["src.core.Platform"] = nil +package.loaded["src.import.RomImporter"] = nil + +S.finish()