diff --git a/README.md b/README.md index 32782c25..175e8c90 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,20 @@ developer data build, test suites, and cache management are covered in By default the game keeps your save, options, and the private ROM-derived data cache in your OS's normal per-user app data folder. To keep everything next to the game instead (handy for a USB stick or portable drive you carry -between computers), drop an empty file named `portable.txt` next to the -executable (or next to `main.lua`/`conf.lua` when running from source), then -launch the game. +between computers), drop an empty file named `portable.txt` next to the app +(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when +running from source), then launch the game. Portable mode is desktop-only +(Windows, Linux, macOS); it has no effect on Android or iOS, where the app +runs from a read-only package. With `portable.txt` present: - `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to that same folder instead of the OS save directory. -- After a ROM import, the generated `data/generated` and `assets/generated` - cache is copied into that folder too, so a later launch (even on a - different computer, as long as the same folder comes along) reuses it - without asking for the ROM again. +- A ROM import writes the generated `data/generated` and `assets/generated` + cache straight into that folder too (nothing is left in the OS save + directory), so a later launch reuses it without asking for the ROM again + even on a different computer, as long as the same folder comes along. - Deleting `portable.txt` switches back to the normal OS save directory; nothing already written to either location is touched automatically, so copy files over yourself if you want to carry existing progress across the switch. diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 7d4ec4f9..55c21dd3 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -83,15 +83,41 @@ local function detectPortable() portableChecked = true portableBase = false if not (love and love.filesystem) then return false end + -- Desktop only: portable mode carries the save (and, since issue #74, the + -- ROM cache) in the game folder next to the executable/source. On + -- Android/iOS the source is a read-only package with no such folder, so + -- portable mode never applies there. + if love.system and love.system.getOS then + local osName = love.system.getOS() + if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then + return false + end + end + local src = love.filesystem.getSource and love.filesystem.getSource() + local sbd = love.filesystem.getSourceBaseDirectory + and love.filesystem.getSourceBaseDirectory() + -- A packaged macOS build nests the game inside PokemonRed.app/Contents/ + -- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the + -- bundle -- not where the player drops portable.txt (next to the .app). + -- Recover the folder containing the .app so a packaged app finds its + -- marker. On Windows/Linux the executable is not a bundle, so this is nil + -- and the plain source-base directory (next to the .exe/AppImage) is used. + local function appContainer(path) + local appPath = path and path:match("^(.*%.app)/Contents/") + return appPath and appPath:match("^(.*)/[^/]+$") or nil + end + -- Order: the .app's containing folder (packaged macOS), then the + -- source-base directory (next to a packaged .exe/AppImage), then the + -- source itself (a `love ` run drops portable.txt in the game + -- folder). First one holding the marker wins. Built by appending so a + -- nil (e.g. no .app in the path) never truncates the ipairs scan. local candidates = {} - if love.filesystem.getSourceBaseDirectory then - candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory() - end - if love.filesystem.getSource then - candidates[#candidates + 1] = love.filesystem.getSource() - end + local appDir = appContainer(src) or appContainer(sbd) + if appDir then candidates[#candidates + 1] = appDir end + if sbd then candidates[#candidates + 1] = sbd end + if src then candidates[#candidates + 1] = src end for _, base in ipairs(candidates) do - if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then + if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then portableBase = base break end diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua new file mode 100644 index 00000000..a4081fe4 --- /dev/null +++ b/src/import/CacheFs.lua @@ -0,0 +1,214 @@ +-- Routes ROM-derived cache I/O (data/generated, assets/generated and the +-- rom-cache.complete marker) to the right place. +-- +-- Normally the cache lives in LÖVE's per-user OS save directory and is +-- written through love.filesystem. In portable mode it lives in the game +-- folder next to the executable instead (the folder holding portable.txt -- +-- see SaveData), so nothing is left on the host machine. That folder is +-- written with raw io.* (love.filesystem can only write to the save dir) and +-- read back through love.filesystem, require and love.graphics.newImage -- +-- which works because the folder is on the physfs read path: +-- +-- * Source runs (`love `, what the Play-* launchers use): the +-- folder IS the physfs source, so it is already readable. +-- * Fused builds (the packaged .app/.exe): the folder sits next to the +-- executable and is NOT normally readable, so CacheFs mounts it onto the +-- read path via PhysFS. love.filesystem.mount refuses external folders, +-- but the underlying PHYSFS_mount (exported from love's framework) allows +-- them; we call it through LuaJIT's FFI. +-- +-- Directories in the portable folder are created with a plain mkdir syscall +-- via FFI rather than os.execute, so importing never flashes a console window +-- on Windows (issue #74 -- the old per-file `os.execute("mkdir")` froze the +-- app behind a storm of one-frame cmd.exe windows). +-- +-- Portable mode is desktop-only (Windows/Linux/macOS); on Android/iOS the +-- source is a read-only package with no game folder to write into, so +-- SaveData.isPortable() is false there and this module falls back to the +-- ordinary love.filesystem/save-directory behaviour. + +local CacheFs = {} + +local SEP = package.config:sub(1, 1) + +-- lazily-resolved windowless mkdir: function(absolutePath) or false when +-- FFI is unavailable (the cache then stays on the save directory) +local mkdirFn = nil + +local function resolveMkdir() + if mkdirFn ~= nil then return mkdirFn end + mkdirFn = false + local ok, ffi = pcall(require, "ffi") + if not ok then return mkdirFn end + if ffi.os == "Windows" then + -- kernel32 is reliably resolvable through ffi.C on Windows (the engine + -- already binds it in DiscordPresence); CreateDirectoryA returns + -- nonzero on success and 0 when the directory already exists -- both + -- fine, the result is ignored. + pcall(ffi.cdef, + "int CreateDirectoryA(const char *lpPathName, void *lpSecurityAttributes);") + local resolved = pcall(function() return ffi.C.CreateDirectoryA end) + if resolved then + mkdirFn = function(path) pcall(ffi.C.CreateDirectoryA, path, nil) end + end + else + pcall(ffi.cdef, "int mkdir(const char *pathname, unsigned int mode);") + local resolved = pcall(function() return ffi.C.mkdir end) + if resolved then + mkdirFn = function(path) pcall(ffi.C.mkdir, path, 493) end -- 0755 + end + end + return mkdirFn +end + +-- Mount an external directory onto the physfs read path (appended, so the +-- game's own source always wins a name clash). Returns true on success. +-- Guarded and lazily bound like the mkdir helper; PHYSFS_mount is exported +-- from love's framework, so ffi.C resolves it in the running process. +local physfsMountFn = nil +local function mountReadable(dir) + if physfsMountFn == nil then + physfsMountFn = false + local ok, ffi = pcall(require, "ffi") + if ok then + pcall(ffi.cdef, + "int PHYSFS_mount(const char *newDir, const char *mountPoint, int appendToPath);") + if pcall(function() return ffi.C.PHYSFS_mount end) then + physfsMountFn = function(d) + local okc, ret = pcall(ffi.C.PHYSFS_mount, d, "", 1) + return okc and ret ~= 0 + end + end + end + end + if not physfsMountFn then return false end + return physfsMountFn(dir) +end + +-- The portable game folder when the cache should live there, else nil. +-- Resolved (and, for a fused build, mounted) once and cached. Requires a +-- desktop portable install (SaveData) and a working windowless mkdir. +local portableRoot = nil +local portableResolved = false +local function resolvePortableRoot() + if portableResolved then return portableRoot end + portableResolved = true + portableRoot = nil + if not resolveMkdir() then return nil end + local base = require("src.core.SaveData").portableBaseDir() + if not base then return nil end + if love.filesystem.getSource and base == love.filesystem.getSource() then + -- source run: the folder is already the physfs source + portableRoot = base + elseif mountReadable(base) then + -- fused build: base is next to the executable; mount it so io.* writes + -- there are visible to love.filesystem/require/newImage + portableRoot = base + end + return portableRoot +end + +function CacheFs.root() + return resolvePortableRoot() +end + +local function realPath(root, rel) + return root .. SEP .. rel:gsub("/", SEP) +end + +-- create every parent directory of `rel` under `root` (best effort; an +-- already-existing directory is fine, a genuine failure surfaces when the +-- subsequent io.open write fails) +local function ensureParents(root, rel) + local mkdir = resolveMkdir() + if not mkdir then return end + local parts = {} + for part in rel:gmatch("[^/]+") do parts[#parts + 1] = part end + local cur = root + for i = 1, #parts - 1 do + cur = cur .. SEP .. parts[i] + mkdir(cur) + end +end + +-- write cache-relative `rel` (forward-slash path) with the given bytes; +-- returns ok, err like love.filesystem.write +function CacheFs.write(rel, data) + local root = CacheFs.root() + if root then + ensureParents(root, rel) + local f, err = io.open(realPath(root, rel), "wb") + if not f then return false, err end + f:write(data) + f:close() + return true + end + local parent = rel:match("^(.*)/[^/]+$") + if parent and not love.filesystem.createDirectory(parent) then + local info = love.filesystem.getInfo(parent) + local reason = info and ("a " .. info.type .. " already exists there") + or "unknown reason" + return false, "could not create " .. parent .. ": " .. reason + end + return love.filesystem.write(rel, data) +end + +-- read cache-relative `rel`; returns the bytes or nil +function CacheFs.read(rel) + local root = CacheFs.root() + if root then + local f = io.open(realPath(root, rel), "rb") + if not f then return nil end + local data = f:read("*a") + f:close() + return data + end + return love.filesystem.read(rel) +end + +-- does cache-relative `rel` exist as a file? +function CacheFs.exists(rel) + local root = CacheFs.root() + if root then + local f = io.open(realPath(root, rel), "rb") + if not f then return false end + f:close() + return true + end + return love.filesystem.getInfo(rel, "file") ~= nil +end + +-- remove a single cache-relative file +function CacheFs.remove(rel) + local root = CacheFs.root() + if root then + os.remove(realPath(root, rel)) + return + end + love.filesystem.remove(rel) +end + +-- Remove the game-folder copy of a cache subtree before a fresh import, so a +-- cache-format bump does not leave orphaned files behind. No-op when the +-- portable cache is inactive (the save-directory copy is cleared by +-- RomImporter's own removeTree). The tree is enumerated through +-- love.filesystem (the game folder is mounted) and the real files deleted +-- with os.remove; empty directories are harmless and left in place. +function CacheFs.removeTree(rel) + local root = CacheFs.root() + if not root then return end + local function walk(r) + local info = love.filesystem.getInfo(r) + if not info then return end + if info.type == "directory" then + for _, child in ipairs(love.filesystem.getDirectoryItems(r)) do + walk(r .. "/" .. child) + end + else + os.remove(realPath(root, r)) + end + end + walk(rel) +end + +return CacheFs diff --git a/src/import/ImageWriter.lua b/src/import/ImageWriter.lua index 6b6a589d..9ab64fc7 100644 --- a/src/import/ImageWriter.lua +++ b/src/import/ImageWriter.lua @@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile) end function ImageWriter.save(image, path) - local parent = path:match("^(.*)/[^/]+$") - if parent then - local ok, err = love.filesystem.createDirectory(parent) - if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end - end local ok, fileData = pcall(image.encode, image, "png") if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end - local written, writeError = love.filesystem.write(path, fileData) + -- CacheFs routes this to the OS save directory (normal builds) or straight + -- into the game folder (portable installs), creating parent directories as + -- needed. io.* needs the bytes as a string; love.filesystem would also + -- take the FileData, but getString() keeps one code path. + local CacheFs = require("src.import.CacheFs") + local written, writeError = CacheFs.write(path, fileData:getString()) if not written then error("could not write " .. path .. ": " .. tostring(writeError)) end diff --git a/src/import/LuaWriter.lua b/src/import/LuaWriter.lua index d42d4899..ad2955d5 100644 --- a/src/import/LuaWriter.lua +++ b/src/import/LuaWriter.lua @@ -87,17 +87,11 @@ function LuaWriter.encode(value) end function LuaWriter.write(path, value) - local parent = path:match("^(.*)/[^/]+$") - -- love.filesystem.createDirectory returns a single boolean (no error - -- value), so a second return here is always nil; getInfo after a failure - -- at least reports what is blocking the path (e.g. a file with that name). - if parent and not love.filesystem.createDirectory(parent) then - local info = love.filesystem.getInfo(parent) - local reason = info and ("a " .. info.type .. " already exists there") - or "unknown reason" - error("could not create " .. parent .. ": " .. reason) - end - local ok, err = love.filesystem.write(path, LuaWriter.encode(value)) + -- CacheFs routes this to the OS save directory (normal builds) or straight + -- into the game folder (portable installs); it also creates the parent + -- directories. See src/import/CacheFs.lua. + local CacheFs = require("src.import.CacheFs") + local ok, err = CacheFs.write(path, LuaWriter.encode(value)) if not ok then error("could not write " .. path .. ": " .. tostring(err)) end end diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 1d63613e..7982241f 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1669,10 +1669,11 @@ function RomExtractor:extractAudio() chunks[index] = self.rom.data:sub(first, first + 0x3FFF) self:tick("Sound programs", index, #bankOrder + 2) end - local ok, writeError = love.filesystem.createDirectory( - "assets/generated/audio") - if ok == false then error("could not create audio cache: " .. tostring(writeError)) end - ok, writeError = love.filesystem.write( + -- CacheFs (not love.filesystem directly) so a portable install lands this + -- in the game folder with the rest of the cache; it creates the parent + -- directory too. + local CacheFs = require("src.import.CacheFs") + local ok, writeError = CacheFs.write( "assets/generated/audio/programs.bin", table.concat(chunks)) if not ok then error("could not write audio programs: " .. tostring(writeError)) end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 1d342a27..beedb5f1 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -24,8 +24,11 @@ local REQUIRED_FILES = { } local function allRequiredFilesExist() + -- CacheFs.exists checks the game folder directly for a portable install, + -- otherwise the save directory through love.filesystem. + local CacheFs = require("src.import.CacheFs") for _, path in ipairs(REQUIRED_FILES) do - if not love.filesystem.getInfo(path, "file") then return false end + if not CacheFs.exists(path) then return false end end return true end @@ -38,128 +41,21 @@ local function sourceTreeHasData() return real == love.filesystem.getSource() end --- ------- portable ROM-derived asset cache +-- ------- ROM cache location -- --- The extracted cache (data/generated, assets/generated) is written --- exclusively through love.filesystem.write, which always targets the OS --- save directory -- it cannot be redirected to an arbitrary folder. So a --- portable install mirrors the cache both ways instead: after a fresh --- import, every generated file is copied out to the portable folder --- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a --- different machine sharing the same USB copy -- a matching portable --- cache is copied back into the save directory before the normal --- isReady() check runs, so nothing downstream needs to know the cache --- ever lived anywhere but the save directory. -local PORTABLE_CACHE_DIRS = { "data/generated", "assets/generated" } -local PORTABLE_MANIFEST_NAME = "portable_cache_manifest.txt" -local PORTABLE_SEP = package.config:sub(1, 1) - -local function walkLoveDir(dir, out) - out = out or {} - for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do - local full = dir .. "/" .. name - local info = love.filesystem.getInfo(full) - if info and info.type == "directory" then - walkLoveDir(full, out) - elseif info and info.type == "file" then - out[#out + 1] = full - end - end - return out -end - -local function portablePath(base, relPath) - return base .. PORTABLE_SEP .. relPath:gsub("/", PORTABLE_SEP) -end - -local function ensurePortableDir(fullDirPath) - if love.system.getOS() == "Windows" then - os.execute(('mkdir "%s" 2>NUL'):format(fullDirPath)) - else - os.execute(("mkdir -p '%s' 2>/dev/null"):format(fullDirPath)) - end -end - --- copies data/generated + assets/generated out to the portable folder --- after a fresh import; a plain-text manifest travels alongside so a --- later sync-in knows exactly which files to copy back without needing --- to list an arbitrary external directory (io.* has no listdir) -local function syncCacheToPortable() - local SaveData = require("src.core.SaveData") - local base = SaveData.portableBaseDir() - if not base then return end - local manifest = {} - for _, dir in ipairs(PORTABLE_CACHE_DIRS) do - if love.filesystem.getInfo(dir, "directory") then - for _, relPath in ipairs(walkLoveDir(dir)) do - local data = love.filesystem.read(relPath) - if data then - local outPath = portablePath(base, relPath) - local outDir = outPath:match("^(.*)" .. PORTABLE_SEP .. "[^" .. PORTABLE_SEP .. "]+$") - if outDir then ensurePortableDir(outDir) end - local f, err = io.open(outPath, "wb") - if f then - f:write(data) - f:close() - manifest[#manifest + 1] = relPath - else - require("src.core.Logger").error( - "portable cache: could not write %s: %s", outPath, tostring(err)) - end - end - end - end - end - local mf = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "wb") - if mf then - mf:write(table.concat(manifest, "\n")) - mf:close() - end - local mk = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "wb") - if mk then - mk:write(CACHE_MARKER) - mk:close() - end -end - --- copies a matching portable cache back into the save directory before --- isReady() runs its normal check; a mismatched or missing marker means --- either no portable cache exists yet or it belongs to an older build, so --- it is left alone and a fresh import proceeds as usual -local function syncCacheFromPortable() - local SaveData = require("src.core.SaveData") - local base = SaveData.portableBaseDir() - if not base then return end - local markerFile = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "rb") - if not markerFile then return end - local marker = markerFile:read("*a") - markerFile:close() - if marker ~= CACHE_MARKER then return end - local manifestFile = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "rb") - if not manifestFile then return end - local manifestBody = manifestFile:read("*a") - manifestFile:close() - for relPath in manifestBody:gmatch("[^\r\n]+") do - local f = io.open(portablePath(base, relPath), "rb") - if f then - local data = f:read("*a") - f:close() - love.filesystem.write(relPath, data) - end - end - love.filesystem.write(MARKER_PATH, CACHE_MARKER) -end - -function RomImporter.isReady() - if sourceTreeHasData() then return true end - if love.filesystem.read(MARKER_PATH) ~= CACHE_MARKER - and require("src.core.SaveData").isPortable() then - syncCacheFromPortable() - end - return love.filesystem.read(MARKER_PATH) == CACHE_MARKER - and allRequiredFilesExist() -end +-- The extracted cache (data/generated, assets/generated) plus the +-- rom-cache.complete marker normally live in LÖVE's per-user OS save +-- directory. A portable install instead keeps them in the game folder next +-- to the executable (the folder holding portable.txt), so nothing is left on +-- the host machine. Every cache write/read/remove goes through CacheFs, +-- which writes that folder with io.* and makes it readable (mounting it via +-- PhysFS for a fused build) -- there is no mirror step and no per-file +-- os.execute (issue #74: that flashed a console window per file on Windows +-- and froze the app). +-- Remove a cache subtree from the OS save directory. The realDirectory +-- guard keeps this from ever deleting the game folder (portable installs +-- read the cache from there) or a developer's checked-out source tree. local function removeTree(path) local info = love.filesystem.getInfo(path) if not info then return end @@ -179,6 +75,48 @@ local function removeTree(path) end end +-- Portable installs read the cache from the game folder. Any copy an +-- earlier non-portable run -- or the pre-#74 build, which always wrote the +-- cache to the save directory and only mirrored it out -- left behind would +-- shadow it, because physfs searches the save directory before the source. +-- Clear it out once, and only when a remnant is actually present so a clean +-- install pays nothing. +local saveDirPurged = false +local function purgeSaveDirCache() + if saveDirPurged then return end + saveDirPurged = true + local saveDir = love.filesystem.getSaveDirectory() + local function saveDirHas(rel) + local f = io.open(saveDir .. "/" .. rel, "rb") + if not f then return false end + f:close() + return true + end + if not (saveDirHas(MARKER_PATH) or saveDirHas(REQUIRED_FILES[1])) then + return + end + removeTree("data/generated") + removeTree("assets/generated") + love.filesystem.remove(MARKER_PATH) +end + +function RomImporter.isReady() + local CacheFs = require("src.import.CacheFs") + if CacheFs.root() then + -- Portable: the cache lives in the game folder next to the executable + -- (mounted onto the read path for a fused build). Drop any stale + -- save-directory copy that would otherwise shadow it at runtime -- and, + -- for a source run, hide the game folder from sourceTreeHasData below. + purgeSaveDirCache() + end + -- Generated data sitting in the physfs source -- a developer checkout, a + -- Python/bootstrap build, or a source-run portable import -- is always + -- current (as it has always been). A fused portable install is not the + -- source, so it falls through to the version-marker gate. + if sourceTreeHasData() then return true end + return CacheFs.read(MARKER_PATH) == CACHE_MARKER and allRequiredFilesExist() +end + local function decodeManifest() local raw, readError = love.filesystem.read("tools/rom_manifest.json") if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end @@ -268,7 +206,7 @@ local function chooseRom() end function RomImporter.new(onComplete) - local previousMarker = love.filesystem.read(MARKER_PATH) + local previousMarker = require("src.import.CacheFs").read(MARKER_PATH) local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER local android = love.system.getOS() == "Android" local self = setmetatable({ @@ -350,9 +288,15 @@ function RomImporter:startData(data, displayName) end self.status = "Preparing private game data" coroutine.yield() + -- Clear any previous cache from both possible homes: the save directory + -- (removeTree) and, for a portable install, the game folder (CacheFs). + local CacheFs = require("src.import.CacheFs") removeTree("data/generated") removeTree("assets/generated") love.filesystem.remove(MARKER_PATH) + CacheFs.removeTree("data/generated") + CacheFs.removeTree("assets/generated") + CacheFs.remove(MARKER_PATH) local manifest = decodeManifest() local RomExtractor = require("src.import.RomExtractor") @@ -367,13 +311,12 @@ function RomImporter:startData(data, displayName) extractor:run() self.romData = nil collectgarbage("collect") - local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER) + -- Written last: the marker is what isReady() checks, so it must only + -- appear once every required file is in place. CacheFs puts it beside + -- the cache -- the game folder for a portable install, else the save + -- directory. + local ok, writeError = CacheFs.write(MARKER_PATH, CACHE_MARKER) if not ok then error("could not finish the private cache: " .. tostring(writeError)) end - if require("src.core.SaveData").isPortable() then - self.status = "Copying data to the portable folder" - coroutine.yield() - syncCacheToPortable() - end self.state = "complete" self.status = "Ready" self.detail = "Starting Pokemon Red..."