This commit is contained in:
bryanthaboi
2026-08-06 14:36:03 -04:00
61 changed files with 4943 additions and 926 deletions
+128 -22
View File
@@ -33,11 +33,11 @@ local Platform = require("src.core.Platform")
local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
-- while a Red import keeps the historical root. The launcher sets it per
-- import / per readiness check; it stays "" for Red. Runtime *reads*
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
-- the active version's subtree onto the un-prefixed paths instead.
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
-- yellow/). The launcher sets it per import / per readiness check; it stays
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
-- through here -- CacheFs.mountVersion overlays the active version's subtree
-- onto the un-prefixed paths instead.
CacheFs.prefix = ""
local function withPrefix(rel)
@@ -383,17 +383,120 @@ function CacheFs.removeTree(rel)
walk(rel)
end
-- One-time move of Red's pre-#899 cache (data/generated, assets/generated
-- and the rom-cache.complete marker at the cache root) into red/, the
-- layout Blue and Yellow always used. Idempotent: an existing red/ cache
-- wins and a missing root marker means nothing to do.
--
-- The two cache homes are handled separately: the save directory goes
-- through love.filesystem so every host (NX included) and the headless test
-- stub take the same path, and the portable game folder goes through
-- os.rename on real paths -- skipped for a source run, where the game
-- folder IS the checkout and its data/generated is Red's source data, not
-- a cache. Called from RomImporter.new (before the readiness loop) and
-- from mountVersion, so no boot path can probe red/ before the move ran.
function CacheFs.migrateLegacyRedCache()
if not (love and love.filesystem and love.filesystem.getInfo) then return end
local fs = love.filesystem
local function hasFile(p) return fs.getInfo(p, "file") ~= nil end
local function hasDir(p) return fs.getInfo(p, "directory") ~= nil end
local function moveFile(src, dst)
local data = fs.read(src)
if data then
local parent = dst:match("^(.*)/[^/]+$")
if parent and fs.createDirectory then fs.createDirectory(parent) end
fs.write(dst, data)
end
fs.remove(src)
end
local function moveTree(src, dst)
for _, child in ipairs(fs.getDirectoryItems(src) or {}) do
local sp, dp = src .. "/" .. child, dst .. "/" .. child
if hasDir(sp) then moveTree(sp, dp) else moveFile(sp, dp) end
end
-- remove only takes an empty directory; a non-empty one simply stays
fs.remove(src)
end
-- --- save directory
if hasDir("red/data/generated") or hasFile("red/rom-cache.complete") then
-- already on the new layout
elseif hasFile("rom-cache.complete") then
-- The marker must be a save-dir file before anything moves: a developer
-- checkout also resolves data/generated at the root, but from the physfs
-- SOURCE, and moving that tree would gut the repository.
local real = fs.getRealDirectory and fs.getRealDirectory("rom-cache.complete")
if not real or (fs.getSaveDirectory and real == fs.getSaveDirectory()) then
-- cheap path first: renames inside the same directory; the copy below
-- covers whatever rename could not take (or hosts where the save dir
-- is not a plain os path, like the headless stub)
local saveDir = fs.getSaveDirectory and fs.getSaveDirectory()
if saveDir and fs.createDirectory then
fs.createDirectory("red/data")
fs.createDirectory("red/assets")
os.rename(saveDir .. SEP .. "data" .. SEP .. "generated",
saveDir .. SEP .. "red" .. SEP .. "data" .. SEP .. "generated")
os.rename(saveDir .. SEP .. "assets" .. SEP .. "generated",
saveDir .. SEP .. "red" .. SEP .. "assets" .. SEP .. "generated")
os.rename(saveDir .. SEP .. "rom-cache.complete",
saveDir .. SEP .. "red" .. SEP .. "rom-cache.complete")
end
if hasDir("data/generated") then
moveTree("data/generated", "red/data/generated")
end
if hasDir("assets/generated") then
moveTree("assets/generated", "red/assets/generated")
end
if hasFile("rom-cache.complete") then
moveFile("rom-cache.complete", "red/rom-cache.complete")
end
-- drop the emptied roots; a non-empty one (e.g. mods/ beside them is
-- untouched -- only data and assets are cache subtrees) simply stays
fs.remove("data")
fs.remove("assets")
end
end
-- --- portable game folder (desktop only): rename on real paths
local root = CacheFs.root()
if root and not (fs.getSource and root == fs.getSource()) then
local function rootHas(rel)
local f = io.open(realPath(root, rel), "rb")
if f then f:close() return true end
return false
end
if rootHas("rom-cache.complete") and not rootHas("red/rom-cache.complete") then
local mkdir = resolveMkdir()
if mkdir then
mkdir(realPath(root, "red"))
mkdir(realPath(root, "red/data"))
mkdir(realPath(root, "red/assets"))
os.rename(realPath(root, "data/generated"),
realPath(root, "red/data/generated"))
os.rename(realPath(root, "assets/generated"),
realPath(root, "red/assets/generated"))
os.rename(realPath(root, "rom-cache.complete"),
realPath(root, "red/rom-cache.complete"))
end
end
end
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.
--
-- 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.
-- Each version lives under its cachePrefix folder 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 another
-- version'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
@@ -416,10 +519,13 @@ local function mountGeneratedTrees(prefix)
end
function CacheFs.mountVersion(version)
-- A legacy root Red cache has to move into red/ before anything probes
-- red/ paths (idempotent and near-free once migrated, issue #899).
if version == "red" then CacheFs.migrateLegacyRedCache() end
local prefix = require("src.core.GameVersion").cachePrefix(version)
local sub = prefix:gsub("/+$", "")
-- Save-dir relative mount first (NX / no-FFI). Prepend so blue|yellow win.
-- Save-dir relative mount first (NX / no-FFI). Prepend so the version wins.
if sub ~= "" and love.filesystem.mount
and love.filesystem.getInfo(sub, "directory") then
love.filesystem.mount(sub, "", false)
@@ -436,21 +542,21 @@ function CacheFs.mountVersion(version)
end
end
-- Version-scoped generated trees → un-prefixed paths (Red prefix is "").
-- Version-scoped generated trees → un-prefixed paths.
mountGeneratedTrees(prefix)
return true
end
-- Undo mountVersion. A process normally mounts exactly one version and then
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
-- close it, and press Play on Red: with that version's subtree still
-- prepended, Red's require("data.generated.*") and its generated art would
-- silently resolve to the other game's files. Callers must also drop the
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
-- unmounting alone only fixes the read path, not what require already cached.
-- boots it, but the launcher can open the save editor on one game's save,
-- close it, and press Play on another: with the first version's subtree
-- still prepended, the other's require("data.generated.*") and generated
-- art would silently resolve to the first game's files. Callers must also
-- drop the generated modules from package.loaded
-- (src.core.Data:unloadGenerated) -- unmounting alone only fixes the read
-- path, not what require already cached.
--
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
-- because its cache lives at the root and was never overlaid.
-- Returns true when nothing was mounted or the unmount took.
function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end
+32 -2
View File
@@ -33,6 +33,7 @@ local Theme = require("src.ui.kit.Theme")
local Layout = require("src.ui.kit.Layout")
local Loader = require("src.ui.kit.Loader")
local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version")
local Strings = require("src.core.Strings")
local PAL = Theme.PAL
@@ -398,6 +399,21 @@ local function buildHeader(imp, m)
local rx = m.x + m.w - m.pad
local by = y + (rowH - gear) / 2
-- Switch-only: show the running app version opposite the settings gear so
-- players can confirm which build is on the microSD (OTA / zip updates).
if imp.isNX then
local label = "v" .. tostring(Version.engine or "?")
local tw = Kit.textWidth("small", label)
local padX = math.floor(12 * m.s)
local chipW = math.max(tw + 2 * padX, gear)
local lx = m.x + m.pad
Theme.fill(lx, by, chipW, gear, PAL.bg, 1)
Theme.stroke(lx, by, chipW, gear, PAL.yellow, Theme.A.hover, 1)
local th = Kit.textHeight("small")
Kit.text("small", label, lx + math.floor((chipW - tw) / 2),
by + math.floor((gear - th) / 2), PAL.yellow)
end
-- The right cluster is laid out right to left -- Quit outermost, the gear
-- inboard of it -- but the two are REGISTERED gear first, because the first
-- focusable of the first frame adopts the keyboard ring and that must not be
@@ -541,11 +557,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 +584,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 +950,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)
+102 -9
View File
@@ -36,7 +36,7 @@ end
-- the same unbootable save as before.
local CACHE_FORMAT = "rom-cache-v10:"
-- The completion marker is written under each version's cache prefix
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
local MARKER_PATH = "rom-cache.complete"
-- The marker a finished import writes for a version: the generation tag plus
@@ -139,8 +139,8 @@ local PAL = {
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (Red at the
-- root, Blue under blue/).
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- blue/, yellow/).
local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix
@@ -157,11 +157,15 @@ local function allRequiredFilesExist(version)
end
-- A developer checkout / Python build leaves Red's generated data in the
-- physfs SOURCE at the un-prefixed root; that is always current. Only Red
-- ships this way (Blue is import-only), so this stays a Red-root check.
-- physfs SOURCE at the un-prefixed root (the checked-out data/generated and
-- assets/generated); it is always current and never moves into red/. Only
-- Red ships this way (Blue/Yellow are import-only). The check goes through
-- love.filesystem directly so the red/ cache prefix cannot hide the source
-- tree, and the realDirectory test keeps a save-dir cache from counting.
local function sourceTreeHasData()
if not allRequiredFilesExist("red") or not love.filesystem.getRealDirectory then
return false
if not love.filesystem.getRealDirectory then return false end
for _, path in ipairs(REQUIRED_FILES) do
if love.filesystem.getInfo(path, "file") == nil then return false end
end
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
return real == love.filesystem.getSource()
@@ -218,8 +222,8 @@ local function purgeSaveDirCache()
f:close()
return true
end
-- Purge each version's stale save-directory copy (Red at the root, Blue
-- under blue/) so it cannot shadow the portable game-folder cache.
-- Purge each version's stale save-directory copy (under its red/ / blue/
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
@@ -341,6 +345,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 +479,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 +1114,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
@@ -1128,6 +1195,11 @@ function RomImporter.new(onComplete, opts)
_padInited = false,
}, RomImporter)
-- Pre-#899 installs keep Red's extracted cache at the save-dir root; move
-- it under red/ before the readiness loop looks for red/ paths, or every
-- such install would read as "never imported" and demand the ROM again.
CacheFs.migrateLegacyRedCache()
for _, version in ipairs(GameVersion.ORDER) do
local info = GameVersion.info(version)
local ready = RomImporter.isReady(version) and not self.forceImport
@@ -1144,6 +1216,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
@@ -1750,6 +1823,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
@@ -1872,6 +1960,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.
@@ -2288,6 +2377,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)