mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
Merge pull request #574 from andrewqsantos/feat/switch-nx
Nintendo Switch / love-nx support (#531)
This commit is contained in:
+68
-19
@@ -294,6 +294,24 @@ function CacheFs.read(rel)
|
||||
return love.filesystem.read(rel)
|
||||
end
|
||||
|
||||
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
|
||||
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
|
||||
-- already used: active version prefix with CacheFs.prefix cleared, then
|
||||
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
|
||||
function CacheFs.readActive(rel)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local prefix = GameVersion.cachePrefix()
|
||||
local saved = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
local bytes = CacheFs.read(prefix .. rel)
|
||||
CacheFs.prefix = saved
|
||||
if type(bytes) ~= "string" then
|
||||
bytes = CacheFs.read(rel)
|
||||
end
|
||||
if type(bytes) == "string" then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- does cache-relative `rel` exist as a file?
|
||||
function CacheFs.exists(rel)
|
||||
rel = withPrefix(rel)
|
||||
@@ -361,29 +379,60 @@ end
|
||||
|
||||
-- Overlay the active version's extracted cache onto the un-prefixed read
|
||||
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
||||
-- "assets/generated/*") resolve to that version's files. Red lives at the
|
||||
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
|
||||
-- *prepended* so they win over any Red copy at the root and over the game
|
||||
-- source. Called once at boot, before Game:load (main.lua). Returns true
|
||||
-- when nothing was needed or the mount succeeded.
|
||||
-- "assets/generated/*") resolve to that version's files.
|
||||
--
|
||||
-- Non-Red versions live under blue/ / yellow/ in the save directory. On
|
||||
-- desktop fused+portable we PHYSFS_mount that folder by absolute path. On
|
||||
-- NX (and any host without a working FFI mount) love.filesystem.mount of
|
||||
-- the save-dir-relative name must succeed, or Play boots with Red's paths
|
||||
-- and Data:load dies. Always also prepend-mount the version's
|
||||
-- data/generated + assets/generated onto the un-prefixed paths so PhysFS
|
||||
-- directory non-merge (archive data/ vs save generated) cannot hide them.
|
||||
local function mountGeneratedTrees(prefix)
|
||||
prefix = prefix or ""
|
||||
if not (love and love.filesystem and love.filesystem.mount) then
|
||||
return false
|
||||
end
|
||||
local mounted = false
|
||||
local pairs_ = {
|
||||
{ prefix .. "data/generated", "data/generated" },
|
||||
{ prefix .. "assets/generated", "assets/generated" },
|
||||
}
|
||||
for _, item in ipairs(pairs_) do
|
||||
local src, dest = item[1], item[2]
|
||||
if love.filesystem.getInfo(src, "directory") then
|
||||
if love.filesystem.mount(src, dest, false) then
|
||||
mounted = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return mounted
|
||||
end
|
||||
|
||||
function CacheFs.mountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end -- Red: already at the root
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
|
||||
-- The cache root is the portable game folder when active, else LÖVE's OS
|
||||
-- save directory (where love.filesystem wrote blue/... or yellow/...).
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
local sub = prefix:gsub("/+$", "")
|
||||
|
||||
-- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win.
|
||||
if sub ~= "" and love.filesystem.mount
|
||||
and love.filesystem.getInfo(sub, "directory") then
|
||||
love.filesystem.mount(sub, "", false)
|
||||
end
|
||||
if not base then return false end
|
||||
if mountReadable(base .. SEP .. sub, false) then return true end
|
||||
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder
|
||||
-- that lives in the save directory by name (prepended: appendToPath=false).
|
||||
if love.filesystem.mount then
|
||||
return love.filesystem.mount(sub, "", false)
|
||||
|
||||
-- Portable / desktop fused: absolute PHYSFS_mount of the version folder.
|
||||
if sub ~= "" then
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
end
|
||||
if base then
|
||||
mountReadable(base .. SEP .. sub, false)
|
||||
end
|
||||
end
|
||||
return false
|
||||
|
||||
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
|
||||
mountGeneratedTrees(prefix)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
|
||||
+53
-17
@@ -143,6 +143,30 @@ LauncherView._refreshAutoHeight = refreshAutoHeight
|
||||
|
||||
-- ------- lifecycle
|
||||
|
||||
-- NX-only: FlexLove's init maps `performanceMonitoring = false` to true
|
||||
-- (`false or true`), which leaves layout/render timers + memory sampling on
|
||||
-- every immediate-mode frame and makes the pad cursor feel lagged. Force
|
||||
-- them off after init. Desktop keeps the library default. Exported so the
|
||||
-- engine tier can assert the Switch guards without drawing the full tree.
|
||||
function LauncherView.applyNxPerfGuards(imp)
|
||||
if not (imp and imp.isNX and FlexLove.isReady() and FlexLove._Performance) then
|
||||
return false
|
||||
end
|
||||
FlexLove._Performance.enabled = false
|
||||
local mp = FlexLove._Performance._memoryProfiler
|
||||
if mp then mp.enabled = false end
|
||||
-- Immediate-mode rebuilds allocate a full tree every frame; the default
|
||||
-- auto GC steps hitch the pad cursor on Switch. Less frequent steps, higher
|
||||
-- threshold — desktop keeps FlexLove defaults.
|
||||
if FlexLove._gcConfig then
|
||||
FlexLove._gcConfig.strategy = "periodic"
|
||||
FlexLove._gcConfig.interval = 90
|
||||
FlexLove._gcConfig.stepSize = 40
|
||||
FlexLove._gcConfig.memoryThreshold = 180
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function ensureFlex(imp)
|
||||
if not FlexLove.isReady() then
|
||||
FlexLove.init({
|
||||
@@ -151,6 +175,9 @@ local function ensureFlex(imp)
|
||||
keyboardNavigation = false,
|
||||
})
|
||||
end
|
||||
-- Re-apply on every ensure: FlexLove may already be ready from a prior
|
||||
-- init (hot reload / editor round-trip). No-op when not NX.
|
||||
LauncherView.applyNxPerfGuards(imp)
|
||||
if not imp._flex then
|
||||
imp._flex = true
|
||||
imp._hot = imp._hot or {}
|
||||
@@ -169,7 +196,14 @@ end
|
||||
-- engine draws with raw love.graphics and must not share canvases or input
|
||||
-- polling with a live UI toolkit.
|
||||
function LauncherView.detach(imp)
|
||||
if not imp._flex then return end
|
||||
-- Restore the NX mouse shim even if _flex was never set (bridge can
|
||||
-- install on the first update before the first draw).
|
||||
if imp and imp.parkNxPointerForHost then
|
||||
pcall(imp.parkNxPointerForHost, imp)
|
||||
elseif imp and imp._restoreNxPointerBridge then
|
||||
pcall(imp._restoreNxPointerBridge, imp)
|
||||
end
|
||||
if not imp or not imp._flex then return end
|
||||
imp._flex = nil
|
||||
if love.keyboard and love.keyboard.setKeyRepeat then
|
||||
pcall(love.keyboard.setKeyRepeat, false)
|
||||
@@ -674,8 +708,10 @@ end
|
||||
-- ------- game panel
|
||||
|
||||
local function buildRomCard(imp, parent, m, version, info, ready, locked)
|
||||
local dropHint = imp.android and Strings("Copy the .gb/.gbc via USB.")
|
||||
or Strings("Or drop the .gb/.gbc file here.")
|
||||
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."))
|
||||
local importLabel = imp.isNX and Strings("Scan again") or Strings("Import ROM")
|
||||
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
||||
if locked then
|
||||
romState, romDetail = Strings("Not supported yet"),
|
||||
@@ -696,12 +732,12 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked)
|
||||
elseif erroring then
|
||||
romState = Strings("Import failed")
|
||||
romDetail = imp.detail or Strings("That ROM could not be imported.")
|
||||
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true
|
||||
romBtnLabel, romBtnEnabled = importLabel, true
|
||||
elseif notice then
|
||||
romState = Strings("No ROM imported")
|
||||
romDetail = ((notice.status or "") .. " " .. (notice.detail or ""))
|
||||
:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true
|
||||
romBtnLabel, romBtnEnabled = importLabel, true
|
||||
elseif imp.returning[version] then
|
||||
romState = Strings("Update required")
|
||||
romDetail = Strings("This build needs a few more things from your ")
|
||||
@@ -711,7 +747,7 @@ local function buildRomCard(imp, parent, m, version, info, ready, locked)
|
||||
romState = Strings("No ROM imported")
|
||||
romDetail = Strings("The ROM is verified before any files are created. ")
|
||||
.. dropHint
|
||||
romBtnLabel, romBtnEnabled = Strings("Import ROM"), true
|
||||
romBtnLabel, romBtnEnabled = importLabel, true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -750,13 +786,11 @@ local function buildSaveFilesCard(imp, parent, m, version, ready, locked)
|
||||
hintText, hintCol = sfNotice.text, (sfNotice.ok and "green" or "danger")
|
||||
elseif locked then
|
||||
hintText, hintCol = Strings("Not available yet."), "warn"
|
||||
elseif imp.android then
|
||||
hintText = Strings("Import or export a .sav with the system file picker.")
|
||||
hintCol = "warn"
|
||||
else
|
||||
hintText = Strings("Import a .sav to a new slot, or export the active slot.")
|
||||
hintText = imp:_savesDefaultHint(version)
|
||||
hintCol = "warn"
|
||||
end
|
||||
local savImportLabel = imp.isNX and Strings("Scan again") or Strings("Import save")
|
||||
|
||||
local c = card(parent, { padding = m.cardPad, gap = 8 * m.s })
|
||||
label(c, "SAVE FILES", 12 * m.s + 1, C("gray"))
|
||||
@@ -765,7 +799,7 @@ local function buildSaveFilesCard(imp, parent, m, version, ready, locked)
|
||||
-- explicit halves rather than flex growth, which mis-distributed inside
|
||||
-- an auto-height card
|
||||
local halfW = math.floor((m.colW - 32 - 10 * m.s) / 2)
|
||||
button(imp, row, "sav-import-" .. version, Strings("Import save"), {
|
||||
button(imp, row, "sav-import-" .. version, savImportLabel, {
|
||||
w = halfW, h = m.btnH, size = 13 * m.s + 1,
|
||||
kind = sfImportEnabled and "neutral" or "disabled",
|
||||
action = sfImportEnabled and function()
|
||||
@@ -1001,7 +1035,7 @@ local function buildModsPanel(imp, parent, m)
|
||||
action = function() imp:_setAllMods(false) end,
|
||||
})
|
||||
end
|
||||
button(imp, head, "mods-import", Strings("Import mod .zip"), {
|
||||
button(imp, head, "mods-import", imp:_modsImportButtonLabel(), {
|
||||
h = m.btnH, size = 13 * m.s + 1, kind = "neutral",
|
||||
action = function() imp:chooseMod() end,
|
||||
})
|
||||
@@ -1010,8 +1044,7 @@ local function buildModsPanel(imp, parent, m)
|
||||
label(parent, imp.modNotice.text, 12 * m.s + 2,
|
||||
C(imp.modNotice.ok and "green" or "danger"))
|
||||
else
|
||||
label(parent, imp.android and Strings("Or copy a mod .zip via USB.")
|
||||
or Strings("Or drop a mod .zip onto the window."), 12 * m.s + 2, C("warn"))
|
||||
label(parent, imp:_modsDefaultHint(), 12 * m.s + 2, C("warn"))
|
||||
end
|
||||
|
||||
if #mods == 0 then
|
||||
@@ -1022,9 +1055,7 @@ local function buildModsPanel(imp, parent, m)
|
||||
positioning = "flex", justifyContent = "center", alignItems = "center",
|
||||
padding = { horizontal = 16 },
|
||||
})
|
||||
label(box, imp.android
|
||||
and Strings("No mods installed - tap Import mod .zip to add one.")
|
||||
or Strings("No mods installed - drop a mod .zip here to add one."),
|
||||
label(box, imp:_modsEmptyHint(),
|
||||
math.floor(12 * m.s + 2.5), C("detail"), { textAlign = "center" })
|
||||
return
|
||||
end
|
||||
@@ -1814,7 +1845,12 @@ end
|
||||
|
||||
local function drawPadCursor(imp)
|
||||
if not imp._padCursorActive then return end
|
||||
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
|
||||
-- framebuffer when the stick advances by fractional pixels each frame.
|
||||
local x, y = imp._padCursor.x, imp._padCursor.y
|
||||
if imp.isNX then
|
||||
x, y = math.floor(x + 0.5), math.floor(y + 0.5)
|
||||
end
|
||||
love.graphics.push("all")
|
||||
love.graphics.origin()
|
||||
love.graphics.setLineWidth(1)
|
||||
|
||||
@@ -2023,21 +2023,23 @@ end
|
||||
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
|
||||
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
|
||||
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
|
||||
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
|
||||
-- 16-bit stereo WAVs (identical L/R) so OpenAL never spatializes them as
|
||||
-- ambient surround (#626); returns the clip count for data.audio.pikaCries,
|
||||
-- or nil when the manifest has no pointer table (Red/Blue).
|
||||
function RomExtractor:extractPikachuCries()
|
||||
if not self.symbols["PikachuCriesPointerTable"] then return nil end
|
||||
local NUM = 42 -- NUM_PIKA_CRIES
|
||||
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
|
||||
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
-- byte -> 8 mono sample levels, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
-- levels match the old unsigned-8 WAV (on=0xE0, off=0x20) as floats in [-1,1]
|
||||
local lut = {}
|
||||
for byte = 0, 255 do
|
||||
local out = {}
|
||||
for bit = 7, 0, -1 do
|
||||
local on = math.floor(byte / 2 ^ bit) % 2 == 1
|
||||
out[#out + 1] = string.char(on and 0xE0 or 0x20)
|
||||
out[#out + 1] = on and ((0xE0 - 128) / 128) or ((0x20 - 128) / 128)
|
||||
end
|
||||
lut[byte] = table.concat(out)
|
||||
lut[byte] = out
|
||||
end
|
||||
local function u16(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
@@ -2046,6 +2048,12 @@ function RomExtractor:extractPikachuCries()
|
||||
return string.char(v % 256, math.floor(v / 256) % 256,
|
||||
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||
end
|
||||
local function i16le(f)
|
||||
local v = math.floor(f * 32767 + (f >= 0 and 0.5 or -0.5))
|
||||
if v > 32767 then v = 32767 elseif v < -32768 then v = -32768 end
|
||||
if v < 0 then v = v + 65536 end
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
end
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local pointers = self:symbol("PikachuCriesPointerTable")
|
||||
for index = 0, NUM - 1 do
|
||||
@@ -2054,11 +2062,16 @@ function RomExtractor:extractPikachuCries()
|
||||
local header = self.rom:bytes(bank, address, 2)
|
||||
local length = header[1] + header[2] * 256
|
||||
local raw = self.rom:bytes(bank, address + 2, length)
|
||||
local samples = {}
|
||||
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
|
||||
local pcm = table.concat(samples)
|
||||
local parts = {}
|
||||
for _, byte in ipairs(raw) do
|
||||
for _, level in ipairs(lut[byte]) do
|
||||
local s = i16le(level)
|
||||
parts[#parts + 1] = s .. s -- identical L/R (#626)
|
||||
end
|
||||
end
|
||||
local pcm = table.concat(parts)
|
||||
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
|
||||
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
|
||||
.. u16(1) .. u16(2) .. u32(RATE) .. u32(RATE * 4) .. u16(4) .. u16(16)
|
||||
.. "data" .. u32(#pcm) .. pcm
|
||||
local ok, err = CacheFs.write(
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
|
||||
|
||||
+641
-42
@@ -1,6 +1,8 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local Platform = require("src.core.Platform")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local RomImporter = {}
|
||||
@@ -328,6 +330,7 @@ local function releasePointerGrab()
|
||||
end
|
||||
|
||||
local function commandOutput(command)
|
||||
if not Platform.canSpawnProcess() then return nil end
|
||||
releasePointerGrab()
|
||||
local pipe = HostShell.popen(command)
|
||||
if not pipe then return nil end
|
||||
@@ -337,6 +340,417 @@ local function commandOutput(command)
|
||||
return result ~= "" and result or nil
|
||||
end
|
||||
|
||||
local IMPORTS_DIR = "imports"
|
||||
local MODS_INBOX_DIR = "imports/mods"
|
||||
local SAVES_INBOX_DIR = "imports/saves"
|
||||
local ROM_BYTES = 1024 * 1024
|
||||
|
||||
local function savesInboxDir(version)
|
||||
return SAVES_INBOX_DIR .. "/" .. tostring(version)
|
||||
end
|
||||
|
||||
local function savesImportedHashesPath(version)
|
||||
return savesInboxDir(version) .. "/.imported-sha1"
|
||||
end
|
||||
|
||||
local function exportsDir(version)
|
||||
return "exports/" .. tostring(version)
|
||||
end
|
||||
|
||||
-- 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
|
||||
|
||||
-- NX mod zip inbox (separate from ROM imports/). Parent imports/ first —
|
||||
-- love.filesystem.createDirectory does not create nested parents.
|
||||
function RomImporter:ensureModsInboxDir()
|
||||
self:ensureImportsDir()
|
||||
local info = love.filesystem.getInfo(MODS_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(MODS_INBOX_DIR)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- NX raw .sav inbox per game: imports/saves/{red,blue,yellow}/.
|
||||
-- Parent imports/ then imports/saves/ first — createDirectory is not nested.
|
||||
-- Creates all three version folders so MTP browsing shows where each game goes.
|
||||
function RomImporter:ensureSavesInboxDir(version)
|
||||
self:ensureImportsDir()
|
||||
local info = love.filesystem.getInfo(SAVES_INBOX_DIR)
|
||||
if info and info.type ~= "directory" then return false end
|
||||
if not info then
|
||||
if not (love.filesystem.createDirectory
|
||||
and love.filesystem.createDirectory(SAVES_INBOX_DIR)) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
for v in pairs(GameVersion.VERSIONS) do
|
||||
local dir = savesInboxDir(v)
|
||||
local vInfo = love.filesystem.getInfo(dir)
|
||||
if vInfo and vInfo.type ~= "directory" then return false end
|
||||
if not vInfo then
|
||||
if not (love.filesystem.createDirectory
|
||||
and love.filesystem.createDirectory(dir)) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
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
|
||||
|
||||
function RomImporter:_setNxModsInboxNotice()
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
self.modNotice = {
|
||||
ok = true,
|
||||
text = Strings("Copy your .zip into:\n%s/imports/mods/\nDBI MTP → 1: SD Card/%simports/mods/",
|
||||
saveDir, rel),
|
||||
}
|
||||
end
|
||||
|
||||
function RomImporter:_resolveSaveVersion(version)
|
||||
version = version or self.panelVersion or self.tab
|
||||
if GameVersion.VERSIONS[version] then return version end
|
||||
return self:_savedropTarget()
|
||||
end
|
||||
|
||||
function RomImporter:_setNxSavesInboxNotice(version)
|
||||
version = self:_resolveSaveVersion(version)
|
||||
local inbox = savesInboxDir(version)
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
local game = GameVersion.info(version).displayName
|
||||
self.saveNotice = self.saveNotice or {}
|
||||
self.saveNotice[version] = {
|
||||
ok = true,
|
||||
text = Strings("Copy your %s .sav into:\n%s/%s/\nDBI MTP → 1: SD Card/%s%s/",
|
||||
game, saveDir, inbox, rel, inbox),
|
||||
}
|
||||
end
|
||||
|
||||
local function listRomPaths(dir)
|
||||
local paths = {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
-- Skip AppleDouble / hidden junk from Mac MTP (._cart.gb ends in .gb
|
||||
-- but is not a ROM — rescan would try it first and block the real dump).
|
||||
if name:sub(1, 1) ~= "." then
|
||||
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
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
local function listZipPaths(dir)
|
||||
local paths = {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
-- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip
|
||||
-- but is not a PhysFS archive — mount fails with "could not be opened").
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
|
||||
if name:lower():match("%.zip$")
|
||||
and love.filesystem.getInfo(path, "file") then
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
local function listSavPaths(dir)
|
||||
local paths = {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
-- Skip AppleDouble / hidden junk from Mac MTP (._foo.sav ends in .sav
|
||||
-- but is not a real battery save — import would fail and invent noise).
|
||||
if name:sub(1, 1) ~= "." then
|
||||
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
|
||||
if name:lower():match("%.sav$")
|
||||
and love.filesystem.getInfo(path, "file") then
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
function RomImporter:scanInbox()
|
||||
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
|
||||
|
||||
-- NX mods inbox: only *.zip under imports/mods/ (never ROM extensions).
|
||||
function RomImporter:scanModsInbox()
|
||||
self:ensureModsInboxDir()
|
||||
return listZipPaths(MODS_INBOX_DIR)
|
||||
end
|
||||
|
||||
-- NX saves inbox: only non-hidden *.sav under imports/saves/<version>/.
|
||||
function RomImporter:scanSavesInbox(version)
|
||||
version = self:_resolveSaveVersion(version)
|
||||
self:ensureSavesInboxDir(version)
|
||||
return listSavPaths(savesInboxDir(version))
|
||||
end
|
||||
|
||||
local function loadImportedSavHashes(version)
|
||||
local set = {}
|
||||
local raw = love.filesystem.read(savesImportedHashesPath(version))
|
||||
if type(raw) ~= "string" then return set end
|
||||
for line in raw:gmatch("[^\r\n]+") do
|
||||
local h = line:match("^(%x+)$")
|
||||
if h then set[h] = true end
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
local function appendImportedSavHash(version, hash)
|
||||
if type(hash) ~= "string" or hash == "" then return end
|
||||
local path = savesImportedHashesPath(version)
|
||||
local prev = love.filesystem.read(path) or ""
|
||||
if prev:find(hash, 1, true) then return end
|
||||
love.filesystem.write(path, prev .. hash .. string.char(10))
|
||||
end
|
||||
|
||||
-- Keep bytes for the player (MTP recovery) but stop matching %.sav$ on rescan.
|
||||
local function retireImportedSav(path)
|
||||
if type(path) ~= "string" or path == "" then return false end
|
||||
local data = love.filesystem.read(path)
|
||||
if type(data) ~= "string" then return false end
|
||||
local dest = path .. ".imported"
|
||||
if love.filesystem.getInfo(dest) then
|
||||
dest = path .. ".imported." .. tostring(os.time())
|
||||
end
|
||||
if not love.filesystem.write(dest, data) then return false end
|
||||
love.filesystem.remove(path)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Rescan imports/mods/: install each .zip via _installMod / installZip.
|
||||
-- Never deletes inbox zips (success or failure). Empty inbox → MTP notice.
|
||||
function RomImporter:rescanModsAction()
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "mods"
|
||||
self:ensureModsInboxDir()
|
||||
local candidates = self:scanModsInbox()
|
||||
if #candidates == 0 then
|
||||
self:_setNxModsInboxNotice()
|
||||
return
|
||||
end
|
||||
local anyOk = false
|
||||
local lastOk = nil
|
||||
local lastFail = nil
|
||||
local failCount = 0
|
||||
for _, path in ipairs(candidates) do
|
||||
-- Reuse _installMod carefully: it must not remove the inbox source.
|
||||
self:_installMod(path)
|
||||
if self.modNotice and self.modNotice.ok then
|
||||
anyOk = true
|
||||
lastOk = self.modNotice
|
||||
else
|
||||
failCount = failCount + 1
|
||||
lastFail = self.modNotice
|
||||
end
|
||||
end
|
||||
-- Success wins overall ok=true so a leftover MTP junk sibling cannot hide
|
||||
-- a good install; still append the last failure so a real broken zip is
|
||||
-- visible beside the success line.
|
||||
if anyOk and lastFail then
|
||||
local okText = (lastOk and lastOk.text) or "Installed"
|
||||
local failText = (lastFail and lastFail.text) or "unknown error"
|
||||
self.modNotice = {
|
||||
ok = true,
|
||||
text = Strings("%s\n(%d failed: %s)", okText, failCount, failText),
|
||||
}
|
||||
elseif anyOk then
|
||||
self.modNotice = lastOk
|
||||
elseif lastFail then
|
||||
self.modNotice = lastFail
|
||||
end
|
||||
end
|
||||
|
||||
-- Rescan imports/saves/<version>/: import each new .sav via _importSave.
|
||||
-- Failure retains the original .sav. Success records a per-game content hash
|
||||
-- and retires the file to `*.sav.imported` so a second Import save cannot clone
|
||||
-- slots (bytes stay in the inbox for MTP recovery). Already-hashed content
|
||||
-- is skipped even under a new filename. Empty / AppleDouble-only → MTP notice.
|
||||
function RomImporter:rescanSavesAction(version)
|
||||
if self.workState == "working" then return end
|
||||
version = self:_resolveSaveVersion(version)
|
||||
self:ensureSavesInboxDir(version)
|
||||
local candidates = self:scanSavesInbox(version)
|
||||
if #candidates == 0 then
|
||||
self:_setNxSavesInboxNotice(version)
|
||||
return
|
||||
end
|
||||
local seenHashes = loadImportedSavHashes(version)
|
||||
local okCount, failCount, skipCount = 0, 0, 0
|
||||
local lastOk, lastFail = nil, nil
|
||||
local gameLabel = GameVersion.info(version).displayName
|
||||
for _, path in ipairs(candidates) do
|
||||
local data = love.filesystem.read(path)
|
||||
local hash = (type(data) == "string" and data ~= "") and sha1(data) or nil
|
||||
if hash and seenHashes[hash] then
|
||||
skipCount = skipCount + 1
|
||||
-- Leftover live .sav after a prior success: retire without re-importing.
|
||||
retireImportedSav(path)
|
||||
else
|
||||
self:_importSave(version, path)
|
||||
local notice = self.saveNotice and self.saveNotice[version]
|
||||
if notice and notice.ok then
|
||||
okCount = okCount + 1
|
||||
lastOk = notice
|
||||
if hash then
|
||||
seenHashes[hash] = true
|
||||
appendImportedSavHash(version, hash)
|
||||
end
|
||||
retireImportedSav(path)
|
||||
else
|
||||
failCount = failCount + 1
|
||||
lastFail = notice
|
||||
end
|
||||
end
|
||||
end
|
||||
if okCount > 0 then
|
||||
local okText
|
||||
if okCount == 1 and lastOk then
|
||||
okText = Strings("%s (%s tab)", lastOk.text, gameLabel)
|
||||
else
|
||||
okText = Strings("Imported %d saves into %s. Active: %s.",
|
||||
okCount, gameLabel, tostring(self.activeSlot[version]))
|
||||
end
|
||||
if failCount > 0 then
|
||||
local failText = (lastFail and lastFail.text) or "unknown error"
|
||||
okText = Strings("%s\n(%d failed: %s)", okText, failCount, failText)
|
||||
end
|
||||
if skipCount > 0 then
|
||||
okText = Strings("%s\n(%d already imported, skipped)", okText, skipCount)
|
||||
end
|
||||
self.saveNotice[version] = { ok = true, text = okText }
|
||||
elseif failCount > 0 then
|
||||
self.saveNotice[version] = lastFail
|
||||
elseif skipCount > 0 then
|
||||
self.saveNotice[version] = {
|
||||
ok = true,
|
||||
text = Strings("Already imported — %d file(s) skipped. Check SAVE SLOT.",
|
||||
skipCount),
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- NX "Scan again" on a game tab: import only the dump whose SHA-1 matches
|
||||
-- that tab. A shared imports/ inbox often holds Red+Blue+Yellow at once;
|
||||
-- picking the first pending file would jump Yellow → Red (and switch the
|
||||
-- launcher tab via startData). Other known dumps stay for their own tabs.
|
||||
-- Junk (wrong size / unknown hash) still surfaces when nothing matches the
|
||||
-- tab and no other known dump is present — same feedback as before for a
|
||||
-- lone bad file.
|
||||
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()
|
||||
local targetReady = false
|
||||
local sawOtherVersion = false
|
||||
local junkData, junkName = nil, nil
|
||||
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
|
||||
if not junkData then junkData, junkName = data, displayName end
|
||||
else
|
||||
local romVersion = GameVersion.forSha1(sha1(data))
|
||||
if not romVersion then
|
||||
if not junkData then junkData, junkName = data, displayName end
|
||||
elseif romVersion ~= version then
|
||||
sawOtherVersion = true
|
||||
elseif ready[romVersion] then
|
||||
targetReady = true
|
||||
else
|
||||
self:startData(data, displayName)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
if targetReady 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
|
||||
if junkData and not sawOtherVersion then
|
||||
self:startData(junkData, junkName)
|
||||
return
|
||||
end
|
||||
if #candidates > 0 then
|
||||
local label = GameVersion.info(version).displayName
|
||||
self.notice = {
|
||||
version = version,
|
||||
status = Strings("No matching ROM found."),
|
||||
detail = Strings(
|
||||
"%s is matched by SHA-1 on this tab. Other dumps in imports/ stay "
|
||||
.. "for their own tabs — open that game and Scan again.", label),
|
||||
}
|
||||
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
|
||||
|
||||
-- Sanitize a string before it is interpolated into a picker shell command:
|
||||
-- * "%" would be eaten as a string.format directive (#665);
|
||||
-- * '"' would break the AppleScript / zenity double-quoted argument and
|
||||
@@ -572,6 +986,7 @@ end
|
||||
-- import-only run all skip the release check so headless and CI runs never spin
|
||||
-- up the background worker or reach out to the network.
|
||||
local function updaterAllowed()
|
||||
if not Platform.networkValidated() then return false end
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end
|
||||
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
||||
@@ -596,8 +1011,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,
|
||||
@@ -605,6 +1025,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
|
||||
@@ -616,11 +1039,11 @@ 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,
|
||||
-- Mobile drag-scroll goes through FlexLove.touch* (main.lua forwards the
|
||||
-- full touch stream while the launcher is up). love.touch remains pollable
|
||||
-- for click hit-testing inside EventHandler.
|
||||
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"),
|
||||
@@ -702,7 +1125,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)
|
||||
@@ -711,6 +1134,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)
|
||||
@@ -740,13 +1166,16 @@ function RomImporter.new(onComplete, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- On Linux handhelds a gamepad is usually already connected at boot; arm
|
||||
-- the virtual cursor immediately so the player does not have to press a
|
||||
-- button before seeing something move.
|
||||
if self.launcher and love.system.getOS() == "Linux"
|
||||
and love.joystick and love.joystick.getJoystickCount
|
||||
-- On Linux handhelds / NX a gamepad is usually already connected at boot;
|
||||
-- arm the virtual cursor immediately so the player does not have to press a
|
||||
-- button before seeing something move. Desktop keeps the cursor latent
|
||||
-- until the first stick bump so a plugged DualSense does not steal the mouse.
|
||||
if self.launcher and love.joystick and love.joystick.getJoystickCount
|
||||
and love.joystick.getJoystickCount() > 0 then
|
||||
self:_activatePadCursor()
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
if osName == "Linux" or self.isNX then
|
||||
self:_activatePadCursor()
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
@@ -940,7 +1369,7 @@ 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
|
||||
@@ -948,7 +1377,14 @@ function RomImporter:startData(data, displayName)
|
||||
self.workState = "complete"
|
||||
self.completeVersion = version
|
||||
self.status = "Ready"
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
-- NX launcher stays put: keep the imports/ cleanup hint instead of
|
||||
-- overwriting it with a "Starting…" line that never boots from here.
|
||||
if self.launcher and self.isNX and type(displayName) == "string" then
|
||||
self.detail = Strings("%s imported. You may delete the copy from "
|
||||
.. "imports/ when finished.", displayName)
|
||||
else
|
||||
self.detail = "Starting " .. info.displayName .. "..."
|
||||
end
|
||||
self.progress = 1
|
||||
if self.launcher then
|
||||
-- Stay on the launcher; the player presses Play to boot the new game.
|
||||
@@ -1045,8 +1481,14 @@ end
|
||||
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
|
||||
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
|
||||
-- which focus/Choose consumes on return.
|
||||
-- NX: no HostShell/desktop picker — rescan imports/mods/ inbox instead.
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self.isNX then
|
||||
self:ensureModsInboxDir()
|
||||
self:rescanModsAction()
|
||||
return
|
||||
end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "mod"
|
||||
if not pickFile("mod") then
|
||||
@@ -1114,8 +1556,15 @@ end
|
||||
|
||||
-- "Import save" button: open a native .sav picker and import the pick.
|
||||
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
|
||||
-- NX: no HostShell/desktop picker — rescan imports/saves/ inbox instead.
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
version = self:_resolveSaveVersion(version)
|
||||
if self.isNX then
|
||||
self:ensureSavesInboxDir(version)
|
||||
self:rescanSavesAction(version)
|
||||
return
|
||||
end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "sav"
|
||||
self.iosPendingVersion = version
|
||||
@@ -1155,15 +1604,29 @@ end
|
||||
-- affordance. On Android, stage pending_export.sav and open the system
|
||||
-- create-document picker (love.system.createFile) so the player can save to
|
||||
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
|
||||
-- NX: surface exports path + MTP hint; do not rely on openURL / open-folder.
|
||||
function RomImporter:exportSave(version)
|
||||
if self.workState == "working" then return end
|
||||
version = self:_resolveSaveVersion(version)
|
||||
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
|
||||
if not ok then
|
||||
self.saveNotice[version] = { ok = false, text = tostring(res) }
|
||||
return
|
||||
end
|
||||
if self.isNX then
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
local outDir = exportsDir(version)
|
||||
self.saveNotice[version] = {
|
||||
ok = true,
|
||||
text = Strings("Exported to %s\nDBI MTP → 1: SD Card/%s%s/", res, rel, outDir),
|
||||
}
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local rel = res:match("exports[/\\][^/\\]+$")
|
||||
local rel = res:match("(exports[/\\].+%.[Ss][Aa][Vv])$")
|
||||
or res:match("(exports[/\\].+)$")
|
||||
local data = rel and love.filesystem.read(rel)
|
||||
if not data then
|
||||
self.saveNotice[version] = { ok = false,
|
||||
@@ -1215,6 +1678,11 @@ end
|
||||
function RomImporter:choose(version)
|
||||
if self.workState == "working" then return end
|
||||
self.chooseVersion = version or "red"
|
||||
if self.isNX then
|
||||
-- Same path as the Scan again button: rescan imports/ (or show MTP hint).
|
||||
self:rescanAction(self.chooseVersion)
|
||||
return
|
||||
end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "rom"
|
||||
if not pickFile("rom") then
|
||||
@@ -1469,6 +1937,71 @@ function RomImporter:_activatePadCursor()
|
||||
self._padCursorActive = true
|
||||
end
|
||||
|
||||
-- NX: FlexLove hover/hit-test polls love.mouse.getPosition every interactive
|
||||
-- element. Warping via setPosition every stick frame is expensive on love-nx
|
||||
-- and makes the virtual cursor lag. Expose the pad pointer through a getPosition
|
||||
-- shim instead; desktop keeps the setPosition path unchanged.
|
||||
function RomImporter:_ensureNxPointerBridge()
|
||||
if not self.isNX or self._nxPointerBridge then return end
|
||||
if not (love and love.mouse and love.mouse.getPosition) then return end
|
||||
self._nxRealGetPosition = love.mouse.getPosition
|
||||
local importer = self
|
||||
love.mouse.getPosition = function()
|
||||
if importer._padCursorActive then
|
||||
return importer._padCursor.x, importer._padCursor.y
|
||||
end
|
||||
return importer._nxRealGetPosition()
|
||||
end
|
||||
self._nxPointerBridge = true
|
||||
end
|
||||
|
||||
function RomImporter:_restoreNxPointerBridge()
|
||||
if not self._nxPointerBridge then return end
|
||||
if love and love.mouse and self._nxRealGetPosition then
|
||||
love.mouse.getPosition = self._nxRealGetPosition
|
||||
end
|
||||
self._nxPointerBridge = false
|
||||
self._nxRealGetPosition = nil
|
||||
end
|
||||
|
||||
-- NX only: drop the getPosition shim + hide the virtual cursor before a host
|
||||
-- takes over input (embedded save editor). Desktop is a no-op.
|
||||
function RomImporter:parkNxPointerForHost()
|
||||
if not self.isNX then return end
|
||||
self._padCursorActive = false
|
||||
self:_restoreNxPointerBridge()
|
||||
end
|
||||
|
||||
-- Temporary overlay handoff (Edit Save / Touch Controls): restore the system
|
||||
-- arrow cursor, hide the virtual pad pointer, tear down FlexLove when the
|
||||
-- view is already loaded, and drop the NX getPosition shim. Play uses
|
||||
-- resetPointerCursor + detach directly because it never returns here.
|
||||
function RomImporter:prepareOverlayHandoff()
|
||||
resetPointerCursor(self)
|
||||
self._padCursorActive = false
|
||||
-- Avoid requiring LauncherView from headless unit tests (no luautf8). In
|
||||
-- a real session draw() has already loaded it, so detach runs normally.
|
||||
if self._flex and package.loaded["src.import.LauncherView"] then
|
||||
require("src.import.LauncherView").detach(self)
|
||||
else
|
||||
self._flex = nil
|
||||
self:parkNxPointerForHost()
|
||||
end
|
||||
end
|
||||
|
||||
-- After an overlay closes: re-arm the pad cursor when a stick is already
|
||||
-- connected so NX / handhelds are not stranded without a pointer until the
|
||||
-- next stick bump (same class of bug as opening Touch Controls).
|
||||
function RomImporter:resumeAfterOverlay()
|
||||
if not self.launcher then return end
|
||||
if not (love.joystick and love.joystick.getJoystickCount) then return end
|
||||
if love.joystick.getJoystickCount() <= 0 then return end
|
||||
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
|
||||
if osName == "Linux" or self.isNX then
|
||||
self:_activatePadCursor()
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "mods", "find" }
|
||||
local idx = 1
|
||||
@@ -1479,15 +2012,26 @@ function RomImporter:_cycleTab(delta)
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
||||
-- pointer after bumping a stick once.
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if self._lastMouseX and self._padCursorActive then
|
||||
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
||||
self._padCursorActive = false
|
||||
end
|
||||
if self.isNX then
|
||||
self:_ensureNxPointerBridge()
|
||||
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
|
||||
-- the cursor; desktop keeps raw dt (setPosition path already smooth there).
|
||||
if dt > 1 / 30 then dt = 1 / 30 end
|
||||
end
|
||||
|
||||
-- Real mouse motion yields the pad cursor so desktop users keep a normal
|
||||
-- pointer after bumping a stick once. On NX this must stay off: love-nx /
|
||||
-- SDL often drifts the system mouse with the stick (or touch), and axis
|
||||
-- events are not every frame, so yield+reactivate flickers the overlay.
|
||||
if not self.isNX then
|
||||
local mx, my = love.mouse.getPosition()
|
||||
if self._lastMouseX and self._padCursorActive then
|
||||
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
|
||||
self._padCursorActive = false
|
||||
end
|
||||
end
|
||||
self._lastMouseX, self._lastMouseY = mx, my
|
||||
end
|
||||
self._lastMouseX, self._lastMouseY = mx, my
|
||||
|
||||
local ax = self._padAxis.leftx or 0
|
||||
local ay = self._padAxis.lefty or 0
|
||||
@@ -1510,11 +2054,9 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- The FlexLove view polls the real mouse for hover and wheel targeting,
|
||||
-- so the pad pointer warps it along. The self-caused motion is recorded
|
||||
-- as the last seen position, or the yield check above would read the warp
|
||||
-- as real mouse movement and drop the pad cursor immediately.
|
||||
if love.mouse.setPosition then
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords — skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
|
||||
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
|
||||
end
|
||||
@@ -1532,7 +2074,9 @@ end
|
||||
|
||||
function RomImporter:gamepadpressed(_, button)
|
||||
self:_activatePadCursor()
|
||||
if button == "a" then
|
||||
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
|
||||
local action = GamepadMap.mapGamepadButton(button)
|
||||
if action == "a" then
|
||||
-- Instant click at the virtual pointer: dispatched straight into the
|
||||
-- view, since the launcher no longer hit-tests presses itself.
|
||||
if self._flex then
|
||||
@@ -1551,7 +2095,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
|
||||
@@ -1570,25 +2114,23 @@ function RomImporter:gamepadaxis(_, axis, value)
|
||||
end
|
||||
end
|
||||
|
||||
-- Same gate as src/core/Input.lua's isMappedPad: 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).
|
||||
local function isMappedPad(joystick)
|
||||
return joystick ~= nil and joystick.isGamepad ~= nil and joystick:isGamepad()
|
||||
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 isMappedPad(joystick) then return end
|
||||
if button == 1 then self:gamepadpressed(joystick, "a") end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then self:gamepadpressed(joystick, padButton) end
|
||||
end
|
||||
|
||||
function RomImporter:joystickreleased(joystick, button)
|
||||
if isMappedPad(joystick) then return end
|
||||
if button == 1 then self:gamepadreleased(joystick, "a") end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||
if padButton then self:gamepadreleased(joystick, padButton) end
|
||||
end
|
||||
|
||||
function RomImporter:joystickaxis(joystick, axis, value)
|
||||
if isMappedPad(joystick) then return end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
if axis == 1 then
|
||||
self:gamepadaxis(joystick, "leftx", value)
|
||||
elseif axis == 2 then
|
||||
@@ -1597,7 +2139,7 @@ function RomImporter:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function RomImporter:joystickhat(joystick, hat, direction)
|
||||
if isMappedPad(joystick) then return end
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
|
||||
self._padDir[dir] = nil
|
||||
end
|
||||
@@ -1844,7 +2386,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
|
||||
@@ -2152,6 +2694,11 @@ end
|
||||
-- Update button: when a newer release is known, confirm then install; when
|
||||
-- already current, force-refresh the 6h cache and report / offer update.
|
||||
function RomImporter:_modGithubAction(id, action)
|
||||
if not Platform.networkValidated() then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Remote mod download is unavailable on this platform." }
|
||||
return
|
||||
end
|
||||
local ran, err = pcall(function()
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local row
|
||||
@@ -2291,6 +2838,53 @@ function RomImporter:_installModVersion(modId, release)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- NX / desktop / Android labels and inbox hints for the FlexLove view.
|
||||
function RomImporter:_modsImportButtonLabel()
|
||||
if self.isNX then return Strings("Scan again") end
|
||||
return "Import mod .zip"
|
||||
end
|
||||
|
||||
function RomImporter:_modsDefaultHint()
|
||||
if self.isNX then
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
return Strings("Copy a .zip via MTP into %s/imports/mods/\n"
|
||||
.. "DBI MTP → 1: SD Card/%simports/mods/", saveDir, rel)
|
||||
end
|
||||
if self.android then return "Or copy a mod .zip via USB." end
|
||||
return Strings("Or drop a mod .zip onto the window.")
|
||||
end
|
||||
|
||||
function RomImporter:_savesDefaultHint(version)
|
||||
if self.isNX then
|
||||
version = self:_resolveSaveVersion(version)
|
||||
local inbox = savesInboxDir(version)
|
||||
local saveDir = love.filesystem.getSaveDirectory()
|
||||
local rel = RomImporter.mtpHintPath(saveDir)
|
||||
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
|
||||
local game = GameVersion.info(version).displayName
|
||||
return Strings("Copy a %s .sav via MTP into %s/%s/\n"
|
||||
.. "DBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox)
|
||||
end
|
||||
if self.android then
|
||||
return "Import or export a .sav with the system file picker."
|
||||
end
|
||||
return Strings("Import a .sav to a new slot, or export the active slot.")
|
||||
end
|
||||
|
||||
function RomImporter:_modsEmptyHint()
|
||||
if self.isNX then
|
||||
return Strings("No mods installed - copy a .zip into imports/mods/ "
|
||||
.. "and tap Scan again.")
|
||||
end
|
||||
if self.android then
|
||||
return "No mods installed - tap Import mod .zip to add one."
|
||||
end
|
||||
return Strings("No mods installed - drop a mod .zip here to add one.")
|
||||
end
|
||||
|
||||
-- ------- FIND MODS: browsing a community mod index -------------------------
|
||||
--
|
||||
-- The index is metadata only (src/mods/ModIndex.lua): it says where a mod's
|
||||
@@ -2318,6 +2912,11 @@ end
|
||||
-- must not offer two. Per-source failures are collected rather than fatal: an
|
||||
-- index that is down should cost its own rows, not everybody else's.
|
||||
function RomImporter:_refreshFind(force)
|
||||
if not Platform.networkValidated() then
|
||||
self.findLoaded = true
|
||||
self.findIndex = { mods = {}, categories = {} }
|
||||
return
|
||||
end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
self:_refreshFindSources()
|
||||
local mods, seen, cats, catSeen, errs = {}, {}, {}, {}, {}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
|
||||
-- validated), then registers a fresh slot, writes it, and makes it active.
|
||||
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
|
||||
-- drops it in the save directory's exports/ folder, returning the absolute path
|
||||
-- so the launcher can offer an "open folder" affordance.
|
||||
-- drops it in the save directory's exports/<version>/ folder, returning the
|
||||
-- absolute path so the launcher can offer an "open folder" affordance.
|
||||
--
|
||||
-- Every failure returns false + a friendly one-line message (never raises), so
|
||||
-- the card can surface it as a red notice line rather than crashing.
|
||||
@@ -99,9 +99,9 @@ end
|
||||
-- exportActiveSlot(version) -> ok, pathOrErr
|
||||
-- Loads the version's active slot save (SaveData.load semantics), encodes it
|
||||
-- back to a 32768-byte SRAM image, and writes it to
|
||||
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
|
||||
-- absent). Returns true + the absolute path on success, false + a friendly
|
||||
-- message otherwise.
|
||||
-- exports/<version>/gen1recomp-<version>-<slotId>.sav in the save directory
|
||||
-- (created if absent). Returns true + the absolute path on success, false + a
|
||||
-- friendly message otherwise.
|
||||
function SaveFileIO.exportActiveSlot(version)
|
||||
version = version or GameVersion.get()
|
||||
local save = SaveData.load(version)
|
||||
@@ -111,8 +111,12 @@ function SaveFileIO.exportActiveSlot(version)
|
||||
local slotId = SaveData.activeSlot(version) or "save"
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.write) then return false, "no filesystem available to export to" end
|
||||
if fs.createDirectory then fs.createDirectory("exports") end
|
||||
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
|
||||
if fs.createDirectory then
|
||||
fs.createDirectory("exports")
|
||||
fs.createDirectory("exports/" .. version)
|
||||
end
|
||||
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
|
||||
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
||||
local ok, writeErr = fs.write(rel, bytes)
|
||||
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
||||
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
|
||||
|
||||
Reference in New Issue
Block a user