From ebd4985cc05918d18b78b170547dd26d247c608d Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 29 Sep 2026 10:08:21 -0400 Subject: [PATCH] CLOSES #2524, CLOSES #2540, CLOSES #2541, CLOSES #2542 --- README.md | 7 + src/battle/BattleState.lua | 39 +- src/battle/Status.lua | 16 + src/battle/TrainerAI.lua | 15 +- src/core/Printer.lua | 21 +- src/core/SaveData.lua | 340 +++++++++++++++-- src/core/TouchSkin.lua | 34 +- src/import/CacheFs.lua | 78 ++-- src/import/RomImporter.lua | 115 +++--- src/import/RomSources.lua | 34 +- src/render/ShaderFX.lua | 19 +- src/render/TextBox.lua | 30 +- tests/drivers/ai_xitem_sound_bug2541.lua | 75 ++++ .../drivers/learn_move_stale_page_bug2540.lua | 83 +++++ tests/drivers/oak_balls_text_bug2524.lua | 56 +++ .../residual_psn_seed_order_bug2542.lua | 85 +++++ tests/engine/battle_ai_item_sfx_bug2541.lua | 56 +++ .../engine/battle_learn_move_page_bug2540.lua | 79 ++++ tests/engine/leech_seed_timing_bug784.lua | 4 +- tests/engine/portable_writers_2517.lua | 342 ++++++++++++++++++ .../residual_psn_seed_order_bug2542.lua | 95 +++++ .../text_double_line_overwrite_bug2524.lua | 55 +++ 22 files changed, 1515 insertions(+), 163 deletions(-) create mode 100644 tests/drivers/ai_xitem_sound_bug2541.lua create mode 100644 tests/drivers/learn_move_stale_page_bug2540.lua create mode 100644 tests/drivers/oak_balls_text_bug2524.lua create mode 100644 tests/drivers/residual_psn_seed_order_bug2542.lua create mode 100644 tests/engine/battle_ai_item_sfx_bug2541.lua create mode 100644 tests/engine/battle_learn_move_page_bug2540.lua create mode 100644 tests/engine/portable_writers_2517.lua create mode 100644 tests/engine/residual_psn_seed_order_bug2542.lua create mode 100644 tests/engine/text_double_line_overwrite_bug2524.lua diff --git a/README.md b/README.md index f3424e16..ae860fea 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,13 @@ that same folder instead of the OS save directory. 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. +- The kept-ROM copy (`roms/`), import inboxes (`imports/`), `baseroms/`, +`shaders/`, `prints/`, touch skins, and the shader error log also live in +that folder. The auto-update payload cache (`updates/`) stays in the OS save +directory on purpose, as does the temporary shader download archive. +- If `portable.txt` is present but rejected (folder not writable, or the +folder cannot be mounted), the launcher log says why and cache writes fail +with a message instead of silently landing in the OS save directory. - 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/battle/BattleState.lua b/src/battle/BattleState.lua index f04d4101..2bbdd484 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2917,19 +2917,26 @@ function BattleState:residualFor(b, opp) -- engine/battle/core.asm:435-473 if b.residualDone then return end b.residualDone = true - local msgs = Status.residual(b, opp, self) + local msgs = Status.residualStatus(b, opp, self) local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status) for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end -- engine/battle/core.asm:490-493 if rec and rec.residual then self:animNext("BURN_PSN_ANIM", b.isPlayer) end - if b.leechSeeded and b.mon.hp > 0 then - -- the drain plays the ABSORB animation from the healing side - -- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation) + if #msgs > 0 then self:drainNext() end + self:actNext(function() self:residualSeedFor(b, opp) end) +end + +-- engine/battle/core.asm:497-530 +function BattleState:residualSeedFor(b, opp) + local msgs = Status.residualSeed(b, opp, self) + if #msgs > 0 then self:animNext("ABSORB", opp.isPlayer) + self:drainNext() + for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end end - if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved + if b.mon.hp <= 0 then self:waitNext(20) end self.sideToxic = self.sideToxic or {} if b.toxicCounter then self.sideToxic[b.isPlayer and "player" or "enemy"] = b.toxicCounter @@ -4002,7 +4009,9 @@ function BattleState:executeAction(user, target, action) end end self:drainNext() - require("src.core.Sound").play(self.data, "Heal_Ailment") + if TrainerAI.playsRestoringSfx(action.item) then + require("src.core.Sound").play(self.data, "Heal_Ailment") + end return end if action.special == "aiSwitch" then @@ -4974,10 +4983,26 @@ function BattleState:learnMove(mon, moveId) -- ordered insert so multi-level gains keep each level's checks -- between its own stat box and the next "grew to level" text self:uiNext(function() - return self:buildScreen("MoveLearnMenu", mon, moveId, nil, "Level_Up") + return self:buildScreen("MoveLearnMenu", mon, moveId, function(learned) + local name = mon.nickname or self.data.pokemon[mon.species].name + self:holdPage(learned + and self:romText("_LearnedMove1Text", "%s learned\n%s!", name, mdef.name) + or self:romText("_DidNotLearnText", "%s\ndid not learn\v%s!", name, mdef.name)) + end, "Level_Up") end) end +-- engine/pokemon/learn_move.asm:87-95 +function BattleState:holdPage(text) + local lines = {} + for chunk in (require("src.render.TextBox").strip(text) .. "\n"):gmatch("([^\n\v]*)[\n\v]") do + lines[#lines + 1] = Font.encode(chunk) + end + self.shown = {} + for i = math.max(1, #lines - 1), #lines do self.shown[#self.shown + 1] = lines[i] end + self.current, self.msgHold, self.msgPrompt, self.msgWaiting = nil, true, nil, nil +end + -- Map the battle was fought on (overworld wins; save.player.map is fallback). function BattleState.currentMapId(self) local game = self.game diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 3f907b26..2eb35cd7 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -409,6 +409,15 @@ end -- End-of-turn residual damage; opponent is needed for Leech Seed. -- Returns messages. function Status.residual(battler, opponent, battle) + local msgs = Status.residualStatus(battler, opponent, battle) + for _, m in ipairs(Status.residualSeed(battler, opponent, battle)) do + msgs[#msgs + 1] = m + end + return msgs +end + +-- engine/battle/core.asm:482-495 +function Status.residualStatus(battler, opponent, battle) local msgs = {} local mon = battler.mon -- the Haze move-forfeit only covers the turn Haze was used; if this @@ -421,6 +430,13 @@ function Status.residual(battler, opponent, battle) msgs[#msgs + 1] = m end end + return msgs +end + +-- engine/battle/core.asm:497-523 +function Status.residualSeed(battler, opponent, battle) + local msgs = {} + local mon = battler.mon if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then -- the shared Toxic counter multiplies (and advances on) the seed -- drain too -- the Gen 1 Leech Seed glitch diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 2e602b5a..ef8496ad 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -32,7 +32,18 @@ local function displayName(b) end local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 } -local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } +local X_STAT = { + X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed", + X_SPECIAL = "special", +} +local RESTORING_SFX = { + FULL_HEAL = true, GUARD_SPEC = true, X_ACCURACY = true, DIRE_HIT = true, +} + +-- engine/battle/trainer_ai.asm:459 +function TrainerAI.playsRestoringSfx(item) + return RESTORING_SFX[item] == true +end -- Strings.source, not Strings: harvested at require time so the catalog -- generator can see the literal, same pattern as MoveEffects.lua's @@ -40,7 +51,7 @@ local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } -- the harvester can't discover. local STAT_LABEL = { attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"), - speed = Strings.source("SPEED"), + speed = Strings.source("SPEED"), special = Strings.source("SPECIAL"), } -- The trainer's ai_classes record from the merged registry; the direct diff --git a/src/core/Printer.lua b/src/core/Printer.lua index 45ce4db9..74d4b3ab 100644 --- a/src/core/Printer.lua +++ b/src/core/Printer.lua @@ -60,12 +60,23 @@ function Printer.save(name, w, h, drawFn) local data ok, data = pcall(canvas.newImageData, canvas) if not ok then return nil, tostring(data) end - love.filesystem.createDirectory("prints") + local pfs = require("src.core.SaveData").portableFs() local path = ("prints/%s_%s.png"):format(name, os.date("%Y-%m-%d_%H%M%S")) - local encOk, err = pcall(data.encode, data, "png", path) - if not encOk then return nil, tostring(err) end - Logger.info("printed %s -> %s/%s", - name, love.filesystem.getSaveDirectory(), path) + local root + if pfs then + pfs.createDirectory("prints") + local encOk, fd = pcall(data.encode, data, "png") + if not encOk then return nil, tostring(fd) end + local wrote, werr = pfs.write(path, fd:getString()) + if not wrote then return nil, tostring(werr) end + root = require("src.core.SaveData").portableBaseDir() + else + love.filesystem.createDirectory("prints") + local encOk, err = pcall(data.encode, data, "png", path) + if not encOk then return nil, tostring(err) end + root = love.filesystem.getSaveDirectory() + end + Logger.info("printed %s -> %s/%s", name, root, path) exportToAndroidGallery(path) return path end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 1ba64260..65386af9 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -68,55 +68,328 @@ local SEP = package.config:sub(1, 1) local portableChecked = false local portableBase = false -- resolved base dir when active, else false local portableFsCache = nil +local portableTried = {} + +local IS_WIN = SEP == "\\" +local NON_ASCII = "[\128-\255]" + +local winApi = nil +local function loadWinApi() + if winApi ~= nil then return winApi end + winApi = false + if not IS_WIN then return winApi end + local okFfi, ffi = pcall(require, "ffi") + if not (okFfi and ffi.os == "Windows") then return winApi end + pcall(ffi.cdef, [[ + int MultiByteToWideChar(uint32_t cp, uint32_t flags, const char *s, int n, uint16_t *w, int wn); + int WideCharToMultiByte(uint32_t cp, uint32_t flags, const uint16_t *w, int wn, char *s, int n, const char *def, int *used); + uint32_t GetFileAttributesW(const uint16_t *path); + int CreateDirectoryW(const uint16_t *path, void *sec); + int DeleteFileW(const uint16_t *path); + void *CreateFileW(const uint16_t *path, uint32_t access, uint32_t share, void *sec, uint32_t disp, uint32_t flags, void *tmpl); + int ReadFile(void *h, void *buf, uint32_t n, uint32_t *got, void *ov); + int WriteFile(void *h, const void *buf, uint32_t n, uint32_t *put, void *ov); + int CloseHandle(void *h); + int SetFilePointerEx(void *h, int64_t dist, int64_t *newpos, uint32_t method); + typedef struct { + uint32_t attrs; uint32_t ct[2]; uint32_t at[2]; uint32_t wt[2]; + uint32_t sizeHigh; uint32_t sizeLow; uint32_t r0; uint32_t r1; + uint16_t name[260]; uint16_t alt[14]; + } PP_FIND_DATA_W; + void *FindFirstFileW(const uint16_t *pattern, PP_FIND_DATA_W *data); + int FindNextFileW(void *h, PP_FIND_DATA_W *data); + int FindClose(void *h); + ]]) + local C = ffi.C + if not pcall(function() + return C.FindFirstFileW, C.CreateFileW, C.SetFilePointerEx, C.CreateDirectoryW, C.DeleteFileW + end) then + return winApi + end + + local function wide(s) + local n = C.MultiByteToWideChar(65001, 0, s, #s, nil, 0) + if n <= 0 then return nil end + local buf = ffi.new("uint16_t[?]", n + 1) + C.MultiByteToWideChar(65001, 0, s, #s, buf, n) + buf[n] = 0 + return buf + end + local function narrow(w) + local n = C.WideCharToMultiByte(65001, 0, w, -1, nil, 0, nil, nil) + if n <= 1 then return "" end + local buf = ffi.new("char[?]", n) + C.WideCharToMultiByte(65001, 0, w, -1, buf, n, nil, nil) + return ffi.string(buf, n - 1) + end + local function validHandle(h) + return tonumber(ffi.cast("intptr_t", h)) ~= -1 + end + + local api = {} + function api.attrs(p) + local w = wide(p) + if not w then return nil end + local a = C.GetFileAttributesW(w) + if a == 0xFFFFFFFF then return nil end + return a + end + function api.mkdir(p) + local w = wide(p) + if w then C.CreateDirectoryW(w, nil) end + end + function api.remove(p) + local w = wide(p) + if not w then return false end + return C.DeleteFileW(w) ~= 0 + end + + local handle = {} + handle.__index = handle + function handle:read(fmt) + if type(fmt) == "number" then + if fmt == 0 then return "" end + local buf = ffi.new("uint8_t[?]", fmt) + local got = ffi.new("uint32_t[1]") + if C.ReadFile(self.h, buf, fmt, got, nil) == 0 or got[0] == 0 then return nil end + return ffi.string(buf, got[0]) + end + local parts = {} + local chunk = 1048576 + local buf = ffi.new("uint8_t[?]", chunk) + local got = ffi.new("uint32_t[1]") + while C.ReadFile(self.h, buf, chunk, got, nil) ~= 0 and got[0] > 0 do + parts[#parts + 1] = ffi.string(buf, got[0]) + end + return table.concat(parts) + end + function handle:write(...) + local put = ffi.new("uint32_t[1]") + for i = 1, select("#", ...) do + local s = tostring((select(i, ...))) + local off = 0 + while off < #s do + local n = math.min(#s - off, 1048576) + if C.WriteFile(self.h, ffi.cast("const char *", s) + off, n, put, nil) == 0 or put[0] == 0 then + return nil, "write failed" + end + off = off + put[0] + end + end + return self + end + function handle:seek(whence, off) + local method = ({ set = 0, cur = 1, ["end"] = 2 })[whence or "cur"] or 1 + local np = ffi.new("int64_t[1]") + if C.SetFilePointerEx(self.h, off or 0, np, method) == 0 then return nil, "seek failed" end + return tonumber(np[0]) + end + function handle:flush() return self end + function handle:close() + if self.h then C.CloseHandle(self.h); self.h = nil end + return true + end + function api.open(p, mode) + local w = wide(p) + if not w then return nil, "bad path" end + local writing = mode:find("[wa]") ~= nil + local h = C.CreateFileW(w, writing and 0x40000000 or 0x80000000, 3, nil, + writing and 2 or 3, 0x80, nil) + if not validHandle(h) then return nil, "cannot open " .. p end + local f = setmetatable({ h = h }, handle) + if mode:find("a") then f:seek("end", 0) end + return f + end + + function api.list(p) + local out = {} + local w = wide((p:gsub("/", "\\")) .. "\\*") + if not w then return out end + local data = ffi.new("PP_FIND_DATA_W[1]") + local h = C.FindFirstFileW(w, data) + if not validHandle(h) then return out end + repeat + local name = narrow(data[0].name) + if name ~= "." and name ~= ".." and name ~= "" then out[#out + 1] = name end + until C.FindNextFileW(h, data) == 0 + C.FindClose(h) + return out + end + + winApi = api + return winApi +end + +local posixApi = nil +local function loadPosixApi() + if posixApi ~= nil then return posixApi end + posixApi = false + if IS_WIN then return posixApi end + local okFfi, ffi = pcall(require, "ffi") + if not okFfi then return posixApi end + local osx = ffi.os == "OSX" + local osxIntel = osx and ffi.arch == "x64" + pcall(ffi.cdef, "int mkdir(const char *pathname, unsigned int mode);") + pcall(ffi.cdef, "void *opendir(const char *name);") + pcall(ffi.cdef, "int closedir(void *dirp);") + pcall(ffi.cdef, "void *readdir(void *dirp);") + if osxIntel then + pcall(ffi.cdef, 'void *pp_readdir64(void *dirp) __asm__("readdir$INODE64");') + end + local C = ffi.C + if not pcall(function() return C.mkdir end) then return posixApi end + + local api = {} + function api.mkdir(p) pcall(C.mkdir, p, 493) end + + local readdirFn = nil + local nameOff = nil + if osxIntel then + local okR, fn = pcall(function() return C.pp_readdir64 end) + if okR then readdirFn = fn end + nameOff = 21 + elseif osx then + local okR, fn = pcall(function() return C.readdir end) + if okR then readdirFn = fn end + nameOff = 21 + elseif ffi.os == "Linux" and ffi.abi("64bit") then + local okR, fn = pcall(function() return C.readdir end) + if okR then readdirFn = fn end + nameOff = 19 + end + if readdirFn and pcall(function() return C.opendir, C.closedir end) then + function api.list(p) + local out = {} + local d = C.opendir(p) + if d == nil then return out end + while true do + local ent = readdirFn(d) + if ent == nil then break end + local name = ffi.string(ffi.cast("const char *", ent) + nameOff) + if name ~= "." and name ~= ".." then out[#out + 1] = name end + end + C.closedir(d) + return out + end + end + posixApi = api + return posixApi +end + +local function nativeOpen(path, mode) + mode = mode or "rb" + local w = loadWinApi() + if w and path:find(NON_ASCII) then return w.open(path, mode) end + return io.open(path, mode) +end + +local function nativeRemove(path) + local w = loadWinApi() + if w and path:find(NON_ASCII) then return w.remove(path) end + return os.remove(path) +end + +local function nativeStat(path) + local w = loadWinApi() + if w then + local a = w.attrs(path) + if not a then return nil end + if a % 32 >= 16 then return { type = "directory" } end + local f = w.open(path, "rb") + local size = nil + if f then size = f:seek("end"); f:close() end + return { type = "file", size = size } + end + local f = io.open(path, "rb") + if not f then return nil end + local okRead, _, rerr = pcall(f.read, f, 0) + local isDir = okRead and rerr ~= nil + local size = nil + if not isDir then size = f:seek("end") end + f:close() + return { type = isDir and "directory" or "file", size = size } +end + +local function nativeMkdirp(path) + if type(path) ~= "string" or path == "" then return false end + local function isDir(p) + local st = nativeStat(p) + return st ~= nil and st.type == "directory" + end + if isDir(path) then return true end + local api = loadWinApi() or loadPosixApi() + if not api then return false end + local norm = path:gsub("\\", "/") + local cur = norm:sub(1, 1) == "/" and "" or nil + for part in norm:gmatch("[^/]+") do + cur = cur == nil and part or (cur .. "/" .. part) + if not cur:match("^%a:$") and not isDir(cur) then api.mkdir(cur) end + end + return isDir(path) +end + +local function nativeList(path) + local api = loadWinApi() or loadPosixApi() + if not (api and api.list) then return nil end + return api.list(path) +end local function pathExists(path) - local f = io.open(path, "rb") - if not f then return false end - f:close() - return true + return nativeStat(path) ~= nil end +SaveData.openNative = nativeOpen +SaveData.removeNative = nativeRemove +SaveData.statNative = nativeStat +SaveData.mkdirNative = nativeMkdirp +SaveData.listNative = nativeList + -- an io.* filesystem exposing the love.filesystem subset the save/options -- round-trip needs (getInfo/read/write/remove), rooted at `dir` local function makePortableFs(dir) local function full(name) return dir .. SEP .. name end return { - getInfo = function(name) - if not pathExists(full(name)) then return nil end - return { type = "file" } + getInfo = function(name, filter) + local st = nativeStat(full(name)) + if not st then return nil end + if filter and filter ~= st.type then return nil end + return st + end, + getDirectoryItems = function(name) + local items = nativeList(full(name)) + if not items then + items = {} + if love and love.filesystem and love.filesystem.getDirectoryItems then + items = love.filesystem.getDirectoryItems(name) + end + end + table.sort(items) + return items end, read = function(name) - local f = io.open(full(name), "rb") + local f = nativeOpen(full(name), "rb") if not f then return nil, "no file: " .. name end local data = f:read("*a") f:close() return data end, write = function(name, data) - local f, err = io.open(full(name), "wb") + local f, err = nativeOpen(full(name), "wb") if not f then return false, err end f:write(data) f:close() return true end, remove = function(name) - os.remove(full(name)) + nativeRemove(full(name)) return true end, createDirectory = function(name) - -- portable mode writes real files through io.*, which will not - -- create missing parent directories; mkdir the tree so a slot path - -- like "saves/red" exists before a write lands inside it - if type(name) ~= "string" or name:find("[^%w%._%-%/]") or name:find("%.%.") then + if type(name) ~= "string" or name == "" or name:find("[%c\\:*?\"<>|]") + or name:find("%.%.") or name:sub(1, 1) == "/" then return false end - local osPath = full(name):gsub("/", SEP) - if SEP == "\\" then - os.execute('mkdir "' .. osPath .. '" 2>nul') - else - os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null') - end - return true + return nativeMkdirp(full(name)) end, } end @@ -185,11 +458,11 @@ local function dirIsWritable(dir) local name = string.format(".write_probe_%d_%d.tmp", os.time() % 100000000, math.random(0, 999999)) local path = dir .. SEP .. name - local f, err = io.open(path, "wb") + local f = nativeOpen(path, "wb") if not f then return false end local wrote = f:write("ok") f:close() - pcall(os.remove, path) + pcall(nativeRemove, path) return wrote ~= nil end) return ok and writable == true @@ -203,12 +476,26 @@ local function detectPortable() if type(os.getenv) == "function" and os.getenv("FLATPAK_ID") then return portableBase end + portableTried = {} for _, base in ipairs(SaveData.gameFolders()) do local markerOk = false pcall(function() markerOk = pathExists(base .. SEP .. PORTABLE_MARKER) end) - if markerOk and dirIsWritable(base) then + local writable = markerOk and dirIsWritable(base) or false + local reason = nil + if not markerOk then + reason = "no portable.txt" + elseif not writable then + reason = "folder not writable" + end + portableTried[#portableTried + 1] = { + path = base, marker = markerOk, writable = writable, reason = reason, + } + if markerOk and not writable then + require("src.core.Logger").warn("portable rejected %s: %s", tostring(base), reason) + end + if markerOk and writable then portableBase = base break end @@ -216,6 +503,11 @@ local function detectPortable() return portableBase end +function SaveData.portableStatus() + local base = detectPortable() + return { base = base or nil, tried = portableTried } +end + -- Soft-reset cache so tests can re-run detect after env changes. function SaveData._resetPortableCacheForTests() portableChecked = false diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 12bd8df9..5bf13ad8 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -357,6 +357,10 @@ local function listDir(path) return {} end +local function wfs() + return require("src.core.SaveData").persistenceFs(love.filesystem) +end + local function isDir(path) if love and love.filesystem and love.filesystem.getInfo then local ok, info = pcall(love.filesystem.getInfo, path) @@ -733,7 +737,7 @@ function TouchSkin.list() end end if love and love.filesystem and love.filesystem.createDirectory then - pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT) + pcall(wfs().createDirectory, TouchSkin.USER_ROOT) end scan(TouchSkin.USER_ROOT, "user") scan(TouchSkin.BUNDLED_ROOT, "bundled") @@ -756,19 +760,19 @@ function TouchSkin.installArchive(name, data) local id = TouchSkin.archiveId(name) if not id then return nil, "not a .zip or .deltaskin" end - pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT) + pcall(wfs().createDirectory, TouchSkin.USER_ROOT) local dest = TouchSkin.USER_ROOT .. "/" .. name - local ok, err = love.filesystem.write(dest, data) + local ok, err = wfs().write(dest, data) if not ok then return nil, tostring(err) end local entry = TouchSkin.find(id) if not entry then - love.filesystem.remove(dest) + wfs().remove(dest) return nil, "no skin.lua, .cfg or info.json inside " .. name end local skin = TouchSkin.load(entry.root, entry.id) if skin and require("src.core.DeltaSkin").needsConversion(skin) then - love.filesystem.remove(dest) + wfs().remove(dest) return nil, TouchSkin.PDF_ONLY_MESSAGE end return id, skin and skin.warnings or nil @@ -798,7 +802,7 @@ function TouchSkin.remove(id) if not ok then return nil, err end end end - local ok, err = love.filesystem.remove(path) + local ok, err = wfs().remove(path) return ok and true or nil, err end return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id)) @@ -827,10 +831,10 @@ local function writeArchive(entries, destPath) local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil if not absolute and love and love.filesystem and love.filesystem.createDirectory then local dir = destPath:match("^(.*)/[^/]+$") - if dir then pcall(love.filesystem.createDirectory, dir) end + if dir then pcall(wfs().createDirectory, dir) end end if not absolute and love and love.filesystem and love.filesystem.write then - local ok, err = love.filesystem.write(destPath, blob) + local ok, err = wfs().write(destPath, blob) if not ok then return nil, tostring(err) end else local handle = io.open(destPath, "wb") @@ -1130,9 +1134,9 @@ function TouchSkin.importImage(skin, name, data) local saved, err = TouchSkin.saveTo(skin, skin.id) if not saved then return nil, tostring(err) end end - pcall(love.filesystem.createDirectory, dest .. "/img") + pcall(wfs().createDirectory, dest .. "/img") local rel = "img/" .. name - local ok, err = love.filesystem.write(dest .. "/" .. rel, data) + local ok, err = wfs().write(dest .. "/" .. rel, data) if not ok then return nil, tostring(err) end return rel end @@ -1162,25 +1166,25 @@ function TouchSkin.saveTo(skin, id) return nil, "no writable filesystem" end local dest = TouchSkin.USER_ROOT .. "/" .. id - pcall(love.filesystem.createDirectory, dest) + pcall(wfs().createDirectory, dest) local copied, failed = 0, {} for _, rel in ipairs(TouchSkin.assetPaths(skin)) do local target = dest .. "/" .. rel local dir = target:match("^(.*)/[^/]+$") - if dir then pcall(love.filesystem.createDirectory, dir) end + if dir then pcall(wfs().createDirectory, dir) end if skin.root ~= dest then local data = readFile(joinPath(skin.root, rel)) if data then - if love.filesystem.write(target, data) then copied = copied + 1 end + if wfs().write(target, data) then copied = copied + 1 end else failed[#failed + 1] = rel end end end - local ok, err = love.filesystem.write(dest .. "/" .. TouchSkin.NATIVE_NAME, - TouchSkin.serialize(skin)) + local ok, err = wfs().write(dest .. "/" .. TouchSkin.NATIVE_NAME, + TouchSkin.serialize(skin)) if not ok then return nil, tostring(err) end skin.id, skin.root, skin.format = id, dest, "native" return dest, failed, copied diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 96ae1f1e..d95b82e4 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -29,6 +29,9 @@ local CacheFs = {} local Platform = require("src.core.Platform") +local SaveData = setmetatable({}, { + __index = function(_, k) return require("src.core.SaveData")[k] end, +}) local SEP = package.config:sub(1, 1) @@ -58,34 +61,9 @@ local function resolveMkdir() if mkdirFn ~= nil then return mkdirFn end mkdirFn = false if Platform.isUWP() then return mkdirFn end - local ok, ffi = pcall(require, "ffi") + local ok = 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 - else - local okl, clib = pcall(ffi.load, "c") - if okl and clib then - local okm = pcall(function() return clib.mkdir end) - if okm then - mkdirFn = function(path) pcall(clib.mkdir, path, 493) end - end - end - end - end + mkdirFn = function(path) return SaveData.mkdirNative(path) end return mkdirFn end @@ -235,14 +213,17 @@ end -- desktop portable install (SaveData) and a working windowless mkdir. local portableRoot = nil local portableResolved = false +local portableFailure = nil local function resolvePortableRoot() if portableResolved then return portableRoot end portableResolved = true portableRoot = nil - if not resolveMkdir() then return nil end + portableFailure = nil local base = require("src.core.SaveData").portableBaseDir() if not base then return nil end - if type(love.filesystem) == "table" and love.filesystem.getSource + if not resolveMkdir() then + portableFailure = "portable mode is on (" .. base .. ") but no folder-creation call is available" + elseif type(love.filesystem) == "table" and love.filesystem.getSource and base == love.filesystem.getSource() then -- source run: the folder is already the physfs source portableRoot = base @@ -250,6 +231,11 @@ local function resolvePortableRoot() -- fused build: base is next to the executable; mount it so io.* writes -- there are visible to love.filesystem/require/newImage portableRoot = base + else + portableFailure = "portable mode is on (" .. base .. ") but the folder could not be mounted" + end + if portableFailure then + require("src.core.Logger").warn("%s", portableFailure) end return portableRoot end @@ -258,6 +244,17 @@ function CacheFs.root() return resolvePortableRoot() end +function CacheFs.portableError() + resolvePortableRoot() + return portableFailure +end + +function CacheFs._resetPortableForTests() + portableRoot = nil + portableResolved = false + portableFailure = nil +end + local function realPath(root, rel) return root .. SEP .. rel:gsub("/", SEP) end @@ -290,6 +287,7 @@ end local function resolveWriteRoot() local root = CacheFs.root() if root then return root end + if portableFailure then return nil, portableFailure end if Platform.isNX and Platform.isNX() then return nil end if Platform.isUWP and Platform.isUWP() then return nil end if love and (love._version or love.getVersion) and love.filesystem and love.filesystem.getSaveDirectory then @@ -322,10 +320,11 @@ end function CacheFs.write(rel, data) rel = withPrefix(rel) if unsafe_rel(rel) then return false, "unsafe cache path" end - local root = resolveWriteRoot() + local root, rootErr = resolveWriteRoot() + if not root and rootErr then return false, rootErr end if root then ensureParents(root, rel) - local f, err = io.open(realPath(root, rel), "wb") + local f, err = SaveData.openNative(realPath(root, rel), "wb") if not f then return false, err end f:write(data) f:close() @@ -346,10 +345,11 @@ end function CacheFs.openWrite(rel) rel = withPrefix(rel) if unsafe_rel(rel) then return nil, "unsafe cache path" end - local root = resolveWriteRoot() + local root, rootErr = resolveWriteRoot() + if not root and rootErr then return nil, rootErr end if root then ensureParents(root, rel) - local f, err = io.open(realPath(root, rel), "wb") + local f, err = SaveData.openNative(realPath(root, rel), "wb") if not f then return nil, err end return { write = function(_, data) @@ -381,7 +381,7 @@ function CacheFs.readAt(rel) if unsafe_rel(rel) then return nil end local root = CacheFs.root() if root then - local f = io.open(realPath(root, rel), "rb") + local f = SaveData.openNative(realPath(root, rel), "rb") if not f then return nil end local data = f:read("*a") f:close() @@ -447,7 +447,7 @@ function CacheFs.existsAt(rel) if unsafe_rel(rel) then return false end local root = CacheFs.root() if root then - local f = io.open(realPath(root, rel), "rb") + local f = SaveData.openNative(realPath(root, rel), "rb") if not f then return false end f:close() return true @@ -469,7 +469,7 @@ function CacheFs.remove(rel) if unsafe_rel(rel) then return false end local root = CacheFs.root() if root then - os.remove(realPath(root, rel)) + SaveData.removeNative(realPath(root, rel)) return end if love and love.filesystem and love.filesystem.remove then @@ -516,7 +516,7 @@ function CacheFs.removeTree(rel) walk(r .. "/" .. child) end else - os.remove(realPath(root, r)) + SaveData.removeNative(realPath(root, r)) end end walk(rel) @@ -603,7 +603,7 @@ function CacheFs.migrateLegacyRedCache() 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") + local f = SaveData.openNative(realPath(root, rel), "rb") if f then f:close() return true end return false end @@ -673,7 +673,7 @@ function CacheFs.mountVersion(version) -- Portable / desktop fused: absolute PHYSFS_mount of the version folder. if sub ~= "" then local base = CacheFs.root() - if not base and love.filesystem.getSaveDirectory then + if not base and not portableFailure and love.filesystem.getSaveDirectory then base = love.filesystem.getSaveDirectory() end if base then diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 2475b152..048c6f0e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -448,6 +448,10 @@ local function commandOutput(command) return result ~= "" and result or nil end +local function pfs() + return require("src.core.SaveData").persistenceFs(love.filesystem) +end + local IMPORTS_DIR = "imports" local BASE_ROMS_DIR = "baseroms" local MODS_INBOX_DIR = "imports/mods" @@ -510,11 +514,12 @@ function RomImporter.mtpHintPath(saveDir) end function RomImporter:ensureImportsDir() - local info = love.filesystem.getInfo(IMPORTS_DIR) + local fs = pfs() + local info = fs.getInfo(IMPORTS_DIR) if info and info.type == "directory" then return true end if info then return false end - if love.filesystem.createDirectory then - return love.filesystem.createDirectory(IMPORTS_DIR) + if fs.createDirectory then + return fs.createDirectory(IMPORTS_DIR) end return false end @@ -523,22 +528,24 @@ end -- love.filesystem.createDirectory does not create nested parents. function RomImporter:ensureCartsInboxDir() self:ensureImportsDir() - local info = love.filesystem.getInfo(CARTS_INBOX_DIR) + local fs = pfs() + local info = fs.getInfo(CARTS_INBOX_DIR) if info and info.type == "directory" then return true end if info then return false end - if love.filesystem.createDirectory then - return love.filesystem.createDirectory(CARTS_INBOX_DIR) + if fs.createDirectory then + return fs.createDirectory(CARTS_INBOX_DIR) end return false end function RomImporter:ensureModsInboxDir() self:ensureImportsDir() - local info = love.filesystem.getInfo(MODS_INBOX_DIR) + local fs = pfs() + local info = fs.getInfo(MODS_INBOX_DIR) if info and info.type == "directory" then return true end if info then return false end - if love.filesystem.createDirectory then - return love.filesystem.createDirectory(MODS_INBOX_DIR) + if fs.createDirectory then + return fs.createDirectory(MODS_INBOX_DIR) end return false end @@ -548,21 +555,22 @@ end -- Creates all three version folders so MTP browsing shows where each game goes. function RomImporter:ensureSavesInboxDir(version) self:ensureImportsDir() - local info = love.filesystem.getInfo(SAVES_INBOX_DIR) + local fs = pfs() + local info = fs.getInfo(SAVES_INBOX_DIR) if info and info.type ~= "directory" then return false end if not info then - if not (love.filesystem.createDirectory - and love.filesystem.createDirectory(SAVES_INBOX_DIR)) then + if not (fs.createDirectory + and fs.createDirectory(SAVES_INBOX_DIR)) then return false end end for _, v in ipairs(SecretGames.order(self)) do local dir = savesInboxDir(v) - local vInfo = love.filesystem.getInfo(dir) + local vInfo = fs.getInfo(dir) if vInfo and vInfo.type ~= "directory" then return false end if not vInfo then - if not (love.filesystem.createDirectory - and love.filesystem.createDirectory(dir)) then + if not (fs.createDirectory + and fs.createDirectory(dir)) then return false end end @@ -623,15 +631,27 @@ function RomImporter:_setNxSavesInboxNotice(version) } end +local function listFs(dir) + if dir == "" or dir == "/" then return love.filesystem end + return pfs() +end + +local function readListed(path) + local dir = path:match("^(.*)/[^/]+$") or "" + local data = listFs(dir).read(path) + return type(data) == "string" and data or nil +end + local function listRomPaths(dir) local paths = {} - for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do + local fs = listFs(dir) + for _, name in ipairs(fs.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._cart.gb ends in .gb -- but is not a ROM -- rescan would try it first and block the real dump). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if isRomFilename(name) - and love.filesystem.getInfo(path, "file") then + and fs.getInfo(path, "file") then paths[#paths + 1] = path end end @@ -663,9 +683,9 @@ function RomImporter:_stepBaseRomScan() return end if scan.state == "queued" then - local info = love.filesystem.getInfo(BASE_ROMS_DIR) - if not info and love.filesystem.createDirectory then - love.filesystem.createDirectory(BASE_ROMS_DIR) + local info = pfs().getInfo(BASE_ROMS_DIR) + if not info and pfs().createDirectory then + pfs().createDirectory(BASE_ROMS_DIR) end scan.paths = listRomPaths(BASE_ROMS_DIR) table.sort(scan.paths) @@ -679,9 +699,9 @@ function RomImporter:_stepBaseRomScan() end scan.index = scan.index + 1 - local info = love.filesystem.getInfo(path, "file") + local info = pfs().getInfo(path, "file") if info and isAcceptedRomSize(info.size) then - local data = love.filesystem.read(path) + local data = pfs().read(path) if type(data) == "string" and isAcceptedRomSize(#data) then local version = self:_versionForSha1(sha1(data)) if version and not self.ready[version] and not self.baseRoms[version] then @@ -700,13 +720,14 @@ end local function listZipPaths(dir) local paths = {} - for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do + local fs = listFs(dir) + for _, name in ipairs(fs.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip -- but is not a PhysFS archive -- mount fails with "could not be opened"). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.zip$") - and love.filesystem.getInfo(path, "file") then + and fs.getInfo(path, "file") then paths[#paths + 1] = path end end @@ -716,13 +737,14 @@ end local function listSavPaths(dir) local paths = {} - for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do + local fs = listFs(dir) + for _, name in ipairs(fs.getDirectoryItems(dir) or {}) do -- Skip AppleDouble / hidden junk from Mac MTP (._foo.sav ends in .sav -- but is not a real battery save -- import would fail and invent noise). if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.sav$") - and love.filesystem.getInfo(path, "file") then + and fs.getInfo(path, "file") then paths[#paths + 1] = path end end @@ -750,11 +772,12 @@ end local function listCartPaths(dir) local paths = {} - for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do + local fs = listFs(dir) + for _, name in ipairs(fs.getDirectoryItems(dir) or {}) do if name:sub(1, 1) ~= "." then local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) if name:lower():match("%.g1rcart$") - and love.filesystem.getInfo(path, "file") then + and fs.getInfo(path, "file") then paths[#paths + 1] = path end end @@ -777,7 +800,7 @@ end local function loadImportedSavHashes(version) local set = {} - local raw = love.filesystem.read(savesImportedHashesPath(version)) + local raw = pfs().read(savesImportedHashesPath(version)) if type(raw) ~= "string" then return set end for line in raw:gmatch("[^\r\n]+") do local h = line:match("^(%x+)$") @@ -789,22 +812,23 @@ end local function appendImportedSavHash(version, hash) if type(hash) ~= "string" or hash == "" then return end local path = savesImportedHashesPath(version) - local prev = love.filesystem.read(path) or "" + local prev = pfs().read(path) or "" if prev:find(hash, 1, true) then return end - love.filesystem.write(path, prev .. hash .. string.char(10)) + pfs().write(path, prev .. hash .. string.char(10)) end -- Keep bytes for the player (MTP recovery) but stop matching %.sav$ on rescan. local function retireImportedSav(path) if type(path) ~= "string" or path == "" then return false end - local data = love.filesystem.read(path) + local fs = pfs() + local data = fs.read(path) if type(data) ~= "string" then return false end local dest = path .. ".imported" - if love.filesystem.getInfo(dest) then + if fs.getInfo(dest) then dest = path .. ".imported." .. tostring(os.time()) end - if not love.filesystem.write(dest, data) then return false end - love.filesystem.remove(path) + if not fs.write(dest, data) then return false end + fs.remove(path) return true end @@ -870,7 +894,7 @@ function RomImporter:rescanSavesAction(version) local lastOk, lastFail = nil, nil local gameLabel = GameVersion.info(version).displayName for _, path in ipairs(candidates) do - local data = love.filesystem.read(path) + local data = readListed(path) local hash = (type(data) == "string" and data ~= "") and sha1(data) or nil if hash and seenHashes[hash] then skipCount = skipCount + 1 @@ -938,7 +962,7 @@ function RomImporter:rescanAction(version) local sawOtherVersion = false local junkData, junkName = nil, nil for _, path in ipairs(candidates) do - local data = love.filesystem.read(path) + local data = readListed(path) local displayName = path:match("[^/\\]+$") or path if type(data) ~= "string" then self:setError("The file could not be read: " .. displayName, version) @@ -1619,6 +1643,14 @@ function RomImporter.new(onComplete, opts) if GameVersion.VERSIONS[self.tab] and not SecretGames.shown(self, self.tab) then self.tab = "red" end + local portableErr = CacheFs.portableError() + if portableErr then + self.notice = { + version = self.tab or "red", + status = Strings("Portable folder unavailable"), + detail = portableErr, + } + end RomImporter.syncAndroidShortcuts() Transition.reset() Transition.armed = false @@ -2933,12 +2965,13 @@ function RomImporter:chooseRequiredImport(modId, importId) if self.isNX then local inbox = "imports/baseroms" - love.filesystem.createDirectory(inbox) + local fs = pfs() + fs.createDirectory(inbox) local lastError - for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do + for _, name in ipairs(fs.getDirectoryItems(inbox) or {}) do if name:sub(1, 1) ~= "." then local path = inbox .. "/" .. name - local info = love.filesystem.getInfo(path, "file") + local info = fs.getInfo(path, "file") local RequiredImports = require("src.mods.RequiredImports") local sizeErr = info and RequiredImports.sizeError(spec, info.size, false) @@ -2946,7 +2979,7 @@ function RomImporter:chooseRequiredImport(modId, importId) and info.size > RequiredImports.LARGE_WARN_BYTES then return self:_importRequiredSource(modId, importId, path) end - local data = not sizeErr and love.filesystem.read(path) or nil + local data = not sizeErr and fs.read(path) or nil if data and self:_importRequiredData(modId, importId, data) then return end if sizeErr then lastError = sizeErr elseif self.requiredImportNotice diff --git a/src/import/RomSources.lua b/src/import/RomSources.lua index 2aea9166..67c5dee2 100644 --- a/src/import/RomSources.lua +++ b/src/import/RomSources.lua @@ -83,6 +83,10 @@ function RomSources.keptPath(version) return RomSources.KEEP_DIR .. "/" .. keptName(version) end +local function pfs() + return SaveData.persistenceFs(love.filesystem) +end + local function nativePath(path) local prefix = RomSources.PRIVATE_PREFIX if path:sub(1, #prefix) == prefix then @@ -98,13 +102,13 @@ function RomSources.readKept(path) if type(path) ~= "string" then return nil end local native = nativePath(path) if native then - local file = io.open(native, "rb") + local file = SaveData.openNative(native, "rb") if not file then return nil end local data = file:read("*a") file:close() return data end - local data = love.filesystem.read(path) + local data = pfs().read(path) return type(data) == "string" and data or nil end @@ -112,35 +116,36 @@ function RomSources.keptExists(path) if type(path) ~= "string" then return false end local native = nativePath(path) if native then - local file = io.open(native, "rb") + local file = SaveData.openNative(native, "rb") if not file then return false end file:close() return true end - return love.filesystem.getInfo(path, "file") ~= nil + return pfs().getInfo(path, "file") ~= nil end function RomSources.removeKept(path) if type(path) ~= "string" then return end local native = nativePath(path) if native then - os.remove(native) + SaveData.removeNative(native) elseif path:sub(1, #RomSources.PRIVATE_PREFIX) ~= RomSources.PRIVATE_PREFIX then - love.filesystem.remove(path) + pfs().remove(path) end end local function writeKept(path, data) local native = nativePath(path) if native then - local file = io.open(native, "wb") + local file = SaveData.openNative(native, "wb") if not file then return false end local ok = file:write(data) file:close() return ok ~= nil end - love.filesystem.createDirectory(RomSources.KEEP_DIR) - return love.filesystem.write(path, data) == true + local fs = pfs() + fs.createDirectory(RomSources.KEEP_DIR) + return fs.write(path, data) == true end function RomSources.get(version, opts) @@ -182,7 +187,7 @@ function RomSources.migrateKept(opts) end end if changed then - local fs = love.filesystem + local fs = pfs() if fs.getInfo(RomSources.KEEP_DIR, "directory") and #fs.getDirectoryItems(RomSources.KEEP_DIR) == 0 then fs.remove(RomSources.KEEP_DIR) @@ -211,11 +216,12 @@ function RomSources.forgetAll(opts) local path = RomSources.keptPath(version) if nativePath(path) then RomSources.removeKept(path) end end - if love.filesystem.getInfo(RomSources.KEEP_DIR, "directory") then - for _, name in ipairs(love.filesystem.getDirectoryItems(RomSources.KEEP_DIR)) do - love.filesystem.remove(RomSources.KEEP_DIR .. "/" .. name) + local fs = pfs() + if fs.getInfo(RomSources.KEEP_DIR, "directory") then + for _, name in ipairs(fs.getDirectoryItems(RomSources.KEEP_DIR)) do + fs.remove(RomSources.KEEP_DIR .. "/" .. name) end - love.filesystem.remove(RomSources.KEEP_DIR) + fs.remove(RomSources.KEEP_DIR) end opts.romSources = nil end diff --git a/src/render/ShaderFX.lua b/src/render/ShaderFX.lua index 2a7a4f9c..d67381e4 100644 --- a/src/render/ShaderFX.lua +++ b/src/render/ShaderFX.lua @@ -29,7 +29,7 @@ local function realFullPath(rel, fallbackDir) if ok and type(root) == "string" and root ~= "" then local sep = package.config:sub(1, 1) local path = (root:gsub("[/\\]+$", "")) .. sep .. rel - local f = io.open(path, "rb") + local f = SaveData.openNative(path, "rb") if f then f:close() return path @@ -43,7 +43,7 @@ function ShaderFX.list() local dir = ShaderFX.presetDir() if not dir then return {} end pcall(function() require("src.import.CacheFs").root() end) - love.filesystem.createDirectory("shaders") + SaveData.persistenceFs(love.filesystem).createDirectory("shaders") local out = {} local function scan(relPath) local items = love.filesystem.getDirectoryItems(relPath) @@ -74,7 +74,7 @@ end -- A real OS path outside any love.filesystem mount, so io.open, not getInfo. function ShaderFX.isConverted(entry) - local f = io.open(ShaderFX.artifactPath(entry), "rb") + local f = SaveData.openNative(ShaderFX.artifactPath(entry), "rb") if not f then return false end f:close() return true @@ -259,7 +259,7 @@ function ShaderFX.installDownloaded(notModified) if notModified then return 0, nil, true end - love.filesystem.createDirectory("shaders") + SaveData.persistenceFs(love.filesystem).createDirectory("shaders") if not love.filesystem.mount(DOWNLOAD_ZIP_REL, DOWNLOAD_MOUNT) then return nil, "could not open the downloaded archive" end @@ -323,7 +323,7 @@ local function saveDir() end local function fileReadable(path) - local f = io.open(path, "rb") + local f = SaveData.openNative(path, "rb") if not f then return false end f:close() return true @@ -474,7 +474,7 @@ end -- love.graphics.newImage cannot open an absolute path outside LOVE's own -- mounts, which is where a preset's LUTs live, so read the bytes by hand. function ShaderFX.loadImageFromPath(path) - local f, err = io.open(path, "rb") + local f, err = SaveData.openNative(path, "rb") if not f then return nil, "io.open failed: " .. tostring(err) end local bytes = f:read("*a") f:close() @@ -856,7 +856,7 @@ local function doConvert(entry, es) local buf = {} serializeLua(preset, buf) local path = ShaderFX.artifactPath(entry) - local f, ferr = io.open(path, "wb") + local f, ferr = SaveData.openNative(path, "wb") if not f then return false, "io.open failed: " .. tostring(ferr) end f:write("return ") f:write(table.concat(buf)) @@ -905,10 +905,11 @@ function ShaderFX.recordError(presetName, message) errs[#errs + 1] = line while #errs > MAX_ERRORS do table.remove(errs, 1) end require("src.core.Logger").error("ShaderFX: %s", line) - if love.filesystem and love.filesystem.write then + local pfs = love.filesystem and SaveData.persistenceFs(love.filesystem) + if pfs and pfs.write then local head = ("es=%s glsl3=%s renderer=%s\n\n"):format( tostring(defaultEs()), glsl3Supported(), rendererInfo()) - pcall(love.filesystem.write, ShaderFX.ERROR_LOG_REL, head .. table.concat(errs, "\n\n") .. "\n") + pcall(pfs.write, ShaderFX.ERROR_LOG_REL, head .. table.concat(errs, "\n\n") .. "\n") end return line end diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index d99555f6..cfe89c2d 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -166,10 +166,17 @@ function TextBox.new(game, text, onDone, opts) self.textX = (self.boxTx + 1) * 8 self.line1Y = (self.boxTy + 2) * 8 self.line2Y = (self.boxTy + 4) * 8 + local doubleLine = game and game.data and game.data.text + and text == game.data.text._OaksLabGivePokeballsExplanationText text = TextBox.substitute(game, text) local marks text, marks = stripPauses(text) - self.pages = TextBox.paginate(text, self.maxCols) + local save = game and game.save + local gen1 = doubleLine + and not (save and (save.generation == 2 or save.generation == 3 + or save.version == "gold")) + and require("src.core.GameVersion").generation() == 1 + self.pages = TextBox.paginate(text, self.maxCols, gen1) -- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is -- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213) self.pauseSounds = opts and opts.pauseSounds @@ -286,7 +293,7 @@ end -- additional lines on the same page (the box scrolls them). -- pages.contBefore[p][i] is true when line i was preceded by \v (cont): -- pokered ContText waits for A/B + ▼ before scrolling that line in. -function TextBox.paginate(text, maxCols) +function TextBox.paginate(text, maxCols, gen1) maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS text = TextBox.strip(text) -- maxCols is a column count, so the budget is that many vanilla 8px @@ -320,18 +327,31 @@ function TextBox.paginate(text, maxCols) table.insert(lines, line) table.insert(conts, wait) end + local function overlay(base, over) + local b, o = Font.split(base), Font.split(over) + if #o >= #b then return over end + return over .. base:sub(b[#o].to + 1) + end for pageText in (text .. "\f"):gmatch("(.-)\f") do if pageText ~= "" then local lines, conts = {}, {} - local pos, waitNext = 1, false + local pos, waitNext, afterNl = 1, false, false + local function addSegment(seg) + if gen1 and afterNl and #lines == 2 and not conts[2] then + lines[2] = overlay(lines[2], seg) -- home/text.asm:76-81 + else + pushLine(lines, conts, seg, waitNext) + end + end while true do local npos = pageText:find("[\n\v]", pos) if not npos then - pushLine(lines, conts, pageText:sub(pos), waitNext) + addSegment(pageText:sub(pos)) break end - pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext) + addSegment(pageText:sub(pos, npos - 1)) waitNext = pageText:sub(npos, npos) == "\v" + afterNl = not waitNext pos = npos + 1 end if lines[#lines] == "" then diff --git a/tests/drivers/ai_xitem_sound_bug2541.lua b/tests/drivers/ai_xitem_sound_bug2541.lua new file mode 100644 index 00000000..a8a3af7a --- /dev/null +++ b/tests/drivers/ai_xitem_sound_bug2541.lua @@ -0,0 +1,75 @@ +-- engine/battle/trainer_ai.asm:459,690-725 +-- tools/run_driver.sh red tests/drivers/ai_xitem_sound_bug2541.lua /tmp/shots2541 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots2541" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Sound = require("src.core.Sound") + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + local function finish() + love.event.quit(failed and 1 or 0) + while true do coroutine.yield() end + end + + game.save.options.animations = false + game.save.party = { Pokemon.new(game.data, "PIDGEY", 5) } + U.teleport(game, "CERULEAN_GYM", 5, 5, "up") + U.wait(10) + local ow = game.overworld + check("the overworld is up", ow ~= nil) + + local battle = BattleState.newTrainer(game, "OPP_MISTY", 1) + battle.onFinish = function() end + if ow then ow:pushBattle(battle) end + for _ = 1, 900 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the trainer battle reached the screen", game.stack:top() == battle) + check("battle animations are off", battle:animationsOn() == false) + + local played = {} + local realPlay = Sound.play + Sound.play = function(data, name, ...) + played[#played + 1] = name + return realPlay(data, name, ...) + end + battle.aiUses = 1 + battle.trainerAIAction = function(self) + self.trainerAIAction = nil + return { special = "aiItem", item = "X_DEFEND" } + end + + local rose = false + for _ = 1, 1500 do + local cur = battle.current + if cur and cur.text and cur.text:find("rose", 1, true) then + rose = true + break + end + U.tap(game, "a") + U.wait(3) + end + check("the X DEFEND rose page came up", rose) + for _ = 1, 200 do + if battle.charIndex and battle.total and battle.charIndex >= battle.total then break end + U.wait(1) + end + U.still(game, DIR .. "/2541_1_x_defend_rose_page.png") + check("the enemy DEFENSE stage is +1", (battle.enemy.stages.defense or 0) == 1) + local heal = false + for _, n in ipairs(played) do + if n == "Heal_Ailment" then heal = true end + end + check("Heal_Ailment never played for X DEFEND", not heal) + + Sound.play = realPlay + finish() +end diff --git a/tests/drivers/learn_move_stale_page_bug2540.lua b/tests/drivers/learn_move_stale_page_bug2540.lua new file mode 100644 index 00000000..a484ec49 --- /dev/null +++ b/tests/drivers/learn_move_stale_page_bug2540.lua @@ -0,0 +1,83 @@ +-- engine/pokemon/learn_move.asm:87-95, engine/battle/experience.asm:242-256 +-- tools/run_driver.sh red tests/drivers/learn_move_stale_page_bug2540.lua /tmp/shots2540 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots2540" + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local BattleState = require("src.battle.BattleState") + local Font = require("src.render.Font") + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + local function finish() + love.event.quit(failed and 1 or 0) + while true do coroutine.yield() end + end + local function codes(text) + local out = {} + for chunk in (text .. "\n"):gmatch("([^\n\v]*)[\n\v]") do + out[#out + 1] = table.concat(Font.encode(chunk), ",") + end + return out + end + + local mon = Pokemon.new(game.data, "BULBASAUR", 19) + mon.moves = { + { id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 }, + { id = "LEECH_SEED", pp = 10 }, { id = "VINE_WHIP", pp = 10 }, + } + local rate = game.data.pokemon.BULBASAUR.growthRate + mon.exp = Growth.expForLevel(rate, 20, game.data.growth_rates) - 1 + game.save.party = { mon } + U.teleport(game, "ROUTE_3", 13, 6, "left") + U.wait(10) + local ow = game.overworld + check("the overworld is up", ow ~= nil) + + local battle = BattleState.newTrainer(game, "OPP_SWIMMER", 1) + battle.onFinish = function() end + if ow then ow:pushBattle(battle) end + for _ = 1, 1200 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.tap(game, "a") + U.wait(2) + end + check("the trainer battle reached the screen", game.stack:top() == battle) + battle.enemy.mon.hp = 1 + check("the trainer has a second mon", #battle.enemyParty >= 2) + + local seenLearn, checked = false, false + for i = 1, 6000 do + local top = game.stack:top() + if top ~= battle then + if top and top.newMoveId then seenLearn = true end + if i % 4 == 0 then U.tap(game, "a") end + elseif seenLearn and battle.current == nil and (battle.waitFrames or 0) > 0 + and not checked then + checked = true + local name = mon.nickname or game.data.pokemon.BULBASAUR.name + local mdef = game.data.moves.POISONPOWDER + local shown = {} + for j, line in ipairs(battle.shown or {}) do shown[j] = table.concat(line, ",") end + local grew = codes(name .. " grew\nto level 20!") + check("the held page is not the grew-to-level page", + not (shown[1] == grew[1] and shown[2] == grew[2])) + local learned = codes(name .. " learned\n" .. mdef.name .. "!") + check("the held page is the learned page", + shown[1] == learned[1] and shown[2] == learned[2]) + U.still(game, DIR .. "/2540_1_learned_page_held_over_ball_row.png") + break + elseif battle.current or battle.phase ~= "messages" then + if i % 4 == 0 then U.tap(game, "a") end + end + U.wait(1) + end + check("the forget-a-move flow ran", seenLearn) + check("the held-page moment was reached", checked) + finish() +end diff --git a/tests/drivers/oak_balls_text_bug2524.lua b/tests/drivers/oak_balls_text_bug2524.lua new file mode 100644 index 00000000..d6d3561a --- /dev/null +++ b/tests/drivers/oak_balls_text_bug2524.lua @@ -0,0 +1,56 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + local failed = false + + local function check(label, ok) + if ok then print("PASS " .. label) else print("FAIL " .. label) failed = true end + end + + local function finish() + love.event.quit(failed and 1 or 0) + end + + U.newGame(game) + U.teleport(game, "OAKS_LAB", 5, 6, "up") + + local text = game.data.text._OaksLabGivePokeballsExplanationText + check("text present", text ~= nil) + if not text then return finish() end + + local box = TextBox.new(game, text) + game.stack:push(box) + check("page 2 has two rows", #box.pages[2] == 2) + check("page 2 row 2 overwritten", box.pages[2][2] == "to catch it!nd try") + + for _ = 1, 3000 do + if box.waiting then break end + U.wait(1) + end + check("waits at first para", box.waiting) + for _ = 1, 3000 do + if box.pageIndex == 2 and not box.waiting and box.charIndex >= 1 then break end + if box.waiting and box.pageIndex == 1 then + U.wait(10) + U.tap(game, "a") + end + U.wait(1) + end + + local scrolledAuto = false + for _ = 1, 600 do + U.wait(1) + if box.waiting and box.pageIndex == 2 then break end + if box.lineIndex > 2 then scrolledAuto = true end + end + check("page 2 never auto-scrolled a third line", not scrolledAuto) + check("page 2 stops for a press", box.waiting and box.pageIndex == 2) + U.still(game, DIR .. "/page2_end_waiting_arrow.png") + + U.wait(10) + U.tap(game, "a") + U.wait(60) + check("press advances to page 3", box.pageIndex == 3) + finish() +end diff --git a/tests/drivers/residual_psn_seed_order_bug2542.lua b/tests/drivers/residual_psn_seed_order_bug2542.lua new file mode 100644 index 00000000..9ba3c3aa --- /dev/null +++ b/tests/drivers/residual_psn_seed_order_bug2542.lua @@ -0,0 +1,85 @@ +-- engine/battle/core.asm:470-530 +-- tools/run_driver.sh red tests/drivers/residual_psn_seed_order_bug2542.lua /tmp/shots2542 +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots2542" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local failed = false + local function check(label, ok) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failed = true end + return ok + end + local function finish() + love.event.quit(failed and 1 or 0) + while true do coroutine.yield() end + end + + local mon = Pokemon.new(game.data, "BULBASAUR", 40) + mon.hp = mon.stats.hp - 30 + mon.moves = { { id = "GROWL", pp = 40 } } + game.save.party = { mon } + U.teleport(game, "ROUTE_3", 13, 6, "left") + U.wait(10) + local ow = game.overworld + check("the overworld is up", ow ~= nil) + + local battle = BattleState.newWild(game, "SNORLAX", 50) + battle.onFinish = function() end + if ow then ow:pushBattle(battle) end + for _ = 1, 900 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + check("the wild battle reached the screen", game.stack:top() == battle) + + local enemy, player = battle.enemy, battle.player + battle.enemyAction = function() return { id = "SPLASH", pp = 40 } end + enemy.mon.status = "PSN" + enemy.leechSeeded = true + local startEnemy, startPlayer = enemy.mon.hp, player.mon.hp + local tick = math.max(1, math.floor(enemy.mon.stats.hp / 16)) + + local sawPoison, sawSeed = false, false + local order = {} + for i = 1, 4000 do + local text = battle.current and battle.current.text + if text and text:find("poison", 1, true) and not sawPoison then + sawPoison = true + order[#order + 1] = "poison" + check("poison text shows before the bar moves", enemy.shownHP == startEnemy) + check("only the status damage is in the model", + enemy.mon.hp == startEnemy - tick) + for _ = 1, 200 do + if battle.charIndex and battle.total and battle.charIndex >= battle.total then break end + U.wait(1) + end + U.wait(10) + U.still(game, DIR .. "/2542_1_poison_text_bar_full.png") + elseif text and text:find("LEECH SEED", 1, true) and not sawSeed then + sawSeed = true + order[#order + 1] = "seed" + check("the seed text shows after both bars moved", + enemy.shownHP == enemy.mon.hp and player.shownHP == player.mon.hp) + check("the seed drain came off the poison total", + enemy.mon.hp == startEnemy - 2 * tick) + check("the seeder was healed", player.mon.hp == startPlayer + tick + or player.mon.hp > startPlayer) + for _ = 1, 200 do + if battle.charIndex and battle.total and battle.charIndex >= battle.total then break end + U.wait(1) + end + U.wait(10) + U.still(game, DIR .. "/2542_2_seed_text_after_drain.png") + break + end + if i % 5 == 0 then U.tap(game, "a") end + U.wait(1) + end + check("the poison page appeared", sawPoison) + check("the seed page appeared", sawSeed) + check("poison came before the seed", order[1] == "poison" and order[2] == "seed") + finish() +end diff --git a/tests/engine/battle_ai_item_sfx_bug2541.lua b/tests/engine/battle_ai_item_sfx_bug2541.lua new file mode 100644 index 00000000..78c24992 --- /dev/null +++ b/tests/engine/battle_ai_item_sfx_bug2541.lua @@ -0,0 +1,56 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local TrainerAI = require("src.battle.TrainerAI") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") +local Pokemon = require("src.pokemon.Pokemon") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +for _, item in ipairs({ "FULL_HEAL", "GUARD_SPEC" }) do + T.eq(TrainerAI.playsRestoringSfx(item), true, item .. " plays AIPlayRestoringSFX") +end +for _, item in ipairs({ "X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL", + "POTION", "SUPER_POTION", "HYPER_POTION", "FULL_RESTORE" }) do + T.eq(TrainerAI.playsRestoringSfx(item), false, item .. " is silent") +end + +local function newGame() + local save = SaveData.newGame() + save.player.name = "RED" + save.party = { Pokemon.new(Data, "FIXMON_A", 10, function(_, b) return b end) } + return { + data = Data, save = save, + stack = { top = function() return nil end, push = function() end }, + input = { wasPressed = function() return false end, + isDown = function() return false end }, + } +end + +local function sfxFor(item) + local battle = BattleState.newWild(newGame(), "FIXMON_C", 10) + battle.trainer = { name = "MISTY" } + battle.queue, battle.nextInsert = {}, 0 + battle.aiUses = 1 + local played = {} + local orig = Sound.play + Sound.play = function(_, name) played[#played + 1] = name end + local ok, err = pcall(battle.executeAction, battle, battle.enemy, battle.player, + { special = "aiItem", item = item }) + Sound.play = orig + T.check(ok, "executeAction ran for " .. item .. " " .. tostring(err)) + return played +end + +T.eq(#sfxFor("X_DEFEND"), 0, "X DEFEND plays no restoring SFX") +T.eq(#sfxFor("POTION"), 0, "POTION plays no restoring SFX") +T.eq(#sfxFor("FULL_RESTORE"), 0, "FULL RESTORE plays no restoring SFX") +local guard = sfxFor("GUARD_SPEC") +T.eq(guard[1], "Heal_Ailment", "GUARD SPEC plays Heal_Ailment") + +T.finish("battle_ai_item_sfx_bug2541") diff --git a/tests/engine/battle_learn_move_page_bug2540.lua b/tests/engine/battle_learn_move_page_bug2540.lua new file mode 100644 index 00000000..bb7291b6 --- /dev/null +++ b/tests/engine/battle_learn_move_page_bug2540.lua @@ -0,0 +1,79 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function glyphs(text) + local out = {} + for chunk in (text .. "\n"):gmatch("([^\n\v]*)[\n\v]") do + out[#out + 1] = table.concat(Font.encode(chunk), ",") + end + return out +end + +local function shownStrings(battle) + local out = {} + for i, line in ipairs(battle.shown) do out[i] = table.concat(line, ",") end + return out +end + +local function setup() + local save = SaveData.newGame() + local mon = Pokemon.new(Data, "FIXMON_A", 20, function(_, b) return b end) + local moveIds = { "FIX_TACKLE", "FIX_SCRATCH", "FIX_EMBERISH", "FIX_CUT", "FIX_NEW" } + local copy = {} + for k, v in pairs(Data.moves.FIX_CUT) do copy[k] = v end + copy.id, copy.name = "FIX_NEW", "FIX NEW" + Data.moves.FIX_NEW = copy + mon.moves = {} + for i = 1, 4 do mon.moves[i] = { id = moveIds[i], pp = 5 } end + save.party = { mon } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 10) + battle.queue, battle.nextInsert = {}, 0 + local newMove = moveIds[5] + local captured + battle.buildScreen = function(_, _, _, _, onDone) captured = onDone return {} end + battle:learnMove(mon, newMove) + battle.queue[1].ui() + return battle, mon, Data.moves[newMove], captured +end + +do + local battle, mon, mdef, onDone = setup() + T.check(type(onDone) == "function", "the learn menu is handed an onDone") + local grew = glyphs("GREW\nTO LEVEL 20!") + battle.shown = {} + for i, g in ipairs(grew) do battle.shown[i] = { g } end + battle.msgHold = true + onDone(true) + local name = mon.nickname or Data.pokemon[mon.species].name + local want = glyphs(name .. " learned\n" .. mdef.name .. "!") + local got = shownStrings(battle) + T.eq(got[1], want[1], "the held page is the learned line 1") + T.eq(got[2], want[2], "the held page is the learned line 2") + T.eq(battle.msgHold, true, "the page stays held") + T.eq(battle.current, nil, "with no message pending") +end + +do + local battle, mon, mdef, onDone = setup() + battle.shown = { { 1, 2, 3 } } + onDone(false) + local name = mon.nickname or Data.pokemon[mon.species].name + local want = glyphs(name .. "\ndid not learn\v" .. mdef.name .. "!") + local got = shownStrings(battle) + T.eq(#got, 2, "the did-not-learn hold is two lines") + T.eq(got[1], want[2], "line 1 is the did-not-learn line") + T.eq(got[2], want[3], "line 2 is the move name") +end + +T.finish("battle_learn_move_page_bug2540") diff --git a/tests/engine/leech_seed_timing_bug784.lua b/tests/engine/leech_seed_timing_bug784.lua index 478683d8..c03eb5dc 100644 --- a/tests/engine/leech_seed_timing_bug784.lua +++ b/tests/engine/leech_seed_timing_bug784.lua @@ -87,8 +87,8 @@ do T.check(animIdx ~= nil, "the drain plays the ABSORB animation") T.check(animIdx ~= nil and rows[animIdx].attackerIsPlayer == true, "played from the healing side (hWhoseTurn flipped)") - T.check(animIdx ~= nil and animIdx > seedIdx and animIdx < tackleIdx, - "and it rides with the drain, between the two moves") + T.check(animIdx ~= nil and animIdx > scratchIdx and animIdx < seedIdx, + "and it plays before the seed text (core.asm:506-523)") end -- --------------------------------------------------------------------- diff --git a/tests/engine/portable_writers_2517.lua b/tests/engine/portable_writers_2517.lua new file mode 100644 index 00000000..7862082b --- /dev/null +++ b/tests/engine/portable_writers_2517.lua @@ -0,0 +1,342 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("portable writers 2517") +local check, eq = S.check, S.eq + +local FsIo = require("tests.fs_io") +local SaveData = require("src.core.SaveData") + +local WIN = FsIo.isWindows + +local function mkdirp(path) + if WIN then + os.execute('mkdir "' .. path:gsub("/", "\\") .. '" 2>nul') + else + os.execute('mkdir -p "' .. path .. '" 2>/dev/null') + end +end + +local function rmrf(path) + if WIN then + os.execute('rmdir /s /q "' .. path:gsub("/", "\\") .. '" 2>nul') + else + os.execute('chmod -R u+w "' .. path .. '" 2>/dev/null') + os.execute('rm -rf "' .. path .. '" 2>/dev/null') + end +end + +local function exists(path) + local f = io.open(path, "rb") + if not f then return false end + f:close() + return true +end + +local function slurp(path) + local f = io.open(path, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + return body +end + +local OWNED = { + "src/core/SaveData.lua", + "src/import/CacheFs.lua", + "src/import/RomSources.lua", + "src/import/RomImporter.lua", + "src/core/Printer.lua", + "src/render/ShaderFX.lua", + "src/core/TouchSkin.lua", + "src/import/LauncherSettings.lua", + "src/import/LauncherView.lua", +} + +local WRITERS = { "write", "newFile", "createDirectory", "append" } +local REMOVERS = { "remove" } + +local SAVEDIR_BRANCH = "non-portable save-dir branch" +local PICKER_TEMP = "picker/export temp file in the save dir" +local STALE_CACHE = "stale save-dir cache cleanup" +local REQUIRED_IMPORT = "required-import staging file in the save dir" + +local ALLOW = { + ["src/import/CacheFs.lua"] = { + { reason = SAVEDIR_BRANCH, pat = "love%.filesystem%.createDirectory%(curRel%)", n = 1 }, + { reason = SAVEDIR_BRANCH, pat = "love%.filesystem%.createDirectory%(parent%)", n = 1 }, + { reason = SAVEDIR_BRANCH, pat = "love%.filesystem%.write%(rel, data%)", n = 1 }, + { reason = SAVEDIR_BRANCH, pat = "love%.filesystem%.newFile%(rel%)", n = 1 }, + { reason = SAVEDIR_BRANCH, pat = "love%.filesystem%.remove%(rel%)", n = 2 }, + { reason = "legacy save-dir migration", pat = "^%s*local fs = love%.filesystem$", n = 1 }, + }, + ["src/core/Printer.lua"] = { + { reason = SAVEDIR_BRANCH, pat = 'love%.filesystem%.createDirectory%("prints"%)', n = 1 }, + }, + ["src/render/ShaderFX.lua"] = { + { reason = "shader download zip stays in the save dir", pat = "remove%(DOWNLOAD_ZIP_REL%)", n = 1 }, + }, + ["src/import/RomImporter.lua"] = { + { reason = STALE_CACHE, pat = "love%.filesystem%.remove%(prefix %.%. CacheContract%.MARKER_PATH%)", n = 2 }, + { reason = STALE_CACHE, pat = "^%s*love%.filesystem%.remove%(path%)$", n = 4 }, + { reason = REQUIRED_IMPORT, pat = "love%.filesystem%.remove%(RequiredImports%.receiptPath", n = 3 }, + { reason = PICKER_TEMP, pat = "love%.filesystem%.remove%(preferred%)", n = 1 }, + { reason = PICKER_TEMP, pat = "love%.filesystem%.remove%(name%)", n = 1 }, + { reason = PICKER_TEMP, pat = 'love%.filesystem%.remove%("export_done%.flag"%)', n = 1 }, + { reason = PICKER_TEMP, pat = 'love%.filesystem%.remove%("pending_export%.sav"%)', n = 1 }, + { reason = PICKER_TEMP, pat = 'love%.filesystem%.write%("pending_export%.sav"', n = 2 }, + { reason = PICKER_TEMP, pat = 'love%.filesystem%.remove%("pick_error%.flag"%)', n = 1 }, + { reason = PICKER_TEMP, pat = 'love%.filesystem%.remove%("pick_error%.txt"%)', n = 1 }, + { reason = PICKER_TEMP, pat = "love%.filesystem%.remove%(PICK_COMPLETE_FILENAME%)", n = 1 }, + { reason = PICKER_TEMP, pat = "love%.filesystem%.remove%(importerName%)", n = 1 }, + { reason = PICKER_TEMP, pat = "love%.filesystem%.remove%(displayName%)", n = 1 }, + { reason = PICKER_TEMP, pat = "love%.filesystem%.remove%(st%.path%)", n = 1 }, + { reason = PICKER_TEMP, pat = "pcall%(love%.filesystem%.remove, path%)", n = 2 }, + { reason = PICKER_TEMP, pat = "love%.filesystem%.newFile%(path%)", n = 1 }, + }, +} + +local allowCounts = {} + +local function allowed(file, line) + for i, entry in ipairs(ALLOW[file] or {}) do + if line:find(entry.pat) then + allowCounts[file] = allowCounts[file] or {} + allowCounts[file][i] = (allowCounts[file][i] or 0) + 1 + return true + end + end + return false +end + +local function callsBypass(line) + if line:match("^%s*%-%-") then return false end + if line:match("^%s*local%s+[%w_]+%s*=%s*love%.filesystem%s*$") then return true end + for _, fn in ipairs(WRITERS) do + if line:find("love%.filesystem%." .. fn .. "%s*%(") + or line:find("pcall%(%s*love%.filesystem%." .. fn .. "%s*,") then + return true + end + end + for _, fn in ipairs(REMOVERS) do + if line:find("love%.filesystem%." .. fn .. "%s*%(") + or line:find("pcall%(%s*love%.filesystem%." .. fn .. "%s*,") then + return true + end + end + return false +end + +for _, file in ipairs(OWNED) do + local body = slurp(file) + check(body ~= nil, "readable: " .. file) + local bad = {} + local n = 0 + for line in (body or ""):gmatch("([^\n]*)\n?") do + n = n + 1 + if callsBypass(line) and not allowed(file, line) then + bad[#bad + 1] = n .. ": " .. line:gsub("^%s+", "") + end + end + eq(#bad, 0, file .. " has no love.filesystem writes bypassing persistenceFs" + .. (#bad > 0 and (" (" .. table.concat(bad, " | ") .. ")") or "")) + for i, entry in ipairs(ALLOW[file] or {}) do + eq((allowCounts[file] or {})[i] or 0, entry.n, + file .. " allow-list [" .. entry.reason .. "] " .. entry.pat .. " matches exactly " .. entry.n) + end +end + +local SHELL_ALLOW = {} + +for _, file in ipairs(OWNED) do + local body = slurp(file) or "" + local bad = {} + local n = 0 + for line in body:gmatch("([^\n]*)\n?") do + n = n + 1 + if not line:match("^%s*%-%-") + and (line:find("os%.execute") or line:find("io%.popen")) then + local ok = false + for _, pat in ipairs(SHELL_ALLOW[file] or {}) do + if line:find(pat) then ok = true end + end + if not ok then bad[#bad + 1] = n .. ": " .. line:gsub("^%s+", "") end + end + end + eq(#bad, 0, file .. " spawns no shell (os.execute / io.popen)" + .. (#bad > 0 and (" (" .. table.concat(bad, " | ") .. ")") or "")) +end + +local SANDBOX = ((os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp") + :gsub("[/\\]+$", "")) .. ("/pokeport_2517_%d_%d"):format(os.time(), math.random(1, 999999)) +local GAME = SANDBOX .. "/g\195\164me f\195\182lder" +local OTHER = SANDBOX .. "/elsewhere" +local SAVEDIR = SANDBOX .. "/appdata" +mkdirp(GAME) +mkdirp(OTHER) +mkdirp(SAVEDIR) + +local function writeReal(path, body) + local f = io.open(path, "wb") + if not f then return false end + f:write(body) + f:close() + return true +end + +local savedFs = love.filesystem +local savedSystem = love.system +local savedRomSources = package.loaded["src.import.RomSources"] +local savedCacheFs = package.loaded["src.import.CacheFs"] + +local function stubLove(source, sbd) + local fs = {} + for k, v in pairs(savedFs) do fs[k] = v end + fs.getSource = function() return source end + fs.getSourceBaseDirectory = function() return sbd end + fs.getSaveDirectory = function() return SAVEDIR end + love.filesystem = fs + love.system = { getOS = function() return WIN and "Windows" or "Linux" end } + SaveData._resetPortableCacheForTests() +end + +local function saveDirEntries() + local n = 0 + local list = FsIo.listDir(SAVEDIR) + for _ in ipairs(list or {}) do n = n + 1 end + return n +end + +local function run() + writeReal(GAME .. "/portable.txt", "") + stubLove(OTHER .. "/x.exe", GAME) + + check(SaveData.isPortable(), "marker under a non-ASCII folder is detected") + eq(SaveData.portableBaseDir(), GAME, "portable base is the game folder") + local st = SaveData.portableStatus() + eq(st.base, GAME, "portableStatus reports the base") + check(#st.tried >= 1 and st.tried[1].marker == true and st.tried[1].writable == true, + "portableStatus records marker and writable for the winning candidate") + + package.loaded["src.import.RomSources"] = nil + local RomSources = require("src.import.RomSources") + local path = RomSources.keep("red", "ROMBYTES") + check(path ~= nil, "RomSources.keep succeeds in portable mode") + eq(slurp(GAME .. "/roms/red.gb"), "ROMBYTES", "kept ROM lands in the game folder") + check(RomSources.keptExists(path), "kept ROM is visible through the portable fs") + eq(RomSources.readKept(path), "ROMBYTES", "kept ROM reads back") + RomSources.removeKept(path) + check(not exists(GAME .. "/roms/red.gb"), "kept ROM removal reaches the game folder") + eq(saveDirEntries(), 0, "nothing was written to the OS save directory") + + local pfs = SaveData.portableFs() + pfs.createDirectory("imports/saves/red") + eq((pfs.getInfo("imports/saves/red") or {}).type, "directory", + "portable getInfo reports directories") + pfs.write("imports/saves/red/a.sav", "12345") + local info = pfs.getInfo("imports/saves/red/a.sav", "file") + eq(info and info.size, 5, "portable getInfo reports file size") + eq(pfs.getInfo("imports/saves/red/a.sav", "directory"), nil, + "portable getInfo honours the type filter") + local items = pfs.getDirectoryItems("imports/saves/red") + eq(#items, 1, "portable getDirectoryItems lists the file") + eq(items[1], "a.sav", "portable getDirectoryItems names the file") + + local realExecute, realPopen = os.execute, io.popen + local spawned = 0 + os.execute = function(...) spawned = spawned + 1; return realExecute(...) end + io.popen = function(...) spawned = spawned + 1; return realPopen(...) end + local spawnOk, spawnErr = pcall(function() + local uni = "imports/pok\195\169 \195\188.gb" + check(pfs.createDirectory("imports/n\195\182n/asc ii/deep") == true, + "createDirectory builds a nested non-ASCII tree") + check(pfs.getInfo("imports/n\195\182n/asc ii/deep", "directory") ~= nil, + "nested non-ASCII directory exists") + check(pfs.write(uni, "UNI") == true, "portable write to a non-ASCII file name succeeds") + eq(pfs.read(uni), "UNI", "portable read of a non-ASCII file name round-trips") + eq(slurp(GAME .. "/" .. uni), "UNI", "non-ASCII write lands in the non-ASCII game folder") + local listed = pfs.getDirectoryItems("imports") + local seen = false + for _, name in ipairs(listed) do + if name == "pok\195\169 \195\188.gb" then seen = true end + end + check(seen, "portable getDirectoryItems returns non-ASCII names as UTF-8") + for _ = 1, 3 do + pfs.createDirectory("shaders") + pfs.getDirectoryItems("shaders") + end + pfs.remove(uni) + check(pfs.getInfo(uni) == nil, "portable remove deletes the non-ASCII file") + RomSources.keep("blue", "B") + RomSources.removeKept(RomSources.keptPath("blue")) + local RI = require("src.import.RomImporter") + local inst = setmetatable({}, { __index = RI }) + RI.ensureSavesInboxDir(inst) + RI.ensureImportsDir(inst) + RI.scanInbox(inst) + RI.scanInbox(inst) + end) + os.execute, io.popen = realExecute, realPopen + check(spawnOk, "shell-free portable operations ran" .. (spawnOk and "" or (": " .. tostring(spawnErr)))) + eq(spawned, 0, "portable mkdir, list, read, write and inbox scans spawn no shell process") + + local RomImporter = require("src.import.RomImporter") + check(RomImporter.ensureSavesInboxDir(setmetatable({}, { __index = RomImporter })) == true, "inbox dirs are created in portable mode") + check(exists(GAME .. "/imports") or FsIo.isDir(GAME .. "/imports"), + "imports/ lands in the game folder") + eq(saveDirEntries(), 0, "inbox creation left the OS save directory empty") + + stubLove(OTHER .. "/x.exe", GAME) + package.loaded["src.import.CacheFs"] = nil + local CacheFs = require("src.import.CacheFs") + CacheFs._resetPortableForTests() + if CacheFs.root() == nil then + check(CacheFs.portableError() ~= nil, "a failed portable mount records a reason") + local ok, err = CacheFs.write("data/generated/x.lua", "return 1") + eq(ok, false, "cache write refuses to fall back to the OS save directory") + check(type(err) == "string" and err:find("portable"), "cache write error names portable mode") + eq(saveDirEntries(), 0, "failed portable cache write left the OS save directory empty") + end + + writeReal(OTHER .. "/portable.txt", "") + stubLove(OTHER .. "/x.exe", OTHER) + SaveData._resetPortableCacheForTests() + check(SaveData.isPortable(), "control: writable folder with marker is portable") + + if not WIN then + local RO = SANDBOX .. "/readonly" + mkdirp(RO) + writeReal(RO .. "/portable.txt", "") + os.execute('chmod 555 "' .. RO .. '"') + local probe = io.open(RO .. "/.probe", "wb") + if probe then + probe:close() + os.remove(RO .. "/.probe") + else + stubLove(SANDBOX .. "/y.exe", RO) + check(not SaveData.isPortable(), "marker in a read-only folder is rejected") + local rs = SaveData.portableStatus() + eq(rs.tried[1].marker, true, "rejected candidate still records the marker") + eq(rs.tried[1].writable, false, "rejected candidate records not writable") + eq(rs.tried[1].reason, "folder not writable", "rejection reason is observable") + end + os.execute('chmod 755 "' .. RO .. '"') + end + + stubLove(SANDBOX .. "/nomarker.exe", SANDBOX .. "/nomarker") + check(not SaveData.isPortable(), "no marker means not portable") + eq(SaveData.portableStatus().tried[1].reason, "no portable.txt", "missing marker reason recorded") +end + +local ok, err = pcall(run) + +love.filesystem = savedFs +love.system = savedSystem +package.loaded["src.import.RomSources"] = savedRomSources +package.loaded["src.import.CacheFs"] = savedCacheFs +SaveData._resetPortableCacheForTests() +rmrf(SANDBOX) + +if not ok then check(false, "suite raised: " .. tostring(err)) end + +S.finish() diff --git a/tests/engine/residual_psn_seed_order_bug2542.lua b/tests/engine/residual_psn_seed_order_bug2542.lua new file mode 100644 index 00000000..f0ee856c --- /dev/null +++ b/tests/engine/residual_psn_seed_order_bug2542.lua @@ -0,0 +1,95 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Status = require("src.battle.Status") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +local function newBattle(status) + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 30) + battle.queue, battle.nextInsert = {}, 0 + battle.enemy.mon.status = status + battle.enemy.leechSeeded = true + battle.player.mon.hp = math.max(1, battle.player.mon.stats.hp - 20) + return battle +end + +local function walk(battle) + local rows = {} + for _ = 1, 100 do + local item = table.remove(battle.queue, 1) + if not item then break end + if item.text then + rows[#rows + 1] = { kind = "text", text = item.text } + elseif item.anim then + rows[#rows + 1] = { kind = "anim", anim = item.anim } + elseif item.drain then + rows[#rows + 1] = { kind = "drain", enemyHP = battle.enemy.mon.hp, + playerHP = battle.player.mon.hp } + elseif item.wait then + rows[#rows + 1] = { kind = "wait", frames = item.wait } + elseif item.fn then + battle.nextInsert = 0 + item.fn() + end + end + return rows +end + +for _, status in ipairs({ "PSN", "BRN" }) do + local battle = newBattle(status) + local enemy, player = battle.enemy, battle.player + local startEnemy, startPlayer = enemy.mon.hp, player.mon.hp + local tick = math.max(1, math.floor(enemy.mon.stats.hp / 16)) + battle:residualFor(enemy, player) + local rows = walk(battle) + + local kinds = {} + for _, r in ipairs(rows) do kinds[#kinds + 1] = r.kind end + T.eq(table.concat(kinds, ","), "text,anim,drain,anim,drain,text", + status .. " + seed: text, anim, bar, anim, bar, seed text") + T.eq(rows[2].anim, "BURN_PSN_ANIM", status .. " tick animation first") + T.eq(rows[4].anim, "ABSORB", "then the leech seed animation") + T.check(rows[6].text:find("LEECH SEED", 1, true) ~= nil, + "the seed text is printed after the seed bar moves") + T.eq(rows[3].enemyHP, startEnemy - tick, + "the first bar stops at the status damage only") + T.eq(rows[3].playerHP, startPlayer, "the seeder is not healed yet") + T.eq(rows[5].enemyHP, startEnemy - 2 * tick, "the seed drain lands second") + T.eq(rows[5].playerHP, startPlayer + tick, "and heals the seeder") +end + +do + local battle = newBattle(nil) + battle:residualFor(battle.enemy, battle.player) + local kinds = {} + for _, r in ipairs(walk(battle)) do kinds[#kinds + 1] = r.kind end + T.eq(table.concat(kinds, ","), "anim,drain,text", "seed alone: anim, bar, text") +end + +do + local battle = newBattle("PSN") + local msgs = Status.residual(battle.enemy, battle.player, battle) + T.eq(#msgs, 2, "Status.residual still returns both halves flat") +end + +do + local battle = newBattle("PSN") + battle.enemy.mon.hp = 1 + battle:residualFor(battle.enemy, battle.player) + local rows = walk(battle) + local last = rows[#rows] + T.check(battle.enemy.mon.hp == 0, "poison faints the seeded mon") + T.check(last ~= nil, "the queue carries the faint beats") +end + +T.finish("residual_psn_seed_order_bug2542") diff --git a/tests/engine/text_double_line_overwrite_bug2524.lua b/tests/engine/text_double_line_overwrite_bug2524.lua new file mode 100644 index 00000000..8d1bba19 --- /dev/null +++ b/tests/engine/text_double_line_overwrite_bug2524.lua @@ -0,0 +1,55 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TextBox = require("src.render.TextBox") + +local oak = "Just throw a POKé\nBALL at it and try\nto catch it!" + +local g1 = TextBox.paginate(oak, 18, true) +eq(#g1, 1, "gen1: one page") +eq(#g1[1], 2, "gen1: third plain line does not become a scrolled line") +eq(g1[1][1], "Just throw a POKé", "gen1: first row unchanged") +eq(g1[1][2], "to catch it!nd try", "gen1: second line overwrites row 2") + +local g2 = TextBox.paginate(oak, 18) +eq(#g2[1], 3, "default (gen2/3, mods): three lines kept") +eq(g2[1][3], "to catch it!", "default: third line intact") + +local cont = TextBox.paginate("a\nb\vc", 18, true) +eq(#cont[1], 3, "gen1: cont scroll unchanged") +eq(cont.contBefore[1][3], true, "gen1: cont mark kept") + +local two = TextBox.paginate("a\nb", 18, true) +eq(#two[1], 2, "gen1: plain two-line page unchanged") +eq(two[1][2], "b", "gen1: two-line second row") + +local multi = TextBox.paginate("x\ny\f" .. oak, 18, true) +eq(#multi, 2, "gen1: page break kept") +eq(#multi[2], 2, "gen1: overwrite applies per page") + +local wide = TextBox.paginate("abc\ndef\nghi", 18, true) +eq(wide[1][2], "ghi", "gen1: equal-length overwrite replaces the row") + +local pageText = "\012When a wild\nPOKéMON appears,\011it's fair game.\012Just throw a POKé\nBALL at it and try\nto catch it!\012This won't always\nwork, though.{DONE}" +local function box(save, text) + local game = { data = { text = { _OaksLabGivePokeballsExplanationText = pageText } }, + save = save, input = {} } + return TextBox.new(game, text) +end + +local v = box({}, pageText) +eq(#v.pages[2], 2, "TextBox.new: vanilla Oak text overwrites in Gen 1") +eq(v.pages[2][2], "to catch it!nd try", "TextBox.new: cart row 2") + +local g2box = box({ generation = 2 }, pageText) +eq(#g2box.pages[2], 3, "TextBox.new: Gen 2 save unchanged") +local g3box = box({ generation = 3 }, pageText) +eq(#g3box.pages[2], 3, "TextBox.new: Gen 3 save unchanged") + +local other = box({}, "What?\nx is\nevolving!") +eq(#other.pages[1], 3, "TextBox.new: other 3-line text unchanged") + +T.finish()