From fad7443f944c475f1b97e5134d2b3d4c91cf6054 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:57:10 +0200 Subject: [PATCH 1/2] Route Gold's title menu and SAVE screen through Strings() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainMenu.lua's row labels (CONTINUE/NEW GAME/OPTION/EXIT GAME), its clock box's AM/PM half, and its CONTINUE save-summary panel (PLAYER / BADGES/POKéDEX/TIME, or NO SAVE FILE) were bare literals, invisible to a translation mod's strings registry. SaveMenu.lua's confirm/overwrite/ saving/saved prompts, its YES/NO choice, and its own copy of the same summary panel had the same gap. Both mirror the Gen 1 port's already- translated equivalents (src/ui/TitleState.lua, src/ui/StartMenu.lua), which route every one of these rows through Strings(). SaveMenu's two-line prompts (module-level OVERWRITE_PROMPT/SAVING_PROMPT, plus the dynamically-built "%s saved\nthe game." and the confirm/failed messages) are now single Strings()-resolved strings with an embedded "\n", matching the Gen 1 port's own single-call convention for two-line messages, split into the two-slot table drawPanel's fixed-position Chrome.print calls expect only at draw time -- so a translation sees one whole sentence to reorder, not two independently-translated fragments. Added tests/engine/gen2_main_menu_translation_test.lua and gen2_save_menu_translation_test.lua: drive both screens' drawPanel()/ drawSavePanel() with a mod-loaded Strings catalog and check the translated text reaches Font.draw, plus a vanilla no-mod case proving the fallback is unchanged. Confirmed both catch the regression: reverting either file to its pre-fix content fails the corresponding suite (7 and 15 checks respectively). --- src/ui/gen2/MainMenu.lua | 21 +-- src/ui/gen2/SaveMenu.lua | 58 ++++-- .../gen2_main_menu_translation_test.lua | 119 +++++++++++++ .../gen2_save_menu_translation_test.lua | 167 ++++++++++++++++++ 4 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 tests/engine/gen2_main_menu_translation_test.lua create mode 100644 tests/engine/gen2_save_menu_translation_test.lua diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua index 15d653ec..da0d42e9 100644 --- a/src/ui/gen2/MainMenu.lua +++ b/src/ui/gen2/MainMenu.lua @@ -24,6 +24,7 @@ local Logger = require("src.core.Logger") local Music = require("src.core.Music") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") +local Strings = require("src.core.Strings") local MainMenu = {} MainMenu.__index = MainMenu @@ -73,14 +74,14 @@ local function sameItems(_, items) return items end function MainMenu:buildList() local items = {} if self.hasSave then - items[#items + 1] = { label = "CONTINUE", value = "continue" } + items[#items + 1] = { label = Strings("CONTINUE"), value = "continue" } end - items[#items + 1] = { label = "NEW GAME", value = "new" } - items[#items + 1] = { label = "OPTION", value = "option" } + items[#items + 1] = { label = Strings("NEW GAME"), value = "new" } + items[#items + 1] = { label = Strings("OPTION"), value = "option" } -- Not on the cart: a cartridge is left by switching the console off, and -- there is no console here. Mirrors the Gen 1 port's title menu -- (src/ui/TitleState.lua), which adds the same row for the same reason. - items[#items + 1] = { label = "EXIT GAME", value = "exit" } + items[#items + 1] = { label = Strings("EXIT GAME"), value = "exit" } -- The same hook name and the same (game, items) payload the Gen 1 title -- menu raises (src/ui/TitleState.lua:openMenu), so one mod's title rows -- serve both games; only the row shape differs, because Chrome.List reads @@ -170,7 +171,7 @@ function MainMenu:drawClockBox() -- minutes; the AM/PM half is drawn by PrintHour itself. local display = hour % 12 if display == 0 then display = 12 end - local half = hour < 12 and "AM" or "PM" + local half = Strings(hour < 12 and "AM" or "PM") Chrome.print(("%s:%s %s"):format( Chrome.number(display, 2), Chrome.number(minute, 2, true), half), 4, 16) end @@ -180,15 +181,15 @@ function MainMenu:drawSavePanel() -- DisplaySaveInfoOnContinue: a box down the right side listing the trainer. Chrome.textbox(4, 0, 14, 9) if not summary then - Chrome.print("NO SAVE FILE", 5, 2) + Chrome.print(Strings("NO SAVE FILE"), 5, 2) return end - Chrome.print("PLAYER " .. summary.name, 5, 2) - Chrome.print("BADGES", 5, 4) + Chrome.print(Strings("PLAYER %s", summary.name), 5, 2) + Chrome.print(Strings("BADGES"), 5, 4) Chrome.printRight(tostring(summary.badges), 17, 4) - Chrome.print("POKéDEX", 5, 6) + Chrome.print(Strings("POKéDEX"), 5, 6) Chrome.printRight(tostring(summary.caught), 17, 6) - Chrome.print("TIME", 5, 8) + Chrome.print(Strings("TIME"), 5, 8) Chrome.printRight(("%d:%s"):format( summary.hours, Chrome.number(summary.minutes, 2, true)), 17, 8) end diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index c0eeb47b..17bdea32 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -26,6 +26,7 @@ local Chrome = require("src.ui.gen2.Chrome") local Save = require("src.core.gen2.Save") local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") local SaveMenu = {} SaveMenu.__index = SaveMenu @@ -55,10 +56,43 @@ local TIME_X, TIME_Y = 13, 8 local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 -- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and --- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save. +-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save +-- (src/ui/gen2/PcMenu.lua:savePrompt(), which returns this two-slot table +-- straight through to its own lines[1]/lines[2] Chrome.print calls, so the +-- table shape here is a cross-file contract that must not change). local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" } local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." } +-- The same two prompts as a single \n-joined Strings.source() key, used only +-- by this screen's own prompt() below (PcMenu keeps reading the untranslated +-- table above unchanged). One key lets a translation reorder the whole +-- sentence rather than two independently-translated fragments, and lets a +-- cart whose own translation shows it on ONE line (German's SAVING prompt +-- has no second line at all) say so directly -- the per-line "{RAM:...}"- +-- style split load_engine_overrides uses elsewhere requires a non-empty +-- override for every line, so it cannot express "this line is blank" the +-- way an embedded "\n"-less string can. +-- +-- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: tools/ +-- modkit.py's STRINGS_CALL harvester matches a quoted string literal +-- immediately inside Strings.source(...)/Strings(...), not an arbitrary +-- expression, so a computed argument here would be invisible to every +-- translator's `modkit.py translation ... --refresh` scaffold despite the +-- runtime lookup working fine -- caught by an independent review. Keep +-- these two byte-for-byte in sync with OVERWRITE_PROMPT/SAVING_PROMPT +-- above (checked by tests/engine/gen2_save_menu_translation_test.lua). +local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it") +local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.") + +-- Splits a Strings()-resolved "line one\nline two" into the two-slot table +-- drawPanel's fixed-position Chrome.print calls expect; a translation with no +-- "\n" at all (single-line messages like "Could not save.") lands whole on +-- the first slot, matching the untranslated code's own { text, "" } shape. +local function twoLines(text) + local first, second = text:match("^(.-)\n(.*)$") + return { first or text, second or "" } +end + function SaveMenu:wantsFillScale() return true end function SaveMenu:drawsWidescreen() return true end @@ -171,18 +205,18 @@ function SaveMenu:prompt() if self.phase == "overwrite" then -- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText -- when the ID differs. Only the first can happen here. - return OVERWRITE_PROMPT + return twoLines(Strings(OVERWRITE_PROMPT_SOURCE)) end if self.phase == "saving" then - return SAVING_PROMPT + return twoLines(Strings(SAVING_PROMPT_SOURCE)) end if self.phase == "done" then if self.saved then - return { self:playerName() .. " saved", "the game." } + return twoLines(Strings("%s saved\nthe game.", self:playerName())) end - return { "Could not save.", "" } + return twoLines(Strings("Could not save.")) end - return { "Would you like to", "save the game?" } + return twoLines(Strings("Would you like to\nsave the game?")) end function SaveMenu:drawPanel() @@ -190,10 +224,10 @@ function SaveMenu:drawPanel() local summary = Save.summary(self.save) Chrome.box(PANEL_X, PANEL_Y, PANEL_W, PANEL_H) if summary then - Chrome.print("PLAYER " .. summary.name, LABEL_X, LABEL_Y) - Chrome.print("BADGES", LABEL_X, LABEL_Y + 2) - Chrome.print("POKéDEX", LABEL_X, LABEL_Y + 4) - Chrome.print("TIME", LABEL_X, LABEL_Y + 6) + Chrome.print(Strings("PLAYER %s", summary.name), LABEL_X, LABEL_Y) + Chrome.print(Strings("BADGES"), LABEL_X, LABEL_Y + 2) + Chrome.print(Strings("POKéDEX"), LABEL_X, LABEL_Y + 4) + Chrome.print(Strings("TIME"), LABEL_X, LABEL_Y + 6) -- PrintNum fills its field from the left, space padded. Chrome.print(Chrome.number(summary.badges, 2), BADGES_X, BADGES_Y) Chrome.print(Chrome.number(summary.caught, 3), DEX_X, DEX_Y) @@ -211,8 +245,8 @@ function SaveMenu:drawPanel() if self.phase == "confirm" or self.phase == "overwrite" then Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) - Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1) - Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3) + Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) + Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.choice == 1 and 1 or 3)) end love.graphics.setColor(1, 1, 1, 1) diff --git a/tests/engine/gen2_main_menu_translation_test.lua b/tests/engine/gen2_main_menu_translation_test.lua new file mode 100644 index 00000000..e5c7152a --- /dev/null +++ b/tests/engine/gen2_main_menu_translation_test.lua @@ -0,0 +1,119 @@ +-- Gold's title/main menu (src/ui/gen2/MainMenu.lua) drew every row label +-- (CONTINUE/NEW GAME/OPTION/EXIT GAME), the clock box's AM/PM half, and the +-- CONTINUE save-summary panel's labels (PLAYER /BADGES/POKéDEX/TIME, +-- or NO SAVE FILE) as bare literals, invisible to a translation mod's +-- `strings` registry -- unlike the Gen 1 port's own title menu +-- (src/ui/TitleState.lua/StartMenu.lua), which already routes the same rows +-- through Strings(). Drives MainMenu:drawPanel()/:drawSavePanel() with a +-- mod-loaded Strings catalog and checks the translated text reaches +-- Font.draw, same technique as +-- tests/engine/gen2_naming_screen_translation_test.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, + width = function() return 0 end, +} + +local MainMenu = require("src.ui.gen2.MainMenu") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua). +-- List item 1 lands at (self.x, self.y) = (2, 2); the clock box's day name +-- at (1, 14) and the hour:minute half at (4, 16); the save panel's PLAYER +-- row at (5, 2). +local FIRST_ITEM_X, FIRST_ITEM_Y = 2 * 8, 2 * 8 +local CLOCK_HALF_X, CLOCK_HALF_Y = 4 * 8, 16 * 8 +local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8 + +local SAVE = { player = { name = "GOLD" } } +local CLOCK = { hour = 13, minute = 5, weekday = 1 } -- 1 PM, SUNDAY + +-- ---------------------------------------------- vanilla: no mod catalog +do + local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUE", + "the title menu's first row draws in English with no mod loaded") + T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM", + "and the clock box's AM/PM half") + + drawn = {} + menu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD", + "the CONTINUE save-summary panel too") + + local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK }) + drawn = {} + noSaveMenu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "NO SAVE FILE", + "and its no-summary fallback") +end + +-- ------------------------------------------------- a translation mod's turn +do + Strings.load({ + strings = { + ["CONTINUE"] = "CONTINUAR", + ["NEW GAME"] = "NUEVA PARTIDA", + ["OPTION"] = "OPCIÓN", + ["EXIT GAME"] = "SALIR", + ["PM"] = "PM_ES", + ["PLAYER %s"] = "JUGADOR %s", + ["BADGES"] = "MEDALLAS", + ["POKéDEX"] = "POKéDEX_ES", + ["TIME"] = "TIEMPO", + ["NO SAVE FILE"] = "SIN PARTIDA", + }, + }) + + local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUAR", + "a mod catalog reaches the title menu's first row") + T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM_ES", + "and the clock box's AM/PM half") + + drawn = {} + menu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JUGADOR GOLD", + "the save-summary panel's PLAYER row takes the mod's own word order") + T.eq(drawnAt(5 * 8, 4 * 8), "MEDALLAS", "and BADGES") + T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_ES", "and POKéDEX") + T.eq(drawnAt(5 * 8, 8 * 8), "TIEMPO", "and TIME") + + local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK }) + drawn = {} + noSaveMenu:drawSavePanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "SIN PARTIDA", + "and the no-summary fallback") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +T.finish("gen2_main_menu_translation_test") diff --git a/tests/engine/gen2_save_menu_translation_test.lua b/tests/engine/gen2_save_menu_translation_test.lua new file mode 100644 index 00000000..e74a9072 --- /dev/null +++ b/tests/engine/gen2_save_menu_translation_test.lua @@ -0,0 +1,167 @@ +-- Gold's SAVE screen (src/ui/gen2/SaveMenu.lua) drew every prompt ("Would +-- you like to save the game?", the overwrite/saving/saved messages), the +-- YES/NO choice, and the summary panel's labels (PLAYER /BADGES/ +-- POKéDEX/TIME) as bare literals, invisible to a translation mod's +-- `strings` registry -- unlike the Gen 1 port's own SAVE screen +-- (src/ui/StartMenu.lua), which already routes the same rows through +-- Strings(). Drives SaveMenu:drawPanel() directly at each phase with a +-- mod-loaded Strings catalog and checks the translated text reaches +-- Font.draw, same technique as +-- tests/engine/gen2_naming_screen_translation_test.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local drawn +package.loaded["src.render.Font"] = { + draw = function(text, x, y) + drawn[#drawn + 1] = { text = text, x = x, y = y } + end, + drawCode = function() end, + drawBox = function() end, + width = function() return 0 end, +} + +local SaveMenu = require("src.ui.gen2.SaveMenu") +local Strings = require("src.core.Strings") + +local function drawnAt(x, y) + for _, d in ipairs(drawn) do + if d.x == x and d.y == y then return d.text end + end + return nil +end + +-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua). +-- PLAYER row at (5, 2); the two prompt lines at (1, 14)/(1, 16); YES/NO at +-- (2, 8)/(2, 10) (YESNO_X + 2, YESNO_Y + 1 / + 3). +local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8 +local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8 +local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8 +local YES_X, YES_Y = 2 * 8, 8 * 8 +local NO_X, NO_Y = 2 * 8, 10 * 8 + +local SAVE = { player = { name = "GOLD" } } + +-- ---------------------------------------------- vanilla: no mod catalog +do + local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD", + "the summary panel draws in English with no mod loaded") + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Would you like to", + "and the confirm prompt's first line") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save the game?", "and its second line") + T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES") + T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO") + + menu.phase = "overwrite" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "the overwrite prompt") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line") + + menu.phase = "saving" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line") + + menu.phase, menu.saved = "done", true + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line") + + menu.phase, menu.saved = "done", false + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message") +end + +-- ------------------------------------------------- a translation mod's turn +do + Strings.load({ + strings = { + ["PLAYER %s"] = "JOUEUR %s", + ["BADGES"] = "BADGES_FR", + ["POKéDEX"] = "POKéDEX_FR", + ["TIME"] = "TEMPS", + ["YES"] = "OUI", + ["NO"] = "NON", + ["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?", + ["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce", + ["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.", + ["%s saved\nthe game."] = "%s a sauvegarde\nla partie.", + ["Could not save."] = "Echec de sauvegarde.", + }, + }) + + local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JOUEUR GOLD", + "the summary panel's PLAYER row takes the mod's own word order") + T.eq(drawnAt(5 * 8, 4 * 8), "BADGES_FR", "and BADGES") + T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_FR", "and POKéDEX") + T.eq(drawnAt(5 * 8, 8 * 8), "TEMPS", "and TIME") + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Voulez-vous", "the confirm prompt") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarder ?", "its second line") + T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES") + T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO") + + menu.phase = "overwrite" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe", "the overwrite prompt") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line") + + menu.phase = "saving" + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line") + + menu.phase, menu.saved = "done", true + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde", + "the saved message folds the player name into the mod's own word order") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line") + + menu.phase, menu.saved = "done", false + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message") + + -- Module state is process-global (see tests/gen2_clock_test.lua's own + -- note); this suite gets its own process from tests/tier_runner.lua, but + -- leaving the catalog loaded past this point would still mistranslate + -- every check below it in this file. + Strings.load({}) + T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") +end + +-- src/ui/gen2/PcMenu.lua:savePrompt() returns SaveMenu.OVERWRITE_PROMPT/ +-- SAVING_PROMPT straight through to its own `lines[1]`/`lines[2]` +-- Chrome.print calls (the PC's CHANGE BOX save uses the same two prompts). +-- Indexing a plain string with [1]/[2] returns nil, not characters, so this +-- shape is a cross-file contract: it caught a real regression during review, +-- where routing these through a single Strings.source()-wrapped string (to +-- translate SaveMenu's own screen) silently turned them into non-table +-- values and left PcMenu's overwrite/saving prompt blank. +do + T.eq(type(SaveMenu.OVERWRITE_PROMPT), "table", "OVERWRITE_PROMPT stays a table for PcMenu.lua") + T.eq(SaveMenu.OVERWRITE_PROMPT[1], "There is already a", "and its first line stays indexable") + T.eq(SaveMenu.OVERWRITE_PROMPT[2], "save file. Is it", "and its second line") + T.eq(type(SaveMenu.SAVING_PROMPT), "table", "SAVING_PROMPT stays a table for PcMenu.lua") + T.eq(SaveMenu.SAVING_PROMPT[1], "SAVING… DON'T TURN", "and its first line stays indexable") + T.eq(SaveMenu.SAVING_PROMPT[2], "OFF THE POWER.", "and its second line") +end + +T.finish("gen2_save_menu_translation_test") From 654650e3ffac90a1650124d50aa10d70d33b8122 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:31:38 +0200 Subject: [PATCH 2/2] Translate Pokegear's AM/PM too, and warn instead of corrupting a 3rd line An independent review flagged two gaps: Pokegear.lua's clock card (drawClock and the card-list clock view) prints the same AM/PM information MainMenu's clock box does, but was left as bare literals -- so a mod translating AM/PM would now show it correctly on the title screen and in English on the Pokegear for the exact same hour. Both call sites now go through Strings("AM")/Strings("PM"), the same source key MainMenu.lua already added, so no new corpus entry is needed on the translation-mods side. SaveMenu.lua's twoLines() only ever split on the first "\n": drawPanel's box has room for exactly two Chrome.print calls, so a translation needing a third line (nothing in the current fr/de/es/it overrides does, but nothing stopped one from trying) had nowhere on screen to go, and the extra "\n" landed as a literal newline byte drawn as glyph garbage on the second line with no indication anything was wrong. It now warns once per source text instead. Added a gen2_save_menu_translation_test.lua case driving drawPanel() with a three-line mod translation: confirms only the first two lines render, the warning fires exactly once (not once per frame), and drawing does not crash. Pokegear.lua has no engine-tier test suite to extend for the AM/PM change; verified by direct inspection and a syntax check instead. --- src/ui/gen2/Pokegear.lua | 5 +- src/ui/gen2/SaveMenu.lua | 54 +++++++++++-------- .../gen2_save_menu_translation_test.lua | 32 +++++++++++ 3 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 4d94027e..14c54177 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -26,6 +26,7 @@ local Font = require("src.render.Font") local Palettes = require("src.world.gen2.Palettes") local Phone = require("src.core.gen2.Phone") local SpriteRenderer = require("src.render.SpriteRenderer") +local Strings = require("src.core.Strings") local TileSheet = require("src.ui.gen2.TileSheet") local Pokegear = {} @@ -1881,7 +1882,7 @@ function Pokegear:drawClock() self:text(Chrome.number(display, 2), 6, 8) self:text(":", 8, 8) self:text(Chrome.number(minute, 2, true), 9, 8) - self:text(hour < 12 and "AM" or "PM", 12, 8) + self:text(Strings(hour < 12 and "AM" or "PM"), 12, 8) -- The bottom Textbox is part of the card (lb bc, 4, 18 at (0,12)), and -- PokegearClock_Init prints PokegearPressButtonText straight into it @@ -2218,7 +2219,7 @@ function Pokegear:drawPlain() if display == 0 then display = 12 end Chrome.print(("%s:%s %s"):format( Chrome.number(display, 2), Chrome.number(minute, 2, true), - hour < 12 and "AM" or "PM"), 5, 9) + Strings(hour < 12 and "AM" or "PM")), 5, 9) Chrome.print(Clock.daytimeLabel(hour), 5, 11) elseif id == "radio" then -- Without the gear sheet there is no dial art, so the frequencies go down diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index 17bdea32..d3e0eacc 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -24,6 +24,7 @@ -- one-line call. local Chrome = require("src.ui.gen2.Chrome") +local Logger = require("src.core.Logger") local Save = require("src.core.gen2.Save") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") @@ -57,39 +58,46 @@ local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 -- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and -- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save --- (src/ui/gen2/PcMenu.lua:savePrompt(), which returns this two-slot table --- straight through to its own lines[1]/lines[2] Chrome.print calls, so the --- table shape here is a cross-file contract that must not change). +-- (src/ui/gen2/PcMenu.lua:savePrompt() reads these two tables' lines[1]/ +-- lines[2] directly, so their shape is a cross-file contract: keep them +-- plain, untranslated tables). local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" } local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." } --- The same two prompts as a single \n-joined Strings.source() key, used only --- by this screen's own prompt() below (PcMenu keeps reading the untranslated --- table above unchanged). One key lets a translation reorder the whole --- sentence rather than two independently-translated fragments, and lets a --- cart whose own translation shows it on ONE line (German's SAVING prompt --- has no second line at all) say so directly -- the per-line "{RAM:...}"- --- style split load_engine_overrides uses elsewhere requires a non-empty --- override for every line, so it cannot express "this line is blank" the --- way an embedded "\n"-less string can. +-- Translatable copies of the two prompts above, one \n-joined key each, used +-- only by this screen's own prompt() below. One key per prompt lets a +-- translation write one whole, freely reordered sentence instead of two +-- fragments translated in isolation, and lets a cart whose own text is a +-- single line (German's SAVING prompt) say so directly by simply omitting +-- the "\n" -- the per-line override style used elsewhere requires a +-- non-empty value for every line, so it can't express "this line is blank". -- --- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: tools/ --- modkit.py's STRINGS_CALL harvester matches a quoted string literal --- immediately inside Strings.source(...)/Strings(...), not an arbitrary --- expression, so a computed argument here would be invisible to every --- translator's `modkit.py translation ... --refresh` scaffold despite the --- runtime lookup working fine -- caught by an independent review. Keep --- these two byte-for-byte in sync with OVERWRITE_PROMPT/SAVING_PROMPT --- above (checked by tests/engine/gen2_save_menu_translation_test.lua). +-- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: the +-- translation tooling's string harvester only recognizes a literal inside +-- Strings.source(...), not a computed expression, so a concat call here +-- would quietly never reach a translator. Keep byte-for-byte in sync with +-- OVERWRITE_PROMPT/SAVING_PROMPT above (checked by +-- tests/engine/gen2_save_menu_translation_test.lua). local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it") local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.") --- Splits a Strings()-resolved "line one\nline two" into the two-slot table --- drawPanel's fixed-position Chrome.print calls expect; a translation with no --- "\n" at all (single-line messages like "Could not save.") lands whole on +-- Splits a translated "line one\nline two" string back into the two-slot +-- table drawPanel's fixed Chrome.print calls expect. No "\n" at all (a +-- single-line message, or German's one-line SAVING prompt) lands whole on -- the first slot, matching the untranslated code's own { text, "" } shape. +-- +-- Only the first "\n" splits, since this box has room for exactly two +-- lines. A third line would otherwise draw as a raw newline byte -- garbage +-- glyph data -- with no other sign anything went wrong, so this warns once +-- per string instead. +local warnedTooManyLines = {} local function twoLines(text) local first, second = text:match("^(.-)\n(.*)$") + if second and second:find("\n", 1, true) and not warnedTooManyLines[text] then + warnedTooManyLines[text] = true + Logger.warn("SaveMenu: translation of %q has more than two lines; " .. + "only the first two fit this box", text) + end return { first or text, second or "" } end diff --git a/tests/engine/gen2_save_menu_translation_test.lua b/tests/engine/gen2_save_menu_translation_test.lua index e74a9072..c560d0bf 100644 --- a/tests/engine/gen2_save_menu_translation_test.lua +++ b/tests/engine/gen2_save_menu_translation_test.lua @@ -164,4 +164,36 @@ do T.eq(SaveMenu.SAVING_PROMPT[2], "OFF THE POWER.", "and its second line") end +-- A translation with a THIRD line (a second embedded "\n") has nowhere on +-- screen to go -- drawPanel's box has room for exactly two Chrome.print +-- calls -- so it must not silently draw the literal newline byte as glyph +-- garbage on the second line, and should warn so a translator notices. +do + Strings.load({ + strings = { + ["Would you like to\nsave the game?"] = "Ligne un\nLigne deux\nLigne trois", + }, + }) + + local warned = {} + require("src.core.Logger").warn = function(fmt, ...) + warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt + end + + local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois", + "the rest lands in the second slot rather than vanishing") + T.check(#warned == 1, "and a single warning is logged") + + drawn = {} + menu:drawPanel() + T.check(#warned == 1, "the warning does not repeat for the same text") + + require("src.core.Logger").warn = function() end + Strings.load({}) +end + T.finish("gen2_save_menu_translation_test")