From 46c386b19e39e143384cf9386e7cf9876c4741e2 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:41 +0200 Subject: [PATCH] Route remaining RBY text through translations --- src/ui/BagMenu.lua | 4 +- src/ui/BoxMenu.lua | 6 +- src/ui/Credits.lua | 9 +- src/ui/Diploma.lua | 10 +- src/ui/FlyMenu.lua | 5 +- src/ui/OakSpeech.lua | 2 +- src/ui/SurfingMinigame.lua | 3 +- src/ui/TitleState.lua | 11 +- src/ui/TownMap.lua | 38 ++- src/ui/TradeAnim.lua | 4 +- src/ui/TrainerCard.lua | 6 +- src/world/OverworldController.lua | 31 +- tests/engine/fly_map_arrows_bug1892.lua | 21 +- tests/engine/rby_translation_runtime_test.lua | 300 ++++++++++++++++++ tests/rby_translation_harvest_test.py | 58 ++++ 15 files changed, 458 insertions(+), 50 deletions(-) create mode 100644 tests/engine/rby_translation_runtime_test.lua create mode 100644 tests/rby_translation_harvest_test.py diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index f2a7a4af..def8edc7 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -500,7 +500,7 @@ local function pickTargetAndUse(game, battle, id, list) right = ("%d"):format(mv.pp), }) end - game.stack:push(ListMenu.new(game, "Which move?", rows, { + game.stack:push(ListMenu.new(game, Strings.source("Which move?"), rows, { onChoose = function(row, l) l:close() useOn(game, battle, id, mon, list, row.value) @@ -557,7 +557,7 @@ function BagMenu.new(game, opts) opts = opts or {} local battle = opts.battle local list - list = ListMenu.new(game, "ITEMS", buildItems(game), { + list = ListMenu.new(game, Strings.source("ITEMS"), buildItems(game), { kind = "bag", -- StartMenu_Item zeroes wPrintItemPrices and draws no money box: the -- LIST_MENU_BOX floats over the map (engine/menus/start_sub_menus.asm) diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 7dc08d9b..d883a09b 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -84,9 +84,9 @@ local function withdraw(game) if item.cancel then list:close() return end local mon = box[item.value] if not mon then return end - monSubmenu(game, "WITHDRAW", mon, function() + monSubmenu(game, Strings("WITHDRAW"), mon, function() if #game.save.party >= Party.MAX then - list.footer = "The party is full!" + list.footer = Strings("The party is full!") return end -- add_mon.asm _MoveMon's tail ("returning mon to party, compute @@ -141,7 +141,7 @@ local function deposit(game) or Strings("There isn't any\nresponse..."))) return end - monSubmenu(game, "DEPOSIT", mon, function() + monSubmenu(game, Strings("DEPOSIT"), mon, function() if #game.save.party <= 1 then list.footer = Strings("You need at least\none POKéMON!") return diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index b12db5fb..95622978 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -257,7 +257,11 @@ function Credits:drawPage(screen, xoff, shade) if not screen then return end love.graphics.setColor(0, 0, 0, shade) for i, line in ipairs(screen.lines or {}) do - Font.draw(line.text, xoff + (line.column or 0) * 8, 48 + (i - 1) * 16) + -- Credits strings are extracted ROM data, not Lua literals. Keep the + -- source in field.credits for layout/parity and translate only as it is + -- drawn, like the dynamic labels in the other data-backed screens. + Font.draw(Strings(line.text), xoff + (line.column or 0) * 8, + 48 + (i - 1) * 16) end love.graphics.setColor(1, 1, 1, 1) if screen.copyright then self:drawCopyright(xoff) end @@ -332,7 +336,8 @@ function Credits:drawTheEnd() end else love.graphics.setColor(0, 0, 0, 1) - Font.draw((te and te.display) or Strings("T H E E N D"), 32, 64) + local text = te and Strings(te.display) or Strings("T H E E N D") + Font.draw(text, math.max(0, math.floor((160 - Font.width(text)) / 2)), 64) love.graphics.setColor(1, 1, 1, 1) end end diff --git a/src/ui/Diploma.lua b/src/ui/Diploma.lua index e957e12c..bb9f6280 100644 --- a/src/ui/Diploma.lua +++ b/src/ui/Diploma.lua @@ -140,11 +140,11 @@ function Diploma.render(game) -- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14) local congrats = { - { text = "Congrats! This", y = 48 }, -- hlcoord 2, 6 - { text = "diploma certifies", y = 64 }, -- hlcoord 2, 8 - { text = "that you have", y = 80 }, -- hlcoord 2, 10 - { text = "completed your", y = 96 }, -- hlcoord 2, 12 - { text = "POKéDEX.", y = 112 }, -- hlcoord 2, 14 + { text = Strings.source("Congrats! This"), y = 48 }, -- hlcoord 2, 6 + { text = Strings.source("diploma certifies"), y = 64 }, -- hlcoord 2, 8 + { text = Strings.source("that you have"), y = 80 }, -- hlcoord 2, 10 + { text = Strings.source("completed your"), y = 96 }, -- hlcoord 2, 12 + { text = Strings.source("POKéDEX."), y = 112 }, -- hlcoord 2, 14 } for _, line in ipairs(congrats) do Font.draw(Strings(line.text), 16, line.y) diff --git a/src/ui/FlyMenu.lua b/src/ui/FlyMenu.lua index 27d5a02b..c83e3276 100644 --- a/src/ui/FlyMenu.lua +++ b/src/ui/FlyMenu.lua @@ -3,6 +3,7 @@ local ListMenu = require("src.ui.ListMenu") local Map = require("src.world.Map") +local Strings = require("src.core.Strings") local FlyMenu = {} @@ -20,11 +21,11 @@ function FlyMenu.new(game) seen[mapId] = true table.insert(items, { value = mapId, - label = mapId:gsub("_", " "), + label = Strings(mapId:gsub("_", " ")), }) end end - return ListMenu.new(game, "FLY TO?", items, { + return ListMenu.new(game, Strings.source("FLY TO?"), items, { onChoose = function(item, list) list:close() game.overworld:flyTo(item.value) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 67f1729a..b9ff6608 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -443,7 +443,7 @@ function OakSpeech:runStep(step) self.picTrueColor = self.demoTrueColor self:revealPic("wipe", function() Sound.playCry(self.game.data, self.demoSpecies) - self:say(Strings("_OakSpeechText2A"), function() self:advance() end) + self:say("_OakSpeechText2A", function() self:advance() end) end) elseif kind == "name" then local who = step.who or "player" diff --git a/src/ui/SurfingMinigame.lua b/src/ui/SurfingMinigame.lua index 846bd1a3..06e9e024 100644 --- a/src/ui/SurfingMinigame.lua +++ b/src/ui/SurfingMinigame.lua @@ -1642,7 +1642,8 @@ function SurfingMinigame:drawTitleScreen() if self.titleBg then love.graphics.draw(self.titleBg, 0, 0) else - Font.draw("PIKACHU'S BEACH", 20, 32) + local title = Strings("PIKACHU'S BEACH") + Font.draw(title, math.max(0, math.floor((160 - Font.width(title)) / 2)), 32) end -- 2. Intro Pikachu paddling out (SurfingPikachu1Graphics3 / surf_1c.png) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 5c48711d..a4b2506d 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -727,9 +727,11 @@ function TitleState:draw() love.graphics.draw(self.logo, 16, 8 + scrollY) else love.graphics.setColor(0, 0, 0, 1) - local brand = self.yellow and "POKéMON YELLOW" - or (self.blue and "POKéMON BLUE" or Strings("POKéMON RED")) - Font.draw(brand, (160 - 12 * 8) / 2, 24 + scrollY) + local brand = self.yellow and Strings("POKéMON YELLOW") + or (self.blue and Strings("POKéMON BLUE") + or Strings("POKéMON RED")) + Font.draw(brand, math.max(0, math.floor((160 - Font.width(brand)) / 2)), + 24 + scrollY) love.graphics.setColor(1, 1, 1, 1) end if self.yellowLayout then @@ -817,7 +819,8 @@ function TitleState:drawCopyright(y) return end love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y) + Font.draw(self.title.copyrightText and Strings(self.title.copyrightText) + or Strings("GAME FREAK inc."), 16, y) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index ca4a3f00..5174d053 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -18,6 +18,7 @@ local GameVersion = require("src.core.GameVersion") local PaletteFX = require("src.render.PaletteFX") local Sound = require("src.core.Sound") local SpriteRenderer = require("src.render.SpriteRenderer") +local Strings = require("src.core.Strings") local Theme = require("src.ui.Theme") local TownMap = {} @@ -171,7 +172,22 @@ end -- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly -- (engine/menus/town_map.asm prints the destination as "To ") function TownMap:bannerText(loc) - return (self.fly and "To " or "") .. loc.name + local name = Strings(loc.name) + return self.fly and Strings("To %s", name) or name +end + +-- The fly strip reserves its last two tiles for the up/down arrows. Fit the +-- composed translation as one unit so a wider/reordered prefix cannot collide +-- with the destination or draw under those controls. +local function fitFlyBanner(text) + local spans = Font.split(text) + local n = Font.spansFitting(spans, 144) + if n >= #spans then return text end + local out = {} + for i = 1, math.max(0, n - 1) do + out[#out + 1] = text:sub(spans[i].from, spans[i].to) + end + return table.concat(out) .. "." end -- Fly mode selection set (engine/menus/town_map.asm LoadTownMap_Fly): the @@ -432,7 +448,7 @@ function TownMap:draw() -- engine/items/town_map.asm:403 Font.drawBox(1, 7, 17, 4) love.graphics.setColor(0, 0, 0, 1) - Font.draw(" AREA UNKNOWN", 16, 72) + Font.draw(" " .. Strings("AREA UNKNOWN"), 16, 72) love.graphics.setColor(1, 1, 1, 1) end love.graphics.rectangle("fill", 0, 0, 160, 8) @@ -440,7 +456,7 @@ function TownMap:draw() local def = self.game.data.pokemon[self.nestSpecies] local name = def and def.name or self.nestSpecies -- engine/items/town_map.asm:124 - Font.draw(name .. "'s NEST", 8, 0) + Font.draw(Strings("%s's NEST", name), 8, 0) love.graphics.setColor(1, 1, 1, 1) return end @@ -489,8 +505,9 @@ function TownMap:draw() love.graphics.setColor(0, 0, 0, 1) if self.fly then -- engine/items/town_map.asm:167, 176, 185 - Font.draw("To", 0, 0) - if selected then Font.draw(selected.name, 24, 0) end + if selected then + Font.draw(fitFlyBanner(self:bannerText(selected)), 0, 0) + end self:drawFlyArrows() elseif selected then Font.draw(self:bannerText(selected), 8, 0) @@ -541,18 +558,20 @@ function TownMap:draw() local loc = self.locs[first + i] if loc then local y = 40 + i * 16 + local name = Strings(loc.name) -- cursor in list mode (Fly mode) is static in RBY (LoadTownMap_Fly) if first + i == self.sel then Font.drawCode(0xED, 8, y) -- the "▶" cursor glyph end - Font.draw(loc.name, 24, y) + Font.draw(name, 24, y) -- player marker is static if loc == self.playerLoc then -- marker on the player's current town; force the palette-safe -- dark shade explicitly so the red-channel shade-remap keeps it -- visible regardless of Font.draw's leftover color (#152) love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4) + love.graphics.rectangle("fill", 24 + Font.width(name) + 6, + y + 2, 4, 4) end end end @@ -561,7 +580,10 @@ function TownMap:draw() -- name banner across the top Font.drawBox(0, 0, 20, 3) love.graphics.setColor(0, 0, 0, 1) - if selected then Font.draw(self:bannerText(selected), 8, 8) end + if selected then + local text = self:bannerText(selected) + Font.draw(self.fly and fitFlyBanner(text) or text, 8, 8) + end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index ebba1efa..2d365c5f 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -736,14 +736,14 @@ end function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) Font.drawBox(4, boxTy, 12, 8) local y0 = boxTy * 8 - local no = ("No.%03d"):format(dexOf(self.game, mon)) + local no = Strings("No.%03d", dexOf(self.game, mon)) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 56, y0, Font.width(no), 8) love.graphics.setColor(0, 0, 0, 1) Font.draw(no, 56, y0) Font.draw(speciesName(self.game, mon), 40, y0 + 16) Font.draw(Strings("OT/%s", ot or "????"), 40, y0 + 32) - Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48) + Font.draw(Strings("IDNo.%05d", otId or 0), 40, y0 + 48) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 43f0fdba..e1e0bf7d 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -139,10 +139,10 @@ function TrainerCard:draw() end love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("NAME/%s", save.player.name or "RED"), 16, 16) - Font.draw(("MONEY/¥%d"):format(save.money or 0), 16, 32) + Font.draw(Strings("MONEY/¥%d", save.money or 0), 16, 32) local t = math.floor(save.playTime or 0) - Font.draw(("TIME/%3d:%02d"):format(math.floor(t / 3600), - math.floor(t / 60) % 60), 16, 48) + Font.draw(Strings("TIME/%3d:%02d", math.floor(t / 3600), + math.floor(t / 60) % 60), 16, 48) -- the circle-dotted BADGES banner (TrainerInfo_BadgesText) self:frameBox(0, 8, 20, 3) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 70fb37c5..93c5c409 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3225,8 +3225,10 @@ function OverworldState:openPC(onDone) -- PC session (#695); the sub-PC screens (BoxMenu, PlayerPC) already -- use keepOpen for their own rows, matching the original ROM's flow -- where the main menu stays underneath. + local boxPcLabel = metBill and Strings.source("BILL'S PC") + or Strings.source("SOMEONE'S PC") table.insert(items, { - label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"), + label = boxPcLabel, keepOpen = true, onSelect = function() require("src.core.Sound").play(Game.data, "Enter_PC") @@ -3244,8 +3246,10 @@ function OverworldState:openPC(onDone) }) -- the player's item storage is always available + local playerName = Game.save.player.name or "RED" + local playerPcLabel = Strings.source("%s's PC"):format(playerName) table.insert(items, { - label = (Game.save.player.name or "RED") .. "'s PC", + label = playerPcLabel, keepOpen = true, onSelect = function() -- pc.asm .playersPC plays SFX_ENTER_PC then prints AccessedMyPCText @@ -3262,7 +3266,7 @@ function OverworldState:openPC(onDone) -- Prof. Oak's dex rating only appears once you have the Pokédex if flags.EVENT_GOT_POKEDEX then table.insert(items, { - label = Strings("PROF.OAK's PC"), + label = Strings.source("PROF.OAK's PC"), keepOpen = true, onSelect = function() -- pc.asm OaksPC plays SFX_ENTER_PC before the farcall (#960) @@ -3274,7 +3278,7 @@ function OverworldState:openPC(onDone) -- engine/pokemon/bills_pc.asm:48-60 PKMN LEAGUE row (#1566) if #(Game.save.hallOfFame or {}) > 0 then table.insert(items, { - label = Strings("LEAGUE"), + label = Strings.source("LEAGUE"), keepOpen = true, onSelect = function() -- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints @@ -3298,6 +3302,22 @@ function OverworldState:openPC(onDone) type(hooked)) end + -- Hooks identify the vanilla rows by their English source labels. Delay + -- localization until after ui.pc.items has inspected/reordered/replaced + -- them, then translate any source labels it chose to retain. + local translatedLabels = { + [boxPcLabel] = function() + return metBill and Strings("BILL'S PC") or Strings("SOMEONE'S PC") + end, + [playerPcLabel] = function() return Strings("%s's PC", playerName) end, + ["PROF.OAK's PC"] = function() return Strings("PROF.OAK's PC") end, + ["LEAGUE"] = function() return Strings("LEAGUE") end, + } + for _, item in ipairs(items) do + local translate = translatedLabels[item.label] + if translate then item.label = translate() end + end + local logOff = function() require("src.core.Sound").play(Game.data, "Turn_Off_PC") done() @@ -3481,7 +3501,8 @@ function OverworldState:nurseHeal(onDone, npc) end end)) end) - end, choiceLabels = { "HEAL", "CANCEL" }, choiceBox = Theme.healCancelBox })) + end, choiceLabels = { Strings("HEAL"), Strings("CANCEL") }, + choiceBox = Theme.healCancelBox })) end -- pokecenter.asm bows the nurse between the two PrintText calls (#995) diff --git a/tests/engine/fly_map_arrows_bug1892.lua b/tests/engine/fly_map_arrows_bug1892.lua index 4df37b1b..301e4d48 100644 --- a/tests/engine/fly_map_arrows_bug1892.lua +++ b/tests/engine/fly_map_arrows_bug1892.lua @@ -117,14 +117,11 @@ check(tm.fly == true, "the picker opens in fly mode") eq(tm.mode, "grid", "the picker draws the Kanto map, not the name list") local texts, codes, draws, polys = capture(tm) -local to = findText(texts, "To") -check(to ~= nil, "ToText is printed on its own") -if to then eq(to.x, 0, "ToText sits at hlcoord 0, 0") end -local name = findText(texts, "PALLET TOWN") -check(name ~= nil, "the destination name is printed on its own") -if name then eq(name.x, 24, "the name sits at hlcoord 3, 0") end -check(findText(texts, "To PALLET TOWN") == nil, - "the banner is no longer one concatenated string at column 1") +local banner = findText(texts, "To PALLET TOWN") +check(banner ~= nil, "the fly prefix and destination are one format string") +if banner then eq(banner.x, 0, "the composed banner starts at hlcoord 0, 0") end +check(findText(texts, "To") == nil and findText(texts, "PALLET TOWN") == nil, + "the prefix and destination are not drawn at fixed separate columns") check(codeAt(codes, Theme.moreArrow, 152, 0), "the down arrow is drawn at hlcoord 19, 0") @@ -160,11 +157,11 @@ local game7 = newGame() local tm7 = TownMap.new(game7, { fly = true, onFly = function() end }) tm7.sel = 3 local texts7 = capture(tm7) -local long = findText(texts7, "CINNABAR ISLAND") -check(long ~= nil, "the longest fly name is printed") +local long = findText(texts7, "To CINNABAR ISLAND") +check(long ~= nil, "the longest composed fly banner is printed") if long then - check(long.x + #"CINNABAR ISLAND" * 8 <= 144, - "a 15-column name ends before the arrow columns") + check(long.x + Font.width(long.text) <= 144, + "the composed banner ends before the arrow columns") end T.finish("fly map arrows bug 1892") diff --git a/tests/engine/rby_translation_runtime_test.lua b/tests/engine/rby_translation_runtime_test.lua new file mode 100644 index 00000000..332f5314 --- /dev/null +++ b/tests/engine/rby_translation_runtime_test.lua @@ -0,0 +1,300 @@ +-- Runtime coverage for the RBY translation seams backed by dynamic ROM data. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local drawn = {} +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawBox = function() end, + drawCode = function() end, + width = function(text) return #text * 8 end, + split = function(text) + local spans = {} + for i = 1, #text do spans[i] = { from = i, to = i, code = text:byte(i) } end + return spans + end, + spansFitting = function(spans, pixels) + return math.min(#spans, math.floor(pixels / 8)) + end, +} +package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, +} +local pushed +package.loaded["src.render.TextBox"] = { + new = function(_, text) return { text = text } end, +} + +local Strings = require("src.core.Strings") +Strings.load({ strings = { + ["PALLET TOWN"] = "BOURG PALETTE", + ["To %s"] = "Vers %s", + ["FLY TO?"] = "VOLER VERS?", + ["VOLER VERS?"] = "DOUBLE LOOKUP", + ["ITEMS"] = "OBJETS", + ["OBJETS"] = "DOUBLE LOOKUP", + ["DIRECTOR"] = "RÉALISATEUR", + ["T H E E N D"] = "F I N", + ["No.%03d"] = "N°%03d", + ["IDNo.%05d"] = "ID n°%05d", + ["POKéMON BLUE"] = "BLEU", + ["PIKACHU'S BEACH"] = "PLAGE", + ["BILL'S PC"] = "PC DE LÉO", + ["%s's PC"] = "PC DE %s", + ["PC DE RED"] = "DOUBLE LOOKUP", +} }) + +-- TownMap keeps the English source for ROUTE classification and translates +-- only the rendered banner, including the fly prefix. +local TownMap = require("src.ui.TownMap") +local map = setmetatable({ fly = false }, TownMap) +T.eq(map:bannerText({ name = "PALLET TOWN" }), "BOURG PALETTE", + "town names pass through Strings at draw time") +map.fly = true +T.eq(map:bannerText({ name = "PALLET TOWN" }), "Vers BOURG PALETTE", + "the composed fly banner translates its prefix and destination") + +-- Exercise the actual grid/fly draw path: the old fixed "To" at x=0 plus +-- destination at x=24 overlapped as soon as the prefix was wider than two +-- glyphs. It is now one reorderable format lookup and one draw call. +local grid = setmetatable({ + game = { data = { pokemon = {} } }, + mode = "grid", + bg = { map = {} }, + locs = { { name = "PALLET TOWN", x = 1, y = 1 } }, + sel = 1, + fly = true, + blink = 0, + drawFlyArrows = function() end, +}, TownMap) +drawn = {} +grid:draw() +local banner = {} +for _, entry in ipairs(drawn) do + if entry.y == 0 then banner[#banner + 1] = entry end +end +T.eq(#banner, 1, "grid/fly draws one composed destination banner") +T.eq(banner[1] and banner[1].text, "Vers BOURG PALETTE", + "grid/fly uses the reorderable To %s translation") +T.eq(banner[1] and banner[1].x, 0, + "the composed grid/fly banner starts at the strip's left edge") +T.check(banner[1] and banner[1].x + package.loaded["src.render.Font"] + .width(banner[1].text) <= 144, + "the grid/fly banner does not overlap its arrow tiles") + +-- The standalone FlyMenu has the same dynamic map-name path as TownMap. +local realMap = package.loaded["src.world.Map"] +package.loaded["src.ui.ListMenu"] = { + new = function(_, title, items, opts) + return { + title = title, kind = (opts and opts.kind) or title, items = items, + rows = 6, index = 1, scroll = 0, update = function() end, + } + end, +} +package.loaded["src.world.Map"] = { isFlyTown = function() return true end } +local FlyMenu = assert(loadfile("src/ui/FlyMenu.lua"))() +local fly = FlyMenu.new({ + data = { + field = { flyOrder = { "PALLET_TOWN" } }, + maps = { PALLET_TOWN = {} }, + }, + save = { visited = { PALLET_TOWN = true } }, +}) +T.eq(fly.title, "FLY TO?", + "FlyMenu preserves the source title for ListMenu hooks") +T.eq(fly.kind, "FLY TO?", + "FlyMenu preserves the source kind derived by ListMenu") +T.eq(Strings(fly.title), "VOLER VERS?", + "ListMenu's draw-time lookup translates once, not twice") +T.eq(fly.items[1] and fly.items[1].label, "BOURG PALETTE", + "FlyMenu translates dynamic destination names") + +local BagMenu = assert(loadfile("src/ui/BagMenu.lua"))() +local bag = BagMenu.new({ + data = { items = {} }, + save = { inventory = {}, bagOrder = {} }, +}) +T.eq(bag.title, "ITEMS", "BagMenu preserves its source title") +T.eq(bag.kind, "bag", "BagMenu preserves its stable explicit kind") +T.eq(Strings(bag.title), "OBJETS", + "BagMenu's draw-time lookup translates once, not twice") +package.loaded["src.world.Map"] = realMap + +-- Credits lines come from field.credits and therefore need a runtime lookup. +local Credits = require("src.ui.Credits") +local credits = setmetatable({}, Credits) +drawn = {} +credits:drawPage({ lines = { { text = "DIRECTOR", column = 3 } } }, 0, 1) +T.eq(drawn[1] and drawn[1].text, "RÉALISATEUR", + "extracted credits text passes through Strings before drawing") +credits.theEnd = { display = "T H E E N D" } +credits.endImg = nil +drawn = {} +credits:drawTheEnd() +T.eq(drawn[1] and drawn[1].text, "F I N", + "the extracted THE END fallback is translated") +T.eq(drawn[1] and drawn[1].x, 60, + "the translated THE END fallback is centered from Font.width") + +-- Text-only title fallbacks are centered from their translated pixel width, +-- rather than from the English source's hard-coded tile count. +local TitleState = assert(loadfile("src/ui/TitleState.lua"))() +local title = setmetatable({ + game = {}, menuOpen = false, scy = 0, yellowLayout = false, + phase = "loop", logo = nil, yellow = false, blue = true, version = nil, + player = nil, playerQuads = nil, scrollPhase = "hold", monOffset = 0, + currentSprite = function() return nil end, + drawCopyright = function() end, +}, TitleState) +drawn = {} +title:draw() +T.eq(drawn[1] and drawn[1].text, "BLEU", + "the translated title fallback is drawn") +T.eq(drawn[1] and drawn[1].x, 64, + "the translated title fallback is centered from Font.width") + +local SurfingMinigame = assert(loadfile("src/ui/SurfingMinigame.lua"))() +local surfing = setmetatable({ + titleBg = nil, introPikaX = 0, + drawIntroPikachu = function() end, +}, SurfingMinigame) +drawn = {} +surfing:drawTitleScreen() +T.eq(drawn[1] and drawn[1].text, "PLAGE", + "the translated beach title fallback is drawn") +T.eq(drawn[1] and drawn[1].x, 60, + "the translated beach title fallback is centered from Font.width") + +-- The trade info formats keep their numeric padding after translation. +local TradeAnim = require("src.ui.TradeAnim") +local trade = setmetatable({ game = { data = { pokemon = { + PIKACHU = { name = "PIKACHU", dex = 25 }, +} } } }, TradeAnim) +drawn = {} +trade:drawMonInfo({ species = "PIKACHU" }, "RED", 42, 0) +T.eq(drawn[1] and drawn[1].text, "N°025", + "translated Pokédex number format keeps zero padding") +T.eq(drawn[4] and drawn[4].text, "ID n°00042", + "translated trainer ID format keeps zero padding") + +-- Oak's demo step must resolve the ROM label, not translate the label name. +local OakSpeech = require("src.ui.OakSpeech") +local oakGame = { + data = { text = { _OakSpeechText2A = "MONDE TRADUIT" } }, + stack = { push = function(_, state) pushed = state end }, +} +local oak = setmetatable({ + game = oakGame, + demoSpecies = "NIDORINO", + demoPic = {}, + demoTrueColor = false, + revealPic = function(_, _, done) done() end, + advance = function() end, +}, OakSpeech) +pushed = nil +oak:runStep({ kind = "demo" }) +T.eq(pushed and pushed.text, "MONDE TRADUIT", + "Oak demo resolves _OakSpeechText2A through game.data.text") + +-- ui.pc.items must inspect stable English labels even with a live catalog; +-- only the final Menu rows are localized. This is also the runtime coverage +-- for the dynamic player's-PC format. +local function setUpvalue(fn, name, value) + local i = 1 + while true do + local found = debug.getupvalue(fn, i) + if not found then return false end + if found == name then debug.setupvalue(fn, i, value); return true end + i = i + 1 + end +end + +local Overworld = require("src.world.OverworldController") +local hookLabels, menuItems +local pcGame = { + data = { text = {} }, + save = { + flags = { EVENT_MET_BILL = true }, + player = { name = "RED" }, hallOfFame = {}, + }, + stack = { push = function() end }, +} +local pcRuntime = { + call = function(name, _, _, items) + T.eq(name, "ui.pc.items", "the PC rows use the documented hook") + hookLabels = {} + for i, item in ipairs(items) do hookLabels[i] = item.label end + return items + end, +} +package.loaded["src.ui.Menu"] = { + new = function(_, items) menuItems = items; return { items = items } end, +} +T.check(setUpvalue(Overworld.openPC, "Game", pcGame), + "openPC Game upvalue is injectable") +T.check(setUpvalue(Overworld.openPC, "Runtime", pcRuntime), + "openPC Runtime upvalue is injectable") +T.check(setUpvalue(Overworld.openPC, "TextBox", { + new = function(_, text, onDone) return { text = text, onDone = onDone } end, +}), "openPC TextBox upvalue is injectable") +local pc = setmetatable({}, { __index = Overworld }) +pc:openPC() +T.eq(hookLabels[1], "BILL'S PC", + "ui.pc.items sees the stable box-PC source label") +T.eq(hookLabels[2], "RED's PC", + "ui.pc.items sees the stable player-PC source label") +T.eq(menuItems[1].label, "PC DE LÉO", + "the retained box-PC row is translated after the hook") +T.eq(menuItems[2].label, "PC DE RED", + "the dynamic player-PC format is translated exactly once after the hook") + +-- Keep a ROM-free guard in the normal engine tier as well as the Python test +-- that runs modkit's actual harvester. These literals must stay at static +-- call sites; wrapping only a variable would work at runtime but disappear +-- from a freshly generated translation worksheet. +local function source(path) + local file = assert(io.open(path, "r")) + local body = file:read("*a") + file:close() + return body +end + +local harvestCalls = { + ["src/ui/BagMenu.lua"] = { + 'Strings.source("Which move?")', 'Strings.source("ITEMS")', + }, + ["src/ui/BoxMenu.lua"] = { + 'Strings("WITHDRAW")', 'Strings("DEPOSIT")', + 'Strings("The party is full!")', + }, + ["src/ui/Diploma.lua"] = { + 'Strings.source("Congrats! This")', + 'Strings.source("diploma certifies")', + }, + ["src/ui/FlyMenu.lua"] = { 'Strings.source("FLY TO?")' }, + ["src/ui/TradeAnim.lua"] = { + 'Strings("No.%03d",', 'Strings("IDNo.%05d",', + }, + ["src/ui/TrainerCard.lua"] = { + 'Strings("MONEY/¥%d",', 'Strings("TIME/%3d:%02d",', + }, + ["src/world/OverworldController.lua"] = { + 'Strings("BILL\'S PC")', 'Strings("%s\'s PC",', 'Strings("HEAL")', + }, +} +for path, calls in pairs(harvestCalls) do + local body = source(path) + for _, call in ipairs(calls) do + T.check(body:find(call, 1, true) ~= nil, + path .. " keeps harvestable " .. call) + end +end + +Strings.load(nil) +T.finish() diff --git a/tests/rby_translation_harvest_test.py b/tests/rby_translation_harvest_test.py new file mode 100644 index 00000000..88f64fce --- /dev/null +++ b/tests/rby_translation_harvest_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""RBY UI strings that must remain visible to the modkit catalog harvest.""" + +from pathlib import Path +from unittest import TestCase, main + +import sys + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "tools")) +import modkit # noqa: E402 + + +class RbyTranslationHarvestTest(TestCase): + def test_rby_ui_literals_are_harvested(self): + harvested = {literal for literal, _location in + modkit.harvest_engine_strings(str(REPO))} + expected = { + '"FLY TO?"', + '"Which move?"', + '"ITEMS"', + '"HEAL"', + '"WITHDRAW"', + '"DEPOSIT"', + '"The party is full!"', + '"MONEY/¥%d"', + '"TIME/%3d:%02d"', + '"No.%03d"', + '"IDNo.%05d"', + '"BILL\'S PC"', + '"%s\'s PC"', + '"AREA UNKNOWN"', + '"%s\'s NEST"', + '"To %s"', + '"Congrats! This"', + '"diploma certifies"', + '"that you have"', + '"completed your"', + '"POKéDEX."', + '"POKéMON BLUE"', + '"POKéMON YELLOW"', + '"PIKACHU\'S BEACH"', + } + self.assertEqual(expected - harvested, set()) + + def test_dynamic_consumers_translate_at_draw_time(self): + expected_calls = { + "src/ui/Credits.lua": "Strings(line.text)", + "src/ui/TownMap.lua": "Strings(loc.name)", + "src/ui/TitleState.lua": "Strings(self.title.copyrightText)", + } + for relative, call in expected_calls.items(): + source = (REPO / relative).read_text(encoding="utf-8") + self.assertIn(call, source, relative) + + +if __name__ == "__main__": + main()