Merge dev and fix headless ROM builder dependency

This commit is contained in:
Martin
2026-08-06 16:22:34 +02:00
18 changed files with 1737 additions and 1122 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
+25 -3
View File
@@ -62,7 +62,7 @@ local FILTERS = { "OFF", "1X", "2X", "3X" }
-- The core rows. Helper modules are required lazily under pcall: they are
-- pure label/cycle tables, but the launcher must never die because a render
-- module grew a dependency on live game data.
local function coreRows(opts)
local function coreRows(opts, hooks)
local rows = {}
local function add(label, value, step)
rows[#rows + 1] = { label = label, value = value, step = step }
@@ -257,6 +257,26 @@ local function coreRows(opts)
end
end
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
-- button on the game panel, once per game -- but the overlay layout is
-- global (options.touchControls.layouts), so three tabs offered three
-- buttons that edited the same thing while crowding the column that has to
-- hold Play. It belongs with the other control rows, behind the gear.
-- The host owns the editor screen, so the row only fires when a hook was
-- supplied (the standalone save editor opens this model with none).
if hooks and hooks.editTouchControls then
rows[#rows + 1] = {
label = Strings("TOUCH CONTROLS"),
actionLabel = Strings("Edit"),
action = function()
hooks.editTouchControls()
-- The editor replaces the whole screen: nothing left to persist here
-- beyond what the caller already saved on the way out.
return false
end,
}
end
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
-- (src/core/Input.lua:applyBindings layers options.bindings over the
-- defaults rather than replacing them), so a player who has bound
@@ -417,10 +437,12 @@ end
-- sections of rows, and a save() that persists it. The caller keeps the
-- model for as long as the panel is open; nothing else in the launcher
-- writes options while a modal covers it, so the cached table stays true.
function LauncherSettings.open()
-- `hooks` carries the host actions a row cannot perform itself:
-- editTouchControls() -- hand the screen to the touch-overlay editor
function LauncherSettings.open(hooks)
local opts = SaveData.loadOptions()
local sections = {
{ title = Strings("OPTIONS"), rows = coreRows(opts) },
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
}
for _, mod in ipairs(discoverModSchemas(opts)) do
local rows = modRows(opts, mod)
File diff suppressed because it is too large Load Diff
+54 -41
View File
@@ -32,7 +32,7 @@ end
-- carry Red's bank $1f header, wave-table, and CryData offsets.
local CACHE_FORMAT = "rom-cache-v9:"
-- 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
@@ -135,8 +135,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
@@ -154,13 +154,19 @@ end
-- A developer checkout / Python build leaves generated data in the physfs
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
-- Source data is produced from the current manifest, so it needs no runtime
-- import marker; still verify the whole version-specific required-file set.
-- Imported Red caches still live under red/. Check source paths directly so
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
-- from counting as current source data.
local function sourceTreeHasData(version)
if not allRequiredFilesExist(version) or not love.filesystem.getRealDirectory then
return false
if not love.filesystem.getRealDirectory then return false end
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
for _, path in ipairs(REQUIRED_FILES) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
local path = GameVersion.cachePrefix(version) .. REQUIRED_FILES[1]
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
end
local path = prefix .. REQUIRED_FILES[1]
local real = love.filesystem.getRealDirectory(path)
return real == love.filesystem.getSource()
end
@@ -216,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
@@ -328,7 +334,10 @@ local function commandOutput(command)
local pipe = HostShell.popen(command)
if not pipe then return nil end
local result = pipe:read("*a")
pipe:close()
-- HostShell.pclose, never pipe:close(): closing a pipe outside the spawn
-- lock can free a FILE while a worker thread's popen is walking the stream
-- list, which deadlocks that thread for good (see HostShell).
HostShell.pclose(pipe)
result = trim(result)
return result ~= "" and result or nil
end
@@ -1121,6 +1130,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
@@ -1909,6 +1923,12 @@ function RomImporter:update(dt)
"Mods are not reviewed - trust the author." },
}
end
-- POKEPORT_LAUNCHER_SETTINGS=1 opens the gear panel, the other layout
-- a capture cannot otherwise reach without a click. Pair it with
-- POKEPORT_LAUNCHER_SETTINGS_PAGE to land on a page past the first.
if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then
self:_openSettings()
end
local query = os.getenv("POKEPORT_LAUNCHER_QUERY")
if query and query ~= "" then
self.findQuery = query
@@ -2450,8 +2470,19 @@ end
-- ------- settings gear (options.lua + enabled mods' option schemas)
function RomImporter:_openSettings()
-- The touch-overlay editor is a host screen, so the model gets it as a
-- hook rather than reaching for main.lua's handler itself. Closing the
-- settings panel FIRST persists the pending edits (_closeSettings saves)
-- and leaves no modal behind the editor to return to.
local hooks = {}
if self.onEditTouchControls then
hooks.editTouchControls = function()
self:_closeSettings()
self.onEditTouchControls()
end
end
local ok, model = pcall(function()
return require("src.import.LauncherSettings").open()
return require("src.import.LauncherSettings").open(hooks)
end)
if ok and model then self._settings = model end
end
@@ -3334,34 +3365,11 @@ function RomImporter:_pumpFindFetch()
self:_clearBusy()
end
-- Clear every input rebind and the dragged touch-overlay layout, restoring
-- the stock keyboard/gamepad bindings. Rebinds are ADDITIVE
-- (src/core/Input.lua:applyBindings layers options.bindings over the
-- defaults instead of replacing them), so a player who has bound themselves
-- into a corner has no in-game way out; this is it. The running game reads
-- bindings on its next start, which is the same contract every other
-- launcher setting has.
function RomImporter:_resetRebinds()
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.bindings = nil
if type(opts.touchControls) == "table" then
opts.touchControls.layouts = nil
end
SaveData.saveOptions(opts)
end)
-- Its own notice slot: this button lives on the game panel, and borrowing
-- the mods or save notice would print the result on a tab the user is not
-- looking at.
if ok then
self.controlsNotice = { ok = true,
text = Strings("Controls reset to defaults. Applies on the next start.") }
else
self.controlsNotice = { ok = false,
text = Strings("Could not reset controls.") }
end
end
-- Clearing rebinds used to live here, behind a button on the game panel. It
-- is now the RESET REBINDS row of the settings model
-- (src/import/LauncherSettings.lua), which edits the same options table the
-- rest of that panel does and saves through the same save() -- one control
-- for a setting that was never per-game in the first place.
-- ------- busy state (drives the non-dismissable loader overlay)
-- Anything that makes the user wait sets this; LauncherView renders it as a
@@ -3440,7 +3448,12 @@ function RomImporter:_findThumb(entry)
:format(tostring(entry.id):gsub("[^%w%-_]", "_"), ext)
local Fetch = require("src.net.Fetch")
self._findThumbFetch[entry.id] = {
job = Fetch.download(url, name, { userAgent = "gen1recomp-mod-index" }),
-- A short ceiling on purpose: a page of these is queued at once, and
-- each one's ceiling is part of the worst case for closing the window
-- (Fetch.shutdown). A thumbnail that has not arrived in 15s is not
-- worth holding the process open for -- the card shows its placeholder.
job = Fetch.download(url, name,
{ userAgent = "gen1recomp-mod-index", maxSeconds = 15 }),
}
end
return nil