Move Red's extracted cache under red/ with a legacy migration

Importing Red unpacked data/generated, assets/generated and
rom-cache.complete straight into the save-dir root, while Blue and
Yellow land under blue/ and yellow/. Red now uses cachePrefix red/
like the others.

CacheFs.migrateLegacyRedCache moves a pre-existing root cache into
red/ on first boot, from RomImporter.new before the readiness loop
and from mountVersion, so existing installs keep their import instead
of being asked for the ROM again. The move only runs when the root
marker resolves to the save directory, so a dev checkout's source
tree is never touched, and the portable game folder is skipped when
it is the physfs source.

Closes #899
This commit is contained in:
johnjohto
2026-08-06 09:55:28 -04:00
parent 6f75a64c46
commit 6c892cb7c1
4 changed files with 220 additions and 35 deletions
+5 -4
View File
@@ -4,9 +4,10 @@
-- extracted cache lives, and the save-file suffix -- so the importer, -- extracted cache lives, and the save-file suffix -- so the importer,
-- cache mount, SaveData, title screen and palette all agree. -- cache mount, SaveData, title screen and palette all agree.
-- --
-- Red keeps every un-suffixed path it always used (save.lua, the root cache), -- Red keeps the un-suffixed save paths it always used (save.lua) so existing
-- so existing installs are untouched; Blue is namespaced under blue/ and -- saves are untouched, but its extracted cache lives under red/ like Blue and
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and -- Yellow (issue #899); a legacy root cache is moved into red/ once by
-- CacheFs.migrateLegacyRedCache. All three versions can be imported and
-- played side by side. -- played side by side.
-- --
-- Zero requires, so it loads during love.conf and under plain Lua for tools -- Zero requires, so it loads during love.conf and under plain Lua for tools
@@ -23,7 +24,7 @@ GameVersion.VERSIONS = {
launcherName = "Red", -- game-panel header in the launcher launcherName = "Red", -- game-panel header in the launcher
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a", sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
manifest = "tools/rom_manifest.json", manifest = "tools/rom_manifest.json",
cachePrefix = "", -- Red owns the cache root (backwards compatible) cachePrefix = "red/", -- red/data/generated, red/assets/generated (#899)
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
}, },
blue = { blue = {
+128 -22
View File
@@ -33,11 +33,11 @@ local Platform = require("src.core.Platform")
local SEP = package.config:sub(1, 1) local SEP = package.config:sub(1, 1)
-- Cache-relative paths are prefixed with this before every read/write, so a -- Cache-relative paths are prefixed with this before every read/write, so a
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/) -- version's import lands under its GameVersion.cachePrefix (red/, blue/,
-- while a Red import keeps the historical root. The launcher sets it per -- yellow/). The launcher sets it per import / per readiness check; it stays
-- import / per readiness check; it stays "" for Red. Runtime *reads* -- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays -- through here -- CacheFs.mountVersion overlays the active version's subtree
-- the active version's subtree onto the un-prefixed paths instead. -- onto the un-prefixed paths instead.
CacheFs.prefix = "" CacheFs.prefix = ""
local function withPrefix(rel) local function withPrefix(rel)
@@ -383,17 +383,120 @@ function CacheFs.removeTree(rel)
walk(rel) walk(rel)
end 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 -- Overlay the active version's extracted cache onto the un-prefixed read
-- paths, so require("data.generated.*") and love.graphics.newImage( -- paths, so require("data.generated.*") and love.graphics.newImage(
-- "assets/generated/*") resolve to that version's files. -- "assets/generated/*") resolve to that version's files.
-- --
-- Non-Red versions live under blue/ / yellow/ in the save directory. On -- Each version lives under its cachePrefix folder in the save directory.
-- desktop fused+portable we PHYSFS_mount that folder by absolute path. On -- On desktop fused+portable we PHYSFS_mount that folder by absolute path.
-- NX (and any host without a working FFI mount) love.filesystem.mount of -- On NX (and any host without a working FFI mount) love.filesystem.mount
-- the save-dir-relative name must succeed, or Play boots with Red's paths -- of the save-dir-relative name must succeed, or Play boots with another
-- and Data:load dies. Always also prepend-mount the version's -- version's paths and Data:load dies. Always also prepend-mount the
-- data/generated + assets/generated onto the un-prefixed paths so PhysFS -- version's data/generated + assets/generated onto the un-prefixed paths
-- directory non-merge (archive data/ vs save generated) cannot hide them. -- so PhysFS directory non-merge (archive data/ vs save generated) cannot
-- hide them.
local function mountGeneratedTrees(prefix) local function mountGeneratedTrees(prefix)
prefix = prefix or "" prefix = prefix or ""
if not (love and love.filesystem and love.filesystem.mount) then if not (love and love.filesystem and love.filesystem.mount) then
@@ -416,10 +519,13 @@ local function mountGeneratedTrees(prefix)
end end
function CacheFs.mountVersion(version) 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 prefix = require("src.core.GameVersion").cachePrefix(version)
local sub = prefix:gsub("/+$", "") 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 if sub ~= "" and love.filesystem.mount
and love.filesystem.getInfo(sub, "directory") then and love.filesystem.getInfo(sub, "directory") then
love.filesystem.mount(sub, "", false) love.filesystem.mount(sub, "", false)
@@ -436,21 +542,21 @@ function CacheFs.mountVersion(version)
end end
end end
-- Version-scoped generated trees → un-prefixed paths (Red prefix is ""). -- Version-scoped generated trees → un-prefixed paths.
mountGeneratedTrees(prefix) mountGeneratedTrees(prefix)
return true return true
end end
-- Undo mountVersion. A process normally mounts exactly one version and then -- 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, -- boots it, but the launcher can open the save editor on one game's save,
-- close it, and press Play on Red: with that version's subtree still -- close it, and press Play on another: with the first version's subtree
-- prepended, Red's require("data.generated.*") and its generated art would -- still prepended, the other's require("data.generated.*") and generated
-- silently resolve to the other game's files. Callers must also drop the -- art would silently resolve to the first game's files. Callers must also
-- generated modules from package.loaded (src.core.Data:unloadGenerated) -- -- drop the generated modules from package.loaded
-- unmounting alone only fixes the read path, not what require already cached. -- (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 -- Returns true when nothing was mounted or the unmount took.
-- because its cache lives at the root and was never overlaid.
function CacheFs.unmountVersion(version) function CacheFs.unmountVersion(version)
local prefix = require("src.core.GameVersion").cachePrefix(version) local prefix = require("src.core.GameVersion").cachePrefix(version)
if prefix == "" then return true end if prefix == "" then return true end
+18 -9
View File
@@ -32,7 +32,7 @@ end
-- carry Red's bank $1f header, wave-table, and CryData offsets. -- carry Red's bank $1f header, wave-table, and CryData offsets.
local CACHE_FORMAT = "rom-cache-v9:" local CACHE_FORMAT = "rom-cache-v9:"
-- The completion marker is written under each version's cache prefix -- 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" local MARKER_PATH = "rom-cache.complete"
-- The marker a finished import writes for a version: the generation tag plus -- 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, -- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem. It honors -- otherwise the save directory through love.filesystem. It honors
-- CacheFs.prefix, so we point it at the version's cache subtree (Red at the -- CacheFs.prefix, so we point it at the version's cache subtree (red/,
-- root, Blue under blue/). -- blue/, yellow/).
local function allRequiredFilesExist(version) local function allRequiredFilesExist(version)
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local saved = CacheFs.prefix local saved = CacheFs.prefix
@@ -153,11 +153,15 @@ local function allRequiredFilesExist(version)
end end
-- A developer checkout / Python build leaves Red's generated data in the -- 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 -- physfs SOURCE at the un-prefixed root (the checked-out data/generated and
-- ships this way (Blue is import-only), so this stays a Red-root check. -- 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() local function sourceTreeHasData()
if not allRequiredFilesExist("red") or not love.filesystem.getRealDirectory then if not love.filesystem.getRealDirectory then return false end
return false for _, path in ipairs(REQUIRED_FILES) do
if love.filesystem.getInfo(path, "file") == nil then return false end
end end
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1]) local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
return real == love.filesystem.getSource() return real == love.filesystem.getSource()
@@ -214,8 +218,8 @@ local function purgeSaveDirCache()
f:close() f:close()
return true return true
end end
-- Purge each version's stale save-directory copy (Red at the root, Blue -- Purge each version's stale save-directory copy (under its red/ / blue/
-- under blue/) so it cannot shadow the portable game-folder cache. -- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
for _, version in ipairs(GameVersion.ORDER) do for _, version in ipairs(GameVersion.ORDER) do
local prefix = GameVersion.cachePrefix(version) local prefix = GameVersion.cachePrefix(version)
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
@@ -1121,6 +1125,11 @@ function RomImporter.new(onComplete, opts)
_padInited = false, _padInited = false,
}, RomImporter) }, 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 for _, version in ipairs(GameVersion.ORDER) do
local info = GameVersion.info(version) local info = GameVersion.info(version)
local ready = RomImporter.isReady(version) and not self.forceImport local ready = RomImporter.isReady(version) and not self.forceImport
@@ -0,0 +1,69 @@
-- Issue #899: Red's extracted cache lives under red/ like blue/ and
-- yellow/, and a legacy root cache (pre-fix installs) is migrated on first
-- boot instead of reading as "never imported".
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
eq(GameVersion.cachePrefix("red"), "red/",
"Red's cache is namespaced under red/")
-- legacy layout: the marker and both generated trees at the save-dir root
love.filesystem.write("rom-cache.complete", "rom-cache-v9:abc")
love.filesystem.write("data/generated/maps.lua", "return {}")
love.filesystem.write("data/generated/constants.lua", "return {}")
love.filesystem.write("assets/generated/fonts/font.png", "font-bytes")
CacheFs.migrateLegacyRedCache()
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
"the marker moved under red/")
eq(love.filesystem.read("red/data/generated/maps.lua"), "return {}",
"the data tree moved under red/")
eq(love.filesystem.read("red/assets/generated/fonts/font.png"), "font-bytes",
"the assets tree moved under red/")
check(love.filesystem.read("rom-cache.complete") == nil,
"the root marker is gone")
check(love.filesystem.read("data/generated/maps.lua") == nil,
"the root data tree is gone")
check(love.filesystem.read("assets/generated/fonts/font.png") == nil,
"the root assets tree is gone")
-- idempotent: a second run leaves the migrated tree alone
CacheFs.migrateLegacyRedCache()
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
"a second run keeps the migrated cache")
-- an existing red/ cache wins over a legacy root leftover: no clobber
love.filesystem.write("rom-cache.complete", "rom-cache-v9:STALE")
CacheFs.migrateLegacyRedCache()
eq(love.filesystem.read("red/rom-cache.complete"), "rom-cache-v9:abc",
"an existing red/ cache is not clobbered")
check(love.filesystem.read("rom-cache.complete") ~= nil,
"the unmigrated leftover stays (a stale-marker re-import handles it)")
love.filesystem.remove("rom-cache.complete")
-- mountVersion("red") overlays red/ at the un-prefixed paths, like blue/
love.filesystem._mounts = {}
check(CacheFs.mountVersion("red") == true, "mountVersion(red) returns true")
eq(love.filesystem.read("assets/generated/fonts/font.png"), "font-bytes",
"post-mount probe reads red assets at the un-prefixed path")
eq(love.filesystem.read("data/generated/constants.lua"), "return {}",
"post-mount probe reads red data at the un-prefixed path")
-- no legacy cache at all: migration is a no-op, not an error
love.filesystem.remove("red/rom-cache.complete")
love.filesystem.remove("red/data/generated/maps.lua")
love.filesystem.remove("red/data/generated/constants.lua")
love.filesystem.remove("red/assets/generated/fonts/font.png")
CacheFs.migrateLegacyRedCache()
check(love.filesystem.read("red/rom-cache.complete") == nil,
"nothing to migrate invents nothing")
T.finish()