Compare commits

..

4 Commits

Author SHA1 Message Date
bryanthaboi 09b12a7cf0 Fixes portable windows and unlocks orientation on android
Fixes portable windows
and unlocks orientation on android
2026-07-22 15:44:14 -04:00
bryanthaboi e2f2b432ff bazinga moment 2 2026-07-22 15:42:19 -04:00
bryanthaboi 8c274d969e Merge pull request #77 from bryanthaboi/portablemodefix
PORTABLE MODE
2026-07-22 14:20:00 -04:00
bryanthaboi 4c7633b56e PORTABLE MODE 2026-07-22 14:18:39 -04:00
12 changed files with 384 additions and 170 deletions
+9 -7
View File
@@ -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 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 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 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 between computers), drop an empty file named `portable.txt` next to the app
executable (or next to `main.lua`/`conf.lua` when running from source), then (next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
launch the game. 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: With `portable.txt` present:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to - `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory. that same folder instead of the OS save directory.
- After a ROM import, the generated `data/generated` and `assets/generated` - A ROM import writes the generated `data/generated` and `assets/generated`
cache is copied into that folder too, so a later launch (even on a cache straight into that folder too (nothing is left in the OS save
different computer, as long as the same folder comes along) reuses it directory), so a later launch reuses it without asking for the ROM again
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 - Deleting `portable.txt` switches back to the normal OS save directory; nothing
already written to either location is touched automatically, so copy files already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch. over yourself if you want to carry existing progress across the switch.
+13 -4
View File
@@ -37,10 +37,19 @@ function love.conf(t)
local osName = love._os local osName = love._os
local mobile = osName == "Android" or osName == "iOS" local mobile = osName == "Android" or osName == "iOS"
if mobile then if mobile then
-- On Android/iOS, width/height aspect picks portrait vs landscape -- resizable is what unlocks orientation. SDL's Android backend, given no
-- (fullscreen alone is not enough). Use a tall portrait size; the -- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- OS then resizes to the real display. highdpi is required for -- at window creation -- FULL_SENSOR when the window is resizable (rotates
-- Retina iOS (Android always behaves as highdpi). -- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
-- display and rotations resize again. highdpi is required for Retina iOS
-- (Android always behaves as highdpi).
t.window.width = 1080 t.window.width = 1080
t.window.height = 1920 t.window.height = 1920
t.window.fullscreen = true t.window.fullscreen = true
+3 -1
View File
@@ -6,7 +6,9 @@
#app.name=LÖVE for Android #app.name=LÖVE for Android
app.application_id=com.theboisclub.pokemonred app.application_id=com.theboisclub.pokemonred
app.orientation=portrait # fullUser: allow every orientation the player's device permits (portrait and
# landscape), honouring their auto-rotate lock. Was "portrait" (locked).
app.orientation=fullUser
app.version_code=32 app.version_code=32
app.version_name=11.5a app.version_name=11.5a
+5
View File
@@ -62,10 +62,15 @@
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UTExportedTypeDeclarations</key> <key>UTExportedTypeDeclarations</key>
<array> <array>
+1 -1
View File
@@ -101,7 +101,7 @@ def set_prop(text, key, value):
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text) text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
text = set_prop(text, "app.name", name) text = set_prop(text, "app.name", name)
text = set_prop(text, "app.application_id", app_id) text = set_prop(text, "app.application_id", app_id)
text = set_prop(text, "app.orientation", "portrait") text = set_prop(text, "app.orientation", "fullUser")
if version: if version:
text = set_prop(text, "app.version_name", version) text = set_prop(text, "app.version_name", version)
text = set_prop(text, "app.version_code", version_code) text = set_prop(text, "app.version_code", version_code)
+1 -1
View File
@@ -155,7 +155,7 @@ require_ios_libraries
apply_ios_branding() { apply_ios_branding() {
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST" [ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
local dest="$XCODE_DIR/ios/love-ios.plist" local dest="$XCODE_DIR/ios/love-ios.plist"
say "applying iOS branding (portrait-only Info.plist, display name)" say "applying iOS branding (portrait + landscape Info.plist, display name)"
cp "$OVERLAY_PLIST" "$dest" cp "$OVERLAY_PLIST" "$dest"
} }
+33 -7
View File
@@ -83,15 +83,41 @@ local function detectPortable()
portableChecked = true portableChecked = true
portableBase = false portableBase = false
if not (love and love.filesystem) then return false end 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 <gamedir>` 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 = {} local candidates = {}
if love.filesystem.getSourceBaseDirectory then local appDir = appContainer(src) or appContainer(sbd)
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory() if appDir then candidates[#candidates + 1] = appDir end
end if sbd then candidates[#candidates + 1] = sbd end
if love.filesystem.getSource then if src then candidates[#candidates + 1] = src end
candidates[#candidates + 1] = love.filesystem.getSource()
end
for _, base in ipairs(candidates) do 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 portableBase = base
break break
end end
+232
View File
@@ -0,0 +1,232 @@
-- 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 <gamedir>`, 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.
--
-- PHYSFS_mount is exported by love's own binary. How ffi finds it differs
-- per platform: on macOS/Linux the symbol is in the default namespace, so
-- ffi.C resolves it; on Windows it lives in love.dll, which ffi.C does NOT
-- search, so love.dll is loaded explicitly with ffi.load("love"). Try the
-- default first, then love.
local physfsMountFn = nil
local function resolveMount()
if physfsMountFn ~= nil then return physfsMountFn end
physfsMountFn = false
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountFn end
pcall(ffi.cdef,
"int PHYSFS_mount(const char *newDir, const char *mountPoint, int appendToPath);")
local libs = {
function() return ffi.C end,
function() return ffi.load("love") end,
}
for _, getlib in ipairs(libs) do
local okl, lib = pcall(getlib)
if okl and lib then
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
if oks and fn then
physfsMountFn = function(d)
local okr, ret = pcall(fn, d, "", 1)
return okr and ret ~= 0
end
break
end
end
end
return physfsMountFn
end
local function mountReadable(dir)
local fn = resolveMount()
if not fn then return false end
return fn(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
+6 -6
View File
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
end end
function ImageWriter.save(image, path) 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") local ok, fileData = pcall(image.encode, image, "png")
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end 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 if not written then
error("could not write " .. path .. ": " .. tostring(writeError)) error("could not write " .. path .. ": " .. tostring(writeError))
end end
+5 -11
View File
@@ -87,17 +87,11 @@ function LuaWriter.encode(value)
end end
function LuaWriter.write(path, value) function LuaWriter.write(path, value)
local parent = path:match("^(.*)/[^/]+$") -- CacheFs routes this to the OS save directory (normal builds) or straight
-- love.filesystem.createDirectory returns a single boolean (no error -- into the game folder (portable installs); it also creates the parent
-- value), so a second return here is always nil; getInfo after a failure -- directories. See src/import/CacheFs.lua.
-- at least reports what is blocking the path (e.g. a file with that name). local CacheFs = require("src.import.CacheFs")
if parent and not love.filesystem.createDirectory(parent) then local ok, err = CacheFs.write(path, LuaWriter.encode(value))
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))
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
end end
+5 -4
View File
@@ -1669,10 +1669,11 @@ function RomExtractor:extractAudio()
chunks[index] = self.rom.data:sub(first, first + 0x3FFF) chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
self:tick("Sound programs", index, #bankOrder + 2) self:tick("Sound programs", index, #bankOrder + 2)
end end
local ok, writeError = love.filesystem.createDirectory( -- CacheFs (not love.filesystem directly) so a portable install lands this
"assets/generated/audio") -- in the game folder with the rest of the cache; it creates the parent
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end -- directory too.
ok, writeError = love.filesystem.write( local CacheFs = require("src.import.CacheFs")
local ok, writeError = CacheFs.write(
"assets/generated/audio/programs.bin", table.concat(chunks)) "assets/generated/audio/programs.bin", table.concat(chunks))
if not ok then error("could not write audio programs: " .. tostring(writeError)) end if not ok then error("could not write audio programs: " .. tostring(writeError)) end
+71 -128
View File
@@ -24,8 +24,11 @@ local REQUIRED_FILES = {
} }
local function allRequiredFilesExist() 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 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 end
return true return true
end end
@@ -38,128 +41,21 @@ local function sourceTreeHasData()
return real == love.filesystem.getSource() return real == love.filesystem.getSource()
end end
-- ------- portable ROM-derived asset cache -- ------- ROM cache location
-- --
-- The extracted cache (data/generated, assets/generated) is written -- The extracted cache (data/generated, assets/generated) plus the
-- exclusively through love.filesystem.write, which always targets the OS -- rom-cache.complete marker normally live in LÖVE's per-user OS save
-- save directory -- it cannot be redirected to an arbitrary folder. So a -- directory. A portable install instead keeps them in the game folder next
-- portable install mirrors the cache both ways instead: after a fresh -- to the executable (the folder holding portable.txt), so nothing is left on
-- import, every generated file is copied out to the portable folder -- the host machine. Every cache write/read/remove goes through CacheFs,
-- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a -- which writes that folder with io.* and makes it readable (mounting it via
-- different machine sharing the same USB copy -- a matching portable -- PhysFS for a fused build) -- there is no mirror step and no per-file
-- cache is copied back into the save directory before the normal -- os.execute (issue #74: that flashed a console window per file on Windows
-- isReady() check runs, so nothing downstream needs to know the cache -- and froze the app).
-- 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
-- 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 function removeTree(path)
local info = love.filesystem.getInfo(path) local info = love.filesystem.getInfo(path)
if not info then return end if not info then return end
@@ -179,6 +75,48 @@ local function removeTree(path)
end end
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 function decodeManifest()
local raw, readError = love.filesystem.read("tools/rom_manifest.json") local raw, readError = love.filesystem.read("tools/rom_manifest.json")
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
@@ -268,7 +206,7 @@ local function chooseRom()
end end
function RomImporter.new(onComplete) 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 returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER
local android = love.system.getOS() == "Android" local android = love.system.getOS() == "Android"
local self = setmetatable({ local self = setmetatable({
@@ -350,9 +288,15 @@ function RomImporter:startData(data, displayName)
end end
self.status = "Preparing private game data" self.status = "Preparing private game data"
coroutine.yield() 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("data/generated")
removeTree("assets/generated") removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH) love.filesystem.remove(MARKER_PATH)
CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH)
local manifest = decodeManifest() local manifest = decodeManifest()
local RomExtractor = require("src.import.RomExtractor") local RomExtractor = require("src.import.RomExtractor")
@@ -367,13 +311,12 @@ function RomImporter:startData(data, displayName)
extractor:run() extractor:run()
self.romData = nil self.romData = nil
collectgarbage("collect") 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 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.state = "complete"
self.status = "Ready" self.status = "Ready"
self.detail = "Starting Pokemon Red..." self.detail = "Starting Pokemon Red..."