diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94075d42..9c8d14c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,7 +199,7 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 fi - if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then + if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 8fa851d5..93fbb99e 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,40 @@ build and install from source on a Mac instead, see Download from GitHub +## Xbox Dev Mode + +Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series +consoles in Developer Mode. It cannot be installed in retail mode. + +Extract the archive, then use Xbox Device Portal to install the `.msix` and +the x64 package under `Dependencies`. + +### External setup + +1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive. + Mod ZIPs can go on the same drive. +2. Connect the drive to the Xbox and open Gen1Recomp. +3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox + file picker. +4. Repeat the ROM import for each version you want to use. + +### Internal setup + +1. Create a folder named `baseroms` on your PC and place your legally obtained + Red, Blue, or Yellow ROMs inside it. +2. ZIP the folder, keeping `baseroms` at the top level of the archive. +3. Launch Gen1Recomp once, then close it. +4. Open Xbox Device Portal and upload the ZIP to + `Gen1Recomp/LocalState/pokemon-love2d/`. +5. Choose **Yes** when Device Portal asks whether to extract the archive. +6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button. + +ROMs, generated game data, saves, and mods remain in LocalState and are not +included in the app. + +Source builds and package details are covered in +[the Xbox UWP build notes](ports/uwp/BUILD.md). + ## Handhelds A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD diff --git a/ports/uwp/BUILD.md b/ports/uwp/BUILD.md index 26e8faf6..40cddc14 100644 --- a/ports/uwp/BUILD.md +++ b/ports/uwp/BUILD.md @@ -113,6 +113,11 @@ picker copies user-selected files into LocalState and the launcher imports them from there. Saves, ROM cache and installed mods remain under the LÖVE save directory in LocalState. +The launcher also checks `LocalState\pokemon-love2d\baseroms` once at startup +for clean Red, Blue, and Yellow ROMs. Compatible files are offered on their +launcher tabs and remain in `baseroms` after import. The file picker remains +available when no compatible ROM is found. + LuaJIT requires the `codeGeneration` capability. `removableStorage` exposes external media to the Xbox picker. The network capabilities support relay play and direct hosting. The package does not request full trust or broad filesystem diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index c1732e6a..56125c9d 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -541,11 +541,15 @@ local function romModel(imp, version, info, ready, locked) label = Strings("Import unavailable"), enabled = false } end local dropHint = imp.isNX and Strings("Copy the .gb/.gbc via MTP into imports/.") - or (imp.android and Strings("Copy the .gb/.gbc via USB.") - or Strings("Or drop the .gb/.gbc file here.")) + or (imp.baseRomDiscovery and Strings("Or copy the .gb/.gbc into baseroms/.") + or (imp.android and Strings("Copy the .gb/.gbc via USB.") + or Strings("Or drop the .gb/.gbc file here."))) local importing = imp.importing == version local erroring = imp.workState == "error" and imp.errorVersion == version local notice = imp.notice and imp.notice.version == version and imp.notice + local baseRom = imp.baseRoms and imp.baseRoms[version] + local scanning = imp.baseRomDiscovery and imp.baseRomScan + and imp.baseRomScan.state ~= "done" if importing and (imp.workState == "working" or imp.workState == "complete") then return { state = imp.status or Strings("Importing"), detail = imp.detail or "", progress = imp.progress or 0 } @@ -564,6 +568,14 @@ local function romModel(imp, version, info, ready, locked) detail = ((notice.status or "") .. " " .. (notice.detail or "")) :gsub("^%s+", ""):gsub("%s+$", ""), label = importLabel, enabled = true } + elseif baseRom then + return { state = Strings("Compatible ROM found"), + detail = Strings("Found in baseroms/: %s", baseRom.name), + label = Strings("Import detected ROM"), enabled = true } + elseif scanning then + return { state = Strings("Checking baseroms..."), + detail = Strings("Looking for compatible Red, Blue, and Yellow ROMs."), + label = Strings("Import ROM"), enabled = false } elseif imp.returning[version] then return { state = Strings("Update required"), detail = Strings("This build needs a few more things from your ") @@ -922,6 +934,8 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading) local tagText, tagCol if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green + elseif imp.baseRoms and imp.baseRoms[version] then + tagText, tagCol = Strings("ROM FOUND"), PAL.green elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8b7c1df2..d9501764 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -341,6 +341,7 @@ local function commandOutput(command) end local IMPORTS_DIR = "imports" +local BASE_ROMS_DIR = "baseroms" local MODS_INBOX_DIR = "imports/mods" local SAVES_INBOX_DIR = "imports/saves" local ROM_BYTES = 1024 * 1024 @@ -474,6 +475,65 @@ local function listRomPaths(dir) return paths end +local function baseRomScanSatisfied(self) + for _, version in ipairs(GameVersion.ORDER) do + if not self.ready[version] and not self.baseRoms[version] then + return false + end + end + return true +end + +function RomImporter:_queueBaseRomScan() + if not self.baseRomDiscovery then return end + if baseRomScanSatisfied(self) then + self.baseRomScan = { state = "done" } + return + end + self.baseRomScan = { state = "queued", index = 1 } +end + +function RomImporter:_stepBaseRomScan() + local scan = self.baseRomScan + if not scan or scan.state == "done" or self.workState == "working" then + return + end + if scan.state == "queued" then + local info = love.filesystem.getInfo(BASE_ROMS_DIR) + if not info and love.filesystem.createDirectory then + love.filesystem.createDirectory(BASE_ROMS_DIR) + end + scan.paths = listRomPaths(BASE_ROMS_DIR) + table.sort(scan.paths) + scan.state = "running" + end + + local path = scan.paths[scan.index] + if not path then + scan.state = "done" + return + end + scan.index = scan.index + 1 + + local info = love.filesystem.getInfo(path, "file") + if info and info.size == ROM_BYTES then + local data = love.filesystem.read(path) + if type(data) == "string" and #data == ROM_BYTES then + local version = GameVersion.forSha1(sha1(data)) + if version and not self.ready[version] and not self.baseRoms[version] then + self.baseRoms[version] = { + path = path, + name = path:match("[^/\\]+$") or path, + } + end + end + end + + if baseRomScanSatisfied(self) or not scan.paths[scan.index] then + scan.state = "done" + end +end + local function listZipPaths(dir) local paths = {} for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do @@ -1050,6 +1110,9 @@ function RomImporter.new(onComplete, opts) android = android, ios = mobileOS == "iOS", nativePicker = romImportMode == "native-picker", + baseRomDiscovery = opts.launcher and Platform.isUWP(), + baseRoms = {}, + baseRomScan = nil, -- One startup poll pass on both mobiles. iOS: files dropped through the -- Files app are swept into the save dir before Lua boots (GRBootstrap) with -- no love.focus event necessarily following. Android: the SAF picker is a @@ -1149,6 +1212,7 @@ function RomImporter.new(onComplete, opts) .. (info.id == "yellow" and ".gbc" or ".gb") end self:_applyLastVersionTab() + self:_queueBaseRomScan() -- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a -- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped @@ -1755,6 +1819,21 @@ function RomImporter:choose(version) self:rescanAction(self.chooseVersion) return end + local baseRom = self.baseRomDiscovery and self.baseRoms[self.chooseVersion] + if baseRom then + self.baseRoms[self.chooseVersion] = nil + local data = love.filesystem.read(baseRom.path) + if not data then + self.notice = { + version = self.chooseVersion, + status = "The detected ROM is no longer available.", + detail = "Choose Import ROM to select it another way.", + } + return + end + self:startData(data, baseRom.name) + return + end if self.nativePicker and love.system.getPickedFile then self.pickerPendingKind = "rom" if not pickFile("rom") then @@ -1877,6 +1956,7 @@ end function RomImporter:update(dt) self.pulse = self.pulse + dt self:_updatePadCursor(dt) + self:_stepBaseRomScan() -- Pump the FlexLove view (input polling + the queued click actions). The -- flag is only set once draw() has built a tree, so headless runs and the -- test tier never touch the toolkit. @@ -2293,6 +2373,10 @@ function RomImporter:reimport(version) self.ready[version] = false self.returning[version] = false self.chooseVersion = version + if self.baseRomDiscovery then + self.baseRoms[version] = nil + self:_queueBaseRomScan() + end end local function clamp(v, lo, hi) diff --git a/tests/engine/uwp_baseroms_test.lua b/tests/engine/uwp_baseroms_test.lua new file mode 100644 index 00000000..65e03cf3 --- /dev/null +++ b/tests/engine/uwp_baseroms_test.lua @@ -0,0 +1,156 @@ +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("UWP baseroms discovery") +local check, eq = S.check, S.eq + +local GameVersion = require("src.core.GameVersion") +local Platform = require("src.core.Platform") +local RomImporter = require("src.import.RomImporter") + +love.data = love.data or {} +love.system = love.system or {} +love.filesystem = love.filesystem or {} + +local MiB = 1024 * 1024 +local roms = { + ["baseroms/z-red.gb"] = string.rep("R", MiB), + ["baseroms/a-blue.gb"] = string.rep("B", MiB), + ["baseroms/b-yellow.gbc"] = string.rep("Y", MiB), + ["baseroms/small.gb"] = "small", + ["baseroms/unknown.gb"] = string.rep("?", MiB), +} + +local saved = { + hash = love.data.hash, + encode = love.data.encode, + getOS = love.system.getOS, + pickFile = love.system.pickFile, + getPickedFile = love.system.getPickedFile, + read = love.filesystem.read, + getInfo = love.filesystem.getInfo, + getDirectoryItems = love.filesystem.getDirectoryItems, + createDirectory = love.filesystem.createDirectory, + isReady = RomImporter.isReady, +} + +love.data.hash = function(_, data) return data:sub(1, 1) end +love.data.encode = function(_, _, digest) + if digest == "R" then return GameVersion.info("red").sha1 end + if digest == "B" then return GameVersion.info("blue").sha1 end + if digest == "Y" then return GameVersion.info("yellow").sha1 end + return "0000000000000000000000000000000000000000" +end + +local reads, listings, picks = 0, 0, 0 +love.filesystem.read = function(path) + reads = reads + 1 + return roms[path] +end +love.filesystem.getInfo = function(path, filter) + if path == "baseroms" then + return filter and nil or { type = "directory" } + end + local data = roms[path] + if data and (not filter or filter == "file") then + return { type = "file", size = #data } + end + return nil +end +love.filesystem.getDirectoryItems = function(path) + if path ~= "baseroms" then return {} end + listings = listings + 1 + return { "z-red.gb", "small.gb", "unknown.gb", "b-yellow.gbc", "a-blue.gb" } +end +love.filesystem.createDirectory = function() return true end +love.system.pickFile = function() + picks = picks + 1 + return true +end +love.system.getPickedFile = function() return nil end + +local function importer(ready) + return setmetatable({ + baseRomDiscovery = true, + baseRoms = {}, + ready = ready or { red = false, blue = false, yellow = false }, + returning = {}, + workState = nil, + nativePicker = true, + isNX = false, + }, RomImporter) +end + +local allReady = importer({ red = true, blue = true, yellow = true }) +allReady:_queueBaseRomScan() +eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery") +eq(listings, 0, "ready launcher does not enumerate baseroms") + +local imp = importer() +imp:_queueBaseRomScan() +for _ = 1, 5 do + local before = reads + imp:_stepBaseRomScan() + check(reads - before <= 1, "discovery reads at most one ROM per step") +end +eq(listings, 1, "discovery enumerates baseroms once") +eq(imp.baseRoms.blue.name, "a-blue.gb", "Blue ROM is detected by SHA-1") +eq(imp.baseRoms.yellow.name, "b-yellow.gbc", "Yellow ROM is detected by SHA-1") +eq(imp.baseRoms.red.name, "z-red.gb", "Red ROM is detected by SHA-1") +eq(reads, 4, "wrong-sized files are skipped before reading") +eq(imp.baseRomScan.state, "done", "discovery stops when every missing ROM is found") + +local settledReads, settledListings = reads, listings +for _ = 1, 10 do imp:_stepBaseRomScan() end +eq(reads, settledReads, "completed discovery does not read again") +eq(listings, settledListings, "completed discovery does not enumerate again") + +local detected = importer() +detected.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" } +detected.startData = function(self, data, name) + self.started = { data = data, name = name } +end +detected:choose("red") +eq(detected.started.name, "z-red.gb", "detected ROM uses the normal import path") +eq(picks, 0, "detected ROM does not open the picker") +check(roms["baseroms/z-red.gb"] ~= nil, "detected ROM remains in baseroms") + +local missing = importer() +missing.baseRoms.red = { path = "baseroms/gone.gb", name = "gone.gb" } +missing:choose("red") +check(missing.notice ~= nil, "missing detected ROM reports a notice") +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 }) +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") +eq(rescanned.baseRomScan.state, "queued", "re-import queues one fresh scan") + +RomImporter.isReady = function() return false end +love.system.getOS = function() return "UWP" end +Platform._resetForTests() +local launcher = RomImporter.new(function() end, { launcher = true }) +check(launcher.baseRomDiscovery, "UWP launcher enables baseroms discovery") +local importOnly = RomImporter.new(function() end) +check(not importOnly.baseRomDiscovery, "non-launcher UWP import stays unchanged") +love.system.getOS = function() return "Windows" end +Platform._resetForTests() +local desktop = RomImporter.new(function() end, { launcher = true }) +check(not desktop.baseRomDiscovery, "desktop launcher does not scan baseroms") + +love.data.hash = saved.hash +love.data.encode = saved.encode +love.system.getOS = saved.getOS +love.system.pickFile = saved.pickFile +love.system.getPickedFile = saved.getPickedFile +love.filesystem.read = saved.read +love.filesystem.getInfo = saved.getInfo +love.filesystem.getDirectoryItems = saved.getDirectoryItems +love.filesystem.createDirectory = saved.createDirectory +RomImporter.isReady = saved.isReady +Platform._resetForTests() + +S.finish()