Compare commits

...

18 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
bryanthaboi 28f741f72f Merge pull request #1489 from bryanthaboi/dev
Update LauncherView.lua
2026-08-17 14:04:35 -04:00
bryanthaboi 22bcd95da1 Update LauncherView.lua 2026-08-17 14:03:07 -04:00
github-actions ea28f886f3 chore(ios): update app-repo.json [skip ci] 2026-08-17 13:20:07 -04:00
25 changed files with 1214 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 {} local t = game.data.text or {}
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
game.stack:push(TextBox.new(game, 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 return
end end
-- scripts/Museum1F.asm:72 -- scripts/Museum1F.asm:72
@@ -751,10 +752,12 @@ local function museumClerk(game, ow, done, onDecline)
{ money = money })) { money = money }))
elseif yes then elseif yes then
game.stack:push(TextBox.new(game, 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 else
game.stack:push(TextBox.new(game, 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 })) 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.crit`, `battle.accuracy`, `battle.turn_order`,
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
`battle.catch_exp`, `battle.bottom_ui_visible` and `battle.catch_exp`, `battle.bottom_ui_visible`,
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla `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 `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 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 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`, `prompt` describes the currently visible choice (`menu`, `moves`, `party`,
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle `advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
phase owns input. Generation-specific features remain optional: Gen 1 includes phase owns input. Generation-specific features remain optional: Gen 1 includes
battle medicine, balls, catch previews, Safari balls, and Mimic choices; battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold
Gold currently returns an empty `items` list rather than guessing at its exposes balls and their exact stock catch previews; targeted medicine remains
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent screen-owned and is omitted rather than guessing at its pocketed PACK flow.
optional ones. Callers should ignore unknown fields and tolerate absent optional ones.
## Battle menu intents ## 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), (draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/ui/SkinStudio.lua` (the desktop editor). Tests: `src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`, `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`. `tests/engine/launcher_skins_tab.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and 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 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 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 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 per page. The cutout is itself a draggable element with a 10:9 lock.
or JPG on the window to import art into the skin.
**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 **Testing.** **Test** makes the canvas live: clicking presses real Game Boy
buttons and the footer reports what is held. **Play** saves the skin, selects 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 ## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types. 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 TouchEditor then return TouchEditor.update(dt) end
if Studio then return Studio.update(dt) end if Studio then return Studio.update(dt) end
if Importer then return Importer: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 -- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
-- once per Game:update, so they must keep a 1:1 relationship with the -- 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 TouchEditor then return TouchEditor.keypressed(key) end
if Studio then return Studio.keypressed(key) end if Studio then return Studio.keypressed(key) end
if Importer then return Importer:keypressed(key) end if Importer then return Importer:keypressed(key) end
if not Game then return end
Game:keypressed(key) Game:keypressed(key)
end end
function love.keyreleased(key) function love.keyreleased(key)
if editorMode or TouchEditor or Studio then return end if editorMode or TouchEditor or Studio then return end
if Importer then return end if Importer then return end
if not Game then return end
Game:keyreleased(key) Game:keyreleased(key)
end end
@@ -625,7 +628,9 @@ function love.gamepadpressed(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:gamepadpressed(joystick, button) end if Importer then return Importer:gamepadpressed(joystick, button) end
if not Game then return end
Game:gamepadpressed(joystick, button) Game:gamepadpressed(joystick, button)
end end
@@ -643,7 +648,9 @@ function love.gamepadreleased(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:gamepadreleased(joystick, button) end if Importer then return Importer:gamepadreleased(joystick, button) end
if not Game then return end
Game:gamepadreleased(joystick, button) Game:gamepadreleased(joystick, button)
end end
@@ -661,7 +668,9 @@ function love.gamepadaxis(joystick, axis, value)
end end
return return
end end
if Studio then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end if Importer then return Importer:gamepadaxis(joystick, axis, value) end
if not Game then return end
Game:gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value)
end end
@@ -679,7 +688,9 @@ function love.joystickpressed(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickpressed(joystick, button) end if Importer then return Importer:joystickpressed(joystick, button) end
if not Game then return end
Game:joystickpressed(joystick, button) Game:joystickpressed(joystick, button)
end end
@@ -697,7 +708,9 @@ function love.joystickreleased(joystick, button)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickreleased(joystick, button) end if Importer then return Importer:joystickreleased(joystick, button) end
if not Game then return end
Game:joystickreleased(joystick, button) Game:joystickreleased(joystick, button)
end end
@@ -715,7 +728,9 @@ function love.joystickaxis(joystick, axis, value)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickaxis(joystick, axis, value) end if Importer then return Importer:joystickaxis(joystick, axis, value) end
if not Game then return end
Game:joystickaxis(joystick, axis, value) Game:joystickaxis(joystick, axis, value)
end end
@@ -733,21 +748,25 @@ function love.joystickhat(joystick, hat, direction)
end end
return return
end end
if Studio then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end if Importer then return Importer:joystickhat(joystick, hat, direction) end
if not Game then return end
Game:joystickhat(joystick, hat, direction) Game:joystickhat(joystick, hat, direction)
end end
function love.joystickadded(joystick) function love.joystickadded(joystick)
SwitchDiagnostics.onJoystickEvent("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 Importer then return end
if not Game then return end
Game:joystickadded(joystick) Game:joystickadded(joystick)
end end
function love.joystickremoved(joystick) function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("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 Importer then return end
if not Game then return end
Game:joystickremoved(joystick) Game:joystickremoved(joystick)
end end
@@ -756,26 +775,36 @@ end
-- unfocused, so reset input on either transition rather than trust it. -- unfocused, so reset input on either transition rather than trust it.
function love.focus(f) function love.focus(f)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Studio then
if Studio.focus then Studio.focus(f) end
return
end
if Importer then if Importer then
require("src.core.Input"):reset() require("src.core.Input"):reset()
if Importer.focus then Importer:focus(f) end if Importer.focus then Importer:focus(f) end
return return
end end
if not Game then return end
Game:focus(f) Game:focus(f)
end end
-- v is true when the window becomes visible again, false on minimize. -- v is true when the window becomes visible again, false on minimize.
function love.visible(v) function love.visible(v)
if editorMode or TouchEditor then return end if editorMode or TouchEditor then return end
if Studio then
if Studio.visible then Studio.visible(v) end
return
end
if Importer then if Importer then
require("src.core.Input"):reset() require("src.core.Input"):reset()
return return
end end
if not Game then return end
Game:visible(v) Game:visible(v)
end end
function love.lowmemory() 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 if Game then Game:onResume() end
end end
@@ -796,12 +825,14 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y) return TouchEditor.touchpressed(id, x, y)
end end
if Studio then return end
if Importer then if Importer then
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops -- polled inside the view; the istouch filter on mousepressed still drops
-- Android's synthesized mouse twin so Import cannot double-fire (#553). -- Android's synthesized mouse twin so Import cannot double-fire (#553).
return Importer:touchpressed(id, x, y, dx, dy, pressure) return Importer:touchpressed(id, x, y, dx, dy, pressure)
end end
if not Game then return end
Game:touchpressed(id, x, y, dx, dy, pressure) Game:touchpressed(id, x, y, dx, dy, pressure)
end end
@@ -811,9 +842,11 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y) return TouchEditor.touchmoved(id, x, y)
end end
if Studio then return end
if Importer then if Importer then
return Importer:touchmoved(id, x, y, dx, dy, pressure) return Importer:touchmoved(id, x, y, dx, dy, pressure)
end end
if not Game then return end
Game:touchmoved(id, x, y, dx, dy, pressure) Game:touchmoved(id, x, y, dx, dy, pressure)
end end
@@ -823,9 +856,11 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
if love.system.getOS() == "iOS" then return end if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y) return TouchEditor.touchreleased(id, x, y)
end end
if Studio then return end
if Importer then if Importer then
return Importer:touchreleased(id, x, y, dx, dy, pressure) return Importer:touchreleased(id, x, y, dx, dy, pressure)
end end
if not Game then return end
Game:touchreleased(id, x, y, dx, dy, pressure) Game:touchreleased(id, x, y, dx, dy, pressure)
end end
@@ -837,6 +872,7 @@ function love.wheelmoved(x, y)
if TouchEditor then return end if TouchEditor then return end
if Studio then return Studio.wheelmoved(x, y) end if Studio then return Studio.wheelmoved(x, y) end
if Importer then return end if Importer then return end
if not Game then return end
Game:wheelmoved(x, y) Game:wheelmoved(x, y)
end end
+21
View File
@@ -12,6 +12,27 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "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",
"size": 13575299,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.1.99", "version": "0.1.99",
"date": "2026-08-17", "date": "2026-08-17",
+21 -3
View File
@@ -39,6 +39,21 @@ local function messageCopy(screen)
return #lines > 0 and lines or nil return #lines > 0 and lines or nil
end 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) local function signature(game, screen, top)
if not screen then return "none" end if not screen then return "none" end
local battle = screen.battle or {} 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.hp)
parts[#parts + 1] = tostring(mon.status) parts[#parts + 1] = tostring(mon.status)
end end
for _, item in ipairs(itemCopies(game, screen, false)) do
parts[#parts + 1] = item.id .. "=" .. tostring(item.count)
end
return table.concat(parts, "|") return table.concat(parts, "|")
end end
@@ -111,9 +129,9 @@ function BattleAPI:snapshot()
player = monCopy(game.data, battle.player, true), player = monCopy(game.data, battle.player, true),
enemy = monCopy(game.data, battle.enemy, true), enemy = monCopy(game.data, battle.enemy, true),
party = party, moves = moveCopies(game, battle), party = party, moves = moveCopies(game, battle),
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it -- Targeted medicine remains screen-owned, but balls are complete semantic
-- until the engine can expose the same semantic item records as Gen 1. -- records and can safely expose the same read-only preview as Gen 1.
items = {} } items = itemCopies(game, screen, battle.wild and not screen.tutorial) }
end end
local MENU_CHOICES = { fight = true, party = true, item = true, run = true } 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 return math.min(255, rate), false
end 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 -- 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 -- 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: -- 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
+74 -33
View File
@@ -2017,6 +2017,23 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
local gap = m.gap local gap = m.gap
local cy = y 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 if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w, 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) 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) cy = cy + rowH + math.floor(4 * m.s)
end end
skinRow("skin-none", nil, Strings("Built-in pad"), local entries = { false }
Strings("The default on-screen buttons."), active == nil, for _, entry in ipairs(skins) do entries[#entries + 1] = entry end
imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red")
end or nil)
for _, entry in ipairs(skins) do local TouchSkin = require("src.core.TouchSkin")
local bits = {} local hint = Strings(
bits[#bits + 1] = entry.source == "user" and Strings("installed") "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.",
or Strings("bundled") TouchSkin.USER_ROOT)
if entry.controls > 0 then local hintH = Kit.wrapHeight("small", hint, w, 3)
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons") 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 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 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 end
if #skins == 0 then 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 cy = cy + math.floor(72 * m.s) + gap
end end
cy = cy + math.floor(6 * m.s) if pages > 1 then
local TouchSkin = require("src.core.TouchSkin") setPage(imp, "skins",
Kit.caption(x, cy, Strings("IMPORT")) Kit.pager(x, cy, w, cur, #entries, perPage, "skins"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) cy = cy + pagerH + gap
local boxH = math.floor(76 * m.s) end
Kit.card(x, cy, w, boxH, "muted")
Kit.textWrapped("small", Strings( cy = cy + math.floor(10 * m.s)
"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."), Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
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)
end end
local function buildFindPanel(imp, x, y, w, availH, m) local function buildFindPanel(imp, x, y, w, availH, m)
@@ -3850,6 +3890,7 @@ local function modalUp(imp)
or imp._appPatchNotes or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup
or imp._modActions or imp._modImports or imp._modActions or imp._modImports
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
+148 -10
View File
@@ -988,8 +988,18 @@ local function pickerHasKind(kind)
return false return false
end end
local function findPendingRequiredImport() local function findPendingRequiredImport(self)
local names = { "picked_required_import.bin", "picked_stadium.z64" } 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 for _, name in ipairs(names) do
if love.filesystem.getInfo(name, "file") then return name end if love.filesystem.getInfo(name, "file") then return name end
end end
@@ -1089,6 +1099,39 @@ local function chooseZip()
return nil return nil
end 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 -- 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 -- dialogs). Returns the chosen absolute path or nil. Android uses
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport. -- 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 " local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: " .. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory() .. love.filesystem.getSaveDirectory()
local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import"
if pickError:find("picked_required_import", 1, true) 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.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingModId = nil self.pickerPendingModId = nil
self.pickerPendingImportId = nil self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
elseif pickError:find("picked_mod", 1, true) then 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 elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget() local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil self.androidPendingVersion = nil
@@ -1488,11 +1540,12 @@ function RomImporter:focus(f)
end end
return return
end end
local requiredName = findPendingRequiredImport() local requiredName = findPendingRequiredImport(self)
if requiredName then if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingModId, self.pickerPendingImportId = nil, nil self.pickerPendingModId, self.pickerPendingImportId = nil, nil
self.requiredImportLegacyRomPick = nil
local imported = modId and importId local imported = modId and importId
and self:_importRequiredSource(modId, importId, requiredName) and self:_importRequiredSource(modId, importId, requiredName)
consumePick(self, requiredName, requiredName, imported) consumePick(self, requiredName, requiredName, imported)
@@ -1504,6 +1557,13 @@ function RomImporter:focus(f)
end end
local modName = findPendingMod(false, self.pickSkip) local modName = findPendingMod(false, self.pickSkip)
if modName then 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) self:_installMod(modName)
consumePick(self, modName, "picked_mod.zip", consumePick(self, modName, "picked_mod.zip",
self.modNotice and self.modNotice.ok) self.modNotice and self.modNotice.ok)
@@ -2024,7 +2084,17 @@ function RomImporter:chooseRequiredImport(modId, importId)
return return
end end
if self.nativePicker then 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, requiredImportNotice(self, modId, importId,
"This app build cannot pick required mod files yet. Update the app and try again.") "This app build cannot pick required mod files yet. Update the app and try again.")
self.modNotice = nil self.modNotice = nil
@@ -2033,10 +2103,12 @@ function RomImporter:chooseRequiredImport(modId, importId)
self.pickerPendingKind = "required_import" self.pickerPendingKind = "required_import"
self.pickerPendingModId = modId self.pickerPendingModId = modId
self.pickerPendingImportId = importId 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.pickerPendingKind = nil
self.pickerPendingModId = nil self.pickerPendingModId = nil
self.pickerPendingImportId = nil self.pickerPendingImportId = nil
self.requiredImportLegacyRomPick = nil
requiredImportNotice(self, modId, importId, "Could not open the file picker.") requiredImportNotice(self, modId, importId, "Could not open the file picker.")
self.modNotice = nil self.modNotice = nil
elseif self.android then elseif self.android then
@@ -2498,6 +2570,11 @@ function RomImporter:update(dt)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path) os.remove(path)
end 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 elseif kind == "sav" then
local target = version or self:_savedropTarget() local target = version or self:_savedropTarget()
self:_importSave(target, path) self:_importSave(target, path)
@@ -2520,6 +2597,8 @@ function RomImporter:update(dt)
self.pickerPendingModId, self.pickerPendingImportId = nil, nil self.pickerPendingModId, self.pickerPendingImportId = nil, nil
elseif kind == "mod" then elseif kind == "mod" then
self.modNotice = { ok = false, text = errorText } self.modNotice = { ok = false, text = errorText }
elseif kind == "skin" then
self._skinNotice = { ok = false, text = errorText }
elseif kind == "sav" then elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText } self.saveNotice[version] = { ok = false, text = errorText }
else else
@@ -3047,13 +3126,27 @@ function RomImporter:_useSkin(id)
} }
end 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 TouchSkin = require("src.core.TouchSkin")
local name = file:getFilename() or "" local name, data, readError
local data, readError = readDroppedFile(file) 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 if not data then
self._skinNotice = { ok = false, 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 return
end end
local id, err = TouchSkin.installArchive(name, data) local id, err = TouchSkin.installArchive(name, data)
@@ -3065,6 +3158,51 @@ function RomImporter:_installSkinZip(file)
self._skinNotice = { ok = true, text = "Imported " .. id } self._skinNotice = { ok = true, text = "Imported " .. id }
end 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() function RomImporter:_toggleFindSearchFocus()
self._findSearchFocus = not self._findSearchFocus self._findSearchFocus = not self._findSearchFocus
if self._findSearchFocus then if self._findSearchFocus then
+98 -18
View File
@@ -4,6 +4,7 @@ local PAL = Theme.PAL
local TouchSkin = require("src.core.TouchSkin") local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local FilePicker = require("src.core.FilePicker")
local Studio = {} local Studio = {}
@@ -214,12 +215,19 @@ function Studio.cycleImage(dir)
markDirty() markDirty()
end 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) function Studio.assignImage(rel)
local page, ctl = Studio.page(), Studio.selectedControl() local page, ctl = Studio.page(), Studio.selectedControl()
local img = TouchSkin.resolveImage(Studio.skin.root, rel) 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 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 ctl.imagePath, ctl.image = rel, img
elseif page then elseif page then
page.imagePath, page.image = rel, img page.imagePath, page.image = rel, img
@@ -228,11 +236,69 @@ function Studio.assignImage(rel)
Studio.dirty = true Studio.dirty = true
end 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) function Studio.filedropped(file)
if not Studio.skin then return end if not Studio.skin then return end
local path = (file.getFilename and file:getFilename()) or "" local path = (file.getFilename and file:getFilename()) or ""
local base = path:match("([^/\\]+)$") or path local base = FilePicker.basename(path)
if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then if not FilePicker.matches(base, FilePicker.IMAGE) then
Studio.status = "Drop a PNG or JPG to use it as art." Studio.status = "Drop a PNG or JPG to use it as art."
return return
end end
@@ -246,17 +312,7 @@ function Studio.filedropped(file)
Studio.status = "Could not read " .. base Studio.status = "Could not read " .. base
return return
end end
local rel, err = TouchSkin.importImage(Studio.skin, base, data) Studio.adoptImage(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
end end
function Studio.detectViewport() function Studio.detectViewport()
@@ -617,10 +673,16 @@ local function inspectorBody(x, y, w)
if page then if page then
local bezel = page.imagePath or "(none)" 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.imageTarget = "bezel"
Studio.cycleImage(1) Studio.cycleImage(1)
end end
if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import",
{ id = "bezelpick" }) then
Studio.importImageFile("bezel")
end
cy = cy + rowH + gap cy = cy + rowH + gap
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF" local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", 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) Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint)
cy = cy + Kit.textHeight("small") + gap cy = cy + Kit.textHeight("small") + gap
local pickW = 82 * Kit.scale
local artW = w - pickW - gap
local idle = ctl.imagePath or "(none)" 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.imageTarget = "idle"
Studio.cycleImage(1) Studio.cycleImage(1)
end end
if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then
Studio.importImageFile("idle")
end
cy = cy + rowH + gap cy = cy + rowH + gap
local pressed = ctl.pressedImagePath or "(none)" 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.imageTarget = "pressed"
Studio.cycleImage(1) Studio.cycleImage(1)
end end
if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then
Studio.importImageFile("pressed")
end
return cy + rowH return cy + rowH
end end
@@ -885,6 +955,16 @@ function Studio.wheelmoved(_, dy)
Studio.wheel = dy Studio.wheel = dy
end 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) function Studio.textinput(text)
Kit.textinput(text) Kit.textinput(text)
end end
+55 -31
View File
@@ -44,6 +44,12 @@ local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true 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 -- 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 -- 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. -- the victory jingle can keep looping through the post-win prompts.
@@ -2043,7 +2049,22 @@ function BattleState:update(_dt)
if self.phase == "moves" then if self.phase == "moves" then
local moves = self:playerMoves() 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 self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1 self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
@@ -2945,6 +2966,37 @@ function BattleState:openDexEntry(species)
}) })
end 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 -- 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 -- 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 -- the field pack runs. Anything else reports that it cannot be used, which is
@@ -2974,7 +3026,6 @@ function BattleState:useItem(itemId)
return return
end end
local enemy = self.battle.enemy local enemy = self.battle.enemy
local enemyDef = data.pokemon and data.pokemon[enemy.species]
local caught, rate local caught, rate
if self.tutorial then if self.tutorial then
-- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL / -- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL /
@@ -2988,35 +3039,8 @@ function BattleState:useItem(itemId)
caught, rate = true, 255 caught, rate = true, 255
else else
-- The specialty-ball conditions (BallMultiplierFunctionTable): each one -- The specialty-ball conditions (BallMultiplierFunctionTable): each one
-- is something this screen already knows. Heavy Ball reads the dex -- is also used by the read-only preview, so both paths stay exact.
-- weight, Moon Ball the species' stone row, Love Ball both genders, caught, rate = Catching.attempt(self:catchOptions(itemId))
-- 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,
})
end end
-- wWildMon carries the answer through the animation, and -- wWildMon carries the answer through the animation, and
-- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per -- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per
+15
View File
@@ -94,6 +94,21 @@ return function(game)
imp:_useSkin(nil) imp:_useSkin(nil)
shot("skins_tab_imported.png") 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") U.log("done")
love.event.quit() love.event.quit()
while true do coroutine.yield() end 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). -- nextFn gets nil there).
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay", "battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
"battle.bottom_ui_visible", "battle.status_hud_visible", "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 -- 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 -- 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 -- 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({ local asleepRate = Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" }) maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" })
check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10)) 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. -- The cart's bug: burn/poison/paralysis add nothing.
check("poison adds nothing (cart bug)", Catching.rate({ check("poison adds nothing (cart bug)", Catching.rate({
maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL",
+10
View File
@@ -282,6 +282,16 @@ local function runToMenu(screen, cap)
return false return false
end 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 --------------------------------------- -- ---- BattleMenu empties the textbox ---------------------------------------
do do
local screen = newScreen() local screen = newScreen()
+13 -1
View File
@@ -163,14 +163,23 @@ function screen2:chooseMove(slot)
return true return true
end end
function screen2:cancelMove() self.phase = "menu" 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 = { local game2 = {
data = { data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" }, pokemon = { CHIKORITA = { name = "CHIKORITA" },
RATTATA = { name = "RATTATA" } }, RATTATA = { name = "RATTATA" } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
power = 35, accuracy = 95, pp = 35 } }, 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) 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") "Gold battle is discovered through its screen id")
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field") eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied") 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.player.hp = 0
snapshot2.moves[1].pp = 0 snapshot2.moves[1].pp = 0
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon") eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
+36
View File
@@ -266,13 +266,49 @@ end
do do
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
local Gen2BattleState = require("src.ui.gen2.BattleState")
local battle = { wideLayout = function() return false end } local battle = { wideLayout = function() return false end }
check(not BattleState.moveGridNavigation(battle), check(not BattleState.moveGridNavigation(battle),
"classic move navigation stays a list without a mod") "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) local unsub = wrap("battle.move_grid_navigation", function() return true end)
check(BattleState.moveGridNavigation(battle), check(BattleState.moveGridNavigation(battle),
"a mod can opt the classic move menu into grid navigation") "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() 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 battle.wideLayout = function() return true end
check(BattleState.moveGridNavigation(battle), check(BattleState.moveGridNavigation(battle),
"the native wide move grid remains enabled without a mod") "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, check(love.filesystem.getInfo("picked_required_import.bin") == nil,
"focus removes the staged required-file pick") "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.getOS = saved.getOS
love.system.pickFile = saved.pickFile love.system.pickFile = saved.pickFile
love.system.pickFileKinds = saved.pickFileKinds 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_mod.zip")
love.filesystem.remove("picked_save.sav") love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin") love.filesystem.remove("picked_required_import.bin")
love.filesystem.remove("picked_rom.gb")
S.finish() S.finish()