mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
Add UWP LocalState baserom discovery
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -255,6 +255,40 @@ build and install from source on a Mac instead, see
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user