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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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")