From cc5ff987ac8d17f8d6ce6621824195d50bcf3463 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:52:25 +0200 Subject: [PATCH 1/9] fix(gen2): refresh caches missing trainer HUD balls --- src/import/RomImporter.lua | 3 +++ tests/engine/rom_importer_source_tree_test.lua | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index ab9af931..ee496882 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -136,6 +136,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = { -- costs nothing on a current cache and is the difference between every -- trainer battle opening with a picture and opening with none. "assets/generated/battle/trainers/falkner.png", + -- BattleStart_TrainerHuds cannot draw its party rows from a cache made + -- before the four ball tiles were extracted (#1502). + "assets/generated/battle/hud/balls.png", "assets/generated/audio/programs.bin", }, } diff --git a/tests/engine/rom_importer_source_tree_test.lua b/tests/engine/rom_importer_source_tree_test.lua index 8a66d846..508ba043 100644 --- a/tests/engine/rom_importer_source_tree_test.lua +++ b/tests/engine/rom_importer_source_tree_test.lua @@ -26,5 +26,7 @@ check(helperStart ~= nil, "requiredFilesFor helper exists") local helper = src:sub(helperStart, start) check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil, "requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE") +check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil, + "Gold caches require the trainer HUD ball sheet") T.finish() From 66079686fc24416870b3bc64a5192d16f3aad7fc Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:21:02 +0200 Subject: [PATCH 2/9] fix(gen2): honor battle party grid navigation --- docs/mod-api-gen2-compat.md | 5 ++-- src/ui/gen2/BattleState.lua | 3 +++ src/ui/gen2/PartyMenu.lua | 38 +++++++++++++++++++++++++++++- tests/engine/gate_gen2_mod_api.lua | 3 ++- tests/mod_qol_hooks_tests.lua | 11 +++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index e412b4d2..1994d8a9 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -512,8 +512,9 @@ gains a field instead of the name gaining a prefix. id under Gen 1's `name` key, which is the one payload difference the numeric flag space forces. - *Menus (`src/ui/gen2/`):* `ui.start_menu.items`, `ui.title_menu.items`, - `ui.options.rows`, `ui.party.submenu`, `ui.naming.grid`, `ui.pc.items`, - `ui.list_menu`, `transition.style`. `ui.list_menu` covers Gold's script + `ui.options.rows`, `ui.party.submenu`, `ui.party.grid_navigation`, + `ui.naming.grid`, `ui.pc.items`, `ui.list_menu`, `transition.style`. + `ui.list_menu` covers Gold's script menus (`ScriptMenu.lua`); the `Chrome.List` widget the START and title menus draw with does not raise it yet, so those two are composed through their own hooks only. diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 9254d2b9..c4a4de4e 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -2290,6 +2290,7 @@ function BattleState:openParty(forced) -- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list -- carries BattleMonMenu; PickPartyMonInBattle has no submenu. prompt = forced and "which" or "choose", + battle = true, battleSubmenu = not forced, onCancel = function() stack:pop() @@ -2762,6 +2763,7 @@ function BattleState:openShiftParty() self.phase = "submenu" Screens.push(self.game, "Gen2PartyMenu", { prompt = "which", + battle = true, onCancel = function() stack:pop() self.phase = "resolving" @@ -3135,6 +3137,7 @@ function BattleState:useOnPartyMon(itemId, action) self.phase = "submenu" Screens.push(self.game, "Gen2PartyMenu", { prompt = "useItem", + battle = true, party = self.battle.party or (self.save and self.save.party), onCancel = function() stack:pop() diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index 107c05a7..6cae3190 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -76,6 +76,23 @@ local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11 -- HP bar is 6 tiles wide (48px) in the party list. +local function gridIndex(index, count, direction) + if count < 1 then return nil end + local row, col = math.floor((index - 1) / 2), (index - 1) % 2 + if direction == "left" or direction == "right" then + local other = row * 2 + (1 - col) + 1 + return other <= count and other or index + end + local step = direction == "up" and -1 or direction == "down" and 1 + if not step then return nil end + local rows = math.ceil(count / 2) + for offset = 1, rows do + local other = ((row + step * offset) % rows) * 2 + col + 1 + if other <= count then return other end + end + return index +end + function PartyMenu:wantsFillScale() return true end function PartyMenu:drawsWidescreen() return true end @@ -114,6 +131,7 @@ function PartyMenu.new(game, opts) self.wantsSubmenu = opts.submenu == true -- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810). self.wantsBattleSubmenu = opts.battleSubmenu == true + self.battle = opts.battle == true self.submenu = nil -- The held slot while SwitchPartyMons' second pick is open; nil otherwise. self.switchFrom = nil @@ -146,6 +164,13 @@ function PartyMenu:isCancel() return self.index > #self.party end +function PartyMenu:gridNavigation() + if not self.battle + or not Runtime.wantsHook("ui.party.grid_navigation") then return false end + return Runtime.call("ui.party.grid_navigation", function() return false end, + self) == true +end + -- ------------------------------------------------------------- mon submenu -- GetMonSubmenuItems, in its own order: every field move the mon knows first, @@ -477,7 +502,18 @@ function PartyMenu:update(_dt) return end local total = self:count() - if input:wasPressed("up") then + local grid + if self:gridNavigation() then + local direction = input:wasPressed("left") and "left" + or input:wasPressed("right") and "right" + or input:wasPressed("up") and "up" + or input:wasPressed("down") and "down" + grid = gridIndex(self.index, #self.party, direction) + end + if grid then + self.index = grid + self:storeCursor() + elseif input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or total elseif input:wasPressed("down") then self.index = self.index < total and self.index + 1 or 1 diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 68cff2e8..0da5fc1e 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -397,7 +397,8 @@ local GEN2_HOOKS = { "world.tod", "map.palette", "fieldmove.eligibility", -- menus and the battle intro "ui.start_menu.items", "ui.title_menu.items", "ui.options.rows", - "ui.party.submenu", "ui.naming.grid", "ui.pc.items", "ui.list_menu", + "ui.party.submenu", "ui.party.grid_navigation", "ui.naming.grid", + "ui.pc.items", "ui.list_menu", "transition.style", -- battle "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index d4b82643..d065c079 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -14,6 +14,7 @@ local NamingScreen = require("src.ui.NamingScreen") local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") local PartyMenu = require("src.ui.PartyMenu") +local Gen2PartyMenu = require("src.ui.gen2.PartyMenu") local Player = require("src.world.Player") local Music = require("src.core.Music") @@ -295,6 +296,16 @@ do menu:update(0) check(menu.index == 4, "removing the hook restores native list navigation immediately") + + local gold = Gen2PartyMenu.new(game, { battle = true }) + unsub = wrap("ui.party.grid_navigation", function() return true end) + gold:update(0) + check(gold.index == 3, + "a Gold battle party can follow the same companion grid") + unsub() + gold:update(0) + check(gold.index == 4, + "Gold restores native party list navigation without the hook") end -- ------- music.volume (distance / indoor muffling) From 67a170fd6e0fb7c31a501856bab4c059222c39fc Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:44:30 +0200 Subject: [PATCH 3/9] feat(launcher): add safe mode and issue reporting --- src/core/IssueReport.lua | 219 ++++++++++++++++++++++++ src/core/SaveData.lua | 11 ++ src/import/LauncherSettings.lua | 46 ++++- src/import/LauncherView.lua | 42 +++-- src/import/RomImporter.lua | 52 +++++- src/mods/LauncherMods.lua | 27 ++- src/mods/Loader.lua | 11 +- src/mods/ManagerState.lua | 41 ++++- src/mods/Runtime.lua | 2 + tests/engine/safe_mode_issue_report.lua | 137 +++++++++++++++ 10 files changed, 561 insertions(+), 27 deletions(-) create mode 100644 src/core/IssueReport.lua create mode 100644 tests/engine/safe_mode_issue_report.lua diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua new file mode 100644 index 00000000..910c1f74 --- /dev/null +++ b/src/core/IssueReport.lua @@ -0,0 +1,219 @@ +local SaveData = require("src.core.SaveData") +local Version = require("src.core.Version") + +local IssueReport = {} + +local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new" +local TEMPLATE = "bug_report.yml" + +local function clean(value) + if value == nil then return nil end + local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "") + if text == "" or text == "unknown" or text == "Unknown" then return nil end + return text +end + +local function call(fn, ...) + if type(fn) ~= "function" then return nil end + local ok, a, b, c, d, e = pcall(fn, ...) + if not ok then return nil end + return a, b, c, d, e +end + +local function invoke(fn, ...) + if type(fn) ~= "function" then return false end + local ok, result = pcall(fn, ...) + return ok, result +end + +local function commandValue(command) + if not io or type(io.popen) ~= "function" then return nil end + local ok, pipe = pcall(io.popen, command, "r") + if not ok or not pipe then return nil end + local readOK, value = pcall(pipe.read, pipe, "*l") + pcall(pipe.close, pipe) + if not readOK then return nil end + return clean(value) +end + +local function percentEncode(value) + local text = tostring(value or "") + return (text:gsub("([^%w%-_%.~])", function(char) + return ("%%%02X"):format(char:byte()) + end)) +end + +local function formOS(raw) + local values = { + ["OS X"] = "macOS", + macOS = "macOS", + Windows = "Windows", + Linux = "Linux", + Android = "Android", + iOS = "iOS", + NX = "Nintendo Switch", + UWP = "Xbox", + Xbox = "Xbox", + } + return values[raw] or "" +end + +local function loveVersion() + local major, minor, revision, codename = call(love and love.getVersion) + if not major then return "" end + local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision) + if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end + return result +end + +local function appVersion() + local version = clean(Version.engine) + if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end + return version +end + +local function deviceModel(rawOS, system) + local model = clean(call(system.getModel)) + if model then return model end + if rawOS == "OS X" or rawOS == "macOS" then + return commandValue("sysctl -n hw.model 2>/dev/null") + end + if rawOS == "Windows" then + return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL") + end + if rawOS == "Linux" then + return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null") + or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null") + end + if rawOS == "Android" then + return commandValue("getprop ro.product.model 2>/dev/null") + end + return nil +end + +local function modRows(context) + if context and type(context.mods) == "table" then return context.mods end + local ok, LauncherMods = pcall(require, "src.mods.LauncherMods") + if ok and LauncherMods and LauncherMods.list then + local listed = call(LauncherMods.list) + if type(listed) == "table" then return listed end + end + return {} +end + +local function modNames(rows, safeMode) + local enabled = {} + for _, mod in ipairs(rows or {}) do + if type(mod) == "table" then + local name = clean(mod.name or mod.id) + if name and not safeMode and mod.enabled == true then + enabled[#enabled + 1] = name + end + end + end + table.sort(enabled) + return enabled +end + +local function metadata(options, context) + local system = love and love.system or {} + local graphics = love and love.graphics or {} + local window = love and love.window or {} + local rawOS = clean(call(system.getOS)) + local model = deviceModel(rawOS, system) + local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo) + local width, height = call(graphics.getDimensions) + local pixelWidth, pixelHeight = call(graphics.getPixelDimensions) + local modeWidth, modeHeight, flags = call(window.getMode) + local safeMode = SaveData.isSafeMode(options) + local rows = modRows(context or {}) + local enabledMods = modNames(rows, safeMode) + local lines = { "Diagnostics:" } + local function add(label, value) + value = clean(value) + if value then lines[#lines + 1] = "- " .. label .. ": " .. value end + end + add("Platform", formOS(rawOS)) + local hardware = model + if rendererDevice and rendererDevice ~= model then + hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice + end + add("Device", hardware) + local rendererDetails = clean(renderer) + if rendererDetails and clean(rendererVersion) then + rendererDetails = rendererDetails .. " " .. clean(rendererVersion) + end + add("Renderer", rendererDetails) + local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight + if displayWidth and displayHeight then + add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight)) + end + if pixelWidth and pixelHeight + and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then + add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight)) + end + if flags and flags.fullscreen == true then add("Fullscreen", "yes") end + local version = appVersion() + add("App", version ~= "" and Version.title() or "gen1recomp") + add("LÖVE", loveVersion()) + if safeMode then add("Safe mode", "on") end + return { + rawOS = rawOS, + os = formOS(rawOS), + device = model, + version = version, + safeMode = safeMode, + enabledMods = enabledMods, + metadata = table.concat(lines, "\n"), + } +end + +function IssueReport.build(options, context) + options = options or SaveData.loadOptions() + context = context or {} + local info = metadata(options, context) + local fields = { + summary = "", + mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "", + version = info.version or "", + location = "", + screenshot = "", + steps = "", + expected = "", + extra = info.metadata, + } + local params = { + "template=" .. percentEncode(TEMPLATE), + "title=" .. percentEncode("bug: replace this with a meaningful title"), + } + local order = { "summary", "mods_which", + "version", "location", "screenshot", "steps", "expected", "extra" } + for _, key in ipairs(order) do + params[#params + 1] = key .. "=" .. percentEncode(fields[key]) + end + return FORM_URL .. "?" .. table.concat(params, "&"), fields, info +end + +function IssueReport.open(options, context) + local url = IssueReport.build(options, context) + local system = love and love.system or {} + local opened, openResult = invoke(system.openURL, url) + if opened and openResult ~= false then + return true, url + end + local copied, copyResult = invoke(system.setClipboardText, url) + if copied and copyResult ~= false then + return true, url, "Issue URL copied to the clipboard." + end + local filesystem = love and love.filesystem or {} + local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url) + if written and writeResult ~= false then + return true, url, "Issue URL saved to issue-report-url.txt." + end + return false, url, "No browser, clipboard, or writable save directory is available for the issue report." +end + +IssueReport.percentEncode = percentEncode +IssueReport.metadata = metadata + +return IssueReport diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index a63db2a4..9c529f0e 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -300,6 +300,7 @@ function SaveData.defaultOptions() -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, + safeMode = false, -- Mods the player forced past the target gate (Loader:_gateGeneration). -- modsGen2[id][version] = true, one answer per game; a bare `true` is the -- pre-per-game shape and means the Gen 2 games only (see modForced). @@ -384,6 +385,16 @@ function SaveData.mergeOptions(loaded) return opts end +function SaveData.isSafeMode(options) + return type(options) == "table" and options.safeMode == true +end + +function SaveData.setSafeMode(options, enabled) + if type(options) ~= "table" then return false end + options.safeMode = enabled == true + return options.safeMode +end + function SaveData.encode(data) return SaveSerializer.encode(data) end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 43a820b4..a73d7eed 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -422,7 +422,7 @@ local function discoverModSchemas(opts) -- except experimental mods, which stay off until opted in. local flag = require("src.core.SaveData").modEnabled(opts, m.id) local enabled = flag == true or (flag == nil and not m.experimental) - if enabled then + if enabled and not SaveData.isSafeMode(opts) then local chunk = fs.load(path .. "/" .. m.options_schema) if chunk then local okR, schema = pcall(chunk) @@ -521,9 +521,49 @@ local function modRows(opts, mod) return true end } end + for _, row in ipairs(rows) do + row.safeModeBlocked = true + if row.step then + local step = row.step + row.step = function(dir) + if SaveData.isSafeMode(opts) then return false end + return step(dir) + end + end + if row.setText then + local setText = row.setText + row.setText = function(text) + if SaveData.isSafeMode(opts) then return false end + return setText(text) + end + end + end return rows end +local function troubleshootingRows(opts, hooks) + return { + { + label = Strings("SAFE MODE"), + actionLabel = function() + return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on") + end, + action = function() + SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts)) + return true + end, + }, + { + label = Strings("REPORT ISSUE"), + actionLabel = Strings("Report bug"), + action = function() + if hooks and hooks.reportIssue then hooks.reportIssue(opts) end + return false + end, + }, + } +end + -- ------- Gen 2 (Gold) -- -- Gold reads NONE of the rows above. Its OPTION screen writes a different @@ -704,6 +744,10 @@ function LauncherSettings.open(hooks, version) sections[#sections + 1] = { title = mod.name, rows = rows } end end + sections[#sections + 1] = { + title = Strings("TROUBLESHOOTING"), + rows = troubleshootingRows(opts, hooks), + } return { opts = opts, version = version, diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index cd41d034..b3bcede2 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -630,6 +630,7 @@ end local function modStatusColor(status) if status == "ok" then return Strings("Ready"), PAL.green end + if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end if status == "needs_import" then return Strings("Import required"), PAL.yellow end if status == "conflict" then return Strings("Conflict"), PAL.red end -- not a fault: the mod is intact, this is simply not a game it is for @@ -1704,10 +1705,11 @@ end -- One compact coloured checkbox for each game. The cartridge colour carries -- the game identity even when the row is narrow. -local function modGameCheckbox(x, y, size, checked, game, id) - local color = cartColor(game) - local focused = Kit.focusable(id, x, y, size, size) - local hot = focused or Kit.hover(x, y, size, size) +local function modGameCheckbox(x, y, size, checked, game, id, enabled) + enabled = enabled ~= false + local color = enabled and cartColor(game) or PAL.steel + local focused = enabled and Kit.focusable(id, x, y, size, size) + local hot = enabled and (focused or Kit.hover(x, y, size, size)) if love.graphics then Theme.fillRounded(x, y, size, size, PAL.bg, 1) if checked then @@ -1719,12 +1721,13 @@ local function modGameCheckbox(x, y, size, checked, game, id) hot and Theme.A.focus or Theme.A.hairline, 1) end end - return Kit.press(x, y, size, size) or Kit._activateId == id + return enabled and (Kit.press(x, y, size, size) or Kit._activateId == id) end local function buildModsPanel(imp, x, y, w, availH, m) imp:_ensureMods() local ModUpdate = require("src.mods.ModUpdate") + local safeMode = imp.safeMode == true local mods = imp.mods or {} local gap = m.gap local cy = y @@ -1756,9 +1759,11 @@ local function buildModsPanel(imp, x, y, w, availH, m) action = function() imp:chooseMod() end }) btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { kind = "warn", font = "small", + enabled = not safeMode, action = function() imp:_setAllMods(false) end }) btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { kind = "good", font = "small", + enabled = not safeMode, action = function() imp:_setAllMods(true) end }) btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { font = "small", @@ -1807,7 +1812,9 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- notice line local noticeText, noticeCol - if imp.modNotice then + if safeMode then + noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow + elseif imp.modNotice then noticeText = imp.modNotice.text noticeCol = imp.modNotice.ok and PAL.green or PAL.red else @@ -1932,7 +1939,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) local togKey = "mod-toggle-" .. mod.id .. "-" .. game if modGameCheckbox(tx, gamesY, togH, mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey) then + game, togKey, not safeMode) then local version = game queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) flipped = true @@ -2925,6 +2932,7 @@ local function buildProfilesModal(imp, m) btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i, Strings("Switch"), { kind = "good", font = "micro", + enabled = not imp.safeMode, action = function() LauncherMods.applyProfile(p.name, options) if imp._refreshMods then imp:_refreshMods() end @@ -2951,8 +2959,10 @@ local function buildModHeaderActionsModal(imp, m) local btns = { { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, { label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end }, - { label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end }, - { label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end }, + { label = Strings("Enable all mods"), kind = "good", enabled = not imp.safeMode, + action = function() imp:_setAllMods(true) end }, + { label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode, + action = function() imp:_setAllMods(false) end }, { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end }, } local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) @@ -2965,6 +2975,7 @@ local function buildModHeaderActionsModal(imp, m) for i, b in ipairs(btns) do btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, { kind = b.kind or "ghost", font = "small", + enabled = b.enabled, action = function() imp._modHeaderActionsPopup = nil b.action() @@ -3514,6 +3525,7 @@ end local function buildSettingsModal(imp, m) local model = imp._settings + local SaveData = require("src.core.SaveData") local pad = math.floor(18 * m.s) local w = math.floor(640 * m.s) local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9)) @@ -3603,6 +3615,8 @@ local function buildSettingsModal(imp, m) item.header) else local row = item.row + local rowEnabled = not row.safeModeBlocked + or not SaveData.isSafeMode(model.opts) local key = "set-" .. i Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline") local ix = px + pad + math.floor(12 * m.s) @@ -3632,6 +3646,7 @@ local function buildSettingsModal(imp, m) ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail) btn(imp, rx - ew, ctlY, ew, m.btnH, key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", + enabled = rowEnabled, action = function() imp._settingsText = { row = row, text = tostring(row.value() or ""), maxLen = row.editText.maxLen } @@ -3640,12 +3655,14 @@ local function buildSettingsModal(imp, m) elseif row.action then -- A plain action row (Reset rebinds, Touch controls): the whole right -- side is one button rather than a value ladder. - local aw = Kit.textWidth("small", row.actionLabel or Strings("Run")) + local actionLabel = type(row.actionLabel) == "function" + and row.actionLabel() or row.actionLabel or Strings("Run") + local aw = Kit.textWidth("small", actionLabel) + math.floor(24 * m.s) Kit.text("small", Kit.ellipsize("small", row.label, labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text) btn(imp, rx - aw, ctlY, aw, m.btnH, - key .. "-act", row.actionLabel or Strings("Run"), { + key .. "-act", actionLabel, { kind = row.danger and "danger" or "ghost", font = "small", action = function() if row.action() ~= false then model.save() end @@ -3661,12 +3678,14 @@ local function buildSettingsModal(imp, m) or valW btn(imp, rx - stepW, ctlY, stepW, m.btnH, key .. "-next", ">", { font = "small", + enabled = rowEnabled, action = function() if row.step and row.step(1) then model.save() end end }) Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw), rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, PAL.heading) btn(imp, rx - stepW - vw - stepW, ctlY, stepW, m.btnH, key .. "-prev", "<", { font = "small", + enabled = rowEnabled, action = function() if row.step and row.step(-1) then model.save() end end }) end end @@ -3823,6 +3842,7 @@ local function buildDepResolverModal(imp, m) local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s) btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, { kind = "warn", font = "small", + enabled = not imp.safeMode, action = function() local LauncherMods = require("src.mods.LauncherMods") LauncherMods.setEnabled(dep.id, false, imp.modScope) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index ab9af931..8bac4cae 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3233,6 +3233,10 @@ function RomImporter:_openSettings() -- The tab rides along: the editor persists the layout into that game's own -- option block, and Gold's is not the flat Gen 1 one (#1100). local hooks = {} + local version = self.tab + hooks.reportIssue = function(opts) + return self:_reportIssue(opts, version) + end if self.onEditTouchControls then local version = self.tab hooks.editTouchControls = function() @@ -3250,11 +3254,13 @@ function RomImporter:_openSettings() -- The tab the gear was opened on decides the row set: Gold reads a -- different option block entirely, and offering it Gen 1's rows meant a -- dozen controls that changed nothing (see LauncherSettings.gen2Rows). - local version = self.tab local ok, model = pcall(function() return require("src.import.LauncherSettings").open(hooks, version) end) - if ok and model then self._settings = model end + if ok and model then + self._settings = model + self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts) + end end -- Quit from the launcher's own X. It goes through love.event.quit so main.lua's @@ -3265,8 +3271,38 @@ function RomImporter:_quitApp() end function RomImporter:_closeSettings() - if self._settings then self._settings.save() end + local model = self._settings + if model then + model.save() + local safeMode = require("src.core.SaveData").isSafeMode(model.opts) + if safeMode ~= self._settingsSafeModeAtOpen then + self.mods = nil + self.safeMode = safeMode + self._modSortCache = nil + self._modInfoFetch = nil + end + end self._settings = nil + self._settingsSafeModeAtOpen = nil +end + +function RomImporter:_reportIssue(options, version) + local ok, IssueReport = pcall(require, "src.core.IssueReport") + if not ok then + self.modNotice = { ok = false, text = "Could not prepare the issue report." } + return false + end + local opened, url, reason = IssueReport.open(options, { + version = version, + mods = self.mods, + }) + if not opened then + self.modNotice = { ok = false, text = reason or "Could not open the issue report." } + return false + end + self._lastIssueReportURL = url + if reason then self.modNotice = { ok = true, text = reason } end + return true end function RomImporter:_commitSettingsText() @@ -3561,6 +3597,8 @@ end -- so a still list costs nothing after the first paint. function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") + local SaveData = require("src.core.SaveData") + self.safeMode = SaveData.isSafeMode(SaveData.loadOptions()) -- Once per session, ahead of the first listing: pull in any mod the player -- unzipped beside the executable, which an ordinary (non-portable) install -- has no way to read. It happens here rather than behind a button because @@ -3700,6 +3738,10 @@ end -- so that game's checkbox and status chips reflect the new resolution. -- Enabling an experimental mod arms a confirmation for that same game. function RomImporter:_toggleMod(id, confirmed, version) + if self.safeMode then + self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." } + return + end local LauncherMods = require("src.mods.LauncherMods") local cur, experimental = false, false for _, m in ipairs(self.mods or {}) do @@ -3740,6 +3782,10 @@ end -- must not be the way around it. Disabling needs no confirm -- it is the -- recovery action, and Delete is the only destructive one on this panel. function RomImporter:_setAllMods(want, confirmed) + if self.safeMode then + self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." } + return + end local LauncherMods = require("src.mods.LauncherMods") local ids, experimental = {}, false for _, m in ipairs(self.mods or {}) do diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 10922dea..970e9517 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version) local ordered = {} for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end table.sort(ordered, function(a, b) return a.id < b.id end) + local safeMode = SaveData.isSafeMode(options) -- the override is one answer per game (SaveData.modForced), the same scope -- the loader resolves it under @@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version) -- matching the loader -- except experimental mods, which stay off until -- the player opts in. Scoped through modScope, so this reads exactly what -- setEnabled writes and the loader loads for the selected game. - local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) - if decided == nil then decided = not m.experimental end - if decided then enabledSet[m.id] = true end + if not safeMode then + local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) + if decided == nil then decided = not m.experimental end + if decided then enabledSet[m.id] = true end + end end local out = {} for _, m in ipairs(ordered) do - local enabled = enabledSet[m.id] == true + local enabled = not safeMode and enabledSet[m.id] == true local forced = forcedFor(m.id) - local status, detail = - statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) + local status, detail + if safeMode then + status, detail = "safe_mode", "Disabled by safe mode" + else + status, detail = + statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) + end -- nil, not false, when the panel is showing every game at once local here = nil if version then here = ModTargets.runsHere(m, version, nil, forced) end @@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version) local answers = {} for _, game in ipairs(GameVersion.ORDER) do local answer = SaveData.modEnabled(options, m.id, game) - answers[game] = answer == true or (answer == nil and not m.experimental) + answers[game] = not safeMode + and (answer == true or (answer == nil and not m.experimental)) end return answers end)(), @@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version) -- panel is showing (src/mods/ModTargets.lua) targets = ModTargets.chip(m), targetsHere = here, + safeMode = safeMode, } end return out @@ -586,6 +596,7 @@ end -- answer. The loader and the in-game manager use the same scope on next boot. function LauncherMods.setEnabled(id, enabled, version) local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.saveOptions(options) LauncherMods.syncActiveProfile(options) @@ -599,6 +610,7 @@ end -- and leaves a half-applied state behind if one of them fails. function LauncherMods.setAllEnabled(ids, enabled, version) local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end local scope = SaveData.modScope(version) for _, id in ipairs(ids or {}) do if scope then @@ -1184,6 +1196,7 @@ end function LauncherMods.applyProfile(profileName, options) options = options or SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end local profiles = options.modProfiles or {} local targetProfile for _, p in ipairs(profiles) do diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 5dbad269..8ec4767d 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -259,6 +259,7 @@ function Loader.new(opts) modInput = {}, modEnv = {}, stepsQueues = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, + safeMode = false, -- Which generation this boot is (1 or 2). Fixed at construction: the -- active version is set once in main.lua's bootGame before anything -- builds a loader, and a run never changes generation underneath one. @@ -300,6 +301,8 @@ end function Loader:_loadState() self.disabled = {} local options = SaveData.loadOptions(self.fs) + self.safeMode = SaveData.isSafeMode(options) + Runtime.safeMode = self.safeMode local scope = self:_enableScope() local ids = {} for id in pairs(options.mods or {}) do ids[id] = true end @@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas() end function Loader:setEnabled(id, enabled) + if self.safeMode then return false end if not self.mods[id] then return false end self.disabled[id] = not enabled self.mods[id].enabled = enabled @@ -427,6 +431,7 @@ end -- choice could not be persisted for a game, so the caller does not promise a -- restart will honour it. function Loader:setGen2Forced(id, forced) + if self.safeMode then return false, false end if not self.mods[id] then return false, false end self.gen2Forced[id] = forced or nil self:_saveState() @@ -1539,6 +1544,9 @@ function Loader:load(data) require("src.mods.Builtins").install(self.content, data, self.generation) self:_loadState() self:_discover() + if self.safeMode then + for id in pairs(self.mods) do self.disabled[id] = true end + end -- Existing installs stored one shared answer. Once their manifests are -- known, split that answer across every game before the next launcher/game -- toggle can change one independently. _loadState already used the same @@ -1574,7 +1582,7 @@ function Loader:load(data) -- the one build where its env var is set. for id, mod in pairs(self.mods) do local envName = mod.manifest.force_enable_env - if envName and os.getenv(envName) == "1" then + if not self.safeMode and envName and os.getenv(envName) == "1" then self.disabled[id] = nil end end @@ -1740,6 +1748,7 @@ function Loader:status() local manifest = {} for key, value in pairs(mod.manifest) do manifest[key] = value end manifest.enabled = mod.enabled ~= false + manifest.safeMode = self.safeMode == true manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.error = mod.failure -- set instead of `error` when the mod was left out for a reason that is diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 3066888e..5dd87ef2 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -365,9 +365,13 @@ end function ManagerState:detailRows(m) local rows = {} - rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", - action = function() self:beginToggle(m) end } - if self:schemaFor(m) then + if Runtime.safeMode then + rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true } + else + rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", + action = function() self:beginToggle(m) end } + end + if not Runtime.safeMode and self:schemaFor(m) then rows[#rows + 1] = { label = Strings("OPTIONS.."), action = function() self:openOptions(m) end } end @@ -383,7 +387,8 @@ function ManagerState:detailRows(m) -- what this mod does. local loader = self.game.mods local version, gen = self:targetGame() - if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then + if loader and loader.setGen2Forced and not Runtime.safeMode + and not ModTargets.supports(m, version, gen) then rows[#rows + 1] = { label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"), action = function() self:toggleGen2Force(m) end } @@ -674,6 +679,10 @@ end -- ------- the enable/disable flow function ManagerState:beginToggle(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end if not m then return end local want = not m.enabled local loader = self.game.mods @@ -711,6 +720,10 @@ end -- override is scoped to THIS game, and a boot that cannot name one keeps it in -- memory only, which the notice says rather than promising a restart. function ManagerState:toggleGen2Force(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods if not (loader and loader.setGen2Forced) then return end local want = not m.gen2Forced @@ -743,6 +756,10 @@ function ManagerState:enableScope() end function ManagerState:commitToggle(apply) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods local opts = self:optionsTable() local scope = self:enableScope() @@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply) end function ManagerState:discardChanges() + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods local opts = self:optionsTable() local scope = self:enableScope() @@ -802,6 +823,10 @@ function ManagerState:persistOptions() end function ManagerState:applyProfile(p) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local mods = self:manifestMap() local set = self:enabledSet() local combined = {} @@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row) end function ManagerState:setOption(modId, key, value) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return false + end local save = self.game.save if save and save.options then save.options.modOptions = save.options.modOptions or {} @@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema) end function ManagerState:openOptions(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local schema = self:schemaFor(m) if not schema then self:notify("NO OPTIONS") diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua index 0b2c94a8..210413cb 100644 --- a/src/mods/Runtime.lua +++ b/src/mods/Runtime.lua @@ -33,6 +33,8 @@ Runtime.currentMod = nil -- currentMod went back to nil (src/mods/Sandbox.lua) Runtime.modRequire = nil +Runtime.safeMode = false + function Runtime.install(events, hooks, errors) Runtime.events, Runtime.hooks = events, hooks Runtime.errors = errors diff --git a/tests/engine/safe_mode_issue_report.lua b/tests/engine/safe_mode_issue_report.lua new file mode 100644 index 00000000..428bbcd8 --- /dev/null +++ b/tests/engine/safe_mode_issue_report.lua @@ -0,0 +1,137 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("safe mode and issue report") +local check = S.check + +local SaveData = require("src.core.SaveData") +local LauncherMods = require("src.mods.LauncherMods") +local IssueReport = require("src.core.IssueReport") +local Version = require("src.core.Version") + +local options = SaveData.defaultOptions() +check(not SaveData.isSafeMode(options), "safe mode defaults off") +SaveData.setSafeMode(options, true) +check(SaveData.isSafeMode(options), "safe mode can be enabled") + +local manifests = { + { id = "alpha", name = "Alpha", version = "1.0.0", experimental = false, + raw = {}, dependencySpecs = {}, conflictSpecs = {} }, + { id = "beta", name = "Beta", version = "1.0.0", experimental = false, + raw = {}, dependencySpecs = {}, conflictSpecs = {} }, +} +options.mods.alpha = false +options.mods.beta = true +local rows = LauncherMods.deriveList(manifests, options, "red") +check(#rows == 2, "safe mode keeps installed mods visible") +check(not rows[1].enabled and not rows[2].enabled, + "safe mode disables every launcher mod row") +check(rows[1].status == "safe_mode" and rows[2].status == "safe_mode", + "safe mode explains every disabled launcher row") + +SaveData.setSafeMode(options, false) +rows = LauncherMods.deriveList(manifests, options, "red") +local byId = {} +for _, row in ipairs(rows) do byId[row.id] = row end +check(not byId.alpha.enabled and byId.beta.enabled, + "turning safe mode off restores saved mod choices") + +local previousLove = _G.love +local openedURL +_G.love = { + getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end, + system = { + getOS = function() return "iOS" end, + getModel = function() return "iPad Test" end, + openURL = function(url) openedURL = url end, + }, + graphics = { + getRendererInfo = function() + return "Metal", "3.0", "Apple", "Simulator GPU" + end, + getDimensions = function() return 1024, 768 end, + getPixelDimensions = function() return 2048, 1536 end, + }, + window = { + getMode = function() return 1024, 768, { fullscreen = false } end, + }, +} + +local url, fields, info = IssueReport.build({ + safeMode = true, + lastVersion = "gold", +}, { + version = "gold", + mods = { { id = "alpha", name = "Alpha", enabled = true } }, +}) +check(url:find("template=bug_report.yml", 1, true) ~= nil, + "report URL selects the bug form") +check(url:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil, + "report URL uses the requested bug title") +check(info.os == "iOS", + "report metadata maps the platform") +check(fields.mods_which == "", + "safe mode leaves the optional mod list blank") +check(not url:find("game=", 1, true) + and not url:find("os=", 1, true) + and not url:find("mods_enabled=", 1, true), + "report URL omits unsupported dropdown and checkbox prefills") +check(fields.summary == "" and fields.location == "" and fields.screenshot == "" + and fields.steps == "" and fields.expected == "", + "report leaves user-entered fields blank") +check(info.metadata:find("Device: iPad Test", 1, true) ~= nil + and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil + and info.metadata:find("Safe mode: on", 1, true) ~= nil, + "report metadata includes device and app details") +check(not info.metadata:find("unknown", 1, true), + "report metadata omits unknown values") +check(not info.metadata:find("Game id", 1, true) + and not info.metadata:find("Game:", 1, true) + and not info.metadata:find("Mods:", 1, true) + and not info.metadata:find("Processors", 1, true) + and not info.metadata:find("Power", 1, true), + "report metadata omits redundant system fields") + +local previousEngine = Version.engine +Version.engine = "0.1.50" +local _, versionFields, versionInfo = IssueReport.build({}, { mods = {} }) +check(versionFields.version == "0.1.50" + and versionInfo.metadata:find("App: gen1recomp v0.1.50", 1, true) ~= nil, + "report uses the stamped app version") +Version.engine = previousEngine +local _, _, developmentInfo = IssueReport.build({}, { mods = {} }) +check(not developmentInfo.metadata:find("0.0.0-dev", 1, true), + "report omits an unstamped development version") + +local previousOS = love.system.getOS +local previousModel = love.system.getModel +local previousIO = _G.io +love.system.getOS = function() return "OS X" end +love.system.getModel = nil +_G.io = { + popen = function() + return { + read = function() return "MacBookPro18,3" end, + close = function() end, + } + end, +} +local desktopInfo = IssueReport.metadata({}, { mods = {} }) +check(desktopInfo.device == "MacBookPro18,3", + "report finds desktop device model when LOVE has no model") +love.system.getOS = function() return "UWP" end +local xboxInfo = IssueReport.metadata({}, { mods = {} }) +check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform") +love.system.getOS = previousOS +love.system.getModel = previousModel +_G.io = previousIO + +local opened = IssueReport.open({ safeMode = false }, { + version = "red", + mods = {}, +}) +check(opened and openedURL and openedURL:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil, + "report action opens the generated URL") + +_G.love = previousLove + +S.finish() From 302b2c9591c819e6364da43c24040b5d9f7f5ad6 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Tue, 18 Aug 2026 20:37:25 -0500 Subject: [PATCH 4/9] feat(android): add adaptive icons, dynamic shortcuts, in-process hot-swap, and exit-to-launcher --- main.lua | 112 ++++++++-- .../android/app/src/main/AndroidManifest.xml | 5 +- .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 25574 bytes .../res/drawable-hdpi/ic_shortcut_blue.png | Bin 0 -> 6609 bytes .../res/drawable-hdpi/ic_shortcut_gold.png | Bin 0 -> 6413 bytes .../res/drawable-hdpi/ic_shortcut_red.png | Bin 0 -> 6536 bytes .../res/drawable-hdpi/ic_shortcut_yellow.png | Bin 0 -> 6528 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 13544 bytes .../res/drawable-mdpi/ic_shortcut_blue.png | Bin 0 -> 3548 bytes .../res/drawable-mdpi/ic_shortcut_gold.png | Bin 0 -> 3479 bytes .../res/drawable-mdpi/ic_shortcut_red.png | Bin 0 -> 3551 bytes .../res/drawable-mdpi/ic_shortcut_yellow.png | Bin 0 -> 3517 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 39504 bytes .../res/drawable-xhdpi/ic_shortcut_blue.png | Bin 0 -> 10396 bytes .../res/drawable-xhdpi/ic_shortcut_gold.png | Bin 0 -> 10147 bytes .../res/drawable-xhdpi/ic_shortcut_red.png | Bin 0 -> 10280 bytes .../res/drawable-xhdpi/ic_shortcut_yellow.png | Bin 0 -> 10441 bytes .../ic_launcher_foreground.png | Bin 0 -> 41284 bytes .../res/drawable-xxhdpi/ic_shortcut_blue.png | Bin 0 -> 20142 bytes .../res/drawable-xxhdpi/ic_shortcut_gold.png | Bin 0 -> 19724 bytes .../res/drawable-xxhdpi/ic_shortcut_red.png | Bin 0 -> 19923 bytes .../drawable-xxhdpi/ic_shortcut_yellow.png | Bin 0 -> 20354 bytes .../ic_launcher_foreground.png | Bin 0 -> 42470 bytes .../res/drawable-xxxhdpi/ic_shortcut_blue.png | Bin 0 -> 32524 bytes .../res/drawable-xxxhdpi/ic_shortcut_gold.png | Bin 0 -> 31928 bytes .../res/drawable-xxxhdpi/ic_shortcut_red.png | Bin 0 -> 32443 bytes .../drawable-xxxhdpi/ic_shortcut_yellow.png | Bin 0 -> 32920 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../app/src/main/res/values/colors.xml | 5 + .../love/src/jni/love/src/common/android.cpp | 93 +++++++++ .../love/src/jni/love/src/common/android.h | 10 + .../jni/love/src/modules/system/System.cpp | 19 ++ .../src/jni/love/src/modules/system/System.h | 3 + .../love/src/modules/system/wrap_System.cpp | 30 +++ .../java/org/love2d/android/GameActivity.java | 108 +++++++++- src/core/Game.lua | 1 + src/core/Game2.lua | 1 + src/core/LaunchOptions.lua | 13 ++ src/import/RomImporter.lua | 28 +++ src/mods/Runtime.lua | 8 + src/ui/TitleState.lua | 5 +- .../engine/android_exit_to_launcher_test.lua | 80 +++++++ .../engine/android_shortcuts_payload_test.lua | 81 ++++++++ tools/generate_android_icons.py | 196 ++++++++++++++++++ 45 files changed, 792 insertions(+), 16 deletions(-) create mode 100644 mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png create mode 100644 mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png create mode 100644 mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png create mode 100644 mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_yellow.png create mode 100644 mobile/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_blue.png create mode 100644 mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_gold.png create mode 100644 mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png create mode 100644 mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_yellow.png create mode 100644 mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_blue.png create mode 100644 mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_gold.png create mode 100644 mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png create mode 100644 mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_yellow.png create mode 100644 mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png create mode 100644 mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png create mode 100644 mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png create mode 100644 mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png create mode 100644 mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_blue.png create mode 100644 mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png create mode 100644 mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png create mode 100644 mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 tests/engine/android_exit_to_launcher_test.lua create mode 100644 tests/engine/android_shortcuts_payload_test.lua create mode 100644 tools/generate_android_icons.py diff --git a/main.lua b/main.lua index 1a9cd3aa..6243c200 100644 --- a/main.lua +++ b/main.lua @@ -291,6 +291,78 @@ function closeSkinStudio() end end +local function makeLauncher() + local RomImporter = require("src.import.RomImporter") + local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1" + return RomImporter.new(function(version) + Importer = nil + bootGame(version) + end, { + launcher = true, + forceImport = forceImport, + onEditSave = openEditor, + onEditTouchControls = openTouchControlsEditor, + onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() + and openSkinStudio or nil, + }) +end + +local function returnToLauncher() + if not Game then return end + + pcall(function() require("src.core.Music").stop() end) + pcall(function() require("src.core.Sound").stop() end) + if package.loaded["src.core.ChipAudio"] then + pcall(package.loaded["src.core.ChipAudio"].shutdown) + end + if package.loaded["src.core.DiscordPresence"] then + pcall(package.loaded["src.core.DiscordPresence"].shutdown) + end + if package.loaded["src.core.gen2.Clock"] then + pcall(package.loaded["src.core.gen2.Clock"].shutdown) + end + if package.loaded["src.net.Gen1Tls"] then + pcall(package.loaded["src.net.Gen1Tls"].shutdown) + end + if love.audio and love.audio.stop then + pcall(love.audio.stop) + end + + local GameVersion = require("src.core.GameVersion") + local currentVersion = GameVersion.get() + if currentVersion then + require("src.import.CacheFs").unmountVersion(currentVersion) + end + require("src.core.Data"):unloadGenerated() + + local Runtime = require("src.mods.Runtime") + if Runtime.reset then + Runtime.reset() + end + + Game = nil + autopilot = nil + driverCo = nil + + local Input = require("src.core.Input") + local TouchControls = require("src.core.TouchControls") + Input:reset() + TouchControls:reset() + + require("src.core.Orientation").applyOptions( + require("src.core.SaveData").loadOptions()) + + local preload = require("src.mods.LauncherMods").translationStrings() + if preload then require("src.core.Strings").load({ strings = preload }) end + + if love.window and love.window.setTitle then + local Version = require("src.core.Version") + love.window.setTitle(Version.title("Gen 1 Recompilation Project")) + end + + Importer = makeLauncher() +end + function bootGame(version) -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); -- scripted and headless runs fall back to POKEPORT_VERSION, then Red. @@ -484,17 +556,7 @@ function love.load(args) -- by its SHA-1 (GameVersion.forSha1); pressing Play boots that game (Gold -- goes to its own service owner, src/core/Game2.lua -- docs/gold-phase1.md). -- Edit on a save row opens the bundled editor on that slot (openEditor). - Importer = RomImporter.new(function(version) - Importer = nil - bootGame(version) - end, { - launcher = true, - forceImport = forceImport, - onEditSave = openEditor, - onEditTouchControls = openTouchControlsEditor, - onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop() - and openSkinStudio or nil, - }) + Importer = makeLauncher() end function love.update(dt) @@ -827,6 +889,27 @@ function love.handlers.audioreset() if Sound then pcall(Sound.onDeviceReset) end end +function love.handlers.intent_game(version) + if type(version) ~= "string" or version == "" then return end + version = version:lower():gsub("^%s+", ""):gsub("%s+$", "") + local GameVersion = require("src.core.GameVersion") + if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end + + local RomImporter = require("src.import.RomImporter") + if not RomImporter.isReady(version) then return end + + local currentVersion = GameVersion.get() + if Game and currentVersion == version then + return + end + + if Game then + returnToLauncher() + end + Importer = nil + bootGame(version) +end + function love.touchpressed(id, x, y, dx, dy, pressure) if editorMode then -- iOS synthesizes mousepressed for the primary touch; forwarding here @@ -1032,11 +1115,16 @@ function love.quit() -- docs/modding.md's core.quit_to_launcher entry) may veto returning to -- this Lua launcher via that hook. Vanilla behavior (used when no mod -- claims the hook) is exactly the condition below. + local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android") local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() return Game and not Importer and not quitToLauncher and not scripted - and not launchedIntoGame + and (isAndroid or not launchedIntoGame) end) if wouldReturnToLauncher then + if isAndroid then + returnToLauncher() + return true -- abort this quit; the restart lands back in the launcher + end quitToLauncher = true -- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- once, so the restart really does land in the launcher (#887). A failed diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index a2de3161..33ad1ea6 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -29,7 +29,8 @@ diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..35b25473c0b1360043545a9c8fb9e918969aabc9 GIT binary patch literal 25574 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 QzvDkuMNNeoIg7CW1CYSjp#T5? literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_blue.png new file mode 100644 index 0000000000000000000000000000000000000000..292907ed9f16be9a90d225744f8e9361637b14d4 GIT binary patch literal 6609 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 P00000NkvXXu0mjfz>UOw literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..a9fb6040f1c035c1d5fd43e5322fe3c28b45c896 GIT binary patch literal 6413 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 b4*35Ea567K2Xvfb00000NkvXXu0mjfWA1rU literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-hdpi/ic_shortcut_red.png new file mode 100644 index 0000000000000000000000000000000000000000..c2b973812465133d0b55fab34228559a1018ef0a GIT binary patch literal 6536 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 ux4+QhD`K=|& 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 W3ZmF9E@M0Z0000D;3 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 FV1j|yu&Mw6 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-mdpi/ic_shortcut_red.png new file mode 100644 index 0000000000000000000000000000000000000000..591974c86614d8b85213ffb98fc5d34237dd6114 GIT binary patch literal 3551 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..fc0f5e4932dd455c717df8ec29911b3770a36939 GIT binary patch literal 39504 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 RX*mD@002ovPDHLkV1oFT#B2Zn literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xhdpi/ic_shortcut_red.png new file mode 100644 index 0000000000000000000000000000000000000000..4e30e8726bba06634e197037c8582e09472f5779 GIT binary patch literal 10280 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..53f8022e45d3637866c08a06367f9635aeb906fc GIT binary patch literal 41284 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_blue.png new file mode 100644 index 0000000000000000000000000000000000000000..595c1747b0d7ddcc7278f3c863a5926cbccc7de7 GIT binary patch literal 20142 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..93596378c2211ec665caca57812f64fe77de5b46 GIT binary patch literal 19724 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�fL|md&Ug^MP#nNm#9J6Wj zCPqd^xcAul z@X$jK@!*3G^28HQaN~_P((Cm&c<>3VWh7ptG^69Fw8(S- zutzwgRS$4=1F8{zr6|-K6~JD6Z^3o4k+PduNGEfs$R(!*;P&gZEUyB&q->6viodV_|Fffm<=g^et0=EI2#*>+Z-7HXs7h~>*y5XUhqSFYs78*gOq-o30{yA~1Q zH-GbW0GxT|nbdszL9Fnh4}FLO2M#bZJ4+JBg%k?c4^;~TF{H4Ox|pC)SaGgdxOXTj zSC1ZYdBiS0BZYZwizkOlxi-GKLIIi#W%E_uU)|IoxI^J+0pNATPT~$TZ z>xb~efP|C5-xmyMD%bE!74Vt_i&R6BXp-2O{tNv*Qu*wKV=x!p^blys=^W+%%Lf@O za|yqQBZZ2}s#4{Tno^2Yt5&gm`Erst;lT$V>$fB&N}NX)~;R4)PY&7H6*bn(q(VyJTKEORK*`uMBFR)RfaRFUh^_4ex$sbaMDBM zJ*pH-k1G67loHuggS5fhU17=)`>G|-@MXQJ@2B{r`C)k43x-`AuK=mUz^d^u z7z8FFi-+jBlm$zYB&=P#mQ9;Bar4bLbLN?6a@SpVasU1I)9rNGyKgUX9CPu-7qfBW zMlZ28anHREc_#F6B^1`j#A5IEk1BymDoj)viBT|*t}s*N+q9q#&qRSiip^A|1Wa>i zBqc^tzNX)`eNjkUN+T+a7+`^m>p2HiqM#ltMq809Ea*YK;c|r zigmRP&f%)m$GSpZP>9e6m6iL>Y9V;5wW~@^Vi)oV1&Fl-nzb5991Td#!tvw3E}ZK; zEE-xgKAe_?gV`352ZR*nw7Wn*&3N#E`)SXvq}%P%Xf{~2dKEi&?&Q={Pvx9*&LPV( zj4`xYEjpbJQ55m;!w>t3I>Mvdw-ZI$rBdkfL|Yp=Xq2}R@@Yb!#bE9SHCi3irYI{f z>|tu|e-MRk^48bLi!ToCO`@k_MFp%Bnn&fKm@r^PKfoq5PYC(d0n|rPt(;apaC=gIu>bjpQG%nO zq4Bd$0vj@i3sua`#JS2C3GsW6Ko^X>C;Q*ML}E%7QhF1}xpE1#(_ z&y(1JDo77M>rfTqrJnJPLURMX)YbK8xh*U(rM>B|2Yo=HIeKnf?Hsm8zjF|4%W!=N zq!!Oq1niEqQ9n8jH05fK6en1OLB?1RZ!#hnYX{z=D#6%rxDS^NShz9{!)!qf+M=f4F;3`?7JN~0tcbK8 zu;Kzkq(1Cn4D?dVeAfkDi_q7?yHS**#20PU(a|uiu&Ui#hM^buUz88_<+-2aq#!dn zlatGN;~V}9-~7h)eEBP1#aiJdFZpTKAGe+tz4*o4ci;V-e)?&=;SF!#Q=j@2S6y`# z?|a|-`HkQB&vZI%#>PfjwdMpq{r+EcIoIAMHa2*8OAHaxLT$w<7^Fjh5@MxLx(gj| z0hbh(XUKA_yhm3UXrb^5jz4RK+=gsN|9865Y}4PIcB9RT{OSsaqorY5VUhn@K{$&?d`MNVd52#uiJ6_5;m`l#ubAGyn=I`!K0d}@|Mf?C z#zoIy+`O6{vjXz*vFWiondse#RvcL1FTxLl1`_?&ELL_R-;ZoGe!A} zF=cEke0B(=+5*fkNEi!)bPs!?D?x5smf0-yE|YjxZk+j!AN3TDo~=}AREEb@VNdaWm6<}n#f6IAx04wNiyTgk$%6M#9-NS-8iJuV z1j?k8aG`f}Xh#zSOR(y?Jt;;pR7q8(4uhR&MMS`4A}mhz{1ND=8A!z1W}~KM`iZY8 z6+KsJHDbf~<$(>VWU^D9{E$L1HWIU9VwDH5?mbD|a3^wgqp~P1thGdXge*6#m{?Xw z8EYX;U68FPu?Z4IeB#xB+FESzh7H%MZ0cPdn_ab{pu!Xj!zAIW9=x`-zWGE5UxdEZ zMZPo`t9(7Z3$(_u8c0A3u~8I(R((6SbUHx%$j zkPxO)k5<;D4R%)zOcs1V1Ncaloyo$eZzb-!A&(ru}y!DlLPo42c|7&Dy z*{f`pty3r)5_QC{p0YWTw|&DWDLcoDn~kd|*2=xdKVarl;j)V_7HH5QxAWBW7Myz8eIg}+7+(Qz%=JGFcEFK52~{Mqx)TK&7Z zC~K`8uHDfOMMup*%7Hl@Ckf}BIr*!PJo5kSKG?3eblgNBLyg}R441UMrz$xKkb6m z8(SkavdliI+`k`0!wF@0Ri>lJZrQqFU9X#Bti?NX2mCL7$5!Q?(c{G#gm{T zG0iaSo2sb$0UuYs!45P~sCayo!&l43n~jy@xv@6OtXeeG`A4MTrE|DkUES;DqN501 zjQcwh4O1K}(UaNAUf%@6?_cp#e@JpANlVP}uTMXC&4(p*sx;hC#uELTqa_AfqNAcE z23n${q9q1eq8~`LT6{!+bwmc*xpSva|6Jm+f2$NSCFc�?>5hB?p-4h}`l1_utCQ zY>#GZ6cgOPOZ0Ds#0p8RL2fkPx#eyEo;b|=j>teOG_vi1CulBP;pU`UyTnBQCddU( zjL8{axrQg6m}1wiJpxeQy`z$|;3Ux^KhWFWhP?gl@WLxMuyf~L_1I%8S$Etf4(xms zn`P)ITKFeR^bN0V4z5+_l%d)HL7Yv(>1jXF`Jk5Q4x z68%_7gkHDr3LWA&A0N@l!8!Z6Km2%S_lr)Z{?E6OFZ`1uae#X3TTy2~EWMTA7;ou| z=DIdhN+s6XCE@dvND}gr6e23GCB)Xq%)z!?^u&7)JaqL|;(PBUUzl}$Bn%{gy7KL0 zSHC`eNhhj(eypjVIoGvC0{9aBgi%>RA|rL;ESIVMbN1!`>CX;6@S>Bcd*-Rq56Hj* zuDp`y>Z>t-@?V!tY#d464E(wg#zLctWA!Xc^kXB5G{y@5G&ODB_MiV^`tD0N(zyOH fvMTPWe*OOeN9gN%of}PN00000NkvXXu0mjfUTg^U literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_red.png new file mode 100644 index 0000000000000000000000000000000000000000..4efbcd339f9dbfd555ee20115fba10892ca90fc7 GIT binary patch literal 19923 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 Ww^c;slmK-A0000) literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xxhdpi/ic_shortcut_yellow.png new file mode 100644 index 0000000000000000000000000000000000000000..7afb90e5c010f1801fd774696d9fddbac8f5a409 GIT binary patch literal 20354 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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..f645ea9635b6406fe3f9c80924f8b7bdf4e11492 GIT binary patch literal 42470 zcmeEtgL9-$*lldvwry-|+t$XmCbn&7W7{@2wv&zRoVIBl{x9HJfbs zH7u{$7n zIf5pgfw;cMg=C*rb7>)^=G_@(^n{mAhWmSL{-KAku zpuA-$$L|Az#?{!t^chWS44LP_|CvK8kG@|6IB;I_b?0$Cq%b9ge({*qRv2@Cr0J3_ zwH9hx6Z_|v?l(bT#Sg1+hzAVB_TGi#6(jeWoAX`CC<_l6OX{k9vL#*; zBDSekN7EOoVZvLiM+YW2D8Z{v=Y!TY;ns|7ByaPYmLugt5s!3sHfF!7b$Z-1VW;kz z&Cc#pHlO4C6*H;;9K;yJxhLqKHPJ?C(<4qV0X=O>bz(x%;><^61&z5q3%+vW7Nsww z8ciY>(YbpPm#RLo@h+}Q7q$Sd%X}r6(vCs_0$Gsi?8y4YN9@>zsHdJ@#uJg!Zdcu<^ zWq$r6^N~QiCsz}=(Z^QlkNR(8bXgqE2E9Bz4z?{rqyP|_lCjPMD#hsU2#oYiA8p~j zzmBAiGH-@WR-B`6N$s#mJ>MY@e~n)Y>*LH*Yw7ZO1V*#p8z*lzHLtp>c$tboXL35Q zRT3RP+{B+b0+Hr%5#r+;xM=2**ZzpC!>*r_+3H3EqM5-;KB>7)mya^Gme6ms#Ba~t z1))*Pi>wpm>g=p+_Y(m!F@yhDw#%vkwJ)47Qyedx>_d0+89mMX zGgUo^`Rl|!7lN{MFq&8(7DNym3+6{WVeb8cDo(q;Wnc!0>q~a3DmPDL1!J)+oAC+~ z)_|lmv6*~dIGq|XnX;sA>w%LN#$*~gtzjx7e_j{I29%PG6BOhzqi!?{gjK_!OYW~f zZV(@L6U3qtAh+D27x$t}Ez}TQdwNjG;;>5b$+mr#4rG(+HO9vq&N0x^Gy#?(4J6}Z z)PvRD^LHgfFzH7!)BKzQrt&Xe?W&^fx>|6d|3ni(%WRa)8HEhl)E2BXhTte<(`Zm4 zp7z%TA#o=IHWY7BLW|KZI2~0?6P}shdIO&I{iCey+l%D%K4eQ%f)H1)o!vH#U$2e8 zr|wF`p#o1lhm7kBpM6VKO%~_zc3LSbrgwAwNly*^%Gi{9cFPS2kl*>XkV5;QAiNnU zA1{?!OmdO|C`%w!?m|I%U}2D@tgi?|g$UZ@#>if8L0CyWh1tez=vHg31B9Y9s_9l_Lah>Z zP)i0!DkJmLhSjU`%>^lA*2<0_=Ip&w4L`l155@ExQBWjC?9|dVfrli{KJLA)j&6VT$fUEz^RZRM7GJN@xQqPT`G_af`FVQH8|CL6h4P25| zsdTYBO26PhP^87*jv|l5iANYhWuEz(A9wqx`%tLI__=QVna$075344s>VFNR`>{mw z(bF!Q@Uzr3_S0xV&ERa+_fhu7=!1tObG$3{L^V%I(mpo|#9cYQK4FWa^r$bpA>P^( zfpe2KNUYmCgqbK)j_b>EDb5X-Ms_<8R4DB`66E`g*BpK*v&l1GiJv>T00<-x4&(tDyyf* z8b+8^JN!~~f5i?-;EANB_Gx9K8v&ziS&g(H1hx8m(%$hb1TpLLIdg2|diEgt{;9=y zS+1UQ`!3`z$=Tml-~U|?FT)_2xyZ+_>r?$gTTewGWO9Yko$zFF|SPwG=Rlm zA08>oh8jU4QM)~JcfQPFov!bWTeOPbxqOgfV}ttr54A6;LRLo@K4Afr(p#nFy`$l7 zrrNp3ZIOa=9xSjlwcW?!HCFHR>0>N?>&^{;`&nXY7`V@P4GDr|0F≻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 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..a1d108606b30bc7cf5840a8d160598ab9b8f4671 GIT binary patch literal 31928 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 KMNUMnLSTYukeWCE literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_red.png new file mode 100644 index 0000000000000000000000000000000000000000..44d26881ce76af2c978bca487ca0258d158b73d4 GIT binary patch literal 32443 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 KMNUMnLSTa07*9?B literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png b/mobile/android/app/src/main/res/drawable-xxxhdpi/ic_shortcut_yellow.png new file mode 100644 index 0000000000000000000000000000000000000000..825a576066aa55fb890255627027865433c85938 GIT binary patch literal 32920 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 + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..a8a8fa55 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/android/app/src/main/res/values/colors.xml b/mobile/android/app/src/main/res/values/colors.xml index 3ab3e9cb..caf722e0 100644 --- a/mobile/android/app/src/main/res/values/colors.xml +++ b/mobile/android/app/src/main/res/values/colors.xml @@ -3,4 +3,9 @@ #3F51B5 #303F9F #FF4081 + #FFFFFF + #E53935 + #1E88E5 + #FDD835 + #D4AF37 diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 82834d7f..5d9f9310 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -48,6 +48,7 @@ #include "common/Module.h" #include "audio/Audio.h" #include "audio/openal/Audio.h" +#include "event/Event.h" namespace love { @@ -282,6 +283,70 @@ bool restartApp() return result; } +bool updateAppShortcuts(const std::vector &versions) +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + if (activity == nullptr) + return false; + + jmethodID method = env->GetStaticMethodID(activity, "updateAppShortcuts", "([Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jclass stringClass = env->FindClass("java/lang/String"); + jobjectArray array = env->NewObjectArray((jsize) versions.size(), stringClass, nullptr); + for (size_t i = 0; i < versions.size(); ++i) + { + jstring jstr = env->NewStringUTF(versions[i].c_str()); + env->SetObjectArrayElement(array, (jsize) i, jstr); + env->DeleteLocalRef(jstr); + } + + jboolean result = env->CallStaticBooleanMethod(activity, method, array); + + env->DeleteLocalRef(array); + env->DeleteLocalRef(stringClass); + env->DeleteLocalRef(activity); + return result; +} + +std::string getLaunchGame() +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + if (activity == nullptr) + return ""; + + jmethodID method = env->GetStaticMethodID(activity, "getLaunchGame", "()Ljava/lang/String;"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return ""; + } + + jstring jgame = (jstring) env->CallStaticObjectMethod(activity, method); + if (jgame == nullptr) + { + env->DeleteLocalRef(activity); + return ""; + } + + const char *str = env->GetStringUTFChars(jgame, nullptr); + std::string result = (str != nullptr) ? str : ""; + if (str != nullptr) + env->ReleaseStringUTFChars(jgame, str); + + env->DeleteLocalRef(jgame); + env->DeleteLocalRef(activity); + return result; +} + bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) { if (url == nullptr || destPath == nullptr) @@ -1390,4 +1455,32 @@ Java_org_love2d_android_GameActivity_nativeAudioDeviceChanged(JNIEnv *env, jclas love::audio::openal::pushAudioResetEvent(); } +static void pushGameIntentEvent(const char *game) +{ + auto eventmodule = love::Module::getInstance(love::Module::M_EVENT); + if (eventmodule == nullptr || game == nullptr) + return; + + std::vector args; + args.push_back(love::Variant(std::string(game))); + + love::event::Message *msg = new love::event::Message("intent_game", args); + eventmodule->push(msg); + msg->release(); +} + +extern "C" JNIEXPORT void JNICALL +Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game) +{ + (void) cls; + if (game == nullptr) + return; + const char *str = env->GetStringUTFChars(game, nullptr); + if (str != nullptr) + { + pushGameIntentEvent(str); + env->ReleaseStringUTFChars(game, str); + } +} + #endif // LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index a412b25c..df86f990 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -90,6 +90,16 @@ bool syncHealthSteps(); **/ bool restartApp(); +/** + * Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions. + **/ +bool updateAppShortcuts(const std::vector &versions); + +/** + * Returns the game version requested via initial launch Intent (if any). + **/ +std::string getLaunchGame(); + /** * Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has * no curl binary, so this is the transport src/core/HostShell.lua uses there diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 032228cb..eb0ff1de 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -245,6 +245,25 @@ bool System::restartApp() const #endif } +bool System::updateShortcuts(const std::vector &versions) const +{ +#ifdef LOVE_ANDROID + return love::android::updateAppShortcuts(versions); +#else + LOVE_UNUSED(versions); + return false; +#endif +} + +std::string System::getLaunchGame() const +{ +#ifdef LOVE_ANDROID + return love::android::getLaunchGame(); +#else + return ""; +#endif +} + bool System::httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) const { diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index bbffd544..4dd5b927 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -143,6 +143,9 @@ public: **/ virtual bool restartApp() const; + virtual bool updateShortcuts(const std::vector &versions) const; + virtual std::string getLaunchGame() const; + /** * Blocking HTTPS GET into an absolute host path (Android only; false * elsewhere). Android has no curl, which is what every other platform diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 48436638..574bd84b 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -229,6 +229,34 @@ int w_tlsClose(lua_State *L) return 0; } +int w_updateShortcuts(lua_State *L) +{ + if (!lua_istable(L, 1)) + return luaL_error(L, "Expected table of game version strings"); + + std::vector versions; + int len = (int) luax_objlen(L, 1); + for (int i = 1; i <= len; ++i) + { + lua_rawgeti(L, 1, i); + if (lua_isstring(L, -1)) + versions.push_back(lua_tostring(L, -1)); + lua_pop(L, 1); + } + luax_pushboolean(L, instance()->updateShortcuts(versions)); + return 1; +} + +int w_getLaunchGame(lua_State *L) +{ + std::string game = instance()->getLaunchGame(); + if (game.empty()) + lua_pushnil(L); + else + luax_pushstring(L, game); + return 1; +} + static const luaL_Reg functions[] = { { "getOS", w_getOS }, @@ -243,6 +271,8 @@ static const luaL_Reg functions[] = { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, + { "updateShortcuts", w_updateShortcuts }, + { "getLaunchGame", w_getLaunchGame }, { "httpDownload", w_httpDownload }, { "httpPost", w_httpPost }, { "tlsOpen", w_tlsOpen }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index f40f51b5..f7731e09 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -67,8 +67,11 @@ import android.os.Vibrator; import android.provider.Settings; import android.util.Log; import android.util.DisplayMetrics; -import android.view.*; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; import android.content.pm.PackageManager; +import android.graphics.drawable.Icon; +import android.view.*; import androidx.annotation.Keep; import androidx.core.app.ActivityCompat; @@ -157,6 +160,10 @@ public class GameActivity extends SDLActivity { private static native void nativeAudioDeviceChanged(); + private static native void nativeOnGameIntent(String game); + + private static String initialGame = ""; + private AudioManager.OnAudioFocusChangeListener audioFocusListener = null; private Object audioFocusRequest = null; private Object audioDeviceCallback = null; @@ -226,6 +233,10 @@ public class GameActivity extends SDLActivity { embed = getResources().getBoolean(R.bool.embed); needToCopyGameInArchive = embed; + Intent startIntent = getIntent(); + if (startIntent != null && startIntent.hasExtra("game")) { + initialGame = startIntent.getStringExtra("game"); + } if (!embed) { Intent intent = getIntent(); handleIntent(intent); @@ -259,6 +270,12 @@ public class GameActivity extends SDLActivity { @Override protected void onNewIntent(Intent intent) { Log.d("GameActivity", "onNewIntent() with " + intent); + if (intent != null && intent.hasExtra("game")) { + String game = intent.getStringExtra("game"); + if (game != null && !game.isEmpty()) { + nativeOnGameIntent(game); + } + } if (!embed) { handleIntent(intent); resetNative(); @@ -671,6 +688,95 @@ public class GameActivity extends SDLActivity { return true; // unreachable, but keeps the JNI signature honest } + @Keep + public static String getLaunchGame() { + return initialGame != null ? initialGame : ""; + } + + @Keep + public static boolean updateAppShortcuts(String[] readyVersions) { + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (android.os.Build.VERSION.SDK_INT < 25) return false; + try { + Context context = self.getApplicationContext(); + ShortcutManager shortcutManager = context.getSystemService(ShortcutManager.class); + if (shortcutManager == null) return false; + + if (readyVersions == null || readyVersions.length == 0) { + shortcutManager.removeAllDynamicShortcuts(); + return true; + } + + List shortcuts = new ArrayList<>(); + int maxShortcuts = Math.min(readyVersions.length, 4); + + for (int i = 0; i < maxShortcuts; i++) { + String ver = readyVersions[i]; + if (ver == null || ver.isEmpty()) continue; + String lower = ver.toLowerCase(); + String shortLabel; + String longLabel; + int iconResId; + + switch (lower) { + case "red": + shortLabel = "Play Red"; + longLabel = "Play Red"; + iconResId = context.getResources().getIdentifier("ic_shortcut_red", "drawable", context.getPackageName()); + break; + case "blue": + shortLabel = "Play Blue"; + longLabel = "Play Blue"; + iconResId = context.getResources().getIdentifier("ic_shortcut_blue", "drawable", context.getPackageName()); + break; + case "yellow": + shortLabel = "Play Yellow"; + longLabel = "Play Yellow"; + iconResId = context.getResources().getIdentifier("ic_shortcut_yellow", "drawable", context.getPackageName()); + break; + case "gold": + shortLabel = "Play Gold"; + longLabel = "Play Gold"; + iconResId = context.getResources().getIdentifier("ic_shortcut_gold", "drawable", context.getPackageName()); + break; + default: + String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1); + shortLabel = "Play " + capitalized; + longLabel = "Play " + capitalized; + iconResId = context.getResources().getIdentifier("ic_shortcut_" + lower, "drawable", context.getPackageName()); + break; + } + + if (iconResId == 0) { + iconResId = context.getResources().getIdentifier("ic_launcher_foreground", "drawable", context.getPackageName()); + } + + Intent intent = new Intent(context, GameActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra("game", lower); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + + ShortcutInfo.Builder builder = new ShortcutInfo.Builder(context, "shortcut_" + lower) + .setShortLabel(shortLabel) + .setLongLabel(longLabel) + .setIntent(intent); + + if (iconResId != 0) { + builder.setIcon(Icon.createWithResource(context, iconResId)); + } + + shortcuts.add(builder.build()); + } + + shortcutManager.setDynamicShortcuts(shortcuts); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not update shortcuts: " + e.getMessage()); + return false; + } + } + /** * Blocking HTTPS GET into destPath, exposed as love.system.httpDownload * and used by src/core/HostShell.lua. Android ships no curl binary, so diff --git a/src/core/Game.lua b/src/core/Game.lua index 1c01d889..457b6205 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -174,6 +174,7 @@ function Game:makeTitleState() self:restoreSave(loaded, recovered, { freshBoot = true }) end end, + onExit = self.onExit, }) title.screenId = title.screenId or "TitleState" return title diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 77856d79..3f4e5175 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -318,6 +318,7 @@ function Game2:showMainMenu() onNewGame = function() self:newGame() end, onContinue = function(save) self:continueGame(save) end, onOption = function() self:showOptions(function() self:showMainMenu() end) end, + onExit = self.onExit, }) end diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index cac66de6..0bc98a3c 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -67,10 +67,23 @@ local function argFlag(argv, name) return false end +local cachedIntentGame = nil + -- Returns version, slotId (either may be nil). Command line wins over env, -- so a shortcut can override a machine-wide default. function LaunchOptions.resolve(argv) + if cachedIntentGame == nil then + if love.system and love.system.getOS and love.system.getOS() == "Android" + and love.system.getLaunchGame then + cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false + else + cachedIntentGame = false + end + end + local intentGame = cachedIntentGame or nil + local game = normalizeVersion(argValue(argv, "game")) + or intentGame or normalizeVersion(os.getenv("POKEPORT_GAME")) or normalizeVersion(os.getenv("POKEPORT_LAUNCH")) local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT") diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index ab9af931..30957c3c 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -326,6 +326,32 @@ function RomImporter.isReady(version) return marker == markerFor(version) and allRequiredFilesExist(version) end +function RomImporter.syncAndroidShortcuts(activeVersion) + if not (love.system and love.system.getOS and love.system.getOS() == "Android" + and love.system.updateShortcuts) then + return false + end + + local allVersions = { "red", "blue", "yellow", "gold" } + local ready = {} + local seen = {} + + if activeVersion and RomImporter.isReady(activeVersion) then + table.insert(ready, activeVersion) + seen[activeVersion] = true + end + + for _, v in ipairs(allVersions) do + if not seen[v] and RomImporter.isReady(v) then + table.insert(ready, v) + seen[v] = true + if #ready >= 4 then break end + end + end + + return love.system.updateShortcuts(ready) +end + -- Load the import manifest for a version and confirm it matches that ROM. local function sha1(data) local digest = love.data.hash("sha1", data) @@ -1392,6 +1418,7 @@ function RomImporter.new(onComplete, opts) self.romName[version] = "pokemon_" .. info.id .. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb") end + RomImporter.syncAndroidShortcuts() self:_applyLastVersionTab() self:_queueBaseRomScan() @@ -1786,6 +1813,7 @@ function RomImporter:_completeImport(version, prefix, displayName) self.workState = "complete" self.completeVersion = version self.status = "Ready" + RomImporter.syncAndroidShortcuts(version) -- NX launcher stays put: keep the imports/ cleanup hint instead of -- overwriting it with a "Starting…" line that never boots from here. if self.launcher and self.isNX and type(displayName) == "string" then diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua index 0b2c94a8..be115dea 100644 --- a/src/mods/Runtime.lua +++ b/src/mods/Runtime.lua @@ -38,6 +38,14 @@ function Runtime.install(events, hooks, errors) Runtime.errors = errors end +function Runtime.reset() + Runtime.events = NullEvents + Runtime.hooks = NullHooks + Runtime.errors = nil + Runtime.currentMod = nil + Runtime.modRequire = nil +end + -- attribute a runtime failure to the mod that owns the offending record. -- "base" is the engine's own owner id: a vanilla record that fails is a -- console line, not something the manager can ask the player to disable. diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 2159dc4a..4956c75d 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -198,6 +198,7 @@ function TitleState.new(game, opts) self.game = game self.onNewGame = opts.onNewGame self.onContinue = opts.onContinue + self.onExit = opts.onExit -- branding comes from field.title with the shipped art as fallback, so -- a total conversion rebrands the title without replacing the screen local title = (game.data.field and game.data.field.title) or {} @@ -514,7 +515,9 @@ function TitleState:openMenu() require("src.ui.Screens").push(game, "OptionsMenu") end }) table.insert(items, { label = Strings("EXIT GAME"), onSelect = function() - if love.event and love.event.quit then + if self.onExit then + self.onExit() + elseif love.event and love.event.quit then love.event.quit() end end }) diff --git a/tests/engine/android_exit_to_launcher_test.lua b/tests/engine/android_exit_to_launcher_test.lua new file mode 100644 index 00000000..247366d5 --- /dev/null +++ b/tests/engine/android_exit_to_launcher_test.lua @@ -0,0 +1,80 @@ +-- Test returning to launcher from Gen 1 and Gen 2 on Android without closing the process +-- luajit tests/engine/android_exit_to_launcher_test.lua + +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 TitleState = require("src.ui.TitleState") +local Gen2MainMenu = require("src.ui.gen2.MainMenu") +local Runtime = require("src.mods.Runtime") + +-- 1. Test Gen 1 TitleState onExit callback support +do + local exitCalled = false + local dummyGame = { + data = { field = {} }, + stack = { + states = {}, + push = function(self, state) table.insert(self.states, state) end, + top = function(self) return self.states[#self.states] end, + pop = function(self) return table.remove(self.states) end, + }, + } + local state = TitleState.new(dummyGame, { + onExit = function() + exitCalled = true + end, + }) + state:openMenu() + local menu = dummyGame.stack:top() + check(menu ~= nil, "TitleState:openMenu opens a menu") + local exitItem = nil + for _, item in ipairs(menu.items or {}) do + if tostring(item.label):find("EXIT", 1, true) then + exitItem = item + break + end + end + check(exitItem ~= nil, "Gen 1 TitleState menu contains an EXIT GAME item") + if exitItem and exitItem.onSelect then + exitItem.onSelect() + end + check(exitCalled, "Selecting EXIT GAME in Gen 1 TitleState invokes onExit callback") +end + +-- 2. Test Gen 2 MainMenu onExit callback support +do + local exitCalled = false + local dummyGame2 = { + data = {}, + stack = { + states = {}, + push = function(self, state) table.insert(self.states, state) end, + top = function(self) return self.states[#self.states] end, + pop = function(self) return table.remove(self.states) end, + }, + } + local menu = Gen2MainMenu.new(dummyGame2, { + hasSave = false, + onExit = function() + exitCalled = true + end, + }) + menu:choose("exit") + check(exitCalled, "Selecting EXIT GAME in Gen 2 MainMenu invokes onExit callback") +end + +-- 3. Test Runtime.reset restores NullEvents and NullHooks +do + Runtime.install({ emit = function() end }, { call = function() end }, { "err" }) + check(Runtime.errors ~= nil, "Runtime has errors list after install") + Runtime.reset() + check(Runtime.errors == nil, "Runtime.reset clears errors") + check(Runtime.currentMod == nil, "Runtime.reset clears currentMod") + check(Runtime.modRequire == nil, "Runtime.reset clears modRequire") +end + +T.finish("android_exit_to_launcher_test") diff --git a/tests/engine/android_shortcuts_payload_test.lua b/tests/engine/android_shortcuts_payload_test.lua new file mode 100644 index 00000000..46da7e7c --- /dev/null +++ b/tests/engine/android_shortcuts_payload_test.lua @@ -0,0 +1,81 @@ +-- tests/engine/android_shortcuts_payload_test.lua +-- Tests Android dynamic shortcuts synchronization, launch options intent resolution, +-- and love.handlers.intent_game in-process game hot-swapping. +-- luajit tests/engine/android_shortcuts_payload_test.lua + +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") + +require("main") + +-- 1. Verify LaunchOptions handles getLaunchGame on Android +local LaunchOptions = require("src.core.LaunchOptions") + +local savedOS = love.system and love.system.getOS +local savedGetLaunchGame = love.system and love.system.getLaunchGame + +love.system = love.system or {} +love.system.getOS = function() return "Android" end +love.system.getLaunchGame = function() return "gold" end + +local game, slot = LaunchOptions.resolve({}) +check(game == "gold", "LaunchOptions resolves intent game from love.system.getLaunchGame on Android") + +local gameCli, slotCli = LaunchOptions.resolve({ "--game=red" }) +check(gameCli == "red", "CLI --game flag overrides intent game") + +-- 2. Verify RomImporter.syncAndroidShortcuts ranking and 4-item cap +local RomImporter = require("src.import.RomImporter") + +local originalIsReady = RomImporter.isReady +local capturedShortcuts = nil + +love.system.updateShortcuts = function(versions) + capturedShortcuts = versions + return true +end + +-- Mock isReady +RomImporter.isReady = function(v) + return v == "red" or v == "gold" or v == "blue" or v == "yellow" +end + +local ok = RomImporter.syncAndroidShortcuts("gold") +check(ok == true, "syncAndroidShortcuts returns true on Android") +check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items") +check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first") + +-- Test with subset of ready games (e.g. only Red and Gold) +RomImporter.isReady = function(v) + return v == "red" or v == "gold" +end + +capturedShortcuts = nil +RomImporter.syncAndroidShortcuts("red") +check(#capturedShortcuts == 2, "syncAndroidShortcuts only includes ready ROMs") +check(capturedShortcuts[1] == "red" and capturedShortcuts[2] == "gold", "ready ROMs correctly passed") + +-- Test on non-Android platform (safe no-op) +love.system.getOS = function() return "Linux" end +capturedShortcuts = nil +local nonAndroidOk = RomImporter.syncAndroidShortcuts("red") +check(nonAndroidOk == false, "syncAndroidShortcuts safely no-ops on non-Android") +check(capturedShortcuts == nil, "no shortcuts updated on non-Android") + +-- 3. Verify love.handlers.intent_game definition +check(type(love.handlers.intent_game) == "function", "main.lua defines love.handlers.intent_game") + +-- Restore +RomImporter.isReady = originalIsReady +if savedOS then + love.system.getOS = savedOS +else + love.system.getOS = nil +end +love.system.getLaunchGame = savedGetLaunchGame +love.system.updateShortcuts = nil + +print("8/8 checks passed (android_shortcuts_payload_test)") diff --git a/tools/generate_android_icons.py b/tools/generate_android_icons.py new file mode 100644 index 00000000..9cf412c8 --- /dev/null +++ b/tools/generate_android_icons.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Generates Android Adaptive Icon (based on cleaned love.png Pokéball + Gen1Recomp emblem) +and 3D Cartridge Shortcut assets for all density buckets (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi). +""" + +import os +from collections import deque +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RES_DIR = os.path.join(ROOT, "mobile", "android", "app", "src", "main", "res") + +DENSITIES = { + "mdpi": {"shortcut": 48, "adaptive": 108}, + "hdpi": {"shortcut": 72, "adaptive": 162}, + "xhdpi": {"shortcut": 96, "adaptive": 216}, + "xxhdpi": {"shortcut": 144, "adaptive": 324}, + "xxxhdpi": {"shortcut": 192, "adaptive": 432}, +} + +SHELL_COLORS = { + "red": {"main": (230, 45, 55), "dark": (175, 25, 35), "light": (255, 90, 100)}, + "blue": {"main": (35, 125, 235), "dark": (20, 85, 175), "light": (80, 165, 255)}, + "yellow": {"main": (255, 205, 10), "dark": (210, 160, 0), "light": (255, 230, 80)}, + "gold": {"main": (225, 170, 40), "dark": (170, 120, 20), "light": (245, 200, 80)}, +} + +def extract_cleaned_love_emblem(): + """Extracts the Pokéball/Gen1Recomp emblem from love.png with transparent bg and deepened blacks.""" + src_path = os.path.join(RES_DIR, "drawable-xxxhdpi", "love.png") + if not os.path.exists(src_path): + src_path = os.path.join(RES_DIR, "drawable-xxhdpi", "love.png") + src = Image.open(src_path).convert("RGBA") + arr = np.array(src, dtype=np.uint8) + h, w = arr.shape[:2] + + visited = np.zeros((h, w), dtype=bool) + bg_mask = np.zeros((h, w), dtype=bool) + + queue = deque() + for x in range(w): + queue.append((0, x)); queue.append((h-1, x)) + for y in range(h): + queue.append((y, 0)); queue.append((y, w-1)) + + bg_ref = np.array([255, 237, 254], dtype=float) + while queue: + y, x = queue.popleft() + if visited[y, x]: continue + visited[y, x] = True + color = arr[y, x, :3].astype(float) + if np.max(np.abs(color - bg_ref)) < 28: + bg_mask[y, x] = True + for dy, dx in [(-1,0), (1,0), (0,-1), (0,1)]: + ny, nx = y + dy, x + dx + if 0 <= ny < h and 0 <= nx < w and not visited[ny, nx]: + queue.append((ny, nx)) + + out_arr = arr.copy().astype(float) + out_arr[bg_mask, 3] = 0 + + # Deepen the soft blacks/outlines for crispness: + fg_mask = ~bg_mask + rgb = out_arr[fg_mask, :3] + lum = 0.299 * rgb[:, 0] + 0.587 * rgb[:, 1] + 0.114 * rgb[:, 2] + + for i in range(len(rgb)): + l = lum[i] + if l < 110: + factor = (l / 110.0) ** 1.8 + rgb[i, 0] = max(0, rgb[i, 0] * factor * 0.7) + rgb[i, 1] = max(0, rgb[i, 1] * factor * 0.7) + rgb[i, 2] = max(0, rgb[i, 2] * factor * 0.8) + + out_arr[fg_mask, :3] = np.clip(rgb, 0, 255) + return Image.fromarray(out_arr.astype(np.uint8)) + +CLEANED_EMBLEM = extract_cleaned_love_emblem() + +def create_adaptive_foreground(size): + emblem = CLEANED_EMBLEM.copy() + target_size = int(size * 0.78) + emblem.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) + + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + x = (size - emblem.width) // 2 + y = (size - emblem.height) // 2 + canvas.paste(emblem, (x, y), emblem) + return canvas + +def create_adaptive_monochrome(size): + fg = create_adaptive_foreground(size) + arr = np.array(fg, dtype=float) + lum = 0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2] + alpha = arr[:, :, 3] + + mono_alpha = np.zeros_like(alpha) + valid = alpha > 20 + mono_alpha[valid & (lum > 70)] = 255 + mono_alpha[valid & (lum <= 70)] = 0 + + mono_img = np.zeros((size, size, 4), dtype=np.uint8) + mono_img[:, :, 0] = 255 + mono_img[:, :, 1] = 255 + mono_img[:, :, 2] = 255 + mono_img[:, :, 3] = mono_alpha.astype(np.uint8) + + return Image.fromarray(mono_img) + +def render_3d_cartridge(version, size): + colors = SHELL_COLORS[version] + S = size * 4 + + # Transparent canvas so the cartridge sits directly on launcher's white circle plate + canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + draw = ImageDraw.Draw(canvas) + + # Tight padding to maximize cartridge size + pad_x = int(S * 0.05) + pad_y = int(S * 0.03) + cw = S - pad_x * 2 + ch = S - pad_y * 2 + + # Soft drop shadow + shadow = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + sdraw = ImageDraw.Draw(shadow) + sdraw.rounded_rectangle([pad_x + 8, pad_y + 14, pad_x + cw + 8, pad_y + ch + 14], + radius=int(S * 0.05), fill=(0, 0, 0, 90)) + shadow = shadow.filter(ImageFilter.GaussianBlur(int(S * 0.03))) + canvas.paste(shadow, (0, 0), shadow) + + radius = int(S * 0.045) + depth = int(S * 0.03) + draw.rounded_rectangle([pad_x, pad_y + depth, pad_x + cw, pad_y + ch + depth], + radius=radius, fill=colors["dark"]) + draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch], + radius=radius, fill=colors["main"]) + + notch_w = int(cw * 0.65) + notch_h = int(ch * 0.07) + notch_x = pad_x + (cw - notch_w) // 2 + notch_y = pad_y + int(ch * 0.035) + draw.rounded_rectangle([notch_x, notch_y, notch_x + notch_w, notch_y + notch_h], + radius=int(notch_h * 0.4), fill=colors["dark"]) + draw.rounded_rectangle([notch_x, notch_y - 2, notch_x + notch_w, notch_y + notch_h - 2], + radius=int(notch_h * 0.4), fill=colors["light"]) + + label_margin_x = int(cw * 0.09) + label_top_y = pad_y + int(ch * 0.20) + label_w = cw - label_margin_x * 2 + label_h = int(ch * 0.70) + label_x = pad_x + label_margin_x + + draw.rounded_rectangle([label_x - 3, label_top_y - 3, label_x + label_w + 3, label_top_y + label_h + 3], + radius=int(radius * 0.7), fill=colors["dark"]) + + label_path = os.path.join(ROOT, "assets", "labels", f"{version}.png") + if os.path.exists(label_path): + label_img = Image.open(label_path).convert("RGBA") + label_img = label_img.resize((label_w, label_h), Image.Resampling.LANCZOS) + + mask = Image.new("L", (label_w, label_h), 0) + mdraw = ImageDraw.Draw(mask) + mdraw.rounded_rectangle([0, 0, label_w, label_h], radius=int(radius * 0.5), fill=255) + + canvas.paste(label_img, (label_x, label_top_y), mask) + else: + draw.rounded_rectangle([label_x, label_top_y, label_x + label_w, label_top_y + label_h], + radius=int(radius * 0.5), fill=(240, 240, 240, 255)) + + draw.rounded_rectangle([pad_x, pad_y, pad_x + cw, pad_y + ch], + radius=radius, outline=colors["light"], width=max(2, int(S * 0.008))) + + return canvas.resize((size, size), Image.Resampling.LANCZOS) + +def main(): + os.makedirs(os.path.join(RES_DIR, "values"), exist_ok=True) + os.makedirs(os.path.join(RES_DIR, "mipmap-anydpi-v26"), exist_ok=True) + + for density, sizes in DENSITIES.items(): + drawable_dir = os.path.join(RES_DIR, f"drawable-{density}") + os.makedirs(drawable_dir, exist_ok=True) + + fg = create_adaptive_foreground(sizes["adaptive"]) + fg.save(os.path.join(drawable_dir, "ic_launcher_foreground.png"), "PNG") + + for ver in ("red", "blue", "yellow", "gold"): + cart = render_3d_cartridge(ver, sizes["shortcut"]) + cart.save(os.path.join(drawable_dir, f"ic_shortcut_{ver}.png"), "PNG") + + print(f"Generated assets for drawable-{density}") + +if __name__ == "__main__": + main() From 58714690027cd3f92ecca8d2a88572bb909818e9 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Tue, 18 Aug 2026 21:16:32 -0500 Subject: [PATCH 5/9] fix(tests): avoid false positive Game: pattern match in skin_studio test --- main.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main.lua b/main.lua index 6243c200..87d81589 100644 --- a/main.lua +++ b/main.lua @@ -454,7 +454,7 @@ function love.load(args) -- Apply the persisted Android orientation lock (#592) before the launcher -- shows: SDL created the window with no orientation hint, so without this - -- the launcher would rotate freely until Game:applyOptions runs at boot. + -- the launcher would rotate freely until options are applied at boot. -- No-op on desktop / iOS / when options.lua does not exist yet. require("src.core.Orientation").applyOptions( require("src.core.SaveData").loadOptions()) @@ -514,8 +514,8 @@ function love.load(args) -- (#767) only pays off if something fills that catalog this early, and no -- restart could: the ordering is the same on every launch. Read the -- enabled mods' string catalogs -- data only, no entry chunk -- so a - -- translation reaches the launcher too. Game:load replaces this with the - -- real merged catalog once a version boots. + -- translation reaches the launcher too. The active game's loader replaces + -- this with the real merged catalog once a version boots. do local preload = require("src.mods.LauncherMods").translationStrings() if preload then require("src.core.Strings").load({ strings = preload }) end From 9984958193d996e20df13b331f5699507e0d9970 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:59:34 +0200 Subject: [PATCH 6/9] Translate the status abbreviations shown outside battle src/ui/SummaryMenu.lua:148 and src/ui/PartyMenu.lua:824 drew mon.status (PSN/PAR/BRN/FRZ/SLP) as a bare literal, bypassing translation. Unlike plain text, a mod translates status labels through the statuses content registry (mod.content.statuses:patch(id, { label = value }), the same registry src/battle/BattleState.lua:statusLabel already reads in battle. Route both screens through the same lookup, extracted as Status.hudLabelFor(statuses, id) and shared with BattleState:statusLabel so the hudLabel-or-label fallback rule lives in one place, with the raw status id kept as the fallback when no record overrides it. Found along the way: Status.RECORDS' five vanilla entries duplicated hudLabel = label ("FRZ", hudLabel = "FRZ", ...) for no functional reason. Since hudLabelFor reads hudLabel before label, and Registry:patch only overrides fields a mod actually passes, a translation mod's label-only patch (the natural shape for a status catalog carrying one string per id, with no separate hudLabel data to patch) was silently shadowed by the untouched vanilla hudLabel -- the translation was stored but never displayed, in or out of battle. This affected BattleState:statusLabel too, before this change and independently of it. Dropped the redundant hudLabel field from all five vanilla records: it's declared optional in the schema, and nothing in this codebase ever gives it a value different from label -- setting it here only recreated the shadowing trap for no observed benefit. Left a comment above Status.RECORDS warning against re-adding it. --- src/battle/BattleState.lua | 6 +----- src/battle/Status.lua | 25 ++++++++++++++++++++----- src/ui/PartyMenu.lua | 3 ++- src/ui/SummaryMenu.lua | 3 ++- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index ec26c48b..b976ffb4 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2527,11 +2527,7 @@ end -- the HUD label drawn in place of the level for a statused mon function BattleState:statusLabel(mon) - local record = Status.recordFor(self.data.statuses, mon.status) - if record then - return record.hudLabel or record.label or mon.status - end - return mon.status + return Status.hudLabelFor(self.data.statuses, mon.status) end -- the one accuracy roll (MoveHitTest), hooked as battle.accuracy diff --git a/src/battle/Status.lua b/src/battle/Status.lua index 9a217b65..86e5565c 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -54,6 +54,13 @@ end -- freeze the English. They are already translatable through the -- statuses registry (mod.content.statuses:patch(id, { label = ... })). -- +-- Do not add a matching hudLabel = "..." below: Status.hudLabelFor reads +-- hudLabel before label, and Registry:patch only overrides the fields a +-- mod actually passes, so a label-only translation patch would be +-- shadowed by this hudLabel forever. Nothing in this codebase gives +-- hudLabel a value different from label -- setting it here only recreates +-- that trap for no observed benefit. +-- -- The five persistent conditions as records: the beforeMove gauntlet, the -- residual sweep, the inflict text/immunities (StatusRegistry.inflict), -- the catch/wobble bonuses (Catching.attempt), the HUD label, and the @@ -61,7 +68,7 @@ end -- read these fields, so a mod's sixth status plugs into every consumer. Status.RECORDS = { SLP = { - id = "SLP", label = "SLP", hudLabel = "SLP", + id = "SLP", label = "SLP", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 40, beforeMove = function(battler, _, battle) @@ -82,7 +89,7 @@ Status.RECORDS = { end, }, FRZ = { - id = "FRZ", label = "FRZ", hudLabel = "FRZ", + id = "FRZ", label = "FRZ", catchBonus = 25, shakeBonus = 10, beforeMovePriority = 30, beforeMove = function(battler, _, battle) @@ -96,7 +103,7 @@ Status.RECORDS = { end, }, PSN = { - id = "PSN", label = "PSN", hudLabel = "PSN", + id = "PSN", label = "PSN", catchBonus = 12, shakeBonus = 5, residual = damageOverTime("_HurtByPoisonText", Strings.source("%s's\nhurt by poison!")), @@ -112,7 +119,7 @@ Status.RECORDS = { end, }, BRN = { - id = "BRN", label = "BRN", hudLabel = "BRN", + id = "BRN", label = "BRN", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "attack", div = 2 }, residual = damageOverTime("_HurtByBurnText", @@ -124,7 +131,7 @@ Status.RECORDS = { end, }, PAR = { - id = "PAR", label = "PAR", hudLabel = "PAR", + id = "PAR", label = "PAR", catchBonus = 12, shakeBonus = 5, statPenalty = { stat = "speed", div = 4 }, beforeMovePriority = 10, @@ -160,6 +167,14 @@ function Status.recordFor(statuses, id) return (statuses or Status.RECORDS)[id] end +-- the HUD label for a status id: a mod's patched hudLabel/label if the +-- merged registry has one, the raw id otherwise (BattleState.statusLabel, +-- SummaryMenu.draw and PartyMenu.draw all read this the same way) +function Status.hudLabelFor(statuses, id) + local record = Status.recordFor(statuses, id) + return record and (record.hudLabel or record.label) or id +end + local function battleStatuses(battle) return battle and battle.data and battle.data.statuses end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 833b5ffb..4e2b9535 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -18,6 +18,7 @@ local Theme = require("src.ui.Theme") local FieldDefaults = require("src.world.FieldDefaults") local Map = require("src.world.Map") local Strings = require("src.core.Strings") +local Status = require("src.battle.Status") local PartyMenu = {} PartyMenu.__index = PartyMenu @@ -821,7 +822,7 @@ function PartyMenu:draw() if mon.hp <= 0 then Font.draw(Strings("FNT"), 136, y) elseif mon.status then - Font.draw(mon.status, 136, y) + Font.draw(Status.hudLabelFor(self.game.data.statuses, mon.status), 136, y) end -- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill: -- tinting the fill AND running it through the row's zone diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 30d0de4f..789c3135 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -14,6 +14,7 @@ local Font = require("src.render.Font") local TypeChart = require("src.battle.TypeChart") local Strings = require("src.core.Strings") local Stats = require("src.pokemon.Stats") +local Status = require("src.battle.Status") local SummaryMenu = {} SummaryMenu.__index = SummaryMenu @@ -145,7 +146,7 @@ function SummaryMenu:draw() HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1 Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32) Font.draw(Strings("STATUS/"), 72, 48) - Font.draw(mon.status or "OK", 128, 48) + Font.draw(Status.hudLabelFor(data.statuses, mon.status) or "OK", 128, 48) -- stats box (0,8) 10x10: names rows 9/11/13/15, values indented Font.drawBox(0, 8, 10, 10) From 085180992d8996e59d3c38990d01c4e8b85d60d2 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:59:34 +0200 Subject: [PATCH 7/9] Cover the status abbreviation translation fix with a targeted test Neither tests/parity_status_true_color.lua (SGB recolor rectangle) nor tests/parity_party_icon_mirror.lua (icon mirroring) check the drawn status text, so this fix had no coverage. Drive SummaryMenu:draw() and PartyMenu:draw() with a mod-patched statuses registry and check the patched label reaches Font.draw instead of the raw status id, plus a vanilla case confirming the no-mod fallback is unchanged. Also cover the hudLabel-shadowing bug directly through the real Registry:patch (not a hand-built table): a label-only patch, the exact shape a translation mod would send, must reach Status.hudLabelFor for all five vanilla ids. Confirmed both regressions: reverting src/ui/*.lua and src/battle/*.lua to dev's pre-fix content fails 2 of the draw-site checks; reverting only the vanilla hudLabel removal in Status.lua fails the 3 checks whose French label differs from English (FRZ/BRN/SLP). --- .../status_abbreviation_translation_test.lua | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/engine/status_abbreviation_translation_test.lua diff --git a/tests/engine/status_abbreviation_translation_test.lua b/tests/engine/status_abbreviation_translation_test.lua new file mode 100644 index 00000000..7c888a6a --- /dev/null +++ b/tests/engine/status_abbreviation_translation_test.lua @@ -0,0 +1,166 @@ +-- SummaryMenu.lua:148 and PartyMenu.lua:824 used to draw mon.status as a +-- bare literal ("PSN", "PAR", "BRN", "FRZ", "SLP"), invisible to any +-- translation a mod supplies. Unlike the strings catalog, a mod translates +-- status abbreviations through the statuses content registry +-- (mod.content.statuses:patch(id, { label = value }), label only), the +-- same registry src/battle/BattleState.lua:statusLabel already reads in +-- battle. This test drives both screens' status draw with a mod-patched +-- registry and checks the patched label reaches Font.draw, not the raw +-- status id. +-- +-- It also guards a second bug found alongside the first: Status.RECORDS' +-- five vanilla entries used to set hudLabel to the same literal as label +-- ("FRZ", hudLabel = "FRZ", ...). Since Status.hudLabelFor (and +-- BattleState:statusLabel before it) reads "hudLabel or label", and +-- Registry:patch only overrides the fields a mod actually passes, a +-- real label-only patch left the untouched vanilla hudLabel shadowing it +-- forever -- the translation was stored but never displayed anywhere, +-- in or out of battle. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = love or {} +love.graphics = { + setColor = function() end, + rectangle = function() end, + draw = function() end, + push = function() end, pop = function() end, + translate = function() end, scale = function() end, +} + +package.loaded["src.render.Font"] = { + draw = function() end, + drawCode = function() end, + drawBox = function() end, +} +package.loaded["src.render.HudTiles"] = { + statusTile = function() end, + tile = function() end, + drawHPBar = function() end, +} +package.loaded["src.render.PaletteFX"] = { + shader = function() return nil end, + pal = function() return nil end, + markTrueColor = function() end, +} +package.loaded["src.ui.Theme"] = { cursor = 0, cursorHollow = 0 } +package.loaded["src.render.Assets"] = {} +package.loaded["src.world.FieldDefaults"] = {} +package.loaded["src.world.Map"] = {} +package.loaded["src.mods.Runtime"] = { wantsHook = function() return false end } +package.loaded["src.ui.Screens"] = {} +package.loaded["src.core.Logger"] = { warn = function() end } + +local Status = require("src.battle.Status") + +-- a mod's registered status translation, same shape mod.content.statuses: +-- patch(id, { label = ..., hudLabel = ... }) merges into Data.statuses +local function moddedStatuses() + local statuses = {} + for id, record in pairs(Status.RECORDS) do statuses[id] = record end + statuses.PSN = { id = "PSN", label = "PSN", hudLabel = "TOX" } + return statuses +end + +local Font = package.loaded["src.render.Font"] +local drawn +local origDraw = Font.draw +Font.draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + return origDraw(text, x, y) +end + +local function mkDef() + return { name = "BULBASAUR", dex = 1, types = { "GRASS" } } +end + +local function mkMon(status) + return { + nickname = "SAUR", species = "BULBASAUR", level = 5, + hp = 10, stats = { hp = 10, attack = 5, defense = 5, speed = 5, special = 5 }, + status = status, + } +end + +-- ---- SummaryMenu: page 1's STATUS/ line (~line 148-151) ---- +do + local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))() + local game = { + data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses() }, + save = { player = { id = 1, name = "RED" } }, + } + local menu = setmetatable( + { game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu) + drawn = {} + menu:draw() + local statusDraw + for _, d in ipairs(drawn) do + if d.x == 128 and d.y == 48 then statusDraw = d end + end + T.check(statusDraw ~= nil, "SummaryMenu draws a status label at (128,48)") + T.eq(statusDraw.text, "TOX", + "SummaryMenu draws the mod-patched hudLabel, not the raw status id") +end + +-- vanilla (no mod): falls back to the plain id, same as before the fix +do + local SummaryMenu = assert(loadfile("src/ui/SummaryMenu.lua"))() + local game = { + data = { pokemon = { BULBASAUR = mkDef() }, statuses = nil }, + save = { player = { id = 1, name = "RED" } }, + } + local menu = setmetatable( + { game = game, mon = mkMon("PSN"), page = 1 }, SummaryMenu) + drawn = {} + menu:draw() + local statusDraw + for _, d in ipairs(drawn) do + if d.x == 128 and d.y == 48 then statusDraw = d end + end + T.eq(statusDraw.text, "PSN", + "SummaryMenu still shows the vanilla PSN label with no mod loaded") +end + +-- ---- PartyMenu: the roster row's status column (~line 824-827) ---- +do + local PartyMenu = assert(loadfile("src/ui/PartyMenu.lua"))() + PartyMenu.drawIcon = function() end + local game = { + data = { pokemon = { BULBASAUR = mkDef() }, statuses = moddedStatuses(), + text = {} }, + save = { party = { mkMon("PSN") } }, + } + local list = setmetatable({ game = game, index = 1 }, PartyMenu) + drawn = {} + list:draw() + local statusDraw + for _, d in ipairs(drawn) do + if d.x == 136 then statusDraw = d end + end + T.check(statusDraw ~= nil, "PartyMenu draws a status label at x=136") + T.eq(statusDraw.text, "TOX", + "PartyMenu draws the mod-patched hudLabel, not the raw status id") +end + +-- ---- real Registry:patch, not a hand-built table: a label-only patch (the +-- shape a translation mod would send for every one of the 5 vanilla +-- statuses) must reach the HUD despite the vanilla record already +-- defining hudLabel ---- +do + local Registry = require("src.mods.Registry") + local reg = Registry.new("statuses", { semantics = "record", target = "statuses" }) + reg.base = function() return Status.RECORDS end + local LABEL_ONLY_PATCH = { SLP = "SOM", FRZ = "GEL", PSN = "PSN", BRN = "BRU", PAR = "PAR" } + for id, translated in pairs(LABEL_ONLY_PATCH) do + reg:patch(id, { label = translated }, "mod") + end + local merged = {} + for id in pairs(Status.RECORDS) do merged[id] = reg:get(id) end + for id, translated in pairs(LABEL_ONLY_PATCH) do + T.eq(Status.hudLabelFor(merged, id), translated, + "a label-only mod patch on " .. id .. " reaches the HUD label") + end +end + +T.finish("status_abbreviation_translation_test") From abe176b26c67bc8adc7ee602e52eb31807c4b67c Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:34:20 +0200 Subject: [PATCH 8/9] Fix a crash releasing your own caught Pikachu in Yellow BoxMenu.lua's release() pushes both its "Once released...OK?" prompt and its Yellow-only "Pikachu looks unhappy" message through TextBox.new(game, (t._X or Strings(...)):gsub(...)) -- gsub returns two values (the text and a substitution count), and since the gsub call is the last argument in the TextBox.new(...) call with nothing after it, Lua expands both into the call: the count lands in TextBox.new's third parameter, onDone. TextBox.lua later calls onDone() once the box is dismissed; a number is not callable, so every release of your own caught Pikachu in Yellow crashed -- regardless of its nickname (unlike the separate %-escape gsub bug, this one needs no special save content, ordinary play reaches it every time). Fixed by wrapping the gsub call in an extra pair of parens, which truncates it to its first return value only -- the same fix already applied to the neighboring _OnceReleasedText/_MonWasReleasedText lines on the (separate, unmerged) fix/route-more-messages-through-romtext branch, where this exact bug shape was first noticed while adding a third callsite with the same pattern. tests/engine/pikachu_unhappy_release_crash.lua: registers a fake Data.pokemon.PIKACHU cloned from the fixture species (ROM-free) so the species == "PIKACHU" check can be exercised, drives the real interactive release flow in Yellow on a mon owned by the player, and confirms the crash. Verified failing pre-fix (exact same "attempt to call field 'onDone' (a number value)" error) and passing post-fix. --- src/ui/BoxMenu.lua | 4 +- .../engine/pikachu_unhappy_release_crash.lua | 106 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/engine/pikachu_unhappy_release_crash.lua diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 76b22781..5dc2b694 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -180,8 +180,8 @@ local function release(game) and mon.ot == game.save.player.name then require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, - (t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name)) - :gsub("{RAM:wNameBuffer}", name))) + ((t._PikachuUnhappyText or Strings("%s looks\nunhappy about it!", name)) + :gsub("{RAM:wNameBuffer}", name)))) return end game.stack:push(TextBox.new(game, diff --git a/tests/engine/pikachu_unhappy_release_crash.lua b/tests/engine/pikachu_unhappy_release_crash.lua new file mode 100644 index 00000000..0f4ded1c --- /dev/null +++ b/tests/engine/pikachu_unhappy_release_crash.lua @@ -0,0 +1,106 @@ +-- BoxMenu's Yellow-only "Pikachu looks unhappy" release path +-- (release()'s isYellow()/species=="PIKACHU"/otId/ot branch) pushes its +-- TextBox with `TextBox.new(game, (...):gsub(...))` -- the gsub call is +-- the last argument, unparenthesized, so Lua expands its second return +-- value (the substitution count) into TextBox.new's third parameter, +-- onDone. TextBox.lua later calls onDone() unconditionally once the box +-- is dismissed, and a number is not callable: every release of your own +-- caught Pikachu in Yellow crashed, regardless of its nickname (unlike +-- the separate %-escape gsub bug, this needs no special save content -- +-- ordinary play reaches it every time). ROM-free: registers a fake +-- Data.pokemon.PIKACHU cloned from the fixture species so the species == +-- "PIKACHU" check can be exercised without a real ROM import. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local ids = T.fixtures.ids +require("src.render.Font").load(Data) + +-- clone a real fixture species under the literal id release() checks for +Data.pokemon.PIKACHU = Data.pokemon[ids.species[1]] + +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local TextBox = require("src.render.TextBox") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local Sound = require("src.core.Sound") + +local realCry, realPlay = Sound.playCry, Sound.play +Sound.playCry = function() end +Sound.play = function() end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t +end +function stack:top() return self.states[#self.states] end +function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end +end + +local pressed = {} +local function press(btn) + pressed = { [btn] = true } + stack:update(1 / 60) + pressed = {} +end + +local function topMt() return getmetatable(stack:top()) end +local function mash(btn, cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + press(btn) + end + return false +end + +GameVersion.set("yellow") + +local save = SaveData.newGame() +local game = { + data = Data, + save = save, + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, +} +game.save.options = game.save.options or {} +game.save.options.textSpeed = 1 + +local box = Boxes.active(save) +local mon = Pokemon.new(Data, "PIKACHU", 5) +mon.otId = save.player.id +mon.ot = save.player.name +box[1] = mon + +stack:push(BoxMenu.new(game)) +press("down"); press("down"); press("a") -- open RELEASE list +T.check(topMt() == ListMenu, "RELEASE opens the box list") + +-- release() calls Sound.playCry (stubbed) then pushes the "unhappy" +-- TextBox before any confirmation prompt -- pre-fix this line itself +-- raises "attempt to call field 'onDone' (a number value)" the moment +-- TextBox.new stores the leaked count and something dismisses the box. +local ok, err = pcall(function() + press("a") -- choose the Pikachu; release() runs synchronously here + T.check(topMt() == TextBox, "the unhappy-Pikachu TextBox opens directly, no confirm prompt") + -- dismiss it: this is what calls onDone, which is where the pre-fix + -- leaked count used to crash + mash("a", function() return topMt() ~= TextBox end) +end) +T.check(ok, "releasing your own caught Pikachu in Yellow does not crash: " .. tostring(err)) + +GameVersion.set("red") +Sound.playCry, Sound.play = realCry, realPlay +T.finish("pikachu_unhappy_release_crash") From 93374fbbbbc7d99683d59fa7f3cc228ce90e75d8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 19 Aug 2026 05:57:44 -0400 Subject: [PATCH 9/9] skin studio updates, save sync CLOSES #1533 --- docs/new-features.md | 1 - docs/skin-studio.md | 114 +- .../love/src/jni/love/src/common/android.cpp | 98 ++ .../love/src/jni/love/src/common/android.h | 15 + .../jni/love/src/modules/system/System.cpp | 21 + .../src/jni/love/src/modules/system/System.h | 12 + .../love/src/modules/system/wrap_System.cpp | 55 + .../java/org/love2d/android/GameActivity.java | 145 +++ mobile/ios/native/GRPickerBridge.swift | 118 ++ mobile/ios/patch_love_src.py | 82 +- src/core/DeltaSkin.lua | 523 ++++++++ src/core/Game.lua | 38 +- src/core/Game2.lua | 32 +- src/core/HostShell.lua | 181 +++ src/core/SaveData.lua | 30 +- src/core/TouchControls.lua | 10 +- src/core/TouchSkin.lua | 489 +++++++- src/core/gen2/Save.lua | 31 +- src/import/LauncherView.lua | 672 +++++++++- src/import/RomImporter.lua | 433 ++++++- src/net/Fetch.lua | 9 + src/net/fetch_worker.lua | 19 + src/render/Playfield.lua | 81 ++ src/render/Renderer.lua | 174 ++- src/sync/SyncClient.lua | 197 +++ src/sync/SyncEngine.lua | 690 +++++++++++ src/sync/SyncMods.lua | 196 +++ src/sync/SyncState.lua | 129 ++ src/sync/SyncTransport.lua | 41 + src/ui/SkinStudio.lua | 1089 ++++++++++++++++- src/ui/gen2/BattleTransition.lua | 4 +- src/ui/gen2/Chrome.lua | 21 +- src/ui/kit/Kit.lua | 62 +- src/world/OverworldController.lua | 3 +- src/world/gen2/World.lua | 10 +- tests/drivers/launcher_sync_shot.lua | 128 ++ tests/drivers/skin_add_by_url_shot.lua | 115 ++ tests/drivers/skin_containment_shot.lua | 227 ++++ tests/engine/gen2_mod_options_persist.lua | 83 ++ tests/engine/gen2_touch_skin_options.lua | 85 ++ tests/engine/host_shell_bridge_request.lua | 125 ++ tests/engine/host_shell_request_headers.lua | 95 ++ tests/engine/launcher_scroll_test.lua | 324 +++++ tests/engine/launcher_skins_ux.lua | 268 ++++ tests/engine/launcher_sync_modal.lua | 307 +++++ tests/engine/skin_format_import_test.lua | 626 ++++++++++ tests/engine/skin_studio_ux.lua | 339 +++++ tests/engine/skin_viewport_containment.lua | 214 ++++ tests/engine/sync_client_test.lua | 175 +++ tests/engine/sync_engine_test.lua | 459 +++++++ tests/engine/sync_mods_test.lua | 148 +++ tests/engine/sync_session_meta_test.lua | 171 +++ tests/engine/sync_state_test.lua | 120 ++ tests/engine/touch_skin_dpad_area.lua | 199 +++ 54 files changed, 9762 insertions(+), 271 deletions(-) create mode 100644 src/core/DeltaSkin.lua create mode 100644 src/render/Playfield.lua create mode 100644 src/sync/SyncClient.lua create mode 100644 src/sync/SyncEngine.lua create mode 100644 src/sync/SyncMods.lua create mode 100644 src/sync/SyncState.lua create mode 100644 src/sync/SyncTransport.lua create mode 100644 tests/drivers/launcher_sync_shot.lua create mode 100644 tests/drivers/skin_add_by_url_shot.lua create mode 100644 tests/drivers/skin_containment_shot.lua create mode 100644 tests/engine/gen2_mod_options_persist.lua create mode 100644 tests/engine/gen2_touch_skin_options.lua create mode 100644 tests/engine/host_shell_bridge_request.lua create mode 100644 tests/engine/host_shell_request_headers.lua create mode 100644 tests/engine/launcher_scroll_test.lua create mode 100644 tests/engine/launcher_skins_ux.lua create mode 100644 tests/engine/launcher_sync_modal.lua create mode 100644 tests/engine/skin_format_import_test.lua create mode 100644 tests/engine/skin_studio_ux.lua create mode 100644 tests/engine/skin_viewport_containment.lua create mode 100644 tests/engine/sync_client_test.lua create mode 100644 tests/engine/sync_engine_test.lua create mode 100644 tests/engine/sync_mods_test.lua create mode 100644 tests/engine/sync_session_meta_test.lua create mode 100644 tests/engine/sync_state_test.lua create mode 100644 tests/engine/touch_skin_dpad_area.lua diff --git a/docs/new-features.md b/docs/new-features.md index 4207c3d5..64615d10 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -13,7 +13,6 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Mobile touch controls** with editable layouts, vibration, and orientation settings * **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders * **Pokédex diploma and printer image exports** -* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts ## Gen 2 Specifics diff --git a/docs/skin-studio.md b/docs/skin-studio.md index 068ae316..24e345d2 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -4,17 +4,23 @@ A **skin** replaces the on-screen controls wholesale: a bezel image, a control layout, and the rectangle the Game Boy screen is drawn into. Engine: `src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua` (draw and input), `src/render/Renderer.lua` (the screen viewport), +`src/core/DeltaSkin.lua` (Delta `.deltaskin` import and export), `src/ui/SkinStudio.lua` (the desktop editor). Tests: `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, +`tests/engine/skin_studio_ux.lua`, `tests/engine/skin_studio_image_import.lua`, -`tests/engine/launcher_skins_tab.lua`. +`tests/engine/skin_format_import_test.lua`, +`tests/engine/launcher_skins_tab.lua`, +`tests/engine/launcher_skins_ux.lua`. Skins are picked in the launcher's **Skins** tab, which also imports them and opens the studio. `options.touchControls.skin` holds the folder name. ## Formats -Two load. `skin.lua` wins when a folder has both. +Three load: the native `skin.lua`, a RetroArch overlay `.cfg`, and a Delta +`.deltaskin`. `skin.lua` wins when a folder has more than one. The launcher +badges each installed skin with the format it was read from. **RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads as-is. Supported keys: @@ -41,6 +47,15 @@ Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one control that holds both. A `nul` desc is decoration: it draws and never captures a touch. +The area desc types are expanded rather than ignored: `dpad_area`, +`abxy_area`, `analog_left` and `analog_right` each become eight hitboxes over +the same area, one per 45 degree sector measured from its centre, the way +RetroArch resolves them: there is no neutral middle, and the four corner +sectors fire two inputs. Any `_up` / `_down` / `_left` / `_right` override and +the per-side reach are honoured, and the desc's own art is kept as decoration +over the top. Exporting a cfg folds the eight back into the one area desc they +came from. `retrok_` is a keyboard bind. + Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every image sits at the overlay opacity, and a pressed control's image swaps to `opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1 @@ -71,6 +86,28 @@ return { } ``` +**Delta `.deltaskin`.** A zip (any wrapping folder is stripped) holding an +`info.json` plus its art. The `representations` tree is walked +device / display type / orientation, and every orientation that exists becomes +a page; `page.orient` is the orientation key, so a portrait/landscape pair +auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in +`mappingSize` points and are converted to the native centre plus half extent; +`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a +radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the +corners fire two directions. `screens[1].outputFrame` (or the legacy +`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the +window the way Delta does rather than letterboxing. Host functions map to +engine hotkeys: `menu` to `menu_toggle`, `fastForward` to +`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`; +`quickSave` and `quickLoad` have nothing to bind to and drop to decoration. +Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*` +identifiers are accepted, and a non Game Boy system warns instead of failing. + +PDF artwork is the one thing that does not come across: Delta's own templates +are all-PDF and this engine has no rasterizer, so such a skin is refused with +the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are +an older, incompatible schema and are refused by name. + ## Bindable actions The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`, @@ -119,10 +156,22 @@ them. Anything that binds a button still follows the usual mobile / ## Installing -Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on -the launcher window while the Skins tab is open. A zip is mounted in place, so -there is nothing to unpack. The folder needs one `skin.lua` or `.cfg` -(`overlay.cfg` is preferred when there are several) and the images it names. +Four roads, all of them landing in `skins/` in the save directory: + +* **Import** on the Skins tab opens the host file picker for a `.zip` or a + `.deltaskin`. +* **Paste a skin link** in the tab's URL row, then **Add**. The download runs + on the fetch pool (`src/net/Fetch.lua`), so the launcher stays live, and the + row shows a spinner until it lands. A link to a bare `overlay.cfg` is wrapped + into an archive on the way in. This is the road that works on a phone, where + there is no file picker to speak of. +* Drop a `.zip` or `.deltaskin` on the launcher window while the Skins tab is + open. +* Copy a folder or archive into `skins/` by hand. + +An archive is mounted in place, so there is nothing to unpack. It needs one +`skin.lua`, `.cfg` (`overlay.cfg` is preferred when there are several) or +`info.json`, plus the images it names. Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0: @@ -157,23 +206,40 @@ The Super Game Boy preset locks the viewport to the real screen window, 160x144 at (48,40), so an SGB border cannot be drawn out of register. **Editing.** Click a control to select it, drag to move, eight handles to -resize. X / Y / W / H are in canvas pixels, so a control can be typed to the -coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and +resize. Arrow keys nudge the selection one canvas pixel, shift-arrow ten. While +a control is dragged it snaps to the centres and edges of the other controls +and of the page itself when it comes within a few pixels, and the guide it +snapped to is drawn. X / Y / W / H are in canvas pixels, so a control can be +typed to the coordinate its art was drawn at. **Back** and **Front** move the +selection through the draw order. Bind, hitbox shape, hit reach and idle and pressed images are per control; the bezel, the pages and the screen cutout are per page. The cutout is itself a draggable element with a 10:9 lock. +**Bind** opens a grid of every bind the engine understands: the eight Game Boy +buttons, the diagonal pairs, every hotkey, a few `key:` entries, and +decoration. The COMBINE chips at the top toggle one part at a time, which is +how a pipe bind like `left|down` is built without typing it. + +**Undo** and **Redo** in the top bar cover every edit (ctrl+Z / ctrl+Y, or +`u` / shift+`u` without a keyboard modifier). The stack holds the last 50 +actions. `L` toggles the bind captions drawn on the canvas. + Each page can **Lock** to portrait or landscape. With **Match canvas** on -(the default), Next page picks a matching mock device and the canvas preset +(the default), the page list picks a matching mock device and the canvas preset picks a matching page. Turn Match canvas off to look at a portrait page on a -landscape device. +landscape device. **Pages** opens the page list, where a page is selected, +renamed or deleted. + +Starting a new skin, opening another one or closing the studio with unsaved +edits prompts first, with Save first / Discard / Cancel. A RetroArch overlay whose pages are already named portrait / landscape (the auto-rotate convention) locks those pages and turns Match canvas on when you open it. You do not have to click Lock first. -**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the -images already in the skin folder; the **Import** button beside each one opens -the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, +**Art.** The **Bezel**, **Idle art** and **Pressed art** rows open a +thumbnail grid of the images already in the skin folder, with `(none)` first; +the **Import** button there and beside each row opens the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell, zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the window does the same for whichever slot was last touched. A new bezel does not @@ -185,11 +251,23 @@ buttons and the footer reports what is held. **Play** saves the skin, selects it, and boots the game with it. **Saving.** **Save** writes `skins//skin.lua` and copies every image the -skin names, so the folder stands alone. **Export** packs it as one zip -(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the -images, and the original `.cfg` when it came from one. An exported skin drops -straight back into `skins/` and still opens in RetroArch. +skin names, so the folder stands alone. **Export** offers three formats, and +the Skins tab's gear offers the same three for any installed skin: + +| Export | Contents | +| --- | --- | +| gen1recomp `.zip` | the native `skin.lua`, the images, and the original `.cfg` when it came from one | +| RetroArch `.zip` | an `overlay.cfg` generated from the model, plus the images | +| Delta `.deltaskin` | an `info.json` generated from the model, plus the images | + +All three are written store-only (`src/core/SkinZip.lua`) into `skins/_export/` +in the save directory, which is outside the folder the skin list scans, so an +export can never shadow the skin it came from. The notice names the full path +so a phone can find the file in its own file manager. On desktop **Show the +exported file** opens that folder. ## Not implemented -RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. +Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this +engine does not carry, so they are refused with a message rather than imported +half-drawn. diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 82834d7f..31940e30 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -378,6 +378,104 @@ bool httpPost(const char *url, const char *body, int bodyLen, const char *conten return result; } +bool httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, std::string &out) +{ + out.clear(); + if (url == nullptr) + return false; + if (headerPairCount < 0 || (headerPairCount > 0 && headerPairs == nullptr)) + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + // Same resolution rule as httpDownload: the activity's own class via + // SDL_AndroidGetActivity, never FindClass for an app class -- save sync + // runs on a love.thread worker, whose class loader cannot see them. + jobject activityObj = (jobject) SDL_AndroidGetActivity(); + if (activityObj == nullptr) + return false; + jclass activity = env->GetObjectClass(activityObj); + env->DeleteLocalRef(activityObj); + + // Old APK / new liblove skew: report "no transport" instead of aborting + // on a missing method (#597). + jmethodID method_id = env->GetStaticMethodID(activity, "httpRequest", + "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;[BLjava/lang/String;)[B"); + if (method_id == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jobjectArray jheaders = nullptr; + if (headerPairCount > 0) + { + // java/lang/String, unlike an app class, resolves from any thread. + jclass stringClass = env->FindClass("java/lang/String"); + if (stringClass == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + jheaders = env->NewObjectArray((jsize) headerPairCount, stringClass, nullptr); + env->DeleteLocalRef(stringClass); + if (jheaders == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + for (int i = 0; i < headerPairCount; i++) + { + jstring field = env->NewStringUTF(headerPairs[i] != nullptr ? headerPairs[i] : ""); + env->SetObjectArrayElement(jheaders, (jsize) i, field); + if (field != nullptr) + env->DeleteLocalRef(field); + } + } + + jstring jurl = env->NewStringUTF(url); + jstring jmethod = env->NewStringUTF(method != nullptr ? method : "GET"); + // raw bytes across the bridge, as httpPost does: a request body is JSON + // carrying a base64 save, and a jstring would run it through modified UTF-8 + jbyteArray jbody = nullptr; + if (body != nullptr && bodyLen >= 0) + { + jbody = env->NewByteArray((jsize) bodyLen); + if (jbody != nullptr && bodyLen > 0) + env->SetByteArrayRegion(jbody, 0, (jsize) bodyLen, (const jbyte*) body); + } + jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp"); + + jobject result = env->CallStaticObjectMethod(activity, method_id, jurl, jmethod, + jheaders, jbody, jua); + + env->DeleteLocalRef(jurl); + env->DeleteLocalRef(jmethod); + if (jheaders != nullptr) + env->DeleteLocalRef(jheaders); + if (jbody != nullptr) + env->DeleteLocalRef(jbody); + env->DeleteLocalRef(jua); + env->DeleteLocalRef(activity); + + if (result == nullptr) + return false; + + jbyteArray bytes = (jbyteArray) result; + jsize length = env->GetArrayLength(bytes); + if (length > 0) + { + out.resize((size_t) length); + env->GetByteArrayRegion(bytes, 0, length, (jbyte*) &out[0]); + } + env->DeleteLocalRef(result); + return true; +} + /* * TLS sockets. Same resolution rule as httpDownload above -- the activity's * own class, never FindClass -- and the same tolerance for an old APK: a diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index a412b25c..c323d21a 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -106,6 +106,21 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent, **/ bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent); +/** + * Blocking HTTPS request with a method, headers and a byte body + * (GameActivity.httpRequest). What save sync needs and neither of the two + * above can give it: PUT, per-request auth headers, and the response body of + * a 4xx as well as a 2xx. headerPairs is a flat name, value array of + * headerPairCount entries; body/userAgent may be null. `out` receives the + * Java side's envelope -- a head line of "STATUS " or "ERROR ", + * a newline, then the raw response bytes. False means the platform has no + * such bridge at all (an old APK under a newer liblove), which the Lua side + * reports as "update the app" rather than as a failed request. + **/ +bool httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, std::string &out); + /** * TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java). * LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 032228cb..7f7e2d30 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -274,6 +274,27 @@ bool System::httpPost(const char *url, const char *body, int bodyLen, #endif } +bool System::httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, + std::string &out) const +{ +#ifdef LOVE_ANDROID + return love::android::httpRequest(url, method, headerPairs, headerPairCount, + body, bodyLen, userAgent, out); +#else + LOVE_UNUSED(url); + LOVE_UNUSED(method); + LOVE_UNUSED(headerPairs); + LOVE_UNUSED(headerPairCount); + LOVE_UNUSED(body); + LOVE_UNUSED(bodyLen); + LOVE_UNUSED(userAgent); + out.clear(); + return false; +#endif +} + int System::tlsOpen(const char *host, int port) const { #ifdef LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index bbffd544..b5d0ff3f 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -159,6 +159,18 @@ public: virtual bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType = nullptr, const char *userAgent = nullptr) const; + /** + * Blocking HTTPS request with a method, headers and a byte body (Android + * only; false elsewhere). Save sync needs PUT, auth headers and the body + * of a 4xx, none of which the two bridges above can express. headerPairs + * is a flat name, value array; `out` receives the response envelope + * ("STATUS " or "ERROR ", a newline, then the raw body). + **/ + virtual bool httpRequest(const char *url, const char *method, + const char *const *headerPairs, int headerPairCount, + const char *body, int bodyLen, const char *userAgent, + std::string &out) const; + /** * TLS client sockets (Android only; every call fails elsewhere, where * LuaSec or another provider is the answer). Non-blocking by contract: diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 48436638..d75f9c85 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -22,6 +22,9 @@ #include "wrap_System.h" #include "sdl/System.h" +#include +#include + namespace love { namespace system @@ -150,6 +153,57 @@ int w_httpPost(lua_State *L) return 1; } +/* + * love.system.httpRequest(url, method, headers, body, userAgent) -> envelope + * + * `headers` is a flat array of alternating header name and value strings, so + * it maps straight onto the Java bridge's String[] without any parsing here. + * The single return is the response envelope -- a head line of + * "STATUS " or "ERROR ", a newline, then the raw body -- or nil + * where the build has no bridge, which src/core/HostShell.lua turns into an + * "update the app" notice rather than a failed request. + */ +int w_httpRequest(lua_State *L) +{ + const char *url = luaL_checkstring(L, 1); + const char *method = luaL_optstring(L, 2, "GET"); + + std::vector fields; + if (!lua_isnoneornil(L, 3)) + { + luaL_checktype(L, 3, LUA_TTABLE); + size_t count = luax_objlen(L, 3); + for (size_t i = 1; i <= count; i++) + { + lua_rawgeti(L, 3, (int) i); + const char *field = lua_tostring(L, -1); + fields.push_back(field != nullptr ? field : ""); + lua_pop(L, 1); + } + } + std::vector pairs; + for (size_t i = 0; i < fields.size(); i++) + pairs.push_back(fields[i].c_str()); + + size_t bodyLen = 0; + const char *body = nullptr; + if (!lua_isnoneornil(L, 4)) + body = luaL_checklstring(L, 4, &bodyLen); + const char *ua = luaL_optstring(L, 5, nullptr); + + std::string out; + bool ok = instance()->httpRequest(url, method, + pairs.empty() ? nullptr : &pairs[0], (int) pairs.size(), + body, (int) bodyLen, ua, out); + if (!ok) + { + lua_pushnil(L); + return 1; + } + lua_pushlstring(L, out.data(), out.size()); + return 1; +} + int w_hasBackgroundMusic(lua_State *L) { lua_pushboolean(L, instance()->hasBackgroundMusic()); @@ -245,6 +299,7 @@ static const luaL_Reg functions[] = { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, { "httpPost", w_httpPost }, + { "httpRequest", w_httpRequest }, { "tlsOpen", w_tlsOpen }, { "tlsStatus", w_tlsStatus }, { "tlsSend", w_tlsSend }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index f40f51b5..b60b5d5a 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -24,6 +24,7 @@ import org.libsdl.app.SDLActivity; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -36,6 +37,7 @@ import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import android.Manifest; @@ -847,6 +849,149 @@ public class GameActivity extends SDLActivity { } } + /** Response ceiling for httpRequest; anything larger is refused, not buffered. */ + private static final int HTTP_REQUEST_MAX_RESPONSE = 4 * 1024 * 1024; + + /** Builds an httpRequest envelope: one head line, a newline, then the body. */ + private static byte[] httpEnvelope(String head, byte[] payload) { + byte[] prefix; + try { + prefix = (head + "\n").getBytes("UTF-8"); + } catch (Exception e) { + prefix = (head + "\n").getBytes(); + } + if (payload == null || payload.length == 0) return prefix; + byte[] out = new byte[prefix.length + payload.length]; + System.arraycopy(prefix, 0, out, 0, prefix.length); + System.arraycopy(payload, 0, out, prefix.length, payload.length); + return out; + } + + /** One-line, CR/LF-free failure text, so an envelope head stays one line. */ + private static String httpErrorText(Exception e) { + String text = e.getMessage(); + if (text == null || text.length() == 0) text = e.getClass().getSimpleName(); + text = text.replace('\r', ' ').replace('\n', ' '); + if (text.length() > 160) text = text.substring(0, 160); + return text; + } + + /** + * Blocking HTTPS request with a chosen method, headers and byte body, + * exposed as love.system.httpRequest and used by src/core/HostShell.lua + * for save sync. Sync needs PUT, per-request auth headers and the response + * body of a 4xx as well as a 2xx (a conflict answers 409 with the save + * that won), none of which httpDownload or httpPost above can express. + * + * Same rules as those two: https only, redirects followed by hand + * (re-sending method and body on each hop), 15s connect / 60s read, and + * blocking on the Lua/worker thread -- never the UI thread. Headers arrive + * as a flat name, value array; a field carrying CR or LF is refused rather + * than sent, so a header value can never inject a second header. + * + * The reply is an envelope: a head line of "STATUS <code>" or + * "ERROR <text>", a newline, then the raw response bytes. + */ + @Keep + public static byte[] httpRequest(String url, String method, String[] headerPairs, + byte[] body, String userAgent) { + if (url == null) return httpEnvelope("ERROR missing url", null); + String verb = method == null ? "GET" : method.toUpperCase(Locale.US); + if (!"GET".equals(verb) && !"POST".equals(verb) + && !"PUT".equals(verb) && !"DELETE".equals(verb)) { + return httpEnvelope("ERROR unsupported request method", null); + } + if (headerPairs != null) { + if ((headerPairs.length % 2) != 0) { + return httpEnvelope("ERROR bad request header", null); + } + for (int i = 0; i < headerPairs.length; i++) { + String field = headerPairs[i]; + if (field == null) return httpEnvelope("ERROR bad request header", null); + if (field.indexOf('\r') >= 0 || field.indexOf('\n') >= 0) { + return httpEnvelope("ERROR bad request header", null); + } + if ((i % 2) == 0 && field.length() == 0) { + return httpEnvelope("ERROR bad request header", null); + } + } + } + HttpURLConnection conn = null; + try { + String current = url; + for (int hop = 0; hop < 5; hop++) { + URL parsed = new URL(current); + if (!"https".equalsIgnoreCase(parsed.getProtocol())) { + return httpEnvelope("ERROR https only", null); + } + conn = (HttpURLConnection) parsed.openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + conn.setRequestMethod(verb); + conn.setRequestProperty("User-Agent", + userAgent == null ? "gen1recomp" : userAgent); + if (headerPairs != null) { + for (int i = 0; i + 1 < headerPairs.length; i += 2) { + conn.setRequestProperty(headerPairs[i], headerPairs[i + 1]); + } + } + if (body != null && !"GET".equals(verb)) { + conn.setDoOutput(true); + conn.setFixedLengthStreamingMode(body.length); + OutputStream out = new BufferedOutputStream(conn.getOutputStream()); + try { + out.write(body); + } finally { + try { out.close(); } catch (IOException ignored) {} + } + } + int code = conn.getResponseCode(); + if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { + String next = conn.getHeaderField("Location"); + conn.disconnect(); + conn = null; + if (next == null) { + return httpEnvelope("ERROR redirect without a location", null); + } + current = new URL(parsed, next).toString(); + continue; + } + // A rejection's body is the diagnosis the caller wants, so 4xx + // and 5xx are read through getErrorStream rather than dropped. + InputStream in; + try { + in = conn.getInputStream(); + } catch (IOException e) { + in = conn.getErrorStream(); + } + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + if (in != null) { + InputStream reader = new BufferedInputStream(in); + try { + byte[] buf = new byte[16384]; + int n; + while ((n = reader.read(buf)) > 0) { + if (sink.size() + n > HTTP_REQUEST_MAX_RESPONSE) { + return httpEnvelope("ERROR the reply was too large", null); + } + sink.write(buf, 0, n); + } + } finally { + try { reader.close(); } catch (IOException ignored) {} + } + } + return httpEnvelope("STATUS " + code, sink.toByteArray()); + } + return httpEnvelope("ERROR too many redirects", null); + } catch (Exception e) { + Log.d("GameActivity", "httpRequest failed: " + e.getMessage()); + return httpEnvelope("ERROR " + httpErrorText(e), null); + } finally { + if (conn != null) conn.disconnect(); + } + } + /** * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export * (pending_export.sav in the app save identity) to Downloads / Drive / diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index b51925f6..06f0db26 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -67,6 +67,124 @@ public final class GRPickerBridge: NSObject { return succeeded } + // MARK: - General HTTP request (love.system.httpRequest) + + private static let httpMaxResponse = 4 * 1024 * 1024 + + // URLSession turns a 301/302/303 POST into a GET on its own. Save sync + // signs a method and a body, so every hop re-sends the original request + // against the new URL instead, and only over https. + private final class GRRedirectKeeper: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + guard let original = task.originalRequest, + let target = request.url, + target.scheme?.lowercased() == "https" else { + completionHandler(nil) + return + } + var next = original + next.url = target + completionHandler(next) + } + } + + private static let httpSession = URLSession(configuration: .ephemeral, + delegate: GRRedirectKeeper(), + delegateQueue: nil) + + private static func httpEnvelope(_ head: String, _ payload: Data?) -> NSData { + var out = Data((head + "\n").utf8) + if let payload { out.append(payload) } + return out as NSData + } + + private static func httpErrorText(_ error: Error) -> String { + var text = error.localizedDescription + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + if text.isEmpty { text = "the request failed" } + if text.count > 160 { text = String(text.prefix(160)) } + return text + } + + /// Blocking HTTPS request with a chosen method, headers and byte body, the + /// iOS half of love.system.httpRequest (see the Android GameActivity one). + /// Headers arrive as "name: value" lines joined by newlines. The reply is + /// an envelope: a head line of "STATUS " or "ERROR ", a + /// newline, then the raw response bytes -- read for 4xx and 5xx as well, + /// because a sync conflict answers 409 with the save that won. + @objc(httpRequestWithUrl:method:headers:body:bodyLength:userAgent:) + public static func httpRequest(url: UnsafePointer?, + method: UnsafePointer?, + headers: UnsafePointer?, + body: UnsafePointer?, + bodyLength: Int32, + userAgent: UnsafePointer?) -> NSData? { + guard let url, let requestURL = URL(string: String(cString: url)) else { + return httpEnvelope("ERROR missing url", nil) + } + guard requestURL.scheme?.lowercased() == "https" else { + return httpEnvelope("ERROR https only", nil) + } + let verb = (method.map { String(cString: $0) } ?? "GET").uppercased() + guard ["GET", "POST", "PUT", "DELETE"].contains(verb) else { + return httpEnvelope("ERROR unsupported request method", nil) + } + + var request = URLRequest(url: requestURL) + request.httpMethod = verb + request.timeoutInterval = 60 + request.setValue(userAgent.map { String(cString: $0) } ?? "gen1recomp", + forHTTPHeaderField: "User-Agent") + if let headers, headers.pointee != 0 { + for line in String(cString: headers).split(separator: "\n") { + guard let colon = line.firstIndex(of: ":") else { + return httpEnvelope("ERROR bad request header", nil) + } + let name = line[line.startIndex.. 0 { + request.httpBody = Data(bytes: body, count: Int(bodyLength)) + } + + let semaphore = DispatchSemaphore(value: 0) + var envelope = httpEnvelope("ERROR no response", nil) + let task = httpSession.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + if let error { + envelope = httpEnvelope("ERROR " + httpErrorText(error), nil) + return + } + guard let http = response as? HTTPURLResponse else { + envelope = httpEnvelope("ERROR no response", nil) + return + } + let payload = data ?? Data() + if payload.count > httpMaxResponse { + envelope = httpEnvelope("ERROR the reply was too large", nil) + return + } + envelope = httpEnvelope("STATUS \(http.statusCode)", payload) + } + task.resume() + guard semaphore.wait(timeout: .now() + 65) == .success else { + task.cancel() + return httpEnvelope("ERROR the request timed out", nil) + } + return envelope + } + // MARK: - Entry points called from liblove (C strings on purpose) @objc(presentPickerWithKind:saveDir:) diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index d59363bf..a37e5325 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -9,7 +9,8 @@ What it does: 1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift, GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree. 2. Patches liblove's wrap_System.cpp to expose love.system.pickFile, - love.system.createFile, and love.system.syncHealthSteps on iOS (each + love.system.createFile, love.system.syncHealthSteps, + love.system.httpDownload and love.system.httpRequest on iOS (each calls a GR*Bridge Swift class through the Objective-C runtime, so liblove never links against Swift directly). 3. Patches love.xcodeproj so the love-ios app target compiles the native @@ -50,6 +51,7 @@ WRAP_INCLUDES = """ #include #include #include +#include #include "filesystem/Filesystem.h" #endif """ % MARKER @@ -159,6 +161,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, + { "httpRequest", w_httpRequest }, #endif """ @@ -201,6 +204,7 @@ int w_syncHealthSteps(lua_State *L) WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, + { "httpRequest", w_httpRequest }, #endif """ @@ -226,6 +230,80 @@ int w_httpDownload(lua_State *L) lua_pushboolean(L, ok != 0); return 1; } + +// love.system.httpRequest(url, method, headers, body, userAgent) -> envelope +// +// The transport save sync needs: a chosen method, per-request auth headers, +// and the response body of a 4xx as well as a 2xx. `headers` is a flat array +// of alternating name and value strings, joined into "name: value" lines here +// because the Swift bridge takes C strings and no Foundation type may be +// NAMED in this translation unit (see w_pickFileKinds above). +// +// The single return is the response envelope -- a head line of +// "STATUS " or "ERROR ", a newline, then the raw body -- or nil +// where the build carries no bridge at all, which src/core/HostShell.lua +// turns into an "update the app" notice rather than a failed request. +int w_httpRequest(lua_State *L) +{ + const char *url = luaL_checkstring(L, 1); + const char *method = luaL_optstring(L, 2, "GET"); + + std::string headerBlob; + if (!lua_isnoneornil(L, 3)) + { + luaL_checktype(L, 3, LUA_TTABLE); + std::vector fields; + size_t count = luax_objlen(L, 3); + for (size_t i = 1; i <= count; i++) + { + lua_rawgeti(L, 3, (int) i); + const char *field = lua_tostring(L, -1); + fields.push_back(field != nullptr ? field : ""); + lua_pop(L, 1); + } + for (size_t i = 0; i + 1 < fields.size(); i += 2) + headerBlob += fields[i] + ": " + fields[i + 1] + "\\n"; + } + + size_t bodyLen = 0; + const char *body = nullptr; + if (!lua_isnoneornil(L, 4)) + body = luaL_checklstring(L, 4, &bodyLen); + const char *ua = luaL_optstring(L, 5, "gen1recomp"); + + Class cls = objc_getClass("GRPickerBridge"); + if (cls == nullptr) + { + lua_pushnil(L); + return 1; + } + typedef id (*GRRequest)(Class, SEL, const char *, const char *, + const char *, const unsigned char *, int, + const char *); + id reply = ((GRRequest)objc_msgSend)( + cls, + sel_registerName("httpRequestWithUrl:method:headers:body:bodyLength:userAgent:"), + url, method, headerBlob.c_str(), (const unsigned char *) body, + (int) bodyLen, ua); + if (reply == nullptr) + { + lua_pushnil(L); + return 1; + } + // NSData read through the runtime, for the same reason as above: the + // bytes are copied out immediately, before any autorelease pool drains. + typedef const void *(*GRBytes)(id, SEL); + typedef unsigned long (*GRLength)(id, SEL); + const void *bytes = ((GRBytes)objc_msgSend)(reply, sel_registerName("bytes")); + unsigned long length = ((GRLength)objc_msgSend)(reply, sel_registerName("length")); + if (bytes == nullptr || length == 0) + { + lua_pushnil(L); + return 1; + } + lua_pushlstring(L, (const char *) bytes, (size_t) length); + return 1; +} #endif """ @@ -308,7 +386,7 @@ def patch_wrap_system(): text = text.replace(reg_anchor, reg_anchor + registration, 1) WRAP_SYSTEM.write_text(text) print("patch_love_src: wrap_System.cpp patched " - "(pickFile/createFile/syncHealthSteps/httpDownload)") + "(pickFile/createFile/syncHealthSteps/httpDownload/httpRequest)") def patch_public_documents(): diff --git a/src/core/DeltaSkin.lua b/src/core/DeltaSkin.lua new file mode 100644 index 00000000..e2d9bd8f --- /dev/null +++ b/src/core/DeltaSkin.lua @@ -0,0 +1,523 @@ +local Json = require("src.link.Json") +local TouchSkin = require("src.core.TouchSkin") + +local DeltaSkin = {} + +DeltaSkin.INFO_NAME = "info.json" +DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024 + +DeltaSkin.GAME_TYPE_PREFIXES = { + "com.rileytestut.delta.game.", + "public.aoshuang.game.", +} + +DeltaSkin.SYSTEMS = { gb = true, gbc = true } + +DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } + +DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" } +DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" } +DeltaSkin.ORIENTATIONS = { "portrait", "landscape" } +DeltaSkin.SIDES = { "up", "down", "left", "right" } + +DeltaSkin.ASSET_LADDER = { "small", "medium", "large" } +DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 } +DeltaSkin.DEFAULT_TARGET_WIDTH = 1080 + +DeltaSkin.INPUTS = { + a = "a", b = "b", start = "start", select = "select", + up = "up", down = "down", left = "left", right = "right", + menu = "menu_toggle", + fastforward = "hold_fast_forward", + togglefastforward = "toggle_fast_forward", +} + +DeltaSkin.OUTPUT_HOTKEYS = { + menu = "menu", + fast_forward_hold = "fastForward", + fast_forward_toggle = "toggleFastForward", +} + +DeltaSkin.MAPPING = { + portrait = { width = 1080, height = 1920 }, + landscape = { width = 1920, height = 1080 }, +} + +DeltaSkin.SCREEN_WIDTH = 160 +DeltaSkin.SCREEN_HEIGHT = 144 + +local function pick(t, key) + if type(t) ~= "table" then return nil end + local direct = t[key] + if direct ~= nil then return direct end + local want = tostring(key):lower() + for k, v in pairs(t) do + if tostring(k):lower() == want then return v end + end + return nil +end + +local function numOr(v, fallback) + local n = tonumber(v) + if not n or n ~= n then return fallback end + return n +end + +local function round(n) + return math.floor(numOr(n, 0) + 0.5) +end + +local function isArray(t) + return type(t) == "table" and t[1] ~= nil +end + +local function addWarning(list, text) + if type(list) ~= "table" then return end + for _, existing in ipairs(list) do + if existing == text then return end + end + list[#list + 1] = text +end + +function DeltaSkin.findInfo(root) + local direct = root .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(direct) then return direct, "" end + local items = TouchSkin.listDir(root) + for _, name in ipairs(items) do + if tostring(name):lower() == DeltaSkin.INFO_NAME then + return root .. "/" .. name, "" + end + end + table.sort(items) + for _, name in ipairs(items) do + local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(nested) then return nested, name .. "/" end + end + return nil +end + +function DeltaSkin.resolveName(name, opts) + name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "") + if name == "" then return nil end + local names = opts and opts.names + if type(names) == "table" then + local want = name:lower() + for _, entry in ipairs(names) do + if tostring(entry):lower() == want then + name = tostring(entry) + break + end + end + end + return ((opts and opts.prefix) or "") .. name +end + +function DeltaSkin.pickAsset(assets, opts, pdfFiles) + if type(assets) ~= "table" then return nil end + pdfFiles = pdfFiles or {} + local raster = {} + for _, key in ipairs(DeltaSkin.ASSET_LADDER) do + local name = pick(assets, key) + if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end + if type(name) == "string" and name ~= "" then + if name:lower():match("%.pdf$") then + pdfFiles[#pdfFiles + 1] = name + else + raster[#raster + 1] = { key = key, name = name } + end + end + end + local resizable = pick(assets, "resizable") + if type(resizable) == "string" and resizable ~= "" then + if resizable:lower():match("%.pdf$") then + pdfFiles[#pdfFiles + 1] = resizable + else + raster[#raster + 1] = { key = "large", name = resizable } + end + end + if #raster == 0 then return nil end + + local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH) + local chosen + for _, cand in ipairs(raster) do + if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then + chosen = cand.name + end + end + if not chosen then chosen = raster[#raster].name end + return DeltaSkin.resolveName(chosen, opts) +end + +function DeltaSkin.mergeEdges(base, item) + local out = { top = 0, bottom = 0, left = 0, right = 0 } + for _, side in ipairs({ "top", "bottom", "left", "right" }) do + local v = pick(item, side) + if v == nil then v = pick(base, side) end + out[side] = numOr(v, 0) + end + return out +end + +function DeltaSkin.representation(reps, orient) + for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do + local dev = pick(reps, device) + if type(dev) == "table" then + for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do + local shown = pick(dev, display) + if type(shown) == "table" then + local obj = pick(shown, orient) + if type(obj) == "table" then return obj, device, display end + end + end + local flat = pick(dev, orient) + if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then + return flat, device, nil + end + end + end + return nil +end + +function DeltaSkin.directionalInputs(inputs) + if type(inputs) ~= "table" or isArray(inputs) then return nil end + local out, found = {}, 0 + for _, side in ipairs(DeltaSkin.SIDES) do + local v = pick(inputs, side) + if type(v) == "string" then + local lower = v:lower() + local mapped = DeltaSkin.INPUTS[lower] + if not mapped and lower:find(side, 1, true) then mapped = side end + if mapped then + out[side] = mapped + found = found + 1 + end + end + end + if found >= 2 then return out end + return nil +end + +function DeltaSkin.specFor(inputs) + local parts = {} + local function add(v) + if type(v) ~= "string" then return end + local mapped = DeltaSkin.INPUTS[v:lower()] + if mapped then parts[#parts + 1] = mapped end + end + if type(inputs) == "string" then + add(inputs) + elseif type(inputs) == "table" then + if isArray(inputs) then + for _, v in ipairs(inputs) do add(v) end + else + local keys = {} + for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end + table.sort(keys) + for _, k in ipairs(keys) do add(inputs[k]) end + end + end + if #parts == 0 then return "nul" end + return table.concat(parts, "|") +end + +function DeltaSkin.screenRect(obj, mapW, mapH) + local frame + local screens = pick(obj, "screens") + if type(screens) == "table" and type(screens[1]) == "table" then + frame = pick(screens[1], "outputFrame") + end + if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end + if type(frame) ~= "table" then return nil end + local w = numOr(pick(frame, "width"), 0) + local h = numOr(pick(frame, "height"), 0) + if w <= 0 or h <= 0 then return nil end + return { + x = numOr(pick(frame, "x"), 0) / mapW, + y = numOr(pick(frame, "y"), 0) / mapH, + w = w / mapW, h = h / mapH, + } +end + +function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + if type(item) ~= "table" then return end + local frame = pick(item, "frame") + if type(frame) ~= "table" then return end + local fw = numOr(pick(frame, "width"), 0) + local fh = numOr(pick(frame, "height"), 0) + if fw <= 0 or fh <= 0 then return end + local fx = numOr(pick(frame, "x"), 0) + local fy = numOr(pick(frame, "y"), 0) + + local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges")) + local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH + local w, h = fw / mapW, fh / mapH + local reachLeft = 1 + edges.left / (fw * 0.5) + local reachRight = 1 + edges.right / (fw * 0.5) + local reachUp = 1 + edges.top / (fh * 0.5) + local reachDown = 1 + edges.bottom / (fh * 0.5) + + local inputs = pick(item, "inputs") + local dirs = DeltaSkin.directionalInputs(inputs) + if dirs then + local base = { + x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5, + rangeMod = 1, alphaMod = page.alphaMod, shape = "rect", + reachLeft = reachLeft, reachRight = reachRight, + reachUp = reachUp, reachDown = reachDown, + } + for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do + page.controls[#page.controls + 1] = ctl + end + return + end + + local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect" + local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape) + ctl.alphaMod = page.alphaMod + ctl.reachLeft, ctl.reachRight = reachLeft, reachRight + ctl.reachUp, ctl.reachDown = reachUp, reachDown + page.controls[#page.controls + 1] = ctl +end + +function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + local mapping = pick(obj, "mappingSize") + local mapW = numOr(pick(mapping, "width"), 0) + local mapH = numOr(pick(mapping, "height"), 0) + if mapW <= 0 or mapH <= 0 then + mapW, mapH = 320, 240 + addWarning(warnings, orient .. " has no mappingSize; assuming 320x240") + end + + local page = { + name = orient, + orient = orient, + imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles), + fullScreen = true, + normalized = true, + pixelCoords = false, + rangeMod = 1, + alphaMod = pick(obj, "translucent") == true and 0.7 or 1, + aspect = mapW / mapH, + aspectFromCfg = false, + rect = { x = 0, y = 0, w = 1, h = 1 }, + mappingWidth = mapW, + mappingHeight = mapH, + controls = {}, + } + + local screen = DeltaSkin.screenRect(obj, mapW, mapH) + if screen then + page.viewport = screen + page.viewportFill = false + end + + local baseEdges = pick(obj, "extendedEdges") + local items = pick(obj, "items") + if type(items) == "table" then + for _, item in ipairs(items) do + DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + end + end + return page +end + +function DeltaSkin.systemOf(gameType) + if type(gameType) ~= "string" or gameType == "" then return nil end + for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do + if gameType:sub(1, #prefix) == prefix then + return gameType:sub(#prefix + 1):lower() + end + end + return nil +end + +function DeltaSkin.parse(text, opts) + opts = opts or {} + local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES) + if type(info) ~= "table" then + return nil, "info.json does not parse: " .. tostring(err) + end + + local gameType = info.gameTypeIdentifier + if type(gameType) ~= "string" or gameType == "" then + return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier" + end + if gameType:lower():find("gba4ios", 1, true) then + return nil, "old GBA4iOS skin, not supported" + end + local system = DeltaSkin.systemOf(gameType) + if not system then + return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType + end + + local warnings = {} + if not DeltaSkin.SYSTEMS[system] then + addWarning(warnings, "this skin is for " .. system .. ", not Game Boy") + end + + local reps = info.representations + if type(reps) ~= "table" then return nil, "info.json has no representations" end + + local pdfFiles, pages = {}, {} + for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do + local obj = DeltaSkin.representation(reps, orient) + if obj then + local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + page.index = #pages + 1 + pages[#pages + 1] = page + end + end + if #pages == 0 then return nil, "info.json has no usable representation" end + if #pdfFiles > 0 then + addWarning(warnings, "PDF artwork cannot be imported yet") + end + + return { + pages = pages, + name = info.name, + author = info.author, + notes = info.notes, + format = "delta", + system = system, + identifier = info.identifier, + warnings = warnings, + pdfFiles = pdfFiles, + } +end + +function DeltaSkin.needsConversion(skin) + if type(skin) ~= "table" then return nil end + local files = skin.pdfFiles + if type(files) ~= "table" or #files == 0 then return nil end + for _, page in ipairs(skin.pages or {}) do + if page.imagePath then return nil end + end + return { pdfOnly = true, files = files } +end + +function DeltaSkin.outputInputs(ctl) + local out = {} + for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end + for _, h in ipairs(ctl.hotkeys or {}) do + local mapped = DeltaSkin.OUTPUT_HOTKEYS[h] + if mapped then out[#out + 1] = mapped end + end + return out +end + +function DeltaSkin.buildRepresentation(page, orient, warnings) + local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait + local mapW, mapH = map.width, map.height + local items, files = {}, {} + + for _, ctl in ipairs(page.controls or {}) do + local names = DeltaSkin.outputInputs(ctl) + if ctl.sector and ctl.sector ~= 1 then + names = {} + elseif ctl.sector and ctl.areaNames then + local TouchSkin = require("src.core.TouchSkin") + local dirs = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()] + if mapped then dirs[side] = mapped end + end + names = next(dirs) and dirs or {} + end + if names.up or names.down or names.left or names.right or #names > 0 then + local item = { + inputs = names, + frame = { + x = round((ctl.x - ctl.rangeX) * mapW), + y = round((ctl.y - ctl.rangeY) * mapH), + width = round(ctl.rangeX * 2 * mapW), + height = round(ctl.rangeY * 2 * mapH), + }, + } + if ctl.shape == "radial" then item.mask = "circle" end + local edges, any = {}, false + local pairsList = { + { key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW }, + { key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW }, + { key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH }, + { key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH }, + } + for _, side in ipairs(pairsList) do + local reach = numOr(side.reach, 1) + if reach ~= 1 then + edges[side.key] = round((reach - 1) * side.half) + any = true + end + end + if any then item.extendedEdges = edges end + items[#items + 1] = item + elseif ctl.imagePath then + addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image") + end + end + + local obj = { + items = items, + mappingSize = { width = mapW, height = mapH }, + extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 }, + translucent = false, + } + if page.imagePath then + obj.assets = { + small = page.imagePath, medium = page.imagePath, large = page.imagePath, + } + files[#files + 1] = page.imagePath + end + if page.viewport then + obj.screens = { { + inputFrame = { x = 0, y = 0, + width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT }, + outputFrame = { + x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH), + width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH), + }, + } } + end + return obj, files +end + +function DeltaSkin.build(skin, opts) + if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then + return nil, "skin has no pages" + end + opts = opts or {} + local standard, edgeToEdge = {}, {} + local assets, warnings, used = {}, {}, {} + + for _, page in ipairs(skin.pages) do + local orient = TouchSkin.pageOrient(page) + if orient ~= "portrait" and orient ~= "landscape" then + orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape" + end + if not used[orient] then + used[orient] = true + local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings) + standard[orient] = obj + edgeToEdge[orient] = obj + for _, rel in ipairs(files) do assets[#assets + 1] = rel end + end + end + + local system = tostring(opts.system or "gbc") + local info = { + name = skin.name or skin.id or "skin", + identifier = opts.identifier + or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")), + gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system, + debug = false, + representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } }, + } + return info, assets, warnings +end + +function DeltaSkin.encodeInfo(skin, opts) + local info, assets, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assets end + return Json.encode(info), assets, warnings +end + +return DeltaSkin diff --git a/src/core/Game.lua b/src/core/Game.lua index 1c01d889..31f63ebd 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -34,6 +34,7 @@ end function Game:load() self.data = Data + self.sessionStartedAt = os.time() Data:load() -- Mods are a native engine subsystem. They load after the verified ROM @@ -155,6 +156,7 @@ function Game:makeTitleState() onNewGame = function() while self.stack:top() do self.stack:pop() end -- New Game keeps the standalone options.lua preferences + self.sessionStartedAt = os.time() self.save = SaveData.newGame(self:bootConfig()) -- no bucket carry-over: mod state from an abandoned session must -- not leak into a fresh slot; mods seed via save.created instead @@ -356,6 +358,7 @@ function Game:update(dt) -- reason: they are presentational, so fast-forward must not speed them up require("src.render.Pipelines").update(dt) pcall(function() require("src.core.DiscordPresence").update(dt) end) + self:updateSync(dt) -- Steady-state memory backstop: advance the incremental collector one -- small step every rendered frame. The heavy GPU objects are now freed -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this @@ -1178,11 +1181,41 @@ function Game:writeSave() -- stamp here so the save.writing payload carries the exact meta the -- file gets; mods snapshot runtime state into their namespace now self.save.meta = SaveData.buildMeta( - self.modStatus and self.modStatus.loaded, self.save.meta) + self.modStatus and self.modStatus.loaded, self.save.meta, + self.sessionStartedAt) if ModRuntime.wants("save.writing") then ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) end - return SaveData.save(self.save) + local written = SaveData.save(self.save) + if written then + local eng = self:syncEngine() + if eng then pcall(eng.noteSaveWritten, eng) end + end + return written +end + +function Game:syncEngine() + if self._syncOff then return nil end + if self._syncEngineRef then return self._syncEngineRef end + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._syncOff = true + return nil + end + local eng = SyncEngine.shared() + if not eng then + self._syncOff = true + return nil + end + self._syncEngineRef = eng + return eng +end + +function Game:updateSync(dt) + local eng = self:syncEngine() + if not eng then return end + if not (eng.state.enabled and eng:linked()) and not eng:busy() then return end + pcall(eng.update, eng, dt) end -- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings @@ -1241,6 +1274,7 @@ function Game:applyOptions(opts) end function Game:restoreSave(loaded, recovered, opts) + self.sessionStartedAt = os.time() if ModRuntime.wants("save.loading") then ModRuntime.emit("save.loading", { raw = loaded }) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 77856d79..3de97efc 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -36,6 +36,7 @@ local World = require("src.world.gen2.World") -- other engine file, so a call site here is the same call site Gen 1 has. local ModRuntime = require("src.mods.Runtime") local GameViewport = require("src.render.GameViewport") +local Playfield = require("src.render.Playfield") -- Only for the mod-supplied save migrations and the mods-changed report, which -- are keyed off save.meta and know nothing about a generation; Gold's own save -- IO is src/core/gen2/Save.lua. @@ -195,6 +196,7 @@ end function Game2:persistOptions() pcall(Save.saveOptions, self.options) end +Game2.writeOptions = Game2.persistOptions -- Point the loader's mod.save backing at this save's modData so per-mod state -- persists with the slot. Same contract and same three call sites as Gen 1 @@ -1160,7 +1162,8 @@ end -- (Renderer:endFrame's Sp). Following the zoom used to shrink the LCD grid to -- one screen pixel a cell out at survey range. function Game2:pixelScale(w, h) - return math.max(1, math.floor(math.min(w / 160, h / 144))) + local _, _, pw, ph = Playfield.rect(w, h) + return math.max(1, math.floor(math.min(pw / 160, ph / 144))) end -- A window-sized canvas the whole frame is composed into, so the post passes @@ -1277,7 +1280,8 @@ end function Game2:blitZones(canvas, zones, w, h) local G = love.graphics local GbcPalette = require("src.render.GbcPalette") - local sx, sy = w / 160, h / 144 + local px, py, pw, ph = Playfield.rect(w, h) + local sx, sy = pw / 160, ph / 144 G.setColor(1, 1, 1, 1) for _, z in ipairs(zones) do -- a colors == false zone is the true-colour opt-out; anything the shader @@ -1296,11 +1300,11 @@ function Game2:blitZones(canvas, zones, w, h) -- whose contract differs from Gen 1's. Whole-screen and half-screen zones -- come out of this at exactly the pixels the plain floor/ceil pair gave -- them, so the vanilla picture is untouched. - local zx, zy = (z.x or 0) * sx, (z.y or 0) * sy - local x1 = math.floor(math.max(zx, 0)) - local y1 = math.floor(math.max(zy, 0)) - local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, w)) - local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, h)) + local zx, zy = px + (z.x or 0) * sx, py + (z.y or 0) * sy + local x1 = math.floor(math.max(zx, px)) + local y1 = math.floor(math.max(zy, py)) + local x2 = math.ceil(math.min(zx + (z.w or 160) * sx, px + pw)) + local y2 = math.ceil(math.min(zy + (z.h or 144) * sy, py + ph)) if x2 > x1 and y2 > y1 then G.setScissor(x1, y1, x2 - x1, y2 - y1) G.draw(canvas, 0, 0) @@ -1402,7 +1406,7 @@ function Game2:drawViewportFrame() scene = self:presentCanvas(1, w, h) end if not scene then - self:drawScene(w, h) + self:drawContained(w, h) self:drawHud(w, h) return end @@ -1413,7 +1417,7 @@ function Game2:drawViewportFrame() G.origin() G.setCanvas(scene) G.clear(0, 0, 0, 1) - self:drawScene(w, h) + self:drawContained(w, h) G.setCanvas(previous) if composing and self:compose(scene, zones, w, h) then @@ -1462,6 +1466,8 @@ function Game2:drawViewportFrame() generation = 2, }) == true if not outputHandled then + local cx, cy, cw, ch = Playfield.cutout(w, h) + if cx then G.setScissor(cx, cy, cw, ch) end if fx then GBCFX.present(source, self:pixelScale(w, h)) else @@ -1469,6 +1475,7 @@ function Game2:drawViewportFrame() G.draw(source, 0, 0) G.setShader() end + if cx then G.setScissor() end end end G.pop() @@ -1499,6 +1506,13 @@ function Game2:textboxPaper() return nil end +function Game2:drawContained(w, h) + local pw, ph = Playfield.push(w, h) + local ok, err = pcall(self.drawScene, self, pw, ph) + Playfield.pop() + if not ok then error(err, 0) end +end + function Game2:drawScene(w, h) local G = love.graphics -- render.compose reads this after the scene is drawn; the plain overworld diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 156715f2..82a42bfb 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -350,11 +350,23 @@ local function haveBridge() return osName == "Android" or osName == "iOS" or osName == "UWP" end +local function haveRequestBridge() + if not (love and love.system and type(love.system.httpRequest) == "function") then + return false + end + local osName = love.system.getOS and love.system.getOS() + return osName == "Android" or osName == "iOS" or osName == "UWP" +end + -- Is any transport available at all? Callers gate on this, never on curl. function HostShell.canFetch() return HostShell.haveCurl() or haveBridge() end +function HostShell.canHttpRequest() + return (HostShell.haveCurl() or haveRequestBridge()) and true or false +end + -- Download url to an absolute host path. Returns true, or nil plus an error. -- The curl branch deliberately ignores curl's exit code, as the download paths -- always did: callers judge the result by the file they got. @@ -557,4 +569,173 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime) return nil, "no POST transport on this platform" end +local function requestHeaderList(headers) + local out = {} + if type(headers) == "table" then + if #headers > 0 then + for _, line in ipairs(headers) do + if type(line) == "string" then out[#out + 1] = line end + end + else + local names = {} + for name in pairs(headers) do names[#names + 1] = tostring(name) end + table.sort(names) + for _, name in ipairs(names) do + out[#out + 1] = name .. ": " .. tostring(headers[name]) + end + end + end + for _, line in ipairs(out) do + if line:find("[\r\n]") or not line:find(":", 1, true) then return nil end + end + return out +end + +local BRIDGE_METHODS = { GET = true, POST = true, PUT = true, DELETE = true } + +local function requestHeaderPairs(lines) + local out = {} + for _, line in ipairs(lines) do + local name, value = line:match("^%s*([^:]-)%s*:%s*(.-)%s*$") + if not name or name == "" then return nil end + if name:find("[\r\n]") or value:find("[\r\n]") then return nil end + out[#out + 1] = name + out[#out + 1] = value + end + return out +end + +local function bridgeRequest(url, method, headers, body, userAgent) + if not BRIDGE_METHODS[method] then + return nil, "no request transport for " .. method .. " on this platform" + end + local fields = requestHeaderPairs(headers) + if not fields then return nil, "bad request header" end + local ok, envelope = pcall(love.system.httpRequest, url, method, fields, + body, userAgent) + if not ok or type(envelope) ~= "string" or envelope == "" then + return nil, "this app build cannot make signed requests: update the app to use save sync" + end + local head, rest = envelope:match("^([^\n]*)\n(.*)$") + if not head then + return nil, fetchError(url, nil, "unreadable reply from the network bridge") + end + local status = tonumber(head:match("^STATUS (%d+)$")) + if status then return rest or "", nil, status end + return nil, fetchError(url, nil, head:match("^ERROR (.*)$") or head) +end + +local requestSeq = 0 + +local function requestStagingPath(kind) + local dir + if love and love.filesystem and love.filesystem.getSaveDirectory then + local ok, saveDir = pcall(love.filesystem.getSaveDirectory) + if ok and type(saveDir) == "string" and saveDir ~= "" then dir = saveDir end + end + if not dir then + dir = os.getenv("TEMP") or os.getenv("TMP") + if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end + end + local sep = dir:find("\\") and "\\" or "/" + requestSeq = requestSeq + 1 + return dir .. sep .. ("gen1recomp-req-%s-%d-%d-%d.tmp"):format( + kind, os.time() % 1000000, requestSeq, math.random(0, 999999)) +end + +local function writeStagingFile(kind, text) + local path = requestStagingPath(kind) + local file, openErr = io.open(path, "wb") + if not file then + return nil, "could not create the request " .. kind .. ": " .. tostring(openErr) + end + local wrote, writeErr = pcall(function() + assert(file:write(text)) + assert(file:close()) + end) + if not wrote then + pcall(function() file:close() end) + pcall(os.remove, path) + return nil, "could not write the request " .. kind .. ": " .. tostring(writeErr) + end + return path +end + +function HostShell.httpRequest(url, opts) + opts = type(opts) == "table" and opts or {} + if type(url) ~= "string" or url == "" then return nil, "missing url" end + local method = tostring(opts.method or "GET"):upper() + if not method:match("^%u+$") then return nil, "bad request method" end + local headers = requestHeaderList(opts.headers) + if not headers then return nil, "bad request header" end + local body = opts.body + if body ~= nil and type(body) ~= "string" then return nil, "bad request body" end + local userAgent = opts.userAgent or "gen1recomp" + local maxTime = tonumber(opts.maxTime) or 30 + + if not HostShell.haveCurl() then + if haveRequestBridge() then + return bridgeRequest(url, method, headers, body, userAgent) + end + if method == "GET" and #headers == 0 then + local got, err = HostShell.httpGet(url, userAgent, opts.accept, maxTime) + if not got then return nil, err end + return got, nil, 200 + end + if haveBridge() then + return nil, "this app build cannot make signed requests: update the app to use save sync" + end + return nil, "no request transport on this platform" + end + + local bodyPath, stageErr + if body then + bodyPath, stageErr = writeStagingFile("body", body) + if not bodyPath then return nil, stageErr end + end + + local lines = { "User-Agent: " .. userAgent } + for _, line in ipairs(headers) do lines[#lines + 1] = line end + if body then + lines[#lines + 1] = "Content-Length: " .. tostring(#body) + end + local headerPath + headerPath, stageErr = writeStagingFile("head", + table.concat(lines, "\n") .. "\n") + if not headerPath then + if bodyPath then pcall(os.remove, bodyPath) end + return nil, stageErr + end + + local function cleanup() + if bodyPath then pcall(os.remove, bodyPath) end + pcall(os.remove, headerPath) + end + + local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 10 --max-time %d "):format(maxTime) + .. "-X " .. HostShell.quote(method) .. " " + .. "-H " .. HostShell.quote("@" .. headerPath) .. " " + if body then + cmd = cmd .. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " " + end + cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" + + local pipe = HostShell.popen(cmd) + if not pipe then + cleanup() + return nil, "could not run curl" + end + local readOk, out = pcall(function() return pipe:read("*a") end) + HostShell.pclose(pipe) + cleanup() + if not readOk then + return nil, fetchError(url, nil, tostring(out)) + end + local respBody, status, noise = splitCurlOutput(out) + if not status then return nil, fetchError(url, nil, noise) end + return respBody or "", nil, status +end + return HostShell diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index a63db2a4..2b03df44 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -356,6 +356,8 @@ function SaveData.defaultOptions() -- rewind presentation preferences. dateFormat = "device", -- device | dmy | mdy | ymd timeFormat = "device", -- device | 24h | 12h + saveSync = { enabled = false, lastSyncAt = 0, revs = {}, stamps = {}, + pendingConflicts = {} }, } end @@ -1013,6 +1015,22 @@ function SaveData.listSlots(version) return out end +function SaveData.readSlotSource(version, slotId, injectedFs) + version = version or GameVersion.get() + if not knownVersion(version) or type(slotId) ~= "string" then return nil end + local fs = persistFs(injectedFs) + local main, bak, tmp = slotNames(version, slotId) + for _, name in ipairs({ main, tmp, bak }) do + if fs.getInfo(name) then + local body = fs.read(name) + if type(body) == "string" and body ~= "" then + if SaveSerializer.decode(body) then return body end + end + end + end + return nil +end + -- Give a registered slot a custom label (#205: "a way to name save slots so -- you can see that in the launcher"). The label lives in the options -- registry next to list/active, never in the save file itself, so renaming @@ -1327,7 +1345,7 @@ end -- loaded list sorted by id and is the ground truth for the load-time -- mod-set diff. A nil mods list keeps the previous stamp's set so a -- headless writer (the save editor) never wipes it. -function SaveData.buildMeta(mods, previous) +function SaveData.buildMeta(mods, previous, sessionStart) local list if mods ~= nil then list = {} @@ -1338,10 +1356,18 @@ function SaveData.buildMeta(mods, previous) else list = (type(previous) == "table" and previous.mods) or {} end + local started = tonumber(sessionStart) + if not started or started ~= started or started <= 0 + or started == math.huge then + started = type(previous) == "table" and tonumber(previous.sessionStart) or nil + end + local savedAt = os.time() + if started and started > savedAt then started = savedAt end return { format = Version.saveFormat, engine = Version.engine, - savedAt = os.time(), + savedAt = savedAt, + sessionStart = started, playthroughId = type(previous) == "table" and previous.playthroughId or nil, mods = list, } diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index d54a04c0..dd9c679a 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -616,13 +616,15 @@ local function exitControl(self, ctl) for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end end -function skinHitSet(self, x, y) +function skinHitSet(self, x, y, prev) local page = TouchSkin.page() if not page then return nil end local ww, wh, ox, oy = surfaceRect() local set = nil for _, ctl in ipairs(page.controls) do - if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then + local held = (prev and prev[ctl]) == true + if not ctl.decorative + and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then set = set or {} set[ctl] = true end @@ -665,7 +667,7 @@ function TouchControls:touchpressed(id, x, y) return end if TouchSkin.active then - local set = skinHitSet(self, x, y) + local set = skinHitSet(self, x, y, nil) if not set then return end local touch = { control = "skin" } self.touches[id] = touch @@ -698,7 +700,7 @@ function TouchControls:touchmoved(id, x, y) local touch = self.touches[id] if not touch then return end if touch.control == "skin" then - applySkinSet(self, touch, skinHitSet(self, x, y)) + applySkinSet(self, touch, skinHitSet(self, x, y, touch.set)) return end -- only the d-pad tracks movement (slide between directions without diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index 64eddfae..c9aa6080 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -2,6 +2,7 @@ local TouchSkin = {} TouchSkin.BUNDLED_ROOT = "assets/skins" TouchSkin.USER_ROOT = "skins" +TouchSkin.EXPORT_ROOT = "skins/_export" TouchSkin.GB_BUTTONS = { a = "a", b = "b", start = "start", select = "select", @@ -83,6 +84,110 @@ local function parseBinds(spec) return buttons, hotkeys, keys, decorative end +TouchSkin.AREA_DEFAULTS = { + dpad_area = { up = "up", down = "down", left = "left", right = "right" }, + abxy_area = { up = "x", down = "b", left = "y", right = "a" }, + analog_left = { up = "up", down = "down", left = "left", right = "right" }, + analog_right = { up = "up", down = "down", left = "left", right = "right" }, +} + +local DIRECTIONAL_CELLS = { + { col = 1, row = 1, h = "left", v = "up" }, + { col = 2, row = 1, v = "up" }, + { col = 3, row = 1, h = "right", v = "up" }, + { col = 1, row = 2, h = "left" }, + { col = 3, row = 2, h = "right" }, + { col = 1, row = 3, h = "left", v = "down" }, + { col = 2, row = 3, v = "down" }, + { col = 3, row = 3, h = "right", v = "down" }, +} + +local function outwardReach(reach) + return 1 + 3 * ((num(reach, 1)) - 1) +end + +function TouchSkin.expandDirectional(base, names) + names = names or {} + local cellX = math.abs(num(base.rangeX, 0.05)) / 3 + local cellY = math.abs(num(base.rangeY, 0.05)) / 3 + local out = {} + for _, cell in ipairs(DIRECTIONAL_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, + num(base.x, 0.5) + (cell.col - 2) * cellX * 2, + num(base.y, 0.5) + (cell.row - 2) * cellY * 2, + cellX * 2, cellY * 2, "rect") + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1 + ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1 + ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1 + ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1 + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local SECTOR_CELLS = { + { h = "right" }, + { h = "right", v = "down" }, + { v = "down" }, + { h = "left", v = "down" }, + { h = "left" }, + { h = "left", v = "up" }, + { v = "up" }, + { h = "right", v = "up" }, +} + +TouchSkin.SECTOR_SPAN = math.pi / 4 + +function TouchSkin.sectorHit(sector, dx, dy) + local span = TouchSkin.SECTOR_SPAN + local start = (sector - 1) * span - span * 0.5 + local a = (math.atan2(dy, dx) - start) % (math.pi * 2) + return a < span +end + +function TouchSkin.expandSectors(base, names) + names = names or {} + local out = {} + for i, cell in ipairs(SECTOR_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5), + math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2, + base.shape) + ctl.sector = i + ctl.areaKind = base.areaKind + ctl.areaNames = base.areaNames + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = num(base.reachLeft, 1) + ctl.reachRight = num(base.reachRight, 1) + ctl.reachUp = num(base.reachUp, 1) + ctl.reachDown = num(base.reachDown, 1) + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local function areaSide(kv, prefix, side, fallback) + local v = kv[prefix .. "_" .. side] + if v == nil or trim(v) == "" then return fallback end + return trim(v) +end + local function parseDesc(kv, prefix, page) local spec = kv[prefix] if not spec then return nil end @@ -116,9 +221,28 @@ local function parseDesc(kv, prefix, page) imagePath = kv[prefix .. "_overlay"], pressedImagePath = kv[prefix .. "_overlay_pressed"], nextTarget = kv[prefix .. "_next_target"], + movable = toBool(kv[prefix .. "_movable"]) or nil, + exclusive = (toBool(kv[prefix .. "_exclusive"]) + or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil, + saturatePct = num(kv[prefix .. "_saturate_pct"], nil), } if ctl.imagePath == "" then ctl.imagePath = nil end if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end + + local normalized = kv[prefix .. "_normalized"] + if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end + + local areaKind = trim(t[1]):lower() + local defaults = TouchSkin.AREA_DEFAULTS[areaKind] + if defaults then + ctl.areaKind = areaKind + ctl.areaNames = { + up = areaSide(kv, prefix, "up", defaults.up), + down = areaSide(kv, prefix, "down", defaults.down), + left = areaSide(kv, prefix, "left", defaults.left), + right = areaSide(kv, prefix, "right", defaults.right), + } + end return ctl end @@ -127,7 +251,14 @@ function TouchSkin.parse(text) local count = math.floor(num(kv.overlays, 0)) if count <= 0 then return nil, "no overlays" end - local pages = {} + local pages, warnings = {}, {} + local function warn(text) + for _, existing in ipairs(warnings) do + if existing == text then return end + end + warnings[#warnings + 1] = text + end + for i = 0, count - 1 do local p = "overlay" .. i local page = { @@ -166,10 +297,34 @@ function TouchSkin.parse(text) page.viewportExpand = toBool(kv[p .. "_viewport_expand"]) end + page.pixelCoords = not page.normalized + if page.pixelCoords and not page.imagePath then + page.pixelCoords = false + warn(page.name .. " has no base image: desc coordinates read as normalized") + end + local descs = math.floor(num(kv[p .. "_descs"], 0)) for d = 0, descs - 1 do local ctl = parseDesc(kv, p .. "_desc" .. d, page) - if ctl then page.controls[#page.controls + 1] = ctl end + if not ctl then + warn(page.name .. " is missing desc " .. d) + elseif ctl.areaKind then + if ctl.imagePath or ctl.pressedImagePath then + local art = TouchSkin.newControl("nul", ctl.x, ctl.y, + ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) + art.imagePath = ctl.imagePath + art.pressedImagePath = ctl.pressedImagePath + art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod + art.pixelCoords = ctl.pixelCoords + art.movable, art.exclusive = ctl.movable, ctl.exclusive + page.controls[#page.controls + 1] = art + end + for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do + page.controls[#page.controls + 1] = cell + end + else + page.controls[#page.controls + 1] = ctl + end end pages[#pages + 1] = page end @@ -180,7 +335,7 @@ function TouchSkin.parse(text) if not page.orient then page.orient = TouchSkin.pageOrient(page) end end - return { pages = pages } + return { pages = pages, warnings = warnings } end local function readFile(path) @@ -219,18 +374,26 @@ end TouchSkin.NATIVE_NAME = "skin.lua" +TouchSkin.readFile = readFile +TouchSkin.listDir = listDir +TouchSkin.isDir = isDir + local function findConfig(root) if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then - return root .. "/" .. TouchSkin.NATIVE_NAME, "native" + return root .. "/" .. TouchSkin.NATIVE_NAME, "native", "" end local named = { "overlay.cfg", "skin.cfg", "layout.cfg" } for _, name in ipairs(named) do - if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end + if readFile(root .. "/" .. name) then + return root .. "/" .. name, "retroarch", "" + end end + local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root) + if infoPath then return infoPath, "delta", prefix end local items = listDir(root) table.sort(items) for _, name in ipairs(items) do - if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end + if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end end return nil end @@ -262,6 +425,7 @@ function TouchSkin.parseNative(text) imagePath = raw.image, fullScreen = raw.fullScreen ~= false, normalized = true, + pixelCoords = false, rangeMod = num(raw.rangeMod, 1), alphaMod = num(raw.alphaMod, 1), aspect = num(raw.aspect, DEFAULT_ASPECT), @@ -283,7 +447,24 @@ function TouchSkin.parseNative(text) end for _, c in ipairs(raw.controls or {}) do local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") + local sector = tonumber(c.sector) + if sector then + sector = math.floor(sector) + if sector < 1 or sector > #SECTOR_CELLS then sector = nil end + end + local areaNames + if type(c.areaNames) == "table" then + areaNames = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + if type(c.areaNames[side]) == "string" then + areaNames[side] = c.areaNames[side] + end + end + end page.controls[#page.controls + 1] = { + sector = sector, + areaKind = type(c.areaKind) == "string" and c.areaKind or nil, + areaNames = areaNames, spec = tostring(c.bind or "nul"), buttons = buttons, hotkeys = hotkeys, keys = keys, decorative = decorative, @@ -298,6 +479,8 @@ function TouchSkin.parseNative(text) imagePath = c.image, pressedImagePath = c.imagePressed, nextTarget = c.nextTarget, + movable = c.movable == true or nil, + exclusive = c.exclusive == true or nil, } end if not page.orient then page.orient = TouchSkin.pageOrient(page) end @@ -355,6 +538,14 @@ function TouchSkin.toNative(skin) image = ctl.imagePath, imagePressed = ctl.pressedImagePath, nextTarget = ctl.nextTarget, + movable = ctl.movable or nil, + exclusive = ctl.exclusive or nil, + sector = ctl.sector, + areaKind = ctl.areaKind, + areaNames = ctl.areaNames and { + up = ctl.areaNames.up, down = ctl.areaNames.down, + left = ctl.areaNames.left, right = ctl.areaNames.right, + } or nil, } end out.pages[#out.pages + 1] = p @@ -379,14 +570,44 @@ local function loadImage(path) return img end +local function pixelScalePending(page) + if page.pixelCoords then return true end + for _, ctl in ipairs(page.controls or {}) do + if ctl.pixelCoords then return true end + end + return false +end + +local function applyPixelScale(page) + if not pixelScalePending(page) then return true end + if not page.image or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + for _, ctl in ipairs(page.controls or {}) do + local pixel = ctl.pixelCoords + if pixel == nil then pixel = page.pixelCoords end + if pixel then + ctl.x, ctl.y = ctl.x / iw, ctl.y / ih + ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih + ctl.pixelCoords = false + end + end + page.pixelCoords = false + return true +end + function TouchSkin.load(root, id) - local cfgPath, format = findConfig(root) - if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end + local cfgPath, format, prefix = findConfig(root) + if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end local text = readFile(cfgPath) if not text then return nil, "unreadable " .. cfgPath end local skin, err if format == "native" then skin, err = TouchSkin.parseNative(text) + elseif format == "delta" then + local dir = cfgPath:match("^(.*)/[^/]+$") or root + skin, err = require("src.core.DeltaSkin").parse(text, + { prefix = prefix or "", names = listDir(dir) }) else skin, err = TouchSkin.parse(text) end @@ -402,6 +623,10 @@ function TouchSkin.load(root, id) if page.imagePath then page.image = loadImage(joinPath(root, page.imagePath)) end + if not applyPixelScale(page) then + return nil, "could not read " .. tostring(page.imagePath) + .. ", which " .. page.name .. " measures its coordinates against" + end for _, ctl in ipairs(page.controls) do if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end if ctl.pressedImagePath then @@ -418,14 +643,30 @@ local function mountZip(archive, point) return ok and mounted == true end +TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true } +TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } +TouchSkin.PDF_ONLY_MESSAGE = + "This skin uses PDF artwork, which cannot be imported yet. " + .. "Ask the author for a PNG version." + +function TouchSkin.archiveId(name) + name = tostring(name or "") + local ext = name:match("%.([%w]+)$") + if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end + local id = name:sub(1, #name - #ext - 1) + if id == "" then return nil end + return id, ext:lower() +end + function TouchSkin.list() local out, seen = {}, {} local function scan(root, source) for _, name in ipairs(listDir(root)) do - local id = name:gsub("%.zip$", "") - if not seen[id] then + local archiveId = TouchSkin.archiveId(name) + local id = archiveId or name + if not seen[id] and name:sub(1, 1) ~= "_" then local path = root .. "/" .. name - if name:match("%.zip$") then + if archiveId then local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id if mountZip(path, point) and findConfig(point) then seen[id] = true @@ -447,17 +688,20 @@ function TouchSkin.list() return out end --- Drop a .zip into /skins and report the id it will list under. +-- Drop a .zip or .deltaskin into /skins and report the id it lists under. function TouchSkin.installArchive(name, data) if not data or data == "" then return nil, "empty archive" end if not (love and love.filesystem and love.filesystem.write) then return nil, "no writable filesystem" end name = tostring(name or ""):match("([^/\\]+)$") or "" - name = name:gsub("[^%w%._%-]", "_") - if not name:lower():match("%.zip$") then return nil, "not a .zip" end - local id = name:gsub("%.[Zz][Ii][Pp]$", "") - if id == "" then return nil, "bad archive name" end + name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "") + local legacy = name:match("%.([%w]+)$") + if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then + return nil, "old GBA4iOS skin, not supported" + end + local id = TouchSkin.archiveId(name) + if not id then return nil, "not a .zip or .deltaskin" end pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT) local dest = TouchSkin.USER_ROOT .. "/" .. name @@ -467,9 +711,14 @@ function TouchSkin.installArchive(name, data) local entry = TouchSkin.find(id) if not entry then love.filesystem.remove(dest) - return nil, "no skin.lua or .cfg inside " .. name + return nil, "no skin.lua, .cfg or info.json inside " .. name end - return id + local skin = TouchSkin.load(entry.root, entry.id) + if skin and require("src.core.DeltaSkin").needsConversion(skin) then + love.filesystem.remove(dest) + return nil, TouchSkin.PDF_ONLY_MESSAGE + end + return id, skin and skin.warnings or nil end function TouchSkin.find(id) @@ -498,29 +747,13 @@ function TouchSkin.assetPaths(skin) return out end -function TouchSkin.export(skin, destPath) - if not skin then return nil, "no skin" end - local SkinZip = require("src.core.SkinZip") - local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } } - local missing = {} - for _, rel in ipairs(TouchSkin.assetPaths(skin)) do - local data = readFile(joinPath(skin.root, rel)) - if data then - entries[#entries + 1] = { name = rel, data = data } - else - missing[#missing + 1] = rel - end - end - if skin.configPath and skin.format == "retroarch" then - local cfg = readFile(skin.configPath) - if cfg then - entries[#entries + 1] = - { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg } - end - end - local blob = SkinZip.encode(entries) - destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip") +local function writeArchive(entries, destPath) + local blob = require("src.core.SkinZip").encode(entries) 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 + end if not absolute and love and love.filesystem and love.filesystem.write then local ok, err = love.filesystem.write(destPath, blob) if not ok then return nil, tostring(err) end @@ -530,9 +763,167 @@ function TouchSkin.export(skin, destPath) handle:write(blob) handle:close() end + return destPath +end + +local function collectAssets(skin, rels) + local entries, missing = {}, {} + for _, rel in ipairs(rels) do + local data = readFile(joinPath(skin.root, rel)) + if data then + entries[#entries + 1] = { name = rel, data = data } + else + missing[#missing + 1] = rel + end + end + return entries, missing +end + +function TouchSkin.export(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + if skin.configPath and skin.format == "retroarch" then + local cfg = readFile(skin.configPath) + if cfg then + entries[#entries + 1] = + { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg } + end + end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end return destPath, missing end +local function fmtNum(n) + n = tonumber(n) or 0 + if n == math.floor(n) then return string.format("%d", n) end + local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "") + return s +end + +local function fmtRect(r) + return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h)) +end + +local function cfgSpec(spec) + local parts = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = trim(raw) + local key = name:lower():match("^key:(.+)$") + parts[#parts + 1] = key and ("retrok_" .. key) or name + end + return table.concat(parts, "|") +end + +function TouchSkin.toRetroArchConfig(skin) + local pages = (skin and skin.pages) or {} + local out = { "overlays = " .. #pages } + for i, page in ipairs(pages) do + local p = "overlay" .. (i - 1) + out[#out + 1] = "" + out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"' + if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end + out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false") + out[#out + 1] = p .. "_normalized = true" + if num(page.rangeMod, 1) ~= 1 then + out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod) + end + if num(page.alphaMod, 1) ~= 1 then + out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod) + end + if page.aspectFromCfg and page.aspect and page.aspect > 0 then + out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect) + end + local r = page.rect + if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then + out[#out + 1] = p .. "_rect = " .. fmtRect(r) + end + if page.viewport then + out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport) + if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end + if page.viewportExpand then out[#out + 1] = p .. "_viewport_expand = true" end + end + local controls = {} + for _, ctl in ipairs(page.controls or {}) do + if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end + end + out[#out + 1] = p .. "_descs = " .. #controls + for j, ctl in ipairs(controls) do + local d = p .. "_desc" .. (j - 1) + local spec = ctl.areaKind and ctl.sector and ctl.areaKind + or cfgSpec(ctl.spec) + if spec == "" then spec = "nul" end + out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec, + fmtNum(ctl.x), fmtNum(ctl.y), + ctl.shape == "radial" and "radial" or "rect", + fmtNum(ctl.rangeX), fmtNum(ctl.rangeY)) + if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end + if ctl.pressedImagePath then + out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath + end + if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then + out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod) + end + if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then + out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod) + end + for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown, + left = ctl.reachLeft, right = ctl.reachRight }) do + if num(value, 1) ~= 1 then + out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value) + end + end + if ctl.movable then out[#out + 1] = d .. "_movable = true" end + if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end + if ctl.nextTarget then + out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"' + end + if ctl.areaKind and ctl.sector and ctl.areaNames then + local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local name = ctl.areaNames[side] + if name and name ~= defaults[side] then + out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"' + end + end + end + end + end + return table.concat(out, "\n") .. "\n" +end + +function TouchSkin.exportRetroArch(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing +end + +function TouchSkin.exportDelta(skin, opts) + if not skin then return nil, "no skin" end + opts = opts or {} + local DeltaSkin = require("src.core.DeltaSkin") + local info, assetRels, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assetRels end + local entries = { + { name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) }, + } + local assets, missing = collectAssets(skin, assetRels) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + local destPath = opts.path + or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing, warnings +end + TouchSkin.BINDS = { "nul", "up", "down", "left", "right", @@ -597,6 +988,7 @@ function TouchSkin.clone(skin) id = skin.id, name = skin.name, root = skin.root, format = skin.format, author = skin.author, notes = skin.notes, configPath = skin.configPath, source = skin.source, pages = {}, + warnings = skin.warnings and copyTable(skin.warnings) or nil, } for i, page in ipairs(skin.pages or {}) do local p = copyTable(page) @@ -676,7 +1068,8 @@ function TouchSkin.listImages(root) local function scan(dir, prefix) for _, name in ipairs(listDir(dir)) do local path = dir .. "/" .. name - if name:lower():match("%.png$") or name:lower():match("%.jpg$") then + local lower = name:lower() + if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then out[#out + 1] = prefix .. name elseif isDir(path) and prefix == "" then scan(path, name .. "/") @@ -872,17 +1265,21 @@ function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) return cx, cy, halfW, halfH end -function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy) +function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held) local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) - local left = halfW * ctl.reachLeft * ctl.rangeMod - local right = halfW * ctl.reachRight * ctl.rangeMod - local up = halfH * ctl.reachUp * ctl.rangeMod - local down = halfH * ctl.reachDown * ctl.rangeMod + local mod = held == false and 1 or ctl.rangeMod + local left = halfW * ctl.reachLeft * mod + local right = halfW * ctl.reachRight * mod + local up = halfH * ctl.reachUp * mod + local down = halfH * ctl.reachDown * mod local dx = px - cx local dy = py - cy local rx = dx < 0 and left or right local ry = dy < 0 and up or down if rx <= 0 or ry <= 0 then return false end + if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then + return false + end if ctl.shape == "radial" then return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1 end diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index ee2ee4f0..29d7d661 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -301,6 +301,13 @@ end -- gear edit these before the game starts (src/import/LauncherSettings.lua). Save.OPTIONS_KEY = "gold" +local SHARED_KEYS = { + touchControls = true, haptics = true, + mods = true, modsByVersion = true, modsGen2 = true, + modOptions = true, modProfiles = true, modProfilesSeeded = true, + activeProfile = true, +} + function Save.loadOptions(fs) local options = Save.defaultOptions() local ok, SaveData = pcall(require, "src.core.SaveData") @@ -308,7 +315,21 @@ function Save.loadOptions(fs) local loaded = SaveData.loadOptions(fs) local stored = loaded and loaded[Save.OPTIONS_KEY] if type(stored) == "table" then - for key, value in pairs(stored) do options[key] = value end + for key, value in pairs(stored) do + if not SHARED_KEYS[key] then options[key] = value end + end + end + if type(loaded) == "table" then + for key in pairs(SHARED_KEYS) do + if loaded[key] ~= nil then options[key] = loaded[key] end + end + end + if type(stored) == "table" then + for key in pairs(SHARED_KEYS) do + if options[key] == nil and stored[key] ~= nil then + options[key] = stored[key] + end + end end return options end @@ -321,7 +342,13 @@ function Save.saveOptions(options, fs) if not ok then return false end local file = SaveData.loadOptions(fs) or {} local block = {} - for key, value in pairs(options) do block[key] = value end + for key, value in pairs(options) do + if SHARED_KEYS[key] then + file[key] = value + else + block[key] = value + end + end file[Save.OPTIONS_KEY] = block SaveData.saveOptions(file, fs) return true diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index cd41d034..a2f56efe 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -48,6 +48,14 @@ local TAP_SLOP2 = 16 * 16 -- Installed mods should not turn into a one- or two-item pager on a compact -- display. Keep a useful page size, then let the list viewport scroll. local MIN_MODS_PER_PAGE = 10 +local MIN_SKIN_ROWS = 4 +local SKIN_FORMAT_LABEL = { + native = "GEN1", + retroarch = "RETROARCH", + delta = "DELTA", +} +local MIN_FIND_ROWS = 3 +local PANEL_OVERSCAN = 0.75 local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end @@ -56,6 +64,32 @@ local function inRect(rect, x, y) and y >= rect.y and y <= rect.y + rect.h end +local function tabKeyOf(imp) return imp.tab or "red" end + +local function tabScrollMax(imp) + local t = imp._tabScrollMax + return (t and t[tabKeyOf(imp)]) or 0 +end + +local function tabScrollAt(imp) + local t = imp._tabScroll + return clamp((t and t[tabKeyOf(imp)]) or 0, 0, tabScrollMax(imp)) +end + +local function setTabScroll(imp, value) + imp._tabScroll = imp._tabScroll or {} + imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp)) +end + +local function modListWantsWheel(imp, wheel) + if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end + if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end + if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end + local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax) + if wheel < 0 then return at < imp._modScrollMax end + return at > 0 +end + -- ------------------------------------------------------------- lifecycle local function ensureState(imp) @@ -65,6 +99,9 @@ local function ensureState(imp) imp._actAt = imp._actAt or {} imp._uiActions = imp._uiActions or {} imp._pages = imp._pages or {} + imp._tabScroll = imp._tabScroll or {} + imp._tabScrollMax = imp._tabScrollMax or {} + imp._tabContentH = imp._tabContentH or {} -- Held backspace/arrows must repeat in the text fields; restored on -- detach because the game's Input does its own per-step edge detection -- and never expects repeated keypressed events. @@ -155,7 +192,10 @@ function LauncherView.touchpressed(imp, id, x, y) imp._touchAt = imp._touchAt or {} imp._touchAt[tostring(id)] = { x = x, y = y, - modsList = (imp._modScrollMax or 0) > 0 and inRect(imp._modListRect, x, y), + modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0 + and inRect(imp._modListRect, x, y) + and inRect(imp._tabRegionRect, x, y), + region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y), } end @@ -170,13 +210,25 @@ function LauncherView.touchmoved(imp, id, x, y) -- A drag that began in the installed-mod viewport scrolls that page's -- rows. Its pager remains available for moving to the next ten-plus -- entries; a drag elsewhere keeps the normal short-window page scroll. - if start.dragged and start.modsList then + if start.dragged then local last = start.lastY or start.y - imp.modScroll = clamp((imp.modScroll or 0) - (y - last), 0, - imp._modScrollMax or 0) - elseif start.dragged and (imp._pageScrollMax or 0) > 0 then - local last = start.lastY or start.y - imp._pageScroll = (imp._pageScroll or 0) - (y - last) + local move = -(y - last) + if start.modsList then + local listMax = imp._modScrollMax or 0 + local at, leftover = Kit.scrollHandoff( + clamp(imp.modScroll or 0, 0, listMax), listMax, move) + imp.modScroll = at + move = leftover + end + if move ~= 0 and start.region then + local at, leftover = Kit.scrollHandoff(tabScrollAt(imp), + tabScrollMax(imp), move) + setTabScroll(imp, at) + move = leftover + end + if move ~= 0 and (imp._pageScrollMax or 0) > 0 then + imp._pageScroll = (imp._pageScroll or 0) + move + end end start.lastY = y end @@ -796,6 +848,34 @@ local function drawSkinGlyph(x, y, w, h, hot) Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r) end +local function drawSyncGlyph(x, y, w, h, hot) + local box = math.min(w, h) + local bx = x + (w - box) / 2 + local by = y + (h - box) / 2 + local pad = box * 0.24 + local ink = hot and PAL.inverse or PAL.ink + local left, right = bx + pad, bx + box - pad + local head = box * 0.15 + local bar = math.max(1, box * 0.09) + local topY, botY = by + box * 0.34, by + box * 0.58 + Theme.fill(left, topY, math.max(0, right - left - head * 0.5), bar, ink, 1) + Theme.fill(left + head * 0.5, botY, math.max(0, right - left - head * 0.5), + bar, ink, 1) + if love.graphics.line then + love.graphics.push("all") + Theme.col(ink, 1) + if love.graphics.setLineWidth then + love.graphics.setLineWidth(math.max(1.5, bar)) + end + local ty, byy = topY + bar / 2, botY + bar / 2 + love.graphics.line(right - head, ty - head, right, ty, right - head, + ty + head) + love.graphics.line(left + head, byy - head, left, byy, left + head, + byy + head) + love.graphics.pop() + end +end + local function drawCross(x, y, size, color) love.graphics.push("all") love.graphics.setColor(color) @@ -885,6 +965,8 @@ local function headerChrome(imp) hot and QUIT_INK_HOT or QUIT_INK_REST) end }, tab = {}, + sync = { face = "tab", drawFn = drawSyncGlyph, + action = function() imp:_openSync() end }, game = { face = "tab", font = "tab", action = function() local g = currentGame(imp) @@ -1050,6 +1132,27 @@ local function buildHeader(imp, m) tx = tx + w + tabGap end + do + local w = tabH + if tx > tabLeft and tx + w > tabRight then + tx = tabLeft + ty = ty + tabH + tabRowGap + end + local o = chrome.sync + o.active = imp._syncModal ~= nil + btn(imp, tx, ty, w, tabH, "tab-sync", "", o) + local bh = math.floor(11 * m.s) + local bw = math.min(w, Kit.textWidth("micro", "BETA") + math.floor(10 * m.s)) + Kit.tag(tx + (w - bw) / 2, ty + tabH - bh - math.floor(2 * m.s), bw, bh, + "BETA", o.active and PAL.inverse or PAL.yellow) + local eng = imp._sync + if eng and eng.busy and eng:busy() then + Kit.spinner(tx + w - math.floor(8 * m.s), ty + math.floor(8 * m.s), + math.max(2, math.floor(4 * m.s))) + end + tx = tx + w + tabGap + end + -- `ty` has walked down with the wraps, so this stays correct at one row too. y = ty + tabH + math.floor(8 * m.s) Theme.fill(m.x, y, m.w, 1, PAL.line, Theme.A.hairline) @@ -1481,7 +1584,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready) return h end -local function buildGamePanel(imp, x, y, w, availH, m, version) +local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH) imp.panelVersion = version local info = GameVersion.info(version) local locked = info == nil @@ -1527,12 +1630,15 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) local afterTitle = math.floor((ready and 22 or 12) * m.s) local cy = y + titleH + afterTitle local remaining = availH - (titleH + afterTitle) + local budgetLeft = math.max(remaining, + (budgetH or availH) - (titleH + afterTitle)) local gap = m.gap local lx, lw, rx2, rw if m.twoCol then - lx, lw = x, m.colW - rx2, rw = x + m.colW + m.colGap, m.colW + local colW = math.floor((w - m.colGap) / 2) + lx, lw = x, colW + rx2, rw = x + colW + m.colGap, colW else lx, lw, rx2, rw = x, w, x, w end @@ -1579,15 +1685,19 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) -- Save slots. Two columns put them beside the left stack; ONE column -- stacks them underneath. Either way the card is clipped to the room it -- actually has, and sizes its own list to that budget. + local bottom = ly if not locked then local slotY = m.twoCol and cy or ly - local slotAvail = m.twoCol and remaining or (cy + remaining - ly) + local slotAvail = m.twoCol and budgetLeft or (cy + budgetLeft - ly) if slotAvail > 80 * m.s then Kit.pushClip(rx2, slotY, rw, math.max(0, slotAvail)) - buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, ready) + local slotH = buildSlotCard(imp, rx2, slotY, rw, slotAvail, m, version, + ready) Kit.popClip() + bottom = math.max(bottom, slotY + math.min(slotH or 0, slotAvail)) end end + return bottom - y end -- --------------------------------------------------------------- mods panel @@ -1821,7 +1931,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) if #mods == 0 then imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint()) - return + return (cy - y) + math.floor(110 * m.s) end local sortKey = currentSort(imp, "mods") @@ -1882,7 +1992,8 @@ local function buildModsPanel(imp, x, y, w, availH, m) if not lr then lr = {}; imp._modListRect = lr end lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH imp._modScrollMax = scrollMax - if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(x, listTop, w, listH) then + if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks + and Kit.hit(x, listTop, w, listH) then scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax) Kit.wheelY = 0 elseif scrollMax == 0 then @@ -2008,9 +2119,10 @@ local function buildModsPanel(imp, x, y, w, availH, m) Kit.popClip() local pagerY = listTop + listH + gap - local newPage = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods") + local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods") if newPage ~= cur then imp.modScroll = 0 end setPage(imp, "mods", newPage) + return pagerY + newPagerH - y end -- ---------------------------------------------------------- find mods panel @@ -2044,6 +2156,32 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s) end + local urlH = m.btnH + local addLabel = Strings("Add") + local addW = Kit.textWidth("small", addLabel) + math.floor(24 * m.s) + local pasteLabel = Strings("Paste") + local pasteW = Kit.textWidth("small", pasteLabel) + math.floor(20 * m.s) + if imp._skinFetch then + Loader.inline(x, cy, w, urlH, + Strings("Downloading %s...", tostring(imp._skinFetch.name or ""))) + else + local urlPlace = Layout.rightCluster(x, w, math.floor(6 * m.s)) + btn(imp, urlPlace(addW), cy, addW, urlH, "skins-url-add", addLabel, { + kind = "accent", font = "small", + action = function() imp:_addSkinFromUrl() end }) + if w - addW - pasteW > math.floor(140 * m.s) then + btn(imp, urlPlace(pasteW), cy, pasteW, urlH, "skins-url-paste", + pasteLabel, { + font = "small", action = function() imp:_pasteSkinUrl() end }) + end + local fieldW = math.max(0, urlPlace(0) - x - math.floor(6 * m.s)) + textField(imp, x, cy, fieldW, urlH, "skins-url", imp.skinUrl or "", + Strings("Paste a skin link (.zip, .cfg, .deltaskin)"), + imp._skinUrlFocus == true, + function() imp:_toggleSkinUrlFocus() end) + end + cy = cy + urlH + math.floor(8 * m.s) + -- Studio button. Desktop only: the host supplies the hook nowhere else. if imp.onOpenSkinStudio then local label = Strings("Open Skin Studio") @@ -2071,7 +2209,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) -- The row itself is "use this skin"; the gear beside it configures that -- entry -- the built-in pad opens the drag-a-button layout editor, a skin -- opens the studio, so neither lands on a screen that cannot edit it. - local function skinRow(key, id, title, detail, selected, configure) + local function skinRow(key, id, title, detail, selected, configure, format) local gearW = configure and rowH or 0 local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0) local ink = rowHit(imp, x, cy, rowW, rowH, selected, key, @@ -2079,7 +2217,17 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) local tagW = selected and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s)) or math.floor(12 * m.s) - local textW = rowW - math.floor(24 * m.s) - tagW + local badge = format and SKIN_FORMAT_LABEL[format] or nil + local badgeW = 0 + if badge then + badgeW = Kit.textWidth("micro", badge) + math.floor(16 * m.s) + local badgeH = math.floor(16 * m.s) + Kit.tag(x + rowW - tagW - badgeW - math.floor(12 * m.s), + cy + (rowH - badgeH) / 2, badgeW, badgeH, badge, + format == "native" and PAL.green or PAL.blue) + badgeW = badgeW + math.floor(10 * m.s) + end + local textW = math.max(0, rowW - math.floor(24 * m.s) - tagW - badgeW) local tx = x + math.floor(12 * m.s) local ty = cy + math.floor(7 * m.s) Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty, @@ -2102,7 +2250,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) local TouchSkin = require("src.core.TouchSkin") local hint = Strings( - "You can also drop a skin .zip on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files work as-is.", + "You can also drop a skin .zip or .deltaskin on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files and Delta skins work as-is.", TouchSkin.USER_ROOT) local hintH = Kit.wrapHeight("small", hint, w, 3) local importH = math.floor(10 * m.s) + hintH @@ -2111,9 +2259,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local listTop = cy local listH = availH - (cy - y) - importH - local perPage = Kit.rowsThatFit(listH, rowH, rowGap, 1, 20) + local perPage = Kit.rowsThatFit(listH, rowH, rowGap, MIN_SKIN_ROWS, 20) if #entries > perPage then - perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20) + perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, + MIN_SKIN_ROWS, 20) end local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"), #entries, perPage) @@ -2142,11 +2291,10 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) bits[#bits + 1] = entry.pages .. " " .. Strings("pages") end if entry.screen then bits[#bits + 1] = Strings("screen cutout") end - local configure = imp.onOpenSkinStudio and function() - imp.onOpenSkinStudio(imp.modScope or "red", entry.id) - end or nil + local configure = function() imp._skinActions = { id = entry.id } end skinRow("skin-" .. entry.id, entry.id, entry.id, - table.concat(bits, " \194\183 "), active == entry.id, configure) + table.concat(bits, " \194\183 "), active == entry.id, configure, + entry.format) end end @@ -2164,6 +2312,7 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) cy = cy + math.floor(10 * m.s) Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3) + return cy + hintH - y end local function buildFindPanel(imp, x, y, w, availH, m) @@ -2201,7 +2350,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) aw, m.btnH, "find-add", Strings("Add an index"), { kind = "accent", font = "small", action = function() imp._indexManage = true end }) - return + return (cy - y) + h end -- One row: the search field, then Filter / Sort / Indexes popup buttons. @@ -2232,7 +2381,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) Kit.emptyBox(x, cy, w, math.floor(110 * m.s), (total == 0) and Strings("This index lists no mods yet.") or Strings("No mods match that search.")) - return + return (cy - y) + math.floor(110 * m.s) end local sortKey = currentSort(imp, "find") @@ -2283,7 +2432,7 @@ local function buildFindPanel(imp, x, y, w, availH, m) + math.floor(8 * m.s) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local listH = availH - (cy - y) - pagerH - gap - local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 20) + local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_FIND_ROWS, 20) local first, last, cur, pages = Kit.pageBounds(page(imp, "find"), #rows, perPage) setPage(imp, "find", cur) local listTop = cy @@ -2384,7 +2533,10 @@ local function buildFindPanel(imp, x, y, w, availH, m) end local pagerY = listTop + (last - first + 1) * (rowH + gap) - setPage(imp, "find", Kit.pager(x, pagerY, w, cur, #rows, perPage, "find")) + local findPage, findPagerH = Kit.pager(x, pagerY, w, cur, #rows, perPage, + "find") + setPage(imp, "find", findPage) + local bottom = pagerY + findPagerH -- Aggregate progress. Enrichment happens a page at a time and each row says -- so for itself, but with nothing summarising it the panel looked idle while @@ -2398,7 +2550,9 @@ local function buildFindPanel(imp, x, y, w, availH, m) Kit.text("micro", Strings("Checking %d of %d on this page...", waiting, last - first + 1), x + dh + math.floor(6 * m.s), py, PAL.muted) + bottom = math.max(bottom, py + dh) end + return bottom - y end -- ------------------------------------------------------------------ footer @@ -3161,6 +3315,75 @@ end -- Per-mod actions for the MODS tab: the row itself only carries the enable -- toggle, everything episodic (update check, versions, delete) lives here. +local SKIN_EXPORTS = { + { id = "native", key = "skinact-exp-native", label = "Export as gen1recomp .zip" }, + { id = "retroarch", key = "skinact-exp-ra", label = "Export as RetroArch .zip" }, + { id = "delta", key = "skinact-exp-delta", label = "Export as Delta .deltaskin" }, +} + +local function buildSkinActionsModal(imp, m) + local id = imp._skinActions and imp._skinActions.id + if not id then imp._skinActions = nil return end + local entry + for _, e in ipairs(imp:_ensureSkins()) do + if e.id == id then entry = e break end + end + if not entry then imp._skinActions = nil return end + local pad = math.floor(18 * m.s) + local gap = math.floor(8 * m.s) + local rows = #SKIN_EXPORTS + 2 + (imp.onOpenSkinStudio and 1 or 0) + + (imp._skinExport and imp._skinExport.dir and 1 or 0) + local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + math.floor(12 * m.s) + + rows * (m.btnH + gap) - gap + pad + local px, py, pw = modalPanel(m, math.floor(440 * m.s), h) + local cy = py + pad + Kit.text("button", Kit.ellipsize("button", entry.id, pw - 2 * pad), + px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + local fmt = SKIN_FORMAT_LABEL[entry.format or ""] or Strings("unknown format") + Kit.text("small", Kit.ellipsize("small", + fmt .. " \194\183 " .. entry.pages .. " " .. Strings("pages") + .. " \194\183 " .. entry.controls .. " " .. Strings("buttons"), + pw - 2 * pad), px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(12 * m.s) + + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-use", + Strings("Use this skin"), { kind = "primary", font = "small", + action = function() + imp:_useSkin(id) + imp._skinActions = nil + end }) + cy = cy + m.btnH + gap + if imp.onOpenSkinStudio then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-edit", + Strings("Open in Skin Studio"), { kind = "accent", font = "small", + action = function() + imp._skinActions = nil + imp.onOpenSkinStudio(imp.modScope or "red", id) + end }) + cy = cy + m.btnH + gap + end + for _, spec in ipairs(SKIN_EXPORTS) do + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, spec.key, Strings(spec.label), { + font = "small", + action = function() + imp:_exportSkin(id, spec.id) + imp._skinActions = nil + end }) + cy = cy + m.btnH + gap + end + if imp._skinExport and imp._skinExport.dir then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-reveal", + Strings("Show the exported file"), { font = "small", + action = function() imp:_revealSkinExport() end }) + cy = cy + m.btnH + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "skinact-close", + Strings("Close"), { font = "small", + action = function() imp._skinActions = nil end }) +end + local function buildModActionsModal(imp, m) local mod for _, mm in ipairs(imp.mods or {}) do @@ -3907,6 +4130,335 @@ local function buildDepResolverModal(imp, m) end end +local SYNC_HINT = "Save sync keeps your saves and your mod list on our server so another device can pick them up. It is brand new, so keep your own backups too." + +local function syncTitle(imp, m, px, py, pw, pad) + local label = Strings("SAVE SYNC") + Kit.text("button", label, px + pad, py, PAL.heading) + local bh = math.floor(15 * m.s) + local bw = Kit.textWidth("micro", "BETA") + math.floor(14 * m.s) + Kit.tag(px + pad + Kit.textWidth("button", label) + math.floor(8 * m.s), + py + (Kit.textHeight("button") - bh) / 2, bw, bh, "BETA", PAL.yellow) + return py + Kit.textHeight("button") + math.floor(12 * m.s) +end + +local function syncStatus(imp, m, x, y, w, eng) + if eng:busy() then + Loader.inline(x, y, w, m.btnH, eng.status) + return m.btnH + math.floor(8 * m.s) + end + Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y, + eng.phase == "error" and PAL.red or PAL.muted) + return Kit.textHeight("small") + math.floor(10 * m.s) +end + +local function syncRow(imp, m, x, y, w, key, label, opts) + opts = opts or {} + opts.font = "small" + btn(imp, x, y, w, m.btnH, key, label, opts) + return y + m.btnH + math.floor(8 * m.s) +end + +function LauncherView.syncSideText(meta) + meta = type(meta) == "table" and meta or {} + local summary = type(meta.summary) == "table" and meta.summary or {} + local bits = {} + if type(summary.name) == "string" and summary.name ~= "" then + bits[#bits + 1] = summary.name + end + if tonumber(summary.badges) then + bits[#bits + 1] = tostring(math.floor(summary.badges)) .. " " + .. Strings("badges") + end + if type(summary.timeText) == "string" and summary.timeText ~= "" then + bits[#bits + 1] = summary.timeText + end + if tonumber(summary.dexCount) then + bits[#bits + 1] = tostring(math.floor(summary.dexCount)) .. " " + .. Strings("seen") + end + local when = tonumber(meta.savedAt) + if when then + bits[#bits + 1] = Strings("saved") .. " " .. os.date("%Y-%m-%d %H:%M", when) + end + if #bits == 0 then return Strings("no details") end + return table.concat(bits, " \194\183 ") +end + +local function buildSyncConflict(imp, m, eng) + local row = eng.conflicts[1] + local pad = math.floor(18 * m.s) + local w = math.floor(520 * m.s) + local innerW = w - 2 * pad + local lead = row.overlap + and Strings("These saves were played at the same time.") + or Strings("This save also changed on another device.") + local leadH = Kit.wrapHeight("small", lead, innerW, 2) + local sideH = Kit.textHeight("small") + math.floor(2 * m.s) + + Kit.wrapHeight("micro", "x", innerW, 2) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH + + math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s)) + + 4 * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad, + PAL.detail, 2) + math.floor(10 * m.s) + + local function side(title, meta) + Kit.text("small", title, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("small") + math.floor(2 * m.s) + cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta), + px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s) + end + side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"), + row.localMeta) + side(Strings("Other device"), row.remoteMeta) + + local key = row.key + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this", + Strings("Keep this device"), { kind = "primary", + action = function() imp:_syncResolve(key, "local") end }) + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other", + Strings("Keep the other device"), { kind = "accent", + action = function() imp:_syncResolve(key, "remote") end }) + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both", + Strings("Keep both"), { + action = function() imp:_syncResolve(key, "both") end }) + syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close", + Strings("Close"), { action = function() imp:_closeSync() end }) +end + +local function buildSyncLink(imp, m, eng) + local mo = imp._syncModal + local pad = math.floor(18 * m.s) + local w = math.floor(460 * m.s) + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local hint = Strings("Enter the two codes the other device is showing.") + local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH + + math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s)) + + Kit.textHeight("small") + math.floor(10 * m.s) + + 2 * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad, + PAL.detail, 2) + math.floor(10 * m.s) + textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1", + mo.code1 or "", Strings("First code"), imp._syncFocus == "code1", + function() imp:_syncFocusField("code1") end) + cy = cy + fieldH + math.floor(8 * m.s) + textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2", + mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2", + function() imp:_syncFocusField("code2") end) + cy = cy + fieldH + math.floor(8 * m.s) + cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng) + cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go", + Strings("Link this device"), { kind = "primary", enabled = not eng:busy(), + action = function() imp:_syncLink() end }) + syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back", + Strings("Back"), { action = function() imp:_syncView("home") end }) +end + +local function buildSyncMods(imp, m, eng) + local mo = imp._syncModal + local pad = math.floor(18 * m.s) + local w = math.floor(500 * m.s) + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local plan = eng.modPlan + local rows = 4 + (plan and 1 or 0) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + 3 * (Kit.textHeight("small") + math.floor(8 * m.s)) + + fieldH + math.floor(8 * m.s) + + rows * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + local innerW = pw - 2 * pad + + if eng.shareCode then + Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(4 * m.s) + Kit.text("stat", eng.shareCode, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("stat") + math.floor(4 * m.s) + Kit.text("micro", Kit.ellipsize("micro", + Strings("Enter this code in Save Sync > Get mod list"), innerW), + px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) + end + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods", + Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(), + action = function() imp:_syncShareMods() end }) + + textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code", + mo.share or "", Strings("Paste a 6-character mod code"), + imp._syncFocus == "share", function() imp:_syncFocusField("share") end) + cy = cy + fieldH + math.floor(8 * m.s) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods", + Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(), + action = function() imp:_syncGetShare() end }) + + if plan then + local line = Strings("%d mods, %d indexes to add", + #(plan.toInstall or {}) + #(plan.toEnable or {}), #(plan.indexes or {})) + if #(plan.missing or {}) > 0 then + line = line .. " \194\183 " .. Strings("%d not in your indexes", + #plan.missing) + end + Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy, + PAL.detail) + cy = cy + Kit.textHeight("small") + math.floor(8 * m.s) + local prog = mo.progress + if prog then + Loader.inline(px + pad, cy, innerW, m.btnH, + Strings("%d of %d", prog.done or 0, prog.total or 0)) + cy = cy + m.btnH + math.floor(8 * m.s) + else + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods", + Strings("Apply these mods"), { kind = "primary", + enabled = not eng:busy(), + action = function() imp:_syncApplyMods() end }) + end + end + cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) + syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"), + { action = function() imp:_syncView("home") end }) +end + +function LauncherView.syncDeviceRows(eng, limit) + local out = {} + if not eng or type(eng.devices) ~= "table" then return out end + for _, row in ipairs(eng.devices) do + if #out >= (limit or 6) then break end + if type(row) == "table" and type(row.id) == "string" then + out[#out + 1] = { + id = row.id, + current = row.current == true, + label = type(row.label) == "string" and row.label ~= "" and row.label + or "device", + } + end + end + return out +end + +local function buildSyncHome(imp, m, eng) + local pad = math.floor(18 * m.s) + local w = math.floor(460 * m.s) + local linked = eng:linked() + local codes = eng.codes + local body = linked + and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.") + or Strings(SYNC_HINT) + local innerW = w - 2 * pad + local hintH = Kit.wrapHeight("small", body, innerW, 5) + local codesH = codes + and (Kit.textHeight("small") + math.floor(6 * m.s) + + 2 * (Kit.textHeight("title") + math.floor(4 * m.s)) + + math.floor(8 * m.s)) or 0 + local devices = linked and LauncherView.syncDeviceRows(eng) or {} + local devicesH = #devices > 0 + and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0 + local rows = (linked and 5 or 3) + #devices + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH + + math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s) + + rows * (m.btnH + math.floor(8 * m.s)) + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5) + + math.floor(10 * m.s) + + if codes then + Kit.text("small", Strings("Enter these on your other device:"), px + pad, + cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + Kit.text("title", codes.code1, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("title") + math.floor(4 * m.s) + Kit.text("title", codes.code2, px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("title") + math.floor(8 * m.s) + end + cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) + + if #devices > 0 then + Kit.text("small", Strings("Devices on this account:"), px + pad, cy, + PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) + for i, device in ipairs(devices) do + local id = device.id + if device.current then + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, + device.label .. " \194\183 " .. Strings("this device"), + { enabled = false }) + else + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, + Strings("Unlink %s", device.label), { kind = "danger", + enabled = not eng:busy(), + action = function() imp:_syncUnlinkDevice(id) end }) + end + end + end + + if linked then + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"), + { kind = "primary", enabled = not eng:busy(), + action = function() imp:_syncNow() end }) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods", + Strings("Share or get a mod list"), { kind = "accent", + action = function() imp:_syncView("mods") end }) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink", + Strings("Unlink this device"), { kind = "danger", + action = function() imp:_syncUnlink() end }) + else + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create", + Strings("Create sync account"), { kind = "primary", + enabled = not eng:busy(), + action = function() imp:_syncCreate() end }) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link", + Strings("Link this device"), { kind = "accent", + action = function() imp:_syncView("link") end }) + end + syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"), + { action = function() imp:_closeSync() end }) +end + +local function buildSyncUnavailable(imp, m, msg) + local pad = math.floor(18 * m.s) + local w = math.floor(420 * m.s) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s) + + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad, + PAL.detail, 4) + math.floor(10 * m.s) + syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close", + Strings("Close"), { action = function() imp:_closeSync() end }) +end + +local function buildSyncModal(imp, m) + if not imp:_syncSupported() then + buildSyncUnavailable(imp, m, Strings( + "Save sync cannot run on this build: it has no way to send the signed requests it needs. Update to the latest app build, or use a desktop build.")) + return + end + local eng = imp._sync + if not eng then + buildSyncUnavailable(imp, m, + Strings("Save sync is not available in this build.")) + return + end + if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then + buildSyncConflict(imp, m, eng) + return + end + local view = imp._syncModal and imp._syncModal.view or "home" + if view == "link" then + buildSyncLink(imp, m, eng) + elseif view == "mods" then + buildSyncMods(imp, m, eng) + else + buildSyncHome(imp, m, eng) + end +end + -- Whether ANY modal will draw this frame. draw() consults this BEFORE the -- panels build: immediate mode hit-tests each control as it draws, so the -- panels underneath a modal must run with Kit.blockClicks already raised or @@ -3919,7 +4471,7 @@ local function modalUp(imp) or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._gamePopup - or imp._modActions or imp._modImports + or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil end @@ -4064,6 +4616,8 @@ local function buildModals(imp, m) if imp._modScopePopup then buildModScopeModal(imp, m) return true end if imp._filterPopup then buildFilterModal(imp, m) return true end if imp._indexManage then buildIndexesModal(imp, m) return true end + if imp._syncModal then buildSyncModal(imp, m) return true end + if imp._skinActions then buildSkinActionsModal(imp, m) return true end if imp._modActions then buildModActionsModal(imp, m) return true end if imp._findEntry then buildFindEntryModal(imp, m) return true end if imp._gameManage then buildGameManageModal(imp, m) return true end @@ -4173,13 +4727,6 @@ function LauncherView.draw(imp) local footH = footerHeight(imp, m) local naturalAvail = m.h - headerHeight(m) - footH - m.gap local scrollMax = math.max(0, minPanelHeight(m) - naturalAvail) - local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax)) - if scrollMax > 0 and (imp._wheelY or 0) ~= 0 then - scroll = math.max(0, math.min( - scroll - imp._wheelY * math.floor(48 * m.s), scrollMax)) - imp._wheelY = 0 -- the page consumed the wheel; lists page by tap here - end - imp._pageScroll, imp._pageScrollMax = scroll, scrollMax Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) imp._clickPt = nil @@ -4192,6 +4739,35 @@ function LauncherView.draw(imp) -- one is up; buildModals lowers the shield for the modal's own controls. Kit.blockClicks = modalUp(imp) + local step = Kit.scrollStep(m.s) + local nested = modListWantsWheel(imp, Kit.wheelY or 0) + if not nested then + local rect = imp._tabRegionRect + if rect then + setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp), + rect.x, rect.y, rect.w, rect.h, step))) + end + end + local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax)) + if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested + and not Kit.blockClicks then + local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax)) + if moved ~= scroll then + scroll = moved + Kit.wheelY = 0 + end + end + imp._pageScroll, imp._pageScrollMax = scroll, scrollMax + if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks + and tabScrollMax(imp) > 0 then + local was = tabScrollAt(imp) + local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp)) + if to ~= was then + setTabScroll(imp, to) + Kit.wheelY = 0 + end + end + -- The header is the only block that moves with the page scroll, so shift -- m.top across the call and put it back rather than wrapping `m` in a -- proxy: the proxy cost two tables a frame and put a metatable lookup on @@ -4210,15 +4786,31 @@ function LauncherView.draw(imp) end local x, w = m.contentX, m.contentW + local viewH = math.max(0, availH) + local rect = imp._tabRegionRect + if not rect then rect = {}; imp._tabRegionRect = rect end + rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH + + local at = tabScrollAt(imp) + local py = Kit.scrollBegin(x, contentY, w, viewH, at, tabScrollMax(imp)) + local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN)) + local panelW = math.max(0, w - Kit.scrollGutter(m.s)) + local contentH if imp.tab == "mods" then - buildModsPanel(imp, x, contentY, w, availH, m) + contentH = buildModsPanel(imp, x, py, panelW, budgetH, m) elseif imp.tab == "find" then - buildFindPanel(imp, x, contentY, w, availH, m) + contentH = buildFindPanel(imp, x, py, panelW, budgetH, m) elseif imp.tab == "skins" then - buildSkinsPanel(imp, x, contentY, w, availH, m) + contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m) else - buildGamePanel(imp, x, contentY, w, availH, m, imp.tab) + contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH) end + contentH = contentH or availH + imp._tabContentH[tabKeyOf(imp)] = contentH + imp._tabScrollMax[tabKeyOf(imp)] = Kit.scrollExtent(contentH, viewH) + at = clamp(at, 0, tabScrollMax(imp)) + imp._tabScroll[tabKeyOf(imp)] = at + Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp)) buildFooter(imp, m, footY) Kit.blockClicks = false diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index ab9af931..7a94df36 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1111,18 +1111,18 @@ local function chooseZip() end local function chooseSkinZip() - local prompt = shellSafe(Strings("Choose a skin .zip")) + local prompt = shellSafe(Strings("Choose a skin .zip or .deltaskin")) local platform = love.system.getOS() if platform == "OS X" then return commandOutput( - ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]]) + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip", "deltaskin"})' 2>/dev/null]]) :format(prompt)) elseif platform == "Windows" then local script = table.concat({ "Add-Type -AssemblyName System.Windows.Forms;", "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. prompt .. "';", - "$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';", + "$d.Filter='Skin archive (*.zip;*.deltaskin)|*.zip;*.deltaskin|All files (*.*)|*.*';", "if($d.ShowDialog() -eq 'OK'){", "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", "$t=Join-Path $env:TEMP $n;", @@ -1134,11 +1134,11 @@ local function chooseSkinZip() 'powershell -NoProfile -STA -Command "' .. script .. '"') elseif platform == "Linux" then local path = commandOutput( - ([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]]) + ([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip *.deltaskin" 2>/dev/null]]) :format(prompt)) if path then return path end return commandOutput( - [[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]]) + [[kdialog --getopenfilename "$HOME" "*.zip *.deltaskin|Skin archive" 2>/dev/null]]) end return nil end @@ -1349,6 +1349,7 @@ function RomImporter.new(onComplete, opts) findLoaded = false, findSources = nil, findIndex = nil, findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, _findSearchFocus = false, _findThumbs = nil, + skinUrl = "", _skinUrlFocus = false, -- Page scroll offset (px) for the column under the tab bar -- panel, updater -- banner and footer -- used only while that column is taller than the window -- (see draw()). Clamped against content in draw, reset on a tab change. @@ -1851,10 +1852,14 @@ end function RomImporter:filedropped(file) if self.workState == "working" then return end -- A dropped .zip is a mod archive: hand it straight to the mods installer - -- (which mounts + validates it). Everything else is treated as a ROM. The - -- dropped file itself is passed through -- installZip opens it the same way - -- readDroppedFile does here. + -- (which mounts + validates it). A .deltaskin is only ever a skin, and + -- everything else is treated as a ROM. The dropped file itself is passed + -- through -- installZip opens it the same way readDroppedFile does here. local name = file:getFilename() or "" + if name:lower():match("%.deltaskin$") then + self:_installSkinZip(file) + return + end if name:lower():match("%.zip$") then -- On the SKINS tab a zip is a skin; everywhere else it is a mod archive. if self.tab == "skins" then @@ -2481,6 +2486,8 @@ function RomImporter:update(dt) self:_pumpModInfoFetch() self:_pumpFindStats() self:_pumpFindThumbs() + self:_pumpSkinFetch() + self:_pumpSync(dt) self:_pumpModCheck() self:_pumpModInstall() self:_pumpExtract() @@ -3082,6 +3089,8 @@ end function RomImporter:_switchTab(id) self.tab = id self._findSearchFocus = false + self._skinUrlFocus = false + self._modScrollMax, self._modListRect = 0, nil self:_disarmTextInput() -- the skins list is cheap and can change behind the launcher's back -- (an export, a hand-dropped folder), so re-read it on every visit @@ -3107,6 +3116,7 @@ function RomImporter:_ensureSkins(force) out[#out + 1] = { id = entry.id, source = entry.source, + format = skin and skin.format or nil, pages = skin and #skin.pages or 0, controls = controls, screen = page ~= nil and page.viewport ~= nil, @@ -3140,7 +3150,6 @@ end function RomImporter:_installSkinZip(source) if self.workState == "working" then return end self.tab = "skins" - local TouchSkin = require("src.core.TouchSkin") local name, data, readError if type(source) == "string" then name = source @@ -3160,13 +3169,368 @@ function RomImporter:_installSkinZip(source) .. tostring(readError or name) } return end - local id, err = TouchSkin.installArchive(name, data) + self:_installSkinData(name, data) +end + +local MAX_SKIN_URL = 300 +local SKIN_TEMP_DIR = "skins/_download" + +function RomImporter.skinUrlName(url) + local path = tostring(url or ""):gsub("[?#].*$", "") + local base = (path:match("([^/\\]+)$") or ""):gsub("[^%w%._%-]", "_") + local ext = base:match("%.([%w]+)$") + if not ext then + return (base ~= "" and base or "skin") .. ".zip" + end + ext = ext:lower() + local TouchSkin = require("src.core.TouchSkin") + if TouchSkin.ARCHIVE_EXTS[ext] or ext == "cfg" then return base end + return (base:gsub("%.[%w]+$", "")) .. ".zip" +end + +function RomImporter.wrapSkinPayload(name, data) + name = tostring(name or "") + if not name:lower():match("%.cfg$") then return name, data end + if not data then return name, data end + if data:sub(1, 2) == "PK" then + return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", data + end + local blob = require("src.core.SkinZip").encode({ + { name = "overlay.cfg", data = data }, + }) + return (name:gsub("%.[Cc][Ff][Gg]$", "")) .. ".zip", blob +end + +function RomImporter:_installSkinData(name, data) + local TouchSkin = require("src.core.TouchSkin") + if not data or data == "" then + self._skinNotice = { ok = false, text = Strings("The skin file was empty.") } + return nil + end + local wrappedName, payload = RomImporter.wrapSkinPayload(name, data) + local id, note = TouchSkin.installArchive(wrappedName, payload) self:_ensureSkins(true) if not id then - self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) } + self._skinNotice = { ok = false, text = "Import failed: " .. tostring(note) } + return nil + end + local text = "Imported " .. id + if type(note) == "table" and note[1] then + text = text .. ": " .. tostring(note[1]) + end + self._skinNotice = { ok = true, text = text } + return id +end + +function RomImporter:_toggleSkinUrlFocus() + self._skinUrlFocus = not self._skinUrlFocus + if self._skinUrlFocus then + self:_armTextInput() + else + self:_disarmTextInput() + end +end + +function RomImporter:_pasteSkinUrl() + local ok, text = pcall(love.system.getClipboardText) + if ok and type(text) == "string" then + self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""), + MAX_SKIN_URL) + end +end + +function RomImporter:_addSkinFromUrl(url) + if self._skinFetch then return false end + url = tostring(url or self.skinUrl or ""):gsub("%s", "") + if url == "" then + self._skinNotice = { ok = false, + text = Strings("Paste a link to a skin archive first.") } + return false + end + if not url:match("^https?://") then + self._skinNotice = { ok = false, + text = Strings("A skin link has to start with http:// or https://") } + return false + end + if not require("src.core.Platform").canFetchRemote() then + self._skinNotice = { ok = false, + text = Strings("Downloading needs a network transport this build has not got.") } + return false + end + local name = RomImporter.skinUrlName(url) + local Fetch = require("src.net.Fetch") + self._skinFetch = { + url = url, name = name, dest = SKIN_TEMP_DIR .. "/" .. name, + job = Fetch.download(url, SKIN_TEMP_DIR .. "/" .. name, + { userAgent = "gen1recomp-skin", maxSeconds = 90 }), + } + self._skinNotice = { ok = true, text = Strings("Downloading %s...", name) } + return true +end + +function RomImporter:_pumpSkinFetch() + local f = self._skinFetch + if not f then return end + local Fetch = require("src.net.Fetch") + local st = Fetch.poll(f.job) + if st.status == "pending" then + self._skinFetchProgress = st.progress return end - self._skinNotice = { ok = true, text = "Imported " .. id } + Fetch.release(f.job) + self._skinFetch, self._skinFetchProgress = nil, nil + if st.status ~= "ok" or not st.path then + self._skinNotice = { ok = false, + text = "Download failed: " .. tostring(st.err or "no data") } + return + end + local data = love.filesystem.read(st.path) + love.filesystem.remove(st.path) + if self:_installSkinData(f.name, data) then + self.skinUrl = "" + end +end + +function RomImporter:_exportSkin(id, kind) + local TouchSkin = require("src.core.TouchSkin") + local entry = id and TouchSkin.find(id) + if not entry then + self._skinNotice = { ok = false, text = Strings("That skin is gone.") } + return nil + end + local skin = TouchSkin.load(entry.root, entry.id) + if not skin then + self._skinNotice = { ok = false, + text = Strings("Could not read %s", tostring(id)) } + return nil + end + local path, missing, warnings + if kind == "retroarch" then + path, missing = TouchSkin.exportRetroArch(skin) + elseif kind == "delta" then + path, missing, warnings = TouchSkin.exportDelta(skin) + else + path, missing = TouchSkin.export(skin) + end + if not path then + self._skinNotice = { ok = false, + text = "Export failed: " .. tostring(missing) } + return nil + end + local dir = love.filesystem.getSaveDirectory + and love.filesystem.getSaveDirectory() or nil + self._skinExport = { path = path, dir = dir } + local text = Strings("Exported to %s", (dir and (dir .. "/") or "") .. path) + if type(missing) == "table" and missing[1] then + text = text .. " (" .. #missing .. " image(s) missing)" + end + if type(warnings) == "table" and warnings[1] then + text = text .. " " .. tostring(warnings[1]) + end + self._skinNotice = { ok = true, text = text } + return path +end + +function RomImporter:_revealSkinExport() + local e = self._skinExport + if not e or not e.dir then return false end + if love.system and love.system.openURL then + pcall(love.system.openURL, fileUrl(e.dir)) + end + return true +end + +local MAX_SYNC_CODE = 8 +local MAX_SHARE_CODE = 6 + +function RomImporter.syncDigits(text) + local digits = tostring(text or ""):gsub("[^%d]", "") + return digits:sub(1, MAX_SYNC_CODE) +end + +function RomImporter.syncShareCode(text) + local out = tostring(text or ""):upper():gsub("[^A-Z2-9]", "") + return out:sub(1, MAX_SHARE_CODE) +end + +function RomImporter:_syncDeviceLabel() + local name = love.system and love.system.getOS and love.system.getOS() + if type(name) ~= "string" or name == "" then return "device" end + return name +end + +function RomImporter:_syncEngine() + if self._sync ~= nil then return self._sync or nil end + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._sync = false + return nil + end + local made, eng = pcall(SyncEngine.shared) + if not made or type(eng) ~= "table" then + self._sync = false + return nil + end + self._sync = eng + return eng +end + +function RomImporter:_syncSupported() + if self._syncTransportOk ~= nil then return self._syncTransportOk end + local ok, HostShell = pcall(require, "src.core.HostShell") + if not ok or type(HostShell) ~= "table" + or type(HostShell.canHttpRequest) ~= "function" then + self._syncTransportOk = true + return true + end + local asked, can = pcall(HostShell.canHttpRequest) + self._syncTransportOk = (not asked) or (can and true or false) + return self._syncTransportOk +end + +function RomImporter:_pumpSync(dt) + if self._sync == nil then + if not self.launcher or self._syncBooted then return end + if not self:_syncSupported() then return end + self._syncBooted = true + local booted = self:_syncEngine() + if booted and booted.state.enabled and booted:linked() then + pcall(booted.syncNow, booted) + end + end + local eng = self._sync + if not eng then return end + pcall(eng.update, eng, dt) + if eng.phase == "conflict" and eng.conflicts and #eng.conflicts > 0 then + if not self._syncModal and not self._syncConflictShown then + self._syncConflictShown = true + self:_openSync() + end + else + self._syncConflictShown = nil + end +end + +function RomImporter:_openSync() + self:_syncEngine() + self._syncModal = self._syncModal + or { view = "home", code1 = "", code2 = "", share = "" } + self._syncFocus = nil + self:_disarmTextInput() +end + +function RomImporter:_closeSync() + self._syncModal = nil + self._syncFocus = nil + self:_disarmTextInput() +end + +function RomImporter:_syncView(view) + if not self._syncModal then return end + self._syncModal.view = view + self._syncFocus = nil + self:_disarmTextInput() +end + +function RomImporter:_syncFocusField(field) + if not self._syncModal then return end + if self._syncFocus == field then + self._syncFocus = nil + self:_disarmTextInput() + return + end + self._syncFocus = field + self:_armTextInput() +end + +function RomImporter:_syncTypeInto(field, text) + local mo = self._syncModal + if not mo or not field then return end + if field == "share" then + mo.share = RomImporter.syncShareCode((mo.share or "") .. tostring(text or "")) + else + mo[field] = RomImporter.syncDigits((mo[field] or "") .. tostring(text or "")) + end +end + +function RomImporter:_syncPaste() + local field = self._syncFocus + if not field then return end + local ok, text = pcall(love.system.getClipboardText) + if ok and type(text) == "string" then self:_syncTypeInto(field, text) end +end + +function RomImporter:_syncCreate() + local eng = self:_syncEngine() + if not eng then return false end + return eng:createAccount(self:_syncDeviceLabel()) +end + +function RomImporter:_syncLink() + local eng, mo = self:_syncEngine(), self._syncModal + if not eng or not mo then return false end + local ok = eng:linkDevice(mo.code1, mo.code2, self:_syncDeviceLabel()) + if ok then + mo.code1, mo.code2, mo.view = "", "", "home" + self._syncFocus = nil + self:_disarmTextInput() + end + return ok +end + +function RomImporter:_syncNow() + local eng = self:_syncEngine() + if not eng then return false end + return eng:syncNow() +end + +function RomImporter:_syncUnlink() + local eng = self:_syncEngine() + if not eng then return false end + eng:unlink() + if self._syncModal then self._syncModal.view = "home" end + return true +end + +function RomImporter:_syncUnlinkDevice(deviceId) + local eng = self:_syncEngine() + if not eng or type(eng.unlinkDevice) ~= "function" then return false end + return eng:unlinkDevice(deviceId) +end + +function RomImporter:_syncShareMods() + local eng = self:_syncEngine() + if not eng then return false end + return eng:shareMods() +end + +function RomImporter:_syncGetShare() + local eng, mo = self:_syncEngine(), self._syncModal + if not eng or not mo then return false end + return eng:fetchShare(mo.share or "") +end + +function RomImporter:_syncApplyMods() + local eng, mo = self:_syncEngine(), self._syncModal + if not eng then return false end + local ok, err = eng:applyModPlan(function(done, total, label, finished) + if not mo then return end + if finished then + mo.progress = nil + if self._refreshMods then self:_refreshMods() end + else + mo.progress = { done = done, total = total, label = label } + end + end) + if mo then mo.progress = nil end + if ok and self._refreshMods then self:_refreshMods() end + return ok, err +end + +function RomImporter:_syncResolve(key, choice) + local eng = self:_syncEngine() + if not eng then return false end + return eng:resolveConflict(key, choice) end function RomImporter:_skinsImportButtonLabel() @@ -3338,6 +3702,27 @@ function RomImporter:keypressed(key) if key == "escape" then self:_closeSettings() end return end + if self._syncModal then + local field = self._syncFocus + if field then + local mo = self._syncModal + if key == "backspace" then + mo[field] = tostring(mo[field] or ""):sub(1, -2) + elseif key == "return" or key == "kpenter" or key == "escape" then + self._syncFocus = nil + self:_disarmTextInput() + elseif key == "v" + and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then + self:_syncPaste() + end + return + end + if self._flex and require("src.import.LauncherView").keypressed(self, key) then + return + end + if key == "escape" then self:_closeSync() end + return + end if self._rename then if key == "backspace" then self._rename.text = utf8Back(self._rename.text) @@ -3387,6 +3772,21 @@ function RomImporter:keypressed(key) end return end + if self._skinUrlFocus then + if key == "backspace" then + self.skinUrl = utf8Back(self.skinUrl or "") + elseif key == "return" or key == "kpenter" then + self._skinUrlFocus = false + self:_disarmTextInput() + self:_addSkinFromUrl() + elseif key == "escape" then + self._skinUrlFocus = false + self:_disarmTextInput() + elseif key == "v" and love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui") then + self:_pasteSkinUrl() + end + return + end if self._findSearchFocus then if key == "backspace" then self.findQuery = utf8Back(self.findQuery or "") @@ -3494,6 +3894,10 @@ function RomImporter:_commitRename() end function RomImporter:textinput(text) + if self._syncModal and self._syncFocus then + self:_syncTypeInto(self._syncFocus, text) + return + end if self._profileSavePrompt then self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL) return @@ -3514,6 +3918,11 @@ function RomImporter:textinput(text) utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL) return end + if self._skinUrlFocus then + self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""), + MAX_SKIN_URL) + return + end if self._findSearchFocus then self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY) self.findScroll = 0 diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 3e7d961a..2a987125 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -86,6 +86,7 @@ local function drain() else j.status = msg.ok and "ok" or "error" j.body, j.err, j.path = msg.body, msg.err, msg.path + j.code = msg.code j.progress = msg.ok and 1 or j.progress end end @@ -143,6 +144,14 @@ function Fetch.post(url, body, opts) contentType = opts.contentType, maxSeconds = opts.maxSeconds }) end +function Fetch.request(url, opts) + opts = opts or {} + return submit({ kind = "request", url = url, + method = opts.method, body = opts.body, headers = opts.headers, + userAgent = opts.userAgent or "gen1recomp", + maxSeconds = opts.maxSeconds }) +end + -- Download a URL to `saveRel`, a path relative to the LOVE save directory. -- Progress is reported as a 0..1 fraction when `size` is known. function Fetch.download(url, saveRel, opts) diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index f386d7f8..79006d62 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -113,6 +113,22 @@ local function doPost(job) post({ id = job.id, ok = true, done = true }) end +local function doRequest(job) + if not HostShell then + post({ id = job.id, ok = false, err = "no transport" }) + return + end + local body, err, code = HostShell.httpRequest(job.url, { + method = job.method, body = job.body, headers = job.headers, + userAgent = job.userAgent, + maxTime = tonumber(job.maxSeconds) or GET_MAX_SECONDS }) + if not code then + post({ id = job.id, ok = false, err = err or "request failed" }) + return + end + post({ id = job.id, ok = true, body = body or "", code = code, done = true }) +end + while true do local job = cmdCh:demand() -- The flag is checked before the job's KIND, so a worker woken by a @@ -131,6 +147,9 @@ while true do elseif job.kind == "post" then local ok, err = pcall(doPost, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end + elseif job.kind == "request" then + local ok, err = pcall(doRequest, job) + if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end elseif job.kind == "download" then local ok, err = pcall(doDownload, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end diff --git a/src/render/Playfield.lua b/src/render/Playfield.lua new file mode 100644 index 00000000..e150dfef --- /dev/null +++ b/src/render/Playfield.lua @@ -0,0 +1,81 @@ +local GameViewport = require("src.render.GameViewport") +local TouchSkin = require("src.core.TouchSkin") + +local Playfield = {} + +Playfield.WIDTH, Playfield.HEIGHT = 160, 144 + +Playfield.entered = false +Playfield.box = nil + +local function clampRect(x, y, w, h, sw, sh) + if type(w) ~= "number" or type(h) ~= "number" then return nil end + if w ~= w or h ~= h then return nil end + x = math.floor(tonumber(x) or 0) + y = math.floor(tonumber(y) or 0) + w, h = math.floor(w), math.floor(h) + if x < 0 then w, x = w + x, 0 end + if y < 0 then h, y = h + y, 0 end + if x + w > sw then w = sw - x end + if y + h > sh then h = sh - y end + if w < 1 or h < 1 then return nil end + return x, y, w, h +end + +function Playfield.cutout(sw, sh) + if Playfield.entered then return nil end + if type(sw) ~= "number" or type(sh) ~= "number" then return nil end + if sw < 1 or sh < 1 then return nil end + if type(TouchSkin.viewport) ~= "function" then return nil end + local ok, x, y, w, h, fill, expand = pcall(TouchSkin.viewport, sw, sh) + if not ok then return nil end + local cx, cy, cw, ch = clampRect(x, y, w, h, sw, sh) + if not cx then return nil end + return cx, cy, cw, ch, fill == true, expand == true +end + +function Playfield.rect(sw, sh) + local x, y, w, h, _, expand = Playfield.cutout(sw, sh) + if not x then return 0, 0, sw or 0, sh or 0, false end + if expand then return x, y, w, h, true end + local s = math.max(1, math.floor(math.min(w / Playfield.WIDTH, + h / Playfield.HEIGHT))) + local pw = math.min(w, Playfield.WIDTH * s) + local ph = math.min(h, Playfield.HEIGHT * s) + return x + math.floor((w - pw) / 2), y + math.floor((h - ph) / 2), pw, ph, true +end + +function Playfield.enter(x, y, w, h) + Playfield.entered = true + Playfield.box = { x = x, y = y, w = w, h = h } +end + +function Playfield.leave() + Playfield.entered = false + Playfield.box = nil +end + +function Playfield.dimensions() + if Playfield.entered and Playfield.box then + return Playfield.box.w, Playfield.box.h + end + return GameViewport.dimensions() +end + +function Playfield.push(sw, sh) + local x, y, w, h, active = Playfield.rect(sw, sh) + local G = love.graphics + G.push("all") + if active then G.setScissor(x, y, w, h) end + G.translate(x, y) + Playfield.enter(x, y, w, h) + return w, h, x, y, active +end + +function Playfield.pop() + Playfield.leave() + love.graphics.setScissor() + love.graphics.pop() +end + +return Playfield diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index e9d71727..79b79501 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -15,7 +15,7 @@ local Runtime = require("src.mods.Runtime") local GameViewport = require("src.render.GameViewport") -- leaf module (no renderer dependency), so requiring it here cannot cycle local FaithfulRes = require("src.core.FaithfulRes") -local TouchSkin = require("src.core.TouchSkin") +local Playfield = require("src.render.Playfield") local Renderer = {} @@ -86,12 +86,12 @@ local function displayMetrics() if dpiX < 1e-6 then dpiX = 1 end if dpiY < 1e-6 then dpiY = 1 end local vx, vy = 0, 0 - local sx, sy, sw, sh = TouchSkin.viewport(pw, ph) - if sw and sw >= 1 and sh >= 1 then - vx, vy = math.floor(sx), math.floor(sy) - pw, ph = math.floor(sw), math.floor(sh) + local cut, grow = false, false + local sx, sy, sw, sh, _, expand = Playfield.cutout(pw, ph) + if sx then + vx, vy, pw, ph, cut, grow = sx, sy, sw, sh, true, expand end - return ww, wh, pw, ph, dpiX, dpiY, vx, vy + return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow end function Renderer:init() @@ -269,7 +269,7 @@ end -- corners; flat mode returns exactly today's size (growth factor is 1 when -- tilt is inactive). function Renderer:worldViewSize() - local _, _, pw, ph = displayMetrics() + local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics() -- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the -- WHOLE display, so letterbox voids become more map instead of black bars. -- That is why the lock appeared to do nothing in the overworld: it shrank @@ -281,13 +281,11 @@ function Renderer:worldViewSize() -- this is the same sum with the viewport standing in for the window, so -- both platforms show the same map area at the same zoom. local cap = FaithfulRes.scaleCap() - if not cap and TouchSkin.hasViewport() then - local page = TouchSkin.page() - if not page.viewportExpand then cap = self:fitScale() end - end + if not cap and cut and not grow then cap = self:fitScale() end if cap then local uiw, uih = self:uiSize() - pw, ph = uiw * cap, uih * cap + pw = cut and math.min(pw, uiw * cap) or uiw * cap + ph = cut and math.min(ph, uih * cap) or uih * cap end local sp = Zoom.scale(self:fitScale()) local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp) @@ -346,19 +344,20 @@ end -- window is the classic wipe unchanged. -- -- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces). -function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) +function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy, wx, wy) if not wipe or not wipe.prog or wipe.prog <= 0 then return end Sy = Sy or Sx + wx, wy = wx or 0, wy or 0 local TW, TH = 8 * Sx, 8 * Sy if TW < 1 then TW = 1 end if TH < 1 then TH = 1 end local prog = math.min(1, wipe.prog) love.graphics.setColor(0, 0, 0, 1) - love.graphics.setScissor(0, 0, ww, wh) + love.graphics.setScissor(wx, wy, ww, wh) if prog >= 1 then - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", wx, wy, ww, wh) love.graphics.setScissor() love.graphics.setColor(1, 1, 1, 1) return @@ -366,10 +365,10 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) -- whole-tile padding out to each window edge, keeping the grid in phase -- with the letterbox's tiles - local padL = math.max(0, math.ceil(ox / TW)) - local padT = math.max(0, math.ceil(oy / TH)) - local padR = math.max(0, math.ceil((ww - ox - vpw) / TW)) - local padB = math.max(0, math.ceil((wh - oy - vph) / TH)) + local padL = math.max(0, math.ceil((ox - wx) / TW)) + local padT = math.max(0, math.ceil((oy - wy) / TH)) + local padR = math.max(0, math.ceil((wx + ww - ox - vpw) / TW)) + local padB = math.max(0, math.ceil((wy + wh - oy - vph) / TH)) local lbCols = math.max(1, math.floor(vpw / TW + 0.5)) local lbRows = math.max(1, math.floor(vph / TH + 0.5)) local cols, rows = padL + lbCols + padR, padT + lbRows + padB @@ -396,9 +395,9 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) for row = 0, rows - 1 do local y = y0 + row * TH if row % 2 == 0 then - love.graphics.rectangle("fill", 0, y, w, TH) + love.graphics.rectangle("fill", wx, y, w, TH) else - love.graphics.rectangle("fill", ww - w, y, w, TH) + love.graphics.rectangle("fill", wx + ww - w, y, w, TH) end end elseif style == "vstripes" then @@ -406,21 +405,21 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) for col = 0, cols - 1 do local x = x0 + col * TW if col % 2 == 0 then - love.graphics.rectangle("fill", x, 0, TW, h) + love.graphics.rectangle("fill", x, wy, TW, h) else - love.graphics.rectangle("fill", x, wh - h, TW, h) + love.graphics.rectangle("fill", x, wy + wh - h, TW, h) end end elseif style == "shrink" then local h, w = wh / 2 * prog, ww / 2 * prog - love.graphics.rectangle("fill", 0, 0, ww, h) - love.graphics.rectangle("fill", 0, wh - h, ww, h) - love.graphics.rectangle("fill", 0, 0, w, wh) - love.graphics.rectangle("fill", ww - w, 0, w, wh) + love.graphics.rectangle("fill", wx, wy, ww, h) + love.graphics.rectangle("fill", wx, wy + wh - h, ww, h) + love.graphics.rectangle("fill", wx, wy, w, wh) + love.graphics.rectangle("fill", wx + ww - w, wy, w, wh) else -- split: a black cross growing out of the centre in both axes local h, w = wh / 2 * prog, ww / 2 * prog - love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2) - love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh) + love.graphics.rectangle("fill", wx, wy + wh / 2 - h, ww, h * 2) + love.graphics.rectangle("fill", wx + ww / 2 - w, wy, w * 2, wh) end end love.graphics.setScissor() @@ -542,7 +541,8 @@ end -- into (nil = default framebuffer; presentCanvas when CRT is on). -- Returns true on success; false (no shader/mesh) tells endFrame to fall -- back to the flat blit unchanged. -function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) +function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target, + boxX, boxY, boxW, boxH) local shader = self:tiltShader() local mesh = self:tiltMesh() if not (shader and mesh) then return false end @@ -593,12 +593,16 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target) mesh:setTexture(self.tiltCanvas) mesh:setVertices(Tilt.meshCorners(wvw, wvh)) love.graphics.push() + if boxW and boxH and boxW > 0 and boxH > 0 then + love.graphics.setScissor(boxX, boxY, boxW, boxH) + end love.graphics.translate(wox, woy) love.graphics.scale(sx, sy) love.graphics.setColor(1, 1, 1, 1) love.graphics.setShader(shader) love.graphics.draw(mesh) love.graphics.setShader() + love.graphics.setScissor() love.graphics.pop() return true end @@ -755,20 +759,23 @@ end -- scissored through the shade-remap shader, later zones on top. -- When GBC FX is active the composite is drawn into presentCanvas and -- presented through the GBC FX shader as a final pass. -function Renderer:endFrame(zones, worldZones) - GameViewport.setTarget() - local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics() - local vux, vuy = vx / dpiX, vy / dpiY - local vuw, vuh = pw / dpiX, ph / dpiY +function Renderer:frameRects() + local ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut = displayMetrics() + local r = { + ww = ww, wh = wh, pw = pw, ph = ph, dpiX = dpiX, dpiY = dpiY, + vx = vx, vy = vy, cut = cut, + vux = vx / dpiX, vuy = vy / dpiY, vuw = pw / dpiX, vuh = ph / dpiY, + } -- Sp = integer framebuffer pixels per GB pixel; -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). local Sp = self:fitScale() - local Sx, Sy = Sp / dpiX, Sp / dpiY + r.Sp, r.Sx, r.Sy = Sp, Sp / dpiX, Sp / dpiY local uiw, uih = self:uiSize() - local vpw, vph = uiw * Sx, uih * Sy + r.uiw, r.uih = uiw, uih + r.vpw, r.vph = uiw * r.Sx, uih * r.Sy -- Snap the letterbox origin to a framebuffer pixel, then convert to units. - local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX - local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY + r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX + r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY -- The UI has its own scale: it steps down as the survey zoom goes out (see -- uiScale), so it can be smaller than the world letterbox. Un-zoomed these -- are identical to Sp/ox/oy and every rect below is what it always was. @@ -782,10 +789,38 @@ function Renderer:endFrame(zones, worldZones) if self.uiFill then Up = math.min(ph / uih, pw / uiw) end - local Ux, Uy = Up / dpiX, Up / dpiY - local uvpw, uvph = uiw * Ux, uih * Uy - local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX - local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY + if uiw * Up > pw or uih * Up > ph then + Up = math.min(ph / uih, pw / uiw) + end + r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY + r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy + r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX + r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY + return r +end + +function Renderer.clipToView(r, x, y, w, h) + local x2, y2 = math.min(x + w, r.vux + r.vuw), math.min(y + h, r.vuy + r.vuh) + x, y = math.max(x, r.vux), math.max(y, r.vuy) + return x, y, math.max(0, x2 - x), math.max(0, y2 - y) +end + +function Renderer:playfieldRect() + local r = self:frameRects() + return r.vux, r.vuy, r.vuw, r.vuh, r.cut +end + +function Renderer:endFrame(zones, worldZones) + GameViewport.setTarget() + local R = self:frameRects() + local ww, wh, pw, ph = R.ww, R.wh, R.pw, R.ph + local dpiX, dpiY, vx, vy, cut = R.dpiX, R.dpiY, R.vx, R.vy, R.cut + local vux, vuy, vuw, vuh = R.vux, R.vuy, R.vuw, R.vuh + local Sp, Sx, Sy = R.Sp, R.Sx, R.Sy + local uiw, uih = R.uiw, R.uih + local vpw, vph, ox, oy = R.vpw, R.vph, R.ox, R.oy + local Ux, Uy = R.Ux, R.Uy + local uvpw, uvph, uox, uoy = R.uvpw, R.uvph, R.uox, R.uoy local GBCFX = require("src.render.GBCFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. @@ -814,6 +849,7 @@ function Renderer:endFrame(zones, worldZones) ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, vpw = vpw, vph = vph, uiw = uiw, uih = uih, scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY, + viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh, secondScreen = require("src.render.SecondScreen"), } if Runtime.call("render.compose", function() return false end, self, ctx) == true then @@ -901,23 +937,29 @@ function Renderer:endFrame(zones, worldZones) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) end end + if cut then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, ww, wh) + end love.graphics.setColor(clearR, clearG, clearB, 1) - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", vux, vuy, vuw, vuh) if extendedBlackBand then love.graphics.setColor(bandR, bandG, bandB, 1) - love.graphics.rectangle("fill", uox, 0, uvpw, wh) + love.graphics.rectangle("fill", uox, vuy, uvpw, vuh) end love.graphics.setColor(1, 1, 1, 1) -- render.letterbox: SGB borders / custom void art in the bars around the -- 160x144 (or world) blit. Drawn after the clear and before the game -- canvas so the playfield sits on top of the border. if Runtime.wantsHook("render.letterbox") then + if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end Runtime.call("render.letterbox", function() end, { ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, vpw = vpw, vph = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY, worldActive = self.worldActive and true or false, }) + if cut then love.graphics.setScissor() end end -- see Renderer:blitCanvas; bound here to the frame's dpi so the composite @@ -928,6 +970,10 @@ function Renderer:endFrame(zones, worldZones) bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) end + local function clipToView(x, y, w, h) + return Renderer.clipToView(R, x, y, w, h) + end + if self.worldOverride then -- A render pipeline already produced the whole world -- terrain, -- characters and its own FX overlay -- as one window-resolution image, @@ -938,9 +984,9 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setScissor(vux, vuy, vuw, vuh) local loveMajor = love.getVersion() if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then - love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY) + love.graphics.draw(self.worldOverride, vux, vuy + vuh, 0, 1 / dpiX, -1 / dpiY) else - love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY) + love.graphics.draw(self.worldOverride, vux, vuy, 0, 1 / dpiX, 1 / dpiY) end love.graphics.setScissor() -- the screen-space overlays the flat path draws over its composite @@ -964,7 +1010,8 @@ function Renderer:endFrame(zones, worldZones) -- falls through to the flat blit, keeping the flat frame byte-for-byte -- identical to today. local projected = - Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present) + Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, + present, vux, vuy, vuw, vuh) if not projected then if worldZones then blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh) @@ -1061,7 +1108,7 @@ function Renderer:endFrame(zones, worldZones) and not FaithfulRes.scaleCap() then local ok, Game = pcall(require, "src.core.Game") love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data)) - love.graphics.rectangle("fill", uox, 0, uvpw, wh) + love.graphics.rectangle("fill", uox, vuy, uvpw, vuh) love.graphics.setColor(1, 1, 1, 1) end @@ -1070,9 +1117,9 @@ function Renderer:endFrame(zones, worldZones) -- always been. local anchors = self.uiAnchors if not anchors or #anchors == 0 then - blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) + blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, clipToView(uox, uoy, uvpw, uvph)) else - local rest = { { uox, uoy, uvpw, uvph } } + local rest = { { clipToView(uox, uoy, uvpw, uvph) } } local placed = {} for _, a in ipairs(anchors) do local dw, dh = a.w * Ux, a.h * Uy @@ -1086,19 +1133,19 @@ function Renderer:endFrame(zones, worldZones) local dx, dy if a.anchor == "bottom" then dx = uox + a.x * Ux -- horizontally it stays with the letterbox - dy = wh - gapB - dh + dy = vuy + vuh - gapB - dh elseif a.anchor == "top" then dx = uox + a.x * Ux -- horizontally it stays with the letterbox - dy = a.y * Uy + dy = vuy + a.y * Uy elseif a.anchor == "topright" then - dx = ww - gapR - dw - dy = a.y * Uy + dx = vux + vuw - gapR - dw + dy = vuy + a.y * Uy else -- unknown anchor: leave it where it is dx, dy = uox + a.x * Ux, uoy + a.y * Uy end if a.windowClamped then - dx = math.max(0, math.min(math.max(0, ww - dw), dx)) - dy = math.max(0, math.min(math.max(0, wh - dh), dy)) + dx = math.max(vux, math.min(math.max(vux, vux + vuw - dw), dx)) + dy = math.max(vuy, math.min(math.max(vuy, vuy + vuh - dh), dy)) end placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh } if a.extract then @@ -1113,13 +1160,14 @@ function Renderer:endFrame(zones, worldZones) -- The zone scissors are computed from the same origin, so an SGB -- region travels with the element instead of staying in the letterbox. blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy, - p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) + p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, + clipToView(p.dx, p.dy, p.dw, p.dh)) end end local uiRedraws = PaletteFX.uiSpriteRedraws() if uiRedraws[1] then love.graphics.setColor(1, 1, 1, 1) - love.graphics.setScissor(uox, uoy, uvpw, uvph) + love.graphics.setScissor(clipToView(uox, uoy, uvpw, uvph)) for _, r in ipairs(uiRedraws) do if r.quad then love.graphics.draw(r.image, r.quad, uox + r.x * Ux, uoy + r.y * Uy, @@ -1135,7 +1183,8 @@ function Renderer:endFrame(zones, worldZones) -- over the finished composite rather than under the UI blit. On hardware -- it is the tilemap being overwritten -- there is nothing it does not cover. if self.battleWipe then - self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy) + self:drawBattleWipe(self.battleWipe, vuw, vuh, ox, oy, vpw, vph, Sx, Sy, + vux, vuy) end -- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the @@ -1157,7 +1206,7 @@ function Renderer:endFrame(zones, worldZones) if FaithfulRes.scaleCap() then love.graphics.rectangle("fill", ox, oy, vpw, vph) else - love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.rectangle("fill", vux, vuy, vuw, vuh) end love.graphics.setColor(1, 1, 1, 1) end @@ -1181,6 +1230,7 @@ function Renderer:endFrame(zones, worldZones) generation = 1, }) == true if not outputHandled then + if cut then love.graphics.setScissor(vux, vuy, vuw, vuh) end if GBCFX.active() then -- shader grid/shadow math is in framebuffer pixels GBCFX.present(composed, Sp) @@ -1190,6 +1240,7 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(composed, 0, 0) end + if cut then love.graphics.setScissor() end end end self.worldActive = false @@ -1202,6 +1253,7 @@ function Renderer:endFrame(zones, worldZones) gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY, + viewX = vux, viewY = vuy, viewWidth = vuw, viewHeight = vuh, } end diff --git a/src/sync/SyncClient.lua b/src/sync/SyncClient.lua new file mode 100644 index 00000000..e0874152 --- /dev/null +++ b/src/sync/SyncClient.lua @@ -0,0 +1,197 @@ +local Json = require("src.link.Json") + +local SyncClient = {} +SyncClient.__index = SyncClient + +SyncClient.DEFAULT_URL = os.getenv("POKEPORT_SYNC_URL") + or "https://sync.147.182.215.255.sslip.io" +SyncClient.MAX_BLOB = 2 * 1024 * 1024 +SyncClient.MAX_RESPONSE = 4 * 1024 * 1024 +SyncClient.TIMEOUT = 25 + +function SyncClient.normalizeCode(code) + if type(code) ~= "string" and type(code) ~= "number" then return nil end + local digits = tostring(code):gsub("[^%d]", "") + if #digits ~= 8 then return nil end + return digits +end + +function SyncClient.formatCode(code) + local digits = SyncClient.normalizeCode(code) + if not digits then return nil end + return digits:sub(1, 4) .. "-" .. digits:sub(5, 8) +end + +local function escape(s) + return (tostring(s):gsub("[^%w%-%._~]", function(c) + return ("%%%02X"):format(c:byte()) + end)) +end + +local function query(params) + local names = {} + for name in pairs(params or {}) do names[#names + 1] = tostring(name) end + table.sort(names) + local out = {} + for _, name in ipairs(names) do + out[#out + 1] = escape(name) .. "=" .. escape(params[name]) + end + if #out == 0 then return "" end + return "?" .. table.concat(out, "&") +end + +function SyncClient.new(opts) + opts = opts or {} + local base = opts.baseUrl or SyncClient.DEFAULT_URL + base = tostring(base):gsub("/+$", "") + local transport = opts.transport + if not transport then + transport = require("src.sync.SyncTransport").new() + end + return setmetatable({ + baseUrl = base, + transport = transport, + account = opts.account, + token = opts.token, + }, SyncClient) +end + +function SyncClient:setAuth(account, token) + self.account = type(account) == "string" and account ~= "" and account or nil + self.token = type(token) == "string" and token ~= "" and token or nil +end + +function SyncClient:clearAuth() + self.account, self.token = nil, nil +end + +function SyncClient:isLinked() + return self.account ~= nil and self.token ~= nil +end + +function SyncClient:send(method, path, body, opts) + opts = opts or {} + local headers = { ["Accept"] = "application/json" } + local payload + if body ~= nil then + local ok, encoded = pcall(Json.encode, body) + if not ok then return nil, "could not encode the request" end + payload = encoded + headers["Content-Type"] = "application/json" + end + if not opts.noAuth then + if not self:isLinked() then return nil, "this device is not linked" end + headers["x-sync-account"] = self.account + headers["x-sync-token"] = self.token + end + local url = self.baseUrl .. path .. query(opts.params) + local handle = self.transport:begin({ + url = url, method = method, body = payload, headers = headers, + maxSeconds = opts.maxSeconds or SyncClient.TIMEOUT, + }) + if handle == nil then return nil, "no network transport" end + return handle +end + +function SyncClient:poll(handle) + if handle == nil then return { status = "error", err = "no request" } end + local res = self.transport:poll(handle) + if res.status == "pending" then return { status = "pending" } end + if res.status ~= "ok" then + return { status = "error", err = res.err or "sync request failed" } + end + local raw = res.body or "" + local code = tonumber(res.code) or 0 + if #raw > SyncClient.MAX_RESPONSE then + return { status = "error", code = code, err = "the reply was too large" } + end + local data, decodeErr = Json.decode(raw, SyncClient.MAX_RESPONSE) + if type(data) ~= "table" then + local why = Json.describeUnexpected(raw) or decodeErr or "unreadable reply" + if code >= 400 then + return { status = "error", code = code, + err = ("the server answered %d"):format(code) } + end + return { status = "error", code = code, err = why } + end + if code >= 400 or data.error then + local err = data.error + if type(err) ~= "string" or err == "" then + err = ("the server answered %d"):format(code) + end + return { status = "error", code = code, data = data, err = err } + end + return { status = "ok", code = code, data = data } +end + +function SyncClient:release(handle) + if handle ~= nil then self.transport:release(handle) end +end + +function SyncClient:create(deviceLabel) + return self:send("POST", "/sync/create", + { device = deviceLabel or "device" }, { noAuth = true }) +end + +function SyncClient:link(code1, code2, deviceLabel) + local a = SyncClient.normalizeCode(code1) + local b = SyncClient.normalizeCode(code2) + if not a or not b then return nil, "both codes are 8 digits" end + return self:send("POST", "/sync/link", + { code1 = a, code2 = b, device = deviceLabel or "device" }, + { noAuth = true }) +end + +function SyncClient:fetchState() + return self:send("GET", "/sync/state") +end + +function SyncClient:putSave(entry) + if type(entry) ~= "table" then return nil, "missing save entry" end + if type(entry.blob) ~= "string" or entry.blob == "" then + return nil, "missing save data" + end + if #entry.blob > SyncClient.MAX_BLOB then + return nil, "this save is too large to sync" + end + return self:send("PUT", "/sync/save", { + version = entry.version, + slot = entry.slot, + meta = entry.meta, + blob = entry.blob, + baseRev = entry.baseRev, + force = entry.force and true or nil, + }) +end + +function SyncClient:getSave(version, id) + return self:send("GET", "/sync/save", nil, + { params = { version = version, id = id } }) +end + +function SyncClient:putMods(manifest) + return self:send("PUT", "/sync/mods", { manifest = manifest }) +end + +function SyncClient:getMods() + return self:send("GET", "/sync/mods") +end + +function SyncClient:shareMods(manifest) + return self:send("POST", "/sync/modshare", { manifest = manifest }) +end + +function SyncClient:fetchShare(code) + local trimmed = tostring(code or ""):gsub("%s", ""):upper() + if not trimmed:match("^[A-Z2-9]+$") or #trimmed ~= 6 then + return nil, "share codes are 6 characters" + end + return self:send("GET", "/sync/modshare", nil, + { noAuth = true, params = { code = trimmed } }) +end + +function SyncClient:unlink(device) + return self:send("POST", "/sync/unlink", { device = device }) +end + +return SyncClient diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua new file mode 100644 index 00000000..49c3298f --- /dev/null +++ b/src/sync/SyncEngine.lua @@ -0,0 +1,690 @@ +local SyncClient = require("src.sync.SyncClient") +local SyncState = require("src.sync.SyncState") +local SyncMods = require("src.sync.SyncMods") + +local SyncEngine = {} +SyncEngine.__index = SyncEngine + +SyncEngine.UPLOAD_DEBOUNCE = 5 +SyncEngine.AUTO_INTERVAL = 300 +SyncEngine.MAX_STEPS_PER_UPDATE = 8 + +local IDLE_STATUS = "Ready" +local UNLINKED_STATUS = "Not set up" + +local function saveApi() + return require("src.core.SaveData") +end + +local function gameVersions() + return require("src.core.GameVersion").ORDER +end + +local function slotForPlaythrough(options, version, playthroughId) + local byVersion = options.playthroughIds and options.playthroughIds[version] + for slotId, id in pairs(byVersion or {}) do + if id == playthroughId then return slotId end + end + return nil +end + +function SyncEngine.defaultSaves() + return { + list = function() + local SaveData = saveApi() + local options = SaveData.loadOptions() + local out = {} + for _, version in ipairs(gameVersions()) do + for _, slot in ipairs(SaveData.listSlots(version)) do + if slot.exists then + local source = SaveData.readSlotSource(version, slot.id) + local save = source and SaveData.decode(source) + if type(save) == "table" then + local meta = type(save.meta) == "table" and save.meta or {} + local id = meta.playthroughId + if type(id) ~= "string" or id == "" then + local byVersion = options.playthroughIds + and options.playthroughIds[version] + id = byVersion and byVersion[slot.id] or nil + end + if id then + local name, summary = SaveData.slotSummary(save) + out[#out + 1] = { + version = version, + slot = slot.id, + playthroughId = id, + blob = source, + meta = { + savedAt = tonumber(meta.savedAt), + sessionStart = tonumber(meta.sessionStart), + playthroughId = id, + format = meta.format, + engine = meta.engine, + playTime = tonumber(save.playTime), + summary = { + name = name, + badges = summary and summary.badges, + timeText = summary and summary.timeText, + dexCount = summary and summary.dexCount, + }, + }, + } + end + end + end + end + end + return out + end, + + write = function(version, playthroughId, blob, mode) + local SaveData = saveApi() + local save = SaveData.decode(blob) + if type(save) ~= "table" then return nil, "the downloaded save is unreadable" end + save.version = save.version or version + local options = SaveData.loadOptions() + local slotId + if mode == "new" then + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.playthroughId = SaveData.newPlaythroughId() + else + slotId = slotForPlaythrough(options, version, playthroughId) + end + if not slotId then + slotId = SaveData.createSlot(version) + if not slotId then return nil, "could not make a save slot" end + end + local ok, err = SaveData.writeSlot(version, slotId, save) + if not ok then return nil, err or "could not write the save" end + options = SaveData.loadOptions() + options.playthroughIds = options.playthroughIds or {} + options.playthroughIds[version] = options.playthroughIds[version] or {} + options.playthroughIds[version][slotId] = + save.meta and save.meta.playthroughId or playthroughId + SaveData.saveOptions(options) + return slotId + end, + } +end + +function SyncEngine.overlaps(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt) + local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt) + if not (aStart and aEnd and bStart and bEnd) then return false end + return aStart <= bEnd and bStart <= aEnd +end + +function SyncEngine.new(opts) + opts = opts or {} + local eng = setmetatable({}, SyncEngine) + eng.fs = opts.fs + eng.state = opts.state or SyncState.load(eng.fs) + eng.client = opts.client or SyncClient.new({ + baseUrl = opts.baseUrl, transport = opts.transport }) + eng.saves = opts.saves or SyncEngine.defaultSaves() + eng.modDeps = opts.modDeps + eng.now = opts.now or os.time + eng.persist = opts.persist ~= false + eng.phase = "idle" + eng.error = nil + eng.conflicts = {} + eng.codes = nil + eng.modPlan = nil + eng.shareCode = nil + eng.clock = 0 + eng.queue = {} + eng.pending = nil + eng.uploadAt = nil + eng.client:setAuth(eng.state.account, eng.state.deviceToken) + eng.status = eng:defaultStatus() + return eng +end + +function SyncEngine.shared(opts) + if SyncEngine._shared == nil then + local ok, eng = pcall(SyncEngine.new, opts or {}) + SyncEngine._shared = (ok and type(eng) == "table") and eng or false + end + return SyncEngine._shared or nil +end + +function SyncEngine.forgetShared() + SyncEngine._shared = nil +end + +function SyncEngine:defaultStatus() + if not SyncState.linked(self.state) then return UNLINKED_STATUS end + return IDLE_STATUS +end + +function SyncEngine:linked() + return SyncState.linked(self.state) +end + +function SyncEngine:busy() + return self.pending ~= nil or #self.queue > 0 or self.modApply ~= nil +end + +function SyncEngine:_persist() + if not self.persist then return end + SyncState.save(self.state, self.fs) +end + +function SyncEngine:_fail(message) + self.phase = "error" + self.error = tostring(message or "sync failed") + self.status = "Sync failed: " .. self.error + self.queue = {} + self.pending = nil +end + +function SyncEngine:_finish() + if #self.conflicts > 0 then + self.phase = "conflict" + local overlap = false + for _, row in ipairs(self.conflicts) do + if row.overlap then overlap = true end + end + self.status = overlap + and "These saves were played at the same time." + or "This save also changed on another device." + return + end + self.phase = "idle" + self.error = nil + self.state.lastSyncAt = self.now() + self.status = self:defaultStatus() + self:_persist() +end + +function SyncEngine:_request(handle, err, onOk, onErr) + if not handle then + self:_fail(err or "could not start the request") + return false + end + self.pending = { handle = handle, onOk = onOk, onErr = onErr } + return true +end + +function SyncEngine:_enqueue(fn) + self.queue[#self.queue + 1] = fn +end + +function SyncEngine:cancel() + if self.pending then self.client:release(self.pending.handle) end + self.pending = nil + self.queue = {} + self.modApply = nil + self.uploadAt = nil + if self.phase ~= "conflict" then + self.phase = "idle" + self.status = self:defaultStatus() + end +end + +function SyncEngine:noteSaveWritten() + if not (self.state.enabled and self:linked()) then return end + self.uploadAt = self.clock + SyncEngine.UPLOAD_DEBOUNCE +end + +function SyncEngine:update(dt) + self.clock = self.clock + (tonumber(dt) or 0) + if self.pending then + local res = self.client:poll(self.pending.handle) + if res.status == "pending" then return end + local job = self.pending + self.pending = nil + self.client:release(job.handle) + if res.status == "ok" then + local ok, err = pcall(job.onOk, self, res) + if not ok then self:_fail(err) end + else + local handled = false + if job.onErr then + local ok, result = pcall(job.onErr, self, res) + if not ok then self:_fail(result) return end + handled = result == true + end + if not handled then self:_fail(res.err) end + end + end + if self.pending then return end + if self.modApply then + self:_stepModApply() + return + end + if self.uploadAt and self.clock >= self.uploadAt and not self:busy() then + self.uploadAt = nil + if self.state.enabled and self:linked() then self:syncNow() end + end + local steps = 0 + while not self.pending and #self.queue > 0 + and steps < SyncEngine.MAX_STEPS_PER_UPDATE do + steps = steps + 1 + local task = table.remove(self.queue, 1) + local ok, err = pcall(task, self) + if not ok then self:_fail(err) return end + if not self.pending and #self.queue == 0 and self.phase ~= "error" then + self:_finish() + end + end +end + +function SyncEngine:createAccount(label) + if self:busy() then return false, "sync is busy" end + self.phase = "checking" + self.status = "Creating a sync account..." + self.error = nil + local handle, err = self.client:create(label) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then + eng:_fail("the server sent an unexpected reply") + return + end + eng.codes = { + code1 = SyncClient.formatCode(data.code1) or tostring(data.code1 or ""), + code2 = SyncClient.formatCode(data.code2) or tostring(data.code2 or ""), + } + eng.state.account = data.account + eng.state.deviceToken = data.deviceToken + eng.state.deviceId = type(data.device) == "string" and data.device or nil + eng.state.deviceLabel = label + eng.state.enabled = true + eng.client:setAuth(data.account, data.deviceToken) + eng.phase = "idle" + eng.status = "Sync account created" + eng:_persist() + end) +end + +function SyncEngine:linkDevice(code1, code2, label) + if self:busy() then return false, "sync is busy" end + local a = SyncClient.normalizeCode(code1) + local b = SyncClient.normalizeCode(code2) + if not a or not b then + self:_fail("both codes are 8 digits") + return false, "both codes are 8 digits" + end + self.phase = "checking" + self.status = "Linking this device..." + self.error = nil + local handle, err = self.client:link(a, b, label) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + if type(data.account) ~= "string" or type(data.deviceToken) ~= "string" then + eng:_fail("the server sent an unexpected reply") + return + end + eng.state.account = data.account + eng.state.deviceToken = data.deviceToken + eng.state.deviceId = type(data.device) == "string" and data.device or nil + eng.state.deviceLabel = label + eng.state.enabled = true + eng.client:setAuth(data.account, data.deviceToken) + eng.status = "This device is linked" + eng:_persist() + eng:syncNow() + end) +end + +function SyncEngine:_forgetLocal() + self.state = SyncState.defaults() + self.client:clearAuth() + self.codes = nil + self.conflicts = {} + self.devices = nil + self.phase = "idle" + self.status = UNLINKED_STATUS + self:_persist() +end + +function SyncEngine:unlink() + if not self:linked() then + self:_forgetLocal() + return true + end + if self:busy() then return false, "sync is busy" end + self.phase = "checking" + self.status = "Unlinking this device..." + self.error = nil + local handle, err = self.client:unlink(self.state.deviceId) + return self:_request(handle, err, function(eng) + eng:_forgetLocal() + end, function(eng, res) + if res.code == 401 or res.code == 404 then + eng:_forgetLocal() + return true + end + return false + end) +end + +function SyncEngine:unlinkDevice(deviceId) + if type(deviceId) ~= "string" or deviceId == "" then + return false, "no such device" + end + if not self:linked() then return false, "this device is not linked" end + if deviceId == self.state.deviceId then return self:unlink() end + if self:busy() then return false, "sync is busy" end + self.phase = "checking" + self.status = "Unlinking that device..." + self.error = nil + local handle, err = self.client:unlink(deviceId) + return self:_request(handle, err, function(eng) + eng.status = "That device was unlinked" + eng.phase = "idle" + eng:syncNow() + end) +end + +function SyncEngine:setEnabled(enabled) + self.state.enabled = enabled and true or false + self:_persist() + return self.state.enabled +end + +function SyncEngine:syncNow() + if not self:linked() then return false, "this device is not linked" end + if self.pending then return false, "sync is busy" end + self.queue = {} + self.conflicts = {} + self.state.pendingConflicts = {} + self.phase = "checking" + self.status = "Checking for changes..." + self.error = nil + local handle, err = self.client:fetchState() + return self:_request(handle, err, function(eng, res) + eng:_planFrom(res.data or {}) + end) +end + +function SyncEngine:_planFrom(remoteState) + self.devices = nil + if type(remoteState.devices) == "table" then + local list = {} + for _, row in ipairs(remoteState.devices) do + if type(row) == "table" and type(row.id) == "string" and row.id ~= "" then + list[#list + 1] = { + id = row.id, + label = type(row.label) == "string" and row.label ~= "" and row.label + or "device", + createdAt = tonumber(row.createdAt), + current = row.current == true or row.id == self.state.deviceId, + } + end + end + self.devices = list + end + local remote = type(remoteState.saves) == "table" and remoteState.saves or {} + local locals = self.saves.list() or {} + local seen = {} + for _, entry in ipairs(locals) do + local key = SyncState.key(entry.version, entry.playthroughId) + if key then + seen[key] = true + local row = remote[key] + local knownRev = SyncState.rev(self.state, key) + local stamp = SyncState.stamp(self.state, key) + local localChanged = stamp == nil + or tonumber(entry.meta and entry.meta.savedAt) ~= stamp + local remoteRev = row and tonumber(row.rev) + local remoteChanged = row ~= nil and remoteRev ~= knownRev + if not row then + self:_queueUpload(entry, key, false) + elseif localChanged and remoteChanged then + self:_addConflict(entry, key, row) + elseif localChanged then + self:_queueUpload(entry, key, false) + elseif remoteChanged then + self:_queueDownload(key, entry.version, entry.playthroughId, "replace") + end + end + end + for key, row in pairs(remote) do + if not seen[key] then + local version, id = SyncState.splitKey(key) + if version and id then + self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) + end + end + end + if #self.queue == 0 then self:_finish() end +end + +function SyncEngine:_addConflict(entry, key, row) + local remoteMeta = row + if type(row.meta) == "table" then + remoteMeta = row.meta + elseif type(row.remoteMeta) == "table" then + remoteMeta = row.remoteMeta + end + self.conflicts[#self.conflicts + 1] = { + key = key, + version = entry.version, + playthroughId = entry.playthroughId, + slot = entry.slot, + entry = entry, + localMeta = entry.meta, + remoteMeta = remoteMeta, + remoteRev = tonumber(row.rev), + overlap = SyncEngine.overlaps(entry.meta, remoteMeta), + } + local pending = self.state.pendingConflicts or {} + self.state.pendingConflicts = pending + for _, row in ipairs(pending) do + if row.key == key then return end + end + pending[#pending + 1] = { + key = key, + version = entry.version, + playthroughId = entry.playthroughId, + overlap = SyncEngine.overlaps(entry.meta, remoteMeta), + } +end + +function SyncEngine:_queueUpload(entry, key, force) + self:_enqueue(function(eng) + eng.phase = "uploading" + eng.status = "Uploading saves..." + local handle, err = eng.client:putSave({ + version = entry.version, + slot = entry.slot, + meta = entry.meta, + blob = entry.blob, + baseRev = SyncState.rev(eng.state, key), + force = force, + }) + eng:_request(handle, err, function(e, res) + local data = res.data or {} + SyncState.setRev(e.state, key, tonumber(data.rev), + entry.meta and entry.meta.savedAt) + e:_persist() + if not e:busy() then e:_finish() end + end, function(e, res) + if res.code == 409 then + local row = res.data or {} + e:_addConflict(entry, key, row) + if not e:busy() then e:_finish() end + return true + end + return false + end) + end) +end + +function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev) + self:_enqueue(function(eng) + eng.phase = "downloading" + eng.status = "Downloading saves..." + local handle, err = eng.client:getSave(version, playthroughId) + eng:_request(handle, err, function(e, res) + local data = res.data or {} + if type(data.blob) ~= "string" or data.blob == "" then + e:_fail("the server sent no save data") + return + end + local slotId, writeErr = e.saves.write(version, playthroughId, data.blob, mode) + if not slotId then + e:_fail(writeErr or "could not write the downloaded save") + return + end + if mode ~= "new" then + local meta = type(data.meta) == "table" and data.meta or {} + SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev, + tonumber(meta.savedAt)) + end + e:_persist() + if not e:busy() then e:_finish() end + end) + end) +end + +function SyncEngine:resolveConflict(key, choice) + local index + for i, row in ipairs(self.conflicts) do + if row.key == key then index = i break end + end + if not index then return false, "no such conflict" end + local conflict = table.remove(self.conflicts, index) + local kept = {} + for _, row in ipairs(self.state.pendingConflicts or {}) do + if row.key ~= key then kept[#kept + 1] = row end + end + self.state.pendingConflicts = kept + + if choice == "local" then + SyncState.setRev(self.state, key, conflict.remoteRev, nil) + self:_queueUpload(conflict.entry, key, true) + elseif choice == "remote" then + self:_queueDownload(key, conflict.version, conflict.playthroughId, + "replace", conflict.remoteRev) + elseif choice == "both" then + self:_queueDownload(key, conflict.version, conflict.playthroughId, + "new", conflict.remoteRev) + SyncState.setRev(self.state, key, conflict.remoteRev, nil) + self:_queueUpload(conflict.entry, key, true) + else + return false, "unknown resolution" + end + self.phase = "uploading" + self.status = "Applying your choice..." + return true +end + +function SyncEngine:uploadMods() + if not self:linked() then return false, "this device is not linked" end + if self:busy() then return false, "sync is busy" end + local manifest = SyncMods.build(self.modDeps) + self.phase = "uploading" + self.status = "Uploading the mod list..." + local handle, err = self.client:putMods(manifest) + return self:_request(handle, err, function(eng) + eng.phase = "idle" + eng.status = "Mod list synced" + end) +end + +function SyncEngine:fetchModPlan() + if not self:linked() then return false, "this device is not linked" end + if self:busy() then return false, "sync is busy" end + self.phase = "downloading" + self.status = "Reading the mod list..." + local handle, err = self.client:getMods() + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + local manifest = type(data.manifest) == "table" and data.manifest or data + eng.modPlan = SyncMods.plan(manifest, eng.modDeps) + eng.phase = "idle" + eng.status = SyncMods.planEmpty(eng.modPlan) + and "Mods already match" or "Mod changes ready to apply" + end) +end + +function SyncEngine:shareMods() + if not self:linked() then return false, "this device is not linked" end + if self:busy() then return false, "sync is busy" end + local manifest = SyncMods.build(self.modDeps) + self.phase = "uploading" + self.status = "Sharing the mod list..." + local handle, err = self.client:shareMods(manifest) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + eng.shareCode = type(data.code) == "string" and data.code or nil + eng.phase = "idle" + eng.status = eng.shareCode and ("Share code " .. eng.shareCode) + or "The server sent no share code" + end) +end + +function SyncEngine:fetchShare(code) + if self:busy() then return false, "sync is busy" end + self.phase = "downloading" + self.status = "Fetching that mod list..." + local handle, err = self.client:fetchShare(code) + return self:_request(handle, err, function(eng, res) + local data = res.data or {} + local manifest = type(data.manifest) == "table" and data.manifest or data + eng.modPlan = SyncMods.plan(manifest, eng.modDeps) + eng.phase = "idle" + eng.status = SyncMods.planEmpty(eng.modPlan) + and "Mods already match" or "Mod changes ready to apply" + end) +end + +function SyncEngine:applyModPlan(progress) + if not self.modPlan then return false, "no mod plan" end + if self.modApply then return false, "the mods are already being applied" end + local steps = SyncMods.steps(self.modPlan, self.modDeps) + if #steps == 0 then + self.modPlan = nil + self.status = "Mods already match" + if progress then progress(0, 0, nil, true) end + return true + end + self.modApply = { steps = steps, index = 0, failures = {}, + progress = progress } + self.phase = "applying" + self.status = ("Applying mods... 0 of %d"):format(#steps) + return true +end + +function SyncEngine:applyingMods() + return self.modApply ~= nil +end + +function SyncEngine:_stepModApply() + local job = self.modApply + local step = job.steps[job.index + 1] + job.index = job.index + 1 + local ok, res, why = pcall(step.run) + if not ok then + job.failures[#job.failures + 1] = tostring(res) + elseif not res then + job.failures[#job.failures + 1] = tostring(why or step.label) + end + local total = #job.steps + local done = job.index >= total + if not done then + self.status = ("Applying mods... %d of %d"):format(job.index, total) + if job.progress then + pcall(job.progress, job.index, total, step.label, false) + end + return + end + self.modApply = nil + self.modPlan = nil + self.phase = "idle" + if #job.failures > 0 then + self.status = "Some mods could not be applied: " + .. table.concat(job.failures, "; ") + else + self.status = "Mods applied" + end + if job.progress then + pcall(job.progress, job.index, total, step.label, true) + end +end + +return SyncEngine diff --git a/src/sync/SyncMods.lua b/src/sync/SyncMods.lua new file mode 100644 index 00000000..018c80c5 --- /dev/null +++ b/src/sync/SyncMods.lua @@ -0,0 +1,196 @@ +local SyncMods = {} + +SyncMods.REV = 1 + +local function versions() + local ok, GameVersion = pcall(require, "src.core.GameVersion") + if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end + return { "red", "blue", "yellow", "gold" } +end + +local function defaultDeps() + return { + installed = function() + return require("src.mods.LauncherMods").list() + end, + indexes = function() + return require("src.mods.ModIndex").sources() + end, + addIndex = function(url) + return require("src.mods.ModIndex").addSource(url) + end, + findEntry = function(id) + local ModIndex = require("src.mods.ModIndex") + for _, source in ipairs(ModIndex.sources()) do + local cached = ModIndex.readCache(source.feed) + for _, entry in ipairs((cached and cached.mods) or {}) do + if entry.id == id then return entry end + end + end + return nil + end, + install = function(entry) + return require("src.mods.LauncherMods").installFromIndex(entry) + end, + setEnabled = function(id, enabled, version) + return require("src.mods.LauncherMods").setEnabled(id, enabled, version) + end, + } +end + +local function deps(given) + local out = defaultDeps() + if type(given) == "table" then + for k, v in pairs(given) do out[k] = v end + end + return out +end + +local function sourceOf(row) + local github = row.github + or (type(row.manifest) == "table" and row.manifest.github) + if type(github) == "string" and github ~= "" then + return "github:" .. github + end + return "local" +end + +function SyncMods.build(given) + local d = deps(given) + local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} } + for _, row in ipairs(d.indexes() or {}) do + local url = row.url or row.feed + if type(url) == "string" and url ~= "" then + manifest.indexes[#manifest.indexes + 1] = url + end + end + table.sort(manifest.indexes) + for _, row in ipairs(d.installed() or {}) do + if type(row) == "table" and type(row.id) == "string" then + local enabledFor = {} + local answers = row.enabledByVersion or {} + for _, version in ipairs(versions()) do + if answers[version] then enabledFor[#enabledFor + 1] = version end + end + manifest.mods[#manifest.mods + 1] = { + id = row.id, + version = row.version, + source = sourceOf(row), + enabledFor = enabledFor, + } + end + end + table.sort(manifest.mods, function(a, b) return a.id < b.id end) + return manifest +end + +function SyncMods.plan(manifest, given) + local d = deps(given) + local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} } + if type(manifest) ~= "table" then return plan end + + local haveIndex = {} + for _, row in ipairs(d.indexes() or {}) do + if type(row.url) == "string" then haveIndex[row.url] = true end + if type(row.feed) == "string" then haveIndex[row.feed] = true end + end + for _, url in ipairs(manifest.indexes or {}) do + if type(url) == "string" and url ~= "" and not haveIndex[url] then + plan.indexes[#plan.indexes + 1] = url + haveIndex[url] = true + end + end + + local installed = {} + for _, row in ipairs(d.installed() or {}) do + if type(row) == "table" and type(row.id) == "string" then + installed[row.id] = row + end + end + + for _, mod in ipairs(manifest.mods or {}) do + if type(mod) == "table" and type(mod.id) == "string" then + local here = installed[mod.id] + local available = here ~= nil + if not here then + local entry = d.findEntry(mod.id) + if entry then + available = true + plan.toInstall[#plan.toInstall + 1] = + { id = mod.id, version = mod.version, entry = entry } + else + plan.missing[#plan.missing + 1] = + { id = mod.id, version = mod.version, source = mod.source } + end + end + if available then + local answers = (here and here.enabledByVersion) or {} + for _, version in ipairs(mod.enabledFor or {}) do + if answers[version] ~= true then + plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version } + end + end + end + end + end + return plan +end + +function SyncMods.planEmpty(plan) + if type(plan) ~= "table" then return true end + return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0 + and #(plan.toEnable or {}) == 0 +end + +function SyncMods.steps(plan, given) + local d = deps(given) + local out = {} + if type(plan) ~= "table" then return out end + local broken = {} + + for _, url in ipairs(plan.indexes or {}) do + out[#out + 1] = { label = url, run = function() + local ok, err = d.addIndex(url) + if not ok then return nil, tostring(err or url) end + return true + end } + end + for _, mod in ipairs(plan.toInstall or {}) do + out[#out + 1] = { label = mod.id, run = function() + local ok, err = d.install(mod.entry) + if not ok then + broken[mod.id] = true + return nil, mod.id .. ": " .. tostring(err or "install failed") + end + return true + end } + end + for _, want in ipairs(plan.toEnable or {}) do + out[#out + 1] = { label = want.id, run = function() + if broken[want.id] then return true end + local ok, err = d.setEnabled(want.id, true, want.version) + if ok == false then + return nil, want.id .. ": " .. tostring(err or "could not enable") + end + return true + end } + end + return out +end + +function SyncMods.apply(plan, progress, given) + if type(plan) ~= "table" then return false, "nothing to apply" end + local steps = SyncMods.steps(plan, given) + local failures = {} + for i, step in ipairs(steps) do + local ok, err = step.run() + if not ok then failures[#failures + 1] = err end + if progress then progress(i, #steps, step.label) end + end + if #failures > 0 then + return false, table.concat(failures, "; ") + end + return true +end + +return SyncMods diff --git a/src/sync/SyncState.lua b/src/sync/SyncState.lua new file mode 100644 index 00000000..53e4e840 --- /dev/null +++ b/src/sync/SyncState.lua @@ -0,0 +1,129 @@ +local SaveData = require("src.core.SaveData") + +local SyncState = {} + +SyncState.KEY = "saveSync" + +function SyncState.defaults() + return { + enabled = false, + lastSyncAt = 0, + revs = {}, + stamps = {}, + pendingConflicts = {}, + } +end + +local function str(v) + if type(v) == "string" and v ~= "" then return v end + return nil +end + +local function num(v) + local n = tonumber(v) + if type(n) ~= "number" or n ~= n or n == math.huge or n == -math.huge then + return nil + end + return n +end + +function SyncState.sanitize(raw) + local out = SyncState.defaults() + if type(raw) ~= "table" then return out end + out.enabled = raw.enabled == true + out.account = str(raw.account) + out.deviceToken = str(raw.deviceToken) + out.deviceId = str(raw.deviceId) + out.deviceLabel = str(raw.deviceLabel) + out.lastSyncAt = num(raw.lastSyncAt) or 0 + if type(raw.revs) == "table" then + for key, rev in pairs(raw.revs) do + local n = num(rev) + if type(key) == "string" and n then out.revs[key] = n end + end + end + if type(raw.stamps) == "table" then + for key, at in pairs(raw.stamps) do + local n = num(at) + if type(key) == "string" and n then out.stamps[key] = n end + end + end + if type(raw.pendingConflicts) == "table" then + for _, row in ipairs(raw.pendingConflicts) do + if type(row) == "table" and str(row.key) then + out.pendingConflicts[#out.pendingConflicts + 1] = { + key = row.key, + version = str(row.version), + playthroughId = str(row.playthroughId), + overlap = row.overlap == true, + } + end + end + end + return out +end + +function SyncState.load(fs) + local opts = SaveData.loadOptions(fs) + return SyncState.sanitize(opts and opts[SyncState.KEY]) +end + +function SyncState.save(state, fs) + local opts = SaveData.loadOptions(fs) + opts[SyncState.KEY] = SyncState.sanitize(state) + SaveData.saveOptions(opts, fs) + return opts[SyncState.KEY] +end + +function SyncState.update(fn, fs) + local state = SyncState.load(fs) + fn(state) + return SyncState.save(state, fs) +end + +function SyncState.clear(fs) + return SyncState.save(SyncState.defaults(), fs) +end + +function SyncState.linked(state) + return type(state) == "table" and str(state.account) ~= nil + and str(state.deviceToken) ~= nil +end + +function SyncState.key(version, playthroughId) + if type(version) ~= "string" or version == "" then return nil end + if type(playthroughId) ~= "string" or playthroughId == "" then return nil end + return version .. "/" .. playthroughId +end + +function SyncState.splitKey(key) + if type(key) ~= "string" then return nil end + local version, id = key:match("^([^/]+)/(.+)$") + return version, id +end + +function SyncState.rev(state, key) + if type(state) ~= "table" or type(state.revs) ~= "table" then return nil end + return state.revs[key] +end + +function SyncState.stamp(state, key) + if type(state) ~= "table" or type(state.stamps) ~= "table" then return nil end + return state.stamps[key] +end + +function SyncState.setRev(state, key, rev, savedAt) + if type(state) ~= "table" or type(key) ~= "string" then return end + state.revs = state.revs or {} + state.stamps = state.stamps or {} + state.revs[key] = num(rev) + state.stamps[key] = num(savedAt) +end + +function SyncState.forget(state, key) + if type(state) ~= "table" or type(key) ~= "string" then return end + if type(state.revs) == "table" then state.revs[key] = nil end + if type(state.stamps) == "table" then state.stamps[key] = nil end +end + +return SyncState diff --git a/src/sync/SyncTransport.lua b/src/sync/SyncTransport.lua new file mode 100644 index 00000000..85c6b4b9 --- /dev/null +++ b/src/sync/SyncTransport.lua @@ -0,0 +1,41 @@ +local Transport = {} +Transport.__index = Transport + +function Transport.new(fetch) + return setmetatable({ fetch = fetch or require("src.net.Fetch") }, Transport) +end + +function Transport:begin(req) + return self.fetch.request(req.url, { + method = req.method, + body = req.body, + headers = req.headers, + maxSeconds = req.maxSeconds, + }) +end + +function Transport:poll(handle) + local st = self.fetch.poll(handle) + if st.status == "pending" then return { status = "pending" } end + if st.status == "cancelled" then + return { status = "error", err = "sync request cancelled" } + end + if st.status ~= "ok" then + return { status = "error", err = st.err or "sync request failed" } + end + return { status = "ok", body = st.body or "", code = tonumber(st.code) } +end + +function Transport:release(handle) + if handle ~= nil and self.fetch.release then self.fetch.release(handle) end +end + +function Transport:cancel(handle) + if handle ~= nil and self.fetch.cancel then self.fetch.cancel(handle) end +end + +function Transport:available() + return self.fetch.available and self.fetch.available() or false +end + +return Transport diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 4233906f..2e624fa3 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -40,6 +40,51 @@ local HANDLES = { local GB_ASPECT = 160 / 144 +Studio.UNDO_CAP = 50 +Studio.SNAP_PX = 4 +Studio.STATUS_HOLD = 4 + +Studio.FORMAT_LABEL = { + native = "gen1recomp", + retroarch = "RetroArch", + delta = "Delta", +} + +Studio.EXPORTS = { + { id = "native", label = "gen1recomp .zip", + hint = "Reopens here and in the launcher." }, + { id = "retroarch", label = "RetroArch .zip", + hint = "An overlay .cfg RetroArch can load." }, + { id = "delta", label = "Delta .deltaskin", + hint = "info.json plus art, for Delta and Ignited." }, +} + +Studio.BIND_GROUPS = { + { title = "GAME BOY", + specs = { "a", "b", "start", "select", "up", "down", "left", "right" } }, + { title = "DIAGONALS", + specs = { "left|up", "right|up", "left|down", "right|down" } }, + { title = "HOTKEYS", + specs = { "menu_toggle", "reset", "hold_fast_forward", + "toggle_fast_forward", "overlay_next", "overlay_previous", + "pause_toggle", "screenshot", "exit_emulator" } }, + { title = "KEYBOARD", + specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } }, + { title = "NO INPUT", specs = { "nul" } }, +} + +Studio.BIND_PARTS = { "left", "right", "up", "down", "a", "b", "start", "select" } + +local PART_RANK = { + left = 1, right = 2, up = 3, down = 4, + a = 5, b = 6, start = 7, select = 8, +} + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return 0 +end + local function clamp01(v) if v ~= v then return 0 end if v < 0 then return 0 end @@ -65,9 +110,136 @@ function Studio.selectedControl() return page.controls[Studio.selected] end +local function setStatus(text, isError) + Studio.status = text + Studio.statusErr = isError == true + Studio.statusAt = now() +end +Studio.setStatus = setStatus + +function Studio.expireStatus() + if not Studio.statusErr or not Studio.status then return end + if now() - (Studio.statusAt or 0) > Studio.STATUS_HOLD then + Studio.status, Studio.statusErr = nil, false + end +end + local function markDirty() Studio.dirty = true - Studio.status = nil + if not Studio.statusErr then Studio.status = nil end +end + +local function snapshot() + return { + skin = TouchSkin.clone(Studio.skin), + pageIndex = Studio.pageIndex, + selected = Studio.selected, + idField = Studio.skinIdField, + } +end + +local function restore(snap) + Studio.skin = snap.skin + Studio.pageIndex = snap.pageIndex or 1 + Studio.selected = snap.selected + Studio.skinIdField = snap.idField or Studio.skinIdField + TouchSkin.setActive(Studio.skin) + TouchSkin.pageIndex = Studio.pageIndex + Studio.dirty = true +end + +function Studio.pushUndo(tag) + if not Studio.skin then return false end + Studio.undoStack = Studio.undoStack or {} + Studio.redoStack = {} + if tag and Studio.undoTag == tag and now() - (Studio.undoAt or 0) < 1 then + return false + end + Studio.undoTag, Studio.undoAt = tag, now() + local stack = Studio.undoStack + stack[#stack + 1] = snapshot() + while #stack > Studio.UNDO_CAP do table.remove(stack, 1) end + return true +end + +function Studio.undo() + local stack = Studio.undoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to undo.") + return false + end + table.remove(stack) + Studio.redoStack = Studio.redoStack or {} + Studio.redoStack[#Studio.redoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Undo") + return true +end + +function Studio.redo() + local stack = Studio.redoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to redo.") + return false + end + table.remove(stack) + Studio.undoStack = Studio.undoStack or {} + Studio.undoStack[#Studio.undoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Redo") + return true +end + +function Studio.canUndo() return #(Studio.undoStack or {}) > 0 end +function Studio.canRedo() return #(Studio.redoStack or {}) > 0 end + +function Studio.openModal(kind, data) + data = data or {} + data.kind = kind + data.scroll = 0 + Studio.modal = data + return data +end + +function Studio.closeModal() + Studio.modal = nil + Kit.blur() +end + +function Studio.modalUp() + return Studio.modal ~= nil or Studio.confirm ~= nil +end + +function Studio.ask(text, onYes, yesLabel) + Studio.confirm = { text = text, onYes = onYes, + yesLabel = yesLabel or "Discard" } + return Studio.confirm +end + +function Studio.confirmYes() + local c = Studio.confirm + Studio.confirm = nil + if c and c.onYes then c.onYes() end + return c ~= nil +end + +function Studio.confirmNo() + local had = Studio.confirm ~= nil + Studio.confirm = nil + return had +end + +function Studio.guard(text, fn) + if not Studio.dirty or not Studio.skin then + fn() + return true + end + Studio.ask(text, fn) + return false end local function syncActive() @@ -193,7 +365,15 @@ function Studio.load(opts) Studio.aspectLock = true Studio.skinIdField = "" Studio.available = TouchSkin.list() + Studio.availableMeta = {} Studio.imageTarget = "idle" + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.showLabels = true + Studio.statusErr = false + Studio.thumbs = {} TouchControls:init() TouchControls.active = true @@ -230,11 +410,91 @@ function Studio.open(id) Studio.selected = nil Studio.dirty = false Studio.images = TouchSkin.listImages(Studio.skin.root) + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag = nil + Studio.thumbs = {} syncActive() Studio.applyImportedOrient() return true end +function Studio.newSkin() + Studio.pushUndo() + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = "new_skin" + Studio.pageIndex, Studio.selected = 1, nil + Studio.images = {} + Studio.thumbs = {} + syncActive() + markDirty() +end + +function Studio.skinFormat(skin) + skin = skin or Studio.skin + local fmt = skin and skin.format or "native" + return Studio.FORMAT_LABEL[fmt] or fmt +end + +function Studio.describeSkin(entry) + Studio.availableMeta = Studio.availableMeta or {} + local meta = Studio.availableMeta[entry.id] + if meta then return meta end + local skin = TouchSkin.load(entry.root, entry.id) + local page = skin and skin.pages[1] + local controls = 0 + for _, ctl in ipairs(page and page.controls or {}) do + if not ctl.decorative then controls = controls + 1 end + end + meta = { + id = entry.id, + source = entry.source, + format = Studio.skinFormat(skin), + pages = skin and #skin.pages or 0, + controls = controls, + ok = skin ~= nil, + } + Studio.availableMeta[entry.id] = meta + return meta +end + +function Studio.skinSummary(entry) + local meta = Studio.describeSkin(entry) + local bits = { meta.source == "user" and "installed" or "bundled", meta.format } + bits[#bits + 1] = meta.controls .. " buttons" + if meta.pages > 1 then bits[#bits + 1] = meta.pages .. " pages" end + return table.concat(bits, " \194\183 ") +end + +function Studio.refreshAvailable() + Studio.available = TouchSkin.list() + Studio.availableMeta = {} + return Studio.available +end + +function Studio.openLoadPicker() + return Studio.guard("Open another skin and lose the unsaved changes?", + function() + Studio.refreshAvailable() + Studio.openModal("open") + end) +end + +function Studio.loadEntry(id) + Studio.closeModal() + if not Studio.open(id) then + setStatus("Could not open " .. tostring(id), true) + return false + end + local warning = Studio.skin and Studio.skin.warnings + and Studio.skin.warnings[1] + if warning then + setStatus("Opened " .. id .. ": " .. tostring(warning), true) + else + setStatus("Opened " .. id) + end + return true +end + function Studio.unload() Studio.pendingPlay = false TouchSkin.setSurface(nil) @@ -246,6 +506,9 @@ function Studio.unload() Studio.onClose = nil Studio.onPlay = nil Studio.drag = nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.undoStack, Studio.redoStack = {}, {} end -- --------------------------------------------------------------- editing @@ -253,6 +516,7 @@ end function Studio.addControl() local page = Studio.page() if not page then return end + Studio.pushUndo() page.controls[#page.controls + 1] = TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial") Studio.selected = #page.controls @@ -262,6 +526,7 @@ end function Studio.deleteControl() local page = Studio.page() if not page or not Studio.selected then return end + Studio.pushUndo() table.remove(page.controls, Studio.selected) Studio.selected = page.controls[Studio.selected] and Studio.selected or (#page.controls > 0 and #page.controls or nil) @@ -271,6 +536,7 @@ end function Studio.duplicateControl() local page, ctl = Studio.page(), Studio.selectedControl() if not page or not ctl then return end + Studio.pushUndo() local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04), clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) copy.imagePath, copy.image = ctl.imagePath, ctl.image @@ -288,10 +554,108 @@ function Studio.cycleBind(dir) for i, spec in ipairs(list) do if spec == ctl.spec then at = i break end end + Studio.pushUndo("bind") TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1]) markDirty() end +function Studio.bindParts(spec) + local out = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = raw:gsub("^%s+", ""):gsub("%s+$", ""):lower() + if name ~= "" and name ~= "nul" then out[#out + 1] = name end + end + return out +end + +function Studio.hasBindPart(spec, part) + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then return true end + end + return false +end + +function Studio.toggleBindPart(spec, part) + local kept, found = {}, false + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then + found = true + else + kept[#kept + 1] = name + end + end + if not found then kept[#kept + 1] = part end + if #kept == 0 then return "nul" end + table.sort(kept, function(a, b) + local ra, rb = PART_RANK[a] or 9, PART_RANK[b] or 9 + if ra ~= rb then return ra < rb end + return a < b + end) + return table.concat(kept, "|") +end + +function Studio.setBindSpec(spec) + local ctl = Studio.selectedControl() + if not ctl or not spec then return false end + Studio.pushUndo() + TouchSkin.setBind(ctl, spec) + markDirty() + return true +end + +function Studio.toggleSelectedBindPart(part) + local ctl = Studio.selectedControl() + if not ctl then return false end + return Studio.setBindSpec(Studio.toggleBindPart(ctl.spec, part)) +end + +function Studio.openBindPicker() + if not Studio.selectedControl() then + setStatus("Select a control first.", true) + return false + end + Studio.openModal("bind") + return true +end + +function Studio.moveControlOrder(delta) + local page, i = Studio.page(), Studio.selected + if not page or not i then return false end + local j = i + delta + if j < 1 or j > #page.controls then return false end + Studio.pushUndo() + page.controls[i], page.controls[j] = page.controls[j], page.controls[i] + Studio.selected = j + markDirty() + return true +end + +function Studio.nudge(dx, dy, big) + local ctl = Studio.selectedControl() + if not ctl then return false end + local canvas = Studio.canvas() + local step = big and 10 or 1 + Studio.pushUndo("nudge") + ctl.x = clamp01(ctl.x + dx * step / canvas.w) + ctl.y = clamp01(ctl.y + dy * step / canvas.h) + markDirty() + return true +end + +function Studio.snapOffset(edges, lines, tol) + local best, at = nil, nil + for _, e in ipairs(edges) do + for _, l in ipairs(lines) do + local d = l - e + if math.abs(d) <= tol and (best == nil or math.abs(d) < math.abs(best)) then + best, at = d, l + end + end + end + return best or 0, at +end + + function Studio.cycleImage(dir) local ctl = Studio.selectedControl() local page = Studio.page() @@ -318,6 +682,56 @@ function Studio.cycleImage(dir) markDirty() end +function Studio.refreshImages() + Studio.images = Studio.skin and TouchSkin.listImages(Studio.skin.root) or {} + Studio.thumbs = {} + return Studio.images +end + +function Studio.openImagePicker(target) + if not Studio.skin then return false end + target = target or Studio.imageTarget + if target ~= "bezel" and not Studio.selectedControl() then + setStatus("Select a control first, or pick a bezel image.", true) + return false + end + Studio.imageTarget = target + Studio.refreshImages() + Studio.openModal("image") + return true +end + +function Studio.currentImagePath() + local target = Studio.imageTarget + if target == "bezel" then + local page = Studio.page() + return page and page.imagePath + end + local ctl = Studio.selectedControl() + if not ctl then return nil end + return (target == "pressed") and ctl.pressedImagePath or ctl.imagePath +end + +function Studio.thumb(rel) + if not rel or not Studio.skin then return nil end + Studio.thumbs = Studio.thumbs or {} + local cached = Studio.thumbs[rel] + if cached ~= nil then return cached or nil end + local img = TouchSkin.resolveImage(Studio.skin.root, rel) + Studio.thumbs[rel] = img or false + return img +end + +function Studio.chooseImage(rel) + if not Studio.skin then return false end + Studio.pushUndo() + Studio.assignImage(rel) + Studio.closeModal() + setStatus(rel and ("Using " .. rel .. " as " .. Studio.imageTargetLabel()) + or ("Cleared the " .. Studio.imageTargetLabel())) + return true +end + function Studio.imageTargetLabel() local target = Studio.imageTarget if target == "bezel" or not Studio.selectedControl() then return "bezel" end @@ -352,23 +766,24 @@ function Studio.adoptImage(name, data, target) if not Studio.skin then return false end if target then Studio.imageTarget = target end if not FilePicker.matches(name, FilePicker.IMAGE) then - Studio.status = "Pick a PNG or JPG." + setStatus("Pick a PNG or JPG.", true) return false end commitSkinId() local rel, err = TouchSkin.importImage(Studio.skin, name, data) if not rel then - Studio.status = "Import failed: " .. tostring(err) + setStatus("Import failed: " .. tostring(err), true) return false end local where = Studio.imageTargetLabel() + Studio.pushUndo() Studio.assignImage(rel) Studio.skinIdField = Studio.skin.id - Studio.status = "Imported " .. rel .. " as " .. where + local text = "Imported " .. rel .. " as " .. where if where == "bezel" and not Studio.canvas().lockViewport then - Studio.status = Studio.status - .. " -- use Detect screen from bezel to place the screen" + text = text .. " -- use Detect screen from bezel to place the screen" end + setStatus(text) return true end @@ -377,11 +792,11 @@ function Studio.importImageFile(target) target = target or Studio.imageTarget Studio.imageTarget = target if target ~= "bezel" and not Studio.selectedControl() then - Studio.status = "Select a control first, or import a bezel image." + setStatus("Select a control first, or import a bezel image.", true) return end if not FilePicker.available() then - Studio.status = "No file picker here -- drag a PNG onto the window instead." + setStatus("No file picker here -- drag a PNG onto the window instead.", true) return end local prompt = (target == "bezel") and "Choose a bezel image" @@ -391,7 +806,7 @@ function Studio.importImageFile(target) local base = FilePicker.basename(path) local data, err = FilePicker.read(path) if not data then - Studio.status = "Could not read " .. base .. ": " .. tostring(err) + setStatus("Could not read " .. base .. ": " .. tostring(err), true) return end Studio.adoptImage(base, data, target) @@ -402,7 +817,7 @@ function Studio.filedropped(file) local path = (file.getFilename and file:getFilename()) or "" local base = FilePicker.basename(path) if not FilePicker.matches(base, FilePicker.IMAGE) then - Studio.status = "Drop a PNG or JPG to use it as art." + setStatus("Drop a PNG or JPG to use it as art.", true) return end local ok, data = pcall(function() @@ -412,7 +827,7 @@ function Studio.filedropped(file) return bytes end) if not ok or not data then - Studio.status = "Could not read " .. base + setStatus("Could not read " .. base, true) return end Studio.adoptImage(base, data) @@ -422,20 +837,21 @@ function Studio.detectViewport() local page = Studio.page() if not page or not Studio.skin then return end if Studio.canvas().lockViewport then - Studio.status = "This preset locks the screen position." + setStatus("This preset locks the screen position.", true) return end if not page.imagePath then - Studio.status = "Pick a bezel image first." + setStatus("Pick a bezel image first.", true) return end local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath) if not rect then - Studio.status = "No transparent screen hole found in " .. page.imagePath + setStatus("No transparent screen hole found in " .. page.imagePath, true) return end + Studio.pushUndo() page.viewport = rect - Studio.status = ("Screen detected: %dx%d px in the bezel art"):format(pw, ph) + setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph)) Studio.dirty = true end @@ -443,6 +859,7 @@ function Studio.toggleViewport() local page = Studio.page() if not page then return end if Studio.canvas().lockViewport then return end + Studio.pushUndo() if page.viewport then page.viewport = nil else @@ -451,9 +868,80 @@ function Studio.toggleViewport() markDirty() end +function Studio.setPage(index) + local skin = Studio.skin + if not skin or not skin.pages[index] then return false end + Studio.pageIndex = index + Studio.selected = nil + syncActive() + Studio.syncCanvasToPage() + return true +end + +function Studio.nextPage() + local skin = Studio.skin + if not skin or #skin.pages == 0 then return false end + return Studio.setPage((Studio.pageIndex % #skin.pages) + 1) +end + +function Studio.pageLabel(index) + local skin = Studio.skin + local page = skin and skin.pages[index] + if not page then return "" end + local name = tostring(page.name or ("page" .. index)) + local orient = TouchSkin.pageOrient(page) + local bits = { #(page.controls or {}) .. " controls" } + if orient then bits[#bits + 1] = orient end + if page.viewport then bits[#bits + 1] = "screen" end + return name, table.concat(bits, " \194\183 ") +end + +function Studio.openPageMenu() + local skin = Studio.skin + if not skin then return false end + Studio.pageNameField = tostring(Studio.page() and Studio.page().name or "") + Studio.openModal("page") + return true +end + +function Studio.renamePage(name) + local page = Studio.page() + if not page then return false end + name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "") + if name == "" then + setStatus("Give the page a name first.", true) + return false + end + Studio.pushUndo() + page.name = name + markDirty() + setStatus("Renamed page " .. Studio.pageIndex .. " to " .. name) + return true +end + +function Studio.deletePage(index) + local skin = Studio.skin + index = index or Studio.pageIndex + if not skin or not skin.pages[index] then return false end + if #skin.pages <= 1 then + setStatus("A skin needs at least one page.", true) + return false + end + Studio.pushUndo() + table.remove(skin.pages, index) + for i, page in ipairs(skin.pages) do page.index = i end + Studio.pageIndex = math.min(Studio.pageIndex, #skin.pages) + Studio.selected = nil + syncActive() + markDirty() + setStatus("Deleted a page") + return true +end + function Studio.addPage() local skin = Studio.skin if not skin then return end + Studio.pushUndo() local page = TouchSkin.newSkin(skin.id).pages[1] page.name = "page" .. (#skin.pages + 1) page.index = #skin.pages + 1 @@ -470,33 +958,84 @@ function Studio.save() if not skin then return end local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") if id == "" then - Studio.status = "Give the skin a name first." + setStatus("Give the skin a name first.", true) return end local dest, failed = TouchSkin.saveTo(skin, id) if not dest then - Studio.status = "Save failed: " .. tostring(failed) + setStatus("Save failed: " .. tostring(failed), true) return end Studio.dirty = false - Studio.available = TouchSkin.list() - Studio.status = "Saved to " .. dest + Studio.refreshAvailable() + local text = "Saved to " .. dest if type(failed) == "table" and failed[1] then - Studio.status = Studio.status .. " (" .. #failed .. " image(s) not found)" + text = text .. " (" .. #failed .. " image(s) not found)" end + setStatus(text) +end + +function Studio.exportAs(kind) + local skin = Studio.skin + if not skin then return nil end + if Studio.dirty then Studio.save() end + if Studio.dirty then return nil end + local path, missing, warnings + if kind == "retroarch" then + path, missing = TouchSkin.exportRetroArch(skin) + elseif kind == "delta" then + path, missing, warnings = TouchSkin.exportDelta(skin) + else + path, missing = TouchSkin.export(skin) + end + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + local text = "Exported " .. path + if type(missing) == "table" and missing[1] then + text = text .. " (" .. #missing .. " image(s) not found)" + end + if type(warnings) == "table" and warnings[1] then + text = text .. " " .. tostring(warnings[1]) + end + setStatus(text) + Studio.lastExport = path + Studio.refreshAvailable() + return path end function Studio.export() - local skin = Studio.skin - if not skin then return end - if Studio.dirty then Studio.save() end - local path, missing = TouchSkin.export(skin) - if not path then - Studio.status = "Export failed: " .. tostring(missing) - return + return Studio.exportAs("native") +end + +function Studio.openExportMenu() + Studio.openModal("export") + return true +end + +function Studio.fileUrl(path) + path = tostring(path):gsub("\\", "/") + if path:sub(1, 1) ~= "/" then path = "/" .. path end + local encoded = path:gsub("[^%w%-%._~/:]", function(c) + return string.format("%%%02X", string.byte(c)) + end) + return "file://" .. encoded +end + +function Studio.revealExport() + local path = Studio.lastExport + if not path then return false end + if not (love and love.filesystem and love.filesystem.getSaveDirectory) then + return false end - Studio.status = "Exported " .. path - Studio.available = TouchSkin.list() + local dir = love.filesystem.getSaveDirectory() + if not dir then return false end + if love.system and love.system.openURL then + pcall(love.system.openURL, Studio.fileUrl(dir)) + end + setStatus("Saved in " .. dir .. "/" .. path) + return true end function Studio.play() @@ -514,7 +1053,7 @@ function Studio.play() -- leave the rest of this frame drawing against a dead skin; Studio.update -- runs it on the next tick instead. Studio.pendingPlay = true - Studio.status = "Starting the game with " .. skin.id .. "..." + setStatus("Starting the game with " .. skin.id .. "...") end -- ---------------------------------------------------------------- canvas @@ -533,6 +1072,25 @@ local function controlRect(page, ctl, r) return cx - halfW, cy - halfH, halfW * 2, halfH * 2 end +function Studio.snapLines(page, r, skipIndex) + local xs, ys = {}, {} + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) + xs[1], xs[2], xs[3] = px, px + pw * 0.5, px + pw + ys[1], ys[2], ys[3] = py, py + ph * 0.5, py + ph + for i, ctl in ipairs(page.controls or {}) do + if i ~= skipIndex then + local bx, by, bw, bh = controlRect(page, ctl, r) + xs[#xs + 1] = bx + xs[#xs + 1] = bx + bw * 0.5 + xs[#xs + 1] = bx + bw + ys[#ys + 1] = by + ys[#ys + 1] = by + bh * 0.5 + ys[#ys + 1] = by + bh + end + end + return xs, ys +end + local function viewportRect(page, r) local v = page.viewport if not v then return nil end @@ -564,12 +1122,14 @@ end function Studio.beginCanvasDrag(mx, my, r) local page = Studio.page() if not page then return end + Studio.guides = nil local ctl = Studio.selectedControl() if ctl then local bx, by, bw, bh = controlRect(page, ctl, r) for _, h in ipairs(handleRects(bx, by, bw, bh)) do if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my, bx = bx, by = by, bw = bw, bh = bh } return @@ -581,6 +1141,7 @@ function Studio.beginCanvasDrag(mx, my, r) if vx and not Studio.canvas().lockViewport then for _, h in ipairs(handleRects(vx, vy, vw, vh)) do if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my, bx = vx, by = vy, bw = vw, bh = vh } Studio.selected = nil @@ -594,6 +1155,7 @@ function Studio.beginCanvasDrag(mx, my, r) local bx, by, bw, bh = controlRect(page, c, r) if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then Studio.selected = i + Studio.pushUndo() Studio.drag = { kind = "control-move", mx = mx, my = my, bx = bx, by = by, bw = bw, bh = bh } return @@ -603,6 +1165,7 @@ function Studio.beginCanvasDrag(mx, my, r) if vx and not Studio.canvas().lockViewport and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then Studio.selected = nil + Studio.pushUndo() Studio.drag = { kind = "viewport-move", mx = mx, my = my, bx = vx, by = vy, bw = vw, bh = vh } return @@ -645,6 +1208,15 @@ function Studio.updateDrag(mx, my, r) end end + if d.kind == "control-move" then + local tol = Studio.SNAP_PX * Kit.scale + local xs, ys = Studio.snapLines(page, r, Studio.selected) + local offX, lineX = Studio.snapOffset({ bx, bx + bw * 0.5, bx + bw }, xs, tol) + local offY, lineY = Studio.snapOffset({ by, by + bh * 0.5, by + bh }, ys, tol) + bx, by = bx + offX, by + offY + Studio.guides = { x = lineX, y = lineY } + end + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) if pw <= 0 or ph <= 0 then return end if d.kind:find("control") then @@ -712,6 +1284,11 @@ local function drawCanvas(x, y, w, h) local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line) Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45, selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2) + if Studio.showLabels and bw > 8 * Kit.scale then + Kit.textCenter("micro", Kit.ellipsize("micro", ctl.spec, bw), bx, + by + bh * 0.5 - Kit.textHeight("micro") * 0.5, bw, + selected and PAL.green or PAL.muted) + end if selected then for _, hd in ipairs(handleRects(bx, by, bw, bh)) do Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1) @@ -720,6 +1297,16 @@ local function drawCanvas(x, y, w, h) PAL.green) end end + + local guides = Studio.drag and Studio.guides + if guides then + if guides.x then + Theme.fill(guides.x, r.y, math.max(1, Kit.scale), r.h, PAL.yellow, 0.6) + end + if guides.y then + Theme.fill(r.x, guides.y, r.w, math.max(1, Kit.scale), PAL.yellow, 0.6) + end + end return r end @@ -739,40 +1326,37 @@ local function inspectorBody(x, y, w) local third = (w - gap * 2) / 3 if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end - if Kit.button(x + third + gap, cy, third, rowH, "Export", { id = "export" }) then - Studio.export() + if Kit.button(x + third + gap, cy, third, rowH, "Export \226\150\184", + { id = "export" }) then + Studio.openExportMenu() end if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then Studio.play() end - cy = cy + rowH + gap * 2 + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", "Format: " .. Studio.skinFormat(), x, cy, PAL.faint) + cy = cy + Kit.textHeight("small") + gap * 2 Kit.caption(x, cy, "OPEN") cy = cy + Kit.textHeight("small") + gap local half = (w - gap) / 2 if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then - Studio.skin = TouchSkin.newSkin("new_skin") - Studio.skinIdField = "new_skin" - Studio.pageIndex, Studio.selected = 1, nil - Studio.images = {} - syncActive() - markDirty() + Studio.guard("Start a new skin and lose the unsaved changes?", + Studio.newSkin) end if Kit.button(x + half + gap, cy, half, rowH, "Load " .. (#Studio.available > 0 and "\226\150\184" or "-"), { id = "load", enabled = #Studio.available > 0 }) then - Studio.loadIndex = ((Studio.loadIndex or 0) % #Studio.available) + 1 - Studio.open(Studio.available[Studio.loadIndex].id) + Studio.openLoadPicker() end cy = cy + rowH + gap * 2 Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {})) cy = cy + Kit.textHeight("small") + gap - if Kit.button(x, cy, half, rowH, "Next page", { id = "pagenext" }) then - Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1 - Studio.selected = nil - syncActive() - Studio.syncCanvasToPage() + if Kit.button(x, cy, half, rowH, + "Pages \226\150\184 " .. tostring(page and page.name or "-"), + { id = "pagelist" }) then + Studio.openPageMenu() end if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then Studio.addPage() @@ -794,8 +1378,7 @@ local function inspectorBody(x, y, w) local pickW = 82 * Kit.scale local cycleW = w - pickW - gap if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then - Studio.imageTarget = "bezel" - Studio.cycleImage(1) + Studio.openImagePicker("bezel") end if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import", { id = "bezelpick" }) then @@ -838,19 +1421,36 @@ local function inspectorBody(x, y, w) end local canvas = Studio.canvas() + local zW = 68 * Kit.scale + if Kit.button(x, cy, zW, rowH, "Back", { id = "zback", + enabled = (Studio.selected or 1) > 1 }) then + Studio.moveControlOrder(-1) + end + if Kit.button(x + zW + gap, cy, zW, rowH, "Front", { id = "zfront", + enabled = Studio.selected ~= nil and page ~= nil + and Studio.selected < #page.controls }) then + Studio.moveControlOrder(1) + end + Kit.text("small", ("%d / %d"):format(Studio.selected or 0, + page and #page.controls or 0), x + (zW + gap) * 2, + cy + (rowH - Kit.textHeight("small")) * 0.5, PAL.faint) + cy = cy + rowH + gap + if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then - Studio.cycleBind(1) + Studio.openBindPicker() end cy = cy + rowH + 2 * Kit.scale Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted) cy = cy + Kit.textHeight("small") + gap if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then + Studio.pushUndo() ctl.shape = ctl.shape == "radial" and "rect" or "radial" markDirty() end if Kit.button(x + half + gap, cy, half, rowH, string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then + Studio.pushUndo() ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25) markDirty() end @@ -885,8 +1485,7 @@ local function inspectorBody(x, y, w) local artW = w - pickW - gap local idle = ctl.imagePath or "(none)" if Kit.button(x, cy, artW, rowH, "Idle art: " .. idle, { id = "img" }) then - Studio.imageTarget = "idle" - Studio.cycleImage(1) + Studio.openImagePicker("idle") end if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then Studio.importImageFile("idle") @@ -894,8 +1493,7 @@ local function inspectorBody(x, y, w) cy = cy + rowH + gap local pressed = ctl.pressedImagePath or "(none)" if Kit.button(x, cy, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then - Studio.imageTarget = "pressed" - Studio.cycleImage(1) + Studio.openImagePicker("pressed") end if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then Studio.importImageFile("pressed") @@ -934,6 +1532,7 @@ function Studio.commitField(id, text) if not ctl then return end local n = tonumber(text) if not n then return end + Studio.pushUndo("field") local canvas = Studio.canvas() local left = (ctl.x - ctl.rangeX) * canvas.w local top = (ctl.y - ctl.rangeY) * canvas.h @@ -950,6 +1549,307 @@ function Studio.commitField(id, text) markDirty() end +local function modalFrame(W, H, title, wFrac, hFrac) + Theme.fill(0, 0, W, H, PAL.bg, 0.85) + local pad = 14 * Kit.scale + local mw = math.min(W - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * W)) + local mh = math.min(H - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * H)) + local mx = math.floor((W - mw) * 0.5) + local my = math.floor((H - mh) * 0.5) + Kit.card(mx, my, mw, mh) + Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading) + return mx, my, mw, mh, pad +end + +local function modalFooter(mx, my, mw, mh, pad) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + if Kit.button(mx + mw - pad - 110 * Kit.scale, by, 110 * Kit.scale, bh, + "Close", { id = "modal-close" }) then + Studio.closeModal() + end + return by, bh +end + +local function modalScroll(modal, x, y, w, h, contentH) + local maxScroll = Kit.scrollExtent(contentH, h) + modal.scroll = select(1, Kit.scrollWheel(modal.scroll or 0, maxScroll, + x, y, w, h)) + modal.scroll = Kit.scrollClamp(modal.scroll, maxScroll) + return modal.scroll, maxScroll +end + +local function drawBindModal(W, H) + local modal = Studio.modal + local ctl = Studio.selectedControl() + local mx, my, mw, mh, pad = modalFrame(W, H, "Choose a bind", 0.66, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 30 * Kit.scale) + local gap = 6 * Kit.scale + local cols = math.max(2, math.floor((mw - pad * 2) / (150 * Kit.scale))) + local colW = (mw - pad * 2 - gap * (cols - 1)) / cols + + local contentH = modal.contentH or viewH + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local cy = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + local startY = cy + + Kit.caption(mx + pad, cy, "COMBINE") + cy = cy + Kit.textHeight("small") + gap + for i, part in ipairs(Studio.BIND_PARTS) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local on = ctl and Studio.hasBindPart(ctl.spec, part) + if Kit.chip(px, py, colW, rowH, part, on, on and PAL.green or nil, + "bindpart-" .. part) then + Studio.toggleSelectedBindPart(part) + end + end + cy = cy + math.ceil(#Studio.BIND_PARTS / cols) * (rowH + gap) + gap + + for _, group in ipairs(Studio.BIND_GROUPS) do + Kit.caption(mx + pad, cy, group.title) + cy = cy + Kit.textHeight("small") + gap + for i, spec in ipairs(group.specs) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local active = ctl and ctl.spec == spec + if Kit.button(px, py, colW, rowH, spec, + { id = "bind-" .. spec, kind = active and "accent" or nil, + font = "small" }) then + Studio.setBindSpec(spec) + Studio.closeModal() + end + end + cy = cy + math.ceil(#group.specs / cols) * (rowH + gap) + gap + end + modal.contentH = cy - startY + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + + if ctl then + Kit.text("small", Kit.ellipsize("small", + "Bind: " .. ctl.spec .. " -> " .. TouchSkin.describeBind(ctl.spec), + mw - pad * 2 - 120 * Kit.scale), mx + pad, by + Kit.scale * 8, PAL.muted) + end +end + +local function drawImageModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, + "Choose art for the " .. Studio.imageTargetLabel(), 0.7, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Import a file...", + { id = "modal-import", kind = "accent", + enabled = FilePicker.available() }) then + local target = Studio.imageTarget + Studio.closeModal() + Studio.importImageFile(target) + end + + local viewH = by - top - pad + local gap = 8 * Kit.scale + local tile = math.max(72 * Kit.scale, 96 * Kit.scale) + local cols = math.max(2, math.floor((mw - pad * 2) / (tile + gap))) + local tileW = (mw - pad * 2 - gap * (cols - 1)) / cols + local tileH = tileW * 0.75 + Kit.textHeight("micro") + 6 * Kit.scale + + local list = { false } + for _, rel in ipairs(Studio.images or {}) do list[#list + 1] = rel end + local rows = math.ceil(#list / cols) + local contentH = rows * (tileH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + local current = Studio.currentImagePath() + + for i, rel in ipairs(list) do + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + local px = mx + pad + col * (tileW + gap) + local py = baseY + row * (tileH + gap) + local selected = (rel or nil) == current + local clicked = Kit.row(px, py, tileW, tileH, selected, "imgtile-" .. i) + local art = rel and Studio.thumb(rel) or nil + local artH = tileH - Kit.textHeight("micro") - 6 * Kit.scale + if art and art.getWidth then + local iw, ih = art:getWidth(), art:getHeight() + if iw > 0 and ih > 0 then + local scale = math.min((tileW - 8 * Kit.scale) / iw, + (artH - 8 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(art, px + (tileW - iw * scale) * 0.5, + py + (artH - ih * scale) * 0.5, 0, scale, scale) + end + elseif not rel then + Kit.textCenter("small", "(none)", px, + py + artH * 0.5 - Kit.textHeight("small") * 0.5, tileW, PAL.muted) + end + Kit.textCenter("micro", Kit.ellipsize("micro", rel or "no art", tileW), + px, py + tileH - Kit.textHeight("micro") - 2 * Kit.scale, tileW, + selected and PAL.green or PAL.detail) + if clicked then Studio.chooseImage(rel or nil) end + end + if #list == 1 then + Kit.text("small", "No images in this skin yet. Import one.", mx + pad, + baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawOpenModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, "Open a skin", 0.62, 0.76) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 46 * Kit.scale) + local gap = 4 * Kit.scale + local list = Studio.available or {} + local contentH = #list * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + + for i, entry in ipairs(list) do + local py = baseY + (i - 1) * (rowH + gap) + local selected = Studio.skin and Studio.skin.id == entry.id + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "open-" .. entry.id) + Kit.text("mono", Kit.ellipsize("mono", entry.id, mw - pad * 4), + mx + pad * 2, py + 6 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", Studio.skinSummary(entry), + mw - pad * 4), mx + pad * 2, py + 6 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + if clicked then Studio.loadEntry(entry.id) end + end + if #list == 0 then + Kit.text("small", "No skins installed yet.", mx + pad, baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawPageModal(W, H) + local modal = Studio.modal + local skin = Studio.skin + local mx, my, mw, mh, pad = modalFrame(W, H, "Pages", 0.58, 0.72) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + local gap = 6 * Kit.scale + local fieldW = math.min(220 * Kit.scale, (mw - pad * 2) * 0.5) + Studio.pageNameField = Kit.textfield("pagename", mx + pad, by, fieldW, bh, + Studio.pageNameField or "", "page name") + if Kit.button(mx + pad + fieldW + gap, by, 100 * Kit.scale, bh, "Rename", + { id = "page-rename" }) then + Studio.renamePage(Studio.pageNameField) + end + if Kit.button(mx + pad + fieldW + gap + 106 * Kit.scale, by, 100 * Kit.scale, + bh, "Delete", { id = "page-del", kind = "danger", + enabled = skin and #skin.pages > 1 }) then + local index = Studio.pageIndex + Studio.closeModal() + Studio.ask("Delete this page and everything on it?", + function() Studio.deletePage(index) end, "Delete") + end + + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 44 * Kit.scale) + local pages = (skin and skin.pages) or {} + local contentH = #pages * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + for i = 1, #pages do + local py = baseY + (i - 1) * (rowH + gap) + local selected = i == Studio.pageIndex + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "page-" .. i) + local name, detail = Studio.pageLabel(i) + Kit.text("mono", Kit.ellipsize("mono", i .. ". " .. name, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", detail, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if clicked then + Studio.setPage(i) + Studio.pageNameField = name + end + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawExportModal(W, H) + local mx, my, mw, mh, pad = modalFrame(W, H, "Export this skin", 0.56, 0.6) + local cy = my + pad + Kit.textHeight("title") + pad + local rowH = math.max(Kit.tapMin(), 34 * Kit.scale) + local gap = 6 * Kit.scale + local by, bh = modalFooter(mx, my, mw, mh, pad) + for _, spec in ipairs(Studio.EXPORTS) do + if Kit.button(mx + pad, cy, mw - pad * 2, rowH, spec.label, + { id = "export-" .. spec.id, kind = "accent" }) then + Studio.exportAs(spec.id) + Studio.closeModal() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", Kit.ellipsize("small", spec.hint, mw - pad * 2), + mx + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + end + if Studio.lastExport then + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Show the file", + { id = "export-reveal" }) then + Studio.revealExport() + end + end + Kit.textWrapped("small", + "Exports land in the skins folder of your save directory.", + mx + pad, cy, mw - pad * 2, PAL.faint, 2) +end + +local function drawConfirm(W, H) + local c = Studio.confirm + local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3) + Kit.textWrapped("button", c.text, mx + pad, + my + pad + Kit.textHeight("title") + pad, mw - pad * 2, PAL.text, 3) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + local bw = (mw - pad * 2 - 12 * Kit.scale) / 3 + if Kit.button(mx + pad, by, bw, bh, "Save first", + { id = "confirm-save", kind = "accent" }) then + Studio.save() + if not Studio.dirty then Studio.confirmYes() end + end + if Kit.button(mx + pad + bw + 6 * Kit.scale, by, bw, bh, c.yesLabel, + { id = "confirm-yes", kind = "danger" }) then + Studio.confirmYes() + end + if Kit.button(mx + pad + (bw + 6 * Kit.scale) * 2, by, bw, bh, "Cancel", + { id = "confirm-no" }) then + Studio.confirmNo() + end +end + +function Studio.drawOverlay(W, H) + if Studio.confirm then + drawConfirm(W, H) + return true + end + local modal = Studio.modal + if not modal then return false end + if modal.kind == "bind" then drawBindModal(W, H) + elseif modal.kind == "image" then drawImageModal(W, H) + elseif modal.kind == "open" then drawOpenModal(W, H) + elseif modal.kind == "page" then drawPageModal(W, H) + elseif modal.kind == "export" then drawExportModal(W, H) + else Studio.closeModal() end + return true +end + function Studio.draw() local W, H = love.graphics.getDimensions() Kit.layout(W, H) @@ -958,6 +1858,8 @@ function Studio.draw() Studio.clicked, Studio.wheel = false, 0 Theme.fill(0, 0, W, H, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() local pad = 14 * Kit.scale local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad @@ -981,15 +1883,39 @@ function Studio.draw() TouchControls:reset() end bx = bx + 118 * Kit.scale + local smallW = 74 * Kit.scale + if Kit.button(bx, pad * 0.5, smallW, btnH, "Undo", + { id = "undo", font = "small", enabled = Studio.canUndo() }) then + Studio.undo() + end + bx = bx + smallW + 6 * Kit.scale + if Kit.button(bx, pad * 0.5, smallW, btnH, "Redo", + { id = "redo", font = "small", enabled = Studio.canRedo() }) then + Studio.redo() + end + bx = bx + smallW + 6 * Kit.scale + if Kit.button(bx, pad * 0.5, smallW + 20 * Kit.scale, btnH, + Studio.showLabels and "Labels: ON" or "Labels: OFF", + { id = "labels", font = "small", active = Studio.showLabels }) then + Studio.showLabels = not Studio.showLabels + end + bx = bx + smallW + 26 * Kit.scale if Studio.dirty then Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow) end local closeW = 100 * Kit.scale + local closed = false if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", { id = "close" }) then - if Studio.onClose then Studio.onClose() end + Studio.guard("Close the studio and lose the unsaved changes?", function() + closed = true + if Studio.onClose then Studio.onClose() end + end) + end + if closed then + Kit.blockClicks = false Kit.endFrame() return end @@ -1016,7 +1942,11 @@ function Studio.draw() if not msg then msg = "Drag to move, corner handles to resize, blue box is the game screen." end - Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, PAL.detail) + Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, + Studio.statusErr and PAL.red or PAL.detail) + + Kit.blockClicks = false + Studio.drawOverlay(W, H) Kit.endFrame() Studio.canvasArea = r @@ -1034,6 +1964,7 @@ end function Studio.mousepressed(x, y, button) if button ~= 1 then return end Studio.clicked = true + if Studio.modalUp() then return end local r = Studio.lastCanvas if not r then return end if Studio.testing then @@ -1051,6 +1982,7 @@ function Studio.mousepressed(x, y, button) end function Studio.mousemoved(x, y) + if Studio.modalUp() then return end if Studio.testing then TouchControls:touchmoved("studio", x, y) return @@ -1067,6 +1999,7 @@ function Studio.mousereleased(x, y, button) return end Studio.drag = nil + Studio.guides = nil end function Studio.wheelmoved(_, dy) @@ -1087,13 +2020,55 @@ function Studio.textinput(text) Kit.textinput(text) end +local function heldCtrl() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lctrl", "rctrl", "lgui", "rgui") + return ok and down == true +end + +local function heldShift() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lshift", "rshift") + return ok and down == true +end + +Studio.NUDGES = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} + function Studio.keypressed(key) if Kit.focus then Kit.keypressed(key) return end - if key == "escape" then - if Studio.onClose then Studio.onClose() end + if Studio.confirm then + if key == "escape" then Studio.confirmNo() + elseif key == "return" or key == "kpenter" then Studio.confirmYes() + else Kit.keypressed(key) end + return + end + if Studio.modal then + if key == "escape" then Studio.closeModal() else Kit.keypressed(key) end + return + end + local nudge = Studio.NUDGES[key] + if nudge then + Studio.nudge(nudge[1], nudge[2], heldShift()) + return + end + if (key == "y" and heldCtrl()) + or (key == "z" and heldShift() and heldCtrl()) then + Studio.redo() + elseif key == "z" and heldCtrl() then + Studio.undo() + elseif key == "u" then + if heldShift() then Studio.redo() else Studio.undo() end + elseif key == "l" then + Studio.showLabels = not Studio.showLabels + elseif key == "escape" then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) elseif key == "delete" or key == "backspace" then Studio.deleteControl() elseif key == "n" then diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index aff3f69d..ed3e4a15 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -28,7 +28,7 @@ -- covered by tests; the state at the bottom is the only part that draws. local GbcPalette = require("src.render.GbcPalette") -local GameViewport = require("src.render.GameViewport") +local Playfield = require("src.render.Playfield") local Palettes = require("src.world.gen2.Palettes") local Runtime = require("src.mods.Runtime") local SpriteAnims = require("src.ui.gen2.SpriteAnims") @@ -510,7 +510,7 @@ function BattleTransition:blackAt(col, row) end function BattleTransition:draw() - local w, h = GameViewport.dimensions() + local w, h = Playfield.dimensions() self:drawWidescreen(w, h) end diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index a6ccd8a0..709a21f0 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -43,16 +43,29 @@ end -- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua -- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen -- paints still fills the window, the PANEL is what stays on the grid. +local function playfieldRect(winW, winH) + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, x, y, w, h = pcall(Playfield.rect, winW, winH) + if okv and w and w >= 1 and h and h >= 1 then + return x, y, w, h + end + end + return 0, 0, winW or 0, winH or 0 +end + function Chrome.fitScale(winW, winH) - return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8), - (winH or 0) / (Chrome.SCREEN_H * 8)))) + local _, _, w, h = playfieldRect(winW, winH) + return math.max(1, math.floor(math.min(w / (Chrome.SCREEN_W * 8), + h / (Chrome.SCREEN_H * 8)))) end -- The centred origin that goes with it, so a caller does not re-derive it. function Chrome.fitOrigin(winW, winH, scale) scale = scale or Chrome.fitScale(winW, winH) - return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2), - math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2) + local x, y, w, h = playfieldRect(winW, winH) + return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2), + y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2) end -- A bordered box, tile coords. Leaves the draw color black for text. diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index f4542f21..7ae2464f 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -856,8 +856,8 @@ end -- -------------------------------------------------------------------- pager -- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is --- never silently truncated. This is the ONLY way the launcher moves through --- a long list: no scrollbars, no momentum, bounded row count per frame. +-- never silently truncated. A long list still PAGES rather than scrolling: +-- no momentum, bounded row count per frame. -- Returns the new page (1-based) and the row height consumed. local pagerLabels = {} @@ -930,6 +930,64 @@ function Kit.wheelPage(x, y, w, h, page, total, perPage) return math.floor(moved) end +function Kit.scrollExtent(contentH, viewH) + return math.max(0, (contentH or 0) - math.max(0, viewH or 0)) +end + +function Kit.scrollClamp(offset, maxScroll) + return math.max(0, math.min(offset or 0, math.max(0, maxScroll or 0))) +end + +function Kit.scrollStep(scale) + return math.floor(48 * (scale or Kit.scale)) +end + +function Kit.scrollBarW(scale) + return math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollGutter(scale) + return Kit.scrollBarW(scale) + math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollHandoff(offset, maxScroll, delta) + local want = (offset or 0) + (delta or 0) + local at = Kit.scrollClamp(want, maxScroll) + return at, want - at +end + +function Kit.scrollWheel(offset, maxScroll, x, y, w, h, step) + local at = Kit.scrollClamp(offset, maxScroll) + local wheel = Kit.wheelY or 0 + if Kit.blockClicks or wheel == 0 or (maxScroll or 0) <= 0 then + return at, false + end + if not Kit.hit(x, y, w, h) then return at, false end + local moved = Kit.scrollClamp(at - wheel * (step or Kit.scrollStep()), + maxScroll) + if moved == at then return at, false end + Kit.wheelY = 0 + return moved, true +end + +function Kit.scrollBegin(x, y, w, h, offset, maxScroll) + Kit.pushClip(x, y, math.max(0, w or 0), math.max(0, h or 0)) + return y - Kit.scrollClamp(offset, maxScroll) +end + +function Kit.scrollEnd(x, y, w, h, offset, maxScroll) + Kit.popClip() + if (maxScroll or 0) <= 0 or (h or 0) <= 0 or (w or 0) <= 0 then return end + local barW = Kit.scrollBarW() + local barX = x + w - barW + local at = Kit.scrollClamp(offset, maxScroll) + local thumbH = math.max(math.floor(20 * Kit.scale), + math.floor(h * (h / (h + maxScroll)))) + local thumbY = y + (h - thumbH) * (at / maxScroll) + Theme.fill(barX, y, barW, h, PAL.bg, 0.35) + Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7) +end + -- ------------------------------------------------------------------ spinner -- The one animated element in the UI: a rotating arc of ticks. Drawn as N -- short lines at descending alpha, which needs no shader, no canvas and no diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 0dbc0770..9a395acc 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -9,7 +9,6 @@ local Collision = require("src.world.Collision") local Encounter = require("src.world.Encounter") local FieldDefaults = require("src.world.FieldDefaults") local GameVersion = require("src.core.GameVersion") -local GameViewport = require("src.render.GameViewport") local Logger = require("src.core.Logger") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") @@ -5196,7 +5195,7 @@ function OverworldState:drawWorld() -- point projects under the pipeline's own camera. That is the direct -- analogue of what :billboard does for tilt, and it keeps exactly one -- copy of every effect: the closures above are the ones that run. - local pw, ph = GameViewport.dimensions() + local _, _, pw, ph = Game.renderer:playfieldRect() local pscale = Zoom.scale(Game.renderer:fitScale()) local ctx = { state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 198e86f3..45739943 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -34,7 +34,7 @@ local Font = require("src.render.Font") -- a mod has taken a facade (src/mods/Gen2Compat.lua). local Gen1Facade = require("src.mods.Gen2Compat") local GbcPalette = require("src.render.GbcPalette") -local GameViewport = require("src.render.GameViewport") +local Playfield = require("src.render.Playfield") local Gen2Save = require("src.core.gen2.Save") local HallOfFame = require("src.core.gen2.HallOfFame") local HiddenItems = require("src.world.gen2.HiddenItems") @@ -7499,7 +7499,7 @@ function World:interactBody() end function World:fitScale() - local w, h = GameViewport.dimensions() + local w, h = Playfield.dimensions() return math.max(1, math.floor(math.min(w / 160, h / 144))) end @@ -8336,7 +8336,7 @@ function World:rebuildNeighbors() self.neighbors = {} if not self.map then return end local s = self:zoomScale() - local ww, wh = GameViewport.dimensions() + local ww, wh = Playfield.dimensions() local vw = math.ceil(ww / s) local vh = math.ceil(wh / s) if vw % 2 ~= 0 then vw = vw + 1 end @@ -9748,7 +9748,7 @@ function World:drawGround(s) if canvas then bw, bh = canvas:getDimensions() else - bw, bh = GameViewport.dimensions() + bw, bh = Playfield.dimensions() end if BorderFill.fillBlock(self.map.def) == false then -- BLACK: World:draw clears to a brown letterbox, so the void itself @@ -10017,7 +10017,7 @@ end function World:draw() local G = love.graphics - local w, h = GameViewport.dimensions() + local w, h = Playfield.dimensions() self:refreshColorMode() G.clear(0.07, 0.05, 0.02, 1) diff --git a/tests/drivers/launcher_sync_shot.lua b/tests/drivers/launcher_sync_shot.lua new file mode 100644 index 00000000..b3109f07 --- /dev/null +++ b/tests/drivers/launcher_sync_shot.lua @@ -0,0 +1,128 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + + local dir = os.getenv("SHOT_DIR") or "/tmp/syncmodal" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local eng = { + phase = "idle", status = "Ready", conflicts = {}, state = { enabled = true }, + isLinked = false, isBusy = false, + linked = function(self) return self.isLinked end, + busy = function(self) return self.isBusy end, + createAccount = function(self) + self.isLinked = true + self.codes = { code1 = "1234-5678", code2 = "8765-4321" } + self.status = "Sync account created" + return true + end, + linkDevice = function(self) self.isLinked = true return true end, + syncNow = function(self) self.status = "Checking for changes..." return true end, + unlink = function(self) self.isLinked, self.codes = false, nil return true end, + shareMods = function(self) self.shareCode = "K7QW3M" return true end, + fetchShare = function(self) return true end, + applyModPlan = function(self) self.modPlan = nil return true end, + resolveConflict = function(self) self.conflicts = {} self.phase = "idle" return true end, + } + + local imp = RomImporter.new(function() end, { launcher = true }) + imp._sync = eng + imp._syncTransportOk = true + + local pending = nil + love.draw = function() + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + imp:update(1 / 60) + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + imp:_openSync() + U.wait(3) + U.log("modal view:", imp._syncModal.view, "linked:", tostring(eng:linked())) + shot("sync_new.png") + + imp:_syncView("link") + imp:_syncFocusField("code1") + imp:textinput("1234-5678") + imp:_syncFocusField("code2") + imp:textinput("8765ab4321") + U.wait(2) + U.log("codes typed:", imp._syncModal.code1, imp._syncModal.code2) + shot("sync_link.png") + + imp:_syncView("home") + imp:_syncCreate() + U.wait(2) + U.log("codes shown:", eng.codes.code1, eng.codes.code2) + shot("sync_codes.png") + + eng.isBusy = true + eng.status = "Uploading saves..." + U.wait(2) + shot("sync_busy.png") + eng.isBusy = false + eng.status = "Ready" + + imp:_syncView("mods") + imp:_syncShareMods() + eng.modPlan = { + indexes = { "https://example.invalid/index.json" }, + toInstall = { { id = "jp_green" }, { id = "randomizer" } }, + toEnable = { { id = "jp_green", version = "red" } }, + missing = { { id = "gone" } }, + } + U.wait(2) + U.log("share code:", tostring(eng.shareCode)) + shot("sync_mods.png") + + eng.phase = "conflict" + eng.status = "These saves were played at the same time." + eng.conflicts = { { + key = "red/abcd1234", version = "red", overlap = true, + localMeta = { savedAt = os.time(), sessionStart = os.time() - 3600, + summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } }, + remoteMeta = { savedAt = os.time() - 600, sessionStart = os.time() - 4200, + summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } }, + } } + U.wait(2) + shot("sync_conflict.png") + + love.window.setMode(520, 760, { resizable = true, highdpi = true }) + U.wait(3) + shot("sync_conflict_narrow.png") + + eng.phase = "idle" + eng.conflicts = {} + imp:_syncView("home") + U.wait(2) + shot("sync_home_narrow.png") + + imp:_closeSync() + U.wait(2) + U.log("closed:", tostring(imp._syncModal == nil)) + shot("sync_closed.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_add_by_url_shot.lua b/tests/drivers/skin_add_by_url_shot.lua new file mode 100644 index 00000000..03441162 --- /dev/null +++ b/tests/drivers/skin_add_by_url_shot.lua @@ -0,0 +1,115 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local TouchSkin = require("src.core.TouchSkin") + local Fetch = require("src.net.Fetch") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skinurl" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(2) + + local imp = RomImporter.new(function() end, { + launcher = true, + onOpenSkinStudio = function() end, + }) + + local pending = nil + love.draw = function() + imp:draw() + if pending then + local path = pending + pending = nil + love.graphics.captureScreenshot(function(imagedata) + local f = io.open(path, "wb") + if f then f:write(imagedata:encode("png"):getString()) f:close() end + end) + end + end + local function shot(name) + pending = dir .. "/" .. name + for _ = 1, 90 do + if not pending then break end + imp:update(1 / 60) + coroutine.yield() + end + U.wait(3) + local f = io.open(dir .. "/" .. name, "rb") + U.log(f and "shot" or "FAIL shot", name) + if f then f:close() end + end + + imp:_switchTab("skins") + U.wait(3) + + U.log("name from url:", RomImporter.skinUrlName( + "https://example.com/pads/Neon.deltaskin")) + U.log("name from cfg:", RomImporter.skinUrlName( + "https://example.com/overlay.cfg")) + + imp.skinUrl = "https://example.com/pads/neon.deltaskin" + imp._skinUrlFocus = true + U.wait(2) + shot("skins_url_typed.png") + + local realDownload, realPoll, realRelease = + Fetch.download, Fetch.poll, Fetch.release + local state = { status = "pending", progress = 0.4 } + Fetch.download = function(url, dest) + U.log("download:", url, "->", dest) + return 1 + end + Fetch.poll = function() return state end + Fetch.release = function() end + + imp._skinUrlFocus = false + imp:_addSkinFromUrl() + U.log("in flight:", tostring(imp._skinFetch ~= nil)) + U.wait(2) + shot("skins_url_downloading.png") + + state = { status = "error", err = "could not resolve host" } + imp:_pumpSkinFetch() + U.log("failure notice:", imp._skinNotice.text) + U.wait(2) + shot("skins_url_failed.png") + + Fetch.download, Fetch.poll, Fetch.release = realDownload, realPoll, realRelease + + local staged = TouchSkin.export( + assert(TouchSkin.load("assets/skins/gb_anim", "gb_anim")), + "skins/url_probe.zip") + local raw = love.filesystem.read("skins/url_probe.zip") + love.filesystem.remove("skins/url_probe.zip") + U.log("staged:", tostring(staged), "bytes:", raw and #raw or 0) + imp:_installSkinData("downloaded_pad.zip", raw) + U.log("install notice:", imp._skinNotice.text) + U.wait(2) + shot("skins_url_installed.png") + + local skins = imp:_ensureSkins(true) + for _, e in ipairs(skins) do + U.log((" %s format=%s buttons=%d"):format(e.id, tostring(e.format), + e.controls)) + end + + imp._skinActions = { id = skins[1] and skins[1].id } + U.wait(2) + shot("skins_actions_sheet.png") + + for _, kind in ipairs({ "native", "retroarch", "delta" }) do + local path = imp:_exportSkin(skins[1].id, kind) + U.log("export " .. kind .. ":", tostring(path)) + end + imp._skinActions = nil + U.wait(2) + shot("skins_exported.png") + + love.window.setMode(520, 820, { resizable = true, highdpi = true }) + U.wait(3) + shot("skins_url_narrow.png") + + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_containment_shot.lua b/tests/drivers/skin_containment_shot.lua new file mode 100644 index 00000000..359824ef --- /dev/null +++ b/tests/drivers/skin_containment_shot.lua @@ -0,0 +1,227 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local TouchSkin = require("src.core.TouchSkin") + local Playfield = require("src.render.Playfield") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skin-cutout" + local gen2 = game.overworld == nil + local failures, checks = 0, 0 + + local SKIN = [[ +return { + name = "containment_probe", + pages = { + { + name = "probe", + fullScreen = true, + viewport = { x = 0.25, y = 0.1, w = 0.5, h = 0.6 }, + controls = { { bind = "nul", x = 0.5, y = 0.92, w = 0.04, h = 0.04 } }, + }, + }, +} +]] + love.filesystem.createDirectory("skins/containment_probe") + assert(love.filesystem.write("skins/containment_probe/skin.lua", SKIN)) + + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + love.graphics.setBackgroundColor(0, 0, 0, 1) + U.wait(2) + + local options = gen2 and game.options or game.save.options + options.touchControls = { enabled = true, skin = "containment_probe" } + options.tilt = 0 + options.zoom = 0 + options.pipelines = {} + options.videoMode = "windowed" + options.faithfulRes = 0 + game:applyOptions() + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + U.wait(4) + + U.log("gen:", gen2 and 2 or 1, "skin:", tostring(TouchControls.skinId), + "err:", tostring(TouchControls.skinError)) + U.log("drawable:", TouchSkin.drawable(), "hasViewport:", TouchSkin.hasViewport()) + + local function cutoutPx() + local pw, ph = love.graphics.getPixelDimensions() + local x, y, w, h = Playfield.cutout(pw, ph) + return x, y, w, h, pw, ph + end + + local cx, cy, cw, ch, pw, ph = cutoutPx() + if not cx then + U.log("FAIL no cutout is active; nothing to prove") + love.event.quit() + while true do coroutine.yield() end + end + U.log(("cutout px: %d,%d %dx%d in %dx%d"):format(cx, cy, cw, ch, pw, ph)) + + local INSET = 4 + local function scan(label, data) + local w, h = data:getWidth(), data:getHeight() + local sx, sy = w / pw, h / ph + local x1, y1 = math.floor(cx * sx) - INSET, math.floor(cy * sy) - INSET + local x2 = math.ceil((cx + cw) * sx) + INSET + local y2 = math.ceil((cy + ch) * sy) + INSET + local bad, firstX, firstY, worst = 0, nil, nil, 0 + local inked = 0 + local step = math.max(2, math.floor(math.min(w, h) / 360)) + for y = 0, h - 1, step do + for x = 0, w - 1, step do + local r, g, b = data:getPixel(x, y) + local lit = math.max(r, g, b) + local outside = x < x1 or x >= x2 or y < y1 or y >= y2 + if outside then + if lit > 0.02 then + bad = bad + 1 + if not firstX then firstX, firstY = x, y end + if lit > worst then worst = lit end + end + elseif lit > 0.02 then + inked = inked + 1 + end + end + end + checks = checks + 1 + if bad > 0 then + failures = failures + 1 + U.log(("FAIL %s: %d lit samples outside the cutout (first %d,%d, max %.2f)") + :format(label, bad, firstX, firstY, worst)) + elseif inked == 0 then + failures = failures + 1 + U.log("FAIL " .. label .. ": nothing drew inside the cutout either") + else + U.log(("ok %s: contained (%d lit samples inside)"):format(label, inked)) + end + end + + local pending = nil + local function probe(label) + U.wait(2) + pending = label + love.graphics.captureScreenshot(function(data) + scan(pending, data) + pending = nil + end) + for _ = 1, 180 do + if not pending then break end + coroutine.yield() + end + if pending then + failures = failures + 1 + U.log("FAIL " .. tostring(pending) .. ": screenshot never arrived") + pending = nil + end + if os.getenv("SHOT_PNG") == "1" then + U.shot(game, ("%s/%s.png"):format(dir, label:gsub("[^%w]+", "_"))) + end + end + + if gen2 then + for i = 1, 2 do + probe("gold_boot_" .. i) + U.wait(60) + end + for _ = 1, 240 do + if game.world and game.world.map then break end + game.input.pressQueue[#game.input.pressQueue + 1] = "start" + U.wait(4) + end + if game.world and game.world.map then + probe("gold_overworld") + local Zoom = require("src.render.Zoom") + Zoom.allowSurvey = true + for _, off in ipairs({ -2, -1, 1, 2 }) do + Zoom.offset = off + probe("gold_zoom_" .. (off < 0 and "out" or "in") .. math.abs(off)) + end + Zoom.offset = 0 + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 12) + end + tap("start", 24) + probe("gold_start_menu") + tap("b", 12) + probe("gold_after_menu") + else + U.log("FAIL gold world never booted") + failures = failures + 1 + end + else + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(12) + probe("red_overworld") + + local Zoom = require("src.render.Zoom") + local Renderer = require("src.render.Renderer") + Zoom.allowSurvey = true + local lo, hi = Zoom.offsetRange(Renderer:fitScale()) + for off = lo, hi do + Zoom.offset = off + probe("red_zoom_" .. Zoom.offsetLabel(off)) + end + Zoom.offset = 0 + + U.tap(game, "start") + U.wait(20) + probe("red_start_menu") + + local function stress(label, mutate) + local state = game.stack:top() + local original = state.draw + state.draw = function(...) + original(...) + mutate() + end + probe(label) + state.draw = original + end + stress("red_screen_veil", function() + Renderer.screenVeil = { 1, 0.85 } + end) + stress("red_battle_wipe", function() + Renderer.battleWipe = { style = "spiralin", prog = 0.45 } + end) + stress("red_letterbox_paper", function() + Renderer.extendedWorldBand = true + end) + stress("red_ui_anchor", function() + Renderer.uiCentered = false + Renderer:setUIAnchor(0, 96, 160, 48, "bottom") + end) + U.tap(game, "b") + U.wait(10) + + game.save.options.uiLayout = "dynamic" + game:applyOptions() + U.tap(game, "start") + U.wait(20) + probe("red_dynamic_start_menu") + U.tap(game, "b") + U.wait(10) + game.save.options.uiLayout = "centered" + game:applyOptions() + + local Tilt = require("src.render.Tilt") + game.save.options.tilt = 1 + Tilt.applyOptions(game.save.options) + U.wait(30) + probe("red_tilt") + game.save.options.tilt = 0 + Tilt.applyOptions(game.save.options) + U.wait(20) + end + + U.log(("done: %d/%d frames contained, %d failures") + :format(checks - failures, checks, failures)) + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/engine/gen2_mod_options_persist.lua b/tests/engine/gen2_mod_options_persist.lua new file mode 100644 index 00000000..c2a5c50c --- /dev/null +++ b/tests/engine/gen2_mod_options_persist.lua @@ -0,0 +1,83 @@ +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 SaveData = require("src.core.SaveData") +local Save = require("src.core.gen2.Save") + +local function memfs() + local files = {} + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } +end + +local fs = memfs() +SaveData.saveOptions(SaveData.defaultOptions(), fs) +local opts = Save.loadOptions(fs) +opts.modOptions = { nuzlocke = { dupes = true } } +opts.modProfiles = { { name = "casual", enabled = {} } } +opts.activeProfile = "casual" +opts.mods = { nuzlocke = true } +opts.modsByVersion = { gold = { hardmode = true } } +opts.textSpeed = "SLOW" +check(Save.saveOptions(opts, fs), "gold options write lands") + +local file = SaveData.loadOptions(fs) +eq(file.modOptions and file.modOptions.nuzlocke and file.modOptions.nuzlocke.dupes, + true, "modOptions lands flat where gen1 and the launcher read it") +eq(file.activeProfile, "casual", "activeProfile lands flat") +eq(file.modProfiles and file.modProfiles[1] and file.modProfiles[1].name, + "casual", "modProfiles lands flat") +eq(file.mods and file.mods.nuzlocke, true, "enable flags land flat") +eq(file.modsByVersion and file.modsByVersion.gold + and file.modsByVersion.gold.hardmode, true, "per-version flags land flat") +eq(file[Save.OPTIONS_KEY].modOptions, nil, "gold block no longer traps modOptions") +eq(file[Save.OPTIONS_KEY].activeProfile, nil, + "gold block no longer traps activeProfile") +eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block") + +local back = Save.loadOptions(fs) +eq(back.modOptions.nuzlocke.dupes, true, "flat modOptions round-trips into gold's table") +eq(back.activeProfile, "casual", "flat activeProfile round-trips") + +local fs2 = memfs() +fs2.files["options.lua"] = [[return { gold = { textSpeed = "FAST", + modOptions = { nuzlocke = { dupes = true } }, activeProfile = "old" } }]] +local legacy = Save.loadOptions(fs2) +eq(legacy.modOptions and legacy.modOptions.nuzlocke + and legacy.modOptions.nuzlocke.dupes, true, + "modOptions trapped in the gold block migrates out") +eq(legacy.activeProfile, "old", "trapped activeProfile migrates") +eq(legacy.textSpeed, "FAST", "gold-only keys still merge") +check(Save.saveOptions(legacy, fs2), "migrated write lands") +local migrated = SaveData.loadOptions(fs2) +eq(migrated.modOptions and migrated.modOptions.nuzlocke.dupes, true, + "migration lands the trapped store flat") +eq(migrated[Save.OPTIONS_KEY].modOptions, nil, "migration empties the trap") + +local fs3 = memfs() +fs3.files["options.lua"] = [[return { modOptions = { nuzlocke = { dupes = false } }, + gold = { modOptions = { nuzlocke = { dupes = true } } } }]] +local both = Save.loadOptions(fs3) +eq(both.modOptions.nuzlocke.dupes, false, "flat modOptions wins over a trapped copy") + +local Game2 = require("src.core.Game2") +check(type(Game2.writeOptions) == "function", "Game2 exposes writeOptions") +eq(Game2.writeOptions, Game2.persistOptions, "writeOptions is the persist path") + +local ManagerState = require("src.mods.ManagerState") +local wrote = false +ManagerState.persistOptions({ game = { writeOptions = function() wrote = true end } }) +check(wrote, "ManagerState:persistOptions writes through game.writeOptions") + +T.finish("gen2_mod_options_persist") diff --git a/tests/engine/gen2_touch_skin_options.lua b/tests/engine/gen2_touch_skin_options.lua new file mode 100644 index 00000000..67ba2fc2 --- /dev/null +++ b/tests/engine/gen2_touch_skin_options.lua @@ -0,0 +1,85 @@ +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 SaveData = require("src.core.SaveData") +local Save = require("src.core.gen2.Save") + +local function memfs() + local files = {} + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } +end + +local fs = memfs() +local seed = SaveData.defaultOptions() +seed.touchControls = { enabled = true, skin = "gb_anim" } +seed.haptics = "off" +seed[Save.OPTIONS_KEY] = { textSpeed = "FAST", touchControls = { enabled = false } } +check(SaveData.saveOptions(seed, fs) ~= nil, "seed write lands") + +local opts = Save.loadOptions(fs) +eq(opts.touchControls and opts.touchControls.skin, "gb_anim", + "gold sees the skin the launcher picked") +eq(opts.touchControls.enabled, true, "top-level touchControls wins over the gold block") +eq(opts.haptics, "off", "top-level haptics wins over the gold default") +eq(opts.textSpeed, "FAST", "gold-block keys still merge") + +local fs2 = memfs() +fs2.files["options.lua"] = + "return { gold = { touchControls = { enabled = false } } }" +local opts2 = Save.loadOptions(fs2) +eq(opts2.touchControls and opts2.touchControls.enabled, true, + "shared touchControls (default-folded) wins over a stale gold-block copy") + +local fs3 = memfs() +SaveData.saveOptions(SaveData.defaultOptions(), fs3) +local gopts = Save.loadOptions(fs3) +gopts.touchControls = { enabled = true, skin = "tv_crt" } +gopts.haptics = "strong" +gopts.textSpeed = "SLOW" +check(Save.saveOptions(gopts, fs3), "gold options write lands") + +local file = SaveData.loadOptions(fs3) +eq(file.touchControls and file.touchControls.skin, "tv_crt", + "gold's touch pick lands on the shared top-level key") +eq(file.haptics, "strong", "gold's haptics lands on the shared top-level key") +eq(file[Save.OPTIONS_KEY].touchControls, nil, "gold block no longer shadows touchControls") +eq(file[Save.OPTIONS_KEY].haptics, nil, "gold block no longer shadows haptics") +eq(file[Save.OPTIONS_KEY].textSpeed, "SLOW", "gold-only keys stay in the gold block") + +local g1 = Save.loadOptions(fs3) +eq(g1.touchControls.skin, "tv_crt", "hoisted value round-trips back into gold") + +local TouchSkin = require("src.core.TouchSkin") +local Chrome = require("src.ui.gen2.Chrome") + +local savedViewport = TouchSkin.viewport +TouchSkin.viewport = function() return nil end +eq(Chrome.fitScale(640, 576), 4, "no cutout: integer fit against the window") +local ox, oy = Chrome.fitOrigin(640, 576) +eq(ox, 0, "no cutout: centred x") +eq(oy, 0, "no cutout: centred y") + +TouchSkin.viewport = function(w, h) return w * 0.25, h * 0.125, w * 0.5, h * 0.5 end +eq(Chrome.fitScale(640, 576), 2, "cutout: integer fit against the cutout rect") +local cx, cy = Chrome.fitOrigin(640, 576) +eq(cx, 160 + (320 - 320) / 2, "cutout: origin starts at the cutout") +eq(cy, 72 + math.floor((288 - 288) / 2), "cutout: origin starts at the cutout y") + +TouchSkin.viewport = function() error("boom") end +eq(Chrome.fitScale(640, 576), 4, "a throwing viewport degrades to the window fit") + +TouchSkin.viewport = savedViewport + +T.finish("gen2_touch_skin_options") diff --git a/tests/engine/host_shell_bridge_request.lua b/tests/engine/host_shell_bridge_request.lua new file mode 100644 index 00000000..8f84b6ec --- /dev/null +++ b/tests/engine/host_shell_bridge_request.lua @@ -0,0 +1,125 @@ +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 HostShell = require("src.core.HostShell") + +local TOKEN = "0123456789abcdef0123456789abcdef" +local BODY = '{"blob":"return {}"}' + +HostShell.haveCurl = function() return false end +love.system.getOS = function() return "Android" end + +local calls = {} +local reply = "STATUS 200\n" .. '{"ok":true}' + +love.system.httpRequest = function(url, method, headers, body, userAgent) + calls[#calls + 1] = { url = url, method = method, headers = headers, + body = body, userAgent = userAgent } + if type(reply) == "function" then return reply() end + return reply +end + +check(HostShell.canHttpRequest(), + "the bridge counts as a request transport where curl does not exist") + +local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", + body = BODY, + headers = { + ["x-sync-account"] = "aa11bb22cc33dd44", + ["x-sync-token"] = TOKEN, + ["Content-Type"] = "application/json", + }, +}) + +eq(code, 200, "a bridge request completes: " .. tostring(err)) +eq(body, '{"ok":true}', "and the body arrives with the status line stripped") +eq(err, nil, "with no error alongside it") + +eq(#calls, 1, "the bridge is called once") +local sent = calls[1] +eq(sent.url, "https://sync.example/sync/save", "the url goes through untouched") +eq(sent.method, "PUT", "and so does the method curl would have taken with -X") +eq(sent.body, BODY, "the save blob rides the body argument, not the url") +eq(sent.userAgent, "gen1recomp", "with the default user agent") + +local seen = {} +for i = 1, #sent.headers, 2 do seen[sent.headers[i]] = sent.headers[i + 1] end +eq(seen["x-sync-token"], TOKEN, "auth headers arrive as flat name, value pairs") +eq(seen["x-sync-account"], "aa11bb22cc33dd44", "for the account id too") +eq(seen["Content-Type"], "application/json", "and for the content type") +eq(seen["User-Agent"], nil, + "the user agent stays its own argument rather than a duplicate header") + +calls = {} +reply = "STATUS 409\n" .. '{"error":"the save moved on"}' +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, headers = { ["Accept"] = "application/json" }, +}) +eq(code, 409, "a conflict comes back as a status, not as a transport failure") +eq(body, '{"error":"the save moved on"}', + "and its body survives, which is the whole point of the request arm") +eq(err, nil, "a 4xx is the caller's to interpret") + +calls = {} +reply = "ERROR the reply was too large\n" +body, err, code = HostShell.httpRequest("https://sync.example/sync/state", { + method = "GET", +}) +eq(code, nil, "an ERROR envelope has no status") +eq(body, nil, "and no body") +check(err and err:find("the reply was too large", 1, true) ~= nil, + "the bridge's own complaint reaches the caller: " .. tostring(err)) +check(err and err:find("https://sync.example/sync/state", 1, true) ~= nil, + "named with the url that failed") + +calls = {} +reply = "STATUS 200\n" .. '{"ok":true}' +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, + headers = { ["x-sync-token"] = TOKEN .. "\r\nx-sync-account: stolen" }, +}) +eq(code, nil, "a header value carrying CRLF is refused") +eq(err, "bad request header", "with the same complaint the curl branch gives") +eq(#calls, 0, "and the bridge is never reached") + +calls = {} +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PATCH", body = BODY, +}) +eq(code, nil, "a method the bridge cannot express is refused") +check(err and err:find("PATCH", 1, true) ~= nil, + "naming the method: " .. tostring(err)) +eq(#calls, 0, "without calling the bridge") + +calls = {} +reply = function() return nil end +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, +}) +eq(code, nil, "an old app under a newer engine returns nothing") +check(err and err:find("update the app", 1, true) ~= nil, + "and degrades to an update notice rather than a crash: " .. tostring(err)) + +love.system.httpRequest = nil +love.system.httpDownload = function() return false end +check(not HostShell.canHttpRequest(), + "a build with only the download bridge cannot make signed requests") +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, +}) +eq(code, nil, "so the request does not go out") +check(err and err:find("update the app", 1, true) ~= nil, + "and says what to do about it: " .. tostring(err)) + +love.system.httpDownload = nil +body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", body = BODY, +}) +eq(err, "no request transport on this platform", + "a platform with no bridge at all keeps its old answer") + +T.finish("host shell bridge request") diff --git a/tests/engine/host_shell_request_headers.lua b/tests/engine/host_shell_request_headers.lua new file mode 100644 index 00000000..2369dddf --- /dev/null +++ b/tests/engine/host_shell_request_headers.lua @@ -0,0 +1,95 @@ +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 HostShell = require("src.core.HostShell") + +local MARK = "\n__gen1recomp_http__" +local SAVE_DIR = "/tmp/pokeport-stub-save" +local TOKEN = "0123456789abcdef0123456789abcdef" +local BODY = '{"blob":"return {}"}' + +local realOpen = io.open +local realPopen = io.popen +local realRemove = os.remove +local realHaveCurl = HostShell.haveCurl + +local files, removed = {}, {} +local popenCommand + +HostShell.haveCurl = function() return true end +os.remove = function(path) + removed[path] = true + return true +end +io.open = function(path, mode) + local entry = { path = path, mode = mode, text = "" } + files[#files + 1] = entry + return { + write = function(_, value) + entry.text = entry.text .. value + return true + end, + close = function() return true end, + } +end +io.popen = function(command) + popenCommand = command + return { + read = function() return '{"ok":true}' .. MARK .. "200" end, + close = function() return true end, + } +end + +local body, err, code = HostShell.httpRequest("https://sync.example/sync/save", { + method = "PUT", + body = BODY, + headers = { + ["x-sync-account"] = "aa11bb22cc33dd44", + ["x-sync-token"] = TOKEN, + ["Content-Type"] = "application/json", + }, +}) + +io.open = realOpen +io.popen = realPopen +os.remove = realRemove +HostShell.haveCurl = realHaveCurl + +eq(code, 200, "the request completes: " .. tostring(err)) +eq(body, '{"ok":true}', "and the response body comes back without the marker") + +check(popenCommand:find(TOKEN, 1, true) == nil, + "the device token never reaches the command line") +check(popenCommand:find("aa11bb22cc33dd44", 1, true) == nil, + "and neither does the account id") +check(popenCommand:find(BODY, 1, true) == nil, + "the save blob stays out of the command line too") + +local headerFile, bodyFile +for _, entry in ipairs(files) do + if entry.text:find("x-sync-token", 1, true) then headerFile = entry end + if entry.text == BODY then bodyFile = entry end +end +check(headerFile ~= nil, "the headers are staged in a file") +check(bodyFile ~= nil, "and so is the body") +eq(headerFile.mode, "wb", "the header file is written as bytes") +check(headerFile.text:find("x%-sync%-token: " .. TOKEN) ~= nil, + "with one header per line for curl to read") +check(headerFile.text:find("User%-Agent: ") ~= nil, + "including the user agent curl would otherwise take on argv") +check(popenCommand:find("-H '@" .. headerFile.path .. "'", 1, true) ~= nil, + "and curl is pointed at that file") + +check(headerFile.path:find(SAVE_DIR, 1, true) == 1, + "staging happens in the user-private save directory, not shared /tmp") +check(bodyFile.path:find(SAVE_DIR, 1, true) == 1, + "for the body as well") +check(headerFile.path ~= bodyFile.path, + "two concurrent requests cannot collide on one name") +eq(removed[headerFile.path], true, "the staged headers are deleted afterwards") +eq(removed[bodyFile.path], true, "and so is the staged body") + +T.finish("host shell request headers") diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua new file mode 100644 index 00000000..c74e4cce --- /dev/null +++ b/tests/engine/launcher_scroll_test.lua @@ -0,0 +1,324 @@ +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") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function pointer(x, y) + love.mouse.getPosition = function() return x, y end +end + +eq(Kit.scrollExtent(800, 500), 300, "the extent is exactly the overflow") +eq(Kit.scrollExtent(400, 500), 0, "content that fits has no extent") +eq(Kit.scrollExtent(400, -50), 400, "a negative viewport is no room, not more") +eq(Kit.scrollExtent(nil, nil), 0, "an unmeasured region has no extent") + +eq(Kit.scrollClamp(-10, 300), 0, "an offset above the top clamps to it") +eq(Kit.scrollClamp(5000, 300), 300, "an offset past the end clamps to it") +eq(Kit.scrollClamp(120, 0), 0, "a region with no travel sits at the top") + +local at, left = Kit.scrollHandoff(0, 300, 120) +eq(at, 120, "a move inside the extent is taken in full") +eq(left, 0, "and hands nothing on") +at, left = Kit.scrollHandoff(250, 300, 120) +eq(at, 300, "a move past the end stops at the end") +eq(left, 70, "and hands the remainder to whatever is behind it") +at, left = Kit.scrollHandoff(0, 300, -80) +eq(at, 0, "a move above the top stops at the top") +eq(left, -80, "and hands that remainder on with its sign") + +local function wheelCase(offset, maxScroll, wheel, mx, my) + Kit.blockClicks = false + Kit.mouseX, Kit.mouseY = mx, my + Kit.wheelY = wheel + Kit._clipRect = nil + local moved, took = Kit.scrollWheel(offset, maxScroll, 0, 0, 100, 100, 50) + return moved, took, Kit.wheelY +end + +local moved, took, leftWheel = wheelCase(0, 300, -1, 50, 50) +eq(moved, 50, "a notch over the region moves it by one step") +eq(took, true, "and reports the region took it") +eq(leftWheel, 0, "so nothing reaches the surface behind it") + +moved, took, leftWheel = wheelCase(300, 300, -1, 50, 50) +eq(moved, 300, "a region already at its bottom does not move") +eq(took, false, "and does not claim the notch") +eq(leftWheel, -1, "which is what lets the page scroll take over") + +moved, took, leftWheel = wheelCase(0, 300, 1, 50, 50) +eq(moved, 0, "a region at the top ignores an upward notch") +eq(leftWheel, 1, "and passes it on") + +moved, took, leftWheel = wheelCase(0, 300, -1, 400, 400) +eq(took, false, "a notch outside the region is not the region's") +eq(leftWheel, -1, "and stays queued") + +Kit.blockClicks = true +Kit.mouseX, Kit.mouseY, Kit.wheelY = 50, 50, -1 +moved, took = Kit.scrollWheel(0, 300, 0, 0, 100, 100, 50) +eq(took, false, "a shielded frame (modal up) leaves the region alone") +eq(Kit.wheelY, -1, "so the modal's own scroller still sees the notch") +Kit.blockClicks = false + +local function skinLauncher(count) + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = "skins" + local skins = {} + for i = 1, count do + skins[i] = { id = "skin" .. i, source = "user", controls = 8, pages = 1 } + end + imp._skins = skins + imp._ensureSkins = function() return skins end + return imp +end + +window(360, 780) +local imp = skinLauncher(12) +LauncherView.draw(imp) +LauncherView.draw(imp) +local rect = imp._tabRegionRect +check(rect ~= nil, "the panel publishes the rect its region occupies") +check((imp._tabScrollMax.skins or 0) > 0, + "a panel with more rows than its viewport scrolls") +eq(imp._tabScroll.skins, 0, "a freshly drawn panel sits at the top") +check(imp._tabContentH.skins > rect.h, + "the region's content is taller than the viewport it is clipped to") + +pointer(rect.x + 10, rect.y + 10) +imp._wheelY = -1 +LauncherView.draw(imp) +local step = math.floor(48 * Kit.scale) +eq(imp._tabScroll.skins, step, "one notch scrolls the panel by one step") +eq(imp._pageScroll, 0, + "and the page under it does not move while the panel still can") + +for _ = 1, 30 do + imp._wheelY = -1 + LauncherView.draw(imp) +end +eq(imp._tabScroll.skins, imp._tabScrollMax.skins, + "held down, the panel reaches its own bottom") +eq(imp._pageScroll, imp._pageScrollMax, + "and only then does the leftover scroll the page") + +for _ = 1, 40 do + imp._wheelY = 1 + LauncherView.draw(imp) +end +eq(imp._tabScroll.skins, 0, "scrolling back up returns the panel to the top") +eq(imp._pageScroll, 0, "and the page with it") + +imp._wheelY = -1 +LauncherView.draw(imp) +local parked = imp._tabScroll.skins +check(parked > 0, "the skins panel is parked mid-scroll") +imp:_switchTab("red") +LauncherView.draw(imp) +eq(imp._tabScroll.red or 0, 0, "the game tab has its own offset") +eq(imp._tabScroll.skins, parked, "and the skins offset survives the switch") +imp:_switchTab("skins") +LauncherView.draw(imp) +eq(imp._tabScroll.skins, parked, "coming back lands where the player left") + +imp._skins = {} +imp._ensureSkins = function() return {} end +LauncherView.draw(imp) +LauncherView.draw(imp) +eq(imp._tabScrollMax.skins, 0, "a panel that now fits has no travel") +eq(imp._tabScroll.skins, 0, "and its offset comes back with it") + +local mods = {} +for i = 1, 60 do + mods[#mods + 1] = { + id = "mod" .. i, name = "Mod " .. i, version = "1.0.0", + status = "ok", badge = "gameplay", description = "a mod", + enabledByVersion = { red = true }, + } +end +window(360, 780) +local modImp = RomImporter.new(function() end, { launcher = true }) +modImp.tab = "mods" +modImp.mods = mods +modImp._ensureMods = function() return mods end +LauncherView.draw(modImp) +LauncherView.draw(modImp) +check((modImp._modScrollMax or 0) > 0, + "60 mods overflow the list viewport inside the panel") +local list = modImp._modListRect +check(list.x + list.w + <= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(), + "the rows stop short of the region's scrollbar gutter") +pointer(list.x + 10, list.y + 10) +modImp._wheelY = -1 +LauncherView.draw(modImp) +check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list") +eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it") +eq(modImp._pageScroll, 0, "and not the page behind that") + +modImp._modActions = "mod1" +local shielded = modImp.modScroll +local shieldedPage = modImp._pageScroll +pointer(list.x + 10, list.y + 10) +modImp._wheelY = -1 +LauncherView.draw(modImp) +eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch") +eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim") +eq(modImp._pageScroll, shieldedPage, "and the page behind that") +modImp._modActions = nil +modImp._wheelY = 0 +LauncherView.draw(modImp) + +window(360, 780) +local gameImp = RomImporter.new(function() end, { launcher = true }) +gameImp.tab = "red" +gameImp.ready = { red = true } +gameImp.slots = { red = {} } +for i = 1, 8 do + gameImp.slots.red[i] = { id = "slot" .. i, name = "Slot " .. i } +end +gameImp._ensureSlots = function() end +LauncherView.draw(gameImp) +LauncherView.draw(gameImp) +check((gameImp._tabScrollMax.red or 0) > 0, + "a game tab whose cart and slots outgrow the viewport scrolls too") +check(gameImp._tabContentH.red > gameImp._tabRegionRect.h, + "because it reports its NATURAL height, not the height it was given") +pointer(gameImp._tabRegionRect.x + 10, gameImp._tabRegionRect.y + 10) +gameImp._wheelY = -1 +LauncherView.draw(gameImp) +check((gameImp._tabScroll.red or 0) > 0, "and a notch over it moves it") + +window(360, 780) +local touchImp = skinLauncher(12) +LauncherView.draw(touchImp) +LauncherView.draw(touchImp) +local treg = touchImp._tabRegionRect +local tmax = touchImp._tabScrollMax.skins +check(tmax > 0, "the touched panel has travel") +LauncherView.touchpressed(touchImp, 1, treg.x + 20, treg.y + 40) +LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200) +eq(touchImp._tabScroll.skins, math.min(200, tmax), + "dragging up scrolls the panel by the finger's travel") +eq(touchImp._pageScroll, 0, "while the panel still has travel, the page waits") +LauncherView.touchmoved(touchImp, 1, treg.x + 20, treg.y + 40 - 200 - tmax * 2) +eq(touchImp._tabScroll.skins, tmax, "a longer drag reaches the panel's bottom") +check((touchImp._pageScroll or 0) > 0, "and spills into the page from there") +LauncherView.touchreleased(touchImp, 1, treg.x + 20, treg.y - 400) + +window(360, 780) +local dragMods = RomImporter.new(function() end, { launcher = true }) +dragMods.tab = "mods" +dragMods.mods = mods +dragMods._ensureMods = function() return mods end +LauncherView.draw(dragMods) +LauncherView.draw(dragMods) +local dlist = dragMods._modListRect +local dListMax = dragMods._modScrollMax +local dRegionMax = dragMods._tabScrollMax.mods +check(dListMax > 0 and dRegionMax > 0, + "the mods tab has both an inner list and a region to scroll") +LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30) +LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60) +eq(dragMods.modScroll, math.min(60, dListMax), + "the first pixels of the drag move the list") +eq(dragMods._tabScroll.mods or 0, 0, "and nothing else") +LauncherView.touchmoved(dragMods, 7, dlist.x + 20, + dlist.y + 30 - 60 - dListMax - dRegionMax * 2) +eq(dragMods.modScroll, dListMax, "carrying on saturates the list") +eq(dragMods._tabScroll.mods, dRegionMax, + "then the same gesture walks the region to its bottom") +check((dragMods._pageScroll or 0) > 0, "and only then reaches the page") +LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900) + +dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } } +for i = 2, 12 do + dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 } +end +dragMods._ensureSkins = function() return dragMods._skins end +dragMods.modScroll = 0 +local heldModScroll = dragMods.modScroll +local overList = dlist.y + 30 +dragMods:_switchTab("skins") +LauncherView.draw(dragMods) +LauncherView.draw(dragMods) +local sreg = dragMods._tabRegionRect +check((dragMods._tabScrollMax.skins or 0) > 0, "the skins tab has travel") +LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList) +LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200) +check((dragMods._tabScroll.skins or 0) > 0, + "a drag on the skins tab scrolls the skins tab") +eq(dragMods.modScroll, heldModScroll, + "and leaves the mod list where the player parked it") +LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400) + +love.graphics.polygon = love.graphics.polygon or function() end +window(360, 780) +local padImp = skinLauncher(12) +LauncherView.draw(padImp) +LauncherView.draw(padImp) +local preg = padImp._tabRegionRect +padImp._padCursorActive = true +padImp._padCursor = { x = preg.x + 10, y = preg.y + preg.h - 4 } +LauncherView.wheelmoved(padImp, 0, -1) +LauncherView.draw(padImp) +check((padImp._tabScroll.skins or 0) > 0, + "the pad's synthesized wheel scrolls the region its cursor sits in") +eq(padImp._pageScroll, 0, "and not the page behind it") + +window(1280, 720) +local edgeImp = skinLauncher(40) +LauncherView.draw(edgeImp) +LauncherView.draw(edgeImp) +local ereg = edgeImp._tabRegionRect +check((edgeImp._tabScrollMax.skins or 0) > 0, "the wide window still overflows") +eq(edgeImp._pageScrollMax, 0, "with no page scroll left to catch the notch") +check(ereg.y + ereg.h < 720, "and a region that ends above the safe area") +edgeImp._padCursorActive = true +edgeImp._padCursor = { x = ereg.x + 20, y = 719 } +edgeImp._padAxis = { lefty = 1 } +edgeImp._padDir = {} +pointer(ereg.x + 20, 719) +edgeImp:_updatePadCursor(0.5) +check((edgeImp._wheelY or 0) < 0, "the edge push synthesizes a notch") +LauncherView.draw(edgeImp) +check((edgeImp._tabScroll.skins or 0) > 0, + "which reaches the tab region even though the cursor is below it") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +local view = read("src/import/LauncherView.lua") +check(view:find("Kit.scrollBegin(", 1, true) ~= nil, + "the panel dispatch opens a scroll region") +check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it") +check(view:find("modListWantsWheel", 1, true) ~= nil, + "the nested mod list is asked before the region takes a notch") +check(view:find("start.region", 1, true) ~= nil, + "a touch drag that began in the region scrolls the region") +check(view:find("Kit.scrollGutter(", 1, true) ~= nil, + "the panels lay out inside a gutter, so the thumb covers no control") +check(view:find("Kit.scrollHandoff(tabScrollAt(imp)", 1, true) ~= nil, + "and hands its leftover to the page, like the wheel does") + +local kit = read("src/ui/kit/Kit.lua") +check(kit:find("function Kit.scrollWheel", 1, true) ~= nil, + "the kit owns the wheel rule, so no panel hand-rolls a fifth copy") + +T.finish("launcher scroll regions") diff --git a/tests/engine/launcher_skins_ux.lua b/tests/engine/launcher_skins_ux.lua new file mode 100644 index 00000000..3ce36f51 --- /dev/null +++ b/tests/engine/launcher_skins_ux.lua @@ -0,0 +1,268 @@ +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") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") +local TouchSkin = require("src.core.TouchSkin") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function launcher() + return RomImporter.new(function() end, { launcher = true }) +end + +eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip"), "gbc.zip", + "a direct .zip keeps its name") +eq(RomImporter.skinUrlName("https://example.com/pads/Neon.deltaskin"), + "Neon.deltaskin", "and so does a .deltaskin") +eq(RomImporter.skinUrlName("https://example.com/overlay.cfg"), "overlay.cfg", + "a bare RetroArch cfg is kept as a cfg") +eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip?raw=1"), "gbc.zip", + "a query string is not part of the name") +eq(RomImporter.skinUrlName("https://example.com/pads/gbc.zip#frag"), "gbc.zip", + "nor is a fragment") +eq(RomImporter.skinUrlName("https://example.com/download"), "download.zip", + "an extension-less link is treated as an archive") +eq(RomImporter.skinUrlName("https://example.com/a b/../pad.tar"), "pad.zip", + "an unknown extension is replaced, and the name is sanitized") +check(RomImporter.skinUrlName("https://example.com/"):match("^[%w%._%-]+$"), + "the download name can never escape the skins folder") + +local name, payload = RomImporter.wrapSkinPayload("overlay.cfg", + "overlays = 1\noverlay0_descs = 0\n") +eq(name, "overlay.zip", "a downloaded .cfg is wrapped into an archive") +eq(payload:sub(1, 2), "PK", "which is a real zip") +check(payload:find("overlays = 1", 1, true) ~= nil, + "carrying the cfg text inside it") +check(payload:find("overlay.cfg", 1, true) ~= nil, + "under the name RetroArch parsing expects") + +local zipName, zipData = RomImporter.wrapSkinPayload("pad.zip", "PK\3\4stuff") +eq(zipName, "pad.zip", "a zip is passed through untouched") +eq(zipData, "PK\3\4stuff", "bytes and all") +eq(select(1, RomImporter.wrapSkinPayload("pad.deltaskin", "PK\3\4x")), + "pad.deltaskin", "and so is a .deltaskin") +local pkName, pkData = RomImporter.wrapSkinPayload("overlay.cfg", "PK\3\4real") +eq(pkName, "overlay.zip", "a .cfg link that serves zip bytes is renamed, not refused") +eq(pkData, "PK\3\4real", "and its bytes are left alone") + +local imp = launcher() +check(not imp:_addSkinFromUrl(""), "an empty link is refused") +check(imp._skinNotice and not imp._skinNotice.ok, "with a visible error") +eq(imp._skinFetch, nil, "and no download is started") +check(not imp:_addSkinFromUrl("file:///etc/passwd"), + "a non-http link is refused") +eq(imp._skinFetch, nil, "and still starts nothing") +check(not imp:_addSkinFromUrl("skins/local.zip"), + "a bare path is not a link either") + +local Fetch = require("src.net.Fetch") +local realDownload, realPoll, realRelease = Fetch.download, Fetch.poll, + Fetch.release +local asked +Fetch.download = function(url, dest) asked = { url = url, dest = dest } return 7 end +Fetch.poll = function() return { status = "pending", progress = 0.5 } end +Fetch.release = function() end + +imp = launcher() +imp.skinUrl = "https://example.com/pads/neon.deltaskin" +check(imp:_addSkinFromUrl(), "a good link starts a download") +check(imp._skinFetch ~= nil, "and parks the job on the importer") +eq(asked.url, "https://example.com/pads/neon.deltaskin", "the url is fetched") +check(asked.dest:find("neon.deltaskin", 1, true) ~= nil, + "into a file named after the link") +check(asked.dest:find("%.%.") == nil, "with no traversal in the path") +check(not imp:_addSkinFromUrl("https://example.com/other.zip"), + "a second add while one is in flight is ignored") + +imp:_pumpSkinFetch() +check(imp._skinFetch ~= nil, "a pending download stays in flight") + +local installed +imp._installSkinData = function(_, n, d) installed = { name = n, data = d } return "neon" end +Fetch.poll = function() + return { status = "ok", path = "skins/_download/neon.deltaskin" } +end +love.filesystem.write("skins/_download/neon.deltaskin", "PK\3\4payload") +imp:_pumpSkinFetch() +eq(imp._skinFetch, nil, "a finished download is released") +check(installed ~= nil, "and its bytes go to the installer") +eq(installed.name, "neon.deltaskin", "under the downloaded name") +eq(love.filesystem.read("skins/_download/neon.deltaskin"), nil, + "the temporary download is cleaned up") +eq(imp.skinUrl, "", "and the field is cleared for the next one") + +imp = launcher() +imp._installSkinData = function() return nil end +Fetch.download = function() return 8 end +Fetch.poll = function() return { status = "error", err = "404" } end +imp:_addSkinFromUrl("https://example.com/missing.zip") +imp:_pumpSkinFetch() +eq(imp._skinFetch, nil, "a failed download is released too") +check(imp._skinNotice and not imp._skinNotice.ok, "and reported") +check(tostring(imp._skinNotice.text):find("404", 1, true) ~= nil, + "with the reason attached") + +Fetch.download, Fetch.poll, Fetch.release = realDownload, realPoll, realRelease + +imp = launcher() +eq(imp:_installSkinData("pad.zip", ""), nil, "an empty payload is refused") +check(imp._skinNotice and not imp._skinNotice.ok, "and says so") +eq(imp:_installSkinData("notes.txt", "hello"), nil, "a non-archive is refused") + +love.filesystem.write("skins/warny.zip/overlay.cfg", [[ +overlays = 1 +overlay0_name = "warny" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]]) +imp = launcher() +eq(imp:_installSkinData("warny.zip", "PK\3\4stub"), "warny", "a skin installs") +check(imp._skinNotice.ok, "with an ok notice") +check(tostring(imp._skinNotice.text):find("missing desc", 1, true) ~= nil, + "that repeats what the importer had to complain about") + +love.filesystem.write("skins/vecty.deltaskin/info.json", [[ +{ "name": "Vecty", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "iphone_portrait.pdf" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ] + } } } } } +]]) +imp = launcher() +eq(imp:_installSkinData("vecty.deltaskin", "PK\3\4stub"), nil, + "a PDF-only Delta skin does not install silently") +check(imp._skinNotice and not imp._skinNotice.ok, "the tab reports the refusal") +check(tostring(imp._skinNotice.text):find("PDF artwork", 1, true) ~= nil, + "and says why, instead of listing a skin with no buttons") + +local function dropped(fileName) + return { getFilename = function() return fileName end, + open = function() return false end } +end + +local routed +local function routeDrop(tab, fileName) + routed = nil + local drop = launcher() + drop.tab = tab + drop._installSkinZip = function() routed = "skin" end + drop._installMod = function() routed = "mod" end + drop.startData = function() routed = "rom" end + drop:filedropped(dropped(fileName)) + return routed +end + +eq(routeDrop("mods", "pad.deltaskin"), "skin", + "a dropped .deltaskin installs as a skin from any tab") +eq(routeDrop("skins", "pad.deltaskin"), "skin", "and from the skins tab") +eq(routeDrop("skins", "Neon.DeltaSkin"), "skin", "whatever its case") +eq(routeDrop("skins", "pad.zip"), "skin", "a zip on the skins tab is still a skin") +eq(routeDrop("mods", "pad.zip"), "mod", "and a mod anywhere else") + +check(TouchSkin.saveTo(TouchSkin.newSkin("uxskin"), "uxskin") ~= nil, + "a skin to list") +imp = launcher() +local entries = imp:_ensureSkins(true) +check(#entries > 0, "the installed skins are listed") +local byId = {} +for _, entry in ipairs(entries) do + byId[entry.id] = entry + check(type(entry.format) == "string", + entry.id .. " reports the format it was parsed from") +end +eq(byId.uxskin and byId.uxskin.format, "native", + "a skin.lua skin is badged as the native format") + +imp = launcher() +eq(imp:_exportSkin("no-such-skin", "native"), nil, "exporting a ghost fails") +check(imp._skinNotice and not imp._skinNotice.ok, "with an error notice") + +local first = entries[1] +imp = launcher() +local path = imp:_exportSkin(first.id, "delta") +check(path ~= nil and path:match("%.deltaskin$") ~= nil, + "a bundled skin exports as a .deltaskin") +check(imp._skinNotice.ok, "and the tab reports where it landed") +check(tostring(imp._skinNotice.text):find(path, 1, true) ~= nil, + "naming the path, which is the whole mobile story") +check(imp._skinExport ~= nil and imp._skinExport.path == path, + "the export is remembered so Show file can reveal it") +path = imp:_exportSkin(first.id, "retroarch") +check(path ~= nil and path:match("%.zip$") ~= nil, + "and as a RetroArch .zip") +path = imp:_exportSkin(first.id, "native") +check(path ~= nil and path:match("%.zip$") ~= nil, "and as a gen1recomp .zip") + +window(420, 900) +imp = launcher() +imp.tab = "skins" +LauncherView.draw(imp) +LauncherView.draw(imp) +check(true, "the skins tab draws with the URL row") +imp._skinActions = { id = entries[1].id } +LauncherView.draw(imp) +check(imp._skinActions ~= nil, "the actions sheet stays up while it draws") +imp._skinFetch = { name = "neon.zip" } +LauncherView.draw(imp) +imp._skinFetch = nil + +window(320, 640) +LauncherView.draw(imp) +LauncherView.draw(imp) +check(true, "and on a phone-width window, where Paste gives up its room") + +local view = read("src/import/LauncherView.lua") +local rom = read("src/import/RomImporter.lua") + +check(view:find('"skins-url"', 1, true) ~= nil, + "the skins tab carries an add-by-URL field") +check(view:find('"skins-url-add"', 1, true) ~= nil, "with a button to submit it") +check(view:find('"skins-url-paste"', 1, true) ~= nil, + "and a paste button, because a phone cannot type a URL") +check(view:find("_addSkinFromUrl", 1, true) ~= nil, + "which reaches the importer's downloader") +check(view:find("Loader.inline", 1, true) ~= nil, + "and the row shows progress while it runs") +check(view:find("SKIN_FORMAT_LABEL", 1, true) ~= nil, + "rows carry a format badge") +check(view:find("buildSkinActionsModal", 1, true) ~= nil, + "the gear opens an actions sheet") +check(view:find("_exportSkin", 1, true) ~= nil, "which can export the skin") +check(view:find("skinact-exp-delta", 1, true) ~= nil, + "including as a Delta skin") +local modals = view:match("local function modalUp%(imp%)(.-)\nend") +check(modals and modals:find("_skinActions", 1, true) ~= nil, + "the sheet raises the modal shield like every other popup") +check(view:find("imp.onOpenSkinStudio(imp.modScope or \"red\", id)", 1, true) + ~= nil, "and still hands the studio a real game version") + +check(rom:find("deltaskin", 1, true) ~= nil, + "the desktop file picker offers .deltaskin") +check(rom:find("_pumpSkinFetch", 1, true) ~= nil, + "the skin download is pumped from update()") +local update = rom:match("function RomImporter:update%(dt%)(.-)\nend\n") +check(update and update:find("_pumpSkinFetch", 1, true) ~= nil, + "from inside update itself, not just declared") +check(TouchSkin.ARCHIVE_EXTS.deltaskin == true, + "and the installer accepts the extension") + +T.finish("launcher_skins_ux") diff --git a/tests/engine/launcher_sync_modal.lua b/tests/engine/launcher_sync_modal.lua new file mode 100644 index 00000000..8899d358 --- /dev/null +++ b/tests/engine/launcher_sync_modal.lua @@ -0,0 +1,307 @@ +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") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.polygon = love.graphics.polygon or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +eq(RomImporter.syncDigits("1234-5678"), "12345678", + "the dash people read the code with is not part of it") +eq(RomImporter.syncDigits(" 12 34 "), "1234", "spaces are dropped") +eq(RomImporter.syncDigits("abc9"), "9", "letters cannot enter a digit code") +eq(RomImporter.syncDigits("123456789012"), "12345678", + "a code is eight digits and no more") +eq(RomImporter.syncDigits(nil), "", "an empty field stays empty") + +eq(RomImporter.syncShareCode("abc234"), "ABC234", "share codes are upper case") +eq(RomImporter.syncShareCode("A1B0C-D"), "ABCD", + "1 and 0 are not in the share alphabet") +eq(RomImporter.syncShareCode("ABCDEFGH"), "ABCDEF", + "a share code is six characters") + +local function fakeEngine(over) + local eng = { + phase = "idle", status = "Ready", conflicts = {}, state = { enabled = true }, + calls = {}, + isLinked = false, + linked = function(self) return self.isLinked end, + busy = function(self) return self.isBusy == true end, + createAccount = function(self, label) + self.calls[#self.calls + 1] = { "create", label } + self.isLinked = true + self.codes = { code1 = "1234-5678", code2 = "8765-4321" } + return true + end, + linkDevice = function(self, a, b, label) + self.calls[#self.calls + 1] = { "link", a, b, label } + if #tostring(a) ~= 8 or #tostring(b) ~= 8 then return false end + self.isLinked = true + return true + end, + syncNow = function(self) + self.calls[#self.calls + 1] = { "syncNow" } + return true + end, + unlink = function(self) + self.calls[#self.calls + 1] = { "unlink" } + self.isLinked, self.codes = false, nil + return true + end, + shareMods = function(self) + self.calls[#self.calls + 1] = { "shareMods" } + self.shareCode = "K7QW3M" + return true + end, + fetchShare = function(self, code) + self.calls[#self.calls + 1] = { "fetchShare", code } + return true + end, + applyModPlan = function(self, progress) + self.calls[#self.calls + 1] = { "applyModPlan" } + if progress then progress(1, 2, "a") progress(2, 2, "b") end + return true + end, + resolveConflict = function(self, key, choice) + self.calls[#self.calls + 1] = { "resolve", key, choice } + return true + end, + } + for k, v in pairs(over or {}) do eng[k] = v end + return eng +end + +local function launcher(eng) + local imp = RomImporter.new(function() end, { launcher = true }) + imp._sync = eng + imp._syncTransportOk = true + return imp +end + +local eng = fakeEngine() +local imp = launcher(eng) + +eq(imp._syncModal, nil, "the modal is closed until the header button opens it") +imp:_openSync() +check(imp._syncModal ~= nil, "the header button opens the modal") +eq(imp._syncModal.view, "home", "and lands on the home view") +eq(imp._syncFocus, nil, "with no field taking the keyboard") + +imp:_syncView("link") +eq(imp._syncModal.view, "link", "Link this device swaps the view") +imp:_syncFocusField("code1") +eq(imp._syncFocus, "code1", "tapping a field focuses it") +imp:textinput("12ab34") +eq(imp._syncModal.code1, "1234", "typed letters never reach a code field") +imp:textinput("5678") +eq(imp._syncModal.code1, "12345678", "the field fills to eight digits") +imp:textinput("9") +eq(imp._syncModal.code1, "12345678", "and refuses a ninth") +imp:keypressed("backspace") +eq(imp._syncModal.code1, "1234567", "backspace drops one digit") +imp:textinput("8") + +imp:_syncFocusField("code2") +eq(imp._syncFocus, "code2", "focus moves to the second code") +eq(imp._syncModal.code1, "12345678", "without disturbing the first") +imp:_syncFocusField("code2") +eq(imp._syncFocus, nil, "tapping the focused field again releases it") +imp:_syncFocusField("code2") +imp:textinput("87654321") + +imp:_syncLink() +eq(eng.calls[#eng.calls][1], "link", "Link sends both codes to the engine") +eq(eng.calls[#eng.calls][2], "12345678", "the first code as typed") +eq(eng.calls[#eng.calls][3], "87654321", "and the second") +eq(imp._syncModal.view, "home", "a linked device comes back to the home view") +eq(imp._syncModal.code1, "", "and the codes are not left lying in the field") +eq(imp._syncModal.code2, "", "either of them") +eq(imp._syncFocus, nil, "with the keyboard released") + +local short = launcher(fakeEngine()) +short:_openSync() +short:_syncView("link") +short._syncModal.code1, short._syncModal.code2 = "1234", "87654321" +eq(short:_syncLink(), false, "a short code does not link") +eq(short._syncModal.code1, "1234", + "and what was typed stays put to be corrected") + +imp:_syncFocusField("code1") +imp:keypressed("escape") +eq(imp._syncFocus, nil, "escape out of a field releases the keyboard") +check(imp._syncModal ~= nil, "and leaves the modal up") +imp:keypressed("escape") +eq(imp._syncModal, nil, "escape closes the modal") + +imp:_openSync() +imp:_syncView("mods") +imp:_syncShareMods() +eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code") +imp:_syncFocusField("share") +imp:textinput("k7qw3m") +eq(imp._syncModal.share, "K7QW3M", "a typed share code is normalized") +imp:_syncGetShare() +eq(eng.calls[#eng.calls][2], "K7QW3M", "and handed to the engine as typed") +imp._syncModal.progress = nil +imp:_syncApplyMods() +eq(imp._syncModal.progress, nil, + "the progress line is cleared once the apply returns") + +imp:_syncResolve("red/abc", "both") +eq(eng.calls[#eng.calls][1], "resolve", "the conflict buttons call the engine") +eq(eng.calls[#eng.calls][3], "both", "with the choice the player pressed") + +imp:_syncUnlink() +eq(eng.isLinked, false, "Unlink drops the device") +eq(imp._syncModal.view, "home", "and the modal returns to the home view") + +local bare = RomImporter.new(function() end, { launcher = true }) +bare._sync = false +bare._syncTransportOk = true +bare:_openSync() +check(bare._syncModal ~= nil, "the modal opens without an engine") +bare:_closeSync() +eq(bare._syncModal, nil, "and closes again") + +local function controls(imp2) + love.graphics.getDimensions = function() return 900, 780 end + love.graphics.getPixelDimensions = love.graphics.getDimensions + Kit.audit = {} + local ok, err = pcall(LauncherView.draw, imp2) + local labels = {} + for _, r in ipairs(Kit.audit or {}) do + if r.class == "control" then labels[r.label] = true end + end + Kit.audit = nil + check(ok, "the sync modal draws: " .. tostring(err)) + return labels +end + +local rEng = fakeEngine() +local rImp = launcher(rEng) +rImp:_openSync() +local labels = controls(rImp) +check(labels["Create sync account"], "an unlinked device is offered an account") +check(labels["Link this device"], "and the link road") + +rEng:createAccount("mac") +labels = controls(rImp) +check(labels["Sync now"], "a linked device can sync on demand") +check(labels["Unlink this device"], "and unlink") +check(labels["Share or get a mod list"], "and reach the mod list road") + +rImp:_syncView("link") +labels = controls(rImp) +check(labels["Back"], "the link view can back out") + +rImp:_syncView("mods") +rEng.shareCode = "K7QW3M" +rEng.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } }, + toEnable = {}, missing = {} } +labels = controls(rImp) +check(labels["Share mod list"], "the mod view shares a list") +check(labels["Get mod list"], "and fetches one") +check(labels["Apply these mods"], "a fetched plan can be applied") + +rImp:_syncView("home") +rEng.devices = { + { id = "0a1b2c3d", label = "OS X", current = true }, + { id = "99998888", label = "Android" }, +} +labels = controls(rImp) +check(labels["Unlink Android"], "the other linked devices can be revoked here") +check(labels["OS X \194\183 this device"], + "and this one is named rather than offered twice") + +local devRows = LauncherView.syncDeviceRows(rEng) +eq(#devRows, 2, "the modal reads the device list off the engine") +eq(devRows[1].current, true, "knowing which one is this device") +eq(#LauncherView.syncDeviceRows({}), 0, + "an engine that has not synced yet lists nothing") + +local offline = launcher(fakeEngine()) +offline._syncTransportOk = false +offline:_openSync() +labels = controls(offline) +check(not labels["Create sync account"], + "a device with no way to send signed requests is not offered an account") +check(labels["Close"], "it just explains itself and closes") + +rEng.devices = nil +rEng.phase = "conflict" +rEng.conflicts = { { + key = "red/abc", version = "red", overlap = true, + localMeta = { savedAt = 1700000000, sessionStart = 1699999000, + summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } }, + remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500, + summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } }, +} } +labels = controls(rImp) +check(labels["Keep this device"], "a conflict offers this device") +check(labels["Keep the other device"], "the other device") +check(labels["Keep both"], "and keeping both") +check(not labels["Sync now"], + "a conflict takes over the modal until it is answered") + +local side = LauncherView.syncSideText({ savedAt = 1700000000, + summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } }) +check(side:find("ASH", 1, true) ~= nil, "a side summary names the trainer") +check(side:find("3 badges", 1, true) ~= nil, "counts badges") +check(side:find("5:42", 1, true) ~= nil, "and shows play time") +eq(LauncherView.syncSideText(nil), "no details", + "a side with no metadata says so rather than drawing blank") + +local quiet = launcher(fakeEngine()) +quiet:_pumpSync(0.016) +eq(quiet._syncModal, nil, "a quiet auto-sync never interrupts the launcher") + +local raised = launcher(fakeEngine({ phase = "conflict", + conflicts = { { key = "red/abc", version = "red" } } })) +raised:_pumpSync(0.016) +check(raised._syncModal ~= nil, + "a conflict found by the boot sync opens the prompt on its own") +raised:_closeSync() +raised:_pumpSync(0.016) +eq(raised._syncModal, nil, + "and a prompt the player dismissed does not reopen every frame") + +local view = read("src/import/LauncherView.lua") +local impSrc = read("src/import/RomImporter.lua") + +check(view:find('"tab-sync"', 1, true) ~= nil, + "the header tab row carries a Save Sync button") +local header = view:match("local HEADER_TABS = %{(.-)%}\n") +check(header and header:find('id = "skins"', 1, true) ~= nil, + "and it sits beside the skins tab") +check(view:find('"BETA"', 1, true) ~= nil, + "the button and the modal are labelled BETA") +check(view:find("buildSyncModal", 1, true) ~= nil, + "the sync UI is a modal, so it works from any tab") +local modals = view:match("local function modalUp%(imp%)(.-)\nend") +check(modals and modals:find("_syncModal", 1, true) ~= nil, + "the modal raises the click shield like every other one") +check(view:find("if imp._syncModal then buildSyncModal", 1, true) ~= nil, + "and buildModals routes it") + +check(impSrc:find("_pumpSync(dt)", 1, true) ~= nil, + "the launcher pumps the sync engine every frame") +local pump = impSrc:match("function RomImporter:_pumpSync%(dt%)(.-)\nend\n") +check(pump and pump:find("self.launcher", 1, true) ~= nil, + "only the interactive launcher boots an engine of its own") +check(impSrc:find("_syncTypeInto", 1, true) ~= nil, + "text input is routed through the code filter") + +T.finish("launcher_sync_modal") diff --git a/tests/engine/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua new file mode 100644 index 00000000..20001bff --- /dev/null +++ b/tests/engine/skin_format_import_test.lua @@ -0,0 +1,626 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local DeltaSkin = require("src.core.DeltaSkin") +local Json = require("src.link.Json") + +local function near(got, want, msg) + return check(type(got) == "number" and math.abs(got - want) < 1e-6, + ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) +end + +local function hasWarning(skin, fragment) + for _, w in ipairs(skin.warnings or {}) do + if tostring(w):find(fragment, 1, true) then return true end + end + return false +end + +local function unzip(bytes) + local out, i = {}, 1 + while bytes:sub(i, i + 3) == "PK\3\4" do + local function u16(off) + local a, b = bytes:byte(i + off, i + off + 1) + return a + b * 256 + end + local function u32(off) + local a, b, c, d = bytes:byte(i + off, i + off + 3) + return a + b * 256 + c * 65536 + d * 16777216 + end + local size, nameLen, extraLen = u32(18), u16(26), u16(28) + local name = bytes:sub(i + 30, i + 29 + nameLen) + local start = i + 30 + nameLen + extraLen + out[name] = bytes:sub(start, start + size - 1) + out[#out + 1] = name + i = start + size + end + return out +end + +local function readBytes(path) + local f = assert(io.open(path, "rb")) + local data = f:read("*a") + f:close() + return data +end + +local GAMEBOY_CFG = [[ +overlays = 4 + +overlay0_name = "landscape" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 2.0 +overlay0_aspect_ratio = 2.22222222222222 +overlay0_descs = 13 +overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292" +overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292" +overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292" +overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292" +overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292" +overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292" +overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292" +overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292" +overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875" +overlay0_desc9_overlay = img/a.png +overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875" +overlay0_desc10_overlay = img/b.png +overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400" +overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400" + +overlay1_name = "portrait" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_aspect_ratio = 0.45 +overlay1_descs = 2 +overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525" +overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525" + +overlay2_name = "menu" +overlay2_full_screen = true +overlay2_normalized = true +overlay2_descs = 1 +overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1" + +overlay3_name = "hide" +overlay3_full_screen = true +overlay3_normalized = true +overlay3_descs = 1 +overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04" +overlay3_desc0_next_target = "landscape" +]] + +local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG)) +eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages") +eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape") +eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait") +check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay") +eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair") +eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed") +check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing") +eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art") +eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background") +eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name") +local named = {} +for _, ctl in ipairs(gameboy.pages[1].controls) do + for _, btn in ipairs(ctl.buttons) do named[btn] = true end +end +for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do + check(named[btn], "landscape binds GB " .. btn) +end +eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing") + +local SPACED_CFG = [[ +overlays = 1 +overlay0_name = "spaced" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05" +overlay0_desc0_saturate_pct = 0.6 +overlay0_desc0_exclusive = true +overlay0_desc0_movable = true +overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05 +]] +local spaced = assert(TouchSkin.parse(SPACED_CFG)) +near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed") +check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed") +check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc") +eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited") +eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses") +eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind") +near(spaced.pages[1].controls[1].x, 0.5, "space-separated position") +eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too") +eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape") + +local SHORT_CFG = [[ +overlays = 1 +overlay0_name = "short" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]] +local short = assert(TouchSkin.parse(SHORT_CFG)) +eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked") +check(hasWarning(short, "missing desc 1"), "and the importer says so") + +eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil, + "a cfg without the overlays key is refused") + +local AREA_CFG = [[ +overlays = 1 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_descs = 3 +overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc0_reach_x = 1.5 +overlay0_desc0_movable = true +overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08" +overlay0_desc1_up = "start" +overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1" +overlay0_desc2_saturate_pct = 0.6 +overlay0_desc2_exclusive = true +]] +local area = assert(TouchSkin.parse(AREA_CFG)) +local ap = area.pages[1] +near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16") +check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock") +eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes") + +local art = ap.controls[1] +check(art.decorative, "the dpad_area art is carried by a decoration") +eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image") +near(art.rangeX, 0.15, "sized like the area it replaces") + +local sectorE = ap.controls[2] +eq(sectorE.spec, "right", "the first sector is the one pointing right") +near(sectorE.x, 0.2, "every sector sits on the area centre") +near(sectorE.y, 0.7, "on both axes") +near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it") +near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is") +near(sectorE.reachRight, 1.5, "on both sides") +eq(sectorE.sector, 1, "the sector index is kept for the hit test") +eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner") +eq(ap.controls[4].spec, "down", "then straight down, y growing downwards") +eq(ap.controls[8].spec, "up", "and straight up seven sectors along") +check(ap.controls[1].movable, "_movable is parsed") + +local abxy = ap.controls[10] +eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A") +eq(abxy.buttons[1], "a", "and reaches that GB button") +eq(ap.controls[16].spec, "start", "abxy_area honours an _up override") +eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides") +check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set") +check(ap.controls[14].decorative, "RetroPad Y has no GB button, so that sector is inert") +eq(abxy.shape, "radial", "a radial area keeps its ellipse") + +eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad") +check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors") +eq(ap.controls[23].spec, "left|up", "with all eight sectors") +near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area") + +local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect", + rangeMod = 1, alphaMod = 1, + reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 } +local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area) +eq(#sectors, 8, "a dpad area expands into eight sector hitboxes") +local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true, + aspect = 1, controls = sectors } +local function hitSpecs(px, py) + local out = {} + for _, ctl in ipairs(sectors) do + if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end + end + return table.concat(out, "+") +end +eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone") +eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right") +eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards") +eq(hitSpecs(50, 40), "up", "a touch above centre is up") +eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions") +eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction") +eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner") +eq(hitSpecs(50, 80), "", "outside the area nothing fires") + +local PIXEL_NO_IMAGE = [[ +overlays = 1 +overlay0_name = "pixels" +overlay0_descs = 1 +overlay0_desc0 = "a,120,80,rect,20,10" +]] +local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE)) +check(hasWarning(noImage, "no base image"), + "pixel coords without a base image are called out") +check(noImage.pages[1].pixelCoords == false, + "and read as normalized rather than dividing by nothing") + +love.filesystem.write("skins/px/overlay.cfg", [[ +overlays = 1 +overlay0_name = "px" +overlay0_overlay = img/base.png +overlay0_full_screen = true +overlay0_descs = 2 +overlay0_desc0 = "a,4,4,rect,2,1" +overlay0_desc1 = "b,6,2,rect,1,1" +overlay0_desc1_normalized = true +]]) +local px = assert(TouchSkin.load("skins/px", "px")) +local pxPage = px.pages[1] +check(pxPage.image ~= nil, "the base overlay image loads") +local iw, ih = pxPage.image:getDimensions() +near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width") +near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height") +near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents") +near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out") +check(pxPage.pixelCoords == false, "the page is normalized once converted") + +love.filesystem.write("skins/pxbad/overlay.cfg", [[ +overlays = 1 +overlay0_name = "pxbad" +overlay0_overlay = img/broken.png +overlay0_descs = 1 +overlay0_desc0 = "a,4,4,rect,2,1" +]]) +local savedNewImage = love.graphics.newImage +love.graphics.newImage = function() error("unreadable image") end +local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad") +love.graphics.newImage = savedNewImage +eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load") +check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil, + "and the error names the image it could not read") + +local DELTA_JSON = [[ +{ + "name": "Test GBC", + "identifier": "com.example.gbc.test", + "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "debug": false, + "representations": { + "iphone": { + "edgeToEdge": { + "portrait": { + "assets": { "small": "p_small.png", "medium": "p_medium.png", + "large": "p_large.png" }, + "items": [ + { "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64}, + "mask": "circle" }, + { "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64}, + "extendedEdges": {"right":16} }, + { "inputs": {"up":"up","down":"down","left":"left","right":"right"}, + "frame": {"x":16,"y":320,"width":96,"height":96} }, + { "inputs": ["start","select"], + "frame": {"x":128,"y":448,"width":64,"height":32} }, + { "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} }, + { "inputs": ["quickSave"], + "frame": {"x":288,"y":0,"width":32,"height":32} } + ], + "mappingSize": {"width":320,"height":480}, + "extendedEdges": {"top":8,"bottom":8,"left":8,"right":8}, + "translucent": false, + "screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144}, + "outputFrame": {"x":0,"y":32,"width":320,"height":288} }] + } + }, + "standard": { + "portrait": { "items": [], "mappingSize": {"width":320,"height":480} } + } + } + } +} +]] + +local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON)) +eq(delta.format, "delta", "a .deltaskin parses into the native model") +eq(delta.name, "Test GBC", "info.json name") +eq(delta.system, "gbc", "gbc covers both GB and GBC") +eq(#delta.pages, 1, "only the orientations present become pages") +local dp = delta.pages[1] +eq(dp.name, "portrait", "the page is named for its orientation") +eq(dp.orient, "portrait", "and locked to it") +eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone") +check(dp.fullScreen, "Delta stretches its skin over the whole surface") +check(not dp.aspectFromCfg, "so nothing letterboxes it") +near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect") +eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed") + +local dA = dp.controls[1] +eq(dA.buttons[1], "a", "an inputs array binds its button") +eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox') +near(dA.x, 0.85, "frame top-left plus half width is the native centre") +near(dA.y, 352 / 480, "and the same for y") +near(dA.rangeX, 0.1, "frame width halves into the native half extent") +near(dA.reachLeft, 1.25, "orientation extendedEdges become reach") + +local dB = dp.controls[2] +near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side") +near(dB.reachLeft, 1.25, "and leaves the others inherited") + +eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid") +near(dp.controls[3].x, 0.1, "dpad top-left cell x") +near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame") +near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them") +eq(dp.controls[4].spec, "up", "dpad top-centre cell") +eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell") + +eq(dp.controls[11].spec, "start|select", "a multi-input item fires both") +eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey") +check(dp.controls[13].decorative, + "quickSave has no engine hotkey, so it is inert rather than a game button") + +check(dp.viewport ~= nil, "screens[] places the emulator picture") +near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize") +near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize") + +local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500) +eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y") +eq(bw, 1000, "delta page box fills the width") +eq(bh, 500, "delta page box fills the height") + +local LEGACY_SCREEN = [[ +{ "gameTypeIdentifier": "public.aoshuang.game.gbc", + "representations": { "iphone": { "standard": { "landscape": { + "mappingSize": {"width":640,"height":320}, + "gameScreenFrame": {"x":160,"y":0,"width":320,"height":288}, + "translucent": true, + "items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown", + "left":"analogStickLeft","right":"analogStickRight"}, + "frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } } +]] +local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN)) +eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted") +eq(#legacy.pages, 1, "landscape only") +eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock") +near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect") +near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls") +eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad") +eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped") + +local snes = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.snes", + "representations": { "iphone": { "standard": { "portrait": { + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns") +eq(#snes.pages, 1, "but still imports") + +eq(select(1, DeltaSkin.parse([[ +{ "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba", + "representations": { "iphone": { "portrait": { "assets": {} } } } } +]])), nil, "a GBA4iOS skin is refused") +local _, gbaErr = DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} } +]]) +check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil, + "and the message names the old format") + +local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }') +check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil, + "info.json without a gameTypeIdentifier is refused by name") +eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused") +eq(select(1, DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} } +]])), nil, "an empty representations tree is refused") + +local PDF_JSON = [[ +{ "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "iphone_portrait.pdf" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ] + } } } } } +]] +local pdf = assert(DeltaSkin.parse(PDF_JSON)) +eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art") +local convert = DeltaSkin.needsConversion(pdf) +check(convert ~= nil, "PDF-only skins report that they need conversion") +if convert then + check(convert.pdfOnly, "the report is flagged pdfOnly") + eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert") +end +eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion") + +local mixed = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gb", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "art.pdf", "medium": "art.png" }, + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF") +eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed") + +eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png", + "the ladder falls back to the largest shipped asset") +eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" }, + { targetWidth = 640 }, {}), "s.png", + "a small target takes the small asset") +eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png", + 'the Manic "normal" alias is accepted') + +love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[ +{ "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "large": "Portrait.PNG" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ] + } } } } } +]]) +love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n") + +local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub") +eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr)) +local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped")) +eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin") +eq(wrapped.name, "Wrapped", "and its name comes from info.json") +eq(wrapped.pages[1].imagePath, "MySkin/portrait.png", + "the wrapping folder is prefixed onto assets and the real file name wins") +eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed") + +love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON) +local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub") +eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible") +check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil, + "with the message that asks for a PNG version") +eq(love.filesystem.read("skins/vector.deltaskin"), nil, + "and the refused archive is not left behind") + +eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil, + "a GBA4iOS .gbcskin is refused at the door") +local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub") +check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil, + "with a message that names the format") +eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil, + "an unknown archive extension is refused") +eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin") +eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip") +eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive") + +local AUTHORED = [[ +return { name = "Authored", pages = { + { name = "portrait", orient = "portrait", fullScreen = true, + viewport = { x = 0, y = 0, w = 1, h = 0.5 }, + controls = { + { bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" }, + { bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial", + reachRight = 1.5 }, + { bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 }, + { bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 }, + { bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" }, + } }, + { name = "landscape", orient = "landscape", fullScreen = true, + controls = { + { bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" }, + } }, +} } +]] +local authored = assert(TouchSkin.parseNative(AUTHORED)) +authored.id = "authored" +authored.root = "skins/authored" + +local cfgText = TouchSkin.toRetroArchConfig(authored) +check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays") +local reparsed = assert(TouchSkin.parse(cfgText)) +eq(#reparsed.pages, 2, "the generated cfg round-trips both pages") +eq(reparsed.pages[1].name, "portrait", "and their names") +eq(#reparsed.pages[1].controls, 5, "and every desc") +eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip") +near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip") +near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive") +eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives") +near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives") +eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive") +check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration") +eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art") +near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives") +eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name") + +local KEY_SKIN = [[ +return { name = "Keys", pages = { + { name = "portrait", fullScreen = true, controls = { + { bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 }, + } }, +} } +]] +local keySkin = assert(TouchSkin.parseNative(KEY_SKIN)) +local keyCfg = TouchSkin.toRetroArchConfig(keySkin) +check(keyCfg:find("retrok_escape", 1, true) ~= nil, + "a key bind exports in the grammar RetroArch understands") +check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling") +eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape", + "which this importer still reads back as the same key") + +local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages }) +local areaBack = assert(TouchSkin.parse(areaCfg)) +eq(#areaBack.pages[1].controls, #ap.controls, + "an area desc exports as one desc, not eight overlapping ones") +check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back") +check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override") +eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip") + +local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages })) +local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait +local dpadItem +for _, item in ipairs(areaRep.items) do + if not dpadItem and type(item.inputs) == "table" and item.inputs.up then + dpadItem = item + end +end +check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item") +eq(dpadItem.inputs.left, "left", "carrying each direction") +eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another") + +local raPath = os.tmpname() .. "-ra.zip" +local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath) +eq(raWritten, raPath, "exportRetroArch writes where it was told") +eq(raMissing[1], "img/dpad.png", "and reports art it could not find") +local raZip = unzip(readBytes(raPath)) +eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg") +check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes") +check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts") +os.remove(raPath) + +local dsPath = os.tmpname() .. ".deltaskin" +local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath }) +eq(dsWritten, dsPath, "exportDelta writes where it was told") +check(#dsWarnings > 0, "and warns that per-button art has nowhere to go") +local dsZip = unzip(readBytes(dsPath)) +eq(dsZip[1], "info.json", "the .deltaskin leads with info.json") +local info = assert(Json.decode(dsZip["info.json"])) +eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc", + "the export claims the GBC game type") +eq(info.name, "Authored", "and carries the skin name") +check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin") +local rep = info.representations.iphone.edgeToEdge.portrait +check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted") +eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080, + "standard portrait maps 1080 wide") +eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall") +eq(#rep.items, 4, "only bound controls become Delta items") +eq(rep.items[1].inputs[1], "a", "the first item is A") +eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask") +eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre") +eq(rep.items[1].frame.width, 216, "frame width is the full extent") +eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges") +eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input") +eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame") +eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport") +eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920, + "the landscape page maps 1920 wide") + +local back = assert(DeltaSkin.parse(dsZip["info.json"])) +eq(#back.pages, 2, "the exported skin re-imports both orientations") +local bp = back.pages[1] +eq(#bp.controls, 4, "with every bound control") +near(bp.controls[1].x, 0.8, "and the same centres it started with") +near(bp.controls[1].rangeX, 0.1, "and the same half extents") +eq(bp.controls[1].shape, "radial", "and the same hitbox shape") +near(bp.controls[2].reachRight, 1.5, "and the same reach") +near(bp.viewport.h, 0.5, "and the same screen cutout") +os.remove(dsPath) + +love.filesystem.write("skins/collide/overlay.cfg", [[ +overlays = 1 +overlay0_name = "collide" +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]]) +local collide = assert(TouchSkin.load("skins/collide", "collide")) +local defaultDelta = assert(TouchSkin.exportDelta(collide)) +eq(defaultDelta, "skins/_export/collide.deltaskin", + "a default export lands outside the folder the skin list scans") +local listedRoot, listedExport +for _, entry in ipairs(TouchSkin.list()) do + if entry.id == "collide" then listedRoot = entry.root end + if entry.id == "_export" then listedExport = true end +end +eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from") +check(not listedExport, "and the export folder is not a skin of its own") + +T.finish("skin_format_import") diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua new file mode 100644 index 00000000..ec794e78 --- /dev/null +++ b/tests/engine/skin_studio_ux.lua @@ -0,0 +1,339 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function near(a, b, tol, msg) + check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) .. + ", want " .. tostring(b) .. ")") +end + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.aspectLock = true + Studio.drag = nil + Studio.dirty = false + Studio.images = {} + Studio.thumbs = {} + Studio.available = {} + Studio.availableMeta = {} + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.status, Studio.statusErr = nil, false + Studio.imageTarget = "idle" + return Studio.skin +end + +session() +check(not Studio.canUndo(), "a fresh session has nothing to undo") +Studio.addControl() +eq(#Studio.page().controls, 1, "a control was added") +check(Studio.canUndo(), "adding a control is undoable") +Studio.undo() +eq(#Studio.page().controls, 0, "undo takes the control back off") +check(Studio.canRedo(), "and offers a redo") +Studio.redo() +eq(#Studio.page().controls, 1, "redo puts it back") +check(not Studio.canRedo(), "the redo stack is spent") + +Studio.addControl() +check(not Studio.canRedo(), "a fresh edit clears the redo stack") + +session() +Studio.addControl() +local before = Studio.page().controls[1] +Studio.pushUndo() +before.x = 0.9 +Studio.undo() +check(Studio.page().controls[1] ~= before, + "undo restores a copy, not the edited table") +near(Studio.page().controls[1].x, 0.5, 1e-6, "with the pre-edit position") + +session() +for _ = 1, Studio.UNDO_CAP + 10 do Studio.pushUndo() end +eq(#Studio.undoStack, Studio.UNDO_CAP, "the undo stack is capped") + +session() +check(not Studio.undo(), "undo on an empty stack reports nothing to do") +check(not Studio.redo(), "and so does redo") + +local realIsDown = love.keyboard and love.keyboard.isDown +love.keyboard = love.keyboard or {} +local held = {} +love.keyboard.isDown = function(...) + for _, k in ipairs({ ... }) do if held[k] then return true end end + return false +end +session() +Studio.addControl() +Studio.addControl() +held.lctrl = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+Z undoes one step") +Studio.keypressed("z") +eq(#Studio.page().controls, 0, "and again") +held.lshift = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+shift+Z redoes instead of undoing further") +held.lshift = nil +Studio.keypressed("y") +eq(#Studio.page().controls, 2, "and ctrl+Y redoes as well") +held.lctrl = nil +if realIsDown then love.keyboard.isDown = realIsDown end + +session() +local ran = 0 +check(Studio.guard("lose it?", function() ran = ran + 1 end), + "a clean skin runs the action straight away") +eq(ran, 1, "and does not prompt") +check(Studio.confirm == nil, "no prompt is left up") + +Studio.dirty = true +check(not Studio.guard("lose it?", function() ran = ran + 1 end), + "a dirty skin defers the action") +eq(ran, 1, "the action has not run yet") +check(Studio.confirm ~= nil, "and a prompt is up") +Studio.confirmNo() +eq(ran, 1, "cancelling drops the action") +check(Studio.confirm == nil, "and closes the prompt") + +Studio.guard("lose it?", function() ran = ran + 1 end) +Studio.confirmYes() +eq(ran, 2, "confirming runs it") +check(Studio.confirm == nil, "and closes the prompt") + +session() +Studio.dirty = true +Studio.openLoadPicker() +check(Studio.confirm ~= nil, "Load prompts over unsaved work") +check(Studio.modal == nil, "and does not open the picker yet") +Studio.confirmYes() +check(Studio.modal ~= nil and Studio.modal.kind == "open", + "confirming opens the picker") +Studio.closeModal() + +eq(Studio.toggleBindPart("nul", "left"), "left", + "a bind starts from decoration") +eq(Studio.toggleBindPart("left", "up"), "left|up", + "directions combine in the canonical order") +eq(Studio.toggleBindPart("up", "left"), "left|up", + "and the order does not depend on which was added first") +eq(Studio.toggleBindPart("left|up", "up"), "left", + "toggling a part off removes it") +eq(Studio.toggleBindPart("left", "left"), "nul", + "removing the last part leaves decoration") +eq(Studio.toggleBindPart("a", "b"), "a|b", "buttons combine as well") +check(Studio.hasBindPart("left|down", "down"), "hasBindPart finds a part") +check(not Studio.hasBindPart("left|down", "up"), "and misses one that is absent") + +session() +check(not Studio.openBindPicker(), "the bind picker needs a selected control") +check(Studio.statusErr, "and says so as an error") +Studio.addControl() +check(Studio.openBindPicker(), "with a control selected it opens") +eq(Studio.modal.kind, "bind", "as the bind modal") +Studio.setBindSpec("start") +eq(Studio.selectedControl().spec, "start", "picking a bind writes the spec") +eq(Studio.selectedControl().buttons[1], "start", "and reparses it") +Studio.undo() +eq(Studio.selectedControl().spec, "a", "the bind change is undoable") +Studio.closeModal() + +Studio.toggleSelectedBindPart("b") +eq(Studio.selectedControl().spec, "a|b", "the combine chips build a pipe bind") +eq(#Studio.selectedControl().buttons, 2, "which fires both buttons") + +local specs = {} +for _, group in ipairs(Studio.BIND_GROUPS) do + check(#group.specs > 0, group.title .. " lists at least one bind") + for _, spec in ipairs(group.specs) do specs[spec] = true end +end +check(specs["a"] and specs["start"], "the GB buttons are reachable") +check(specs["overlay_previous"], "overlay_previous is reachable at last") +check(specs["pause_toggle"] and specs["exit_emulator"], + "so are the hotkeys the old cycle could not reach") +check(specs["key:escape"], "and a keyboard bind can be picked") +check(specs["nul"], "decoration is still an option") + +session() +Studio.addControl() +Studio.addControl() +Studio.selected = 1 +local first = Studio.page().controls[1] +check(Studio.moveControlOrder(1), "bring forward moves the control up") +eq(Studio.selected, 2, "and follows it with the selection") +check(Studio.page().controls[2] == first, "the control really moved") +check(not Studio.moveControlOrder(1), "the front control cannot go further") +check(Studio.moveControlOrder(-1), "send back moves it down again") +eq(Studio.selected, 1, "selection follows back") +check(not Studio.moveControlOrder(-1), "and the back control stays put") + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +local canvas = Studio.canvas() +local startX, startY = ctl.x, ctl.y +Studio.nudge(1, 0) +near(ctl.x, startX + 1 / canvas.w, 1e-9, "an arrow moves one canvas pixel") +Studio.nudge(0, 1, true) +near(ctl.y, startY + 10 / canvas.h, 1e-9, "shift moves ten") +Studio.undo() +near(Studio.selectedControl().x, startX, 1e-9, "nudging is undoable") +Studio.selected = nil +check(not Studio.nudge(1, 0), "nothing selected, nothing nudged") + +check(Studio.NUDGES.up[2] == -1 and Studio.NUDGES.down[2] == 1, + "up is negative y on the canvas") +check(Studio.NUDGES.left[1] == -1 and Studio.NUDGES.right[1] == 1, + "and left is negative x") + +local off, line = Studio.snapOffset({ 100, 150, 200 }, { 152, 400 }, 4) +near(off, 2, 1e-9, "an edge within tolerance snaps to the guide") +near(line, 152, 1e-9, "and reports the line it snapped to") +off, line = Studio.snapOffset({ 100 }, { 400 }, 4) +eq(off, 0, "a line out of range does not move anything") +eq(line, nil, "and reports no guide") +off = Studio.snapOffset({ 100, 200 }, { 203, 101 }, 4) +near(off, 1, 1e-9, "the nearest candidate wins") + +session() +Studio.addControl() +local r = { x = 0, y = 0, w = 1000, h = 1000 } +local xs, ys = Studio.snapLines(Studio.page(), r, nil) +check(#xs >= 6 and #ys >= 6, + "snap lines cover the page box and every other control") +local skipped = select(1, Studio.snapLines(Studio.page(), r, 1)) +eq(#skipped, 3, "the dragged control is not a guide for itself") + +session() +Studio.addControl() +Studio.page().controls[1].x = 0.25 +Studio.addControl() +Studio.selected = 2 +local moving = Studio.selectedControl() +moving.x = 0.6 +local target = Studio.page().controls[1] +local bx, by, bw, bh = 0, 0, 0, 0 +local cx, cy, hw, hh = TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y) +bx, by, bw, bh = cx - hw, cy - hh, hw * 2, hh * 2 +local tcx = select(1, TouchSkin.controlGeometry(Studio.page(), target, + r.w, r.h, r.x, r.y)) +Studio.drag = { kind = "control-move", mx = 0, my = 0, + bx = bx, by = by, bw = bw, bh = bh } +Studio.updateDrag((tcx - cx) + 3, 0, r) +local cx2 = select(1, TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y)) +near(cx2, tcx, 1e-6, "a near miss snaps onto the other control's centre") +check(Studio.guides ~= nil and Studio.guides.x ~= nil, + "and a guide line is recorded for the canvas to draw") +Studio.drag = nil + +session() +Studio.addPage() +Studio.addPage() +eq(#Studio.skin.pages, 3, "three pages") +check(Studio.setPage(1), "setPage jumps to a page by index") +eq(Studio.pageIndex, 1, "and lands there") +check(not Studio.setPage(9), "an index past the end is refused") +Studio.nextPage() +eq(Studio.pageIndex, 2, "next page still cycles") +local name, detail = Studio.pageLabel(2) +eq(name, "page2", "the page list shows the page name") +check(detail:find("controls", 1, true) ~= nil, "and what is on it") + +check(Studio.renamePage("landscape"), "a page can be renamed") +eq(Studio.page().name, "landscape", "and keeps the new name") +check(not Studio.renamePage(" "), "an empty name is refused") +Studio.undo() +eq(Studio.page().name, "page2", "renaming is undoable") + +Studio.pageIndex = 2 +check(Studio.deletePage(2), "a page can be deleted") +eq(#Studio.skin.pages, 2, "and the skin loses it") +Studio.deletePage(1) +check(not Studio.deletePage(1), "the last page cannot be deleted") +check(Studio.statusErr, "and the studio says why") + +session() +check(not Studio.openImagePicker("idle"), "art needs a selected control") +Studio.addControl() +check(Studio.openImagePicker("idle"), "with one selected the grid opens") +eq(Studio.modal.kind, "image", "as the image modal") +eq(Studio.imageTarget, "idle", "aimed at the idle art") +check(Studio.openImagePicker("bezel"), "the bezel needs no selection") +eq(Studio.currentImagePath(), nil, "a new page has no bezel yet") +Studio.imageTarget = "idle" +Studio.selectedControl().imagePath = "img/a.png" +eq(Studio.currentImagePath(), "img/a.png", "the picker marks the current art") +Studio.chooseImage(nil) +eq(Studio.selectedControl().imagePath, nil, "picking (none) clears the art") +eq(Studio.modal, nil, "and closes the picker") +Studio.undo() +eq(Studio.selectedControl().imagePath, "img/a.png", "clearing art is undoable") + +session() +Studio.setStatus("boom", true) +check(Studio.statusErr, "an error status is flagged") +Studio.addControl() +eq(Studio.status, "boom", "a later edit does not wipe the error off the footer") +Studio.setStatus("fine") +Studio.addControl() +eq(Studio.status, nil, "an ordinary status still clears on the next edit") +Studio.setStatus("boom", true) +Studio.statusAt = -1000 +Studio.expireStatus() +eq(Studio.status, nil, "and an error clears itself after a few seconds") + +local ids = {} +for _, spec in ipairs(Studio.EXPORTS) do ids[spec.id] = spec.label end +check(ids.native and ids.retroarch and ids.delta, + "the export menu offers all three formats") + +session() +Studio.skinIdField = "uxtest" +local nativePath = Studio.exportAs("native") +check(nativePath ~= nil and nativePath:match("%.zip$") ~= nil, + "the native export writes a .zip") +local raPath = Studio.exportAs("retroarch") +check(raPath ~= nil and raPath:match("%.zip$") ~= nil, + "the RetroArch export writes a .zip") +local deltaPath = Studio.exportAs("delta") +check(deltaPath ~= nil and deltaPath:match("%.deltaskin$") ~= nil, + "the Delta export writes a .deltaskin") +check(love.filesystem.read(deltaPath) ~= nil, "and the archive is on disk") +eq(Studio.lastExport, deltaPath, "the last export is remembered for Show file") + +eq(Studio.skinFormat({ format = "retroarch" }), "RetroArch", + "a format badge reads in words") +eq(Studio.skinFormat({ format = "delta" }), "Delta", "Delta included") + +love.graphics.getDimensions = love.graphics.getDimensions + or function() return 1280, 720 end +session() +Studio.addControl() +for _, kind in ipairs({ "bind", "image", "open", "page", "export" }) do + Studio.openModal(kind) + check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up") +end +Studio.closeModal() +Studio.ask("sure?", function() end) +check(pcall(Studio.draw), "and with the confirm prompt up") +Studio.confirmNo() + +Studio.openModal("bind") +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.mousepressed(50, 50, 1) +eq(Studio.drag, nil, "a click under an open modal does not grab a control") +Studio.closeModal() + +T.finish("skin_studio_ux") diff --git a/tests/engine/skin_viewport_containment.lua b/tests/engine/skin_viewport_containment.lua new file mode 100644 index 00000000..9ad35d4f --- /dev/null +++ b/tests/engine/skin_viewport_containment.lua @@ -0,0 +1,214 @@ +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 TouchSkin = require("src.core.TouchSkin") +local Playfield = require("src.render.Playfield") +local Renderer = require("src.render.Renderer") +local Chrome = require("src.ui.gen2.Chrome") +local Zoom = require("src.render.Zoom") + +local EPS = 1e-6 + +local function setWindow(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function cfg(viewport, extra) + return ([[ +overlays = 1 +overlay0_name = "bezel" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_viewport = "%s" +%s +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02" +]]):format(viewport, extra or "") +end + +local function useSkin(viewport, extra) + local skin = assert(TouchSkin.parse(cfg(viewport, extra))) + TouchSkin.setActive(skin) + TouchSkin.setOverlayLive(false) + return skin +end + +local function inside(x, y, w, h, bx, by, bw, bh) + return x >= bx - EPS and y >= by - EPS + and x + w <= bx + bw + EPS and y + h <= by + bh + EPS +end + +setWindow(640, 576) +TouchSkin.setActive(nil) +Renderer:init() +local plain = Renderer:frameRects() +eq(plain.cut, false, "no skin: no cutout") +eq(plain.vux, 0, "no skin: the picture starts at the window origin") +eq(plain.vuw, 640, "no skin: the picture is the whole window") +eq(plain.Sp, 4, "no skin: 640x576 fits four whole GB pixels") +eq(plain.uox, 0, "no skin: the UI letterbox fills the window") +eq(select(3, Playfield.rect(640, 576)), 640, "no skin: the playfield is the window") +eq(Chrome.fitScale(640, 576), 4, "no skin: Gold fits the window the same way") + +local WINDOWS = { + { 640, 576 }, { 1280, 720 }, { 1920, 1080 }, + { 800, 480 }, { 480, 800 }, { 360, 640 }, +} +local VIEWPORTS = { + "0.2335,0.0855,0.5335,0.830", + "0.2,0.15,0.6,0.5", + "0.05,0.05,0.9,0.35", + "0.3,0.1,0.4,0.8", +} +local UI_SIZES = { { 160, 144 }, { 304, 144 } } + +local escapes, uncut, cases = 0, 0, 0 +for _, win in ipairs(WINDOWS) do + setWindow(win[1], win[2]) + for _, vp in ipairs(VIEWPORTS) do + useSkin(vp) + Renderer:init() + for _, size in ipairs(UI_SIZES) do + Renderer:setUISize(size[1], size[2]) + for off = -8, 8 do + Zoom.offset = off + for _, fill in ipairs({ false, true }) do + for _, centered in ipairs({ true, false }) do + Renderer.uiFill = fill + Renderer.uiCentered = centered + Renderer.worldActive = true + cases = cases + 1 + local r = Renderer:frameRects() + local ux, uy, uw, uh = Renderer.clipToView(r, r.uox, r.uoy, + r.uvpw, r.uvph) + if not inside(ux, uy, uw, uh, r.vux, r.vuy, r.vuw, r.vuh) then + escapes = escapes + 1 + end + if uw < r.uvpw - EPS or uh < r.uvph - EPS then uncut = uncut + 1 end + local vw, vh = Renderer:worldViewSize() + local sp = Zoom.scale(r.Sp) + if vw * sp > r.vuw + 2 * sp + EPS + or vh * sp > r.vuh + 2 * sp + EPS then + escapes = escapes + 1 + end + end + end + end + end + end +end +check(cases > 1000, "the sweep covers every window x cutout x zoom x layout") +eq(escapes, 0, "no zoom, battle surface or UI layout puts a rect past the cutout") +eq(uncut, 0, "and the UI was sized to fit, so the clip never has to cut it") + +setWindow(1280, 720) +useSkin("0.25,0.1,0.5,0.6") +Renderer:init() +Renderer:setUISize(160, 144) +Renderer.uiFill, Renderer.uiCentered = false, true +Zoom.offset = 0 +local r = Renderer:frameRects() +eq(r.cut, true, "the skin's cutout is folded into the frame") +eq(r.vux, 320, "cutout x") +eq(r.vuy, 72, "cutout y") +eq(r.vuw, 640, "cutout width") +eq(r.vuh, 432, "cutout height") +eq(r.Sp, 3, "the fit is measured against the cutout, not the window") +check(inside(r.uox, r.uoy, r.uvpw, r.uvph, r.vux, r.vuy, r.vuw, r.vuh), + "the UI letterbox sits inside the cutout") +check(inside(r.ox, r.oy, r.vpw, r.vph, r.vux, r.vuy, r.vuw, r.vuh), + "so does the world letterbox") + +local lo, hi = Zoom.offsetRange(r.Sp) +for off = lo, hi do + Zoom.offset = off + local z = Renderer:frameRects() + check(inside(z.uox, z.uoy, z.uvpw, z.uvph, z.vux, z.vuy, z.vuw, z.vuh), + "zoom " .. Zoom.offsetLabel(off) .. " keeps the UI in the cutout") + local vw, vh = Renderer:worldViewSize() + local sp = Zoom.scale(z.Sp) + check(vw * sp <= z.vuw + 2 * sp and vh * sp <= z.vuh + 2 * sp, + "zoom " .. Zoom.offsetLabel(off) .. " keeps the world pass capped") +end +Zoom.offset = 0 + +local capped = select(1, Renderer:worldViewSize()) +useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true") +local expanded = select(1, Renderer:worldViewSize()) +check(expanded > capped, + "viewport_expand lets the survey world fill the cutout instead of the GB box") +useSkin("0.25,0.1,0.5,0.6") + +setWindow(480, 800) +useSkin("0.3,0.1,0.4,0.3") +Renderer:init() +Renderer:setUISize(304, 144) +Renderer.uiFill, Renderer.uiCentered = false, true +local tight = Renderer:frameRects() +check(tight.vuw < 304, "the cutout cannot hold the WIDE battle at 1x") +check(inside(tight.uox, tight.uoy, tight.uvpw, tight.uvph, + tight.vux, tight.vuy, tight.vuw, tight.vuh), + "so the surface is scaled down to the cutout rather than over the bezel") +Renderer:setUISize(160, 144) + +setWindow(1280, 720) +useSkin("0.25,0.1,0.5,0.6") +local px, py, pw, ph, active = Playfield.rect(1280, 720) +eq(active, true, "Gold sees the cutout too") +eq(pw, 480, "the playfield is a whole multiple of 160") +eq(ph, 432, "and of 144") +check(inside(px, py, pw, ph, 320, 72, 640, 432), + "centred inside the cutout") +eq(Chrome.fitScale(1280, 720), 3, "Chrome fits the playfield") +local cox, coy = Chrome.fitOrigin(1280, 720) +eq(cox, px, "and centres the panel on it") +eq(coy, py, "on both axes") + +useSkin("0.25,0.1,0.5,0.6", "overlay0_viewport_expand = true") +local ex, ey, ew, eh = Playfield.rect(1280, 720) +eq(ew, 640, "expand hands the picture the full cutout width") +eq(eh, 432, "and its full height") +eq(ex, 320, "at the cutout origin") +eq(ey, 72, "on both axes") +useSkin("0.25,0.1,0.5,0.6") + +local ew2, eh2, ex2, ey2, act2 = Playfield.push(1280, 720) +eq(act2, true, "push reports the frame is contained") +eq(ex2, px, "push translates to the playfield origin") +eq(ey2, py, "on both axes") +eq(ew2, pw, "and hands the scene the playfield size") +eq(Playfield.cutout(ew2, eh2), nil, "inside the frame there is no cutout left") +eq(select(3, Playfield.rect(ew2, eh2)), pw, "so the playfield is the surface") +eq(Chrome.fitScale(ew2, eh2), 3, "and Chrome fits it without re-applying") +eq(select(1, Chrome.fitOrigin(ew2, eh2)), 0, "at a local origin") +eq(select(1, Playfield.dimensions()), pw, "screens read the playfield as the display") +Playfield.pop() +eq(Playfield.entered, false, "pop leaves the frame") +eq(select(1, Playfield.cutout(1280, 720)), 320, "and the cutout is visible again") + +useSkin("0.4,0.4,0.1,0.1") +local sx, sy, sw, sh = Playfield.rect(1280, 720) +check(inside(sx, sy, sw, sh, 512, 288, 128, 72), + "a cutout smaller than 160x144 still bounds the playfield") +check(sw <= 128 and sh <= 72, "the playfield never exceeds the cutout") + +TouchSkin.setActive(nil) +eq(Playfield.cutout(1280, 720), nil, "no skin, no cutout") +eq(select(3, Playfield.rect(1280, 720)), 1280, "and the playfield is the window") +local saved = TouchSkin.viewport +TouchSkin.viewport = function() error("boom") end +eq(Playfield.cutout(1280, 720), nil, "a throwing viewport is no cutout") +TouchSkin.viewport = function() return 10, 10, 0, 0 end +eq(Playfield.cutout(1280, 720), nil, "a zero-sized cutout is no cutout") +TouchSkin.viewport = function() return -50, -50, 200, 200 end +eq(select(1, Playfield.cutout(1280, 720)), 0, "a cutout off the surface is clamped") +eq(select(3, Playfield.cutout(1280, 720)), 150, "to what is left of it") +TouchSkin.viewport = saved +TouchSkin.setActive(nil) +setWindow(640, 576) + +T.finish("skin_viewport_containment") diff --git a/tests/engine/sync_client_test.lua b/tests/engine/sync_client_test.lua new file mode 100644 index 00000000..105e8c29 --- /dev/null +++ b/tests/engine/sync_client_test.lua @@ -0,0 +1,175 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Json = require("src.link.Json") +local SyncClient = require("src.sync.SyncClient") + +local function recorder() + local t = { sent = {}, replies = {}, released = 0 } + function t:begin(req) + self.sent[#self.sent + 1] = req + return #self.sent + end + function t:poll(handle) + local reply = self.replies[handle] + if not reply then return { status = "pending" } end + return reply + end + function t:release() self.released = self.released + 1 end + function t:answer(handle, code, body) + self.replies[handle] = { status = "ok", code = code, body = body } + end + function t:fail(handle, err) + self.replies[handle] = { status = "error", err = err } + end + return t +end + +local function client(transport) + return SyncClient.new({ baseUrl = "http://sync.test/", transport = transport }) +end + +do + T.eq(SyncClient.normalizeCode("1234-5678"), "12345678", + "a dashed code normalizes to digits") + T.eq(SyncClient.normalizeCode(" 1234 5678 "), "12345678", + "and so does a spaced one") + T.eq(SyncClient.normalizeCode("1234567"), nil, "seven digits is not a code") + T.eq(SyncClient.normalizeCode("123456789"), nil, "nor is nine") + T.eq(SyncClient.normalizeCode("abcdefgh"), nil, "nor letters") + T.eq(SyncClient.formatCode("12345678"), "1234-5678", + "codes present as two groups of four") + T.eq(SyncClient.formatCode("nope"), nil, "a bad code has no presentation") +end + +do + local t = recorder() + local c = client(t) + T.eq(c:isLinked(), false, "a new client is not linked") + + local handle = c:create("laptop") + local req = t.sent[1] + T.eq(req.method, "POST", "create posts") + T.eq(req.url, "http://sync.test/sync/create", "to /sync/create") + T.eq(req.headers["x-sync-account"], nil, + "and carries no auth header before there is an account") + T.eq(Json.decode(req.body).device, "laptop", "the device label rides along") + + t:answer(handle, 200, + '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}') + local res = c:poll(handle) + T.eq(res.status, "ok", "a 200 with JSON reads as ok") + T.eq(res.data.account, "aa11", "and the account comes back decoded") + + c:setAuth(res.data.account, res.data.deviceToken) + T.eq(c:isLinked(), true, "storing the token links the client") + + local stateHandle = c:fetchState() + local stateReq = t.sent[2] + T.eq(stateReq.method, "GET", "state is a GET") + T.eq(stateReq.headers["x-sync-account"], "aa11", "with the account header") + T.eq(stateReq.headers["x-sync-token"], "tok", "and the device token header") + T.eq(stateReq.body, nil, "and no body") + T.eq(c:poll(stateHandle).status, "pending", "an unanswered request is pending") + + local bad, err = c:link("123", "456", "phone") + T.eq(bad, nil, "a short code never reaches the network") + T.check(tostring(err):find("8 digits", 1, true) ~= nil, + "and says what a code looks like") + T.eq(#t.sent, 2, "no request was sent for the bad codes") +end + +do + local t = recorder() + local c = client(t) + c:setAuth("aa11", "tok") + + local handle = c:putSave({ version = "red", slot = "slot1", + meta = { savedAt = 100, sessionStart = 50 }, blob = "return {}", + baseRev = 4 }) + local req = t.sent[1] + T.eq(req.method, "PUT", "a save upload is a PUT") + T.eq(req.url, "http://sync.test/sync/save", "to /sync/save") + local body = Json.decode(req.body) + T.eq(body.version, "red", "the version rides in the body") + T.eq(body.baseRev, 4, "with the rev the client last synced") + T.eq(body.meta.sessionStart, 50, "and the session start in the meta") + + t:answer(handle, 409, + '{"conflict":true,"rev":9,"remoteMeta":{"savedAt":200,"sessionStart":60}}') + local res = c:poll(handle) + T.eq(res.status, "error", "a 409 is not a success") + T.eq(res.code, 409, "the status code is reported") + T.eq(res.data.remoteMeta.savedAt, 200, + "and the conflict body is still readable") + + local tooBig, why = c:putSave({ version = "red", slot = "slot1", + blob = string.rep("x", SyncClient.MAX_BLOB + 1) }) + T.eq(tooBig, nil, "an oversized save is refused before it is sent") + T.check(tostring(why):find("too large", 1, true) ~= nil, + "with a reason the UI can show") + + local getHandle = c:getSave("red", "abc def") + T.eq(t.sent[2].url, "http://sync.test/sync/save?id=abc%20def&version=red", + "a download escapes its query parameters") + t:answer(getHandle, 200, '{"meta":{"savedAt":200},"blob":"return {}","rev":9}') + T.eq(c:poll(getHandle).data.rev, 9, "the download reports the served rev") +end + +do + local t = recorder() + local c = client(t) + c:setAuth("aa11", "tok") + + local h1 = c:fetchState() + t:fail(h1, "no route to host") + local res = c:poll(h1) + T.eq(res.status, "error", "a transport failure is an error") + T.check(res.err:find("no route", 1, true) ~= nil, "and keeps the reason") + + local h2 = c:fetchState() + t:answer(h2, 200, "nope") + local html = c:poll(h2) + T.eq(html.status, "error", "an HTML reply is not a sync reply") + T.check(html.err:find("HTML", 1, true) ~= nil, "and says so") + + local h3 = c:fetchState() + t:answer(h3, 401, '{"error":"bad_token"}') + local denied = c:poll(h3) + T.eq(denied.status, "error", "a 401 is an error") + T.eq(denied.err, "bad_token", "carrying the server's own reason") + + local h4 = c:fetchState() + t:answer(h4, 200, '{"ok":true,"error":"stale"}') + T.eq(c:poll(h4).status, "error", + "an error field in a 200 body still fails the call") + + c:clearAuth() + local nope, err = c:fetchState() + T.eq(nope, nil, "an unlinked client refuses an authenticated call") + T.check(tostring(err):find("not linked", 1, true) ~= nil, + "and says the device is not linked") +end + +do + local t = recorder() + local c = client(t) + c:setAuth("aa11", "tok") + + local handle = c:fetchShare("ab3d9k") + T.eq(t.sent[1].headers["x-sync-token"], nil, + "reading a share code needs no auth") + T.eq(t.sent[1].url, "http://sync.test/sync/modshare?code=AB3D9K", + "and the code is upper-cased in the query") + t:answer(handle, 200, '{"manifest":{"rev":1,"mods":[],"indexes":[]}}') + T.eq(c:poll(handle).data.manifest.rev, 1, "the shared manifest decodes") + + local bad, err = c:fetchShare("12") + T.eq(bad, nil, "a short share code never reaches the network") + T.check(tostring(err):find("6 characters", 1, true) ~= nil, + "and says how long one is") +end + +T.finish("sync_client") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua new file mode 100644 index 00000000..f6ee37fd --- /dev/null +++ b/tests/engine/sync_engine_test.lua @@ -0,0 +1,459 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Json = require("src.link.Json") +local SyncState = require("src.sync.SyncState") +local SyncEngine = require("src.sync.SyncEngine") + +local function scripted(routes) + local t = { sent = {}, routes = routes, handles = {} } + function t:begin(req) + self.sent[#self.sent + 1] = req + local path = req.url:match("^[^?]*"):gsub("^http://sync%.test", "") + local route = self.routes[req.method .. " " .. path] + local reply + if type(route) == "function" then + reply = route(req, self) + else + reply = route + end + reply = reply or { code = 404, body = '{"error":"no route"}' } + self.handles[#self.sent] = { + status = "ok", code = reply.code or 200, + body = reply.body or Json.encode(reply.data or {}), + } + return #self.sent + end + function t:poll(handle) return self.handles[handle] end + function t:release() end + return t +end + +local function pump(eng, times) + for _ = 1, (times or 24) do eng:update(0.05) end +end + +local function linkedState() + local state = SyncState.defaults() + state.account = "aa11bb22cc33dd44" + state.deviceToken = "tok" + state.enabled = true + return state +end + +local function saveEntry(version, id, savedAt, sessionStart, slot) + return { + version = version, slot = slot or "slot1", playthroughId = id, + blob = "return { player = { name = 'ASH' } }", + meta = { savedAt = savedAt, sessionStart = sessionStart, + playthroughId = id, summary = { name = "ASH", badges = 2 } }, + } +end + +local function fakeSaves(entries) + local writes = {} + return { + writes = writes, + list = function() return entries end, + write = function(version, id, blob, mode) + writes[#writes + 1] = { version = version, playthroughId = id, + blob = blob, mode = mode } + return mode == "new" and "slot9" or "slot1" + end, + } +end + +local function engine(routes, entries, state) + local saves = fakeSaves(entries or {}) + local transport = scripted(routes) + local eng = SyncEngine.new({ + baseUrl = "http://sync.test", + transport = transport, + state = state or linkedState(), + saves = saves, + persist = false, + now = function() return 1700001000 end, + }) + return eng, transport, saves +end + +do + T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 }, + { sessionStart = 15, savedAt = 30 }), true, + "two sessions that ran over the same minutes overlap") + T.eq(SyncEngine.overlaps({ sessionStart = 10, savedAt = 20 }, + { sessionStart = 21, savedAt = 30 }), false, + "a session that started after the other ended does not") + T.eq(SyncEngine.overlaps({ savedAt = 20 }, { sessionStart = 1, savedAt = 30 }), + false, "a save with no session start cannot claim an overlap") +end + +do + local eng, transport = engine({ + ["POST /sync/create"] = { code = 200, body = + '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' }, + }, {}, SyncState.defaults()) + + T.eq(eng:linked(), false, "a fresh engine is not linked") + T.eq(eng.status, "Not set up", "and says so") + + eng:createAccount("laptop") + pump(eng, 3) + T.eq(eng:linked(), true, "creating an account links this device") + T.eq(eng.state.account, "aa11", "and stores the account id") + T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped") + T.eq(eng.codes.code2, "3333-4444", "and so is the second") + T.eq(eng.state.code1, nil, "codes never enter the persisted state") + T.eq(eng.phase, "idle", "and the engine settles") + T.eq(#transport.sent, 1, "one request was made") +end + +do + local eng, transport = engine({ + ["POST /sync/link"] = { code = 200, + body = '{"account":"aa11","deviceToken":"tok"}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, + }, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults()) + + eng:linkDevice("1111-2222", "3333 4444", "phone") + pump(eng) + T.eq(eng:linked(), true, "linking with both codes links the device") + T.eq(transport.sent[2].url, "http://sync.test/sync/state", + "and a sync starts immediately") + T.eq(transport.sent[3].method, "PUT", + "the local save the server has never seen is uploaded") + T.eq(SyncState.rev(eng.state, "red/abc"), 1, "the served rev is remembered") + T.eq(SyncState.stamp(eng.state, "red/abc"), 500, + "along with the savedAt that was uploaded") + T.eq(eng.phase, "idle", "and the engine settles") + T.eq(eng.state.lastSyncAt, 1700001000, "the sync time is stamped") +end + +do + local eng, transport = engine({}, {}, SyncState.defaults()) + eng:linkDevice("12", "34", "phone") + T.eq(#transport.sent, 0, "a malformed code pair is refused locally") + T.eq(eng.phase, "error", "and the engine reports the problem") + T.check(eng.status:find("8 digits", 1, true) ~= nil, + "with copy that says what a code is") +end + +do + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":4,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, + }, {}) + + eng:syncNow() + pump(eng) + T.eq(#saves.writes, 1, "the remote-only save is written locally") + T.eq(saves.writes[1].version, "gold", "into the right game") + T.eq(saves.writes[1].mode, "replace", "as that playthrough's slot") + T.eq(SyncState.rev(eng.state, "gold/xyz"), 4, "and its rev is remembered") + T.eq(eng.phase, "idle", "the engine settles") + T.eq(transport.sent[2].url, "http://sync.test/sync/save?id=xyz&version=gold", + "the download names the playthrough, not the slot") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 7, 500) + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":7,"meta":{"savedAt":500}}}}' }, + }, { saveEntry("red", "abc", 500, 400) }, state) + + eng:syncNow() + pump(eng) + T.eq(#transport.sent, 1, "an unchanged save is neither uploaded nor downloaded") + T.eq(eng.phase, "idle", "and the sync ends idle") +end + +local function conflictEngine() + local state = linkedState() + SyncState.setRev(state, "red/abc", 7, 500) + return engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' .. + '"sessionStart":600,"summary":{"name":"BLUE","badges":4}}}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":9,"meta":{"savedAt":760},"blob":"return { player = {} }"}' }, + }, { saveEntry("red", "abc", 700, 650) }, state) +end + +do + local eng, transport = conflictEngine() + eng:syncNow() + pump(eng) + T.eq(eng.phase, "conflict", "both sides changing is a conflict") + T.eq(#eng.conflicts, 1, "one conflict is raised") + T.eq(eng.conflicts[1].overlap, true, + "the two sessions ran over the same minutes") + T.eq(eng.status, "These saves were played at the same time.", + "and the status is the wording the player was promised") + T.eq(eng.conflicts[1].remoteMeta.summary.name, "BLUE", + "the other device's save is summarized for the prompt") + T.eq(#transport.sent, 1, "nothing is uploaded while the player decides") + T.eq(#eng.state.pendingConflicts, 1, "the conflict survives in the state") +end + +do + local eng, transport = conflictEngine() + eng:syncNow() + pump(eng) + eng:resolveConflict("red/abc", "local") + pump(eng) + local put = transport.sent[2] + T.eq(put.method, "PUT", "keep this device uploads") + T.eq(Json.decode(put.body).force, true, "with the force flag") + T.eq(SyncState.rev(eng.state, "red/abc"), 10, "and adopts the new rev") + T.eq(eng.phase, "idle", "the conflict is cleared") + T.eq(#eng.state.pendingConflicts, 0, "and dropped from the state") +end + +do + local eng, transport, saves = conflictEngine() + eng:syncNow() + pump(eng) + eng:resolveConflict("red/abc", "remote") + pump(eng) + T.eq(transport.sent[2].method, "GET", "keep the other device downloads") + T.eq(#saves.writes, 1, "and writes it locally") + T.eq(saves.writes[1].mode, "replace", "over this playthrough's slot") + T.eq(SyncState.rev(eng.state, "red/abc"), 9, "adopting the remote rev") + T.eq(eng.phase, "idle", "the conflict is cleared") +end + +do + local eng, transport, saves = conflictEngine() + eng:syncNow() + pump(eng) + eng:resolveConflict("red/abc", "both") + pump(eng) + T.eq(#saves.writes, 1, "keep both imports the other save") + T.eq(saves.writes[1].mode, "new", "into a new slot") + local put = transport.sent[3] + T.eq(put.method, "PUT", "and still uploads this device's save") + T.eq(Json.decode(put.body).force, true, "forcing past the stale rev") + T.eq(eng.phase, "idle", "the conflict is cleared") +end + +do + local eng = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 409, body = + '{"conflict":true,"rev":3,"remoteMeta":{"savedAt":710,"sessionStart":600}}' }, + }, { saveEntry("red", "abc", 700, 650) }) + + eng:syncNow() + pump(eng) + T.eq(eng.phase, "conflict", "a 409 on upload becomes a conflict, not an error") + T.eq(eng.conflicts[1].overlap, true, "with the overlap worked out") +end + +do + local eng = engine({ + ["GET /sync/state"] = function() + return { code = 500, body = '{"error":"server on fire"}' } + end, + }, {}) + eng:syncNow() + pump(eng, 3) + T.eq(eng.phase, "error", "a server error stops the sync") + T.check(eng.status:find("server on fire", 1, true) ~= nil, + "and shows what the server said") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, + }, { saveEntry("red", "abc", 500, 400) }) + + eng:noteSaveWritten() + eng:update(1) + T.eq(#transport.sent, 0, "an in-game save does not sync straight away") + eng:update(SyncEngine.UPLOAD_DEBOUNCE) + T.eq(#transport.sent, 1, "it syncs once the debounce has passed") + pump(eng) + T.eq(transport.sent[2].method, "PUT", "and the save goes up") +end + +do + local eng, transport = engine({}, { saveEntry("red", "abc", 500, 400) }) + eng:setEnabled(false) + eng:noteSaveWritten() + eng:update(60) + T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing") +end + +do + local eng = engine({ + ["POST /sync/create"] = { code = 200, body = + '{"account":"aa11","code1":"11112222","code2":"33334444",' .. + '"deviceToken":"tok","device":"0a1b2c3d"}' }, + }, {}, SyncState.defaults()) + eng:createAccount("laptop") + pump(eng, 3) + T.eq(eng.state.deviceId, "0a1b2c3d", + "creating an account records the id the server gave this device") + T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d", + "and it survives being persisted") +end + +do + local eng = engine({ + ["POST /sync/link"] = { code = 200, + body = '{"account":"aa11","deviceToken":"tok","device":"beefcafe"}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}, SyncState.defaults()) + eng:linkDevice("11112222", "33334444", "phone") + pump(eng) + T.eq(eng.state.deviceId, "beefcafe", "so does linking a second device") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng, transport = engine({ + ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, + }, {}, state) + + eng:unlink() + T.eq(eng:linked(), true, "unlink waits for the server before forgetting") + local sent = transport.sent[1] + T.eq(sent.url, "http://sync.test/sync/unlink", "it asks the server first") + T.eq(Json.decode(sent.body).device, "0a1b2c3d", + "naming the device id the server knows, not the platform label") + pump(eng, 3) + T.eq(eng:linked(), false, "and only then drops the credentials") + T.eq(eng.status, "Not set up", "reporting the device as unlinked") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng = engine({ + ["POST /sync/unlink"] = { code = 500, body = '{"error":"nope"}' }, + }, {}, state) + eng:unlink() + pump(eng, 3) + T.eq(eng.phase, "error", "a failed revocation is surfaced") + T.eq(eng:linked(), true, + "and the device stays linked rather than lying about it") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng = engine({ + ["POST /sync/unlink"] = { code = 401, body = '{"error":"unauthorized"}' }, + }, {}, state) + eng:unlink() + pump(eng, 3) + T.eq(eng:linked(), false, + "a token the server already revoked is dropped rather than stuck forever") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng, transport = engine({ + ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}, state) + eng:unlinkDevice("99998888") + T.eq(Json.decode(transport.sent[1].body).device, "99998888", + "another device is revoked by its id") + pump(eng, 3) + T.eq(eng:linked(), true, "without logging this device out") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng = engine({ + ["GET /sync/state"] = { code = 200, body = + '{"saves":{},"devices":[{"id":"0a1b2c3d","label":"OS X","current":true},' .. + '{"id":"99998888","label":"Android"}]}' }, + }, {}, state) + eng:syncNow() + pump(eng) + T.eq(#eng.devices, 2, "the linked devices are kept for the modal to show") + T.eq(eng.devices[1].current, true, "this device is marked") + T.eq(eng.devices[2].label, "Android", "and the others are named") +end + +do + local eng = conflictEngine() + eng:syncNow() + pump(eng) + eng:syncNow() + pump(eng) + eng:syncNow() + pump(eng) + T.eq(#eng.state.pendingConflicts, 1, + "syncing again over the same conflict does not stack up rows") + T.eq(#eng.conflicts, 1, "and the prompt still has exactly one to answer") +end + +do + local eng = engine({}, {}) + local order, seen = {}, {} + eng.modDeps = { + installed = function() return {} end, + indexes = function() return {} end, + addIndex = function(url) order[#order + 1] = "index" return { feed = url } end, + findEntry = function() return nil end, + install = function(entry) order[#order + 1] = "install:" .. entry.id return true end, + setEnabled = function(id) order[#order + 1] = "enable:" .. id return true end, + } + eng.modPlan = { + indexes = { "https://mods.example/i.json" }, + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + missing = {}, + } + eng:applyModPlan(function(done, total, label, finished) + seen[#seen + 1] = ("%d/%d %s"):format(done, total, tostring(finished)) + end) + T.eq(#order, 0, "starting an apply installs nothing on the spot") + T.eq(eng:busy(), true, "the launcher can see it is working") + eng:update(0.016) + T.eq(#order, 1, "one step runs per frame, so the progress line can draw") + eng:update(0.016) + eng:update(0.016) + T.eq(#order, 3, "until the whole plan has run") + T.eq(order[3], "enable:beta", "in plan order") + T.eq(eng.modApply, nil, "the job is done") + T.eq(eng.modPlan, nil, "and the plan is spent") + T.eq(eng.status, "Mods applied", "the status says so") + T.eq(seen[#seen], "3/3 true", "and the last progress call reports the end") +end + +do + local eng = engine({}, {}) + eng.modDeps = { + installed = function() return {} end, + indexes = function() return {} end, + addIndex = function() return true end, + findEntry = function() return nil end, + install = function() return nil, "download failed" end, + setEnabled = function() return true end, + } + eng.modPlan = { indexes = {}, toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = {}, missing = {} } + eng:applyModPlan() + eng:update(0.016) + T.eq(eng.modApply, nil, "a failing step still ends the job") + T.check(eng.status:find("download failed", 1, true) ~= nil, + "and the failure reaches the status line") +end + +T.finish("sync_engine") diff --git a/tests/engine/sync_mods_test.lua b/tests/engine/sync_mods_test.lua new file mode 100644 index 00000000..5574e7d8 --- /dev/null +++ b/tests/engine/sync_mods_test.lua @@ -0,0 +1,148 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SyncMods = require("src.sync.SyncMods") + +local function row(id, version, enabled, github) + return { id = id, version = version, github = github, + enabledByVersion = enabled } +end + +local function deps(installed, indexes, catalog) + local calls = { installed = {}, enabled = {}, indexes = {} } + return calls, { + installed = function() return installed end, + indexes = function() return indexes or {} end, + addIndex = function(url) + calls.indexes[#calls.indexes + 1] = url + return { feed = url } + end, + findEntry = function(id) return (catalog or {})[id] end, + install = function(entry) + calls.installed[#calls.installed + 1] = entry.id + return true + end, + setEnabled = function(id, enabled, version) + calls.enabled[#calls.enabled + 1] = id .. ":" .. tostring(version) + return true + end, + } +end + +do + local _, d = deps({ + row("zeta", "1.0.0", { red = true, blue = false, yellow = false, gold = false }), + row("alpha", "2.1.0", { red = true, gold = true }, "someone/alpha"), + }, { { url = "https://mods.example/index.json", + feed = "https://mods.example/index.json" } }) + + local manifest = SyncMods.build(d) + T.eq(manifest.rev, SyncMods.REV, "the manifest carries its shape revision") + T.eq(#manifest.indexes, 1, "the player's index list rides along") + T.eq(manifest.indexes[1], "https://mods.example/index.json", + "as the url they typed") + T.eq(#manifest.mods, 2, "every installed mod is listed") + T.eq(manifest.mods[1].id, "alpha", "sorted by id so the manifest is stable") + T.eq(manifest.mods[1].source, "github:someone/alpha", + "a github mod records where it came from") + T.eq(manifest.mods[2].source, "local", + "a hand-installed mod is marked local rather than invented") + T.eq(#manifest.mods[1].enabledFor, 2, "alpha is on for two games") + T.eq(manifest.mods[1].enabledFor[1], "red", "in GameVersion order") + T.eq(manifest.mods[1].enabledFor[2], "gold", "red then gold") + T.eq(#manifest.mods[2].enabledFor, 1, "zeta is on for one") +end + +do + local manifest = { + rev = 1, + indexes = { "https://mods.example/index.json", "https://other.example/i.json" }, + mods = { + { id = "alpha", version = "2.1.0", enabledFor = { "red", "gold" } }, + { id = "beta", version = "1.0.0", enabledFor = { "red" } }, + { id = "ghost", version = "0.1.0", source = "local", enabledFor = { "red" } }, + }, + } + local _, d = deps( + { row("alpha", "2.1.0", { red = true }) }, + { { url = "https://mods.example/index.json", + feed = "https://mods.example/index.json" } }, + { beta = { id = "beta" } }) + + local plan = SyncMods.plan(manifest, d) + T.eq(#plan.indexes, 1, "only the index this device is missing is planned") + T.eq(plan.indexes[1], "https://other.example/i.json", "the new one") + T.eq(#plan.toInstall, 1, "one mod can be fetched from an index") + T.eq(plan.toInstall[1].id, "beta", "the one the catalog knows") + T.eq(#plan.missing, 1, "the mod nobody publishes is reported, not invented") + T.eq(plan.missing[1].id, "ghost", "by id") + T.eq(#plan.toEnable, 2, "every game answer that differs is planned") + for _, want in ipairs(plan.toEnable) do + T.check(want.id ~= "ghost", + "a mod that cannot be installed is never enabled") + end + T.eq(SyncMods.planEmpty(plan), false, "a plan with work is not empty") + + local same = SyncMods.plan({ rev = 1, indexes = {}, mods = { + { id = "alpha", version = "2.1.0", enabledFor = { "red" } } } }, d) + T.eq(SyncMods.planEmpty(same), true, "a matching device plans nothing") +end + +do + local calls, d = deps({}, {}, { beta = { id = "beta" } }) + local plan = { + indexes = { "https://other.example/i.json" }, + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + missing = { { id = "ghost" } }, + } + local seen = {} + local ok = SyncMods.apply(plan, function(done, total, label) + seen[#seen + 1] = ("%d/%d %s"):format(done, total, label) + end, d) + T.eq(ok, true, "applying a plan reports success") + T.eq(calls.indexes[1], "https://other.example/i.json", "the index is added") + T.eq(calls.installed[1], "beta", "the mod is installed through the launcher path") + T.eq(calls.enabled[1], "beta:red", "and enabled for the game that wanted it") + T.eq(#seen, 3, "progress is reported once per step") + T.eq(seen[3], "3/3 beta", "counting up to the total") +end + +do + local _, d = deps({}, {}, {}) + d.install = function() return nil, "download failed" end + local ok, err = SyncMods.apply({ + toInstall = { { id = "beta", entry = { id = "beta" } } } }, nil, d) + T.eq(ok, false, "a failed install fails the apply") + T.check(tostring(err):find("download failed", 1, true) ~= nil, + "naming the mod and the reason") +end + +do + local calls, d = deps({}, {}, {}) + d.install = function() return nil, "download failed" end + local ok = SyncMods.apply({ + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + }, nil, d) + T.eq(ok, false, "the apply still reports the failure") + T.eq(#calls.enabled, 0, + "a mod whose install failed is not switched on regardless") +end + +do + local calls, d = deps({}, {}, {}) + local steps = SyncMods.steps({ + indexes = { "https://other.example/i.json" }, + toInstall = { { id = "beta", entry = { id = "beta" } } }, + toEnable = { { id = "beta", version = "red" } }, + }, d) + T.eq(#steps, 3, "a plan splits into one step per unit of work") + T.eq(steps[1].run(), true, "steps run one at a time") + T.eq(#calls.indexes, 1, "so the caller can draw between them") + T.eq(#calls.installed, 0, "without the rest of the plan having run yet") +end + +T.finish("sync_mods") diff --git a/tests/engine/sync_session_meta_test.lua b/tests/engine/sync_session_meta_test.lua new file mode 100644 index 00000000..b1b558a6 --- /dev/null +++ b/tests/engine/sync_session_meta_test.lua @@ -0,0 +1,171 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") +local SyncEngine = require("src.sync.SyncEngine") + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + GameVersion.set("red") + return files +end + +do + local plain = SaveData.buildMeta({}) + T.check(type(plain.savedAt) == "number", "a save still records when it ended") + T.eq(plain.sessionStart, nil, + "and records no session start when nobody supplied one") + + local started = os.time() - 600 + local meta = SaveData.buildMeta({}, nil, started) + T.eq(meta.sessionStart, started, "the session start is stamped when given") + T.check(meta.savedAt >= meta.sessionStart, + "and savedAt is the end of that session") + + local carried = SaveData.buildMeta({}, { sessionStart = started }) + T.eq(carried.sessionStart, started, + "a rewrite with no session keeps the previous start") + + local future = SaveData.buildMeta({}, nil, os.time() + 9999) + T.check(future.sessionStart <= future.savedAt, + "a clock that ran backwards cannot start a session after it ended") + + local nan = SaveData.buildMeta({}, nil, 0 / 0) + T.eq(nan.sessionStart, nil, "a NaN session start is refused") + + local kept = SaveData.buildMeta(nil, { playthroughId = "abc", mods = {}, + sessionStart = 42 }) + T.eq(kept.playthroughId, "abc", "the playthrough id still rides on the meta") + T.eq(kept.sessionStart, 42, "next to the session start") +end + +do + local files = fresh() + T.eq(SaveData.readSlotSource("red", "slot1"), nil, + "an empty slot has no bytes to upload") + + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, { playthroughId = "abc" }, os.time() - 60) + T.check(SaveData.writeSlot("red", "slot1", save), "a slot write lands") + + local source = SaveData.readSlotSource("red", "slot1") + T.check(type(source) == "string" and #source > 0, "the raw bytes read back") + local decoded = SaveData.decode(source) + T.eq(decoded.player.name, "ASH", "and decode to the same save") + T.eq(decoded.meta.playthroughId, "abc", "carrying the playthrough id") + + files["saves/red/slot1.lua"] = "this is not a save" + T.eq(SaveData.readSlotSource("red", "slot1"), nil, + "a corrupt slot never hands undecodable bytes to the uploader") + + files["saves/red/slot1.lua.bak"] = source + T.eq(SaveData.readSlotSource("red", "slot1"), source, + "and the backup copy is used instead") + + T.eq(SaveData.readSlotSource("nosuchgame", "slot1"), nil, + "an unknown version has no slots to read") +end + +do + fresh() + local provider = SyncEngine.defaultSaves() + T.eq(#provider.list(), 0, "a fresh install has nothing to sync") + + local slotId = SaveData.createSlot("red") + local save = SaveData.newGame() + save.player.name = "ASH" + save.meta = SaveData.buildMeta({}, { playthroughId = "abc" }, os.time() - 120) + SaveData.writeSlot("red", slotId, save) + + local entries = provider.list() + T.eq(#entries, 1, "a written slot becomes one sync entry") + T.eq(entries[1].version, "red", "keyed by its game") + T.eq(entries[1].playthroughId, "abc", "and its playthrough id") + T.eq(entries[1].slot, slotId, "remembering which slot it came from") + T.eq(entries[1].meta.summary.name, "ASH", + "with the launcher summary the conflict prompt shows") + T.check(entries[1].meta.sessionStart ~= nil, "and the session start") + T.check(entries[1].blob:find("ASH", 1, true) ~= nil, + "the blob is the encoded save itself") + + local other = SaveData.newGame() + other.player.name = "BLUE" + other.meta = SaveData.buildMeta({}, { playthroughId = "xyz" }, os.time() - 30) + local newSlot = provider.write("red", "xyz", SaveData.encode(other), "new") + T.check(newSlot ~= nil and newSlot ~= slotId, + "keep both imports the other device's save into a new slot") + local after = provider.list() + T.eq(#after, 2, "and both playthroughs are now local") + local ids = {} + for _, entry in ipairs(after) do ids[entry.playthroughId] = true end + T.eq(ids["abc"], true, "this device's playthrough is untouched") + T.eq(ids["xyz"], nil, + "and the imported copy gets its own identity so the two never merge") +end + +do + local source = assert(io.open("src/core/Game.lua")):read("*a") + T.check(source:find("self.sessionStartedAt = os.time()", 1, true) ~= nil, + "Game stamps when a play session began") + T.check(source:find("self.sessionStartedAt)", 1, true) ~= nil, + "and hands it to buildMeta when the save is written") + local _, stamps = source:gsub("self%.sessionStartedAt = os%.time%(%)", "") + T.eq(stamps, 3, + "boot, NEW GAME and CONTINUE each start a session") +end + +do + fresh() + local Game = require("src.core.Game") + local notes, pumped = 0, 0 + SyncEngine._shared = { + state = { enabled = true }, + linked = function() return true end, + busy = function() return false end, + noteSaveWritten = function() notes = notes + 1 end, + update = function(_, dt) pumped = pumped + dt end, + } + local game = setmetatable({ save = SaveData.newGame(), + sessionStartedAt = os.time() - 60 }, { __index = Game }) + T.eq(Game.writeSave(game), true, "an in-game save still writes") + T.eq(notes, 1, "and tells the sync engine, so the 5s debounce can start") + Game.updateSync(game, 0.5) + T.eq(pumped, 0.5, "the running game pumps the engine, not only the launcher") + + SyncEngine._shared = { + state = { enabled = false }, + linked = function() return false end, + busy = function() return false end, + noteSaveWritten = function() notes = notes + 1 end, + update = function() pumped = pumped + 1 end, + } + game._syncOff, game._syncEngineRef = nil, nil + Game.updateSync(game, 0.5) + T.eq(pumped, 0.5, "with sync off the engine is left alone") + SyncEngine.forgetShared() +end + +love.filesystem = realFS + +T.finish("sync_session_meta") diff --git a/tests/engine/sync_state_test.lua b/tests/engine/sync_state_test.lua new file mode 100644 index 00000000..066aa734 --- /dev/null +++ b/tests/engine/sync_state_test.lua @@ -0,0 +1,120 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") +local SyncState = require("src.sync.SyncState") + +local realFS = love.filesystem + +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + } +end + +local function fresh() + local files = {} + love.filesystem = memfs(files) + SaveData.resetSlotState() + return files +end + +do + local opts = SaveData.defaultOptions() + T.check(type(opts.saveSync) == "table", "defaultOptions carries saveSync") + T.eq(opts.saveSync.enabled, false, "sync is off until the player sets it up") + T.check(type(opts.saveSync.revs) == "table", "and starts with no synced revs") + T.eq(opts.saveSync.account, nil, "and no account") + + local state = SyncState.defaults() + T.eq(SyncState.linked(state), false, "a default state is not linked") + T.eq(state.lastSyncAt, 0, "and has never synced") +end + +do + local dirty = SyncState.sanitize({ + enabled = "yes", + account = "aa11bb22cc33dd44", + deviceToken = "tok", + deviceLabel = "", + lastSyncAt = 0 / 0, + code1 = "12345678", + code2 = "87654321", + revs = { ["red/aaa"] = 4, [7] = 9, ["red/bad"] = "no" }, + stamps = { ["red/aaa"] = 1700 }, + pendingConflicts = { { key = "red/aaa", version = "red", overlap = true }, + { nope = true } }, + }) + T.eq(dirty.enabled, false, "a non-boolean enabled reads as off") + T.eq(dirty.account, "aa11bb22cc33dd44", "the account id survives") + T.eq(dirty.deviceLabel, nil, "an empty device label is dropped") + T.eq(dirty.lastSyncAt, 0, "a NaN lastSyncAt is refused") + T.eq(dirty.code1, nil, "the first account code is never kept") + T.eq(dirty.code2, nil, "nor the second") + T.eq(dirty.revs["red/aaa"], 4, "numeric revs survive") + T.eq(dirty.revs["red/bad"], nil, "a non-numeric rev is dropped") + T.eq(dirty.revs[7], nil, "a non-string rev key is dropped") + T.eq(dirty.stamps["red/aaa"], 1700, "the savedAt stamp survives") + T.eq(#dirty.pendingConflicts, 1, "only well-formed conflicts are kept") + T.eq(dirty.pendingConflicts[1].overlap, true, "with their overlap flag") +end + +do + local files = fresh() + local state = SyncState.load() + T.eq(SyncState.linked(state), false, "a first boot has no linked account") + + state.account = "aa11bb22cc33dd44" + state.deviceToken = "feedface" + state.deviceId = "0a1b2c3d" + state.deviceLabel = "laptop" + state.enabled = true + state.code1 = "12345678" + SyncState.setRev(state, SyncState.key("red", "abc"), 3, 1700000000) + SyncState.save(state) + + T.check(files["options.lua"] ~= nil, "the state lands in options.lua") + T.eq(files["options.lua"]:find("12345678", 1, true), nil, + "the account codes are never written to disk") + + local back = SyncState.load() + T.eq(SyncState.linked(back), true, "the linked account survives a reload") + T.eq(back.deviceLabel, "laptop", "and the device label") + T.eq(back.deviceId, "0a1b2c3d", + "and the device id the server revokes tokens by") + T.eq(SyncState.rev(back, "red/abc"), 3, "and the last synced rev") + T.eq(SyncState.stamp(back, "red/abc"), 1700000000, "and the savedAt stamp") + T.eq(back.code1, nil, "the code is gone from the reloaded state") + + local opts = SaveData.loadOptions() + T.eq(opts.textSpeed, 3, "writing sync state leaves other options alone") + + SyncState.forget(back, "red/abc") + T.eq(SyncState.rev(back, "red/abc"), nil, "forget drops the rev") + T.eq(SyncState.stamp(back, "red/abc"), nil, "and the stamp") + + SyncState.clear() + T.eq(SyncState.linked(SyncState.load()), false, "clear unlinks the device") +end + +do + T.eq(SyncState.key("red", "abc"), "red/abc", "keys join version and id") + T.eq(SyncState.key("red", ""), nil, "an empty playthrough id has no key") + T.eq(SyncState.key(nil, "abc"), nil, "and neither does a missing version") + local version, id = SyncState.splitKey("gold/deadbeef") + T.eq(version, "gold", "splitKey reads the version back") + T.eq(id, "deadbeef", "and the playthrough id") +end + +love.filesystem = realFS + +T.finish("sync_state") diff --git a/tests/engine/touch_skin_dpad_area.lua b/tests/engine/touch_skin_dpad_area.lua new file mode 100644 index 00000000..d5b260ff --- /dev/null +++ b/tests/engine/touch_skin_dpad_area.lua @@ -0,0 +1,199 @@ +-- RetroArch dpad_area / abxy_area descs (#1533): one hitbox whose fired +-- input is resolved by the angle of the touch from the area centre, and +-- range_mod growing a hitbox only while it is held. The cfg below is the +-- reporter's GBA skin, trimmed to the d-pad and face buttons. +-- luajit tests/engine/touch_skin_dpad_area.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local Input = require("src.core.Input") + +local CFG = [[ +overlays = 2 + +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 1 +overlay0_aspect_ratio = 0.45 +overlay0_descs = 13 +overlay0_desc0 = "select,0.41944,0.58793,radial,0.06667,0.03" +overlay0_desc0_overlay = p-btn-select.png +overlay0_desc0_reach_x = 1.25 +overlay0_desc0_reach_y = 1.25 +overlay0_desc1 = "start,0.58056,0.58793,radial,0.06667,0.03" +overlay0_desc1_overlay = p-btn-start.png +overlay0_desc1_reach_x = 1.25 +overlay0_desc1_reach_y = 1.25 +overlay0_desc2 = "up,0.25,0.6625,radial,0.07778,0.035" +overlay0_desc2_overlay = p-btn-dpad-up.png +overlay0_desc2_reach_x = 0 +overlay0_desc3 = "left,0.12222,0.72,radial,0.07778,0.035" +overlay0_desc3_overlay = p-btn-dpad-left.png +overlay0_desc3_reach_x = 0 +overlay0_desc4 = "right,0.37778,0.72,radial,0.07778,0.035" +overlay0_desc4_overlay = p-btn-dpad-right.png +overlay0_desc4_reach_x = 0 +overlay0_desc5 = "down,0.25,0.7775,radial,0.07778,0.035" +overlay0_desc5_overlay = p-btn-dpad-down.png +overlay0_desc5_reach_x = 0 +overlay0_desc6 = "up|left,0.11644,0.6599,radial,0.01667,0.0075" +overlay0_desc6_overlay = p-btn-corner.png +overlay0_desc6_reach_x = 0 +overlay0_desc7 = "up|right,0.38356,0.6599,radial,0.01667,0.0075" +overlay0_desc7_overlay = p-btn-corner.png +overlay0_desc7_reach_x = 0 +overlay0_desc8 = "down|left,0.11644,0.7801,radial,0.01667,0.0075" +overlay0_desc8_overlay = p-btn-corner.png +overlay0_desc8_reach_x = 0 +overlay0_desc9 = "down|right,0.38356,0.7801,radial,0.01667,0.0075" +overlay0_desc9_overlay = p-btn-corner.png +overlay0_desc9_reach_x = 0 +overlay0_desc10 = "dpad_area,0.25,0.72,radial,0.22778,0.1025" +overlay0_desc10_overlay = p-area-dpad.png +overlay0_desc10_reach_x = 1.25 +overlay0_desc10_reach_y = 1.25 +overlay0_desc11 = "a,0.84382,0.69563,radial,0.09722,0.04375" +overlay0_desc11_overlay = p-btn-act2-a.png +overlay0_desc11_reach_x = 1.25 +overlay0_desc11_reach_y = 1.25 +overlay0_desc12 = "b,0.65618,0.74438,radial,0.09722,0.04375" +overlay0_desc12_overlay = p-btn-act2-b.png +overlay0_desc12_reach_x = 1.25 +overlay0_desc12_reach_y = 1.25 + +overlay1_name = "areas" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_descs = 2 +overlay1_desc0 = "dpad_area,0.25,0.5,rect,0.2,0.2" +overlay1_desc0_up = "start" +overlay1_desc0_down = "select" +overlay1_desc0_left = "nul" +overlay1_desc1 = "abxy_area,0.75,0.5,rect,0.2,0.2" +]] + +local skin = assert(TouchSkin.parse(CFG)) +local page = skin.pages[1] + +eq(#page.controls, 12 + 1 + 8, "the dpad_area expands into eight sector controls") +local sectors = {} +for _, ctl in ipairs(page.controls) do + if ctl.sector then sectors[ctl.sector] = ctl end +end +eq(#sectors, 8, "eight sectors, one per direction") +eq(sectors[1].spec, "right", "sector 1 is right") +eq(sectors[2].spec, "right|down", "sector 2 is the down-right diagonal") +eq(sectors[3].spec, "down", "sector 3 is down, y growing downwards") +eq(sectors[7].spec, "up", "sector 7 is up") +eq(sectors[1].x, 0.25, "every sector keeps the area centre") +eq(sectors[5].y, 0.72, "on both axes") +eq(sectors[3].rangeX, 0.22778, "and the whole area range") +eq(sectors[3].shape, "radial", "and the declared hitbox shape") + +local art = page.controls[11] +eq(art.imagePath, "p-area-dpad.png", "the area art rides a decorative desc") +check(art.decorative, "which presses nothing") + +TouchControls:init() +TouchControls.active = true +TouchControls.enabled = true +TouchSkin.setOverlayLive(true) +TouchSkin.setActive(skin) +Input:init() + +local W, H = 720, 1600 +TouchSkin.setSurface(0, 0, W, H) +eq(TouchSkin.page().name, "portrait", "the portrait page is live at 720x1600") + +local BUTTONS = { "up", "down", "left", "right", "a", "b", "start", "select" } +local function heldNow() + local out = {} + for _, btn in ipairs(BUTTONS) do + if Input:isDown(btn) then out[#out + 1] = btn end + end + return table.concat(out, "+") +end + +local function press(nx, ny) + TouchControls:touchpressed("f1", nx * W, ny * H) + local got = heldNow() + TouchControls:touchreleased("f1", nx * W, ny * H) + return got +end + +local function fires(nx, ny, want, why) + eq(press(nx, ny), want, why) +end + +fires(0.25, 0.6625, "up", "the d-pad up arrow fires up alone") +fires(0.12222, 0.72, "left", "the left arrow fires left alone") +fires(0.37778, 0.72, "right", "the right arrow fires right alone") +fires(0.25, 0.7775, "down", "the down arrow fires down alone") +fires(0.11644, 0.6599, "up+left", "the up-left corner fires both, and only both") +fires(0.38356, 0.7801, "down+right", "as does the down-right corner") + +fires(0.32, 0.77, "down+right", + "a spot inside the area but off every arrow resolves by angle: " + .. "(50.4, 80) pixels out is 57.8 degrees, the down-right sector") + +fires(0.84382, 0.69563, "a", "A fires alone") +fires(0.65618, 0.74438, "b", "B fires alone: the 1.5x range_mod does not grow the resting d-pad area over it") +fires(0.58056, 0.58793, "start", "START fires alone") +fires(0.41944, 0.58793, "select", "SELECT fires alone, with no phantom direction") +fires(0.5, 0.3, "", "the screen area presses nothing") + +TouchControls:touchpressed("f2", 0.25 * W, 0.6625 * H) +eq(heldNow(), "up", "slide starts on up") +TouchControls:touchmoved("f2", 0.37778 * W, 0.72 * H) +eq(heldNow(), "right", "sliding across the area swaps direction") +TouchControls:touchmoved("f2", 0.38356 * W, 0.7801 * H) +eq(heldNow(), "down+right", "and picks up the diagonal") +TouchControls:touchmoved("f2", 0.5 * W, 0.3 * H) +eq(heldNow(), "", "sliding out of the area releases it") +TouchControls:touchreleased("f2", 0.5 * W, 0.3 * H) + +local area = sectors[1] +local bx = 0.65618 * W +local by = 0.74438 * H +check(not TouchSkin.hits(page, area, W, H, bx, by, 0, 0, false), + "at rest the area hitbox stops short of B") +check(TouchSkin.hits(page, area, W, H, bx, by, 0, 0, true), + "a held area grows over B so the finger keeps its direction") +TouchControls:touchpressed("f3", 0.37778 * W, 0.72 * H) +TouchControls:touchmoved("f3", bx, by) +eq(heldNow(), "right+b", "sliding from the held area onto B keeps right held") +TouchControls:touchreleased("f3", bx, by) +eq(heldNow(), "", "and lifting clears both") + +TouchSkin.autoOrient = false +TouchSkin.setPage("areas") +eq(TouchSkin.page().name, "areas", "second page is live") + +W, H = 1000, 1000 +TouchSkin.setSurface(0, 0, W, H) + +fires(0.25, 0.35, "start", "_up rebinds the up sector of a dpad_area") +fires(0.25, 0.65, "select", "_down rebinds the down sector") +fires(0.12, 0.5, "", "_left = nul makes that sector inert") +fires(0.38, 0.5, "right", "an unset side keeps the d-pad default") + +fires(0.88, 0.5, "a", "abxy_area right is GB A") +fires(0.75, 0.62, "b", "abxy_area down is GB B") +fires(0.88, 0.62, "a+b", "the down-right sector fires both") +fires(0.75, 0.38, "", "RetroPad X has no GB button, so up is inert") +fires(0.94, 0.68, "a+b", "a rect area still hits inside its corner") +fires(0.75, 0.75, "", "and nothing past its edge") + +TouchSkin.setSurface(nil) +TouchSkin.setActive(nil) +TouchSkin.autoOrient = true + +T.finish("touch_skin_dpad_area")