From 72c244b43399e866d5c04a30c8d351d4d58f8dbb Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:38:04 +0200 Subject: [PATCH 1/4] Route the museum 1F ticket clerk's remaining lines through Strings --- data/scripts/story2.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 094bc011..2ab2a715 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -733,7 +733,8 @@ local function museumClerk(game, ow, done, onDecline) local t = game.data.text or {} if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then game.stack:push(TextBox.new(game, - "Take your time,\nand enjoy it all!", done)) + t._Museum1FScientist1TakePlentyOfTimeText + or "Take your time,\nand enjoy it all!", done)) return end -- scripts/Museum1F.asm:72 @@ -751,10 +752,12 @@ local function museumClerk(game, ow, done, onDecline) { money = money })) elseif yes then game.stack:push(TextBox.new(game, - "You don't have\nenough money.", onDecline or done, { money = money })) + t._Museum1FScientist1DontHaveEnoughMoneyText + or "You don't have\nenough money.", onDecline or done, { money = money })) else game.stack:push(TextBox.new(game, - "Come again!", onDecline or done, { money = money })) + t._Museum1FScientist1ComeAgainText + or "Come again!", onDecline or done, { money = money })) end end })) end From fbdfc1c0530fd36816b7b2e537905dbcae6615b4 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:44:43 +0200 Subject: [PATCH 2/4] Cover the museum 1F ticket clerk's translated lines with a targeted test --- .../museum_1f_clerk_translation_test.lua | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/engine/museum_1f_clerk_translation_test.lua diff --git a/tests/engine/museum_1f_clerk_translation_test.lua b/tests/engine/museum_1f_clerk_translation_test.lua new file mode 100644 index 00000000..5330fdd0 --- /dev/null +++ b/tests/engine/museum_1f_clerk_translation_test.lua @@ -0,0 +1,68 @@ +-- The museum 1F ticket clerk's take-your-time/not-enough-money/come-again +-- lines used to be bare English literals, invisible to game.data.text no +-- matter what a translation mod put there. tests/engine/museum_money_box_ +-- bug1335.lua only ever runs with an empty game.data.text, so it can't +-- tell a properly-wired t._Key or "..." fallback apart from a literal that +-- never looked at t at all -- every assertion there passes either way. +-- This test populates game.data.text with translated values and checks +-- they actually reach the pushed TextBox, for all three lines. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} + +local M = assert(loadfile("data/scripts/story2.lua"))() +local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1 + +local TRANSLATED = { + _Museum1FScientist1TakePlentyOfTimeText = "Prends ton temps\net profite bien !", + _Museum1FScientist1DontHaveEnoughMoneyText = "Tu n'as pas assez\nd'argent.", + _Museum1FScientist1ComeAgainText = "Reviens vite !", +} + +local pushed +local function mkGame(cash) + pushed = {} + return { + data = { text = TRANSLATED }, + save = { money = cash, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, + } +end + +-- already ticketed: take-your-time line +local g = mkGame(3000) +g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g, nil, nil, function() end) +T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1TakePlentyOfTimeText, + "an existing ticket holder gets the translated take-your-time line") + +-- YES but short on cash: not-enough-money line +g = mkGame(20) +clerk(g, nil, nil, function() end) +pushed[1].opts.choice(true) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1DontHaveEnoughMoneyText, + "short on cash opens the translated not-enough-money box") + +-- NO: come-again line +g = mkGame(3000) +clerk(g, nil, nil, function() end) +pushed[1].opts.choice(false) +T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1ComeAgainText, + "declining opens the translated come-again box") + +-- unpopulated game.data.text still falls back to the English literal +g = { data = { text = {} }, save = { money = 3000, flags = {} }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end } } +pushed = {} +g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true +clerk(g, nil, nil, function() end) +T.check(tostring(pushed[1].text):find("Take your time", 1, true) ~= nil, + "an empty catalog still falls back to the English take-your-time line") + +T.finish("museum_1f_clerk_translation_test") From c655217120f861ca7b73069d50d044d3e6b97412 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:00:38 +0200 Subject: [PATCH 3/4] feat(gen2): expose stock ball catch previews --- docs/modding.md | 8 ++-- src/battle/gen2/BattleAPI.lua | 24 ++++++++++-- src/battle/gen2/Catching.lua | 9 +++++ src/ui/gen2/BattleState.lua | 63 ++++++++++++++++-------------- tests/gen2_battle_test.lua | 5 +++ tests/gen2_battle_ui_test.lua | 10 +++++ tests/mod_battle_snapshot_test.lua | 14 ++++++- 7 files changed, 95 insertions(+), 38 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index e7c32c39..c487e5c2 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -268,10 +268,10 @@ an optional stock `catchChance` percentage. `prompt` describes the currently visible choice (`menu`, `moves`, `party`, `advance`, `safari`, or `mimic`) and is `locked` when another screen or battle phase owns input. Generation-specific features remain optional: Gen 1 includes -battle medicine, balls, catch previews, Safari balls, and Mimic choices; -Gold currently returns an empty `items` list rather than guessing at its -pocketed PACK flow. Callers should ignore unknown fields and tolerate absent -optional ones. +battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold +exposes balls and their exact stock catch previews; targeted medicine remains +screen-owned and is omitted rather than guessing at its pocketed PACK flow. +Callers should ignore unknown fields and tolerate absent optional ones. ## Battle menu intents diff --git a/src/battle/gen2/BattleAPI.lua b/src/battle/gen2/BattleAPI.lua index e8381964..d5ef773f 100644 --- a/src/battle/gen2/BattleAPI.lua +++ b/src/battle/gen2/BattleAPI.lua @@ -39,6 +39,21 @@ local function messageCopy(screen) return #lines > 0 and lines or nil end +local function itemCopies(game, screen, catchable) + local out = {} + for id, count in pairs((game.save and game.save.inventory) or {}) do + local def = game.data.items and game.data.items[id] + if count > 0 and def and def.pocket == "BALL" then + out[#out + 1] = { id = id, name = def.name or id, count = count, + ball = true, needsTarget = false, + catchChance = catchable and screen.catchChance + and screen:catchChance(id) or nil } + end + end + table.sort(out, function(a, b) return a.name < b.name end) + return out +end + local function signature(game, screen, top) if not screen then return "none" end local battle = screen.battle or {} @@ -56,6 +71,9 @@ local function signature(game, screen, top) parts[#parts + 1] = tostring(mon.hp) parts[#parts + 1] = tostring(mon.status) end + for _, item in ipairs(itemCopies(game, screen, false)) do + parts[#parts + 1] = item.id .. "=" .. tostring(item.count) + end return table.concat(parts, "|") end @@ -111,9 +129,9 @@ function BattleAPI:snapshot() player = monCopy(game.data, battle.player, true), enemy = monCopy(game.data, battle.enemy, true), party = party, moves = moveCopies(game, battle), - -- Gold's PACK is pocketed and target selection is screen-owned. Omit it - -- until the engine can expose the same semantic item records as Gen 1. - items = {} } + -- Targeted medicine remains screen-owned, but balls are complete semantic + -- records and can safely expose the same read-only preview as Gen 1. + items = itemCopies(game, screen, battle.wild and not screen.tutorial) } end local MENU_CHOICES = { fight = true, party = true, item = true, run = true } diff --git a/src/battle/gen2/Catching.lua b/src/battle/gen2/Catching.lua index bfd54b30..8284add5 100644 --- a/src/battle/gen2/Catching.lua +++ b/src/battle/gen2/Catching.lua @@ -305,6 +305,15 @@ function Catching.rate(opts) return math.min(255, rate), false end +-- Exact stock catch probability for read-only previews. A catch.rate hook +-- may replace the roll entirely, so nil is safer than presenting a guess. +function Catching.chance(opts) + if Runtime.wantsHook("catch.rate") then return nil end + local rate, guaranteed = Catching.rate(opts) + if guaranteed or rate >= 255 then return 100 end + return rate * 100 / 256 +end + -- The status half of the rate, off the merged `statuses` record the same way -- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records -- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers: diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index cd1b7d15..c2d33a5d 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -2945,6 +2945,37 @@ function BattleState:openDexEntry(species) }) end +function BattleState:catchOptions(itemId) + local data = self.game and self.game.data or {} + local battle = self.battle + local enemy = battle and battle.enemy + if not enemy then return nil end + local enemyDef = data.pokemon and data.pokemon[enemy.species] + local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species] + local evolveItem + for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do + if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end + end + local player = battle.player + return { + battle = battle, mon = enemy, def = enemyDef, + maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), hp = enemy.hp, + catchRate = enemyDef and enemyDef.catchRate or 45, ball = itemId, + status = enemy.status, random = battle.random, + weight = dexEntry and dexEntry.weight, level = enemy.level, + playerLevel = player and player.level, + fishing = battle.battleType == "fish", species = enemy.species, + gender = enemy.gender, playerSpecies = player and player.species, + playerGender = player and player.gender, evolveItem = evolveItem, + } +end + +function BattleState:catchChance(itemId) + if self.tutorial then return 100 end + local opts = self:catchOptions(itemId) + return opts and Catching.chance(opts) or nil +end + -- Items in battle: balls try a catch, the stat items apply their stage, and -- everything with a ported party effect runs the same item_effects.asm routine -- the field pack runs. Anything else reports that it cannot be used, which is @@ -2974,7 +3005,6 @@ function BattleState:useItem(itemId) return end local enemy = self.battle.enemy - local enemyDef = data.pokemon and data.pokemon[enemy.species] local caught, rate if self.tutorial then -- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL / @@ -2988,35 +3018,8 @@ function BattleState:useItem(itemId) caught, rate = true, 255 else -- The specialty-ball conditions (BallMultiplierFunctionTable): each one - -- is something this screen already knows. Heavy Ball reads the dex - -- weight, Moon Ball the species' stone row, Love Ball both genders, - -- Level Ball the two levels, Lure Ball wBattleType. - local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species] - local evolveItem - for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do - if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end - end - local player = self.battle.player - caught, rate = Catching.attempt({ - battle = self.battle, - mon = enemy, - def = enemyDef, - maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), - hp = enemy.hp, - catchRate = enemyDef and enemyDef.catchRate or 45, - ball = itemId, - status = enemy.status, - random = self.battle.random, - weight = dexEntry and dexEntry.weight, - level = enemy.level, - playerLevel = player and player.level, - fishing = self.battle.battleType == "fish", - species = enemy.species, - gender = enemy.gender, - playerSpecies = player and player.species, - playerGender = player and player.gender, - evolveItem = evolveItem, - }) + -- is also used by the read-only preview, so both paths stay exact. + caught, rate = Catching.attempt(self:catchOptions(itemId)) end -- wWildMon carries the answer through the animation, and -- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index 790de5a6..8f8cfb84 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -490,6 +490,11 @@ check("hurt is easier to catch", hurtRate > fullRate, true) local asleepRate = Catching.rate({ maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", status = "sleep" }) check("sleep adds 10", asleepRate, math.min(255, hurtRate + 10)) +checkNear("preview converts the exact byte roll", Catching.chance({ + maxHp = 60, hp = 60, catchRate = 45, ball = "POKE_BALL" }), + fullRate * 100 / 256, 0.000001) +check("master ball preview is certain", Catching.chance({ + maxHp = 60, hp = 60, catchRate = 1, ball = "MASTER_BALL" }), 100) -- The cart's bug: burn/poison/paralysis add nothing. check("poison adds nothing (cart bug)", Catching.rate({ maxHp = 60, hp = 1, catchRate = 45, ball = "POKE_BALL", diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index e6090a1c..11486dc0 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -282,6 +282,16 @@ local function runToMenu(screen, cap) return false end +do + local screen = newScreen({ inventory = { + MASTER_BALL = 1, POKE_BALL = 1, + } }) + eq(screen:catchChance("MASTER_BALL"), 100, + "the battle screen exposes a certain Master Ball preview") + check(type(screen:catchChance("POKE_BALL")) == "number", + "the battle screen exposes the live wild catch preview") +end + -- ---- BattleMenu empties the textbox --------------------------------------- do local screen = newScreen() diff --git a/tests/mod_battle_snapshot_test.lua b/tests/mod_battle_snapshot_test.lua index 851d3646..f2d058d1 100644 --- a/tests/mod_battle_snapshot_test.lua +++ b/tests/mod_battle_snapshot_test.lua @@ -163,14 +163,23 @@ function screen2:chooseMove(slot) return true end function screen2:cancelMove() self.phase = "menu" return true end +function screen2:catchChance(ball) + return ball == "MASTER_BALL" and 100 or 37.5 +end local game2 = { data = { pokemon = { CHIKORITA = { name = "CHIKORITA" }, RATTATA = { name = "RATTATA" } }, moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35, accuracy = 95, pp = 35 } }, + items = { + MASTER_BALL = { name = "MASTER BALL", pocket = "BALL" }, + POTION = { name = "POTION", pocket = "ITEM" }, + }, }, - save = { party = { player2 } }, stack = { states = { screen2 } }, + save = { party = { player2 }, inventory = { + MASTER_BALL = 1, POTION = 2, + } }, stack = { states = { screen2 } }, } local api2 = require("src.battle.gen2.BattleAPI").new(game2) @@ -179,6 +188,9 @@ check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu", "Gold battle is discovered through its screen id") eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field") eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied") +eq(#snapshot2.items, 1, "Gold exposes balls without guessing targeted items") +eq(snapshot2.items[1].catchChance, 100, + "Gold ball records expose the exact catch preview") snapshot2.player.hp = 0 snapshot2.moves[1].pp = 0 eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon") From 4f8739c029b4d40a5c4ba23ac0c6c62f34d6d3ab Mon Sep 17 00:00:00 2001 From: anxiousintrovert <82425472+anxiousintrovert@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:29:32 -0500 Subject: [PATCH 4/4] Fix required imports on legacy Android picker bridges --- src/import/RomImporter.lua | 37 ++++++++++++++--- tests/rom_importer_android_mod_pick_test.lua | 42 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 37fa0868..712790df 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -988,8 +988,18 @@ local function pickerHasKind(kind) return false end -local function findPendingRequiredImport() +local function findPendingRequiredImport(self) local names = { "picked_required_import.bin", "picked_stadium.z64" } + -- Builds released before required_import was added to the Android JNI bridge + -- only understand the long-standing "rom" picker kind. While a required + -- import request is in flight, it is safe to treat its staging name as a + -- dependency file: the pending IDs below select the same validation/copy + -- path as a current bridge. Never scan picked_rom.gb otherwise, since that + -- remains reserved for an ordinary game-ROM import. + if self and self.requiredImportLegacyRomPick + and self.pickerPendingKind == "required_import" then + names[#names + 1] = "picked_rom.gb" + end for _, name in ipairs(names) do if love.filesystem.getInfo(name, "file") then return name end end @@ -1471,12 +1481,16 @@ function RomImporter:focus(f) local text = "Could not read the picked file. Reopen the picker and choose " .. "it with the Files (Documents) app, or copy it into: " .. love.filesystem.getSaveDirectory() + local legacyRequiredPick = self.requiredImportLegacyRomPick + and self.pickerPendingKind == "required_import" if pickError:find("picked_required_import", 1, true) - or pickError:find("picked_stadium", 1, true) then + or pickError:find("picked_stadium", 1, true) + or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then self.modNotice = { ok = false, text = text } self.pickerPendingKind = nil self.pickerPendingModId = nil self.pickerPendingImportId = nil + self.requiredImportLegacyRomPick = nil elseif pickError:find("picked_mod", 1, true) then self.modNotice = { ok = false, text = text } elseif pickError:find("picked_save", 1, true) then @@ -1488,11 +1502,12 @@ function RomImporter:focus(f) end return end - local requiredName = findPendingRequiredImport() + local requiredName = findPendingRequiredImport(self) if requiredName then local modId, importId = self.pickerPendingModId, self.pickerPendingImportId self.pickerPendingKind = nil self.pickerPendingModId, self.pickerPendingImportId = nil, nil + self.requiredImportLegacyRomPick = nil local imported = modId and importId and self:_importRequiredSource(modId, importId, requiredName) consumePick(self, requiredName, requiredName, imported) @@ -2024,7 +2039,17 @@ function RomImporter:chooseRequiredImport(modId, importId) return end if self.nativePicker then - if self.mobileFileBridge and not pickerHasKind("required_import") then + -- Android 13+ uses the Storage Access Framework for both paths. Some + -- Android 15 installs carry the newer Lua launcher with an older native + -- bridge, however, so they do not advertise required_import yet. Fall + -- back to that bridge's known "rom" picker and quarantine its result by + -- the pending required-import IDs. iOS has a different asynchronous + -- bridge and deliberately keeps the explicit capability requirement. + local legacyAndroidPicker = self.mobileFileBridge + and love.system.getOS() == "Android" + and not pickerHasKind("required_import") + if self.mobileFileBridge and not pickerHasKind("required_import") + and not legacyAndroidPicker then requiredImportNotice(self, modId, importId, "This app build cannot pick required mod files yet. Update the app and try again.") self.modNotice = nil @@ -2033,10 +2058,12 @@ function RomImporter:chooseRequiredImport(modId, importId) self.pickerPendingKind = "required_import" self.pickerPendingModId = modId self.pickerPendingImportId = importId - if not pickFile("required_import") then + self.requiredImportLegacyRomPick = legacyAndroidPicker or nil + if not pickFile(legacyAndroidPicker and "rom" or "required_import") then self.pickerPendingKind = nil self.pickerPendingModId = nil self.pickerPendingImportId = nil + self.requiredImportLegacyRomPick = nil requiredImportNotice(self, modId, importId, "Could not open the file picker.") self.modNotice = nil elseif self.android then diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua index b958e75b..bc88882f 100644 --- a/tests/rom_importer_android_mod_pick_test.lua +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -166,6 +166,47 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara check(love.filesystem.getInfo("picked_required_import.bin") == nil, "focus removes the staged required-file pick") +-- Android releases with the updated launcher but the older native bridge do +-- not advertise required_import. They still support the established ROM SAF +-- picker, whose result must be quarantined to the pending dependency request. +love.system.pickFileKinds = function() return "rom,mod,sav" end +pickCalls = {} +ri = freshImporter({ red = true, blue = true }) +ri.nativePicker = true +ri.mobileFileBridge = true +ri.mods = { { + id = "needs_source", + manifest = { id = "needs_source", name = "Needs Source", + required_imports = { { id = "source", name = "Source", file = "source.bin", + format = "raw", md5 = { "00000000000000000000000000000000" } } } }, +} } +ri:chooseRequiredImport("needs_source", "source") +eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker") +check(ri.requiredImportLegacyRomPick, + "legacy Android ROM picker result is marked as a required import") +ri._importRequiredSource = function(self, modId, importId, source) + self._requiredImported = { modId = modId, importId = importId, source = source } + return true +end +love.filesystem.write("picked_rom.gb", "source bytes") +ri:focus(true) +eq(ri._requiredImported.source, "picked_rom.gb", + "legacy Android ROM staging name is routed to the required import") +eq(ri._requiredImported.modId, "needs_source", + "legacy Android picker preserves the requested mod") +check(love.filesystem.getInfo("picked_rom.gb") == nil, + "legacy Android dependency pick is removed after import") + +-- A legacy bridge reports a failed copy using that same staging basename; it +-- must stay on the dependency page rather than becoming a game-ROM error. +ri:chooseRequiredImport("needs_source", "source") +love.filesystem.write("pick_error.flag", "picked_rom.gb") +ri:focus(true) +check(ri.modNotice ~= nil and ri.modNotice.ok == false, + "legacy Android picker errors are shown as dependency import errors") +check(ri.pickerPendingKind == nil, + "legacy Android picker error clears the pending dependency request") + love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile love.system.pickFileKinds = saved.pickFileKinds @@ -174,5 +215,6 @@ love.filesystem.remove("usb_mod.zip") love.filesystem.remove("picked_mod.zip") love.filesystem.remove("picked_save.sav") love.filesystem.remove("picked_required_import.bin") +love.filesystem.remove("picked_rom.gb") S.finish()