Compare commits

...

15 Commits

Author SHA1 Message Date
bryanthaboi cf45cbbf92 Merge pull request #1498 from bryanthaboi/dev
more stuff
2026-08-17 20:41:58 -04:00
bryanthaboi 4e1ab1879b updated skin studio 2026-08-17 20:39:53 -04:00
bryanthaboi 917735a41c Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-17 20:15:40 -04:00
bryanthaboi c877ea80a7 importer for skins now w file picker 2026-08-17 20:15:39 -04:00
github-actions 7d1ddf9b7c chore(ios): update app-repo.json [skip ci] 2026-08-17 20:09:51 -04:00
bryanthaboi faf82c2cec Merge pull request #1491 from AverageConsumer/codex/gen2-move-grid-hook
fix(gen2): honor mod move-grid navigation
2026-08-17 19:58:35 -04:00
bryanthaboi 5bc6036735 Merge pull request #1420 from anxiousintrovert/fix/required-import-platform-pickers
Fix required imports on Android legacy picker bridges
2026-08-17 19:58:13 -04:00
bryanthaboi 809628f0fa Merge pull request #1494 from AverageConsumer/codex/gen2-catch-previews
feat(gen2): expose stock ball catch previews
2026-08-17 19:57:37 -04:00
bryanthaboi 4817ff8bf9 Merge pull request #1492 from thibautbus/fix/museum-1f-ticket-clerk-strings
Route the museum 1F ticket clerk's remaining lines through Strings
2026-08-17 19:57:29 -04:00
anxiousintrovert 4f8739c029 Fix required imports on legacy Android picker bridges 2026-08-17 18:35:25 -05:00
AverageConsumer c655217120 feat(gen2): expose stock ball catch previews 2026-08-17 23:00:38 +02:00
thibautbus fbdfc1c053 Cover the museum 1F ticket clerk's translated lines with a targeted test 2026-08-17 21:44:43 +02:00
thibautbus 72c244b433 Route the museum 1F ticket clerk's remaining lines through Strings 2026-08-17 21:38:04 +02:00
AverageConsumer e0e030003b fix(gen2): honor mod move-grid navigation 2026-08-17 20:38:48 +02:00
github-actions ce2afb83f1 chore(ios): update app-repo.json [skip ci] 2026-08-17 14:13:48 -04:00
25 changed files with 1206 additions and 112 deletions
+6 -3
View File
@@ -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
+3 -2
View File
@@ -540,8 +540,9 @@ gains a field instead of the name gaining a prefix.
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible` and
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
`battle.catch_exp`, `battle.bottom_ui_visible`,
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
difference: Gen 1's vanilla
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
+4 -4
View File
@@ -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
+11 -4
View File
@@ -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.
+39 -3
View File
@@ -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
+14
View File
@@ -12,6 +12,20 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.2",
"date": "2026-08-18",
"size": 13575387,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
},
{
"version": "0.2.1",
"date": "2026-08-17",
"size": 13575320,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.2.0",
"date": "2026-08-17",
+21 -3
View File
@@ -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 }
+9
View File
@@ -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:
+109
View File
@@ -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
+73 -33
View File
@@ -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)
+148 -10
View File
@@ -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
@@ -1089,6 +1099,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.
@@ -1471,14 +1514,23 @@ 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 }
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
@@ -1488,11 +1540,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)
@@ -1504,6 +1557,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)
@@ -2024,7 +2084,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 +2103,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
@@ -2498,6 +2570,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 +2597,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 +3126,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 +3158,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
+98 -18
View File
@@ -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
+55 -31
View File
@@ -44,6 +44,12 @@ local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
function BattleState:moveGridNavigation()
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
return Runtime.call("battle.move_grid_navigation", function() return false end,
self) == true
end
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
-- positive value means "hold until A/B"; the cart never times these out, so
-- the victory jingle can keep looping through the post-win prompts.
@@ -2043,7 +2049,22 @@ function BattleState:update(_dt)
if self.phase == "moves" then
local moves = self:playerMoves()
if input:wasPressed("up") then
local grid
if self:moveGridNavigation() then
local index, count = self.moveIndex, #moves
if input:wasPressed("left") or input:wasPressed("right") then
local other = math.floor((index - 1) / 2) * 2
+ (1 - (index - 1) % 2) + 1
grid = other <= count and other or index
elseif input:wasPressed("up") or input:wasPressed("down") then
local other = (1 - math.floor((index - 1) / 2)) * 2
+ (index - 1) % 2 + 1
grid = other <= count and other or index
end
end
if grid then
self.moveIndex = grid
elseif input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
@@ -2945,6 +2966,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 +3026,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 +3039,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
+15
View File
@@ -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
+51
View File
@@ -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
+120
View File
@@ -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
+1
View File
@@ -421,6 +421,7 @@ local GEN2_HOOKS = {
-- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
"battle.bottom_ui_visible", "battle.status_hud_visible",
"battle.move_grid_navigation",
-- One pic path resolver for both games: the Gen 1 site is the SHARED
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
@@ -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")
+118
View File
@@ -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()
+137
View File
@@ -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")
+5
View File
@@ -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",
+10
View File
@@ -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()
+13 -1
View File
@@ -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")
+36
View File
@@ -266,13 +266,49 @@ end
do
local BattleState = require("src.battle.BattleState")
local Gen2BattleState = require("src.ui.gen2.BattleState")
local battle = { wideLayout = function() return false end }
check(not BattleState.moveGridNavigation(battle),
"classic move navigation stays a list without a mod")
check(not Gen2BattleState.moveGridNavigation({}),
"Gold move navigation stays a list without a mod")
local unsub = wrap("battle.move_grid_navigation", function() return true end)
check(BattleState.moveGridNavigation(battle),
"a mod can opt the classic move menu into grid navigation")
check(Gen2BattleState.moveGridNavigation({}),
"the same hook opts Gold's move menu into grid navigation")
local pressed, moveCount = "right", 4
local gold = setmetatable({
phase = "moves", moveIndex = 1,
slideFrame = math.huge,
game = { input = {
wasPressed = function(_, key) return key == pressed end,
} },
updateAlarm = function() end,
stepHpAnim = function() return false end,
playerMoves = function()
local moves = {}
for i = 1, moveCount do moves[i] = {} end
return moves
end,
}, { __index = Gen2BattleState })
gold:update(0)
check(gold.moveIndex == 2,
"Gold grid navigation moves right across a companion move row")
pressed, gold.moveIndex = "down", 1
gold:update(0)
check(gold.moveIndex == 3,
"Gold grid navigation moves down the companion move column")
pressed, moveCount, gold.moveIndex = "down", 3, 2
gold:update(0)
check(gold.moveIndex == 2,
"Gold grid navigation does not select an empty fourth move slot")
unsub()
pressed, gold.moveIndex = "right", 1
gold:update(0)
check(gold.moveIndex == 1,
"removing the hook restores Gold's native vertical move list")
battle.wideLayout = function() return true end
check(BattleState.moveGridNavigation(battle),
"the native wide move grid remains enabled without a mod")
@@ -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()