From b5988d46540f597bc49a8df8b971738d283fbe85 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:14:42 +0200 Subject: [PATCH 1/9] Expand Ruby and Sapphire's placeholders from the script cache The runtime expanded Ruby and Sapphire's {EVIL_TEAM}, {GOOD_LEADER}, {VERSION}, {RIVAL} and {KUN} (and the other version-dependent names) from text/placeholders.lua, the values the extractor reads from the US cart, so a translation never reached them: the team leaders stayed MAXIE and ARCHIE, the rival MAY or BRENDAN, the version RUBY or SAPPHIRE, and the Japanese honorific empty. Each value is a cart string the script cache also holds under its pokeruby label (gExpandedPlaceholder_Maxie, _May, _Ruby, _Kun...); the placeholders now read it from there, with the extract's value as the fallback, the way Emerald's do. The labels are defined once, in text_placeholders_extract.lua (rsSymbols, the evil and good teams swapped by edition), and both the Ruby/Sapphire import and the message box read them from there. --- src/import/gba/games/rs/text.lua | 16 +---- src/import/gba/text_placeholders_extract.lua | 24 +++++++ src/ui/game3/message.lua | 9 +-- .../game3_rs_translation_runtime_test.lua | 67 +++++++++++++++++++ 4 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 tests/engine/game3_rs_translation_runtime_test.lua diff --git a/src/import/gba/games/rs/text.lua b/src/import/gba/games/rs/text.lua index d27b59cd..b7699f30 100644 --- a/src/import/gba/games/rs/text.lua +++ b/src/import/gba/games/rs/text.lua @@ -17,19 +17,7 @@ return function(V) } end - local stems = { - UNKNOWN = "Empty", KUN_MALE = "Kun", KUN_FEMALE = "Chan", - RIVAL_MALE = "May", RIVAL_FEMALE = "Brendan", - VERSION = V.GAME == "sapphire" and "Sapphire" or "Ruby", - AQUA = "Aqua", MAGMA = "Magma", ARCHIE = "Archie", MAXIE = "Maxie", - KYOGRE = "Kyogre", GROUDON = "Groudon", - } - -- pokeruby/src/string_util.c:476 - local evil, good = { "Magma", "Maxie", "Groudon" }, { "Aqua", "Archie", "Kyogre" } - if V.GAME == "sapphire" then evil, good = good, evil end - for i, name in ipairs({ "TEAM", "LEADER", "LEGENDARY" }) do - stems["EVIL_" .. name], stems["GOOD_" .. name] = evil[i], good[i] - end V.TEXT_PLACEHOLDERS = {} - for name, stem in pairs(stems) do V.TEXT_PLACEHOLDERS[name] = V.sym("gExpandedPlaceholder_" .. stem) end + local Placeholders = require("src.import.gba.text_placeholders_extract") + for name, label in pairs(Placeholders.rsSymbols(V.GAME)) do V.TEXT_PLACEHOLDERS[name] = V.sym(label) end end diff --git a/src/import/gba/text_placeholders_extract.lua b/src/import/gba/text_placeholders_extract.lua index 9f43bfbe..42d78fdd 100644 --- a/src/import/gba/text_placeholders_extract.lua +++ b/src/import/gba/text_placeholders_extract.lua @@ -20,6 +20,30 @@ TextPlaceholders.SYMBOLS = { GROUDON = "gText_ExpandedPlaceholder_Groudon", } +-- pokeruby/src/string_util.c:476 +function TextPlaceholders.rsSymbols(game) + local stems = { + UNKNOWN = "Empty", KUN_MALE = "Kun", KUN_FEMALE = "Chan", + RIVAL_MALE = "May", RIVAL_FEMALE = "Brendan", + VERSION = game == "sapphire" and "Sapphire" or "Ruby", + AQUA = "Aqua", MAGMA = "Magma", ARCHIE = "Archie", MAXIE = "Maxie", + KYOGRE = "Kyogre", GROUDON = "Groudon", + } + local evil, good = { "Magma", "Maxie", "Groudon" }, { "Aqua", "Archie", "Kyogre" } + if game == "sapphire" then evil, good = good, evil end + for i, name in ipairs({ "TEAM", "LEADER", "LEGENDARY" }) do + stems["EVIL_" .. name], stems["GOOD_" .. name] = evil[i], good[i] + end + local out = {} + for name, stem in pairs(stems) do out[name] = "gExpandedPlaceholder_" .. stem end + return out +end + +function TextPlaceholders.symbolsFor(game) + if game == "ruby" or game == "sapphire" then return TextPlaceholders.rsSymbols(game) end + return TextPlaceholders.SYMBOLS +end + -- pokeemerald/src/string_util.c:448 TextPlaceholders.BY_GENDER = { KUN = { male = "KUN_MALE", female = "KUN_FEMALE" }, diff --git a/src/ui/game3/message.lua b/src/ui/game3/message.lua index c1b24df5..5bb979cd 100644 --- a/src/ui/game3/message.lua +++ b/src/ui/game3/message.lua @@ -42,17 +42,18 @@ end local placeholderCache = {} -- pokeemerald/src/strings.c:6 -local function cartPlaceholders(extracted) +local function cartPlaceholders(extracted, game) local Extract = require("src.import.gba.text_placeholders_extract") local RomText = require("src.core.game3.rom_text") + local symbols = Extract.symbolsFor(game) local function value(name) - local label = Extract.SYMBOLS[name] + local label = symbols[name] if label and RomText.has(label) then return RomText.plain(label) end return extracted[name] end local out, byGender = {}, {} for name, v in pairs(extracted) do out[name] = v end - for name in pairs(Extract.SYMBOLS) do out[name] = value(name) end + for name in pairs(symbols) do out[name] = value(name) end for name, pair in pairs(extracted.byGender or {}) do byGender[name] = pair end for name, pair in pairs(Extract.BY_GENDER) do byGender[name] = { male = out[pair.male], female = out[pair.female] } @@ -94,7 +95,7 @@ TextIR.setContextProvider(function(kind, dialect, ctx) local okC, CacheFs = pcall(require, "src.import.CacheFs") local t = okC and CacheFs.loadActive(dialect.placeholders) or nil if type(t) == "table" then - placeholderCache[id] = { bundle = bundle, values = cartPlaceholders(t) } + placeholderCache[id] = { bundle = bundle, values = cartPlaceholders(t, id) } return placeholderCache[id].values end return nil diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua new file mode 100644 index 00000000..217ee6e3 --- /dev/null +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -0,0 +1,67 @@ +-- Ruby and Sapphire read the cart strings a translation mod overrides from +-- the script cache, where the mod's text overrides land, as Emerald does +-- (game3_emerald_pack_text_test.lua); the extract's or the pack's copy stays +-- the fallback. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local function ir(s) return { { t = "text", s = s }, { t = "eos" } } end + +local BUNDLE = { text = {} } +package.loaded["src.core.game3.scripting.space"] = { ensureBundle = function() return BUNDLE end } + +local TextIR = require("src.core.game3.scripting.text_ir") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +-- Expanded placeholders (pokeruby/src/string_util.c:476): the teams, their +-- leaders and legendaries by edition, the version, the rival and the +-- honorific. +local Extract = require("src.import.gba.text_placeholders_extract") +local ruby, sapphire = Extract.rsSymbols("ruby"), Extract.rsSymbols("sapphire") +check(ruby.EVIL_TEAM == "gExpandedPlaceholder_Magma" and sapphire.EVIL_TEAM == "gExpandedPlaceholder_Aqua", + "the evil team is Magma in Ruby and Aqua in Sapphire") +check(ruby.GOOD_LEADER == "gExpandedPlaceholder_Archie" and sapphire.GOOD_LEADER == "gExpandedPlaceholder_Maxie", + "the good team's leader follows the edition") +check(ruby.VERSION == "gExpandedPlaceholder_Ruby" and sapphire.VERSION == "gExpandedPlaceholder_Sapphire", + "the version names the edition") +check(Extract.symbolsFor("emerald") == Extract.SYMBOLS, "Emerald keeps its own labels") + +local Message = require("src.ui.game3.message") +BUNDLE.text = { + gExpandedPlaceholder_Maxie = ir("MAX"), gExpandedPlaceholder_Archie = ir("ARTHUR"), + gExpandedPlaceholder_Ruby = ir("RUBIS"), gExpandedPlaceholder_May = ir("FLORA"), + gExpandedPlaceholder_Kun = ir("くん"), +} +local placeholders = Message.cartPlaceholders({ + EVIL_TEAM = "MAGMA", EVIL_LEADER = "MAXIE", GOOD_LEADER = "ARCHIE", VERSION = "RUBY", + RIVAL_MALE = "MAY", RIVAL_FEMALE = "BRENDAN", KUN_MALE = "", KUN_FEMALE = "", + byGender = { RIVAL = { male = "MAY", female = "BRENDAN" }, KUN = { male = "", female = "" } }, +}, "ruby") +check(placeholders.EVIL_LEADER == "MAX" and placeholders.GOOD_LEADER == "ARTHUR", + "the leaders' names read the cache by the edition's labels") +check(placeholders.VERSION == "RUBIS", "the version's name reads the cache") +check(placeholders.EVIL_TEAM == "MAGMA", "a placeholder missing from the cache keeps the copy") +check(placeholders.byGender.RIVAL.male == "FLORA", "the rival's name reads the cache") +local line = { { t = "player" }, { t = "ph", code = 5, name = "KUN" }, { t = "text", s = ": " }, + { t = "ph", code = 10, name = "EVIL_LEADER" }, { t = "text", s = " / " }, + { t = "ph", code = 6, name = "RIVAL" }, { t = "eos" } } +check(TextIR.toPlain(line, { dialect = "rs", playerName = "RED", playerGender = 0, placeholders = placeholders }) + == "REDくん: MAX / FLORA", "a Ruby line expands the honorific, the leader and the rival from the cache") +BUNDLE.text = { gExpandedPlaceholder_Archie = ir("ARTHUR"), gExpandedPlaceholder_Maxie = ir("MAX") } +local sapphireLine = Message.cartPlaceholders({ EVIL_LEADER = "ARCHIE", byGender = {} }, "sapphire") +check(sapphireLine.EVIL_LEADER == "ARTHUR", "Sapphire's evil leader is Archie's row") + +if failed > 0 then + print(failed .. " check(s) failed") + os.exit(1) +end +print("all checks passed") From bb568d32839e0e258cfc127e96d743753f4da894 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:29:15 +0200 Subject: [PATCH 2/9] =?UTF-8?q?Read=20Ruby=20and=20Sapphire's=20Pok=C3=A9d?= =?UTF-8?q?ex=20text=20from=20the=20cart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native Ruby/Sapphire Pokédex printed the entry's two description pages from the entries pack, and its labels (?????POKéMON, CRY OF, REGISTRATION COMPLETE...) and search screen from the chrome packs' English copies, so a translation never reached them: the Emerald path's Strings() lookup does not cover the native one, and the search screen's labels are Emerald's (gText_Dex*), which Ruby and Sapphire's cache does not have. The entries pack now keeps the pret label of each description pointer (DexDescription_Treecko_1 and _2; each edition words its entries in its own way under the same labels), and the Ruby/Sapphire Pokédex pack the DexText_ label of each search title and description (DexText_ABC...); the pack's version moves to 1 and the entries are ready on Ruby and Sapphire only with their labels, so a cache imported without them is imported again. The screen reads them from the script cache by those labels, its own labels by their gDexText_* labels and the category through Strings(), as Emerald's does, with the pack's copy as the fallback. The category is followed by what the unknown-category string has after its question marks, as only the English cart does (pokeruby/src/pokedex.c:4228): " POKéMON" in English, "ポケモン" in Japanese, nothing in the European strings ("?????", "POKéMON ?????"), whose carts print the category alone. The Japanese size comparison row is a suffix ("の おおきさくらべ"), so the screen prints the player's name before it. --- src/import/gba/pokedex_chrome_extract.lua | 17 ++++- src/import/gba/pokedex_entries_extract.lua | 5 ++ src/import/gba/rs/assets.lua | 3 + src/import/gba/rs/cache_readiness.lua | 2 +- src/import/gba/rs/extract_pokedex.lua | 24 ++++-- src/ui/game3/rse/pokedex.lua | 64 +++++++++++----- .../game3_rs_translation_runtime_test.lua | 74 +++++++++++++++++++ 7 files changed, 163 insertions(+), 26 deletions(-) diff --git a/src/import/gba/pokedex_chrome_extract.lua b/src/import/gba/pokedex_chrome_extract.lua index 4d6b55f9..b58987ad 100644 --- a/src/import/gba/pokedex_chrome_extract.lua +++ b/src/import/gba/pokedex_chrome_extract.lua @@ -153,6 +153,12 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) end end + local syms = Versions.SYMS + local function symbolAt(ptr) + if not (syms and syms.namesAt and ptr and ptr >= 0x08000000 and ptr < 0x0A000000) then return nil end + return syms.namesAt(ptr - 0x08000000)[1] + end + local entries = {} -- src/data/pokemon/pokedex_entries.h:3 for nat = 0, natDexCount do @@ -177,6 +183,8 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) weight = weight, description = desc1, description2 = desc2, + descriptionLabel = symbolAt(descPtr1), + descriptionLabel2 = E.desc2 and symbolAt(descPtr2) or nil, pokemonScale = pokemonScale, pokemonOffset = pokemonOffset, trainerScale = trainerScale, @@ -195,8 +203,12 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) table.sort(ids) for _, id in ipairs(ids) do local e = entries[id] + local labels = "" + for _, field in ipairs({ "descriptionLabel", "descriptionLabel2" }) do + if e[field] then labels = labels .. string.format(", %s = \"%s\"", field, escape_lua(e[field])) end + end lines[#lines + 1] = string.format( - " [%d] = { category = \"%s\", height = %d, weight = %d, description = \"%s\", description2 = \"%s\", pokemonScale = %d, pokemonOffset = %d, trainerScale = %d, trainerOffset = %d },", + " [%d] = { category = \"%s\", height = %d, weight = %d, description = \"%s\", description2 = \"%s\", pokemonScale = %d, pokemonOffset = %d, trainerScale = %d, trainerOffset = %d%s },", id, escape_lua(e.category), e.height or 0, @@ -206,7 +218,8 @@ function PokedexChromeExtract.extractEntries(rom, cache, root) e.pokemonScale or 256, e.pokemonOffset or 0, e.trainerScale or 256, - e.trainerOffset or 0 + e.trainerOffset or 0, + labels ) end lines[#lines + 1] = "}" diff --git a/src/import/gba/pokedex_entries_extract.lua b/src/import/gba/pokedex_entries_extract.lua index d8f87ef3..aa7af8af 100644 --- a/src/import/gba/pokedex_entries_extract.lua +++ b/src/import/gba/pokedex_entries_extract.lua @@ -23,6 +23,11 @@ function PokedexEntriesExtract.ready(cache, cacheRoot) for _, f in ipairs(PokedexEntriesExtract.files()) do if not cache:exists(root .. f) then return false end end + local okL, L = pcall(function() return require("src.import.gba.layouts.registry").active() end) + if okL and type(L) == "table" and L.id == "rs" then + local body = cache.read and cache:read(root .. "entries.lua") + return body ~= nil and body:find("descriptionLabel", 1, true) ~= nil + end return true end diff --git a/src/import/gba/rs/assets.lua b/src/import/gba/rs/assets.lua index cb63866c..88578459 100644 --- a/src/import/gba/rs/assets.lua +++ b/src/import/gba/rs/assets.lua @@ -25,6 +25,9 @@ function M.text(c, off) end error("RS text is unterminated") end +function M.label(c, off) + return off and c.S.namesAt(off)[1] or nil +end function M.pairs(c, name, signed) local off, out = c:off(name), {} for i = 0, c.S.size(name) / 2 - 1 do diff --git a/src/import/gba/rs/cache_readiness.lua b/src/import/gba/rs/cache_readiness.lua index 1ed2666f..5524401d 100644 --- a/src/import/gba/rs/cache_readiness.lua +++ b/src/import/gba/rs/cache_readiness.lua @@ -19,7 +19,7 @@ local PACKS = { {"naming", {version = 3}}, {"rse/menus"}, {"rse/common_ui"}, {"rse/bag"}, {"rse/berry_tag"}, {"rse/party"}, {"rse/summary"}, {"rse/mail"}, {"rse/easy_chat"}, {"rse/trendy_phrase"}, {"rse/easy_chat_editor", {editorVersion = 2}}, {"rse/rs_egg_hatch"}, - {"pokemon/storage", {version = 5}}, {"rse/pokedex"}, {"rse/pokedex_detail"}, {"rse/diploma"}, + {"pokemon/storage", {version = 5}}, {"rse/pokedex", {packVersion = 1}}, {"rse/pokedex_detail"}, {"rse/diploma"}, {"rse/trainer_card"}, {"rse/trade"}, {"rse/pokenav"}, {"rse/pokenav_detail"}, {"rse/pokenav_shell"}, {"rse/pokenav_condition"}, {"rse/pokenav_ribbons"}, {"rs/assets"}, } diff --git a/src/import/gba/rs/extract_pokedex.lua b/src/import/gba/rs/extract_pokedex.lua index cfedae3f..ecd31859 100644 --- a/src/import/gba/rs/extract_pokedex.lua +++ b/src/import/gba/rs/extract_pokedex.lua @@ -1,6 +1,6 @@ local K = require("src.import.gba.rse.boot_gfx") local A = require("src.import.gba.rs.assets") -local M = {SUB = "rse/pokedex"} +local M = {SUB = "rse/pokedex", packVersion = 1} local N = "pokedex.o:" local maps = {{"list", "gUnknown_08E96738"}, {"list_underlay", "gUnknown_08E9C6DC"}, {"start_menu_main", "gPokedexStartMenuMain_Tilemap"}, {"start_menu_search", "gPokedexStartMenuSearchResults_Tilemap"}, @@ -18,18 +18,24 @@ local function bytes(c, name, stride) for i = 0, c.S.count(name, stride or 1) - 1 do out[i + 1] = stride == 2 and c:u16(off + i * 2) or c:u8(off + i) end return out end +-- pokeruby/src/pokedex.c:1116 +local function ownLabel(c, off) + local label = A.label(c, off) + return label and label:match("^DexText_") and label or nil +end local function options(c, name) local out, off = {}, c:off(N .. name) for i = 0, c.S.count(N .. name, 8) - 1 do local a, b = c:ptr(off + i * 8), c:ptr(off + i * 8 + 4) if not b then break end - out[#out + 1] = {description = a and A.text(c, a) or "", title = A.text(c, b)} + out[#out + 1] = {description = a and A.text(c, a) or "", title = A.text(c, b), + descriptionKey = ownLabel(c, a), titleKey = ownLabel(c, b)} end return out end function M.run(rom, cache, opts) local c = A.context(rom, cache, opts, M.SUB) - local man = {screen = "pokedex", coverage = "native_list_detail_search_backgrounds_and_search_tables", gfx = {}, maps = {}, layers = {}, palettes = {}} + local man = {screen = "pokedex", packVersion = M.packVersion, coverage = "native_list_detail_search_backgrounds_and_search_tables", gfx = {}, maps = {}, layers = {}, palettes = {}} -- pokeruby/src/pokedex.c:1967 local pals = {hoenn = c:palAt(c:off("gPokedexMenu_Pal"), 96), national = c:pal(N .. "sNationalPokedexPalette", 96), searchResults = c:pal(N .. "sPokedexSearchPalette", 96), search = c:pal("gPokedexMenuSearch_Pal", 64)} @@ -54,12 +60,14 @@ function M.run(rom, cache, opts) local top = c:off(N .. "sSearchMenuTopBarItems") for i = 0, c.S.count(N .. "sSearchMenuTopBarItems", 8) - 1 do local o = top + i * 8 - search.topBar[i + 1] = {description = A.text(c, assert(c:ptr(o))), x = c:u8(o + 4), y = c:u8(o + 5), width = c:u8(o + 6)} + search.topBar[i + 1] = {description = A.text(c, assert(c:ptr(o))), descriptionKey = A.label(c, c:ptr(o)), + x = c:u8(o + 4), y = c:u8(o + 5), width = c:u8(o + 6)} end local io = c:off(N .. "sSearchMenuItems") for i = 0, c.S.count(N .. "sSearchMenuItems", 12) - 1 do local o = io + i * 12 - search.items[i + 1] = {description = A.text(c, assert(c:ptr(o))), titleX = c:u8(o + 4), titleY = c:u8(o + 5), titleWidth = c:u8(o + 6), + search.items[i + 1] = {description = A.text(c, assert(c:ptr(o))), descriptionKey = A.label(c, c:ptr(o)), + titleX = c:u8(o + 4), titleY = c:u8(o + 5), titleWidth = c:u8(o + 6), selX = c:u8(o + 7), selY = c:u8(o + 8), selWidth = c:u8(o + 9)} end for _, k in ipairs({"SearchNatDex", "ShiftNatDex", "SearchHoennDex", "ShiftHoennDex"}) do @@ -74,5 +82,9 @@ function M.run(rom, cache, opts) c.files[#c.files + 1] = "orders.lua" return A.finish(c, man) end -function M.ready(cache, root) return A.ready(M.SUB, cache, root) end +function M.ready(cache, root) + if not A.ready(M.SUB, cache, root) then return false end + local body = cache:read((root or "data/generated/gba") .. "/" .. M.SUB .. "/manifest.lua") + return body:find("packVersion = " .. M.packVersion, 1, true) ~= nil +end return M diff --git a/src/ui/game3/rse/pokedex.lua b/src/ui/game3/rse/pokedex.lua index ca8d6294..8a067251 100644 --- a/src/ui/game3/rse/pokedex.lua +++ b/src/ui/game3/rse/pokedex.lua @@ -12,8 +12,32 @@ local RsPolicy = require("src.ui.game3.rs.pokedex_policy") local function nativeRs() return Gfx.manifest().assetLayout == "rs" end local rsTextNames = {gText_CryOf = "CryOf", gText_SizeComparedTo = "SizeComparedTo", gText_SelectorArrow = "RightPointingTriangle", gText_SearchingPleaseWait = "Searching", gText_SearchCompleted = "SearchComplete", gText_NoMatchingPkmnWereFound = "NoMatching"} +local function cached(label, copy) + if label and RomText.has(label) then return RomText.plain(label) end + return copy +end +local RS_LABELS = {RightPointingTriangle = "DexText_RightPointingTriangle"} +local function rsString(key) + local copy = assert(Gfx.manifest().strings[key], "native RS dex text " .. tostring(key)) + local text = cached(RS_LABELS[key] or ("gDexText_" .. key), nil) + if text == nil then return copy end + return (copy:match("^\252\019.") or "") .. text +end +-- pokeruby/src/pokedex.c:4228 +local function categorySuffix(unknown) + local i, marks = 1, 0 + while i <= #unknown do + local c = unknown:sub(i, i) + if c == "?" then marks, i = marks + 1, i + 1 + elseif c == " " then i = i + 1 + elseif unknown:sub(i, i + 2) == "?" then marks, i = marks + 1, i + 3 + else break end + end + if marks == 0 or i > #unknown then return "" end + return (unknown:sub(i - 1, i - 1) == " " and " " or "") .. unknown:sub(i) +end local function dexText(key) - return nativeRs() and assert(Gfx.manifest().strings[assert(rsTextNames[key], "native RS dex text alias")]) or RomText.plain(key) + return nativeRs() and rsString(assert(rsTextNames[key], "native RS dex text alias")) or RomText.plain(key) end local Pokedex = {} @@ -910,20 +934,26 @@ end local function monInfo(s, nat, nationalNumber, owned, newEntry) local e = entryFor(nat) if nativeRs() then - local strings, out = Gfx.manifest().strings, {} + local out = {} if newEntry then - local t = strings.RegisterComplete + local t = rsString("RegisterComplete") out[#out + 1] = {text = t, x = 16 + math.floor((208 - FrlgFont.measure(t)) / 2), y = 0} end local num = nationalNumber and nat or (Pokedex.hoennNumber(nat) or nat) out[#out + 1] = {text = string.format("%03d", num), x = 104, y = 24} out[#out + 1] = {text = pokemon().name(Pokedex.speciesOf(nat)) or Gfx.manifest().tenDashes, x = 128, y = 24} - local category = owned and ((e.category or "") .. " " .. strings.UnknownPoke:match("[^? ]+.*$")) or strings.UnknownPoke - local cx = 88 + (owned and (FrlgFont.measure(strings.UnknownPoke) - FrlgFont.measure(category)) or 0) + local unknown = rsString("UnknownPoke") + local category = owned and (Strings(e.category or "") .. categorySuffix(unknown)) or unknown + local cx = 88 + (owned and (FrlgFont.measure(unknown) - FrlgFont.measure(category)) or 0) out[#out + 1] = {text = category, x = cx, y = 40} - out[#out + 1] = {text = owned and Pokedex.heightText(e.height or 0) or strings.UnknownHeight, x = 128, y = 56} - out[#out + 1] = {text = owned and RsPolicy.weightText(e.weight or 0) or strings.UnknownWeight, x = 128, y = 72} - local desc = s.descriptionPage == 1 and e.description2 or e.description + out[#out + 1] = {text = owned and Pokedex.heightText(e.height or 0) or rsString("UnknownHeight"), x = 128, y = 56} + out[#out + 1] = {text = owned and RsPolicy.weightText(e.weight or 0) or rsString("UnknownWeight"), x = 128, y = 72} + local desc + if s.descriptionPage == 1 then + desc = cached(e.descriptionLabel2, e.description2) + else + desc = cached(e.descriptionLabel, e.description) + end out[#out + 1] = {text = owned and (desc or "") or "", x = 16, y = 104} return out end @@ -1535,7 +1565,8 @@ function tasks.loadSize(s) s.state = 3 elseif st == 3 then local name = s.session and (s.session.name or s.session.playerName) or "" - local t = dexText("gText_SizeComparedTo") .. tostring(name) + local label = dexText("gText_SizeComparedTo") + local t = label:find("^の") and tostring(name) .. label or label .. tostring(name) local w = FrlgFont.measure(t) or 0 s.sizeText = { text = t, x = nativeRs() and (24 + 96 - math.floor(w / 2)) or (w < 240 and math.floor((240 - w) / 2) or 0), y = nativeRs() and 120 or 121 } s.state = 4 @@ -1593,10 +1624,7 @@ function tasks.switchFromSize(s) end end -local function cartText(key, fallback) - if key and RomText.has(key) then return RomText.plain(key) end - return fallback -end +local cartText = cached -- pokeemerald/src/pokedex.c:1330 local TYPE_OPTION = { "gText_DexSearchTypeNone" } @@ -1632,11 +1660,13 @@ local ITEM_DESCRIPTIONS = { "gText_ListByFirstLetter", "gText_ListByBodyColor", "gText_ListByType", "gText_SelectPokedexListingMode", "gText_SelectPokedexMode", "gText_ExecuteSearchSwitch" } local function topBarDescription(i) - return cartText(TOPBAR_DESCRIPTIONS[i + 1], Gfx.manifest().search.topBar[i + 1].description) + local row = Gfx.manifest().search.topBar[i + 1] + return cartText(row.descriptionKey or TOPBAR_DESCRIPTIONS[i + 1], row.description) end local function itemDescription(i) - return cartText(ITEM_DESCRIPTIONS[i + 1], Gfx.manifest().search.items[i + 1].description) + local row = Gfx.manifest().search.items[i + 1] + return cartText(row.descriptionKey or ITEM_DESCRIPTIONS[i + 1], row.description) end -- pokeemerald/src/pokedex.c:1437 @@ -1657,8 +1687,8 @@ local function searchOptionTexts(which) local out = {} for i, t in ipairs(list) do out[i] = { - title = cartText(keys.titles[i], t.title), - description = cartText(keys.descriptions and keys.descriptions[i], t.description), + title = cartText(t.titleKey or keys.titles[i], t.title), + description = cartText(t.descriptionKey or (keys.descriptions and keys.descriptions[i]), t.description), } end return out diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua index 217ee6e3..7cc2bd02 100644 --- a/tests/engine/game3_rs_translation_runtime_test.lua +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -60,6 +60,80 @@ BUNDLE.text = { gExpandedPlaceholder_Archie = ir("ARTHUR"), gExpandedPlaceholder local sapphireLine = Message.cartPlaceholders({ EVIL_LEADER = "ARCHIE", byGender = {} }, "sapphire") check(sapphireLine.EVIL_LEADER == "ARTHUR", "Sapphire's evil leader is Archie's row") +-- The native Pokédex (src/ui/game3/rse/pokedex.lua): the entry's pages by +-- their own labels, its labels and the search screen by the labels the +-- packs name next to their copies. +local MANIFEST = { + assetLayout = "rs", + strings = { UnknownPoke = " ????? POKéMON", CryOf = "\252\019\002CRY OF", RegisterComplete = "REGISTERED", + UnknownHeight = "\252\019\012??'??\"", UnknownWeight = "????.? lbs.", SizeComparedTo = "SIZE COMPARED TO ", + Searching = "Searching...", SearchComplete = "Search completed.", NoMatching = "No matching POKéMON.", + RightPointingTriangle = ">" }, + search = { + topBar = { { description = "Search for POKéMON.", descriptionKey = "DexText_SearchForPoke" } }, + items = { { description = "List by the first letter.", descriptionKey = "DexText_ListByABC" } }, + names = { { title = "ABC", titleKey = "DexText_ABC", description = "" } }, + colors = {}, types = {}, orders = {}, modes = {}, + }, +} +package.loaded["src.ui.game3.rse.pokedex_gfx"] = setmetatable({ manifest = function() return MANIFEST end }, + { __index = function() return function() end end }) +package.loaded["src.ui.game3.rse.mapsec"] = setmetatable({ readLua = function(path) + if path:find("entries", 1, true) then + return { [277] = { category = "WOOD GECKO", height = 5, weight = 50, description = "Ruby page one.", + description2 = "Ruby page two.", descriptionLabel = "DexDescription_Treecko_1", + descriptionLabel2 = "DexDescription_Treecko_2" } } + end + return { nationalToRegional = {} } +end }, { __index = function() return function() end end }) +package.loaded["src.core.game3.pokemon"] = { speciesFromNational = function() return 277 end, + name = function() return "ARCKO" end } +local Pokedex = require("src.ui.game3.rse.pokedex") +BUNDLE.text = { + DexDescription_Treecko_2 = ir("Page deux."), gDexText_UnknownPoke = ir(" ????? POKéMON"), + gDexText_CryOf = ir("CRI DE"), DexText_SearchForPoke = ir("Chercher des POKéMON."), DexText_ABC = ir("ABC FR"), +} +local function texts(rows) + local out = {} + for _, row in ipairs(rows) do out[#out + 1] = row.text end + return table.concat(out, "|") +end +local page1 = texts(Pokedex.monInfo({ descriptionPage = 0 }, 252, true, true, false)) +local page2 = texts(Pokedex.monInfo({ descriptionPage = 1 }, 252, true, true, false)) +check(page1:find("Ruby page one.", 1, true) ~= nil, "a page missing from the cache keeps the pack's copy") +check(page2:find("Page deux.", 1, true) ~= nil, "the second page reads the cache by its own label") +check(page1:find("WOOD GECKO POKéMON", 1, true) ~= nil, "the category keeps the cart's POKéMON after it") +check(Pokedex.topBarDescription(0) == "Chercher des POKéMON.", "the search top bar reads its own label") +check(Pokedex.itemDescription(0) == "List by the first letter.", "a search line missing from the cache keeps the copy") +check(Pokedex.searchOptionTexts(Pokedex.SEARCH.NAME)[1].title == "ABC FR", "a search option reads its own label") +-- pokeruby/src/pokedex.c:4228: only the English cart follows the category +-- with the word after its unknown-category string's question marks. +local function infoRow(owned, y) + for _, row in ipairs(Pokedex.monInfo({ descriptionPage = 0 }, 252, true, owned, false)) do + if row.y == y then return row.text end + end +end +local function category(unknown) + BUNDLE.text.gDexText_UnknownPoke = ir(unknown) + return infoRow(true, 40) +end +check(category(" ????? POKéMON") == "WOOD GECKO POKéMON", "the English cart follows the category with POKéMON") +check(category("?????") == "WOOD GECKO", "the French and German carts print the category alone") +check(category("POKéMON ?????") == "WOOD GECKO", "so does the Italian cart, whose word comes first") +check(category("?????ポケモン") == "WOOD GECKOポケモン", "the Japanese word follows the category without a space") +BUNDLE.text.gDexText_UnknownHeight = ir("???,? m") +check(infoRow(false, 56) == "\252\019\012???,? m", "a label keeps its copy's CLEAR_TO in front of the cache's text") +local Registry = require("src.import.gba.layouts.registry") +local active = Registry.active +Registry.active = function() return { id = "rs" } end +local Entries = require("src.import.gba.pokedex_entries_extract") +local function entriesCache(body) return { exists = function() return true end, read = function() return body end } end +check(not Entries.ready(entriesCache("return { [277] = { description = \"x\" } }")), + "an R/S cache without the description labels extracts its entries again") +check(Entries.ready(entriesCache("return { [277] = { descriptionLabel = \"DexDescription_Treecko_1\" } }")), + "an R/S cache with the labels keeps its entries") +Registry.active = active + if failed > 0 then print(failed .. " check(s) failed") os.exit(1) From 31351ae2e9a45f0af3152e6cc0acc58da063ddc5 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:29:15 +0200 Subject: [PATCH 3/9] Read Ruby and Sapphire's native screens' cart text from the script cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ruby/Sapphire shop, decoration menus, move relearner and contest paintings printed the cart strings their extractors copied into their packs (textBytes, keyed by the strings' pokeruby labels), so a translation never reached them. The script cache holds the same strings under the same labels, where a mod's text overrides land: these screens now read it there, with the pack's copy as the fallback, the way Emerald's screens do (RomText.irOr). The shop, the decoration menus and the move relearner keep each text they resolve until the screen opens again, the painting its caption once it is built. A hall painting's caption names the trainer, then the POKéMON, as the US cart does; the European carts name the POKéMON first, their possessive row starting with a space (" von ", " de ", " di ", pokeruby/src/contest_painting.c:234), and so does the caption now when the row does. --- src/ui/game3/rs/contest_painting.lua | 18 ++++-- src/ui/game3/rs/decoration.lua | 8 +++ src/ui/game3/rs/move_relearner.lua | 15 ++++- src/ui/game3/rs/shop_menu.lua | 8 ++- .../game3_rs_translation_runtime_test.lua | 59 +++++++++++++++++++ 5 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/ui/game3/rs/contest_painting.lua b/src/ui/game3/rs/contest_painting.lua index c0be9d62..34618ce9 100644 --- a/src/ui/game3/rs/contest_painting.lua +++ b/src/ui/game3/rs/contest_painting.lua @@ -1,5 +1,6 @@ local Base = require("src.ui.game3.rse.contest_painting") local IR = require("src.core.game3.scripting.text_ir") +local RomText = require("src.core.game3.rom_text") local Kit = require("src.ui.game3.rse.scene_kit") local Font = require("src.ui.game3.frlg_font") local Painting = {ID = Base.ID, SUB = Base.SUB} @@ -11,9 +12,9 @@ local function manifest() return man end -local function render(bytes) +local function render(ir) local out = {} - for _, seg in ipairs(IR.decode(bytes, {dialect = "rs"})) do + for _, seg in ipairs(ir) do if seg.t == "ext" then out[#out + 1] = string.char(252, seg.cmd) for _, arg in ipairs(seg.args or {}) do out[#out + 1] = string.char(arg) end @@ -21,7 +22,10 @@ local function render(bytes) end return table.concat(out) end -local function text(man, key) return render(assert(man.textBytes[key], "native painting caption missing: " .. key)) end +local function text(man, key) + local bytes = assert(man.textBytes[key], "native painting caption missing: " .. key) + return render(RomText.irOr(key, IR.decode(bytes, {dialect = "rs"}))) +end local function name(value, limit) if type(value) == "table" then local bytes = {} @@ -30,7 +34,7 @@ local function name(value, limit) bytes[#bytes + 1] = value[i] end bytes[#bytes + 1] = 255 - return render(bytes) + return render(IR.decode(bytes, {dialect = "rs"})) end if type(value) ~= "string" then return "" end if not limit then return value end @@ -48,9 +52,11 @@ function Painting.caption(saveIdx, winner, artist, man) local nickname = name(winner.monName or winner.nickname, man.captionLayout.nicknameBytes) local category = tonumber(winner.contestCategory) or 0 if saveIdx < man.captionLayout.museumStart then + -- pokeruby/src/contest_painting.c:234 + local possessive, first, second = text(man, man.hallPossessive), name(winner.trainerName), nickname + if possessive:find("^[ ,]") then first, second = nickname, name(winner.trainerName) end return text(man, assert(man.rankNames[category], "native painting category")) .. text(man, man.hallCaption) - .. name(winner.trainerName) .. string.char(unpack(man.captionLayout.hallLatinControl)) - .. text(man, man.hallPossessive) .. nickname + .. first .. string.char(unpack(man.captionLayout.hallLatinControl)) .. possessive .. second end local pair = assert(man.captionParts[category], "native museum caption pair") return text(man, pair.prefix) .. nickname .. text(man, pair.suffix) diff --git a/src/ui/game3/rs/decoration.lua b/src/ui/game3/rs/decoration.lua index ebcb86dd..4407424d 100644 --- a/src/ui/game3/rs/decoration.lua +++ b/src/ui/game3/rs/decoration.lua @@ -27,9 +27,16 @@ local aliases = { UI.TEXT_ALIASES = {gText_ApostropheSBase = "gOtherText_PlayersBase"} local cats = {gText_Desk = 0, gText_Chair = 1, gText_Plant = 2, gText_Ornament = 3, gText_Mat = 4, gText_Poster = 5, gText_Doll = 6, gText_Cushion = 7} +local resolved = {} local function nativeText(key, vars) if cats[key] ~= nil then return Inv.categoryName(cats[key]) end local native, m = aliases[key] or key, Decor.manifest() + if not vars and resolved[native] then return resolved[native] end + if Text.has(native) then + local value = Text.plain(native, vars and {stringVars = vars} or nil) + if not vars then resolved[native] = value end + return value + end if m.textBytes and m.textBytes[native] then return IR.toPlain(IR.decode(m.textBytes[native], {dialect = "rs"}), {stringVars = vars or {}}) end @@ -116,6 +123,7 @@ function UI.draw(d, host) end local function options(opts) opts = opts or {} + resolved = {} opts.text, opts.draw = nativeText, UI.draw opts.recordSecretBaseVisit = false opts.avatarGfx = function(s, female) diff --git a/src/ui/game3/rs/move_relearner.lua b/src/ui/game3/rs/move_relearner.lua index 174b7bab..5abc762c 100644 --- a/src/ui/game3/rs/move_relearner.lua +++ b/src/ui/game3/rs/move_relearner.lua @@ -3,6 +3,7 @@ local Kit = require("src.ui.game3.rse.scene_kit") local Font = require("src.ui.game3.frlg_font") local Pokemon = require("src.core.game3.pokemon") local IR = require("src.core.game3.scripting.text_ir") +local RomText = require("src.core.game3.rom_text") local bit = require("bit") local UI = {} local keys = {gText_TeachWhichMoveToPkmn = "gOtherText_TeachWhichMove", @@ -13,13 +14,20 @@ local function manifest() assert(m.assetLayout == "rs", "native RS move tutor pack required") return m end +local resolved = {} +local function cartIr(native) + if not resolved[native] then + resolved[native] = RomText.irOr(native, + IR.decode(assert(manifest().textBytes[native], "native tutor text " .. native), {dialect = "rs"})) + end + return resolved[native] +end local function text(key, ctx) - local m, native = manifest(), keys[key] or key - return IR.toPlain(IR.decode(assert(m.textBytes[native], "native tutor text " .. native), {dialect = "rs"}), ctx or {}) + return IR.toPlain(cartIr(keys[key] or key), ctx or {}) end local function pages(key, vars) local out = {} - local value = IR.toAscii(IR.decode(assert(manifest().textBytes[key]), {dialect = "rs"}), {stringVars = vars}) + local value = IR.toAscii(cartIr(key), {stringVars = vars}) for page in (value .. "\\p"):gmatch("(.-)\\p") do if page ~= "" then out[#out + 1] = page end end return out end @@ -164,6 +172,7 @@ function UI.draw(st) end function UI.show(mon, opts) manifest(); opts = opts or {} + resolved = {} opts.maxShown, opts.text, opts.draw, opts.startLearn = 3, text, UI.draw, startLearn return Base.show(mon, opts) end diff --git a/src/ui/game3/rs/shop_menu.lua b/src/ui/game3/rs/shop_menu.lua index 55e59b8c..5c7e8877 100644 --- a/src/ui/game3/rs/shop_menu.lua +++ b/src/ui/game3/rs/shop_menu.lua @@ -5,6 +5,7 @@ local Chrome = require("src.ui.game3.chrome") local Cursor = require("src.ui.game3.rs.menu_cursor") local Window = require("src.ui.game3.window") local TextIR = require("src.core.game3.scripting.text_ir") +local RomText = require("src.core.game3.rom_text") local Items = require("src.core.game3.items_data") local UI = {CAMERA_OFFSET = {x = -3, y = -3}} UI.WIN = {money = Window.template(1, 1, 12, 2), qty = Window.template(1, 11, 12, 2), @@ -15,10 +16,14 @@ local function manifest() assert(man.assetLayout == "rs" and man.shopVersion == 2, "native RS shop schema required") return man end +local resolved = {} function UI.ir(key) + if resolved[key] then return resolved[key] end local man = manifest() local native = assert(man.textAliases[key] or (man.textBytes[key] and key), "native RS shop text policy missing: " .. key) - return TextIR.decode(assert(man.textBytes[native], "native RS shop text bytes missing: " .. native), {dialect = "rs"}) + resolved[key] = RomText.irOr(native, + TextIR.decode(assert(man.textBytes[native], "native RS shop text bytes missing: " .. native), {dialect = "rs"})) + return resolved[key] end function UI.plain(key, ctx) return TextIR.toPlain(UI.ir(key), ctx or {}) end function UI.box(key, ctx) return UI.plain(key, ctx) end @@ -176,6 +181,7 @@ function UI.drawSell(sale) end function UI.show(shop, opts) opts = opts or {}; opts.nativePolicy = UI + resolved = {} manifest() shop._rsShopFrame = 0 return Base.show(shop, opts) diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua index 7cc2bd02..c9a08427 100644 --- a/tests/engine/game3_rs_translation_runtime_test.lua +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -134,6 +134,65 @@ check(Entries.ready(entriesCache("return { [277] = { descriptionLabel = \"DexDes "an R/S cache with the labels keeps its entries") Registry.active = active +-- Native screens that keep a cart string in their pack under its pret label +-- read the script cache's text under that label first. +local COPY = { 0xBD, 0xC9, 0xCA, 0xD3, 0xFF } -- "COPY" +local Kit = require("src.ui.game3.rse.scene_kit") +local MANIFESTS = { + ["items/shop"] = { assetLayout = "rs", shopVersion = 2, textAliases = { gText_HowMayIServeYou = "gOtherText_HowMayIServe" }, + textBytes = { gOtherText_HowMayIServe = COPY, gOtherText_Unnamed = COPY } }, +} +Kit.manifest = function(sub) + if MANIFESTS[sub] then return MANIFESTS[sub] end + return { assetLayout = "rs", textBytes = { gOtherText_TeachWhichMove = COPY, gOtherText_Unnamed = COPY } } +end +local Shop = require("src.ui.game3.rs.shop_menu") +BUNDLE.text = { gOtherText_HowMayIServe = ir("Que puis-je faire pour vous ?"), + gOtherText_TeachWhichMove = ir("Quelle capacité apprendre ?") } +check(Shop.plain("gText_HowMayIServeYou") == "Que puis-je faire pour vous ?", "the shop reads the cache by its label") +check(Shop.plain("gOtherText_Unnamed") == "COPY", "a shop line missing from the cache keeps the pack's copy") + +-- The move relearner, the decoration menus and the museum's painting +-- captions, through the text function each screen hands its shared base. +package.loaded["src.ui.game3.rse.move_relearner"] = { SUB = "tutor", show = function(_, opts) return opts end } +local tutorText = require("src.ui.game3.rs.move_relearner").show({}, {}).text +check(tutorText("gText_TeachWhichMoveToPkmn") == "Quelle capacité apprendre ?", "the move relearner reads the cache by its label") +check(tutorText("gOtherText_Unnamed") == "COPY", "a move relearner line missing from the cache keeps the pack's copy") +BUNDLE.text.gOtherText_TeachWhichMove = ir("Autre texte") +check(tutorText("gText_TeachWhichMoveToPkmn") == "Quelle capacité apprendre ?", "a screen resolves its text once while it is open") +tutorText = require("src.ui.game3.rs.move_relearner").show({}, {}).text +check(tutorText("gText_TeachWhichMoveToPkmn") == "Autre texte", "and again when it opens") + +package.loaded["src.ui.game3.rse.decoration"] = { open = function(opts) return opts end } +package.loaded["src.core.game3.rse.decoration"] = { manifest = function() + return { textBytes = { gSecretBaseText_DecorReturned = COPY, gSecretBaseText_NoDecor = COPY }, strings = {} } +end } +package.loaded["src.core.game3.rse.decoration_inventory"] = { categoryName = function() return "" end } +local decorText = require("src.ui.game3.rs.decoration").open().text +BUNDLE.text = { gSecretBaseText_DecorReturned = ir("La décoration est retournée au PC.") } +check(decorText("gText_DecorationReturnedToPC") == "La décoration est retournée au PC.", "the decoration menus read the cache by their labels") +check(decorText("gText_NoDecorationHere") == "COPY", "a decoration line missing from the cache keeps the pack's copy") + +package.loaded["src.ui.game3.rse.contest_painting"] = { ID = "contest_painting", SUB = "contest_painting" } +local Painting = require("src.ui.game3.rs.contest_painting") +local PAINT = { captionLayout = { museumStart = 5, nicknameBytes = 10 }, + captionParts = { [0] = { prefix = "gContestPaintingCool1", suffix = "gContestPaintingCool2" } }, + textBytes = { gContestPaintingCool1 = COPY, gContestPaintingCool2 = COPY } } +BUNDLE.text = { gContestPaintingCool1 = ir("Le POKéMON ") } +check(Painting.caption(5, { nickname = "ZIGZATON", contestCategory = 0 }, nil, PAINT) == "Le POKéMON ZIGZATONCOPY", + "a painting's caption reads the cache by its labels, the pack's copy where the cache has none") +-- pokeruby/src/contest_painting.c:234: the European carts name the POKéMON +-- first ("ZIGZATON von MAY"), the US and Japanese ones the trainer. +PAINT.rankNames = { [0] = "gContestRankNormal" } +PAINT.hallCaption, PAINT.hallPossessive = "gContestText_ContestWinner", "gOtherText_Unknown1" +PAINT.captionLayout.hallLatinControl = {} +PAINT.textBytes.gContestRankNormal, PAINT.textBytes.gContestText_ContestWinner, PAINT.textBytes.gOtherText_Unknown1 = COPY, COPY, COPY +local winner = { nickname = "ZIGZATON", trainerName = "MAY", contestCategory = 0 } +BUNDLE.text = { gContestRankNormal = ir("NORMAL "), gContestText_ContestWinner = ir("WINNER "), gOtherText_Unknown1 = ir("'s ") } +check(Painting.caption(0, winner, nil, PAINT) == "NORMAL WINNER MAY's ZIGZATON", "the US hall caption names the trainer first") +BUNDLE.text.gOtherText_Unknown1 = ir(" von ") +check(Painting.caption(0, winner, nil, PAINT) == "NORMAL WINNER ZIGZATON von MAY", "a European hall caption names the POKéMON first") + if failed > 0 then print(failed .. " check(s) failed") os.exit(1) From 2700e0b0733c464cb22601f3e2c10f6acbee782e Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:29:15 +0200 Subject: [PATCH 4/9] Print Ruby and Sapphire's Easy Chat words translated The Ruby/Sapphire Easy Chat editor, mail composer and reader, Dewford trendy phrase editor and trainer card printed each word's cart text (EasyChatText.rawWord) and the pickers each group's and word's raw name, and the TV and the Dewford line naming the new trend got the raw words too, so a translation mod's Easy Chat vocabulary never showed there. They now print EasyChatText.word and groupName, as the other game3 screens do, each screen keeping a word it resolves until it opens again; the comparisons the scripts rely on (changed phrases, the Mystery Event words, the trend's comparison) keep the raw text. Gabby and Ty's last quote is printed from the cart's bytes, so a translated word the cart's charset cannot hold (a Japanese one on a US cart) keeps the cart's word there instead of printing "????". --- src/core/game3/rs/easy_chat_contracts.lua | 2 +- src/core/game3/rs/tv_playback.lua | 2 +- .../game3/scripting/natives_rs_tv_routes.lua | 7 +++- src/ui/game3/rs/easy_chat_editor.lua | 16 ++++++-- src/ui/game3/rs/mail_composer.lua | 16 ++++++-- src/ui/game3/rs/mail_reader.lua | 2 +- src/ui/game3/rs/trainer_card.lua | 11 +++++- src/ui/game3/rs/trendy_phrase.lua | 16 ++++++-- .../game3_rs_translation_runtime_test.lua | 37 +++++++++++++++++++ 9 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/core/game3/rs/easy_chat_contracts.lua b/src/core/game3/rs/easy_chat_contracts.lua index f2e75383..8b238795 100644 --- a/src/core/game3/rs/easy_chat_contracts.lua +++ b/src/core/game3/rs/easy_chat_contracts.lua @@ -231,7 +231,7 @@ function M.commit(token, words) elseif token.type == 6 then writeBard(token, words) elseif token.type == 10 then token.session.gabbyAndTyData.quote[0] = words[1] elseif token.type == 9 then - out.stringVar2 = Easy.rawWord(words[1]) .. " " .. Easy.rawWord(words[2]) + out.stringVar2 = Easy.word(words[1]) .. " " .. Easy.word(words[2]) out.input = require("src.core.game3.rs.dewford_trend").trySetTrendyPhrase(words, token.session) and 1 or 0 elseif token.type ~= 13 then writeTv(token, words) end if token.type == 0 then out.input = M.passphrase(words) and 1 or 0 end diff --git a/src/core/game3/rs/tv_playback.lua b/src/core/game3/rs/tv_playback.lua index 081e6517..9dfef818 100644 --- a/src/core/game3/rs/tv_playback.lua +++ b/src/core/game3/rs/tv_playback.lua @@ -118,7 +118,7 @@ function M.word(id) local E = require("src.core.game3.easy_chat_text") local group = E.group(math.floor(value / 512) % 128) if group then - for _, word in ipairs(group.words) do if word.id == value then return word.text end end + for _, word in ipairs(group.words) do if word.id == value then return E.word(value) end end end return romText("gOtherText_ThreeQuestions") end, id) diff --git a/src/core/game3/scripting/natives_rs_tv_routes.lua b/src/core/game3/scripting/natives_rs_tv_routes.lua index 196f245b..693eedfa 100644 --- a/src/core/game3/scripting/natives_rs_tv_routes.lua +++ b/src/core/game3/scripting/natives_rs_tv_routes.lua @@ -79,7 +79,12 @@ M.BY_NAME.GabbyAndTyGetLastQuote = function(ctx, adapters) local session, g = R.session(), gabby() local quote = n(g.quote[0]) % 65536 if quote == 65535 then return false, 0 end - stringVar(ctx, adapters, 1, session, Q.encode(session, Playback.word(quote))) + local word = Playback.word(quote) + local bytes = Q.encode(session, word) + if Playback.displayBytes(session, bytes) ~= word then + bytes = Q.encode(session, require("src.core.game3.easy_chat_text").rawWord(quote)) + end + stringVar(ctx, adapters, 1, session, bytes) g.quote[0] = 65535 return false, 1 end diff --git a/src/ui/game3/rs/easy_chat_editor.lua b/src/ui/game3/rs/easy_chat_editor.lua index 0e59546a..be8014e4 100644 --- a/src/ui/game3/rs/easy_chat_editor.lua +++ b/src/ui/game3/rs/easy_chat_editor.lua @@ -6,6 +6,15 @@ local Cursor = require("src.ui.game3.rs.menu_cursor") local Pal = require("src.core.game3.pal_fade") local Fx = require("src.core.game3.gba_fx") local Easy = require("src.core.game3.easy_chat_text") +local words, groups = {}, {} +local function wordText(id) + if words[id] == nil then words[id] = Easy.word(id) end + return words[id] +end +local function groupText(group) + if groups[group] == nil then groups[group] = Easy.groupName(group) end + return groups[group] +end local Policy = require("src.ui.game3.rs.easy_chat_editor_policy") local Frames = require("src.ui.game3.rs.easy_chat_editor_frames") local C = {isMenu = true, open = false} @@ -33,6 +42,7 @@ function C.manifest(kind) end function C.show(opts) opts = opts or {} + words, groups = {}, {} assert(opts.token and opts.type ~= 9, "native RS editor requires its save contract; type9 uses its separate host") C._man, C._session, C._opts = C.manifest(opts.type), assert(opts.session), opts C._before, C.words = copyWords(opts.words), copyWords(opts.words) @@ -347,7 +357,7 @@ local function phraseWords() local line = {} for col = 0, C._man.columns - 1 do local i = row * C._man.columns + col + 1 - if i <= C._man.wordCount and C.words[i] ~= Policy.EMPTY then line[#line + 1] = Easy.rawWord(C.words[i]) .. " " end + if i <= C._man.wordCount and C.words[i] ~= Policy.EMPTY then line[#line + 1] = wordText(C.words[i]) .. " " end end local pen = C._man.wordPens[row * C._man.columns + 1] if pen then text(table.concat(line), pen.x, pen.y, "phrase") end @@ -360,7 +370,7 @@ local function phraseWords() local image = assert(Kit.image(C._man.blankWord)) love.graphics.setColor(1, 1, 1, 1) Fx.draw(function() love.graphics.draw(image, x, y) end, C._pal:fx(0)) - else text(Easy.rawWord(C.words[i]), x, y, "phrase") end + else text(wordText(C.words[i]), x, y, "phrase") end end end local function pickerText() @@ -382,7 +392,7 @@ local function pickerText() local word = (view == "groups" and C.groups or C._list)[row * 2 + col + 1] if word then local x = (view == "groups" and 16 or 48) + col * 88 - text(view == "groups" and word.name or word.text, x, 88 + (row - state.top) * 16 + offset, "picker") + text(view == "groups" and groupText(word) or wordText(word.id), x, 88 + (row - state.top) * 16 + offset, "picker") end end end diff --git a/src/ui/game3/rs/mail_composer.lua b/src/ui/game3/rs/mail_composer.lua index bac46f43..050ded2d 100644 --- a/src/ui/game3/rs/mail_composer.lua +++ b/src/ui/game3/rs/mail_composer.lua @@ -6,6 +6,15 @@ local Cursor = require("src.ui.game3.rs.menu_cursor") local Pal = require("src.core.game3.pal_fade") local Fx = require("src.core.game3.gba_fx") local Easy = require("src.core.game3.easy_chat_text") +local words, groups = {}, {} +local function wordText(id) + if words[id] == nil then words[id] = Easy.word(id) end + return words[id] +end +local function groupText(group) + if groups[group] == nil then groups[group] = Easy.groupName(group) end + return groups[group] +end local Policy = require("src.ui.game3.rs.mail_composer_policy") local Frames = require("src.ui.game3.rs.mail_composer_frames") local C = {isMenu = true, open = false} @@ -23,6 +32,7 @@ function C.manifest() end function C.write(record, opts) opts = opts or {} + words, groups = {}, {} C._man, C._record, C._session, C._opts = C.manifest(), assert(record), assert(opts.session), opts C._before, C.words = copyWords(record.words), copyWords(record.words) C.groups = Policy.groups(C._session, opts.gates) @@ -259,7 +269,7 @@ local function phraseWords() local parts = {} for col = 0, 1 do local i = row * 2 + col + 1 - if i <= 9 and C.words[i] ~= Policy.EMPTY then parts[#parts + 1] = Easy.rawWord(C.words[i]) .. " " end + if i <= 9 and C.words[i] ~= Policy.EMPTY then parts[#parts + 1] = wordText(C.words[i]) .. " " end end text(table.concat(parts), 40, row * 16, "phrase") end @@ -271,7 +281,7 @@ local function phraseWords() local image = assert(Kit.image(C._man.blankWord)) love.graphics.setColor(1, 1, 1, 1) Fx.draw(function() love.graphics.draw(image, x, y) end, C._pal:fx(0)) - else text(Easy.rawWord(C.words[i]), x, y, "phrase") end + else text(wordText(C.words[i]), x, y, "phrase") end end end local function pickerText() @@ -293,7 +303,7 @@ local function pickerText() local word = (view == "groups" and C.groups or C._list)[row * 2 + col + 1] if word then local x = (view == "groups" and 16 or 48) + col * 88 - text(view == "groups" and word.name or word.text, x, 88 + (row - state.top) * 16 + offset, "picker") + text(view == "groups" and groupText(word) or wordText(word.id), x, 88 + (row - state.top) * 16 + offset, "picker") end end end diff --git a/src/ui/game3/rs/mail_reader.lua b/src/ui/game3/rs/mail_reader.lua index b587e5f2..5da71047 100644 --- a/src/ui/game3/rs/mail_reader.lua +++ b/src/ui/game3/rs/mail_reader.lua @@ -43,7 +43,7 @@ function Reader.content(mail, man) local parts = {} for i = 1, line.words do local id = tonumber(mail.words and mail.words[word]) or 65535 - parts[#parts + 1] = Easy.rawWord(id) + parts[#parts + 1] = Easy.word(id) if i < line.words and id ~= 65535 then parts[#parts + 1] = " " end word = word + 1 end diff --git a/src/ui/game3/rs/trainer_card.lua b/src/ui/game3/rs/trainer_card.lua index 6640066b..caadbd55 100644 --- a/src/ui/game3/rs/trainer_card.lua +++ b/src/ui/game3/rs/trainer_card.lua @@ -18,6 +18,12 @@ local function copiedCard(card) out.stars = math.max(0, math.min(4, math.floor(tonumber(out.stars) or 0))) return out end +local function profilePhrase(card) + local Easy, words = require("src.core.game3.easy_chat_text"), card.easyChatProfile or {} + local out = {} + for row = 0, 1 do out[row + 1] = Easy.word(words[row * 2 + 1] or 65535) .. " " .. Easy.word(words[row * 2 + 2] or 65535) end + return out +end function T.show(opts) opts = opts or {} local rt = package.loaded["src.core.game3.runtime"] @@ -25,6 +31,7 @@ function T.show(opts) T._clockSession = opts.clockSession or (rt and rt.getSession and rt.getSession()) or T._session T._link = opts.linkCard == true or (T._session.dex == nil and T._session.stars ~= nil) T._card = T._link and copiedCard(T._session) or Policy.generate(T._session) + T._phrase = T._link and profilePhrase(T._card) or nil T._man, T._onClose = T.manifest(), opts.onClose T.open, T.side, T._phase, T._setup = true, "front", "setup", 0 T._pal, T._stepper = Pal.new(), Kit.stepper() @@ -143,8 +150,8 @@ function T.texts(card, side, colon) local time = T._link and c or T._session put("time", tostring(tonumber(time.playTimeHours) or 0) .. (colon and " : " or " ") .. string.format("%02d", tonumber(time.playTimeMinutes) or 0), 80, 96, "values", false, 48) if T._link then - local Easy, words = require("src.core.game3.easy_chat_text"), c.easyChatProfile or {} - for row = 0, 1 do put("phrase" .. row, Easy.rawWord(words[row * 2 + 1] or 65535) .. " " .. Easy.rawWord(words[row * 2 + 2] or 65535), 16, 112 + row * 16) end + local phrase = T._phrase or profilePhrase(c) + for row = 0, 1 do put("phrase" .. row, phrase[row + 1], 16, 112 + row * 16) end end else put("name", c.name .. s.nameSuffix, 224, 16, "values", true) diff --git a/src/ui/game3/rs/trendy_phrase.lua b/src/ui/game3/rs/trendy_phrase.lua index 5e3407e7..37ee7b6c 100644 --- a/src/ui/game3/rs/trendy_phrase.lua +++ b/src/ui/game3/rs/trendy_phrase.lua @@ -6,6 +6,15 @@ local Cursor = require("src.ui.game3.rs.menu_cursor") local Pal = require("src.core.game3.pal_fade") local Fx = require("src.core.game3.gba_fx") local Easy = require("src.core.game3.easy_chat_text") +local words, groups = {}, {} +local function wordText(id) + if words[id] == nil then words[id] = Easy.word(id) end + return words[id] +end +local function groupText(group) + if groups[group] == nil then groups[group] = Easy.groupName(group) end + return groups[group] +end local Policy = require("src.ui.game3.rs.trendy_phrase_policy") local Frames = require("src.ui.game3.rs.trendy_phrase_frames") local C = {isMenu = true, open = false} @@ -27,6 +36,7 @@ function C.manifest() end function C.show(opts) opts = opts or {} + words, groups = {}, {} C._man, C._session, C._opts = C.manifest(), assert(opts.session), opts C._before, C.words = copyWords(opts.words), copyWords(opts.words) C.groups = Policy.groups(C._session, opts.gates) @@ -264,7 +274,7 @@ local function triangle(x, y) end local function phraseWords() if C._confirm and C._confirm.kind == "save" then - text(Easy.rawWord(C.words[1]) .. " " .. Easy.rawWord(C.words[2]) .. " ", 48, 24, "phrase") + text(wordText(C.words[1]) .. " " .. wordText(C.words[2]) .. " ", 48, 24, "phrase") return end for i = 1, 2 do @@ -273,7 +283,7 @@ local function phraseWords() local image = assert(Kit.image(C._man.blankWord)) love.graphics.setColor(1, 1, 1, 1) Fx.draw(function() love.graphics.draw(image, x, y) end, C._pal:fx(0)) - else text(Easy.rawWord(C.words[i]), x, y, "phrase") end + else text(wordText(C.words[i]), x, y, "phrase") end end end local function pickerText() @@ -295,7 +305,7 @@ local function pickerText() local word = (view == "groups" and C.groups or C._list)[row * 2 + col + 1] if word then local x = (view == "groups" and 16 or 48) + col * 88 - text(view == "groups" and word.name or word.text, x, 88 + (row - state.top) * 16 + offset, "picker") + text(view == "groups" and groupText(word) or wordText(word.id), x, 88 + (row - state.top) * 16 + offset, "picker") end end end diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua index c9a08427..94e7bcd5 100644 --- a/tests/engine/game3_rs_translation_runtime_test.lua +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -193,6 +193,43 @@ check(Painting.caption(0, winner, nil, PAINT) == "NORMAL WINNER MAY's ZIGZATON", BUNDLE.text.gOtherText_Unknown1 = ir(" von ") check(Painting.caption(0, winner, nil, PAINT) == "NORMAL WINNER ZIGZATON von MAY", "a European hall caption names the POKéMON first") +-- Easy Chat: what the player reads prints each word translated +-- (EasyChatText.word); what the scripts compare keeps the cart's word. +package.loaded["src.core.game3.easy_chat_text"] = { + -- 1513 is 513's cart word under another id, translated differently + rawWord = function(id) return "RAW" .. id % 1000 end, word = function(id) return "MOT" .. id end, + group = function() return { words = { { id = 513, text = "RAW513" } } } end, +} +package.loaded["src.core.game3.rs.dewford_trend"] = { editorValid = function() return true end, + trySetTrendyPhrase = function() return true end } +local Contracts = require("src.core.game3.rs.easy_chat_contracts") +local trend = Contracts.commit({ type = 9, wordCount = 2, before = { 0xFFFF, 0xFFFF }, session = {} }, { 513, 514 }) +check(trend.stringVar2 == "MOT513 MOT514", "the trendy phrase the script prints is translated") +check(trend.result == 1, "a new trend counts as a change") +local same = Contracts.commit({ type = 9, wordCount = 2, before = { 1513, 514 }, session = {} }, { 513, 514 }) +check(same.result == 0, "the change is measured on the cart's words, not their translation") +check(require("src.core.game3.rs.tv_playback").word(513) == "MOT513", "the TV prints a word translated") +-- Gabby and Ty's last quote is printed from the cart's bytes: a translated +-- word the cart's charset cannot hold keeps the cart's word. +local Routes = require("src.core.game3.scripting.natives_rs_tv_routes") +local Rse, Tv = require("src.core.game3.rse.init"), require("src.core.game3.rse.tv") +local session, gabbyData = { version = "ruby" }, { quote = {} } +local rseSession, tvState = Rse.session, Tv.state +Rse.session = function() return session end +Tv.state = function() return { gabbyAndTyData = gabbyData } end +local function lastQuote() + gabbyData.quote[0] = 513 + local ctx = {} + Routes.BY_NAME.GabbyAndTyGetLastQuote(ctx, nil) + return ctx.stringVars[1] +end +check(lastQuote() == "MOT513", "Gabby and Ty quote a word translated") +local EASY = package.loaded["src.core.game3.easy_chat_text"] +local word = EASY.word +EASY.word = function() return "ともだち" end +check(lastQuote() == "RAW513", "a word the cart cannot print keeps the cart's word") +EASY.word, Rse.session, Tv.state = word, rseSession, tvState + if failed > 0 then print(failed .. " check(s) failed") os.exit(1) From 6a5d69adb81d42ccf0337168710161c84f610d88 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:17:59 +0200 Subject: [PATCH 5/9] Print Ruby and Sapphire's party menu actions from the cart's text The Ruby/Sapphire party menu printed its actions from the rse/menus pack's cursor options, plain English strings the extractor copied from sPokemonMenuActions, and a field move's English label, so a translation mod's SUMMARY, SWITCH, ITEM and the others never showed. The actions now read the script cache by the labels sPokemonMenuActions points at (pokeruby/src/pokemon_menu.c:126), with the pack's copy as the fallback, and a field move prints its move's name, as the cart does; Emerald's party menu does the same since gen1recomp#2678. --- src/ui/game3/rs/party_menu_data.lua | 17 ++++++++++++++--- .../game3_rs_translation_runtime_test.lua | 12 ++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/ui/game3/rs/party_menu_data.lua b/src/ui/game3/rs/party_menu_data.lua index 1134d402..b44a3450 100644 --- a/src/ui/game3/rs/party_menu_data.lua +++ b/src/ui/game3/rs/party_menu_data.lua @@ -12,12 +12,23 @@ local function menuData() return assert(require("src.ui.game3.rse.scene_kit").manifest(Data.manifest).party) end +-- pokeruby/src/pokemon_menu.c:126 +Data.actionLabels = {"OtherText_Summary", "OtherText_Switch2", "OtherText_Item", "gOtherText_CancelNoTerminator", + "OtherText_Give2", "OtherText_Take2", "OtherText_Take", "OtherText_Mail", "OtherText_Read2", + "gOtherText_CancelNoTerminator"} + function Data.actionText(action, fields) + local RomText = require("src.core.game3.rom_text") local nativeId = Data.actionIds[action] - if nativeId then return assert(menuData().cursorOptions[nativeId + 1]) end - if Data.extraLabels[action] then return require("src.core.game3.rom_text").plain(Data.extraLabels[action]) end + if nativeId then + local label = Data.actionLabels[nativeId + 1] + if RomText.has(label) then return RomText.plain(label) end + return assert(menuData().cursorOptions[nativeId + 1]) + end + if Data.extraLabels[action] then return RomText.plain(Data.extraLabels[action]) end assert(fields and fields.index[action] ~= nil, "unknown native RS party action: " .. tostring(action)) - return action + local move = fields.moves and fields.moves[fields.index[action] + 1] + return move and require("src.core.game3.pokemon").moveName(move) or action end function Data.buildActions(mon, party, fields) diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua index 94e7bcd5..5a95b2a1 100644 --- a/tests/engine/game3_rs_translation_runtime_test.lua +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -230,6 +230,18 @@ EASY.word = function() return "ともだち" end check(lastQuote() == "RAW513", "a word the cart cannot print keeps the cart's word") EASY.word, Rse.session, Tv.state = word, rseSession, tvState + +-- The party menu's actions (pokeruby/src/pokemon_menu.c:126) read the +-- script cache by their pret labels, and a field move prints its move's name. +MANIFESTS["rse/menus"] = { party = { cursorOptions = { "SUMMARY", "SWITCH", "ITEM", "CANCEL", "GIVE", "TAKE", + "TAKE", "MAIL", "READ", "CANCEL", "CUT" } } } +package.loaded["src.core.game3.pokemon"].moveName = function(move) return move == 15 and "COUPE" or nil end +local PartyData = require("src.ui.game3.rs.party_menu_data") +BUNDLE.text = { OtherText_Summary = ir("RESUME"), OtherText_Take = ir("PRENDRE") } +check(PartyData.actionText("SUMMARY") == "RESUME", "a party action reads the cache by its label") +check(PartyData.actionText("TAKE_MAIL") == "PRENDRE", "the mail's TAKE reads its own label") +check(PartyData.actionText("SWITCH") == "SWITCH", "an action missing from the cache keeps the pack's copy") +check(PartyData.actionText("CUT", { index = { CUT = 0 }, moves = { 15 } }) == "COUPE", "a field move prints its move's name") if failed > 0 then print(failed .. " check(s) failed") os.exit(1) From 117af9f40c02fe52c4b436cdb5be7449fefc3ee7 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:53:29 +0200 Subject: [PATCH 6/9] Resolve Ruby and Sapphire's party menu actions once per menu Data.drawActions looked each action's text up in the script cache on every frame. It now draws the texts the party menu resolves once for each action list (action_texts, exposed as PartyMenu._actionTextsFor), as Emerald's and FireRed's menus already do. --- src/ui/game3/party_menu.lua | 1 + src/ui/game3/rs/party_menu_data.lua | 3 ++- tests/engine/game3_rs_translation_runtime_test.lua | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 83cc27a7..7002fcf4 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -137,6 +137,7 @@ local function action_texts(list) PartyMenu._actionTexts = { list = list, texts = texts } return texts end +PartyMenu._actionTextsFor = action_texts local FR_INSETS = { msgX = 2, msgY = 2, actX = 9, actY = 2, cursorX = 1 } local function textInsets() diff --git a/src/ui/game3/rs/party_menu_data.lua b/src/ui/game3/rs/party_menu_data.lua index b44a3450..78862ebb 100644 --- a/src/ui/game3/rs/party_menu_data.lua +++ b/src/ui/game3/rs/party_menu_data.lua @@ -179,7 +179,8 @@ function Data.drawActions(ui, item) local Font = require("src.ui.game3.frlg_font") prompt(item and ui._submenuKind ~= "MAIL" and 13 or 5, item and (ui._submenuKind == "MAIL" and 18 or 21) or 17) require("src.ui.game3.chrome").stdFrame(left + 1, top + 1, width - 1, count * 2) - for i, action in ipairs(actions) do Font.draw(Data.actionText(action, ui._fieldMoveData), (left + 1) * 8, (top + 1) * 8 + (i - 1) * 16, + local texts = ui._actionTextsFor(actions) + for i in ipairs(actions) do Font.draw(texts[i], (left + 1) * 8, (top + 1) * 8 + (i - 1) * 16, require("src.ui.game3.rs.party_chrome").textOptions("menu")) end local cursor = item and ui.itemActionCursor or ui.actionCursor require("src.ui.game3.rs.menu_cursor").draw((left + 1) * 8, (top + 1) * 8 + (cursor - 1) * 16, (width - 1) * 8) diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua index 5a95b2a1..5984a2e7 100644 --- a/tests/engine/game3_rs_translation_runtime_test.lua +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -242,6 +242,20 @@ check(PartyData.actionText("SUMMARY") == "RESUME", "a party action reads the cac check(PartyData.actionText("TAKE_MAIL") == "PRENDRE", "the mail's TAKE reads its own label") check(PartyData.actionText("SWITCH") == "SWITCH", "an action missing from the cache keeps the pack's copy") check(PartyData.actionText("CUT", { index = { CUT = 0 }, moves = { 15 } }) == "COUPE", "a field move prints its move's name") +MANIFESTS["rse/party"] = { prompts = setmetatable({}, { __index = function() return "PROMPT" end }) } +local drawn, resolved = {}, 0 +local font, chrome, cursor, partyChrome = package.loaded["src.ui.game3.frlg_font"], package.loaded["src.ui.game3.chrome"], + package.loaded["src.ui.game3.rs.menu_cursor"], package.loaded["src.ui.game3.rs.party_chrome"] +package.loaded["src.ui.game3.frlg_font"] = { draw = function(text) drawn[#drawn + 1] = text end } +package.loaded["src.ui.game3.chrome"] = { stdFrame = function() end } +package.loaded["src.ui.game3.rs.menu_cursor"] = { draw = function() end } +package.loaded["src.ui.game3.rs.party_chrome"] = { textOptions = function() return {} end } +local menu = { ACTIONS = { "SUMMARY", "CANCEL" }, actionCursor = 1, + _actionTextsFor = function(list) resolved = resolved + 1 return { "RESUME", "RETOUR" } end } +PartyData.drawActions(menu, false) +check(drawn[2] == "RESUME" and drawn[3] == "RETOUR" and resolved == 1, "the action menu draws the texts resolved for its list") +package.loaded["src.ui.game3.frlg_font"], package.loaded["src.ui.game3.chrome"] = font, chrome +package.loaded["src.ui.game3.rs.menu_cursor"], package.loaded["src.ui.game3.rs.party_chrome"] = cursor, partyChrome if failed > 0 then print(failed .. " check(s) failed") os.exit(1) From 359f41ff766001bb9c60cc2f8cbbd54ecd08d6e9 Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:09:27 +0200 Subject: [PATCH 7/9] Print Ruby and Sapphire's party menu prompts from the cart's text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The party menu's prompts (Do what with {STR_VAR_1}?, Choose a POKéMON., Move to where?...) printed the rse/party pack's copies, plain English strings that also dropped the POKéMON's name from Do what with {STR_VAR_1}?. They now read the script cache by their PartyMenuPromptTexts slot, with the selected POKéMON's name as STR_VAR_1, the pack's copy as the fallback; a prompt is resolved once for the menu or mode that shows it. --- src/ui/game3/rs/party_menu_data.lua | 22 ++++++++++++++----- .../game3_rs_translation_runtime_test.lua | 10 +++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/ui/game3/rs/party_menu_data.lua b/src/ui/game3/rs/party_menu_data.lua index 78862ebb..5805a602 100644 --- a/src/ui/game3/rs/party_menu_data.lua +++ b/src/ui/game3/rs/party_menu_data.lua @@ -163,11 +163,23 @@ function Data.drawDescription(desc, x, y) love.graphics.draw(image, quad, x, y) end -local function prompt(id, width) +local function promptText(id, name) + local RomText = require("src.core.game3.rom_text") + local key = RomText.key("PartyMenuPromptTexts", id) + if RomText.has(key) then return RomText.plain(key, {stringVars = {name or ""}}) end + return assert(require("src.ui.game3.rse.scene_kit").manifest("rse/party").prompts[id + 1]) +end + +local function prompt(ui, anchor, id, width) + local memo = ui._rsPrompt + if not (memo and memo.anchor == anchor and memo.id == id) then + local mon = id == 5 and ui._party and ui._party[ui.cursor] + memo = {anchor = anchor, id = id, text = promptText(id, mon and require("src.core.game3.pokemon").displayName(mon))} + ui._rsPrompt = memo + end local Chrome = require("src.ui.game3.chrome") Chrome.stdFrame(1, 17, width, 2) - require("src.ui.game3.frlg_font").draw(assert(require("src.ui.game3.rse.scene_kit").manifest("rse/party").prompts[id + 1]), 8, 136, - require("src.ui.game3.rs.party_chrome").textOptions("menu")) + require("src.ui.game3.frlg_font").draw(memo.text, 8, 136, require("src.ui.game3.rs.party_chrome").textOptions("menu")) end function Data.drawActions(ui, item) @@ -177,7 +189,7 @@ function Data.drawActions(ui, item) local left = item and (30 - (width + 1)) or 19 local top = item and (20 - (count * 2 + 2)) or (18 - count * 2) local Font = require("src.ui.game3.frlg_font") - prompt(item and ui._submenuKind ~= "MAIL" and 13 or 5, item and (ui._submenuKind == "MAIL" and 18 or 21) or 17) + prompt(ui, actions, item and ui._submenuKind ~= "MAIL" and 13 or 5, item and (ui._submenuKind == "MAIL" and 18 or 21) or 17) require("src.ui.game3.chrome").stdFrame(left + 1, top + 1, width - 1, count * 2) local texts = ui._actionTextsFor(actions) for i in ipairs(actions) do Font.draw(texts[i], (left + 1) * 8, (top + 1) * 8 + (i - 1) * 16, @@ -192,7 +204,7 @@ function Data.drawPrompt(ui) elseif ui.mode == "give" then id = 4 elseif ui.mode == "move_tutor" then id = 20 elseif ui.mode == "use" then id = require("src.core.game3.items_data").isTm(ui._item) and 2 or 3 end - prompt(id, 22) + prompt(ui, ui.mode, id, 22) end function Data.yesNoLabels() diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua index 5984a2e7..9f859b2b 100644 --- a/tests/engine/game3_rs_translation_runtime_test.lua +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -254,6 +254,16 @@ local menu = { ACTIONS = { "SUMMARY", "CANCEL" }, actionCursor = 1, _actionTextsFor = function(list) resolved = resolved + 1 return { "RESUME", "RETOUR" } end } PartyData.drawActions(menu, false) check(drawn[2] == "RESUME" and drawn[3] == "RETOUR" and resolved == 1, "the action menu draws the texts resolved for its list") +package.loaded["src.core.game3.pokemon"].displayName = function(mon) return mon.nickname end +BUNDLE.text = { ["PartyMenuPromptTexts[5]"] = { { t = "text", s = "Que faire avec " }, { t = "strvar", n = 1 }, + { t = "text", s = "?" }, { t = "eos" } } } +drawn, menu.ACTIONS, menu._party, menu.cursor = {}, { "SUMMARY", "CANCEL" }, { { nickname = "POUSSIFEU" } }, 1 +PartyData.drawActions(menu, false) +check(drawn[1] == "Que faire avec POUSSIFEU?", "the action prompt reads the cache and names the POKeMON") +BUNDLE.text = {} +drawn = {} +PartyData.drawActions(menu, false) +check(drawn[1] == "Que faire avec POUSSIFEU?", "the prompt resolved for this menu is drawn again without a lookup") package.loaded["src.ui.game3.frlg_font"], package.loaded["src.ui.game3.chrome"] = font, chrome package.loaded["src.ui.game3.rs.menu_cursor"], package.loaded["src.ui.game3.rs.party_chrome"] = cursor, partyChrome if failed > 0 then From 870411b88ee2f8986d8e4283acc148743fcd6d0e Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:09:27 +0200 Subject: [PATCH 8/9] Translate the contest move effect description on Ruby and Sapphire's summary The CONTEST MOVES page printed the contest effect's description as the contest moves pack has it. It now goes through SummaryData.contestEffectDescription, as Emerald's summary does, resolved once per effect while the summary is open. --- src/ui/game3/rs/summary_menu.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/game3/rs/summary_menu.lua b/src/ui/game3/rs/summary_menu.lua index 4aff20cf..2ba53e66 100644 --- a/src/ui/game3/rs/summary_menu.lua +++ b/src/ui/game3/rs/summary_menu.lua @@ -50,6 +50,7 @@ local function detailRows(open) S._detailRowsOpen = open end function S.openMenu(party, index, opts) opts = opts or {} S._man, S._party, S._opts = S.manifest(), party or {}, opts + S._contestDescriptions = {} S._cursor = index or 1 S._playerState = opts.playerState or opts.session or require("src.core.game3.runtime").getSession() @@ -500,7 +501,10 @@ local function moves() local amount = value == 255 and 0 or math.floor(value / 10) for i = 0, 7 do frame(S._man.hearts, row == 1 and (i < amount and 1 or 0) or (i < amount and 3 or 4), 48 + i % 4 * 8, 120 + (row - 1) * 16 + math.floor(i / 4) * 8) end end - print(effect.description, 88, 120, 255) + local descriptions = S._contestDescriptions or {} + S._contestDescriptions = descriptions + descriptions[effect] = descriptions[effect] or SummaryData.contestEffectDescription(effect) + print(descriptions[effect], 88, 120, 255) else local def = Pokemon.battleMove(id) text("gOtherText_Power2", 8, 120); text("gOtherText_Accuracy2", 8, 136) From 2d404a7dc77a2c5bc2c68eae310b89491d992fdb Mon Sep 17 00:00:00 2001 From: thibautbus <310327033+thibautbus@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:06:51 +0200 Subject: [PATCH 9/9] Resolve Ruby and Sapphire's party prompts again each time the menu opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt a mode shows (Choose a POKéMON., Move to where?...) was kept on the party menu for the rest of the session. PartyMenu.show now clears it, like the menu's other per-open state, so a prompt is resolved once per opening. --- src/ui/game3/party_menu.lua | 1 + tests/engine/game3_rs_translation_runtime_test.lua | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 7002fcf4..306cf006 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -945,6 +945,7 @@ function PartyMenu.show(sessionParty, moveOverlay, opts) end opts = opts or {} PartyMenu._order = nil + PartyMenu._rsPrompt = nil if opts.mode == "battle_switch" or opts.mode == "battle_faint" or (opts.mode == "use" and opts.battleOrder) then local party0 = sessionParty or (opts.session and opts.session.party) local ov0 = moveOverlay or (opts.session and (opts.session.move_overlay or opts.session.moveOverlay)) diff --git a/tests/engine/game3_rs_translation_runtime_test.lua b/tests/engine/game3_rs_translation_runtime_test.lua index 9f859b2b..70c7421c 100644 --- a/tests/engine/game3_rs_translation_runtime_test.lua +++ b/tests/engine/game3_rs_translation_runtime_test.lua @@ -254,6 +254,7 @@ local menu = { ACTIONS = { "SUMMARY", "CANCEL" }, actionCursor = 1, _actionTextsFor = function(list) resolved = resolved + 1 return { "RESUME", "RETOUR" } end } PartyData.drawActions(menu, false) check(drawn[2] == "RESUME" and drawn[3] == "RETOUR" and resolved == 1, "the action menu draws the texts resolved for its list") +local displayName = package.loaded["src.core.game3.pokemon"].displayName package.loaded["src.core.game3.pokemon"].displayName = function(mon) return mon.nickname end BUNDLE.text = { ["PartyMenuPromptTexts[5]"] = { { t = "text", s = "Que faire avec " }, { t = "strvar", n = 1 }, { t = "text", s = "?" }, { t = "eos" } } } @@ -264,6 +265,12 @@ BUNDLE.text = {} drawn = {} PartyData.drawActions(menu, false) check(drawn[1] == "Que faire avec POUSSIFEU?", "the prompt resolved for this menu is drawn again without a lookup") +BUNDLE.text = { ["PartyMenuPromptTexts[5]"] = { { t = "text", s = "Que faire avec " }, { t = "strvar", n = 1 }, + { t = "text", s = "?" }, { t = "eos" } } } +drawn, menu.ACTIONS, menu._party = {}, { "SUMMARY", "CANCEL" }, { { nickname = "GOBOU" } } +PartyData.drawActions(menu, false) +check(drawn[1] == "Que faire avec GOBOU?", "a new action menu names its own POKeMON") +package.loaded["src.core.game3.pokemon"].displayName = displayName package.loaded["src.ui.game3.frlg_font"], package.loaded["src.ui.game3.chrome"] = font, chrome package.loaded["src.ui.game3.rs.menu_cursor"], package.loaded["src.ui.game3.rs.party_chrome"] = cursor, partyChrome if failed > 0 then