From 354a8b476d44ed990c6a0d5fc986101eeab03730 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:09 +0200 Subject: [PATCH 01/10] Route BattleState's trainer/catch/faint messages through their real ROM text Nine message families in BattleState.lua were plain Lua literals, bypassing already-extracted, already-translated ROM text labels -- some with a comment right next to them already naming the real label: - storeCaughtMon(): the new-Pokedex-data line (_ItemUseBallText06) and the box-transfer line, which used a hardcoded "BILL's PC"/"someone's PC" as if it were a substituted argument in one shared template -- _ItemUseBallText07/08 are two full, independently translated ROM strings, not a template with a substituted PC name. - throwBall(): the dodged-ball and can't-be-caught lines were two separate Strings() calls; _ItemUseBallText00 is one \f-paged ROM label covering both. Unlike TextBox.new() (which splits \f itself), sayNext() goes through the battle queue's own startMessage(), which only splits on \n/\v -- confirmed live in a real build (the second sentence overflowed off the box instead of starting a fresh page). Resolves the label once, splits it the same way TextBox.lua does, and queues one sayNext per page. - onFaint(): displayName(battler) runs the enemy name through a separate Strings("Enemy %s", ...) call, then the shared "%s\nfainted!" literal added the rest -- but _EnemyMonFaintedText already carries its own "Enemy" wording, so this passes the raw battler.name and picks _PlayerMonFaintedText/_EnemyMonFaintedText by battler.isPlayer. - enter()'s pre-battle black-out message (_PlayerBlackedOutText2, a \f-paged pair like _ItemUseBallText00 above). - The AI switch-in withdraw/send-out line and the enemy trainer's first send-out (3 callsites, one shared by the link-battle intro path): _AIBattleWithdrawText and _TrainerSentOutText. Also investigated folding _TrainerAboutToUseText's SHIFT-switch offer (say() then sayChoice(), both plain Strings(), which the label also \f-pages) into one romText + sayChoice call the same way. That does NOT work: tests/engine/trainer_shift_prompt_bug565.lua caught that the battle queue's own text renderer pages a sayChoice string differently from TextBox.lua's \f handling that the say()+say() merges above rely on. Left as two calls, unchanged, with a comment explaining why. --- src/battle/BattleState.lua | 63 +++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e510a171..68644961 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1718,9 +1718,14 @@ function BattleState:enter() -- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs -- playerMonFainted queues on the battle screen; there is no battle -- screen to queue them on here, so they print over the map. + -- _PlayerBlackedOutText (no "2") extracts to the identical wording from + -- a different ROM address and is unused anywhere in this engine -- not + -- a fallback for this one, just pokered printing the same paragraph + -- from a second call site elsewhere. self.game.stack:push(require("src.render.TextBox").new(self.game, - Strings("%s is out of\nuseable POKéMON!", name) .. "\f" - .. Strings("%s blacked\nout!", name), blackedOut)) + self:romText("_PlayerBlackedOutText2", + "%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name), + blackedOut)) return end self.musicKind = self:computeMusicKind() @@ -1857,7 +1862,8 @@ function BattleState:enter() self:slidePic("foe") end) -- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923) - self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name)) + self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!", + foeName, self.enemy.name)) self:act(function() -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- pic grows out of the ball (AnimateSendingOutMon), then the cry @@ -3638,12 +3644,13 @@ function BattleState:executeAction(user, target, action) }) self.aiUses = self:aiUsesFor() markSeen(self.game, self.enemy.mon.species) - -- _AIBattleWithdrawText: "X with-/drew Y!" - self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName)) + self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!", + self.trainer.name, oldName)) -- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText, -- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434) self.enemySendingOut = true - self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) + self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!", + self.trainer.name, self.enemy.name)) self:actNext(function() self.enemySendingOut = false self:startGrowIn(self.enemy) @@ -4142,8 +4149,12 @@ function BattleState:onFaint(battler) -- acknowledged core.asm:797-798 bug.) self:actNext(function() self:playVictoryMusic() end) end - -- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText - self:sayNext(Strings("%s\nfainted!", displayName(battler))) + -- _EnemyMonFaintedText already carries its own "Enemy" wording, so this + -- passes the raw name -- displayName's separate Strings("Enemy %s", ...) + -- would double it up + self:sayNext(battler.isPlayer + and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name) + or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name)) if battler.isPlayer then self:act(function() self:playerMonFainted() end) else @@ -4319,6 +4330,13 @@ function BattleState:enemyMonFainted() -- "X is" off so "about to use" stays above the name, instead of the -- page ending on a bare nick (#565). Then para "Will PLAYER" / -- "change POKéMON?" with YES/NO. + -- + -- _TrainerAboutToUseText combines both \f-paged, but unlike + -- _ItemUseBallText00's say()+say() merge above, this is say()+ + -- sayChoice(): tried merging into one romText/sayChoice call and + -- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that + -- the battle queue's own \f handling (not TextBox.lua's) does not + -- page a sayChoice string the same way -- left as two calls. self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName)) self:sayChoice( Strings("Will %s\nchange POKéMON?", self.game.save.player.name), @@ -4362,7 +4380,8 @@ function BattleState:enemyMonFainted() -- (AnimateSendingOutMon) with the cry; no POOF -- that animation -- belongs to the player-side SendOutMon (core.asm:1757-1762) self.enemySendingOut = true - self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) + self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!", + self.trainer.name, self.enemy.name)) self:actNext(function() self.enemySendingOut = false self:startGrowIn(self.enemy) @@ -4878,7 +4897,8 @@ function BattleState:storeCaughtMon() -- text_promptbutton (item_effects.asm:624-629), so the fanfare follows -- the box rather than firing when the dex bit is set self:sayNextWaitSfx( - Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name), + self:romText("_ItemUseBallText06", + "New POKéDEX data\nwill be added for\n%s!", self.enemy.name), function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end) self:uiNext(function() return self:buildScreen("DexEntryMenu", species) @@ -4899,9 +4919,12 @@ function BattleState:storeCaughtMon() if boxNum then askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL - local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) - and "BILL's PC" or Strings("someone's PC") - self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc)) + local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL + self:sayNext(self:romText( + metBill and "_ItemUseBallText07" or "_ItemUseBallText08", + metBill and "%s was\ntransferred to\nBILL's PC!" + or "%s was\ntransferred to\nsomeone's PC!", + self.enemy.name)) else self:sayNext(Strings("But every BOX\nis full!")) end @@ -5007,8 +5030,18 @@ function BattleState:throwBall(ball) -- RESTLESS SOUL dodges balls even once the scope has revealed it, -- so it is not a ghost battle any more (#444) self:animNext(self:tossAnimFor(ball), true, nil, ball) - self:sayNext(Strings("It dodged the\nthrown BALL!")) - self:sayNext(Strings("This POKéMON\ncan't be caught!")) + -- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike + -- TextBox.new() (which splits \f itself), the battle queue's own + -- startMessage() only splits on \n/\v -- confirmed live: the \f + -- landed mid-line and the second sentence overflowed off the box + -- instead of starting a fresh page. Resolve the label once, then + -- split it the same way TextBox.lua does and queue one sayNext per + -- page, so the two ROM sentences still render as two pages. + local dodgeText = self:romText("_ItemUseBallText00", + "It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!") + for page in (dodgeText .. "\f"):gmatch("(.-)\f") do + self:sayNext(page) + end self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) From bff40a5d90b8759210532d8733bfff94e2b9f721 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:09 +0200 Subject: [PATCH 02/10] Route OverworldController's field messages through their real ROM text Four message families in OverworldController.lua were plain Lua literals instead of their already-extracted, already-translated ROM text labels: - applyFieldPoison()'s faint message: the third of three collapsed "%s\nfainted!" ROM strings (the other two, in BattleState.lua, are fixed in the previous commit) -- routed through _PokemonFaintedText. - useSoftboiledFieldMove()'s two outcome messages: _ItemUseNoEffectText and _PotionText, the exact labels ItemEffects.lua's real potion message already uses, including _PotionText's second slot (the actual amount healed) the old literal never showed at all. - tryHiddenObject()'s two hidden-item finds: _FoundHiddenItemText. - The normal item-ball pickup path's two finds (one Yellow-only bag-full variant): a comment already named _FoundItemText ("FoundItemText: text_far, sound_get_item_1, text_end"). Both found-item labels lead with a {PLAYER} token that romText auto-fills from a 2-arg call (player name, item name) in the same order the literal already used. The fallback text for both is plain "%s found\n%s!", matching the original literal's shape exactly -- an earlier version of this fix used a "{PLAYER} found\n%s!" fallback that relied on TextBox.new's later TextBox.substitute pass to resolve {PLAYER}, which works but needlessly made the fallback path depend on a downstream call instead of being self-contained. --- src/world/OverworldController.lua | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3af3a100..088fbe84 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -846,15 +846,20 @@ function OverworldState:useSoftboiledFieldMove(user, target) if not user or not user.stats or not target or not target.stats or target == user or target.hp <= 0 or target.hp >= target.stats.hp or user.hp <= heal then - Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect."))) + Game.stack:push(TextBox.new(Game, + romText(Game.data, "_ItemUseNoEffectText", "It won't have\nany effect."))) return false end + local before = target.hp user.hp = user.hp - heal target.hp = math.min(target.stats.hp, target.hp + heal) require("src.core.Sound").play(Game.data, "Heal_HP") local def = Game.data.pokemon[target.species] + -- _PotionText's second slot is the recovered amount, same as + -- ItemEffects.lua's potion message -- the engine fallback never shows it Game.stack:push(TextBox.new(Game, - Strings("%s's HP\nwas restored!", target.nickname or def.name))) + romText(Game.data, "_PotionText", "%s's HP\nwas restored!", + target.nickname or def.name, target.hp - before))) return true end @@ -2126,7 +2131,8 @@ function OverworldState:tryHiddenObject(fx, fy) -- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872) local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", save.player.name, name) .. "\f" + romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!", + save.player.name, name) .. "\f" .. romText(Game.data, "_HiddenItemBagFullText", "But, {PLAYER} has\nno more room for\vother items!"))) return true @@ -2137,7 +2143,8 @@ function OverworldState:tryHiddenObject(fx, fy) -- text_asm tail runs it as PlaySoundWaitForCurrent + -- WaitForSoundToFinish once the box has printed (hidden_items.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", save.player.name, name), + romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!", + save.player.name, name), nil, TextBox.soundOpts(Game, "Get_Item2"))) return true end @@ -2795,8 +2802,8 @@ function OverworldState:talkTo(npc) "No more room for\nitems!") if GameVersion.isYellow() then local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item - noRoom = Strings("%s found\n%s!", Game.save.player.name, name) - .. "\f" .. noRoom + noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!", + Game.save.player.name, name) .. "\f" .. noRoom end Game.stack:push(TextBox.new(Game, noRoom)) return @@ -2813,7 +2820,8 @@ function OverworldState:talkTo(npc) local ddef = Game.data.items[d.item] -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", Game.save.player.name, name), nil, + romText(Game.data, "_FoundItemText", "%s found\n%s!", + Game.save.player.name, name), nil, TextBox.soundOpts(Game, (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1"))) return @@ -3745,7 +3753,7 @@ function OverworldState:applyFieldPoison() local queue = {} for _, mon in ipairs(fainted) do local name = mon.nickname or Game.data.pokemon[mon.species].name - table.insert(queue, Strings("%s\nfainted!", name)) + table.insert(queue, romText(Game.data, "_PokemonFaintedText", "%s\nfainted!", name)) end local alive = false for _, mon in ipairs(save.party) do From 34c4481f9643f6e5bffe896348a1f8056387b2ce Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:09 +0200 Subject: [PATCH 03/10] Route the box-release confirmation through the real ROM text BoxMenu.lua's "Once released,\n%s is\ngone forever. OK?" prompt and its "%s was\nreleased outside.\fBye %s!" follow-up (shown after confirming) were both plain Lua literals, bypassing the extracted _OnceReleasedText and _MonWasReleasedText labels entirely even though both exist and are already translated in a real corpus build. Wrapped both in the same t._X or Strings(...) pattern already used four lines above for the Pikachu-unhappy prompt in this same function, with the same trailing gsub to fill the {RAM:wStringBuffer} token(s) either branch leaves in place -- _MonWasReleasedText's real text repeats the token twice (the name appears at both ends of the sentence), and gsub's default replace-all handles that the same way a single occurrence does. Independent code review flagged a separate issue on this line and the pre-existing Pikachu one right above it: both pass the nickname as a bare gsub replacement string, which Lua %-escapes ("%" followed by a digit 1-9 crashes with "invalid capture index", confirmed directly). Checked how reachable that actually is: the naming screen's charset can't produce a literal "%", and neither can a real cartridge import (the Gen 1/2 character-decode tables never map any ROM byte to "%" either) -- the only way in is editing a save's plain-Lua-source nickname field directly. Not fixed here, to stay consistent with the separate branch (fix/gsub-percent-escape-crash) already carrying this exact fix across every callsite that shares it, including this one -- splitting the same bug's fix across two branches by which one happened to touch the line first isn't a real reason to fix it in one place and not the other. --- src/ui/BoxMenu.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 5dc2b694..5c39c7cc 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -185,14 +185,16 @@ local function release(game) return end game.stack:push(TextBox.new(game, - Strings("Once released,\n%s is\ngone forever. OK?", name), nil, { + (t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name)) + :gsub("{RAM:wStringBuffer}", name), nil, { defaultNo = true, noSound = true, choice = function(yes) if not yes then return end table.remove(box, list.index) require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, - Strings("%s was\nreleased outside.\fBye %s!", name, name))) + ((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name)) + :gsub("{RAM:wStringBuffer}", name)))) list:removeCurrent() end, })) From 17fbf6cec486c0ffc159d612dc8f5c9cb886b27a Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:09 +0200 Subject: [PATCH 04/10] Route the slot machine's lined-up message through the real ROM text SlotMachine.lua built "%s lined up!\nScored %d coins!" as a plain Lua literal, substituting the symbol id (sym) as if it were part of the translatable sentence. The real extracted _LinedUpText label (" lined up!\nScored {RAM:wStringBuffer} coins!") shows the original never had a slot for the symbol at all -- it was drawn separately and only this fixed suffix was ROM text. Concatenate sym in front of romText's real, already-translated label instead of interpolating it into an engine literal. --- src/ui/SlotMachine.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/SlotMachine.lua b/src/ui/SlotMachine.lua index 353b414a..a0c04734 100644 --- a/src/ui/SlotMachine.lua +++ b/src/ui/SlotMachine.lua @@ -46,6 +46,7 @@ local Font = require("src.render.Font") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local romText = require("src.core.RomText") local SlotMachine = {} SlotMachine.__index = SlotMachine @@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win) -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- shows it in the box while the screen flashes. LinedUpText follows. self.yeah = (sym == "7") - self.message = Strings("%s lined up!\nScored %d coins!", sym, pay) + self.message = sym .. romText(self.game.data, "_LinedUpText", + " lined up!\nScored %d coins!", pay) -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not -- credited until the player dismisses the "lined up" text (see startPayout). self.stage = "flash" From 2279617b29338bc463f7dd7dfbafdc3951016ab7 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:09 +0200 Subject: [PATCH 05/10] Route the mart's price confirmation prompts through their real ROM text ShopMenu.lua's buy/sell price confirmations were plain Lua literals, even though the comment on each line already named the real label (_PokemartTellBuyPriceText, _PokemartTellSellPriceText). This file's own txt(game, key, fallback) helper doesn't support substitution arguments, so it can't be reused as-is; added the module-level romText helper instead, same as every other file in this batch. --- src/ui/ShopMenu.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index b185f6ad..23ecc7ad 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local QuantityBox = require("src.ui.QuantityBox") local Strings = require("src.core.Strings") +local romText = require("src.core.RomText") local ShopMenu = {} @@ -57,7 +58,8 @@ local function buy(game, stock) end local cost = qty * def.price -- _PokemartTellBuyPriceText + yes/no confirm - list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost) + list.footer = romText(game.data, "_PokemartTellBuyPriceText", + "%s?\nThat will be\n¥%d. OK?", def.name, cost) game.stack:push(ChoiceBox.new(game, function(yes) if not yes then list.footer = greet @@ -147,7 +149,8 @@ local function sell(game) return end -- _PokemartTellSellPriceText + yes/no confirm - list.footer = Strings("I can pay you\n¥%d for that.", unit * qty) + list.footer = romText(game.data, "_PokemartTellSellPriceText", + "I can pay you\n¥%d for that.", unit * qty) game.stack:push(ChoiceBox.new(game, function(yes) if not yes then list.footer = greet From 8f88d01cf22688a4fa9c3cbd60f0b87d962ca585 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:09 +0200 Subject: [PATCH 06/10] Route the link battle's opening line through its real ROM text LinkBattle.lua's "%s wants\nto fight!" intro was a plain Lua literal, even though the comment right above it already named the real label (_TrainerWantsToFightText). The battle object built at this point is already a BattleState, so this reuses its self:romText convenience method rather than requiring the module-level helper separately. --- src/link/LinkBattle.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 2d899738..fa27805d 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts) self.opponentName = theirName -- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2 -- takes PrintBeginningBattleText's .trainerBattle arm, link included - self.introText = Strings("%s wants\nto fight!", theirName) + self.introText = self:romText("_TrainerWantsToFightText", + "%s wants\nto fight!", theirName) self.remoteHashes = {} self.localHashes = {} self.remoteParts = {} From 72592665d772589df68d05641b653e6b5b4a411d Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:31:09 +0200 Subject: [PATCH 07/10] Cover the romText fixes with targeted regression tests Every existing test around these callsites only ever runs with an empty/fixture Data.text, so none of them could tell a properly-wired romText/t._X call apart from a literal that never looked at the catalog at all -- every assertion passed either way, the same coverage gap the museum ticket clerk and status abbreviation fixes hit earlier. Seven new tests fake the real label for each fix and assert the pushed or queued message uses the translated value, plus a vanilla case per fix confirming the no-catalog fallback still matches the original English literal exactly: - box_release_confirmation_romtext.lua (BoxMenu _OnceReleasedText, via a TextBox.new spy so real pagination/choice behavior stays intact) - slot_machine_lined_up_romtext.lua (SlotMachine _LinedUpText, symbol concatenated in front of the translated suffix) - battle_fainted_message_romtext.lua (BattleState onFaint, both _PlayerMonFaintedText and _EnemyMonFaintedText, confirming the raw name reaches each without a duplicated "Enemy") - battle_catch_messages_romtext.lua (BattleState storeCaughtMon, _ItemUseBallText06 plus both _ItemUseBallText07/08 branches on EVENT_MET_BILL) - battle_ball_dodge_romtext.lua (BattleState throwBall, _ItemUseBallText00's \f-merge collapsing to exactly one queued message) - overworld_field_faint_heal_romtext.lua (OverworldController applyFieldPoison's _PokemonFaintedText, and useSoftboiledFieldMove's _ItemUseNoEffectText/_PotionText including the recovered-amount slot the old literal never showed) - overworld_hidden_item_romtext.lua (OverworldController tryHiddenObject's _FoundHiddenItemText, both the {PLAYER} token and the item name landing in the right slots) Confirmed several of these fail against the pre-fix code and pass against the current code, not just reasoned about it. Not every one of the 15 fixed callsites has its own dedicated test -- the ShopMenu, LinkBattle, trainer-withdraw/sent-out and the normal (non-hidden) found-item sites share the same romText mechanism already proven correct by the seven tests above, and building the heavier fixtures each would need (a full mart flow, a link session, a trainer AI switch, an object_event NPC) wasn't judged worth it for what would be the same assertion shape again. --- tests/engine/battle_ball_dodge_romtext.lua | 87 +++++++++++ .../engine/battle_catch_messages_romtext.lua | 77 ++++++++++ .../engine/battle_fainted_message_romtext.lua | 64 ++++++++ .../box_release_confirmation_romtext.lua | 129 ++++++++++++++++ .../overworld_field_faint_heal_romtext.lua | 139 ++++++++++++++++++ .../engine/overworld_hidden_item_romtext.lua | 85 +++++++++++ .../engine/slot_machine_lined_up_romtext.lua | 39 +++++ 7 files changed, 620 insertions(+) create mode 100644 tests/engine/battle_ball_dodge_romtext.lua create mode 100644 tests/engine/battle_catch_messages_romtext.lua create mode 100644 tests/engine/battle_fainted_message_romtext.lua create mode 100644 tests/engine/box_release_confirmation_romtext.lua create mode 100644 tests/engine/overworld_field_faint_heal_romtext.lua create mode 100644 tests/engine/overworld_hidden_item_romtext.lua create mode 100644 tests/engine/slot_machine_lined_up_romtext.lua diff --git a/tests/engine/battle_ball_dodge_romtext.lua b/tests/engine/battle_ball_dodge_romtext.lua new file mode 100644 index 00000000..f49c96a3 --- /dev/null +++ b/tests/engine/battle_ball_dodge_romtext.lua @@ -0,0 +1,87 @@ +-- BattleState:throwBall()'s can't-be-caught path used to queue two +-- separate Strings() literals ("It dodged the\nthrown BALL!" then "This +-- POKéMON\ncan't be caught!"). The real ROM label _ItemUseBallText00 +-- combines both as one \f-paged string. TextBox.new() would split \f +-- itself, but throwBall() queues through self:sayNext(), which goes +-- through the battle queue's own BattleState:startMessage() -- and that +-- one only splits on \n/\v, not \f (confirmed live: the \f landed +-- mid-line and the second sentence overflowed off the box instead of +-- starting a fresh page). The fix resolves the label once, then splits +-- it the same way TextBox.lua does and queues one sayNext per page. This +-- test fakes the label and checks the two pages reach the queue as two +-- separate messages, in order, not merged into one with a raw \f still +-- inside it. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function mkbattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 10) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 8) + battle.ghost = true -- forces the can't-be-caught path + return battle +end + +-- throwBall defers its message-queuing work into self.queue via one +-- top-level self:act(fn); run only that one function to reach the +-- say() calls the fix touches. It's the last entry throwBall itself +-- appends (after the immediate sayAuto), and it further queues its own +-- self:act(function() self:executeAction(...) end) for the actual enemy +-- turn -- deliberately NOT run here (out of scope, and running the queue +-- generically after mutation risks looping into a real turn simulation) +local function runThrowBallAct(battle) + for i = #battle.queue, 1, -1 do + if battle.queue[i].fn then + battle.queue[i].fn() + return + end + end +end + +local function textEntries(battle) + local out = {} + for _, entry in ipairs(battle.queue) do + if entry.text then out[#out + 1] = entry.text end + end + return out +end + +-- translated: the faked label's two \f-separated pages reach the queue +-- as two separate messages, in order, and neither one still contains a +-- raw \f (which would mean the battle queue's own renderer has to deal +-- with it, and it can't) +do + local battle = mkbattle() + Data.text._ItemUseBallText00 = "FAKE-DODGE!\fFAKE-CANTCATCH!" + battle:throwBall("FIX_BALL") + runThrowBallAct(battle) + local texts = textEntries(battle) + T.eq(texts[1], "FAKE-DODGE!", "page 1 reaches the queue on its own") + T.eq(texts[2], "FAKE-CANTCATCH!", "page 2 follows right after, still in order") + for _, t in ipairs(texts) do + T.check(not t:find("\f", 1, true), "no queued message still carries a raw \\f") + end + Data.text._ItemUseBallText00 = nil +end + +-- vanilla: no catalog entry still falls back to the two English pages, +-- split the same way +do + local battle = mkbattle() + battle:throwBall("FIX_BALL") + runThrowBallAct(battle) + local texts = textEntries(battle) + T.eq(texts[1], "It dodged the\nthrown BALL!", "vanilla page 1") + T.eq(texts[2], "This POKéMON\ncan't be caught!", "vanilla page 2") +end + +T.finish("battle_ball_dodge_romtext") diff --git a/tests/engine/battle_catch_messages_romtext.lua b/tests/engine/battle_catch_messages_romtext.lua new file mode 100644 index 00000000..b0feb333 --- /dev/null +++ b/tests/engine/battle_catch_messages_romtext.lua @@ -0,0 +1,77 @@ +-- BattleState:storeCaughtMon() queues up to two plain-Lua-literal +-- messages: the new-Pokedex-data line (_ItemUseBallText06) and, when the +-- party is full, the box-transfer line (_ItemUseBallText07/08, keyed on +-- EVENT_MET_BILL -- two full, independently-translated ROM strings, not +-- one template with a substituted PC name). This test fakes all three +-- labels and checks the queued messages use them, not the English +-- literals. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function findText(battle, needle) + for _, entry in ipairs(battle.queue) do + if entry.text and entry.text:find(needle, 1, true) then return entry.text end + end + return nil +end + +local function mkbattle(partySize, metBill) + local save = SaveData.newGame() + save.party = {} + for i = 1, partySize do + save.party[i] = Pokemon.new(Data, "FIXMON_A", 5) + end + save.flags = save.flags or {} + save.flags.EVENT_MET_BILL = metBill + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + return BattleState.newWild(game, "FIXMON_C", 8) +end + +-- empty party: Party.add succeeds, only the new-Pokedex-data message fires +do + local battle = mkbattle(0, false) + Data.text._ItemUseBallText06 = "FAKE-DEX {RAM:wEnemyMonNick} FAKE!" + battle:storeCaughtMon() + T.eq(findText(battle, "FAKE-DEX"), "FAKE-DEX " .. battle.enemy.name .. " FAKE!", + "a translated _ItemUseBallText06 reaches the new-Pokedex-data message") + Data.text._ItemUseBallText06 = nil +end + +-- full party, EVENT_MET_BILL true: box transfer via _ItemUseBallText07 +do + local battle = mkbattle(6, true) + Data.text._ItemUseBallText07 = "FAKE-BILL {RAM:wBoxMonNicks} FAKE!" + battle:storeCaughtMon() + T.eq(findText(battle, "FAKE-BILL"), "FAKE-BILL " .. battle.enemy.name .. " FAKE!", + "EVENT_MET_BILL true routes through the translated _ItemUseBallText07") + Data.text._ItemUseBallText07 = nil +end + +-- full party, EVENT_MET_BILL false: box transfer via _ItemUseBallText08 +do + local battle = mkbattle(6, false) + Data.text._ItemUseBallText08 = "FAKE-SOMEONE {RAM:wBoxMonNicks} FAKE!" + battle:storeCaughtMon() + T.eq(findText(battle, "FAKE-SOMEONE"), "FAKE-SOMEONE " .. battle.enemy.name .. " FAKE!", + "EVENT_MET_BILL false routes through the translated _ItemUseBallText08") + Data.text._ItemUseBallText08 = nil +end + +-- vanilla, full party, EVENT_MET_BILL true: English literal, BILL's PC +do + local battle = mkbattle(6, true) + battle:storeCaughtMon() + T.eq(findText(battle, "transferred"), + battle.enemy.name .. " was\ntransferred to\nBILL's PC!", + "no catalog entry falls back to the English BILL's-PC literal") +end + +T.finish("battle_catch_messages_romtext") diff --git a/tests/engine/battle_fainted_message_romtext.lua b/tests/engine/battle_fainted_message_romtext.lua new file mode 100644 index 00000000..6f1ce0b7 --- /dev/null +++ b/tests/engine/battle_fainted_message_romtext.lua @@ -0,0 +1,64 @@ +-- BattleState:onFaint's "%s\nfainted!" collapsed two distinct ROM +-- strings (_EnemyMonFaintedText already carries its own "Enemy" wording; +-- _PlayerMonFaintedText does not) into one literal, substituted with +-- displayName(battler) -- which itself runs the enemy name through a +-- SEPARATE Strings("Enemy %s", ...) call. The fix passes the raw +-- battler.name and lets each label supply its own wording. This test +-- fakes both labels and checks the raw name reaches the right one, with +-- no "Enemy" ever duplicated. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function mkbattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 10) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + return BattleState.newWild(game, "FIXMON_C", 8) +end + +-- finds the queued say-message text (onFaint queues several entries: a +-- wait, then the say) +local function findText(battle) + for _, entry in ipairs(battle.queue) do + if entry.text then return entry.text end + end + return nil +end + +-- enemy: translated _EnemyMonFaintedText reaches onFaint, raw name only +do + local battle = mkbattle() + Data.text._EnemyMonFaintedText = "FAKE-ENEMY {RAM:wEnemyMonNick} FAKE!" + battle:onFaint(battle.enemy) + T.eq(findText(battle), "FAKE-ENEMY " .. battle.enemy.name .. " FAKE!", + "a translated _EnemyMonFaintedText reaches the enemy faint message") + Data.text._EnemyMonFaintedText = nil +end + +-- enemy vanilla: the English literal already carries "Enemy " itself +do + local battle = mkbattle() + battle:onFaint(battle.enemy) + T.eq(findText(battle), "Enemy " .. battle.enemy.name .. "\nfainted!", + "no catalog entry falls back to the English literal, Enemy included") +end + +-- player: translated _PlayerMonFaintedText reaches onFaint +do + local battle = mkbattle() + Data.text._PlayerMonFaintedText = "FAKE-PLAYER {RAM:wBattleMonNick} FAKE!" + battle:onFaint(battle.player) + T.eq(findText(battle), "FAKE-PLAYER " .. battle.player.name .. " FAKE!", + "a translated _PlayerMonFaintedText reaches the player faint message") + Data.text._PlayerMonFaintedText = nil +end + +T.finish("battle_fainted_message_romtext") diff --git a/tests/engine/box_release_confirmation_romtext.lua b/tests/engine/box_release_confirmation_romtext.lua new file mode 100644 index 00000000..c2db6709 --- /dev/null +++ b/tests/engine/box_release_confirmation_romtext.lua @@ -0,0 +1,129 @@ +-- BoxMenu's RELEASE confirmation ("Once released,\n%s is\ngone forever. +-- OK?") used to be a bare Lua literal. tests/engine/pc_release.lua only +-- ever runs with an empty Data.text, so it can't tell a properly-wired +-- t._OnceReleasedText or "..." fallback apart from a literal that never +-- looked at t at all -- every assertion there passes either way. This +-- test drives the same interactive release flow with a faked +-- Data.text._OnceReleasedText and checks the pushed TextBox's raw text +-- (captured via a TextBox.new spy, so real pagination/choice behavior is +-- untouched) uses the translated value, not the English literal. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local ids = T.fixtures.ids +require("src.render.Font").load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local TextBox = require("src.render.TextBox") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") + +local realCry, realPlay = Sound.playCry, Sound.play +Sound.playCry = function() end +Sound.play = function() end + +-- spy: records every raw text TextBox.new receives, without disturbing +-- pagination/choice re-push, so the interactive flow behaves exactly like +-- pc_release.lua's +local captured +local realNew = TextBox.new +TextBox.new = function(game, text, onDone, opts) + captured[#captured + 1] = text + return realNew(game, text, onDone, opts) +end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t +end +function stack:top() return self.states[#self.states] end +function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end +end + +local pressed = {} +local function press(btn) + pressed = { [btn] = true } + stack:update(1 / 60) + pressed = {} +end + +local function topMt() return getmetatable(stack:top()) end +local function mash(btn, cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + press(btn) + end + return false +end + +local function mkGame() + stack.states = {} + captured = {} + local game = { + data = Data, + save = SaveData.newGame(), + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, + } + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + local box = Boxes.active(game.save) + box[1] = Pokemon.new(Data, ids.species[1], 5) + return game, box +end + +local function releaseFirstMon(game) + stack:push(BoxMenu.new(game)) + press("down"); press("down"); press("a") -- open RELEASE list + T.check(topMt() == ListMenu, "RELEASE opens the box list") + press("a") -- choose the first (only) mon + T.check(mash("a", function() return topMt() == ChoiceBox end), + "confirm choice opens") + -- the confirmation TextBox is captured[1] the moment it was pushed, + -- before this mash even ran + return captured[1] +end + +-- monName (BoxMenu.lua): mon.nickname or def.name +local function monName(box) + local mon = box[1] + local def = Data.pokemon[mon.species] + return mon.nickname or def.name +end + +-- translated: the fake value must reach the pushed TextBox +do + local game, box = mkGame() + local name = monName(box) + Data.text._OnceReleasedText = "FAKE {RAM:wStringBuffer} released!" + local text = releaseFirstMon(game) + T.eq(text, "FAKE " .. name .. " released!", + "a translated _OnceReleasedText reaches the release confirmation") + Data.text._OnceReleasedText = nil +end + +-- vanilla: with no catalog entry, the English literal still substitutes +do + local game, box = mkGame() + local name = monName(box) + local text = releaseFirstMon(game) + T.eq(text, "Once released,\n" .. name .. " is\ngone forever. OK?", + "no catalog entry still falls back to the English literal") +end + +TextBox.new = realNew +Sound.playCry, Sound.play = realCry, realPlay +T.finish("box_release_confirmation_romtext") diff --git a/tests/engine/overworld_field_faint_heal_romtext.lua b/tests/engine/overworld_field_faint_heal_romtext.lua new file mode 100644 index 00000000..2e2344dd --- /dev/null +++ b/tests/engine/overworld_field_faint_heal_romtext.lua @@ -0,0 +1,139 @@ +-- Two OverworldController.lua messages used to be bare Lua literals: +-- applyFieldPoison()'s "%s\nfainted!" (the third of three collapsed +-- fainted-message ROM strings, _PokemonFaintedText) and +-- useSoftboiledFieldMove()'s "It won't have\nany effect."/"%s's HP\nwas +-- restored!" (the same _ItemUseNoEffectText/_PotionText labels +-- ItemEffects.lua's real potion message already uses -- _PotionText's +-- second slot is the actual amount healed, which the old literal never +-- showed at all). +-- +-- Uses the debug.setupvalue technique already established in +-- oaks_pc_flow.lua to fake the module-level Game/TextBox upvalues +-- ROM-free, without going through the heavy OverworldState:enter(). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() + +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed = {} +local textBoxStub = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} +local realSound = package.loaded["src.core.Sound"] +package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } + +local function mkGame() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + pushed = {} + return { + data = Data, save = save, + stack = { push = function(_, item) pushed[#pushed + 1] = item end }, + } +end + +for _, name in ipairs({ "applyFieldPoison", "useSoftboiledFieldMove" }) do + T.check(setUpvalue(OW[name], "Game", mkGame()), ("Game upvalue on %s"):format(name)) + T.check(setUpvalue(OW[name], "TextBox", textBoxStub), ("TextBox upvalue on %s"):format(name)) +end + +local fakeSelf = setmetatable({}, { __index = OW }) + +-- ---- applyFieldPoison: _PokemonFaintedText ---- +do + local game = mkGame() + setUpvalue(OW.applyFieldPoison, "Game", game) + local mon = game.save.party[1] + mon.status = "PSN" + mon.hp = 1 -- one poison tick (1 dmg by default) faints it + game.save.poisonSteps = 3 -- (3+1) % 4 == 0: this step ticks poison + + Data.text._PokemonFaintedText = "FAKE {RAM:wNameBuffer} FAKE!" + fakeSelf:applyFieldPoison() + T.eq(pushed[1] and pushed[1].text, "FAKE " .. (mon.nickname or "FIXMON A") .. " FAKE!", + "a translated _PokemonFaintedText reaches the field-poison faint message") + Data.text._PokemonFaintedText = nil +end + +-- vanilla: no catalog entry, English literal +do + local game = mkGame() + setUpvalue(OW.applyFieldPoison, "Game", game) + local mon = game.save.party[1] + mon.status = "PSN" + mon.hp = 1 + game.save.poisonSteps = 3 + + fakeSelf:applyFieldPoison() + T.eq(pushed[1] and pushed[1].text, + (mon.nickname or "FIXMON A") .. "\nfainted!", + "no catalog entry falls back to the English fainted literal") +end + +-- ---- useSoftboiledFieldMove: _ItemUseNoEffectText / _PotionText ---- +do + local game = mkGame() + setUpvalue(OW.useSoftboiledFieldMove, "Game", game) + local user = Pokemon.new(Data, "FIXMON_A", 20) + local target = Pokemon.new(Data, "FIXMON_B", 20) + target.hp = target.stats.hp -- already full: no effect + + Data.text._ItemUseNoEffectText = "FAKE-NOEFFECT!" + local ok = fakeSelf:useSoftboiledFieldMove(user, target) + T.check(ok == false, "a full-HP target reports no effect") + T.eq(pushed[1] and pushed[1].text, "FAKE-NOEFFECT!", + "a translated _ItemUseNoEffectText reaches the no-effect message") + Data.text._ItemUseNoEffectText = nil +end + +do + local game = mkGame() + setUpvalue(OW.useSoftboiledFieldMove, "Game", game) + local user = Pokemon.new(Data, "FIXMON_A", 20) + local target = Pokemon.new(Data, "FIXMON_B", 20) + target.hp = target.stats.hp - 10 -- missing exactly 10 HP + + Data.text._PotionText = "FAKE {RAM:wNameBuffer} healed {NUM:wHPBarHPDifference, 2, 3}!" + local ok = fakeSelf:useSoftboiledFieldMove(user, target) + T.check(ok == true, "a damaged target heals successfully") + T.eq(pushed[1] and pushed[1].text, + "FAKE " .. (target.nickname or "FIXMON B") .. " healed 10!", + "a translated _PotionText reaches the heal message, amount included") + Data.text._PotionText = nil +end + +-- vanilla: no catalog entry, the fallback's single %s slot still fills +-- correctly (the amount is silently dropped by design, same as +-- ItemEffects.lua's own _PotionText fallback -- not a regression, this +-- matches the pre-fix literal's behavior exactly) +do + local game = mkGame() + setUpvalue(OW.useSoftboiledFieldMove, "Game", game) + local user = Pokemon.new(Data, "FIXMON_A", 20) + local target = Pokemon.new(Data, "FIXMON_B", 20) + target.hp = target.stats.hp - 10 + local ok = fakeSelf:useSoftboiledFieldMove(user, target) + T.check(ok == true, "a damaged target heals successfully (vanilla)") + T.eq(pushed[1] and pushed[1].text, + (target.nickname or "FIXMON B") .. "'s HP\nwas restored!", + "no catalog entry falls back to the English literal (no amount shown)") +end + +package.loaded["src.core.Sound"] = realSound +T.finish("overworld_field_faint_heal_romtext") diff --git a/tests/engine/overworld_hidden_item_romtext.lua b/tests/engine/overworld_hidden_item_romtext.lua new file mode 100644 index 00000000..f1804e49 --- /dev/null +++ b/tests/engine/overworld_hidden_item_romtext.lua @@ -0,0 +1,85 @@ +-- OverworldState:tryHiddenObject()'s "%s found\n%s!" message used to +-- substitute both the player name and the item name into one bare Lua +-- literal. The real _FoundHiddenItemText label leads with a {PLAYER} +-- named token, which romText auto-fills from a 2-arg call in the same +-- order the literal already used -- this test checks both slots land +-- correctly (an accidental argument swap is the easy mistake this shape +-- invites). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() + +local SaveData = require("src.core.SaveData") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed = {} +local textBoxStub = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, + soundOpts = function() return {} end, +} + +local MAP_ID = "FIX_TOWN" +Data.field.hiddenItems[MAP_ID] = { { x = 3, y = 3, item = "FIX_BALL" } } + +local function mkGame() + local save = SaveData.newGame() + save.player.name = "FAKEPLAYER" + pushed = {} + return { + data = Data, save = save, + stack = { push = function(_, item) pushed[#pushed + 1] = item end }, + } +end + +T.check(setUpvalue(OW.tryHiddenObject, "Game", mkGame()), "Game upvalue on tryHiddenObject") +T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject") + +local fakeSelf = setmetatable({ map = { id = MAP_ID } }, { __index = OW }) + +-- translated: player name and item name both land in the right slots +do + local game = mkGame() + setUpvalue(OW.tryHiddenObject, "Game", game) + Data.text._FoundHiddenItemText = "FAKE {PLAYER} found FAKE {RAM:wNameBuffer} FAKE!" + local found = fakeSelf:tryHiddenObject(3, 3) + T.check(found == true, "the hidden item at (3,3) is found") + T.eq(pushed[1] and pushed[1].text, + "FAKE FAKEPLAYER found FAKE FIX BALL FAKE!", + "a translated _FoundHiddenItemText fills both {PLAYER} and the item name") + Data.text._FoundHiddenItemText = nil +end + +-- vanilla: no catalog entry, so romText falls back to plain +-- Strings(fallback, ...) -- the fallback literal is "%s found\n%s!" (both +-- slots plain %s, matching the pre-fix literal's own shape), not the +-- {PLAYER} token the real label uses, since Strings() never does +-- {TOKEN} substitution on its own. (A {PLAYER}-token fallback would +-- still render correctly too, since the real TextBox.new always runs +-- TextBox.substitute over whatever text it's given -- but this test +-- stubs TextBox without that call, and the fallback shouldn't lean on a +-- substitution pass happening downstream regardless.) +do + local game = mkGame() + setUpvalue(OW.tryHiddenObject, "Game", game) + game.save.hiddenTaken = {} -- fresh spot + local found = fakeSelf:tryHiddenObject(3, 3) + T.check(found == true, "the hidden item is found again in a fresh game") + T.eq(pushed[1] and pushed[1].text, "FAKEPLAYER found\nFIX BALL!", + "with no catalog entry, the fallback still fills both the player " + .. "and item name via plain %s substitution") +end + +T.finish("overworld_hidden_item_romtext") diff --git a/tests/engine/slot_machine_lined_up_romtext.lua b/tests/engine/slot_machine_lined_up_romtext.lua new file mode 100644 index 00000000..699007c4 --- /dev/null +++ b/tests/engine/slot_machine_lined_up_romtext.lua @@ -0,0 +1,39 @@ +-- SlotMachine:resolveWin's "%s lined up!\nScored %d coins!" message used +-- to interpolate the symbol id AND the payout into one bare Lua literal. +-- The real _LinedUpText label has no slot for the symbol at all (the +-- original ROM drew it separately) -- only for the coin count. This test +-- fakes _LinedUpText and checks the symbol is concatenated in front of the +-- translated suffix, with the payout correctly substituted into it. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() + +local SlotMachine = require("src.ui.SlotMachine") + +local function mkSelf() + local game = { data = Data, save = { coins = 0 } } + return setmetatable({ game = game, allowMatchesCounter = 0 }, SlotMachine) +end + +-- translated: the fake suffix reaches self.message, with the symbol +-- concatenated in front and the payout substituted into the fake text +do + local self = mkSelf() + Data.text._LinedUpText = " FAKE-SUFFIX {RAM:wStringBuffer}!" + self:resolveWin({ symbol = "CHERRY", payout = 8 }) + T.eq(self.message, "CHERRY FAKE-SUFFIX 8!", + "a translated _LinedUpText reaches the lined-up message") + Data.text._LinedUpText = nil +end + +-- vanilla: with no catalog entry, the English literal still substitutes, +-- with its own leading space (the original had no slot for the symbol) +do + local self = mkSelf() + self:resolveWin({ symbol = "CHERRY", payout = 8 }) + T.eq(self.message, "CHERRY lined up!\nScored 8 coins!", + "no catalog entry still falls back to the English literal") +end + +T.finish("slot_machine_lined_up_romtext") From 1ac5b867bb5ee9d642bb5cb518d8897794f7f7e8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 20 Aug 2026 11:54:08 -0400 Subject: [PATCH 08/10] for that one guy who has a mouse but for some reason it doesnt have a scroll wheel --- src/import/LauncherView.lua | 97 +++++++++++++++++++++++---- src/ui/kit/Kit.lua | 52 ++++++++++++-- tests/engine/launcher_scroll_test.lua | 82 ++++++++++++++++++++++ 3 files changed, 214 insertions(+), 17 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 49f3a8e9..221226b4 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -124,12 +124,48 @@ function LauncherView.detach(imp) Kit.clearCaches() end +local function markNoDrag(imp, x, y, w, h) + if Kit.blockClicks then return end + local t = imp._noDragRects + if not t then t = {}; imp._noDragRects = t end + local n = (imp._noDragN or 0) + 1 + imp._noDragN = n + local r = t[n] + if not r then r = {}; t[n] = r end + r.x, r.y, r.w, r.h = x, y, w, h +end + +local function noDragAt(imp, x, y) + local rects = imp._noDragRects + for i = 1, imp._noDragN or 0 do + if inRect(rects[i], x, y) then return true end + end + return false +end + +local function armMouse(imp, x, y) + if noDragAt(imp, x, y) then + imp._clickPt = { x = x, y = y } + return + end + local shielded = imp._modalUpNow + imp._mouseAt = { + x = x, y = y, + region = not shielded and tabScrollMax(imp) > 0 + and inRect(imp._tabRegionRect, x, y) or false, + page = not shielded and (imp._pageScrollMax or 0) > 0 or false, + } + Kit.dragBegin(x, y) +end + -- ---------------------------------------------------------------- input --- The kit is polled, not evented: update() samples the mouse and turns a --- rising edge into a click point that the next draw consumes. Host-forwarded --- mousepressed stays unused, exactly as before, so Android's synthesized --- mouse path cannot double-fire a tap (#553) -- the dedup window below is the --- other half of that guarantee. +-- The kit is polled, not evented: update() samples the mouse. A press arms +-- a drag that scrolls like a finger, and the click dispatches on RELEASE so +-- the drag can disqualify it, exactly like the touch path below; only the +-- cartridge (which owns its own spin-drag) keeps the press-down click. +-- Host-forwarded mousepressed stays unused, exactly as before, so Android's +-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window +-- below is the other half of that guarantee. function LauncherView.update(imp, dt) if not imp._flex then return end if imp._launchFade then return end @@ -154,7 +190,39 @@ function LauncherView.update(imp, dt) if not touching and now >= (imp._suppressMouseUntil or 0) and now >= (imp._suppressClickUntil or 0) then local mx, my = love.mouse.getPosition() - imp._clickPt = { x = mx, y = my } + armMouse(imp, mx, my) + end + elseif down and imp._mouseAt then + local start = imp._mouseAt + local mx, my = love.mouse.getPosition() + local ddx, ddy = mx - start.x, my - start.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then + start.dragged = true + end + if start.dragged then + local last = start.lastY or start.y + local move = -(my - last) + if move ~= 0 and start.region then + local at, leftover = Kit.scrollHandoff(tabScrollAt(imp), + tabScrollMax(imp), move) + setTabScroll(imp, at) + move = leftover + end + if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then + local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0, + imp._pageScrollMax, move) + imp._pageScroll = at + move = leftover + end + if move ~= 0 then Kit.dragAdd(move) end + end + start.lastY = my + elseif not down and imp._mouseAt then + local start = imp._mouseAt + imp._mouseAt = nil + Kit.dragEnd() + if not start.dragged then + imp._clickPt = { x = start.x, y = start.y } end end imp._prevMouseDown = down @@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y) imp._clickPt = { x = x, y = y } end --- Event-driven click: a macOS trackpad tap delivers press+release inside one --- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click --- from the press event under the poll's own suppression rules, and mark the --- press seen so the poll cannot mint a second one when isDown does catch it. +-- Event-driven press: a macOS trackpad tap delivers press+release inside one +-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag +-- from the press event under the poll's own suppression rules -- the poll's +-- release branch then mints the tap, still within the same frame for a +-- one-frame tap -- and mark the press seen so the poll cannot arm a second +-- one when isDown does catch it. function LauncherView.mousepressed(imp, x, y) if not imp._flex then return end local now = love.timer.getTime() @@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y) or now < (imp._suppressClickUntil or 0) then return end - imp._clickPt = { x = x, y = y } + if not imp._mouseAt then armMouse(imp, x, y) end imp._prevMouseDown = true end @@ -495,6 +565,7 @@ end local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local state = cartridgeState(imp, version) + markNoDrag(imp, x, y, w, h) local focused = Kit.focusable(key, x, y, w, h) local hot = Kit.hover(x, y, w, h) local active = state.active @@ -4868,13 +4939,15 @@ function LauncherView.draw(imp) Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) imp._clickPt = nil imp._wheelY = 0 + imp._noDragN = 0 Theme.field() -- Everything from here to buildModals sits UNDER any open modal, so the -- whole stage draws shielded (no clicks, no hover, no focus ring) while -- one is up; buildModals lowers the shield for the modal's own controls. - Kit.blockClicks = modalUp(imp) + imp._modalUpNow = modalUp(imp) + Kit.blockClicks = imp._modalUpNow local step = Kit.scrollStep(m.s) do diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 7ae2464f..18718dd2 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -918,16 +918,58 @@ function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows) return math.max(minRows or 1, math.min(maxRows or 99, per)) end +Kit.dragX = nil +Kit.dragY = nil +Kit.dragAccum = 0 + +function Kit.dragBegin(x, y) + Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0 +end + +function Kit.dragAdd(dy) + if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end +end + +function Kit.dragEnd() + Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0 +end + +local function dragOriginIn(x, y, w, h) + if not Kit.dragX then return false end + local x1, y1, x2, y2 = x, y, x + w, y + h + local c = Kit._clipRect + if c then + x1, y1 = math.max(x1, c.x), math.max(y1, c.y) + x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h) + end + return Kit.dragX >= x1 and Kit.dragX <= x2 + and Kit.dragY >= y1 and Kit.dragY <= y2 +end + -- Mouse wheel over a paginated list turns PAGES. The wheel still has to do -- something (users expect it), but it moves a bounded page index rather than -- driving a pixel offset, so there is no scroll state and no interpolation. function Kit.wheelPage(x, y, w, h, page, total, perPage) - if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end - if not Kit.hit(x, y, w, h) then return page end + if Kit.blockClicks then return page end local pages = math.max(1, math.ceil(total / math.max(1, perPage))) - local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages) - Kit.wheelY = 0 - return math.floor(moved) + local out = page + if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then + out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1), + 1, pages)) + Kit.wheelY = 0 + end + local acc = Kit.dragAccum or 0 + if acc ~= 0 and dragOriginIn(x, y, w, h) then + local stepPx = math.max(1, math.floor((h or 0) / 2)) + local flips = acc >= 0 and math.floor(acc / stepPx) + or -math.floor(-acc / stepPx) + if flips ~= 0 then + local want = (out or 1) + flips + out = math.floor(Theme.clamp(want, 1, pages)) + Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx + end + end + return out end function Kit.scrollExtent(contentH, viewH) diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index 0816897c..b896fba3 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -300,6 +300,88 @@ LauncherView.draw(edgeImp) check((edgeImp._tabScroll.skins or 0) > 0, "which reaches the tab region even though the cursor is below it") +Kit.blockClicks = false +Kit._clipRect = nil +Kit.wheelY = 0 +Kit.mouseX, Kit.mouseY = 400, 400 +Kit.dragBegin(50, 50) +Kit.dragAdd(120) +local pg = Kit.wheelPage(0, 0, 100, 100, 1, 100, 10) +eq(pg, 3, "a drag that crossed two half-heights turns two pages") +eq(Kit.dragAccum, 20, "and keeps the remainder for the next flip") +Kit.dragAdd(-140) +pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10) +eq(pg, 1, "dragging back down returns those pages") +eq(Kit.dragAccum, -20, "with the remainder's sign preserved") +Kit.dragAdd(-80) +pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10) +eq(pg, 1, "a drag past the first page clamps to it") +eq(Kit.dragAccum, 0, "and drops the pile-up so reversing is instant") +Kit.dragAdd(200) +pg = Kit.wheelPage(200, 200, 100, 100, pg, 100, 10) +eq(pg, 1, "a drag that began outside the list is not the list's") +eq(Kit.dragAccum, 200, "and its travel stays queued") +Kit.dragEnd() +eq(Kit.dragAccum, 0, "releasing the button retires the gesture") + +window(360, 780) +local mouseImp = skinLauncher(12) +LauncherView.draw(mouseImp) +LauncherView.draw(mouseImp) +local mreg = mouseImp._tabRegionRect +local mmax = mouseImp._tabScrollMax.skins +check(mmax > 0, "the mouse-dragged panel has travel") +local mdown = false +love.mouse.isDown = function() return mdown end + +pointer(mreg.x + 20, mreg.y + 40) +mdown = true +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, + "a press over a scrollable region mints no click") +check(mouseImp._mouseAt ~= nil, "it arms a drag instead") +pointer(mreg.x + 20, mreg.y + 40 - 200) +LauncherView.update(mouseImp, 0.016) +eq(mouseImp._tabScroll.skins, math.min(200, mmax), + "dragging the held mouse scrolls the panel by its travel") +eq(mouseImp._pageScroll or 0, 0, + "while the panel still has travel, the page waits") +pointer(mreg.x + 20, mreg.y + 40 - 200 - mmax * 2) +LauncherView.update(mouseImp, 0.016) +eq(mouseImp._tabScroll.skins, mmax, + "a longer mouse drag reaches the panel's bottom") +check((mouseImp._pageScroll or 0) > 0, "and spills into the page from there") +mdown = false +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, "a released drag is not a click") +check(mouseImp._mouseAt == nil, "and the gesture is retired") + +pointer(mreg.x + 20, mreg.y + 40) +mdown = true +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, "a fresh press still holds its click back") +mdown = false +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt ~= nil, + "press and release without travel is still a tap, on release") +mouseImp._clickPt = nil + +LauncherView.draw(gameImp) +LauncherView.draw(gameImp) +check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect") +local cart = gameImp._noDragRects[1] +pointer(cart.x + cart.w / 2, cart.y + cart.h / 2) +mdown = true +LauncherView.update(gameImp, 0.016) +check(gameImp._clickPt ~= nil, + "a press on the cartridge clicks at once so its own spin-drag still owns " + .. "the gesture") +check(gameImp._mouseAt == nil, "and never arms the scroll drag") +gameImp._clickPt = nil +mdown = false +LauncherView.update(gameImp, 0.016) +love.mouse.isDown = nil + local function read(path) local f = assert(io.open(path, "r")) local src = f:read("*a") From 9e01fe2c2c4908bbd1fb058d9aeb212f1e9b4deb Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:23:20 +0200 Subject: [PATCH 09/10] Fix secondary display lifecycle cleanup --- main.lua | 1 + .../java/org/love2d/android/GameActivity.java | 18 ++++++++--- tests/engine/android_host_extension_test.lua | 32 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/main.lua b/main.lua index b820766c..2c352977 100644 --- a/main.lua +++ b/main.lua @@ -327,6 +327,7 @@ local function returnToLauncher() if love.audio and love.audio.stop then pcall(love.audio.stop) end + pcall(function() require("src.render.SecondScreen").setEnabled(false) end) local GameVersion = require("src.core.GameVersion") local currentVersion = GameVersion.get() 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 c484e900..e59fe574 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 @@ -398,11 +398,15 @@ public class GameActivity extends SDLActivity { @Override protected void onDestroy() { + secondaryHostResumed = false; if (vibrator != null) { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } unregisterSecondaryDisplayListener(); + teardownSecondaryDisplay(); + secondaryEnabled = false; + synchronized (secondaryFrameLock) { secondaryFrame = null; } unregisterAudioDeviceCallback(); abandonAudioFocus(); onHostDestroy(); @@ -411,6 +415,7 @@ public class GameActivity extends SDLActivity { @Override protected void onPause() { + secondaryHostResumed = false; if (vibrator != null) { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); @@ -426,6 +431,7 @@ public class GameActivity extends SDLActivity { @Override public void onResume() { super.onResume(); + secondaryHostResumed = true; onHostResume(); requestGameAudioFocus(); registerAudioDeviceCallback(); @@ -1933,6 +1939,7 @@ public class GameActivity extends SDLActivity { private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY; private static volatile long secondaryRetryAfter; private static volatile boolean secondaryEnabled = false; + private static volatile boolean secondaryHostResumed = false; private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO; private static volatile int dualScreenDisplayMode = -1; private static volatile byte[] secondaryFrame; @@ -1963,7 +1970,7 @@ public class GameActivity extends SDLActivity { if (self == null) return; self.runOnUiThread(new Runnable() { @Override public void run() { - if (on) { + if (on && secondaryHostResumed) { self.refreshDualScreenDisplayMode(); self.registerSecondaryDisplayListener(); rebindSecondaryDisplay(); @@ -2035,9 +2042,11 @@ public class GameActivity extends SDLActivity { private static void rebindSecondaryDisplay() { GameActivity self = (GameActivity) mSingleton; - if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return; + if (self == null || !secondaryHostResumed || !secondaryEnabled + || secondaryOutputIsPreferred(self)) return; self.runOnUiThread(() -> { - if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return; + if (!secondaryHostResumed || !secondaryEnabled + || secondaryOutputIsPreferred(self)) return; teardownSecondaryDisplay(); setupSecondaryDisplay(); }); @@ -2045,7 +2054,8 @@ public class GameActivity extends SDLActivity { private static void setupSecondaryDisplay() { GameActivity self = (GameActivity) mSingleton; - if (self == null || !secondaryEnabled || secondaryPresentation != null + if (self == null || !secondaryHostResumed || !secondaryEnabled + || secondaryPresentation != null || secondaryActivity != null || secondaryActivityPending || android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return; try { diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index aabef3f7..4033cbed 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -58,6 +58,38 @@ check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") < "secondary display monitoring starts before initial discovery") check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true), "a disconnected active display is rebound without replacing a live one") +check(source:find("private static volatile boolean secondaryHostResumed = false;", + 1, true), "secondary output tracks the primary activity lifecycle") +check(source:find("if (on && secondaryHostResumed)", 1, true) + and source:find("self == null || !secondaryHostResumed || !secondaryEnabled", + 1, true), + "paused hosts cannot reopen secondary output from a late mod frame") + +local pause = position("protected void onPause()") +local paused = assert(source:find("secondaryHostResumed = false;", pause, true)) +local teardown = assert(source:find("teardownSecondaryDisplay();", pause, true)) +local pauseSuper = assert(source:find("super.onPause();", pause, true)) +check(pause < paused and paused < teardown and teardown < pauseSuper, + "pause blocks secondary setup before dismissing its output") + +local resume = position("public void onResume()") +local resumeSuper = assert(source:find("super.onResume();", resume, true)) +local resumed = assert(source:find("secondaryHostResumed = true;", resume, true)) +local resumeSetup = assert(source:find("setupSecondaryDisplay();", resume, true)) +check(resume < resumeSuper and resumeSuper < resumed and resumed < resumeSetup, + "resume permits secondary setup only after the primary activity resumes") + +local destroy = position("protected void onDestroy()") +local destroyTeardown = assert(source:find("teardownSecondaryDisplay();", destroy, true)) +local destroySuper = assert(source:find("super.onDestroy();", destroy, true)) +check(destroy < destroyTeardown and destroyTeardown < destroySuper, + "destroy always dismisses secondary output before SDL destruction") + +local mainFile = assert(io.open("main.lua", "rb")) +local main = mainFile:read("*a") +mainFile:close() +check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true), + "returning from a game disables mod-owned secondary output") check(not source:lower():find("openxr", 1, true), "generic Android activity must not require OpenXR") From 1b659dab01b938ef7d17d969e1e5662d88bd85cf Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 20 Aug 2026 12:43:04 -0400 Subject: [PATCH 10/10] CLOSES #1582, CLOSES #1583 --- src/core/Game.lua | 36 ++++++---- src/import/LauncherView.lua | 2 +- src/import/RomImporter.lua | 3 + src/sync/SyncEngine.lua | 27 +++++++- tests/engine/sync_engine_test.lua | 105 ++++++++++++++++++++++++++++-- 5 files changed, 155 insertions(+), 18 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index b2c8e5bf..b4bb8d99 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -982,7 +982,11 @@ end -- parked the player until every direction was re-pressed (#799). function Game:focus(f) Input:reset() - if f then Input:reconcile() end + if f then + Input:reconcile() + local eng = self:syncEngine() + if eng then pcall(eng.noteResumed, eng) end + end TouchControls:reset() self:cancelPointers() end @@ -1002,6 +1006,8 @@ function Game:onResume() Input:reconcile() TouchControls:reset() self:cancelPointers() + local eng = self:syncEngine() + if eng then pcall(eng.noteResumed, eng) end -- Chip music may survive NX suspend as a duplicate stream; stop it and let -- the active screen re-cue on the next frame (hardware audio check: T19). -- Desktop/mobile window-visible flips must not kill overworld music. @@ -1209,18 +1215,26 @@ end function Game:syncEngine() if self._syncOff then return nil end - if self._syncEngineRef then return self._syncEngineRef end - local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") - if not ok or type(SyncEngine) ~= "table" then - self._syncOff = true - return nil - end - local eng = SyncEngine.shared() + local eng = self._syncEngineRef if not eng then - self._syncOff = true - return nil + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._syncOff = true + return nil + end + eng = SyncEngine.shared() + if not eng then + self._syncOff = true + return nil + end + self._syncEngineRef = eng + end + if type(eng.protectPlaythrough) == "function" then + local meta = self.save and self.save.meta + eng:protectPlaythrough( + (self.save and self.save.version) or require("src.core.GameVersion").get(), + type(meta) == "table" and meta.playthroughId or nil) end - self._syncEngineRef = eng return eng end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 221226b4..2d5d9418 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -4554,7 +4554,7 @@ local function buildSyncHome(imp, m, eng) local linked = eng:linked() local codes = eng.codes local body = linked - and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.") + and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.") or Strings(SYNC_HINT) local innerW = w - 2 * pad local hintH = Kit.wrapHeight("small", body, innerW, 5) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 9ce176ff..d5b4aecf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1537,6 +1537,9 @@ function RomImporter:focus(f) self._modPress = nil return end + if type(self._sync) == "table" then + pcall(self._sync.noteResumed, self._sync) + end if not (f and self.android and self.workState ~= "working") then return end -- SAF create-document finished: GameActivity wrote export_done.flag. if love.filesystem.getInfo("export_done.flag", "file") then diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 49c3298f..985ed415 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine SyncEngine.UPLOAD_DEBOUNCE = 5 SyncEngine.AUTO_INTERVAL = 300 +SyncEngine.RESUME_MIN_GAP = 60 SyncEngine.MAX_STEPS_PER_UPDATE = 8 local IDLE_STATUS = "Ready" @@ -133,6 +134,7 @@ function SyncEngine.new(opts) eng.modPlan = nil eng.shareCode = nil eng.clock = 0 + eng.autoAt = SyncEngine.AUTO_INTERVAL eng.queue = {} eng.pending = nil eng.uploadAt = nil @@ -258,6 +260,11 @@ function SyncEngine:update(dt) self.uploadAt = nil if self.state.enabled and self:linked() then self:syncNow() end end + if self.clock >= self.autoAt and not self:busy() + and (self.phase == "idle" or self.phase == "error") + and self.state.enabled and self:linked() then + self:syncNow() + end local steps = 0 while not self.pending and #self.queue > 0 and steps < SyncEngine.MAX_STEPS_PER_UPDATE do @@ -296,6 +303,7 @@ function SyncEngine:createAccount(label) eng.phase = "idle" eng.status = "Sync account created" eng:_persist() + eng:syncNow() end) end @@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled) return self.state.enabled end +function SyncEngine:protectPlaythrough(version, playthroughId) + self.protectedKey = SyncState.key(version, playthroughId) +end + +function SyncEngine:noteResumed() + if not (self.state.enabled and self:linked()) then return end + if self:busy() or self.phase == "conflict" then return end + if self.now() - (tonumber(self.state.lastSyncAt) or 0) + < SyncEngine.RESUME_MIN_GAP then + return + end + self:syncNow() +end + function SyncEngine:syncNow() if not self:linked() then return false, "this device is not linked" end if self.pending then return false, "sync is busy" end + self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL self.queue = {} self.conflicts = {} self.state.pendingConflicts = {} @@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState) self:_addConflict(entry, key, row) elseif localChanged then self:_queueUpload(entry, key, false) - elseif remoteChanged then + elseif remoteChanged and key ~= self.protectedKey then self:_queueDownload(key, entry.version, entry.playthroughId, "replace") end end end for key, row in pairs(remote) do - if not seen[key] then + if not seen[key] and key ~= self.protectedKey then local version, id = SyncState.splitKey(key) if version and id then self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index f6ee37fd..ddd3bc78 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -94,20 +94,26 @@ do local eng, transport = engine({ ["POST /sync/create"] = { code = 200, body = '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' }, - }, {}, SyncState.defaults()) + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, + }, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults()) T.eq(eng:linked(), false, "a fresh engine is not linked") T.eq(eng.status, "Not set up", "and says so") eng:createAccount("laptop") - pump(eng, 3) + pump(eng) T.eq(eng:linked(), true, "creating an account links this device") T.eq(eng.state.account, "aa11", "and stores the account id") T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped") T.eq(eng.codes.code2, "3333-4444", "and so is the second") T.eq(eng.state.code1, nil, "codes never enter the persisted state") + T.eq(transport.sent[2].url, "http://sync.test/sync/state", + "creating the account starts a sync straight away") + T.eq(transport.sent[3].method, "PUT", + "so the saves that existed before setup are uploaded") + T.eq(SyncState.rev(eng.state, "red/abc"), 1, "and the served rev is remembered") T.eq(eng.phase, "idle", "and the engine settles") - T.eq(#transport.sent, 1, "one request was made") end do @@ -293,14 +299,105 @@ do T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing") end +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}) + eng:update(SyncEngine.AUTO_INTERVAL - 1) + T.eq(#transport.sent, 0, "an idle linked engine does not poll early") + eng:update(1) + T.eq(#transport.sent, 1, "after the auto interval it checks the server") + pump(eng) + T.eq(eng.phase, "idle", "and settles") + eng:update(SyncEngine.AUTO_INTERVAL - 10) + T.eq(#transport.sent, 1, "the next poll waits a whole interval again") + eng:update(10) + T.eq(#transport.sent, 2, "then fires") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}, SyncState.defaults()) + eng:update(SyncEngine.AUTO_INTERVAL * 2) + T.eq(#transport.sent, 0, "an unlinked engine never polls on its own") +end + +do + local calls = 0 + local eng = engine({ + ["GET /sync/state"] = function() + calls = calls + 1 + if calls == 1 then return { code = 500, body = '{"error":"down"}' } end + return { code = 200, body = '{"saves":{}}' } + end, + }, {}) + eng:syncNow() + pump(eng, 3) + T.eq(eng.phase, "error", "the first sync fails") + eng:update(SyncEngine.AUTO_INTERVAL) + pump(eng, 3) + T.eq(eng.phase, "idle", "the auto interval retries and recovers") +end + +do + local eng, transport = conflictEngine() + eng:syncNow() + pump(eng) + local sent = #transport.sent + eng:update(SyncEngine.AUTO_INTERVAL * 2) + T.eq(#transport.sent, sent, "a waiting conflict is never auto-synced over") + T.eq(eng.phase, "conflict", "the player still decides") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}) + eng:noteResumed() + T.eq(#transport.sent, 1, "regaining the app checks the server") + pump(eng) + eng:noteResumed() + T.eq(#transport.sent, 1, "but not twice in quick succession") +end + +do + local eng, transport = engine({}, {}, SyncState.defaults()) + eng:noteResumed() + T.eq(#transport.sent, 0, "an unlinked engine ignores a resume") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 2, 500) + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":4,"meta":{"savedAt":900}},' .. + '"gold/xyz":{"rev":1,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":1,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, + }, { saveEntry("red", "abc", 500, 400) }, state) + eng:protectPlaythrough("red", "abc") + eng:syncNow() + pump(eng) + T.eq(#saves.writes, 1, "only the save that is not being played downloads") + T.eq(saves.writes[1].version, "gold", "the other playthrough still arrives") + T.eq(SyncState.rev(eng.state, "red/abc"), 2, + "the live playthrough keeps its rev so the launcher can fetch it later") + T.eq(eng.phase, "idle", "and the sync settles") + eng:protectPlaythrough("red", nil) + T.eq(eng.protectedKey, nil, "no live playthrough means no protection") +end + do local eng = engine({ ["POST /sync/create"] = { code = 200, body = '{"account":"aa11","code1":"11112222","code2":"33334444",' .. '"deviceToken":"tok","device":"0a1b2c3d"}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, }, {}, SyncState.defaults()) eng:createAccount("laptop") - pump(eng, 3) + pump(eng) T.eq(eng.state.deviceId, "0a1b2c3d", "creating an account records the id the server gave this device") T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",