From 72c244b43399e866d5c04a30c8d351d4d58f8dbb Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:38:04 +0200 Subject: [PATCH 1/6] Route the museum 1F ticket clerk's remaining lines through Strings --- data/scripts/story2.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 094bc011..2ab2a715 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -733,7 +733,8 @@ local function museumClerk(game, ow, done, onDecline) local t = game.data.text or {} if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then game.stack:push(TextBox.new(game, - "Take your time,\nand enjoy it all!", done)) + t._Museum1FScientist1TakePlentyOfTimeText + or "Take your time,\nand enjoy it all!", done)) return end -- scripts/Museum1F.asm:72 @@ -751,10 +752,12 @@ local function museumClerk(game, ow, done, onDecline) { money = money })) elseif yes then game.stack:push(TextBox.new(game, - "You don't have\nenough money.", onDecline or done, { money = money })) + t._Museum1FScientist1DontHaveEnoughMoneyText + or "You don't have\nenough money.", onDecline or done, { money = money })) else game.stack:push(TextBox.new(game, - "Come again!", onDecline or done, { money = money })) + t._Museum1FScientist1ComeAgainText + or "Come again!", onDecline or done, { money = money })) end end })) end From fbdfc1c0530fd36816b7b2e537905dbcae6615b4 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:44:43 +0200 Subject: [PATCH 2/6] Cover the museum 1F ticket clerk's translated lines with a targeted test --- .../museum_1f_clerk_translation_test.lua | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/engine/museum_1f_clerk_translation_test.lua diff --git a/tests/engine/museum_1f_clerk_translation_test.lua b/tests/engine/museum_1f_clerk_translation_test.lua new file mode 100644 index 00000000..5330fdd0 --- /dev/null +++ b/tests/engine/museum_1f_clerk_translation_test.lua @@ -0,0 +1,68 @@ +-- The museum 1F ticket clerk's take-your-time/not-enough-money/come-again +-- lines used to be bare English literals, invisible to game.data.text no +-- matter what a translation mod put there. tests/engine/museum_money_box_ +-- bug1335.lua only ever runs with an empty game.data.text, so it can't +-- tell a properly-wired t._Key or "..." fallback apart from a literal that +-- never looked at t at all -- every assertion there passes either way. +-- This test populates game.data.text with translated values and checks +-- they actually reach the pushed TextBox, for all three lines. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} + +local M = assert(loadfile("data/scripts/story2.lua"))() +local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1 + +local TRANSLATED = { + _Museum1FScientist1TakePlentyOfTimeText = "Prends ton temps\net profite bien !", + _Museum1FScientist1DontHaveEnoughMoneyText = "Tu n'as pas assez\nd'argent.", + _Museum1FScientist1ComeAgainText = "Reviens vite !", +} + +local pushed +local function mkGame(cash) + pushed = {} + return { + data = { text = TRANSLATED }, + save = { money = cash, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, + } +end + +-- already ticketed: take-your-time line +local g = mkGame(3000) +g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g, nil, nil, function() end) +T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1TakePlentyOfTimeText, + "an existing ticket holder gets the translated take-your-time line") + +-- YES but short on cash: not-enough-money line +g = mkGame(20) +clerk(g, nil, nil, function() end) +pushed[1].opts.choice(true) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1DontHaveEnoughMoneyText, + "short on cash opens the translated not-enough-money box") + +-- NO: come-again line +g = mkGame(3000) +clerk(g, nil, nil, function() end) +pushed[1].opts.choice(false) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1ComeAgainText, + "declining opens the translated come-again box") + +-- unpopulated game.data.text still falls back to the English literal +g = { data = { text = {} }, save = { money = 3000, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end } } +pushed = {} +g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g, nil, nil, function() end) +T.check(tostring(pushed[1].text):find("Take your time", 1, true) ~= nil, + "an empty catalog still falls back to the English take-your-time line") + +T.finish("museum_1f_clerk_translation_test") From c655217120f861ca7b73069d50d044d3e6b97412 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:00:38 +0200 Subject: [PATCH 3/6] feat(gen2): expose stock ball catch previews --- docs/modding.md | 8 ++-- src/battle/gen2/BattleAPI.lua | 24 ++++++++++-- src/battle/gen2/Catching.lua | 9 +++++ src/ui/gen2/BattleState.lua | 63 ++++++++++++++++-------------- tests/gen2_battle_test.lua | 5 +++ tests/gen2_battle_ui_test.lua | 10 +++++ tests/mod_battle_snapshot_test.lua | 14 ++++++- 7 files changed, 95 insertions(+), 38 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index e7c32c39..c487e5c2 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -268,10 +268,10 @@ an optional stock `catchChance` percentage. `prompt` describes the currently visible choice (`menu`, `moves`, `party`, `advance`, `safari`, or `mimic`) and is `locked` when another screen or battle phase owns input. Generation-specific features remain optional: Gen 1 includes -battle medicine, balls, catch previews, Safari balls, and Mimic choices; -Gold currently returns an empty `items` list rather than guessing at its -pocketed PACK flow. Callers should ignore unknown fields and tolerate absent -optional ones. +battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold +exposes balls and their exact stock catch previews; targeted medicine remains +screen-owned and is omitted rather than guessing at its pocketed PACK flow. +Callers should ignore unknown fields and tolerate absent optional ones. ## Battle menu intents diff --git a/src/battle/gen2/BattleAPI.lua b/src/battle/gen2/BattleAPI.lua index e8381964..d5ef773f 100644 --- a/src/battle/gen2/BattleAPI.lua +++ b/src/battle/gen2/BattleAPI.lua @@ -39,6 +39,21 @@ local function messageCopy(screen) return #lines > 0 and lines or nil end +local function itemCopies(game, screen, catchable) + local out = {} + for id, count in pairs((game.save and game.save.inventory) or {}) do + local def = game.data.items and game.data.items[id] + if count > 0 and def and def.pocket == "BALL" then + out[#out + 1] = { id = id, name = def.name or id, count = count, + ball = true, needsTarget = false, + catchChance = catchable and screen.catchChance + and screen:catchChance(id) or nil } + end + end + table.sort(out, function(a, b) return a.name < b.name end) + return out +end + local function signature(game, screen, top) if not screen then return "none" end local battle = screen.battle or {} @@ -56,6 +71,9 @@ local function signature(game, screen, top) parts[#parts + 1] = tostring(mon.hp) parts[#parts + 1] = tostring(mon.status) end + for _, item in ipairs(itemCopies(game, screen, false)) do + parts[#parts + 1] = item.id .. "=" .. tostring(item.count) + end return table.concat(parts, "|") end @@ -111,9 +129,9 @@ function BattleAPI:snapshot() player = monCopy(game.data, battle.player, true), enemy = monCopy(game.data, battle.enemy, true), party = party, moves = moveCopies(game, battle), - -- Gold's PACK is pocketed and target selection is screen-owned. Omit it - -- until the engine can expose the same semantic item records as Gen 1. - items = {} } + -- Targeted medicine remains screen-owned, but balls are complete semantic + -- records and can safely expose the same read-only preview as Gen 1. + items = itemCopies(game, screen, battle.wild and not screen.tutorial) } end local MENU_CHOICES = { fight = true, party = true, item = true, run = true } diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua index bfd54b30..8284add5 100644 --- a/src/battle/gen2/Catching.lua +++ b/src/battle/gen2/Catching.lua @@ -305,6 +305,15 @@ function Catching.rate(opts) return math.min(255, rate), false end +-- Exact stock catch probability for read-only previews. A catch.rate hook +-- may replace the roll entirely, so nil is safer than presenting a guess. +function Catching.chance(opts) + if Runtime.wantsHook("catch.rate") then return nil end + local rate, guaranteed = Catching.rate(opts) + if guaranteed or rate >= 255 then return 100 end + return rate * 100 / 256 +end + -- The status half of the rate, off the merged `statuses` record the same way -- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records -- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers: diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index cd1b7d15..c2d33a5d 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -2945,6 +2945,37 @@ function BattleState:openDexEntry(species) }) end +function BattleState:catchOptions(itemId) + local data = self.game and self.game.data or {} + local battle = self.battle + local enemy = battle and battle.enemy + if not enemy then return nil end + local enemyDef = data.pokemon and data.pokemon[enemy.species] + local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species] + local evolveItem + for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do + if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end + end + local player = battle.player + return { + battle = battle, mon = enemy, def = enemyDef, + maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), hp = enemy.hp, + catchRate = enemyDef and enemyDef.catchRate or 45, ball = itemId, + status = enemy.status, random = battle.random, + weight = dexEntry and dexEntry.weight, level = enemy.level, + playerLevel = player and player.level, + fishing = battle.battleType == "fish", species = enemy.species, + gender = enemy.gender, playerSpecies = player and player.species, + playerGender = player and player.gender, evolveItem = evolveItem, + } +end + +function BattleState:catchChance(itemId) + if self.tutorial then return 100 end + local opts = self:catchOptions(itemId) + return opts and Catching.chance(opts) or nil +end + -- Items in battle: balls try a catch, the stat items apply their stage, and -- everything with a ported party effect runs the same item_effects.asm routine -- the field pack runs. Anything else reports that it cannot be used, which is @@ -2974,7 +3005,6 @@ function BattleState:useItem(itemId) return end local enemy = self.battle.enemy - local enemyDef = data.pokemon and data.pokemon[enemy.species] local caught, rate if self.tutorial then -- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL / @@ -2988,35 +3018,8 @@ function BattleState:useItem(itemId) caught, rate = true, 255 else -- The specialty-ball conditions (BallMultiplierFunctionTable): each one - -- is something this screen already knows. Heavy Ball reads the dex - -- weight, Moon Ball the species' stone row, Love Ball both genders, - -- Level Ball the two levels, Lure Ball wBattleType. - local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species] - local evolveItem - for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do - if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end - end - local player = self.battle.player - caught, rate = Catching.attempt({ - battle = self.battle, - mon = enemy, - def = enemyDef, - maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), - hp = enemy.hp, - catchRate = enemyDef and enemyDef.catchRate or 45, - ball = itemId, - status = enemy.status, - random = self.battle.random, - weight = dexEntry and dexEntry.weight, - level = enemy.level, - playerLevel = player and player.level, - fishing = self.battle.battleType == "fish", - species = enemy.species, - gender = enemy.gender, - playerSpecies = player and player.species, - playerGender = player and player.gender, - evolveItem = evolveItem, - }) + -- is also used by the read-only preview, so both paths stay exact. + caught, rate = Catching.attempt(self:catchOptions(itemId)) end -- wWildMon carries the answer through the animation, and -- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 790de5a6..8f8cfb84 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -490,6 +490,11 @@ check("hurt is easier to catch", hurtRate > fullRate, true) local asleepRate = Catching.rate({ maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" }) check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10)) +checkNear("preview converts the exact byte roll", Catching.chance({ + maxHp = 60, hp = 60, catchRate = 45, ball = "POKE_BALL" }), + fullRate * 100 / 256, 0.000001) +check("master ball preview is certain", Catching.chance({ + maxHp = 60, hp = 60, catchRate = 1, ball = "MASTER_BALL" }), 100) -- The cart's bug: burn/poison/paralysis add nothing. check("poison adds nothing (cart bug)", Catching.rate({ maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index e6090a1c..11486dc0 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -282,6 +282,16 @@ local function runToMenu(screen, cap) return false end +do + local screen = newScreen({ inventory = { + MASTER_BALL = 1, POKE_BALL = 1, + } }) + eq(screen:catchChance("MASTER_BALL"), 100, + "the battle screen exposes a certain Master Ball preview") + check(type(screen:catchChance("POKE_BALL")) == "number", + "the battle screen exposes the live wild catch preview") +end + -- ---- BattleMenu empties the textbox --------------------------------------- do local screen = newScreen() diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua index 851d3646..f2d058d1 100644 --- a/tests/mod_battle_snapshot_test.lua +++ b/tests/mod_battle_snapshot_test.lua @@ -163,14 +163,23 @@ function screen2:chooseMove(slot) return true end function screen2:cancelMove() self.phase = "menu" return true end +function screen2:catchChance(ball) + return ball == "MASTER_BALL" and 100 or 37.5 +end local game2 = { data = { pokemon = { CHIKORITA = { name = "CHIKORITA" }, RATTATA = { name = "RATTATA" } }, moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35, accuracy = 95, pp = 35 } }, + items = { + MASTER_BALL = { name = "MASTER BALL", pocket = "BALL" }, + POTION = { name = "POTION", pocket = "ITEM" }, + }, }, - save = { party = { player2 } }, stack = { states = { screen2 } }, + save = { party = { player2 }, inventory = { + MASTER_BALL = 1, POTION = 2, + } }, stack = { states = { screen2 } }, } local api2 = require("src.battle.gen2.BattleAPI").new(game2) @@ -179,6 +188,9 @@ check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu", "Gold battle is discovered through its screen id") eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field") eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied") +eq(#snapshot2.items, 1, "Gold exposes balls without guessing targeted items") +eq(snapshot2.items[1].catchChance, 100, + "Gold ball records expose the exact catch preview") snapshot2.player.hp = 0 snapshot2.moves[1].pp = 0 eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon") From 4f8739c029b4d40a5c4ba23ac0c6c62f34d6d3ab Mon Sep 17 00:00:00 2001 From: anxiousintrovert <82425472+anxiousintrovert@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:29:32 -0500 Subject: [PATCH 4/6] Fix required imports on legacy Android picker bridges --- src/import/RomImporter.lua | 37 ++++++++++++++--- tests/rom_importer_android_mod_pick_test.lua | 42 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 37fa0868..712790df 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -988,8 +988,18 @@ local function pickerHasKind(kind) return false end -local function findPendingRequiredImport() +local function findPendingRequiredImport(self) local names = { "picked_required_import.bin", "picked_stadium.z64" } + -- Builds released before required_import was added to the Android JNI bridge + -- only understand the long-standing "rom" picker kind. While a required + -- import request is in flight, it is safe to treat its staging name as a + -- dependency file: the pending IDs below select the same validation/copy + -- path as a current bridge. Never scan picked_rom.gb otherwise, since that + -- remains reserved for an ordinary game-ROM import. + if self and self.requiredImportLegacyRomPick + and self.pickerPendingKind == "required_import" then + names[#names + 1] = "picked_rom.gb" + end for _, name in ipairs(names) do if love.filesystem.getInfo(name, "file") then return name end end @@ -1471,12 +1481,16 @@ function RomImporter:focus(f) local text = "Could not read the picked file. Reopen the picker and choose " .. "it with the Files (Documents) app, or copy it into: " .. love.filesystem.getSaveDirectory() + local legacyRequiredPick = self.requiredImportLegacyRomPick + and self.pickerPendingKind == "required_import" if pickError:find("picked_required_import", 1, true) - or pickError:find("picked_stadium", 1, true) then + or pickError:find("picked_stadium", 1, true) + or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then self.modNotice = { ok = false, text = text } self.pickerPendingKind = nil self.pickerPendingModId = nil self.pickerPendingImportId = nil + self.requiredImportLegacyRomPick = nil elseif pickError:find("picked_mod", 1, true) then self.modNotice = { ok = false, text = text } elseif pickError:find("picked_save", 1, true) then @@ -1488,11 +1502,12 @@ function RomImporter:focus(f) end return end - local requiredName = findPendingRequiredImport() + local requiredName = findPendingRequiredImport(self) if requiredName then local modId, importId = self.pickerPendingModId, self.pickerPendingImportId self.pickerPendingKind = nil self.pickerPendingModId, self.pickerPendingImportId = nil, nil + self.requiredImportLegacyRomPick = nil local imported = modId and importId and self:_importRequiredSource(modId, importId, requiredName) consumePick(self, requiredName, requiredName, imported) @@ -2024,7 +2039,17 @@ function RomImporter:chooseRequiredImport(modId, importId) return end if self.nativePicker then - if self.mobileFileBridge and not pickerHasKind("required_import") then + -- Android 13+ uses the Storage Access Framework for both paths. Some + -- Android 15 installs carry the newer Lua launcher with an older native + -- bridge, however, so they do not advertise required_import yet. Fall + -- back to that bridge's known "rom" picker and quarantine its result by + -- the pending required-import IDs. iOS has a different asynchronous + -- bridge and deliberately keeps the explicit capability requirement. + local legacyAndroidPicker = self.mobileFileBridge + and love.system.getOS() == "Android" + and not pickerHasKind("required_import") + if self.mobileFileBridge and not pickerHasKind("required_import") + and not legacyAndroidPicker then requiredImportNotice(self, modId, importId, "This app build cannot pick required mod files yet. Update the app and try again.") self.modNotice = nil @@ -2033,10 +2058,12 @@ function RomImporter:chooseRequiredImport(modId, importId) self.pickerPendingKind = "required_import" self.pickerPendingModId = modId self.pickerPendingImportId = importId - if not pickFile("required_import") then + self.requiredImportLegacyRomPick = legacyAndroidPicker or nil + if not pickFile(legacyAndroidPicker and "rom" or "required_import") then self.pickerPendingKind = nil self.pickerPendingModId = nil self.pickerPendingImportId = nil + self.requiredImportLegacyRomPick = nil requiredImportNotice(self, modId, importId, "Could not open the file picker.") self.modNotice = nil elseif self.android then diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua index b958e75b..bc88882f 100644 --- a/tests/rom_importer_android_mod_pick_test.lua +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -166,6 +166,47 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara check(love.filesystem.getInfo("picked_required_import.bin") == nil, "focus removes the staged required-file pick") +-- Android releases with the updated launcher but the older native bridge do +-- not advertise required_import. They still support the established ROM SAF +-- picker, whose result must be quarantined to the pending dependency request. +love.system.pickFileKinds = function() return "rom,mod,sav" end +pickCalls = {} +ri = freshImporter({ red = true, blue = true }) +ri.nativePicker = true +ri.mobileFileBridge = true +ri.mods = { { + id = "needs_source", + manifest = { id = "needs_source", name = "Needs Source", + required_imports = { { id = "source", name = "Source", file = "source.bin", + format = "raw", md5 = { "00000000000000000000000000000000" } } } }, +} } +ri:chooseRequiredImport("needs_source", "source") +eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker") +check(ri.requiredImportLegacyRomPick, + "legacy Android ROM picker result is marked as a required import") +ri._importRequiredSource = function(self, modId, importId, source) + self._requiredImported = { modId = modId, importId = importId, source = source } + return true +end +love.filesystem.write("picked_rom.gb", "source bytes") +ri:focus(true) +eq(ri._requiredImported.source, "picked_rom.gb", + "legacy Android ROM staging name is routed to the required import") +eq(ri._requiredImported.modId, "needs_source", + "legacy Android picker preserves the requested mod") +check(love.filesystem.getInfo("picked_rom.gb") == nil, + "legacy Android dependency pick is removed after import") + +-- A legacy bridge reports a failed copy using that same staging basename; it +-- must stay on the dependency page rather than becoming a game-ROM error. +ri:chooseRequiredImport("needs_source", "source") +love.filesystem.write("pick_error.flag", "picked_rom.gb") +ri:focus(true) +check(ri.modNotice ~= nil and ri.modNotice.ok == false, + "legacy Android picker errors are shown as dependency import errors") +check(ri.pickerPendingKind == nil, + "legacy Android picker error clears the pending dependency request") + love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile love.system.pickFileKinds = saved.pickFileKinds @@ -174,5 +215,6 @@ love.filesystem.remove("usb_mod.zip") love.filesystem.remove("picked_mod.zip") love.filesystem.remove("picked_save.sav") love.filesystem.remove("picked_required_import.bin") +love.filesystem.remove("picked_rom.gb") S.finish() From c877ea80a7e50897ca57f718bbfb5f038906053b Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 17 Aug 2026 20:15:39 -0400 Subject: [PATCH 5/6] importer for skins now w file picker --- src/import/LauncherView.lua | 106 +++++++++++++------ src/import/RomImporter.lua | 121 +++++++++++++++++++++- tests/drivers/launcher_skins_tab_shot.lua | 15 +++ tests/engine/skin_import_picker_test.lua | 118 +++++++++++++++++++++ 4 files changed, 322 insertions(+), 38 deletions(-) create mode 100644 tests/engine/skin_import_picker_test.lua diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 669b39f5..9d5c67e4 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2017,6 +2017,23 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) local gap = m.gap local cy = y + local title = Strings("Skins/Borders") + local bh = m.btnH + local importLabel = imp:_skinsImportButtonLabel() + local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s) + if Kit.textWidth("button", title) + importW + math.floor(24 * m.s) > w then + importLabel = Strings("Import") + importW = Kit.textWidth("small", importLabel) + math.floor(20 * m.s) + end + local place = Layout.rightCluster(x, w, math.floor(6 * m.s)) + btn(imp, place(importW), cy, importW, bh, "skins-import", importLabel, { + kind = "accent", font = "small", + action = function() imp:chooseSkin() end }) + Kit.text("button", Kit.ellipsize("button", title, + math.max(0, w - importW - math.floor(12 * m.s))), x, + cy + math.floor((bh - Kit.textHeight("button")) / 2), PAL.heading) + cy = cy + bh + math.floor(8 * m.s) + if imp._skinNotice then cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w, imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s) @@ -2075,30 +2092,57 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) cy = cy + rowH + math.floor(4 * m.s) end - skinRow("skin-none", nil, Strings("Built-in pad"), - Strings("The default on-screen buttons."), active == nil, - imp.onEditTouchControls and function() - imp.onEditTouchControls(imp.modScope or "red") - end or nil) + local entries = { false } + for _, entry in ipairs(skins) do entries[#entries + 1] = entry end - for _, entry in ipairs(skins) do - local bits = {} - bits[#bits + 1] = entry.source == "user" and Strings("installed") - or Strings("bundled") - if entry.controls > 0 then - bits[#bits + 1] = entry.controls .. " " .. Strings("buttons") + 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.", + TouchSkin.USER_ROOT) + local hintH = Kit.wrapHeight("small", hint, w, 3) + local importH = math.floor(10 * m.s) + hintH + + local rowGap = math.floor(4 * m.s) + 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) + if #entries > perPage then + perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20) + end + local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"), + #entries, perPage) + setPage(imp, "skins", cur) + setPage(imp, "skins", + Kit.wheelPage(x, listTop, w, listH, cur, #entries, perPage)) + + for i = first, last do + local entry = entries[i] + if not entry then + skinRow("skin-none", nil, Strings("Built-in pad"), + Strings("The default on-screen buttons."), active == nil, + imp.onEditTouchControls and function() + imp.onEditTouchControls(imp.modScope or "red") + end or nil) else - bits[#bits + 1] = Strings("bezel only") + local bits = {} + bits[#bits + 1] = entry.source == "user" and Strings("installed") + or Strings("bundled") + if entry.controls > 0 then + bits[#bits + 1] = entry.controls .. " " .. Strings("buttons") + else + bits[#bits + 1] = Strings("bezel only") + end + if entry.pages > 1 then + 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 + skinRow("skin-" .. entry.id, entry.id, entry.id, + table.concat(bits, " \194\183 "), active == entry.id, configure) end - if entry.pages > 1 then - 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 - skinRow("skin-" .. entry.id, entry.id, entry.id, - table.concat(bits, " \194\183 "), active == entry.id, configure) end if #skins == 0 then @@ -2107,18 +2151,14 @@ local function buildSkinsPanel(imp, x, y, w, availH, m) cy = cy + math.floor(72 * m.s) + gap end - cy = cy + math.floor(6 * m.s) - local TouchSkin = require("src.core.TouchSkin") - Kit.caption(x, cy, Strings("IMPORT")) - cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) - local boxH = math.floor(76 * m.s) - Kit.card(x, cy, w, boxH, "muted") - Kit.textWrapped("small", Strings( - "Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."), - x + math.floor(14 * m.s), cy + math.floor(12 * m.s), - w - math.floor(28 * m.s), PAL.muted, 3) - Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s), - cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint) + if pages > 1 then + setPage(imp, "skins", + Kit.pager(x, cy, w, cur, #entries, perPage, "skins")) + cy = cy + pagerH + gap + end + + cy = cy + math.floor(10 * m.s) + Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3) end local function buildFindPanel(imp, x, y, w, availH, m) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 37fa0868..7001e2f2 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1089,6 +1089,39 @@ local function chooseZip() return nil end +local function chooseSkinZip() + local prompt = shellSafe(Strings("Choose a skin .zip")) + 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]]) + :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 (*.*)|*.*';", + "if($d.ShowDialog() -eq 'OK'){", + "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", + "$t=Join-Path $env:TEMP $n;", + "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", + "[Console]::Write($t)}", + }) + return commandOutput( + '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]]) + :format(prompt)) + if path then return path end + return commandOutput( + [[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]]) + end + return nil +end + -- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS -- dialogs). Returns the chosen absolute path or nil. Android uses -- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport. @@ -1478,7 +1511,12 @@ function RomImporter:focus(f) self.pickerPendingModId = nil self.pickerPendingImportId = nil elseif pickError:find("picked_mod", 1, true) then - self.modNotice = { ok = false, text = text } + if self.pickerPendingKind == "skin" then + self.pickerPendingKind = nil + self._skinNotice = { ok = false, text = text } + else + self.modNotice = { ok = false, text = text } + end elseif pickError:find("picked_save", 1, true) then local version = self.androidPendingVersion or self:_savedropTarget() self.androidPendingVersion = nil @@ -1504,6 +1542,13 @@ function RomImporter:focus(f) end local modName = findPendingMod(false, self.pickSkip) if modName then + if self.pickerPendingKind == "skin" then + self.pickerPendingKind = nil + self:_installSkinZip(modName) + consumePick(self, modName, "picked_mod.zip", + self._skinNotice and self._skinNotice.ok) + return + end self:_installMod(modName) consumePick(self, modName, "picked_mod.zip", self.modNotice and self.modNotice.ok) @@ -2498,6 +2543,11 @@ function RomImporter:update(dt) if Platform.isUWP() and self.modNotice and self.modNotice.ok then os.remove(path) end + elseif kind == "skin" then + self:_installSkinZip(path) + if Platform.isUWP() and self._skinNotice and self._skinNotice.ok then + os.remove(path) + end elseif kind == "sav" then local target = version or self:_savedropTarget() self:_importSave(target, path) @@ -2520,6 +2570,8 @@ function RomImporter:update(dt) self.pickerPendingModId, self.pickerPendingImportId = nil, nil elseif kind == "mod" then self.modNotice = { ok = false, text = errorText } + elseif kind == "skin" then + self._skinNotice = { ok = false, text = errorText } elseif kind == "sav" then self.saveNotice[version] = { ok = false, text = errorText } else @@ -3047,13 +3099,27 @@ function RomImporter:_useSkin(id) } end -function RomImporter:_installSkinZip(file) +function RomImporter:_installSkinZip(source) + if self.workState == "working" then return end + self.tab = "skins" local TouchSkin = require("src.core.TouchSkin") - local name = file:getFilename() or "" - local data, readError = readDroppedFile(file) + local name, data, readError + if type(source) == "string" then + name = source + if not source:match("^/") and not source:match("^%a:[/\\]") + and not source:match("^[Ss][Dd][Mm][Cc]:") then + data = love.filesystem.read(source) + end + if not data then data, readError = readExternalPath(source) end + if not data then data = love.filesystem.read(source) end + else + name = source:getFilename() or "" + data, readError = readDroppedFile(source) + end if not data then self._skinNotice = { ok = false, - text = "Could not read the dropped file: " .. tostring(readError) } + text = "Could not read the skin archive: " + .. tostring(readError or name) } return end local id, err = TouchSkin.installArchive(name, data) @@ -3065,6 +3131,51 @@ function RomImporter:_installSkinZip(file) self._skinNotice = { ok = true, text = "Imported " .. id } end +function RomImporter:_skinsImportButtonLabel() + if self.isNX then return Strings("Scan again") end + return Strings("Import skin .zip") +end + +function RomImporter:chooseSkin() + if self.workState == "working" then return end + if self.isNX then + local found = #self:_ensureSkins(true) + self._skinNotice = { ok = true, text = Strings( + "%d skins found. Copy a skin .zip into %s/ over MTP, then scan again.", + found, require("src.core.TouchSkin").USER_ROOT) } + return + end + if self.nativePicker and love.system.getPickedFile then + self.pickerPendingKind = "skin" + if not pickFile("mod") then + self.pickerPendingKind = nil + self._skinNotice = { ok = false, text = "Could not open the file picker." } + end + return + end + if self.android then + local name = findPendingMod(true, self.pickSkip) + if name then + self:_installSkinZip(name) + consumePick(self, name, "picked_mod.zip", + self._skinNotice and self._skinNotice.ok) + return + end + self.pickerPendingKind = "skin" + if not pickFile("mod") then + self.pickerPendingKind = nil + self._skinNotice = { ok = false, + text = "Could not open the file picker. Copy a skin .zip via USB." } + else + self.pickPending = true + self.pickTimer = 0 + end + return + end + local path = chooseSkinZip() + if path then self:_installSkinZip(path) end +end + function RomImporter:_toggleFindSearchFocus() self._findSearchFocus = not self._findSearchFocus if self._findSearchFocus then diff --git a/tests/drivers/launcher_skins_tab_shot.lua b/tests/drivers/launcher_skins_tab_shot.lua index 6f0460b7..22d95bca 100644 --- a/tests/drivers/launcher_skins_tab_shot.lua +++ b/tests/drivers/launcher_skins_tab_shot.lua @@ -94,6 +94,21 @@ return function(game) imp:_useSkin(nil) shot("skins_tab_imported.png") + -- import button: the picker hands back an absolute host path, not a drop + local picked = dir .. "/picked_skin.zip" + local pf = io.open(picked, "wb") + pf:write(raw) + pf:close() + imp:_installSkinZip(picked) + U.log("path import notice:", imp._skinNotice.text) + U.log("picked_skin found:", tostring(TouchSkin.find("picked_skin") ~= nil)) + U.log("import label:", imp:_skinsImportButtonLabel()) + shot("skins_tab_path_imported.png") + + love.window.setMode(520, 760, { resizable = true, highdpi = true }) + U.wait(3) + shot("skins_tab_narrow.png") + U.log("done") love.event.quit() while true do coroutine.yield() end diff --git a/tests/engine/skin_import_picker_test.lua b/tests/engine/skin_import_picker_test.lua new file mode 100644 index 00000000..fbb48824 --- /dev/null +++ b/tests/engine/skin_import_picker_test.lua @@ -0,0 +1,118 @@ +-- Skins tab Import button: picker plumbing + path/drop install (SKINIMP-01..05). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("skin import picker") +local eq = S.eq +local check = S.check + +local RomImporter = require("src.import.RomImporter") +local TouchSkin = require("src.core.TouchSkin") + +love.system = love.system or {} +local saved = { + pickFile = love.system.pickFile, + getPickedFile = love.system.getPickedFile, + getPickError = love.system.getPickError, + installArchive = TouchSkin.installArchive, +} + +local installed +TouchSkin.installArchive = function(name, data) + installed = { name = name, data = data } + if not data or data == "" then return nil, "empty archive" end + return (name:match("([^/\\]+)$") or name):gsub("%.[Zz][Ii][Pp]$", "") +end + +local function freshImporter(fields) + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = "skins" + imp._skins = {} + imp._ensureSkins = function(self) return self._skins end + for k, v in pairs(fields or {}) do imp[k] = v end + return imp +end + +-- SKINIMP-01: a picker path installs as a skin, not a mod +installed = nil +local imp = freshImporter() +local tmp = os.tmpname() .. ".zip" +local f = assert(io.open(tmp, "wb")) +f:write("PK\3\4skin-archive") +f:close() +imp:_installSkinZip(tmp) +os.remove(tmp) +check(installed ~= nil, "an absolute picker path reaches TouchSkin.installArchive") +eq(installed.data, "PK\3\4skin-archive", "the picked file's bytes are installed") +check(imp._skinNotice and imp._skinNotice.ok, "a good pick reports success") + +-- SKINIMP-02: a dropped file still installs, and both paths land on the tab +installed = nil +imp = freshImporter({ tab = "red" }) +imp:filedropped({ + getFilename = function() return "/tmp/dropped_skin.zip" end, + getSize = function() return 4 end, + open = function() return true end, + read = function() return "PKZP" end, + close = function() return true end, +}) +check(installed == nil, "a zip dropped off the skins tab is still a mod") +installed = nil +imp = freshImporter() +imp:filedropped({ + getFilename = function() return "/tmp/dropped_skin.zip" end, + getSize = function() return 4 end, + open = function() return true end, + read = function() return "PKZP" end, + close = function() return true end, +}) +eq(installed and installed.name, "/tmp/dropped_skin.zip", + "a zip dropped on the skins tab installs as a skin") +eq(imp.tab, "skins", "the skin install stays on the skins tab") + +-- SKINIMP-03: the mobile bridge borrows the mod picker kind +local requestedKind +love.system.pickFile = function(kind) + requestedKind = kind + return true +end +love.system.getPickedFile = function() return nil end +love.system.getPickError = function() return nil end +imp = freshImporter({ nativePicker = true }) +imp:chooseSkin() +eq(requestedKind, "mod", "chooseSkin opens the .zip picker") +eq(imp.pickerPendingKind, "skin", "the pending pick is routed to the skins tab") + +-- SKINIMP-04: the picked file comes back through update() as a skin +installed = nil +love.system.getPickedFile = function() + love.system.getPickedFile = function() return nil end + return "/picked/from_bridge.zip" +end +imp._installSkinZip = function(self, source) + self.installedSource = source + self._skinNotice = { ok = true, text = "Imported from_bridge" } +end +imp:update(0) +eq(imp.installedSource, "/picked/from_bridge.zip", + "update() hands the picked path to the skin installer") +eq(imp.pickerPendingKind, nil, "the pending kind clears once consumed") + +-- SKINIMP-05: a picker error lands on the skins notice, not the mods one +love.system.getPickedFile = function() return nil end +love.system.getPickError = function() + love.system.getPickError = function() return nil end + return "picker refused" +end +imp = freshImporter({ nativePicker = true, pickerPendingKind = "skin" }) +imp:update(0) +check(imp._skinNotice and not imp._skinNotice.ok, + "a failed skin pick reports on the skins tab") +eq(imp.modNotice, nil, "a failed skin pick leaves the mods notice alone") + +love.system.pickFile = saved.pickFile +love.system.getPickedFile = saved.getPickedFile +love.system.getPickError = saved.getPickError +TouchSkin.installArchive = saved.installArchive + +S.finish() From 4e1ab1879b2311ba01e64ec5ff9c7d7828054da0 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 17 Aug 2026 20:39:53 -0400 Subject: [PATCH 6/6] updated skin studio --- docs/skin-studio.md | 15 ++- main.lua | 42 ++++++- src/core/FilePicker.lua | 109 +++++++++++++++++ src/ui/SkinStudio.lua | 116 +++++++++++++++--- tests/drivers/skin_studio_focus_test.lua | 51 ++++++++ tests/drivers/skin_studio_import_shot.lua | 120 +++++++++++++++++++ tests/engine/skin_studio_image_import.lua | 137 ++++++++++++++++++++++ 7 files changed, 565 insertions(+), 25 deletions(-) create mode 100644 src/core/FilePicker.lua create mode 100644 tests/drivers/skin_studio_focus_test.lua create mode 100644 tests/drivers/skin_studio_import_shot.lua create mode 100644 tests/engine/skin_studio_image_import.lua diff --git a/docs/skin-studio.md b/docs/skin-studio.md index d7a834a4..27218336 100644 --- a/docs/skin-studio.md +++ b/docs/skin-studio.md @@ -6,6 +6,7 @@ control layout, and the rectangle the Game Boy screen is drawn into. Engine: (draw and input), `src/render/Renderer.lua` (the screen viewport), `src/ui/SkinStudio.lua` (the desktop editor). Tests: `tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, +`tests/engine/skin_studio_image_import.lua`, `tests/engine/launcher_skins_tab.lua`. Skins are picked in the launcher's **Skins** tab, which also imports them and @@ -159,8 +160,16 @@ The Super Game Boy preset locks the viewport to the real screen window, 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 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. Drop a PNG -or JPG on the window to import art into the skin. +per page. The cutout is itself a draggable element with a 10:9 lock. + +**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, +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 +move the screen cutout: press **Detect screen from bezel** to measure it out of +the art's alpha. **Testing.** **Test** makes the canvas live: clicking presses real Game Boy buttons and the footer reports what is held. **Play** saves the skin, selects @@ -175,5 +184,3 @@ straight back into `skins/` and still opens in RetroArch. ## Not implemented RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. -Image assignment cycles through art already in the skin folder; there is no -file browser, so new art arrives by drag and drop. diff --git a/main.lua b/main.lua index 10a07426..eb0768c6 100644 --- a/main.lua +++ b/main.lua @@ -506,6 +506,7 @@ function love.update(dt) if TouchEditor then return TouchEditor.update(dt) end if Studio then return Studio.update(dt) end if Importer then return Importer:update(dt) end + if not Game then return end -- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly -- once per Game:update, so they must keep a 1:1 relationship with the @@ -602,12 +603,14 @@ function love.keypressed(key, scancode, isrepeat) if TouchEditor then return TouchEditor.keypressed(key) end if Studio then return Studio.keypressed(key) end if Importer then return Importer:keypressed(key) end + if not Game then return end Game:keypressed(key) end function love.keyreleased(key) if editorMode or TouchEditor or Studio then return end if Importer then return end + if not Game then return end Game:keyreleased(key) end @@ -625,7 +628,9 @@ function love.gamepadpressed(joystick, button) end return end + if Studio then return end if Importer then return Importer:gamepadpressed(joystick, button) end + if not Game then return end Game:gamepadpressed(joystick, button) end @@ -643,7 +648,9 @@ function love.gamepadreleased(joystick, button) end return end + if Studio then return end if Importer then return Importer:gamepadreleased(joystick, button) end + if not Game then return end Game:gamepadreleased(joystick, button) end @@ -661,7 +668,9 @@ function love.gamepadaxis(joystick, axis, value) end return end + if Studio then return end if Importer then return Importer:gamepadaxis(joystick, axis, value) end + if not Game then return end Game:gamepadaxis(joystick, axis, value) end @@ -679,7 +688,9 @@ function love.joystickpressed(joystick, button) end return end + if Studio then return end if Importer then return Importer:joystickpressed(joystick, button) end + if not Game then return end Game:joystickpressed(joystick, button) end @@ -697,7 +708,9 @@ function love.joystickreleased(joystick, button) end return end + if Studio then return end if Importer then return Importer:joystickreleased(joystick, button) end + if not Game then return end Game:joystickreleased(joystick, button) end @@ -715,7 +728,9 @@ function love.joystickaxis(joystick, axis, value) end return end + if Studio then return end if Importer then return Importer:joystickaxis(joystick, axis, value) end + if not Game then return end Game:joystickaxis(joystick, axis, value) end @@ -733,21 +748,25 @@ function love.joystickhat(joystick, hat, direction) end return end + if Studio then return end if Importer then return Importer:joystickhat(joystick, hat, direction) end + if not Game then return end Game:joystickhat(joystick, hat, direction) end function love.joystickadded(joystick) SwitchDiagnostics.onJoystickEvent("joystickadded", joystick) - if editorMode or TouchEditor then return end + if editorMode or TouchEditor or Studio then return end if Importer then return end + if not Game then return end Game:joystickadded(joystick) end function love.joystickremoved(joystick) SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick) - if editorMode or TouchEditor then return end + if editorMode or TouchEditor or Studio then return end if Importer then return end + if not Game then return end Game:joystickremoved(joystick) end @@ -756,26 +775,36 @@ end -- unfocused, so reset input on either transition rather than trust it. function love.focus(f) if editorMode or TouchEditor then return end + if Studio then + if Studio.focus then Studio.focus(f) end + return + end if Importer then require("src.core.Input"):reset() if Importer.focus then Importer:focus(f) end return end + if not Game then return end Game:focus(f) end -- v is true when the window becomes visible again, false on minimize. function love.visible(v) if editorMode or TouchEditor then return end + if Studio then + if Studio.visible then Studio.visible(v) end + return + end if Importer then require("src.core.Input"):reset() return end + if not Game then return end Game:visible(v) end function love.lowmemory() - if editorMode or TouchEditor or Importer then return end + if editorMode or TouchEditor or Studio or Importer then return end if Game then Game:onResume() end end @@ -796,12 +825,14 @@ function love.touchpressed(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchpressed(id, x, y) end + if Studio then return end if Importer then -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- polled inside the view; the istouch filter on mousepressed still drops -- Android's synthesized mouse twin so Import cannot double-fire (#553). return Importer:touchpressed(id, x, y, dx, dy, pressure) end + if not Game then return end Game:touchpressed(id, x, y, dx, dy, pressure) end @@ -811,9 +842,11 @@ function love.touchmoved(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchmoved(id, x, y) end + if Studio then return end if Importer then return Importer:touchmoved(id, x, y, dx, dy, pressure) end + if not Game then return end Game:touchmoved(id, x, y, dx, dy, pressure) end @@ -823,9 +856,11 @@ function love.touchreleased(id, x, y, dx, dy, pressure) if love.system.getOS() == "iOS" then return end return TouchEditor.touchreleased(id, x, y) end + if Studio then return end if Importer then return Importer:touchreleased(id, x, y, dx, dy, pressure) end + if not Game then return end Game:touchreleased(id, x, y, dx, dy, pressure) end @@ -837,6 +872,7 @@ function love.wheelmoved(x, y) if TouchEditor then return end if Studio then return Studio.wheelmoved(x, y) end if Importer then return end + if not Game then return end Game:wheelmoved(x, y) end diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua new file mode 100644 index 00000000..d56cc116 --- /dev/null +++ b/src/core/FilePicker.lua @@ -0,0 +1,109 @@ +local Platform = require("src.core.Platform") +local HostShell = require("src.core.HostShell") + +local FilePicker = {} + +FilePicker.IMAGE = { + label = "Image", + exts = { "png", "jpg", "jpeg" }, + tempName = "pokeport_image_pick", +} + +local function trim(value) + return value and value:gsub("^%s+", ""):gsub("%s+$", "") or "" +end + +local function shellSafe(s) + s = tostring(s):gsub("%%", "%%%%") + return s:gsub('"', '\\"'):gsub("'", "''") +end + +local function commandOutput(command) + if not Platform.canSpawnProcess() then return nil end + local pipe = HostShell.popen(command) + if not pipe then return nil end + local result = pipe:read("*a") + HostShell.pclose(pipe) + result = trim(result) + return result ~= "" and result or nil +end + +local function appleTypes(exts) + local out = {} + for _, ext in ipairs(exts) do out[#out + 1] = '"' .. ext .. '"' end + return table.concat(out, ", ") +end + +local function windowsPatterns(exts) + local out = {} + for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end + return table.concat(out, ";") +end + +local function globPatterns(exts) + local out = {} + for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end + return table.concat(out, " ") +end + +function FilePicker.available() + return Platform.canSpawnProcess() +end + +function FilePicker.matches(name, kind) + local lower = tostring(name or ""):lower() + for _, ext in ipairs(kind.exts) do + if lower:match("%." .. ext .. "$") then return true end + end + return false +end + +function FilePicker.open(prompt, kind) + if not Platform.canSpawnProcess() then return nil end + local title = shellSafe(prompt) + local platform = love.system.getOS() + if platform == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]]) + :format(title, appleTypes(kind.exts))) + elseif platform == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. title .. "';", + "$d.Filter='" .. kind.label .. " (" .. windowsPatterns(kind.exts) .. ")|" + .. windowsPatterns(kind.exts) .. "|All files (*.*)|*.*';", + "if($d.ShowDialog() -eq 'OK'){", + "$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';", + "$t=Join-Path $env:TEMP $n;", + "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", + "[Console]::Write($t)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif platform == "Linux" then + local path = commandOutput( + ([[zenity --file-selection --title="%s" --file-filter="%s | %s" 2>/dev/null]]) + :format(title, kind.label, globPatterns(kind.exts))) + if path then return path end + return commandOutput(([[kdialog --getopenfilename "$HOME" "%s|%s" 2>/dev/null]]) + :format(globPatterns(kind.exts), kind.label)) + end + return nil +end + +function FilePicker.read(path) + local file, openError = io.open(path, "rb") + if not file then return nil, openError end + local data = file:read("*a") + file:close() + if not data or data == "" then return nil, "empty file" end + return data +end + +function FilePicker.basename(path) + return tostring(path or ""):match("([^/\\]+)$") or tostring(path or "") +end + +return FilePicker diff --git a/src/ui/SkinStudio.lua b/src/ui/SkinStudio.lua index 6c37bef8..936ada14 100644 --- a/src/ui/SkinStudio.lua +++ b/src/ui/SkinStudio.lua @@ -4,6 +4,7 @@ local PAL = Theme.PAL local TouchSkin = require("src.core.TouchSkin") local TouchControls = require("src.core.TouchControls") local SaveData = require("src.core.SaveData") +local FilePicker = require("src.core.FilePicker") local Studio = {} @@ -214,12 +215,19 @@ function Studio.cycleImage(dir) markDirty() end +function Studio.imageTargetLabel() + local target = Studio.imageTarget + if target == "bezel" or not Studio.selectedControl() then return "bezel" end + return target == "pressed" and "pressed art" or "idle art" +end + function Studio.assignImage(rel) local page, ctl = Studio.page(), Studio.selectedControl() local img = TouchSkin.resolveImage(Studio.skin.root, rel) - if ctl and Studio.imageTarget == "pressed" then + local target = Studio.imageTarget + if ctl and target == "pressed" then ctl.pressedImagePath, ctl.pressedImage = rel, img - elseif ctl and Studio.imageTarget == "idle" then + elseif ctl and target == "idle" then ctl.imagePath, ctl.image = rel, img elseif page then page.imagePath, page.image = rel, img @@ -228,11 +236,69 @@ function Studio.assignImage(rel) Studio.dirty = true end +local function commitSkinId() + local skin = Studio.skin + if not skin then return end + local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") + if id == "" or id == skin.id then return end + TouchSkin.saveTo(skin, id) + Studio.available = TouchSkin.list() +end + +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." + return false + end + commitSkinId() + local rel, err = TouchSkin.importImage(Studio.skin, name, data) + if not rel then + Studio.status = "Import failed: " .. tostring(err) + return false + end + local where = Studio.imageTargetLabel() + Studio.assignImage(rel) + Studio.skinIdField = Studio.skin.id + Studio.status = "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" + end + return true +end + +function Studio.importImageFile(target) + if not Studio.skin then return end + 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." + return + end + if not FilePicker.available() then + Studio.status = "No file picker here -- drag a PNG onto the window instead." + return + end + local prompt = (target == "bezel") and "Choose a bezel image" + or "Choose a button image" + local path = FilePicker.open(prompt, FilePicker.IMAGE) + if not path then return end + local base = FilePicker.basename(path) + local data, err = FilePicker.read(path) + if not data then + Studio.status = "Could not read " .. base .. ": " .. tostring(err) + return + end + Studio.adoptImage(base, data, target) +end + function Studio.filedropped(file) if not Studio.skin then return end local path = (file.getFilename and file:getFilename()) or "" - local base = path:match("([^/\\]+)$") or path - if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then + 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." return end @@ -246,17 +312,7 @@ function Studio.filedropped(file) Studio.status = "Could not read " .. base return end - local rel, err = TouchSkin.importImage(Studio.skin, base, data) - if not rel then - Studio.status = "Import failed: " .. tostring(err) - return - end - Studio.assignImage(rel) - local where = Studio.selectedControl() - and (Studio.imageTarget == "pressed" and "pressed art" or "idle art") - or "bezel" - Studio.status = "Imported " .. rel .. " as " .. where - Studio.skinIdField = Studio.skin.id + Studio.adoptImage(base, data) end function Studio.detectViewport() @@ -617,10 +673,16 @@ local function inspectorBody(x, y, w) if page then local bezel = page.imagePath or "(none)" - if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then + 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) end + if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import", + { id = "bezelpick" }) then + Studio.importImageFile("bezel") + end cy = cy + rowH + gap local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF" if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", @@ -701,17 +763,25 @@ local function inspectorBody(x, y, w) Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint) cy = cy + Kit.textHeight("small") + gap + local pickW = 82 * Kit.scale + local artW = w - pickW - gap local idle = ctl.imagePath or "(none)" - if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then + if Kit.button(x, cy, artW, rowH, "Idle art: " .. idle, { id = "img" }) then Studio.imageTarget = "idle" Studio.cycleImage(1) end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then + Studio.importImageFile("idle") + end cy = cy + rowH + gap local pressed = ctl.pressedImagePath or "(none)" - if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then + if Kit.button(x, cy, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then Studio.imageTarget = "pressed" Studio.cycleImage(1) end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then + Studio.importImageFile("pressed") + end return cy + rowH end @@ -885,6 +955,16 @@ function Studio.wheelmoved(_, dy) Studio.wheel = dy end +function Studio.focus() + Studio.drag = nil + Studio.clicked = false + if Studio.testing then TouchControls:reset() end +end + +function Studio.visible() + Studio.focus() +end + function Studio.textinput(text) Kit.textinput(text) end diff --git a/tests/drivers/skin_studio_focus_test.lua b/tests/drivers/skin_studio_focus_test.lua new file mode 100644 index 00000000..71793dc9 --- /dev/null +++ b/tests/drivers/skin_studio_focus_test.lua @@ -0,0 +1,51 @@ +-- Driver: the studio's side of the alt-tab crash. Leaving the window mid-drag +-- must drop the drag and the queued click and still draw; main.lua's routing of +-- love.focus / love.visible / pad events while the studio owns the window is +-- pinned by tests/engine/skin_studio_image_import.lua (a driver run boots a +-- game, so main.lua's Studio branch is not live here). +-- POKEPORT_DRIVER=tests/drivers/skin_studio_focus_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Studio = require("src.ui.SkinStudio") + local TouchControls = require("src.core.TouchControls") + + love.window.setMode(1280, 720, { resizable = true, highdpi = true }) + U.wait(2) + + Studio.load({ version = "red", skinId = "gb_anim", onClose = function() end }) + U.wait(2) + U.log("skin:", Studio.skin and Studio.skin.id) + + Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0, + bw = 10, bh = 10 } + Studio.clicked = true + local okFocus, errFocus = pcall(Studio.focus, false) + U.log("focus lost:", okFocus, errFocus or "") + U.log("drag:", tostring(Studio.drag), "click:", tostring(Studio.clicked)) + local droppedDrag = Studio.drag == nil and Studio.clicked == false + + -- a held test press must not survive the trip out of the window either + Studio.testing = true + TouchControls:setPreview(false) + TouchControls.held = TouchControls.held or {} + TouchControls.held.start = true + pcall(Studio.focus, false) + local held = next(TouchControls.held or {}) + U.log("held after focus loss:", tostring(held)) + + local okVisible = pcall(Studio.visible, false) + U.log("minimize:", okVisible) + + local okDraw, errDraw = pcall(Studio.draw) + U.log("draw after alt-tab:", okDraw, errDraw or "") + + Studio.unload() + + if droppedDrag and held == nil and okVisible and okDraw then + U.log("RESULT pass") + else + U.log("RESULT FAIL") + end + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/drivers/skin_studio_import_shot.lua b/tests/drivers/skin_studio_import_shot.lua new file mode 100644 index 00000000..e2014874 --- /dev/null +++ b/tests/drivers/skin_studio_import_shot.lua @@ -0,0 +1,120 @@ +-- Driver: imports a bezel image into a fresh skin through the studio's Import +-- button and shoots the result, with the native dialog stubbed out (the real +-- one blocks on osascript / zenity / PowerShell). Also covers button art and +-- the drag-and-drop path. +-- SHOT_DIR=/tmp/studioimport POKEPORT_DRIVER=tests/drivers/skin_studio_import_shot.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Studio = require("src.ui.SkinStudio") + local FilePicker = require("src.core.FilePicker") + local TouchSkin = require("src.core.TouchSkin") + + local dir = os.getenv("SHOT_DIR") or "/tmp/studioimport" + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + love.window.setMode(1440, 900, { resizable = true, highdpi = true }) + U.wait(2) + + local pending = nil + love.draw = function() + Studio.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 + 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 + + Studio.load({ version = "red", onClose = function() end }) + U.wait(3) + -- start from New so the run does not depend on the saved skin choice + Studio.skin = TouchSkin.newSkin("import_probe") + Studio.skinIdField = "import_probe" + Studio.pageIndex, Studio.selected, Studio.images = 1, nil, {} + U.log("skin:", Studio.skin.id, "bezel:", tostring(Studio.page().imagePath)) + + -- what the native dialog would hand back + local picked = "assets/skins/gb_anim/img/gb_back.png" + local realOpen = FilePicker.open + FilePicker.open = function(prompt, kind) + U.log("picker prompt:", prompt, "exts:", table.concat(kind.exts, "/")) + return picked + end + + Studio.importImageFile("bezel") + U.log("status:", tostring(Studio.status)) + U.log("bezel now:", tostring(Studio.page().imagePath), + "loaded:", tostring(Studio.page().image ~= nil)) + U.log("skin root:", Studio.skin.root) + U.log("file on disk:", + tostring(love.filesystem.getInfo(Studio.skin.root .. "/img/gb_back.png") ~= nil)) + local viewport = Studio.page().viewport + U.log("viewport left alone:", viewport and + ("%.3f %.3f %.3f %.3f"):format(viewport.x, viewport.y, viewport.w, viewport.h) + or "none") + shot("import_bezel.png") + + -- button art goes onto the selected control, not the page + Studio.addControl() + FilePicker.open = function() return "assets/skins/gb_anim/img/gbc_a.png" end + Studio.importImageFile("idle") + local ctl = Studio.selectedControl() + U.log("idle art:", tostring(ctl.imagePath), + "bezel untouched:", tostring(Studio.page().imagePath)) + FilePicker.open = function() return "assets/skins/gb_anim/img/gbc_b.png" end + Studio.importImageFile("pressed") + U.log("pressed art:", tostring(ctl.pressedImagePath)) + shot("import_button_art.png") + + -- cancelling the dialog changes nothing + local before = Studio.page().imagePath + FilePicker.open = function() return nil end + Studio.importImageFile("bezel") + U.log("after cancel:", tostring(Studio.page().imagePath), + "unchanged:", tostring(before == Studio.page().imagePath)) + + -- the drop path shares the import, and refuses non-art + Studio.imageTarget = "bezel" + local raw = love.filesystem.read("assets/skins/tv_crt/img/tv-integer.png") + Studio.filedropped({ + getFilename = function() return "/tmp/tv_frame.png" end, + open = function() return true end, + read = function() return raw end, + close = function() return true end, + }) + U.log("dropped bezel:", tostring(Studio.page().imagePath)) + Studio.filedropped({ + getFilename = function() return "/tmp/notes.txt" end, + open = function() return true end, + read = function() return "nope" end, + close = function() return true end, + }) + U.log("after dropping a txt:", tostring(Studio.page().imagePath), + "status:", tostring(Studio.status)) + shot("import_dropped.png") + + love.window.setMode(760, 900, { resizable = true, highdpi = true }) + U.wait(3) + shot("import_narrow.png") + + FilePicker.open = realOpen + local saved = TouchSkin.find("import_probe") + U.log("skin discoverable:", tostring(saved ~= nil)) + Studio.unload() + U.log("done") + love.event.quit() + while true do coroutine.yield() end +end diff --git a/tests/engine/skin_studio_image_import.lua b/tests/engine/skin_studio_image_import.lua new file mode 100644 index 00000000..3012e6ca --- /dev/null +++ b/tests/engine/skin_studio_image_import.lua @@ -0,0 +1,137 @@ +-- Skin Studio art import: the native image picker behind the "Import" buttons +-- (bezel / idle / pressed), and the alt-tab crash that took the studio down -- +-- love.focus fell through to Game:focus with no game booted. +-- luajit tests/engine/skin_studio_image_import.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 FilePicker = require("src.core.FilePicker") +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.drag = nil + Studio.dirty = false + Studio.status = nil + Studio.testing = false + Studio.images = {} + Studio.imageTarget = "idle" + return Studio.skin +end + +-- ------------------------------------------------------------ file picker + +check(FilePicker.matches("bezel.png", FilePicker.IMAGE), "png is art") +check(FilePicker.matches("BEZEL.PNG", FilePicker.IMAGE), "case does not matter") +check(FilePicker.matches("shot.jpg", FilePicker.IMAGE), "jpg is art") +check(FilePicker.matches("shot.jpeg", FilePicker.IMAGE), "jpeg is art") +check(not FilePicker.matches("skin.zip", FilePicker.IMAGE), "a zip is not art") +eq(FilePicker.basename("/home/me/art/bezel.png"), "bezel.png", "posix basename") +eq(FilePicker.basename("C:\\Users\\me\\bezel.png"), "bezel.png", "windows basename") +eq(FilePicker.basename("bezel.png"), "bezel.png", "a bare name is its own basename") + +-- ----------------------------------------------------------- import target + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +check(Studio.adoptImage("bezel.png", "PNGDATA", "bezel"), "bezel import succeeds") +eq(Studio.page().imagePath, "img/bezel.png", "bezel art lands on the page") +eq(ctl.imagePath, nil, "and not on the selected control") +check(Studio.dirty, "importing marks the skin dirty") +check(Studio.status:find("bezel", 1, true) ~= nil, "the status names the target") + +check(Studio.adoptImage("a_button.png", "PNGDATA", "idle"), "idle import succeeds") +eq(ctl.imagePath, "img/a_button.png", "idle art lands on the control") +eq(Studio.page().imagePath, "img/bezel.png", "and leaves the bezel alone") + +check(Studio.adoptImage("a_down.png", "PNGDATA", "pressed"), "pressed import succeeds") +eq(ctl.pressedImagePath, "img/a_down.png", "pressed art lands on the control") +eq(ctl.imagePath, "img/a_button.png", "idle art survives") + +check(not Studio.adoptImage("notes.txt", "junk", "bezel"), "non-art is refused") +eq(Studio.page().imagePath, "img/bezel.png", "and changes nothing") + +-- the imported files are listed for the cycle buttons +local listed = {} +for _, rel in ipairs(Studio.images or {}) do listed[rel] = true end +check(listed["img/bezel.png"], "the imported bezel joins the image list") + +-- -------------------------------------------------------- picker plumbing + +local realOpen, realRead = FilePicker.open, FilePicker.read +local asked +FilePicker.open = function(prompt, kind) + asked = { prompt = prompt, kind = kind } + return "/tmp/some folder/frame.png" +end +FilePicker.read = function() return "PNGDATA" end + +session() +Studio.importImageFile("bezel") +check(asked ~= nil, "the bezel button opens a picker") +eq(asked.kind, FilePicker.IMAGE, "and asks for images, not archives") +check(asked.prompt:lower():find("bezel", 1, true) ~= nil, "with a bezel prompt") +eq(Studio.page().imagePath, "img/frame.png", "the pick becomes the bezel") +eq(Studio.imageTarget, "bezel", "and the target sticks for the cycle button") + +-- no control selected: an art import says so instead of writing to the page +session() +asked = nil +Studio.importImageFile("idle") +check(asked == nil, "no picker opens without a control to paint") +eq(Studio.page().imagePath, nil, "and the bezel is not overwritten") +check(Studio.status ~= nil, "the studio explains why") + +-- a cancelled dialog leaves the skin exactly as it was +session() +FilePicker.open = function() return nil end +Studio.importImageFile("bezel") +eq(Studio.page().imagePath, nil, "cancelling imports nothing") +check(not Studio.dirty, "and does not dirty the skin") + +FilePicker.open, FilePicker.read = realOpen, realRead + +-- --------------------------------------------------------- focus / alt-tab + +session() +Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0, bw = 1, bh = 1 } +Studio.clicked = true +Studio.focus(false) +eq(Studio.drag, nil, "losing focus drops the in-flight drag") +eq(Studio.clicked, false, "and the queued click") +check(pcall(Studio.visible, false), "minimizing is survivable too") + +-- main.lua used to hand focus / visibility / pad events straight to Game while +-- the studio owned the window, and the launcher has no Game -- alt-tab took the +-- app down with "attempt to index a nil value (global 'Game')". +local f = assert(io.open("main.lua", "r")) +local src = f:read("*a") +f:close() + +local checked = 0 +for name, body in ("\n" .. src):gmatch("\nfunction love%.([%w_]+)%b()\n(.-)\nend\n") do + if body:find("Game:") then + checked = checked + 1 + check(body:find("Studio") ~= nil, + "love." .. name .. " checks Studio before dispatching to Game") + check(body:find("not Game then") ~= nil + or body:find("if Game then") ~= nil + or body:find("Game and ") ~= nil, + "love." .. name .. " tolerates a nil Game (launcher / studio session)") + end +end +check(checked >= 10, "the handler scan actually found handlers (" .. checked .. ")") +check(src:find("Studio.focus", 1, true) ~= nil, "love.focus routes to the studio") +check(src:find("Studio.visible", 1, true) ~= nil, "so does love.visible") + +T.finish("skin_studio_image_import")