From 9401445c0f7f7bbd8afc6f61cbc36afab9b8c240 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:11:23 +0200 Subject: [PATCH 1/3] Route Gen 2 move-learning and item-effect text through Strings() learnMoveOn (Game2.lua) built every move-learning message -- level-up learn, TM/HM teach, the "trying to learn X, forget a move?" flow, "stop learning?", the HM-can't-be-forgotten refusal, "which move should be forgotten?", and the "1, 2 and... forgot X, learned Y!" replace result -- with a bare string literal or a direct :format() call, bypassing Strings() entirely (src/core/Strings.lua). Game2:say()/TextBox.new() show whatever text they're handed with no lookup of their own, so this meant the entire move-learning UI stayed in English no matter what translation catalog a mod supplied -- confirmed against a real gen1recomp-translation-mods build, which has translated overrides for several of these keys that could never apply because the literal never reached the catalog. Same bug for the TM/HM teach refusals ("X can't learn Y!"/"X already knows Y!"). src/core/gen2/ItemEffects.lua had the same bug across its whole family: every item-usage message (Potion/heal, status cure, Revive, Rare Candy, Vitamin, PP restore/PP Up, "no effect"/"can't use on an EGG"/"can't use on this #MON") was either a bare table constant or built with :format() directly, none of it routed through Strings(). Its constants are now wrapped in Strings.source() at declaration (so a mod's catalog harvest still finds them, per the pattern already documented in Strings.lua) and looked up through Strings() at each use site. The Vitamin success message's own VITAMIN_LABEL table (HEALTH/ATTACK/DEFENSE/SPEED/SPECIAL) gets the same two-part fix -- each entry wrapped in Strings.source() at declaration, and Strings(VITAMIN_LABEL[stat]) at the one use site -- matching the identical stat-name tables in MoveEffects.lua/TrainerAI.lua/ gen2/Effects.lua/ContestMenu.lua/SummaryMenu.lua, which all already do this; without it a Vitamin's stat name would stay in English mid-sentence even inside an otherwise fully translated message. Two more call sites read ItemEffects.TEXT_NO_EFFECT raw outside that file and needed the same fix: PartyMenu.lua's Softboiled-no-target refusal, and BattleState.lua's X-item-reused-with-no-effect and BitterBerry-when-not-confused refusals -- both battle-side messages where every neighboring self.message assignment already correctly wraps in Strings(), which is what made these two stand out as missed. None of this changes vanilla (no mod loaded) output: Strings.get() is an identity function with no catalog active, so every message renders byte-identical to before. Verified with the headless Gen 2 suite (luajit tests/run_gen2.lua): 144/145, the one failure (gen2_fishing_time_test.lua) reproduces identically on dev before this change and is unrelated (fishing time-group logic); the full engine suite (luajit tests/run_engine.lua) is 533/533. --- src/core/Game2.lua | 24 ++++----- src/core/gen2/ItemEffects.lua | 94 ++++++++++++++++++++--------------- src/ui/gen2/BattleState.lua | 4 +- src/ui/gen2/PartyMenu.lua | 2 +- 4 files changed, 67 insertions(+), 57 deletions(-) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 22f6ea14..33dc2f40 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -550,7 +550,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) end if ok then -- data/text/common_3.asm:119 - return self:say(("%s learned\n%s!"):format(name, moveName), + return self:say(Strings("%s learned\n%s!", name, moveName), function() finish(true) end, TextBox.soundOpts(self, "Sfx_DexFanfare5079")) end @@ -558,15 +558,14 @@ function Game2:learnMoveOn(mon, moveId, onDone) local askForget, pickMove, askStop -- DidNotLearnMoveText, then `ld b, 0` (learn.asm:110-113). local function decline() - self:say(("%s\ndid not learn\v%s."):format(name, moveName), + self:say(Strings("%s\ndid not learn\v%s.", name, moveName), function() finish(false) end) end -- ForgetMove's AskForgetMoveText + YesNoBox (learn.asm:123-127). askForget = function() self.stack:push(TextBox.new(self, - ("%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves." - .. "\fDelete an older\nmove to make room\vfor %s?") - :format(name, moveName, name, moveName), + Strings("%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves.\fDelete an older\nmove to make room\vfor %s?", + name, moveName, name, moveName), nil, { choice = function(yes) if yes then return pickMove() end return askStop() @@ -575,7 +574,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- StopLearningMoveText, whose NO is `jp c, .loop` (learn.asm:104-108). askStop = function() self.stack:push(TextBox.new(self, - ("Stop learning\n%s?"):format(moveName), nil, + Strings("Stop learning\n%s?", moveName), nil, { choice = function(yes) if yes then return decline() end return askForget() @@ -598,7 +597,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- MoveCantForgetHMText, then `jr .loop` (learn.asm:183-197): the -- question stays up and the list comes back over it. if old and HM_MOVES[old.id] then - return self:say("HM moves can't be\nforgotten now.", pushList) + return self:say(Strings("HM moves can't be\nforgotten now."), pushList) end self.stack:pop() -- the question the list stood on local oldDef = (self.data.moves or {})[old and old.id] @@ -608,9 +607,8 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- pokemon.move_learned is raised here too. ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) -- engine/pokemon/learn.asm:225-229, data/text/common_3.asm:165-173 - self:say(("1, 2 and…" .. TextBox.PAUSE .. " Poof!" .. TextBox.PAUSE - .. "\f%s forgot\n%s.\fAnd…\f%s learned\n%s!") - :format(name, oldName, name, moveName), + self:say(Strings("1, 2 and…\1 Poof!\1\f%s forgot\n%s.\fAnd…\f%s learned\n%s!", + name, oldName, name, moveName), function() finish(true) end, TextBox.soundOpts(self, "Sfx_DexFanfare5079", { pauseSounds = { "Sfx_SwitchPokemon" } })) @@ -620,7 +618,7 @@ function Game2:learnMoveOn(mon, moveId, onDone) -- MoveAskForgetText, a `done` text: the box stays while the list stands on -- it (learn.asm:136-137). pickMove = function() - self.stack:push(TextBox.new(self, "Which move should\nbe forgotten?", nil, + self.stack:push(TextBox.new(self, Strings("Which move should\nbe forgotten?"), nil, { stay = { onShown = pushList } })) end askForget() @@ -655,13 +653,13 @@ function Game2:useFieldItem(itemId) if id == moveId then allowed = true end end if not allowed then - self:say(("%s can't learn %s!"):format( + self:say(Strings("%s can't learn %s!", require("src.battle.gen2.Mon").displayName(mon), moveName)) return end for _, move in ipairs(mon.moves or {}) do if move.id == moveId then - self:say(("%s already knows %s!"):format( + self:say(Strings("%s already knows %s!", require("src.battle.gen2.Mon").displayName(mon), moveName)) return end diff --git a/src/core/gen2/ItemEffects.lua b/src/core/gen2/ItemEffects.lua index b0b8a8a6..9ef47adc 100644 --- a/src/core/gen2/ItemEffects.lua +++ b/src/core/gen2/ItemEffects.lua @@ -16,6 +16,7 @@ local Happiness = require("src.core.gen2.Happiness") local Mon = require("src.battle.gen2.Mon") +local Strings = require("src.core.Strings") local ItemEffects = {} @@ -72,26 +73,33 @@ local BITTER = { } -- _ItemWontHaveEffectText / _ItemCantUseOnEggText (data/text/common_3.asm). -ItemEffects.TEXT_NO_EFFECT = "It won't have any\neffect." -ItemEffects.TEXT_CANT_USE_ON_EGG = "That can't be used\non an EGG." +-- Strings.source keeps these in the catalog harvest even though they are +-- declared here and only formatted/looked up at each use site below (#186, +-- #245): every use site was a bare table read or a direct :format() call +-- until now, which meant a mod's translation catalog never had a chance to +-- apply -- every language showed this same English text. +ItemEffects.TEXT_NO_EFFECT = Strings.source("It won't have any\neffect.") +ItemEffects.TEXT_CANT_USE_ON_EGG = Strings.source("That can't be used\non an EGG.") -- _ItemCantUseOnMonText (data/text/common_3.asm:1265). -ItemEffects.TEXT_CANT_USE_ON_MON = "That can't be used\non this #MON." +ItemEffects.TEXT_CANT_USE_ON_MON = Strings.source("That can't be used\non this #MON.") -- _PPRestoredText (data/text/common_3.asm). -ItemEffects.TEXT_PP_RESTORED = "PP was restored." +ItemEffects.TEXT_PP_RESTORED = Strings.source("PP was restored.") -- _PPIsMaxedOutText / _PPsIncreasedText (data/text/common_3.asm). -ItemEffects.TEXT_PP_MAXED = "%s's PP\nis maxed out." -ItemEffects.TEXT_PP_INCREASED = "%s's PP\nincreased." +ItemEffects.TEXT_PP_MAXED = Strings.source("%s's PP\nis maxed out.") +ItemEffects.TEXT_PP_INCREASED = Strings.source("%s's PP\nincreased.") -- PrintPartyMenuActionText's .MenuActionTexts (engine/pokemon/party_menu.asm), -- keyed by the class GetItemHealingAction resolves. Each is the two rows the --- cart prints: the nickname line, then the fixed line. +-- cart prints: the nickname line, then the fixed line. Strings.source for +-- the same reason as the constants above: healStatus() below formats these +-- directly at each use, so without it a mod's catalog never sees them. local STATUS_TEXT = { - psn = "%s's\ncured of poison.", - par = "%s's\nrid of paralysis.", - brn = "%s's\nburn was healed.", - frz = "%s\nwas defrosted.", - slp = "%s\nwoke up.", - all = "%s's\nhealth returned.", + psn = Strings.source("%s's\ncured of poison."), + par = Strings.source("%s's\nrid of paralysis."), + brn = Strings.source("%s's\nburn was healed."), + frz = Strings.source("%s\nwas defrosted."), + slp = Strings.source("%s\nwoke up."), + all = Strings.source("%s's\nhealth returned."), } -- The port's party records spell status several ways (the battle writes the @@ -145,7 +153,7 @@ local function restoreHp(itemId, mon) local amount = ItemEffects.HEAL_HP[itemId] local maxHp = maxHpOf(mon) if fainted(mon) or (mon.hp or 0) >= maxHp then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local healed = math.min(maxHp, (mon.hp or 0) + amount) local gained = healed - (mon.hp or 0) @@ -157,7 +165,7 @@ local function restoreHp(itemId, mon) used = true, -- data/text/common_1.asm:30 -- home/text.asm:772 - text = ("%s\nrecovered %dHP!"):format(monName(mon), gained), + text = Strings("%s\nrecovered %dHP!", monName(mon), gained), } end @@ -181,23 +189,23 @@ end -- confusion arm reads wPlayerSubStatus3, which does not exist out of battle. local function healStatus(itemId, mon, class, data) if fainted(mon) then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local have = ItemEffects.healClassOf(mon.status, data) if not have or (class ~= "all" and have ~= class) then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end clearStatus(mon) bitterHappiness(itemId, mon) local shape = STATUS_TEXT[class == "all" and "all" or have] - return { used = true, text = shape:format(monName(mon)) } + return { used = true, text = Strings(shape, monName(mon)) } end -- RevivePokemon: only a fainted mon accepts; REVIVE stands it up at half max -- HP (ReviveHalfHP's `srl d / rr e`), the other two at full. local function revive(itemId, mon) if not fainted(mon) then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local maxHp = maxHpOf(mon) mon.hp = (ItemEffects.REVIVE[itemId] == "half") @@ -206,7 +214,7 @@ local function revive(itemId, mon) bitterHappiness(itemId, mon) return { used = true, - text = ("%s\nis revitalized."):format(monName(mon)), + text = Strings("%s\nis revitalized.", monName(mon)), } end @@ -218,7 +226,7 @@ end -- exactly the new level, for the caller to offer. local function rareCandy(mon, data) if (mon.level or 0) >= Mon.MAX_LEVEL then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local def = data and data.pokemon and data.pokemon[mon.species] -- through Mon.growthFor, so a growth_rates record a mod registered is the @@ -245,14 +253,18 @@ local function rareCandy(mon, data) learned = learned, -- data/text/common_1.asm:86 sfx = "Sfx_DexFanfare5079", - text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel), + text = Strings("%s grew to\nlevel %d!", monName(mon), newLevel), } end --- engine/items/item_effects.asm:1216 StatStrings. +-- engine/items/item_effects.asm:1216 StatStrings. Strings.source per entry, +-- the same pattern as the identical stat-name tables in MoveEffects.lua/ +-- TrainerAI.lua/gen2/Effects.lua/ContestMenu.lua/SummaryMenu.lua, so a mod's +-- catalog harvest finds these independently of any other stat-name call site. local VITAMIN_LABEL = { - hp = "HEALTH", attack = "ATTACK", defense = "DEFENSE", - speed = "SPEED", special = "SPECIAL", + hp = Strings.source("HEALTH"), attack = Strings.source("ATTACK"), + defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"), + special = Strings.source("SPECIAL"), } -- engine/items/item_effects.asm:1149 VitaminEffect. @@ -261,7 +273,7 @@ local function vitamin(itemId, mon, data) mon.statExp = mon.statExp or Mon.newStatExp() local cur = mon.statExp[stat] or 0 if cur >= 25600 then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end mon.statExp[stat] = math.min(Mon.MAX_STAT_EXP, cur + 2560) local def = data and data.pokemon and data.pokemon[mon.species] @@ -272,7 +284,7 @@ local function vitamin(itemId, mon, data) Happiness.change(mon, "USEDITEM") return { used = true, - text = ("%s's\n%s rose."):format(monName(mon), VITAMIN_LABEL[stat]), + text = Strings("%s's\n%s rose.", monName(mon), Strings(VITAMIN_LABEL[stat])), } end @@ -392,15 +404,15 @@ end -- The one-call families (everything but PP, which needs a move pick first). -- Returns { used, text, learned?, level? }. function ItemEffects.useOnMon(itemId, mon, data) - if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if not mon then return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end if mon.isEgg then - return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } + return { used = false, text = Strings(ItemEffects.TEXT_CANT_USE_ON_EGG) } end local record = ItemEffects.recordFor(itemId, data) -- The PP family has its own entry point; reaching it here is the same -- "nothing happens" the unported items get. if not record or not record.use or record.action == "pp" then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end return record.use({ item = itemId, mon = mon, data = data }) end @@ -423,13 +435,13 @@ end -- the ELIXER family (Elixer_RestorePPofAllMoves) walks every slot and counts -- -- one restored move is enough for the item to be spent. function ItemEffects.usePpItem(itemId, mon, slot, data) - if not mon then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if not mon then return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end if mon.isEgg then - return { used = false, text = ItemEffects.TEXT_CANT_USE_ON_EGG } + return { used = false, text = Strings(ItemEffects.TEXT_CANT_USE_ON_EGG) } end local record = ItemEffects.recordFor(itemId, data) if not record or not record.use or record.action ~= "pp" then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end return record.use({ item = itemId, mon = mon, data = data, slot = slot }) end @@ -478,9 +490,9 @@ for itemId, row in pairs(ItemEffects.RESTORE_PP) do any = restoreMove(moves[ctx.slot], row.amount) end if not any then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end - return { used = true, text = ItemEffects.TEXT_PP_RESTORED } + return { used = true, text = Strings(ItemEffects.TEXT_PP_RESTORED) } end) end @@ -488,24 +500,24 @@ end record("PP_UP", "pp", function(ctx) local move = (ctx.mon.moves or {})[ctx.slot] if type(move) ~= "table" or not move.id then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local row = ((ctx.data and ctx.data.moves) or {})[move.id] local name = (row and row.name) or move.id -- constants/pokemon_data_constants.asm:216 PP_UP_MASK. if move.id == "SKETCH" or (move.ppUps or 0) >= 3 then - return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) } + return { used = false, text = Strings(ItemEffects.TEXT_PP_MAXED, name) } end local base = (row and row.pp) or move.maxPp if not base then - return { used = false, text = ItemEffects.TEXT_PP_MAXED:format(name) } + return { used = false, text = Strings(ItemEffects.TEXT_PP_MAXED, name) } end -- engine/items/item_effects.asm:2736 ComputeMaxPP. local bonus = math.min(math.floor(base / 5), 7) move.ppUps = (move.ppUps or 0) + 1 move.maxPp = base + move.ppUps * bonus move.pp = (move.pp or 0) + bonus - return { used = true, text = ItemEffects.TEXT_PP_INCREASED:format(name) } + return { used = true, text = Strings(ItemEffects.TEXT_PP_INCREASED, name) } end) for itemId in pairs(ItemEffects.VITAMIN) do @@ -522,12 +534,12 @@ for _, itemId in ipairs({ "SUN_STONE", "MOON_STONE", "FIRE_STONE", "THUNDERSTONE", "WATER_STONE", "LEAF_STONE" }) do record(itemId, "stone", function(ctx) if ctx.mon.item == "EVERSTONE" then - return { used = false, text = ItemEffects.TEXT_NO_EFFECT } + return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end local Evolution = require("src.core.gen2.Evolution") local entry = Evolution.checkMon(ctx.data, ctx.mon, { force = true, item = ctx.item }) - if not entry then return { used = false, text = ItemEffects.TEXT_NO_EFFECT } end + if not entry then return { used = false, text = Strings(ItemEffects.TEXT_NO_EFFECT) } end return { used = true, evolution = entry } end) end diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 328abbf5..9df01039 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -3860,7 +3860,7 @@ function BattleState:useItem(itemId) if not ok then -- _ItemWontHaveEffectText's own `line` break, the same one -- ItemEffects.TEXT_NO_EFFECT carries (data/text/common_3.asm). - self.message = ItemEffects.TEXT_NO_EFFECT + self.message = Strings(ItemEffects.TEXT_NO_EFFECT) self.messageTimer = MESSAGE_FRAMES self.phase = "resolving" return @@ -3965,7 +3965,7 @@ function BattleState:cureBattleConfusion(itemId) local mon = self.battle.player local state = mon and self.battle:volatile(mon) if not (state and state.confuseCount) then - self.message = oneLine(ItemEffects.TEXT_NO_EFFECT) + self.message = oneLine(Strings(ItemEffects.TEXT_NO_EFFECT)) self.messageTimer = MESSAGE_FRAMES self.phase = "resolving" return diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index d7180f27..025a9ff3 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -396,7 +396,7 @@ function PartyMenu:finishSoftboiled() local before, after = FieldMoves.softboiledTransfer(user, target, self.softboiledCost or 0) if not before then - self:showItemResult(slot, { text = ItemEffects.TEXT_CANT_USE_ON_MON }) + self:showItemResult(slot, { text = Strings(ItemEffects.TEXT_CANT_USE_ON_MON) }) return end self.softboiledFrom, self.softboiledCost = nil, nil From fa4e96e8799707acedbf72c1590a15e68ba781a6 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:11:36 +0200 Subject: [PATCH 2/3] Route Battle Tower Menu messages through Strings() BattleTowerMenu:drawPanel() shows self.message via Chrome.printWrapped directly, with no Strings() lookup of its own -- same shape as Game2:say()/TextBox.new(), which also expect their caller to have already resolved the text. Every assignment to self.message in this file used the raw Strings.source() return value (an identity/harvest marker, not a lookup) instead: the level-picker prompt, the "quit your challenge?" confirmation, the level-cap refusal, and the Uber-clause refusal (which additionally called Lua's own string.format() on the untranslated source instead of Strings(UBER_TEXT, name)). The YES/NO confirmation labels and the CANCEL row label had the identical bug one level down: declared with Strings.source() but printed via Chrome.print() with no Strings() call around them either. The whole Battle Tower level-picker menu, including its yes/no prompt, stayed in English regardless of any translation catalog a mod supplied. Found via a systematic trace of every TextBox.new() (143) and Chrome.print()/Chrome.printWrapped() call site in the engine back to its text source, cross-checked against the real tools/modkit.py harvester and an independent review of the branch. That same review also flagged src/world/OverworldController.lua:3314 (Strings.source("%s's PC"):format(playerName)) as the same bug shape, and an earlier revision of this branch "fixed" it to Strings("%s's PC", playerName) -- but that call site is not a bug: openPC() deliberately builds every PC row's label from a stable English Strings.source() value first, so the ui.pc.items mod hook can match/reorder rows by their vanilla English text, and only translates via a second translatedLabels[playerPcLabel] pass after the hook has run (openPC()'s own comment: "Hooks identify the vanilla rows by their English source labels. Delay localization until after ui.pc.items has inspected/reordered/replaced them"). Translating playerPcLabel early broke that contract -- confirmed by tests/engine/rby_translation_runtime_test.lua, which already covered this exact case and failed with "ui.pc.items sees the stable player-PC source label (got PC DE RED, want RED's PC)" once that change landed. Reverted; the label was already being translated correctly by the existing second pass, which this branch never needed to touch. A second independent review round, after the fixes above, found one more real gap in the same family: ItemEffects.lua's VITAMIN_LABEL table (folded into the previous commit once found), and confirmed everything else on the branch clean. Verified with tools/modkit.py's harvest_engine_strings that all seven BattleTowerMenu.lua literals are still discovered from their Strings.source() declarations, luajit tests/run_gen2.lua: 144/145 (the one unrelated pre-existing failure noted in the previous commit), and luajit tests/run_engine.lua: 533/533 (including the PC-label regression test above, now passing). --- src/ui/gen2/BattleTowerMenu.lua | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/ui/gen2/BattleTowerMenu.lua b/src/ui/gen2/BattleTowerMenu.lua index b47c0178..a121c249 100644 --- a/src/ui/gen2/BattleTowerMenu.lua +++ b/src/ui/gen2/BattleTowerMenu.lua @@ -76,7 +76,7 @@ function BattleTowerMenu.new(game, opts) -- `ld a, $1 / ld [wcd4f], a` (../pokecrystal/mobile/mobile_46.asm:1152-1153) self.cursor = 1 self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) return self end @@ -116,17 +116,17 @@ function BattleTowerMenu:confirm() if not row then -- ../pokecrystal/mobile/mobile_46.asm:1291-1303 `.asm_118a3c` self.phase = "quit" - self.message = QUIT_TEXT + self.message = Strings(QUIT_TEXT) self.yes = true return end if BattleTower.levelCheck(self.party, row.group) then - return self:refuse(TOPS_TEXT) + return self:refuse(Strings(TOPS_TEXT)) end local uber = BattleTower.ubersCheck(self.party, row.group) if uber then local name = (self.monName and self.monName(uber)) or uber - return self:refuse(string.format(UBER_TEXT, name)) + return self:refuse(Strings(UBER_TEXT, name)) end self:finish(row.group) end @@ -147,7 +147,7 @@ function BattleTowerMenu:updatePick() elseif input:wasPressed("b") then self:playSfx("Sfx_ReadText2") self.phase = "quit" - self.message = QUIT_TEXT + self.message = Strings(QUIT_TEXT) self.yes = true end end @@ -165,12 +165,12 @@ function BattleTowerMenu:updateQuit() self:playSfx("Sfx_ReadText2") if self.yes then return self:finish(nil) end self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) self.cursor = 1 elseif input:wasPressed("b") then self:playSfx("Sfx_ReadText2") self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) self.cursor = 1 end end @@ -181,7 +181,7 @@ function BattleTowerMenu:update(_dt) self.wait = (self.wait or 0) - 1 if self.wait > 0 then return end self.phase = "pick" - self.message = PICK_TEXT + self.message = Strings(PICK_TEXT) self.cursor = 1 return end @@ -198,15 +198,15 @@ function BattleTowerMenu:drawPanel() end if self.phase == "quit" then Chrome.box(YN_X, YN_Y, YN_W, YN_H) - Chrome.print(YES_LABEL, YN_TEXT_X, YES_Y) - Chrome.print(NO_LABEL, YN_TEXT_X, NO_Y) + Chrome.print(Strings(YES_LABEL), YN_TEXT_X, YES_Y) + Chrome.print(Strings(NO_LABEL), YN_TEXT_X, NO_Y) Chrome.cursor(YN_TEXT_X - 1, self.yes and YES_Y or NO_Y) love.graphics.setColor(1, 1, 1, 1) return end Chrome.box(PICK_X, PICK_Y, PICK_W, PICK_H) local row = self.rows[self.cursor] - Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or CANCEL_LABEL, + Chrome.print(row and BattleTowerMenu.levelLabel(row.group) or Strings(CANCEL_LABEL), ROW_X, ROW_Y) Chrome.print(UP_ARROW, ARROW_X, UP_Y) Chrome.print(DOWN_ARROW, ARROW_X, DOWN_Y) From 3cce66d497dda48d4a12c1e5575c11ac1a442063 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 3 Sep 2026 13:17:59 -0500 Subject: [PATCH 3/3] Implement Android image export functionality and enhance save data management. Added exportImageToGallery method for saving images to the public Pictures directory on Android. Updated Printer and SaveData modules to support this feature, ensuring proper slot resolution and user feedback. Improved touch handling in the launcher to prevent unintended actions after exiting games. --- data/scripts/flavor/celadon_mansion_3f.lua | 2 +- data/scripts/yellow_beach_house.lua | 3 +- main.lua | 14 +- mobile/android/gradle.properties | 4 +- .../love/src/jni/love/src/common/android.cpp | 26 ++++ .../love/src/jni/love/src/common/android.h | 7 + .../jni/love/src/modules/system/System.cpp | 10 ++ .../src/jni/love/src/modules/system/System.h | 6 + .../love/src/modules/system/wrap_System.cpp | 8 ++ .../java/org/love2d/android/GameActivity.java | 123 ++++++++++++++++- src/core/Printer.lua | 39 ++++++ src/core/SaveData.lua | 23 +++- src/import/LauncherView.lua | 21 ++- src/import/RomImporter.lua | 38 ++++++ src/ui/BoxMenu.lua | 4 +- src/ui/PokedexMenu.lua | 4 +- src/ui/gen2/PokedexMenu.lua | 3 +- .../launcher_return_pointer_bug2079.lua | 125 ++++++++++++++++++ .../printer_android_gallery_bug2103.lua | 108 +++++++++++++++ tests/engine/save_slots.lua | 41 ++++++ tests/love_stub.lua | 11 +- 21 files changed, 599 insertions(+), 21 deletions(-) create mode 100644 tests/engine/launcher_return_pointer_bug2079.lua create mode 100644 tests/engine/printer_android_gallery_bug2103.lua diff --git a/data/scripts/flavor/celadon_mansion_3f.lua b/data/scripts/flavor/celadon_mansion_3f.lua index a7aca328..f0ad74aa 100644 --- a/data/scripts/flavor/celadon_mansion_3f.lua +++ b/data/scripts/flavor/celadon_mansion_3f.lua @@ -79,7 +79,7 @@ return { end) -- the PRNT stand-in always reports where the PNG landed game.stack:push(TextBox.new(game, saved - and Strings("There you go!\fSaved as\n%s\vin the save\nfolder.", saved) + and (Strings("There you go!\f") .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)), done)) end)) end)) diff --git a/data/scripts/yellow_beach_house.lua b/data/scripts/yellow_beach_house.lua index 418cc5fd..b1642aef 100644 --- a/data/scripts/yellow_beach_house.lua +++ b/data/scripts/yellow_beach_house.lua @@ -119,8 +119,7 @@ return { Font.draw(Strings("%d pts", hi), 96, 40) end) push(game, saved - and Strings("Printed!\fSaved as\n%s\vin the save\nfolder.", - saved) + and (Strings("Printed!\f") .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)), done) end)) end) diff --git a/main.lua b/main.lua index 95d26351..256f65f1 100644 --- a/main.lua +++ b/main.lua @@ -428,11 +428,19 @@ local function returnToLauncher(opts) -- Leave the cart's scope behind: the launcher's own settings and slots are -- the base game's, not the cart's. The speed ladder is cart state too, so -- a 1x/2x cart must not pin the launcher or the next game. - require("src.core.SaveData").setCart(nil) + local SaveData = require("src.core.SaveData") + local cartId = SaveData.getCart() + SaveData.setCart(nil) require("src.core.GameSpeed").setAllowed(nil) SessionLifecycle.endMountedSession(currentVersion) + -- Slot lists are resolved once per process. Invalidate only the game + -- (and cart, if any) we just left so the new launcher can migrate a flat + -- in-game SAVE into a visible slot -- nothing else is rewritten. + SaveData.refreshSlotResolution(currentVersion) + if cartId then SaveData.refreshSlotResolution("cart_" .. cartId) end + applySavedOrientation() local preload = require("src.mods.LauncherMods").translationStrings() @@ -444,6 +452,10 @@ local function returnToLauncher(opts) end Importer = makeLauncher({ initialTab = opts and opts.tab or nil }) + -- Finger that confirmed EXIT GAME is often still down over Import Save. + if Importer.ignoreReturningPointer then + Importer:ignoreReturningPointer() + end end local pendingLauncherReturn diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index 8f9a1f99..16175604 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -5,7 +5,7 @@ # it in `app.name_byte_array` #app.name=LÖVE for Android -app.application_id=com.theboisclub.pokemonred +app.application_id=com.theboisclub.pokemonred.androidfixes # fullUser: allow every orientation the player's device permits (portrait and # landscape), honouring their auto-rotate lock. Was "portrait" (locked). app.orientation=fullUser @@ -17,4 +17,4 @@ android.enableJetifier=false android.useAndroidX=true android.nonTransitiveRClass=true android.nonFinalResIds=true -app.name=gen1recomp +app.name=gen1recomp (test) diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 52d7ee3d..49b52e44 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -249,6 +249,32 @@ bool showCreateDocument(const char *suggestedName) return result; } +bool exportImageToGallery(const char *relativePath) +{ + if (relativePath == nullptr || relativePath[0] == '\0') + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + + jmethodID method = env->GetStaticMethodID(activity, "exportImageToGallery", + "(Ljava/lang/String;Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + jstring jpath = env->NewStringUTF(relativePath); + jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jsavedir); + env->DeleteLocalRef(jsavedir); + env->DeleteLocalRef(jpath); + + env->DeleteLocalRef(activity); + return result; +} + bool syncHealthSteps() { JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index ba420b0f..f6d2dc1b 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -74,6 +74,13 @@ bool showFilePicker(const char *destFilename = nullptr); **/ bool showCreateDocument(const char *suggestedName = nullptr); +/** + * Copy a PNG/JPEG under the LOVE save identity (relative path, e.g. + * prints/diploma_….png) into Pictures/Gen1Recomp and media-scan the + * in-app copy so USB / file managers can see it (#2103). + **/ +bool exportImageToGallery(const char *relativePath = nullptr); + /** * Pokéwalker step bridge: asks GameActivity to read the hardware step * counter and stage steps_pending.json in the save identity dir (see diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 1dd3b56b..3af28e9b 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -228,6 +228,16 @@ bool System::createFile(const char *suggestedName) const #endif } +bool System::exportImage(const char *relativePath) const +{ +#ifdef LOVE_ANDROID + return love::android::exportImageToGallery(relativePath); +#else + LOVE_UNUSED(relativePath); + return false; +#endif +} + bool System::syncHealthSteps() const { #ifdef LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index 7ed44292..963f1464 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -129,6 +129,12 @@ public: **/ virtual bool createFile(const char *suggestedName = nullptr) const; + /** + * Copy a PNG/JPEG from the LOVE save identity into Pictures/Gen1Recomp + * and media-scan the in-app file (#2103). Android only; false elsewhere. + **/ + virtual bool exportImage(const char *relativePath = nullptr) const; + /** * Pokéwalker: stage pending real-world steps (steps_pending.json in the * save dir) from the platform step source. Android-only; false elsewhere. diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index fa8d2eca..f6462ffc 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -119,6 +119,13 @@ int w_createFile(lua_State *L) return 1; } +int w_exportImage(lua_State *L) +{ + const char *path = luaL_checkstring(L, 1); + luax_pushboolean(L, instance()->exportImage(path)); + return 1; +} + int w_syncHealthSteps(lua_State *L) { luax_pushboolean(L, instance()->syncHealthSteps()); @@ -341,6 +348,7 @@ static const luaL_Reg functions[] = { "pickFile", w_pickFile }, { "pickFileKinds", w_pickFileKinds }, { "createFile", w_createFile }, + { "exportImage", w_exportImage }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "installApk", w_installApk }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index eeca2f56..306de1f8 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -46,6 +46,8 @@ import android.app.AlarmManager; import android.app.AlertDialog; import android.app.PendingIntent; import android.app.UiModeManager; +import android.content.ContentResolver; +import android.content.ContentValues; import android.content.Context; import android.content.ClipData; import android.content.DialogInterface; @@ -64,12 +66,15 @@ import android.media.AudioDeviceCallback; import android.media.AudioDeviceInfo; import android.media.AudioFocusRequest; import android.media.AudioManager; +import android.media.MediaScannerConnection; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.os.Looper; import android.os.Vibrator; +import android.provider.MediaStore; import android.provider.Settings; import android.util.Log; import android.util.DisplayMetrics; @@ -1328,7 +1333,13 @@ public class GameActivity extends SDLActivity { self.pendingCreateSuggestedName = suggestedName; Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType("application/octet-stream"); + // PNG exports (diploma / dex prints) should open as images so the + // picker offers Photos / Gallery, not only generic "Documents". + String mime = "application/octet-stream"; + String lower = suggestedName.toLowerCase(Locale.US); + if (lower.endsWith(".png")) mime = "image/png"; + else if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) mime = "image/jpeg"; + intent.setType(mime); intent.putExtra(Intent.EXTRA_TITLE, suggestedName); try { self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE); @@ -1339,6 +1350,116 @@ public class GameActivity extends SDLActivity { } } + /** + * Copy a PNG/JPEG already written under the LOVE save identity (e.g. + * prints/diploma_….png) into the public Pictures/Gen1Recomp album and + * media-scan the in-app copy so USB / file managers can see it (#2103). + * No picker: diploma / dex print should not interrupt the game. + */ + @Keep + public static boolean exportImageToGallery(String relativePath, String saveDir) { + GameActivity self = (GameActivity) mSingleton; + if (self == null) return false; + if (relativePath == null || relativePath.length() == 0) return false; + if (relativePath.indexOf("..") >= 0) { + Log.d("GameActivity", "refusing unsafe export path: " + relativePath); + return false; + } + self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; + File source = new File(self.saveIdentityDir(), relativePath); + if (!source.isFile()) { + Log.d("GameActivity", "exportImage missing at " + source); + return false; + } + String name = source.getName(); + String lower = name.toLowerCase(Locale.US); + String mime = "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) mime = "image/jpeg"; + else if (!lower.endsWith(".png")) { + Log.d("GameActivity", "exportImage refusing non-image: " + name); + return false; + } + + // Make the app-private prints/ copy visible over MTP / file managers. + MediaScannerConnection.scanFile(self, + new String[]{ source.getAbsolutePath() }, + new String[]{ mime }, + null); + + try { + if (Build.VERSION.SDK_INT >= 29) { + ContentResolver resolver = self.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, name); + values.put(MediaStore.MediaColumns.MIME_TYPE, mime); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, + Environment.DIRECTORY_PICTURES + "/Gen1Recomp"); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + Uri collection = MediaStore.Images.Media + .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + Uri uri = resolver.insert(collection, values); + if (uri == null) { + Log.d("GameActivity", "MediaStore insert returned null"); + return false; + } + OutputStream out = resolver.openOutputStream(uri); + if (out == null) { + resolver.delete(uri, null, null); + return false; + } + try { + copyStream(new FileInputStream(source), out); + } finally { + out.close(); + } + values.clear(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + resolver.update(uri, values, null, null); + return true; + } + + // Pre-Q: write into public Pictures and ask the scanner to index it. + File pictures = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES); + File album = new File(pictures, "Gen1Recomp"); + if (!album.exists() && !album.mkdirs()) { + Log.d("GameActivity", "could not create " + album); + return false; + } + File dest = new File(album, name); + copyFileToFile(source, dest); + MediaScannerConnection.scanFile(self, + new String[]{ dest.getAbsolutePath() }, + new String[]{ mime }, + null); + return true; + } catch (Exception e) { + Log.d("GameActivity", "exportImage failed: " + e.getMessage()); + return false; + } + } + + private static void copyStream(InputStream in, OutputStream out) throws IOException { + try { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) >= 0) { + if (n > 0) out.write(buf, 0, n); + } + } finally { + try { in.close(); } catch (IOException ignored) {} + } + } + + private static void copyFileToFile(File source, File dest) throws IOException { + FileOutputStream out = new FileOutputStream(dest); + try { + copyStream(new FileInputStream(source), out); + } finally { + out.close(); + } + } + private File saveIdentityDir() { // Prefer the mounted save dir the last bridge call reported: the // recomputation below can name a different volume than the one LOVE diff --git a/src/core/Printer.lua b/src/core/Printer.lua index 7fae79a1..45ce4db9 100644 --- a/src/core/Printer.lua +++ b/src/core/Printer.lua @@ -4,17 +4,45 @@ -- under prints/ in the save directory instead, and the caller shows a -- dialog with where it landed. Scaled up 4x so the "print" is legible -- on a modern screen. +-- +-- On Android the app-private prints/ folder is often invisible over USB / +-- file managers even when the write succeeded (#2103). After the PNG is +-- written, love.system.exportImage copies it into Pictures/Gen1Recomp and +-- media-scans the in-app copy so both Gallery and the save folder work. local Logger = require("src.core.Logger") local Printer = {} local SCALE = 4 +local ANDROID_ALBUM = "Pictures/Gen1Recomp" + +-- After a successful Android gallery export, the public album path shown +-- in the "Printed!" dialog. Nil on desktop / when the bridge is absent. +Printer.lastAlbumPath = nil + +local function exportToAndroidGallery(relPath) + Printer.lastAlbumPath = nil + if not (love.system and love.system.getOS + and love.system.getOS() == "Android") then + return false + end + local exportImage = love.system.exportImage + if type(exportImage) ~= "function" then return false end + local ok, exported = pcall(exportImage, relPath) + if ok and exported then + Printer.lastAlbumPath = ANDROID_ALBUM + return true + end + return false +end -- Render drawFn (which draws a w x h GB-pixel image at 0,0) into -- prints/_.png. Returns the save-dir-relative path, or nil -- and an error string (headless / no canvas support degrades gracefully). +-- On Android a successful gallery copy also sets Printer.lastAlbumPath. function Printer.save(name, w, h, drawFn) + Printer.lastAlbumPath = nil if not (love.graphics and love.graphics.newCanvas) then return nil, "no graphics" end @@ -38,7 +66,18 @@ function Printer.save(name, w, h, drawFn) if not encOk then return nil, tostring(err) end Logger.info("printed %s -> %s/%s", name, love.filesystem.getSaveDirectory(), path) + exportToAndroidGallery(path) return path end +-- Second page of the "Printed!" dialog: public album on Android when the +-- gallery bridge worked, otherwise the save-dir-relative PNG path. +function Printer.savedWhereText(path) + local Strings = require("src.core.Strings") + if Printer.lastAlbumPath then + return Strings("Saved to\n%s.", Printer.lastAlbumPath) + end + return Strings("Saved as\n%s\vin the save\nfolder.", path) +end + return Printer diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index b0a1616a..78311903 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -1440,13 +1440,26 @@ function SaveData.deleteSlot(version, slotId) return deleteSlotIn(version, slotId) end --- Test seam: drop the process-global slot cache (and the active cart) so a --- suite can exercise migration/resolution against a freshly injected --- filesystem. Unused by the game, which resolves each scope exactly once per --- boot. -function SaveData.resetSlotState() +-- Drop the process-global "have we resolved slots for this scope" cache so +-- the next listSlots/saveNames re-reads disk (and can migrate a flat legacy +-- SAVE into slot1). Pass a version id or cart scope key to invalidate just +-- that list; nil clears every scope (test seam / resetSlotState). +-- Does not touch carts, seals, options.lua, or any other launcher setup. +function SaveData.refreshSlotResolution(scope) + if scope ~= nil then + if type(scope) ~= "string" or scope == "" then return end + activeSlotCache[scope] = nil + slotsChecked[scope] = nil + return + end for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end for k in pairs(slotsChecked) do slotsChecked[k] = nil end +end + +-- Test seam: full process-global reset (slot resolution + cart + seal) so a +-- suite can exercise migration against a freshly injected filesystem. +function SaveData.resetSlotState() + SaveData.refreshSlotResolution() freshPlaythrough = nil activeCart, activeCartHash = nil, nil sealBroken = false diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 7d9d22b4..8232a64e 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -249,6 +249,10 @@ end function LauncherView.touchpressed(imp, id, x, y) if not imp._flex then return end + -- Leftover finger from EXIT GAME / Close editor: do not start a tap. + if imp._ignoreTouch and imp._ignoreTouch[tostring(id)] then + return + end imp._touchAt = imp._touchAt or {} imp._touchAt[tostring(id)] = { x = x, y = y, started = love.timer.getTime(), @@ -284,8 +288,21 @@ end -- A tap dispatches on RELEASE (not press) so a drag can disqualify it. function LauncherView.touchreleased(imp, id, x, y) if not imp._flex then return end - local start = imp._touchAt and imp._touchAt[tostring(id)] - if imp._touchAt then imp._touchAt[tostring(id)] = nil end + local tid = tostring(id) + if imp._ignoreTouch and imp._ignoreTouch[tid] then + imp._ignoreTouch[tid] = nil + if imp._touchAt then imp._touchAt[tid] = nil end + imp._suppressMouseUntil = love.timer.getTime() + ACT_DEDUP + return + end + local start = imp._touchAt and imp._touchAt[tid] + if imp._touchAt then imp._touchAt[tid] = nil end + -- A release with no matching press is leftover from the previous host + -- (game / save editor), not a launcher tap (#2079). + if not start then + imp._suppressMouseUntil = love.timer.getTime() + ACT_DEDUP + return + end if start and (start.dragged or start.longPressed) then -- Suppress the mouse click SDL will synthesize for this same gesture. imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 2d70d87e..23ad8e55 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3176,10 +3176,48 @@ function RomImporter:prepareOverlayHandoff() end end +-- EXIT GAME / Close editor leave the confirming finger still down, often +-- sitting where Import Save is drawn. The launcher must not treat that +-- leftover hold as a new press once a short suppress window expires +-- (#2079): update() would then arm on the still-down pointer and the +-- later lift would open the system file picker. Swallow this gesture +-- (held mouse, already-down touches) and debounce clicks briefly. +local RETURN_POINTER_HOLD = 0.5 + +function RomImporter:ignoreReturningPointer() + local now = 0 + if love.timer and love.timer.getTime then + now = love.timer.getTime() + end + self._suppressClickUntil = now + RETURN_POINTER_HOLD + self._suppressMouseUntil = now + RETURN_POINTER_HOLD + self._clickPt = nil + self._mouseAt = nil + self._touchAt = nil + -- Already-down is not a rising edge. Always mark the poll as held so + -- the first frame after remount cannot mint a press from a leftover. + self._prevMouseDown = true + local ignore = {} + if love.touch and love.touch.getTouches then + local ok, ids = pcall(love.touch.getTouches) + if ok and type(ids) == "table" then + for i = 1, #ids do + ignore[tostring(ids[i])] = true + end + end + end + self._ignoreTouch = ignore + if package.loaded["src.ui.kit.Kit"] then + local Kit = require("src.ui.kit.Kit") + if Kit.dragEnd then pcall(Kit.dragEnd) end + end +end + -- After an overlay closes: re-arm the pad cursor when a stick is already -- connected so NX / handhelds are not stranded without a pointer until the -- next stick bump (same class of bug as opening Touch Controls). function RomImporter:resumeAfterOverlay() + self:ignoreReturningPointer() if not self.launcher then return end if not (love.joystick and love.joystick.getJoystickCount) then return end if love.joystick.getJoystickCount() <= 0 then return end diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index d883a09b..78ea9013 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -321,8 +321,8 @@ local function printBox(game) love.graphics.setColor(1, 1, 1, 1) end) game.stack:push(TextBox.new(game, saved - and Strings("Printed BOX %d!\fSaved as\n%s\vin the save\nfolder.", - game.save.currentBox or 1, saved) + and (Strings("Printed BOX %d!\f", game.save.currentBox or 1) + .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)))) end diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index 57111ec1..9c3dab80 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -237,8 +237,8 @@ local function chooseEntry(item, dexList) DexEntryMenu.render(game, def, ok and sprite or nil, false) end) game.stack:push(TextBox.new(game, saved - and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.", - def.name, saved) + and (Strings("Printed %s's\ndata!\f", def.name) + .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)))) end } end diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index eb4812a0..9b994618 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -825,8 +825,7 @@ function PokedexMenu:printEntry() -- Word for word what Yellow's PRNT says (src/ui/PokedexMenu.lua), so the two -- generations share one catalog entry and a translation covers both. local text = saved - and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.", - name, tostring(saved)) + and (Strings("Printed %s's\ndata!\f", name) .. Printer.savedWhereText(saved)) or Strings("Printer error!\n%s", tostring(err)) if self.game and self.game.stack then self.game.stack:push(TextBox.new(self.game, text)) diff --git a/tests/engine/launcher_return_pointer_bug2079.lua b/tests/engine/launcher_return_pointer_bug2079.lua new file mode 100644 index 00000000..2a865248 --- /dev/null +++ b/tests/engine/launcher_return_pointer_bug2079.lua @@ -0,0 +1,125 @@ +-- #2079: EXIT GAME / Close editor leave the confirming finger down over +-- Import Save. After remount the launcher must not treat that leftover as +-- a new tap (the file picker opening a few seconds later is that lift). +-- luajit tests/engine/launcher_return_pointer_bug2079.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local clock = 1000 +love.timer.getTime = function() return clock end + +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function launcher() + return setmetatable({ + _flex = true, + _actAt = {}, + _uiActions = {}, + }, RomImporter) +end + +-- ------- ignoreReturningPointer swallows a leftover hold + +do + local down = true + love.mouse.isDown = function() return down end + love.mouse.getPosition = function() return 280, 640 end + local imp = launcher() + imp:ignoreReturningPointer() + check(imp._prevMouseDown == true, + "returning marks the pointer already down so the first poll is not a press") + check(imp._suppressClickUntil > clock, + "and raises a click debounce over the leftover gesture") + check(imp._clickPt == nil, "any queued click is cleared") + + LauncherView.update(imp, 0.016) + check(imp._mouseAt == nil, "the leftover hold does not arm a drag/tap") + check(imp._clickPt == nil) + + clock = 1000.7 + LauncherView.update(imp, 0.016) + check(imp._mouseAt == nil, + "a still-down finger after the debounce window still does not arm (#2079)") + check(imp._clickPt == nil) + + down = false + LauncherView.update(imp, 0.016) + check(imp._clickPt == nil, "lifting the leftover finger is not a launcher tap") + check(imp._prevMouseDown == false, "the poll then sees the pointer idle") + + down = true + LauncherView.update(imp, 0.016) + check(imp._mouseAt ~= nil, "a new press after the leftover lifts arms normally") + down = false + LauncherView.update(imp, 0.016) + check(imp._clickPt ~= nil, "and that new press+release is a tap") + love.mouse.isDown = nil + love.mouse.getPosition = nil +end + +-- ------- unmatched / leftover touch release is not a tap + +do + clock = 2000 + local imp = launcher() + LauncherView.touchreleased(imp, "finger", 280, 640) + check(imp._clickPt == nil, + "a touch up with no matching press does not click Import Save") +end + +do + clock = 3000 + love.touch = love.touch or {} + love.touch.getTouches = function() return { "held" } end + local imp = launcher() + imp:ignoreReturningPointer() + check(imp._ignoreTouch.held == true, "already-down touch ids are ignored") + LauncherView.touchpressed(imp, "held", 280, 640) + check(imp._touchAt == nil or imp._touchAt.held == nil, + "the leftover finger does not start a launcher tap") + LauncherView.touchreleased(imp, "held", 280, 640) + check(imp._clickPt == nil, "and its lift does not click") + + local ran = false + LauncherView.queueAction(imp, "import-save", function() ran = true end) + eq(#imp._uiActions, 0, "debounce drops Import Save during the hold window") + check(not ran) + + clock = 3000.6 + LauncherView.touchpressed(imp, "fresh", 40, 40) + LauncherView.touchreleased(imp, "fresh", 40, 40) + check(imp._clickPt ~= nil, "a new finger after the window still taps") + love.touch.getTouches = nil +end + +-- ------- Close editor / overlay resume always debounces, even with no stick + +do + clock = 4000 + local imp = launcher() + imp.launcher = true + love.joystick = love.joystick or {} + love.joystick.getJoystickCount = function() return 0 end + imp:resumeAfterOverlay() + check(imp._prevMouseDown == true, + "resumeAfterOverlay debounces even when it does not re-arm the pad") + check(imp._suppressClickUntil > clock, + "so Close editor cannot click Import Save") +end + +-- ------- EXIT GAME rebuilds the launcher with the same guard + +do + local main = assert(io.open("main.lua")):read("*a") + local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") + check(body ~= nil, "main.lua still has returnToLauncher") + check(body:find("ignoreReturningPointer", 1, true) ~= nil, + "EXIT GAME asks the new launcher to ignore the leftover pointer (#2079)") +end + +T.finish("launcher_return_pointer_bug2079") diff --git a/tests/engine/printer_android_gallery_bug2103.lua b/tests/engine/printer_android_gallery_bug2103.lua new file mode 100644 index 00000000..dd5efb8f --- /dev/null +++ b/tests/engine/printer_android_gallery_bug2103.lua @@ -0,0 +1,108 @@ +-- #2103: Android diploma / dex prints write under prints/ in the app-private +-- save dir, which USB and file managers often show as empty. Printer.save +-- must call love.system.exportImage so GameActivity can copy into +-- Pictures/Gen1Recomp and media-scan the in-app file. +-- luajit tests/engine/printer_android_gallery_bug2103.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Printer = require("src.core.Printer") + +-- ------- Android: gallery bridge is called with the prints/ path + +do + local exported = {} + love.system.getOS = function() return "Android" end + love.system.exportImage = function(path) + exported[#exported + 1] = path + return true + end + + local path, err = Printer.save("diploma", 40, 36, function() end) + check(path ~= nil, "Printer.save still returns the prints/ path (" .. tostring(err) .. ")") + check(path:match("^prints/diploma_.*%.png$") ~= nil, + "the PNG stays under prints/ for the in-app copy") + eq(#exported, 1, "Android asks the native bridge to export the image") + eq(exported[1], path, "and hands it the same relative path") + eq(Printer.lastAlbumPath, "Pictures/Gen1Recomp", + "the Printed dialog can say Pictures/Gen1Recomp") + local where = Printer.savedWhereText(path) + check(where:find("Pictures/Gen1Recomp", 1, true) ~= nil, + "savedWhereText points at the public album") + check(where:find("save", 1, true) == nil + or where:find("Pictures", 1, true) ~= nil, + "and does not claim the invisible save folder alone") +end + +-- ------- Desktop: no gallery bridge call + +do + local calls = 0 + love.system.getOS = function() return "Linux" end + love.system.exportImage = function() + calls = calls + 1 + return true + end + local path = Printer.save("dex_1", 40, 36, function() end) + check(path ~= nil, "desktop print still writes") + eq(calls, 0, "desktop does not call exportImage") + eq(Printer.lastAlbumPath, nil, "desktop has no album hint") + local where = Printer.savedWhereText(path) + check(where:find(path, 1, true) ~= nil, + "desktop still names the save-folder PNG") +end + +-- ------- Missing bridge: print still succeeds (new .love on old APK) + +do + love.system.getOS = function() return "Android" end + love.system.exportImage = nil + local path = Printer.save("box_1", 40, 36, function() end) + check(path ~= nil, "print works when exportImage is absent (old APK)") + eq(Printer.lastAlbumPath, nil, "no album hint without the bridge") + local where = Printer.savedWhereText(path) + check(where:find(path, 1, true) ~= nil, + "old APK falls back to the save-folder path in the Printed dialog") + check(where:find("Pictures/Gen1Recomp", 1, true) == nil, + "and does not claim the gallery copy that never happened") +end + +-- ------- Bridge present but returns false / throws: same soft fallback + +do + love.system.getOS = function() return "Android" end + love.system.exportImage = function() error("JNI method missing") end + local path = Printer.save("surf_hiscore", 40, 20, function() end) + check(path ~= nil, "print survives a throwing exportImage bridge") + eq(Printer.lastAlbumPath, nil, "a failed bridge leaves no album hint") + + love.system.exportImage = function() return false end + path = Printer.save("box_2", 40, 20, function() end) + check(path ~= nil, "print survives exportImage returning false") + eq(Printer.lastAlbumPath, nil, "false from the bridge is not a gallery success") +end + +-- ------- Native + Lua surface pins + +do + local java = assert(io.open( + "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")):read("*a") + check(java:find("exportImageToGallery", 1, true) ~= nil, + "GameActivity exports exportImageToGallery") + check(java:find("Pictures", 1, true) ~= nil + and java:find("Gen1Recomp", 1, true) ~= nil, + "gallery target is Pictures/Gen1Recomp") + check(java:find("MediaScannerConnection", 1, true) ~= nil, + "and media-scans the in-app prints/ copy for USB") + + local wrap = assert(io.open( + "mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp")):read("*a") + check(wrap:find('"exportImage"', 1, true) ~= nil, + "love.system.exportImage is registered") +end + +T.finish("printer_android_gallery_bug2103") diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua index 7b258b64..00054006 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -300,6 +300,47 @@ do "saveFilename still resolves the flat name with no slot in use") end +-- ---------------------------------------------- returnToLauncher slot refresh +-- In-process EXIT GAME (Android/iOS) keeps the process-wide slotsChecked +-- cache. Without refreshSlotResolution, a flat SAVE written after the +-- launcher already resolved "no slots" stays invisible until cold start. + +do + local files = fresh() + T.eq(#SaveData.listSlots("red"), 0, "session starts with no slots resolved") + + local save = SaveData.newGame() + save.player.name = "EXIT" + T.check(SaveData.save(save), "in-game SAVE writes the flat legacy file") + T.check(files["save.lua"] ~= nil, "flat save.lua exists") + + T.eq(#SaveData.listSlots("red"), 0, + "without a refresh the cached 'no slots' answer sticks") + T.eq(files["saves/red/slot1.lua"], nil, "and migration has not run yet") + + SaveData.setCart("nuzlocke", "abc") + SaveData.refreshSlotResolution("red") + T.eq(SaveData.getCart(), "nuzlocke", + "refreshSlotResolution leaves the active cart alone") + + local slots = SaveData.listSlots("red") + T.eq(#slots, 1, "after refresh, listSlots migrates the flat save") + T.eq(slots[1].id, "slot1", "into slot1") + T.eq(slots[1].name, "EXIT", "with the saved player name") + T.eq(files["save.lua"], nil, "and removes the flat legacy file") + T.check(files["saves/red/slot1.lua"] ~= nil, "as saves/red/slot1.lua") +end + +do + local main = assert(io.open("main.lua")):read("*a") + local body = main:match("local function returnToLauncher%(opts%)(.-)\nend\n") + T.check(body ~= nil, "main.lua still has returnToLauncher") + T.check(body:find("refreshSlotResolution%(currentVersion%)", 1, false) ~= nil, + "EXIT GAME refreshes only the version just left") + T.check(body:find("resetSlotState", 1, true) == nil, + "and does not call resetSlotState (that would clear cart/seal state)") +end + love.filesystem = realFS T.finish("save_slots") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index 5e9379aa..a507c4d6 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -58,6 +58,9 @@ stub.graphics = { newCanvas = function(w, h) local canvas = setmetatable({ w = w, h = h, setFilter = noop, released = false }, Image) function canvas:release() self.released = true end + function canvas:newImageData() + return love.image.newImageData(self.w or 8, self.h or 8) + end return canvas end, newSpriteBatch = function(image, size) @@ -352,7 +355,13 @@ function ImageData:getPixel() return 0, 0, 0, 1 end function ImageData:setPixel() end function ImageData:mapPixel() end function ImageData:paste() end -function ImageData:encode() return { getString = function() return "" end } end +function ImageData:encode(format, filename) + local bytes = "\137PNG\r\n\26\n" + if type(filename) == "string" and love.filesystem and love.filesystem.write then + love.filesystem.write(filename, bytes) + end + return { getString = function() return bytes end } +end stub.image = { newImageData = function(a, b)