From 389184789f49c54887e600d3590caacc528f5327 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 25 Sep 2026 13:18:59 -0400 Subject: [PATCH] import picker for custom cart --- .../jni/love/src/modules/system/System.cpp | 4 +- .../src/jni/love/src/modules/system/System.h | 1 + .../java/org/love2d/android/GameActivity.java | 4 +- mobile/ios/native/GRPickerBridge.swift | 9 +- ports/uwp/third_party/love/README.md | 2 +- .../love/patches/uwp-cart-file-picker.patch | 25 ++ src/core/FilePicker.lua | 29 +- src/import/LauncherView.lua | 3 +- src/import/RomImporter.lua | 313 ++++++++++++++++-- src/ui/kit/FileBrowser.lua | 4 +- tests/engine/android_host_extension_test.lua | 4 +- tests/engine/cart_import_picker.lua | 89 +++++ tests/engine/cart_launcher.lua | 8 +- .../ios_required_import_picker_test.lua | 5 +- 14 files changed, 449 insertions(+), 51 deletions(-) create mode 100644 ports/uwp/third_party/love/patches/uwp-cart-file-picker.patch create mode 100644 tests/engine/cart_import_picker.lua diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 3af28e9b..56ec8219 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -192,6 +192,8 @@ bool System::pickFile(const char *kind, const char *destination) const dest = "picked_mod.zip"; else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) dest = "picked_save.sav"; + else if (strcmp(kind, "cart") == 0) + dest = "picked_cart.g1rcart"; else if (strcmp(kind, "required_import") == 0) dest = (destination != nullptr && destination[0] != '\0') ? destination : "picked_required_import.bin"; @@ -212,7 +214,7 @@ bool System::pickFile(const char *kind, const char *destination) const const char *System::pickFileKinds() const { #ifdef LOVE_ANDROID - return "rom,mod,sav,required_import"; + return "rom,mod,sav,cart,required_import"; #else return ""; #endif diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 963f1464..4a5ad2ff 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -113,6 +113,7 @@ public: * * @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb, * "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav, + * "cart" -> picked_cart.g1rcart, * "required_import" -> picked_required_import.bin. * @return Whether the picker was shown. **/ diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 544db21c..cd41fb1f 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -610,8 +610,8 @@ public class GameActivity extends SDLActivity { * stays picker-agnostic and unchanged. * * @param destFilename basename under the app save identity (e.g. - * picked_rom.gb, picked_mod.zip, picked_save.sav, or - * picked_required_import.bin) + * picked_rom.gb, picked_mod.zip, picked_save.sav, + * picked_cart.g1rcart, or picked_required_import.bin) */ private static boolean isTelevision(Context context) { if (context == null) return false; diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index 54e0bf2c..aba30ee9 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -5,9 +5,9 @@ // without updating that patch (see mobile/ios/patch_love_src.py). // // Contract (mirrors love-android's GameActivity.showFilePicker): -// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into +// love.system.pickFile("rom"|"mod"|"sav"|"cart"|"required_import") -> copies the user's pick into // the LÖVE save directory as picked_rom.gb / picked_mod.zip / -// picked_save.sav; RomImporter's pending-file scan consumes it. +// picked_save.sav / picked_cart.g1rcart; RomImporter's pending-file scan consumes it. // love.system.createFile(name) -> exports save dir's pending_export.sav // through the system picker, then writes export_done.flag. @@ -330,6 +330,9 @@ public final class GRPickerBridge: NSObject { types = [.zip] case "sav": destName = "picked_save.sav" + case "cart": + destName = "picked_cart.g1rcart" + if let t = UTType(filenameExtension: "g1rcart") { types.append(t) } case "required_import": if let requestedDestination, isDirectRequiredDestination(requestedDestination), @@ -396,7 +399,7 @@ public final class GRPickerBridge: NSObject { // Kept beside the switch it describes, because the two drifting apart is // the only way this can lie. @objc public static func supportedPickerKinds() -> NSString { - return "rom,mod,sav,stadium,required_import" as NSString + return "rom,mod,sav,cart,stadium,required_import" as NSString } @objc(presentExportWithName:saveDir:) diff --git a/ports/uwp/third_party/love/README.md b/ports/uwp/third_party/love/README.md index a2864c27..1c7b66a7 100644 --- a/ports/uwp/third_party/love/README.md +++ b/ports/uwp/third_party/love/README.md @@ -6,4 +6,4 @@ The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import lib The normal package build consumes these files directly. Rebuilding the backend is a separate dependency maintenance task. -The ROM picker in commit `3f51bf0` offers `.gb` and `.gbc` only. `love.dll` in this directory has a small in-place patch that adds a third filter for `.gba` (the staged copy is still `picked_rom.gb`). `scripts/xbox-uwp/rebuild_dependencies.ps1` applies `patches/gba-file-picker.patch` on a from-source rebuild so the same filter is compiled in instead of patched afterward. +The ROM picker in commit `3f51bf0` offers `.gb` and `.gbc` only. `love.dll` in this directory has a small in-place patch that adds a third filter for `.gba` (the staged copy is still `picked_rom.gb`). `scripts/xbox-uwp/rebuild_dependencies.ps1` applies `patches/gba-file-picker.patch` on a from-source rebuild so the same filter is compiled in instead of patched afterward. Apply `patches/uwp-cart-file-picker.patch` after that one so `pickFile("cart")` offers `.g1rcart` and stages `picked_cart.g1rcart`. A DLL built before that patch refuses the cart kind; the launcher then falls back to the save-folder hint. diff --git a/ports/uwp/third_party/love/patches/uwp-cart-file-picker.patch b/ports/uwp/third_party/love/patches/uwp-cart-file-picker.patch new file mode 100644 index 00000000..71222592 --- /dev/null +++ b/ports/uwp/third_party/love/patches/uwp-cart-file-picker.patch @@ -0,0 +1,25 @@ +diff --git a/src/modules/system/uwp/FilePicker.cpp b/src/modules/system/uwp/FilePicker.cpp +--- a/src/modules/system/uwp/FilePicker.cpp ++++ b/src/modules/system/uwp/FilePicker.cpp +@@ -56,6 +56,11 @@ + filters.Append(L".sav"); + destination = "picked_save.sav"; + } ++ else if (kind == "cart") ++ { ++ filters.Append(L".g1rcart"); ++ destination = "picked_cart.g1rcart"; ++ } + else + { + filters.Append(L".gb"); +@@ -100,7 +105,8 @@ + bool pickFile(const char *kind) + { + std::string requestedKind = kind ? kind : "rom"; +- if (requestedKind != "rom" && requestedKind != "mod" && requestedKind != "sav") ++ if (requestedKind != "rom" && requestedKind != "mod" && requestedKind != "sav" ++ && requestedKind != "cart") + return false; + + { diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua index 9153fb6c..50020b9a 100644 --- a/src/core/FilePicker.lua +++ b/src/core/FilePicker.lua @@ -60,14 +60,18 @@ function FilePicker.matches(name, kind) return false end -function FilePicker.open(prompt, kind) +-- Shell commands for one pick, in the order this OS should try them. Windows +-- copies the pick to a plain-ASCII temp name: the console codepage would +-- mangle a non-ASCII path, and io.open there needs ANSI bytes. +function FilePicker.commands(prompt, kind) if not Platform.canSpawnProcess() then return nil end local title = shellSafe(prompt) local platform = love.system.getOS() if platform == "OS X" then - return commandOutput( + return { ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]]) - :format(title, appleTypes(kind.exts))) + :format(title, appleTypes(kind.exts)), + } elseif platform == "Windows" then local script = table.concat({ HostPicker.WIN_OPEN_DIALOG, @@ -81,15 +85,22 @@ function FilePicker.open(prompt, kind) "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", "[Console]::Write($t)}", }) - return commandOutput( - 'powershell -NoProfile -STA -Command "' .. script .. '"') + return { 'powershell -NoProfile -STA -Command "' .. script .. '"' } elseif platform == "Linux" then - local path = commandOutput( + return { ([[zenity --file-selection --title="%s" --file-filter="%s | %s" 2>/dev/null]]) - :format(title, kind.label, globPatterns(kind.exts))) + :format(title, kind.label, globPatterns(kind.exts)), + ([[kdialog --getopenfilename "$HOME" "%s|%s" 2>/dev/null]]) + :format(globPatterns(kind.exts), kind.label), + } + end + return nil +end + +function FilePicker.open(prompt, kind) + for _, command in ipairs(FilePicker.commands(prompt, kind) or {}) do + local path = commandOutput(command) if path then return path end - return commandOutput(([[kdialog --getopenfilename "$HOME" "%s|%s" 2>/dev/null]]) - :format(globPatterns(kind.exts), kind.label)) end return nil end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 4a05e379..1fb6ea28 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -4642,8 +4642,7 @@ local function buildCartModal(imp, m) end local available = inner - Kit.scrollGutter(m.s) - 2 * gap local cols = math.max(1, math.min(3, math.floor((available + gap) / (actionMin + gap)))) - local FilePicker = require("src.core.FilePicker") - local importLabel = FilePicker.available() and Strings("Import a cart") or Strings("Get more carts") + local importLabel = imp:_cartImportButtonLabel() local importW = chipWidth(importLabel, m) + iconExtra local closeW = chipWidth(Strings("Close"), m) local footerStacked = importW + gap + closeW > inner diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d4a520f8..b0137d3a 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -450,6 +450,8 @@ local IMPORTS_DIR = "imports" local BASE_ROMS_DIR = "baseroms" local MODS_INBOX_DIR = "imports/mods" local SAVES_INBOX_DIR = "imports/saves" +local CARTS_INBOX_DIR = "imports/carts" +local PICKED_CART = "picked_cart.g1rcart" local ROM_BYTES_GEN1 = 1024 * 1024 local ROM_BYTES_GEN2 = 2 * 1024 * 1024 local ROM_BYTES_GEN3 = 16 * 1024 * 1024 @@ -517,6 +519,17 @@ end -- NX mod zip inbox (separate from ROM imports/). Parent imports/ first -- -- love.filesystem.createDirectory does not create nested parents. +function RomImporter:ensureCartsInboxDir() + self:ensureImportsDir() + local info = love.filesystem.getInfo(CARTS_INBOX_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(CARTS_INBOX_DIR) + end + return false +end + function RomImporter:ensureModsInboxDir() self:ensureImportsDir() local info = love.filesystem.getInfo(MODS_INBOX_DIR) @@ -567,6 +580,15 @@ function RomImporter:_setNxInboxNotice(version) } end +function RomImporter:_setNxCartsInboxNotice() + local saveDir = love.filesystem.getSaveDirectory() + local rel = RomImporter.mtpHintPath(saveDir) + if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end + self._cartNotice = Strings( + "Copy your .g1rcart into:\n%s/imports/carts/\nDBI MTP → 1: SD Card/%simports/carts/", + saveDir, rel) +end + function RomImporter:_setNxModsInboxNotice() local saveDir = love.filesystem.getSaveDirectory() local rel = RomImporter.mtpHintPath(saveDir) @@ -724,6 +746,26 @@ function RomImporter:scanModsInbox() return listZipPaths(MODS_INBOX_DIR) end +local function listCartPaths(dir) + local paths = {} + for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do + if name:sub(1, 1) ~= "." then + local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) + if name:lower():match("%.g1rcart$") + and love.filesystem.getInfo(path, "file") then + paths[#paths + 1] = path + end + end + end + return paths +end + +-- NX cart inbox: only *.g1rcart under imports/carts/. +function RomImporter:scanCartsInbox() + self:ensureCartsInboxDir() + return listCartPaths(CARTS_INBOX_DIR) +end + -- NX saves inbox: only non-hidden *.sav under imports/saves//. function RomImporter:scanSavesInbox(version) version = self:_resolveSaveVersion(version) @@ -1048,6 +1090,24 @@ local function findPendingMod(preferAny, skip) end -- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav). +-- Android/iOS SAF writes a cart pick to picked_cart.g1rcart. USB copies may +-- use any .g1rcart basename at the save-dir root. preferAny=true also accepts +-- those copies (Import). Focus only consumes the SAF basename so a leftover +-- cart file is never auto-installed on every refocus. +local function findPendingCart(preferAny, skip) + if love.filesystem.getInfo(PICKED_CART, "file") then + return PICKED_CART + end + if not preferAny then return nil end + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + if name:lower():match("%.g1rcart$") and not (skip and skip[name]) + and love.filesystem.getInfo(name, "file") then + return name + end + end + return nil +end + local function findPendingSav(preferAny, skip) local preferred = "picked_save.sav" if love.filesystem.getInfo(preferred, "file") then @@ -1751,6 +1811,11 @@ function RomImporter:focus(f) local version = self.androidPendingVersion or self:_savedropTarget() self.androidPendingVersion = nil self.saveNotice[version] = { ok = false, text = text } + elseif pickError:find("picked_cart", 1, true) + or self.pickerPendingKind == "cart" then + self.pickerPendingKind = nil + self.pickerPendingVersion = nil + self._cartNotice = text else self:setError(text) end @@ -1819,6 +1884,15 @@ function RomImporter:focus(f) self.modNotice and self.modNotice.ok) return end + local cartName = findPendingCart(false, self.pickSkip) + if cartName then + local version = self.pickerPendingVersion or self._cartPopup or self.tab + self.pickerPendingKind = nil + self.pickerPendingVersion = nil + local installed = self:_installCartFile(cartName, version) + consumePick(self, cartName, PICKED_CART, installed) + return + end local savName = findPendingSav(false, self.pickSkip) if savName then local version = self.androidPendingVersion or self:_savedropTarget() @@ -2354,6 +2428,10 @@ function RomImporter:filedropped(file) self:_importSave(self:_savedropTarget(), file) return end + if name:lower():match("%.g1rcart$") then + self:_installCartFile(file, self._cartPopup or self.tab) + return + end local data, readError = readDroppedFile(file) if not data then self:setError("Could not read the dropped file: " .. tostring(readError)) @@ -2538,12 +2616,18 @@ function RomImporter:_pumpHostPick() local state, output, err = HostPicker.poll(job.id) if state == "pending" then return end self._hostPick = nil - if self.modNotice == job.notice then self.modNotice = nil end + if job.kind == "cart" then + if self._cartNotice == job.notice then self._cartNotice = nil end + elseif self.modNotice == job.notice then + self.modNotice = nil + end output = trim(output) if output ~= "" then - self:_installMod(output) + if job.kind == "cart" then self:_installCartFile(output, job.version) + else self:_installMod(output) end elseif err then - self:_openModBrowser() + if job.kind == "cart" then self:_openCartBrowser(job.version) + else self:_openModBrowser() end end end @@ -3269,9 +3353,15 @@ function RomImporter:_pollPickedFiles(dt) if pickError then love.filesystem.remove("pick_error.txt") self.pickPending = nil - self.modNotice = { ok = false, text = pickError } - self.notice = { version = self.chooseVersion or "red", - status = "File import failed:", detail = pickError } + if self.pickerPendingKind == "cart" then + self.pickerPendingKind = nil + self.pickerPendingVersion = nil + self._cartNotice = pickError + else + self.modNotice = { ok = false, text = pickError } + self.notice = { version = self.chooseVersion or "red", + status = "File import failed:", detail = pickError } + end return end local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil @@ -3281,6 +3371,7 @@ function RomImporter:_pollPickedFiles(dt) for _, name in ipairs(love.filesystem.getDirectoryItems("")) do local n = name:lower() if isRomFilename(n) or n == "picked_mod.zip" or n == "picked_save.sav" + or n == PICKED_CART or n == "picked_required_import.bin" or n == "picked_stadium.z64" or n:match("^picked_importer_[%l%d_%-]+%.bin$") then found = true @@ -3507,6 +3598,13 @@ function RomImporter:update(dt) if Platform.isUWP() and self.saveNotice[target] and self.saveNotice[target].ok then os.remove(path) end + elseif kind == "cart" then + if Platform.isUWP() then + local installed = self:_installCartFile(path, version) + if installed then os.remove(path) end + else + self:_installCartFile(path, version) + end else self:startPath(path) if Platform.isUWP() then os.remove(path) end @@ -3527,6 +3625,8 @@ function RomImporter:update(dt) self._skinNotice = { ok = false, text = errorText } elseif kind == "sav" then self.saveNotice[version] = { ok = false, text = errorText } + elseif kind == "cart" then + self._cartNotice = errorText else self:setError(errorText) end @@ -5157,8 +5257,6 @@ end -- The view's open-folder affordance needs the same file:// encoding the old -- notice line used. --- Desktop picks a .cart file; everywhere else CartStore's stray scan already --- adopts anything dropped in the folder, so we just point at it. function RomImporter:_resyncPointerAfterDialog() self._mouseAt = nil self._clickPt = nil @@ -5166,23 +5264,60 @@ function RomImporter:_resyncPointerAfterDialog() and love.mouse.isDown(1)) and true or false end -function RomImporter:importCartFile(version) - local CartStore = require("src.carts.CartStore") - local FilePicker = require("src.core.FilePicker") - if not FilePicker.available() then - local dir = self:cartsDir() - self._cartNotice = dir - and Strings("Drop .cart files in %s, then reopen this list.", dir) - or Strings("No filesystem available to import from.") - return false +local CART_PICK = { label = "Cart", exts = { "g1rcart" } } + +local function hostAbsolute(path) + return type(path) == "string" and ( + path:match("^/") + or path:match("^%a:[/\\]") + or path:match("^[Ss][Dd][Mm][Cc]:")) +end + +local function readCartSource(source) + local t = type(source) + if (t == "userdata" or t == "table") and type(source.open) == "function" then + return readDroppedFile(source) end - local path = FilePicker.open("Choose a cart", - { label = "Cart", exts = { CartStore.EXT:gsub("^%.", "") } }) - self:_resyncPointerAfterDialog() - if not path then return false end - local bytes = FilePicker.read(path) + if t ~= "string" then return nil end + if not hostAbsolute(source) and love and love.filesystem then + local data = love.filesystem.read(source) + if type(data) == "string" and data ~= "" then return data end + end + local file = io.open(source, "rb") + if file then + local data = file:read("*a") + file:close() + if type(data) == "string" and data ~= "" then return data end + end + if love and love.filesystem then + local data = love.filesystem.read(source) + if type(data) == "string" and data ~= "" then return data end + end + return nil +end + +local function cartSourceName(source) + if type(source) == "string" then + return source:match("([^/\\]+)$") or source + end + if source and source.getFilename then + return source:getFilename() or "cart" + end + return "cart" +end + +local function handheldHost() + return os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" +end + +function RomImporter:_installCartFile(source, version) + local CartStore = require("src.carts.CartStore") + local name = cartSourceName(source) + local bytes = readCartSource(source) if not bytes then - self._cartNotice = Strings("Could not read %s", FilePicker.basename(path)) + self._cartNotice = Strings("Could not read %s", name) return false end local cart, err = CartStore.install(bytes) @@ -5191,12 +5326,138 @@ function RomImporter:importCartFile(version) tostring(err)) return false end - self:_refreshCarts(version) - self._cartNotice = Strings("Imported %s. It is in this list now.", - tostring(cart.title or cart.id)) + if cart.base then self:_refreshCarts(cart.base) end + if version and version ~= cart.base then self:_refreshCarts(version) end + local title = tostring(cart.title or cart.id) + if version and cart.base and cart.base ~= version then + local info = GameVersion.info(cart.base) + self._cartNotice = Strings("Imported %s for %s.", title, + (info and info.displayName) or cart.base) + else + self._cartNotice = Strings("Imported %s. It is in this list now.", title) + end return true end +function RomImporter:_openCartBrowser(version) + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select Cart (.g1rcart)", + mode = "cart", + onSelect = function(pickedPath) + self:_installCartFile(pickedPath, version) + end, + }) + return true + end + return false +end + +function RomImporter:rescanCartsAction(version) + if self.workState == "working" then return false end + self:ensureCartsInboxDir() + local candidates = self:scanCartsInbox() + if #candidates == 0 then + self:_setNxCartsInboxNotice() + return false + end + local anyOk, fails = false, 0 + local lastOk, lastFail + for _, path in ipairs(candidates) do + if self:_installCartFile(path, version) then + anyOk = true + lastOk = self._cartNotice + else + fails = fails + 1 + lastFail = self._cartNotice + end + end + if anyOk and lastFail then + self._cartNotice = Strings("%s (%d could not be imported.)", lastOk, fails) + elseif not anyOk then + self._cartNotice = lastFail + end + return anyOk +end + +function RomImporter:_cartImportButtonLabel() + if self.isNX then return Strings("Scan again") end + return Strings("Import .g1rcart") +end + +-- "Import .g1rcart" on the Custom Carts modal. Same platform split as ROM, +-- mod, and save import: NX rescans imports/carts/, Android and iOS stage +-- picked_cart.g1rcart through love.system.pickFile("cart"), UWP returns the +-- pick from getPickedFile, handhelds use the in-launcher browser, and desktop +-- opens the OS dialog (async on Windows so the window keeps drawing). +function RomImporter:importCartFile(version) + if self.workState == "working" then return false end + if self._hostPick then return false end + if self.isNX then + self:ensureCartsInboxDir() + return self:rescanCartsAction(version) + end + if self.nativePicker and love.system.getPickedFile then + if pickerHasKind("cart") and pickFile("cart") then + self.pickerPendingKind = "cart" + self.pickerPendingVersion = version + return true + end + self.pickerPendingKind = nil + local dir = self:cartsDir() + self._cartNotice = dir + and Strings("Drop .g1rcart files in %s, then reopen this list.", dir) + or Strings("Could not open the file picker.") + return false + end + if self.android then + local name = findPendingCart(true, self.pickSkip) + if name then + local installed = self:_installCartFile(name, version) + consumePick(self, name, PICKED_CART, installed) + return installed + end + if pickerHasKind("cart") and pickFile("cart") then + self.pickerPendingKind = "cart" + self.pickerPendingVersion = version + self.pickPending = true + self.pickTimer = 0 + return true + end + local dir = love.filesystem.getSaveDirectory() + self._cartNotice = Strings( + "Could not open the file picker. Copy a .g1rcart into:\n%s", dir) + return false + end + if handheldHost() and self:_openCartBrowser(version) then return true end + local FilePicker = require("src.core.FilePicker") + if love.system.getOS() == "Windows" and Platform.canSpawnProcess() then + local id = HostPicker.start(FilePicker.commands( + Strings("Choose a .g1rcart cart"), CART_PICK)) + if id then + local notice = Strings( + "Choose a .g1rcart in the file dialog. The launcher waits until it closes.") + self._hostPick = { id = id, kind = "cart", version = version, notice = notice } + self._cartNotice = notice + return true + end + end + if FilePicker.available() then + local path = FilePicker.open(Strings("Choose a .g1rcart cart"), CART_PICK) + self:_resyncPointerAfterDialog() + if not path then return false end + return self:_installCartFile(path, version) + end + if self:_openCartBrowser(version) then return true end + local dir = self:cartsDir() + self._cartNotice = dir + and Strings("Drop .g1rcart files in %s, then reopen this list.", dir) + or Strings("No filesystem available to import from.") + return false +end + -- The real OS path of the cart folder, so the launcher can open it and so a -- player can drop a .cart in by hand. function RomImporter:cartsDir() diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua index bcdb4a48..631bb73f 100755 --- a/src/ui/kit/FileBrowser.lua +++ b/src/ui/kit/FileBrowser.lua @@ -24,7 +24,7 @@ end local FileBrowser = { active = false, title = "Select File", - mode = "rom", -- "rom", "save", "mod", "all" + mode = "rom", -- "rom", "save", "mod", "cart", "all" currentDir = "/", entries = {}, selectedIdx = 1, @@ -100,6 +100,8 @@ local function isMatchingFilter(name, isDir, mode) return (ext == "sav" or ext == "lua") elseif mode == "mod" then return (ext == "zip") + elseif mode == "cart" then + return (ext == "g1rcart") end return true end diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index 2f6815e4..0522dab6 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -120,7 +120,9 @@ systemFile:close() check(system:find('strcmp(kind, "required_import")', 1, true) and system:find('destination != nullptr', 1, true) and system:find('"picked_required_import.bin"', 1, true) - and system:find('return "rom,mod,sav,required_import"', 1, true), + and system:find('strcmp(kind, "cart")', 1, true) + and system:find('"picked_cart.g1rcart"', 1, true) + and system:find('return "rom,mod,sav,cart,required_import"', 1, true), "native Android bridge advertises and routes required imports") check(source:find('normalized.startsWith("mods/")', 1, true) and source:find('/baseroms/', 1, true) diff --git a/tests/engine/cart_import_picker.lua b/tests/engine/cart_import_picker.lua new file mode 100644 index 00000000..3968c566 --- /dev/null +++ b/tests/engine/cart_import_picker.lua @@ -0,0 +1,89 @@ +-- Custom Carts "Import .g1rcart" follows the same platform split as ROM, mod, +-- and save import. Self-contained: luajit tests/engine/cart_import_picker.lua +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("cart import picker") +local eq, check = S.eq, S.check + +local Platform = require("src.core.Platform") +local RomImporter = require("src.import.RomImporter") + +love.system = love.system or {} +local saved = { + getOS = love.system.getOS, + pickFile = love.system.pickFile, + pickFileKinds = love.system.pickFileKinds, + getPickedFile = love.system.getPickedFile, +} + +local picks = {} +local function androidImporter() + picks = {} + love.system.getOS = function() return "Android" end + love.system.pickFile = function(kind) + picks[#picks + 1] = kind + return true + end + love.system.pickFileKinds = function() + return "rom,mod,sav,cart,required_import" + end + Platform._resetForTests() + local imp = RomImporter.new(function() end, { launcher = true }) + imp._installCartFile = function(self, source) + self.installed = source + self._cartNotice = "Imported Picked One. It is in this list now." + return true + end + return imp +end + +local imp = androidImporter() +imp._cartPopup = "red" +eq(imp:importCartFile("red"), true, "Android Import .g1rcart opens the picker") +eq(picks[1], "cart", "and asks for the cart kind, not a ROM") +eq(imp.pickerPendingKind, "cart", "focus knows the staged file is a cart") + +love.filesystem.write("picked_cart.g1rcart", "cart-bytes") +imp:focus(true) +eq(imp.installed, "picked_cart.g1rcart", "focus installs the staged cart") +check(love.filesystem.getInfo("picked_cart.g1rcart") == nil, + "and retires the staged file") + +love.system.pickFileKinds = function() return "rom,mod,sav" end +Platform._resetForTests() +local legacy = RomImporter.new(function() end, { launcher = true }) +legacy._installCartFile = imp._installCartFile +picks = {} +eq(legacy:importCartFile("red"), false, + "an Android build that predates the cart kind does not open a ROM picker") +eq(#picks, 0, "and never calls pickFile") +check(type(legacy._cartNotice) == "string" and legacy._cartNotice:find(".g1rcart", 1, true), + "it tells the player to copy a .g1rcart instead") + +love.system.getOS = function() return "NX" end +love.system.pickFile = nil +Platform._resetForTests() +local nx = RomImporter.new(function() end, { launcher = true }) +nx._installCartFile = function(self, source) + self.installed = source + self._cartNotice = "Imported inbox." + return true +end +love.filesystem.write("imports/carts/inbox.g1rcart", "cart-bytes") +eq(nx:importCartFile("red"), true, "NX Import scans imports/carts/") +eq(nx.installed, "imports/carts/inbox.g1rcart", "and installs the inbox file") +love.filesystem.remove("imports/carts/inbox.g1rcart") + +nx.installed = nil +eq(nx:importCartFile("red"), false, "an empty NX inbox does not invent a cart") +check(nx._cartNotice:find("imports/carts/", 1, true) ~= nil, + "and the notice names the MTP folder") + +love.system.getOS = saved.getOS +love.system.pickFile = saved.pickFile +love.system.pickFileKinds = saved.pickFileKinds +love.system.getPickedFile = saved.getPickedFile +Platform._resetForTests() + +print("ok cart import picker") diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua index f9d256ac..40947e23 100644 --- a/tests/engine/cart_launcher.lua +++ b/tests/engine/cart_launcher.lua @@ -141,9 +141,9 @@ check(picker:find("Johto Lite", 1, true) == nil, check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version") check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state") check(picker:find("open", 1, true) ~= nil, "including an open one") -check(picker:find("Import a cart", 1, true) ~= nil - or picker:find("Get more carts", 1, true) ~= nil, - "the last row imports a cart, by picker or by folder") +check(picker:find("Import .g1rcart", 1, true) ~= nil + or picker:find("Scan again", 1, true) ~= nil, + "the last row imports a .g1rcart, by picker or by inbox scan") imp._cartPopup = nil local vanillaColors = drawColors(imp) @@ -1115,7 +1115,7 @@ for _, size in ipairs(SIZES) do auditFrame(("%dx%d picker"):format(W, H), "Export") for _, r in ipairs(Kit.audit or {}) do local label = tostring(r.label) - if label == "Get more carts" or label == "Close" then + if label == "Import .g1rcart" or label == "Scan again" or label == "Close" then check(r.y >= -0.5 and r.y + r.h <= H + 0.5, ("%dx%d picker: %q stays inside the window"):format(W, H, label)) end diff --git a/tests/engine/ios_required_import_picker_test.lua b/tests/engine/ios_required_import_picker_test.lua index db66b9c1..bb864c37 100644 --- a/tests/engine/ios_required_import_picker_test.lua +++ b/tests/engine/ios_required_import_picker_test.lua @@ -19,7 +19,10 @@ check(bridge:find('case "required_import":', 1, true) check(bridge:find("types.append(.data)", 1, true) and bridge:find("types.append(.item)", 1, true), "iOS required imports accept user-owned binary ROM files") -check(bridge:find('"rom,mod,sav,stadium,required_import"', 1, true), +check(bridge:find('case "cart":', 1, true) + and bridge:find('destName = "picked_cart.g1rcart"', 1, true), + "iOS routes cart files to their own staged filename") +check(bridge:find('"rom,mod,sav,cart,stadium,required_import"', 1, true), "iOS advertises required_import to Lua before opening the picker") local patch = read("mobile/ios/patch_love_src.py")