Pokered Save Editor 2
Pokemon Red & Blue save file editor - Qt 6 C++/QML
Loading...
Searching...
No Matches
Internationalization (i18n) — translations pipeline

Added 2026-06-13. The app now has a full Qt Linguist translation pipeline. Source language is en_US (the literal strings in the code). English is what ships; the machinery is in place so adding a locale is a small, well-defined task.

Why Qt Linguist (not a custom stringfile)

The original idea was "a stringlist/stringfile in a folder." For a Qt app that's the less clean route — it reinvents what the framework already does well and would be a hack by this project's bar. Qt's built-in system gives us a translations/ folder of per-language catalogs and plugs straight into the engine with zero custom string-loading code:

  • UI strings are wrapped in qsTr() (QML) / tr() (C++).
  • lupdate extracts them into translations/pse_<locale>.ts (XML; editable in Qt Linguist).
  • lrelease compiles each .ts → a binary .qm.
  • qt_add_translations embeds the .qm into the binary under the :/i18n resource prefix.
  • boot.cpp installs a QTranslator at startup; qsTr()/tr() then resolve to the chosen language.

This covers UI chrome only (button labels, headers, tooltips, menus). Game data (Pokémon / move / item / location names) is a separate, larger effort — those live in the DB JSON and are tied to the ROM's region/encoding, so they must NOT go through Qt translations. See "Out of scope" below.

The pieces (where everything lives)

Piece Location
Catalog(s) projects/app/translations/pse_<locale>.ts (currently just pse_en.ts)
CMake wiring projects/app/CMakeLists.txt → the "Internationalization (i18n)" block (qt_add_translations)
LinguistTools dep projects/CMakeLists.txtfind_package(Qt6 ... LinguistTools)
Translator install projects/app/src/boot/boot.cppinstallTranslators() (called in createApp() right after the QApplication is constructed, before the MainWindow/any QML)
Embedded .qm compiled into the exe at :/i18n/pse_<locale>.qm (RESOURCE_PREFIX /i18n)

qt_add_translations creates two targets:

  • release_translations — runs lrelease; it's a build dependency of PokeredSaveEditor, so a normal build always (re)compiles the .qm and embeds it. (lrelease is fast and reliable.)
  • update_translations — runs lupdate to (re)scan the sources and refresh the .ts. This is manual (not part of a normal build). Run it whenever strings are added/changed.

lupdate scans an explicit file list (PSE_I18N_SOURCES, a GLOB_RECURSE over ui/*.qml + src/*.{cpp,h}) because the QML lives inside app.qrc, not in the target's SOURCES — Qt's auto source-collection would otherwise miss it. lupdate also picks up mainwindow.ui (the native menu).

How to add a language

  1. Add the catalog to PSE_TS_FILES in projects/app/CMakeLists.txt, e.g. ${CMAKE_CURRENT_SOURCE_DIR}/translations/pse_fr.ts.
  2. Reconfigure, then build the update_translations target once to create/populate it (in Qt Creator: Tools → External → Linguist → Update Translations, or build the target).
  3. Translate pse_fr.ts in Qt Linguist (mark each message "finished").
  4. Rebuild — lrelease compiles pse_fr.qm, it's embedded, and the app picks it up when the locale resolves to French.

No code change is needed beyond the one TS_FILES line.

How a language is chosen at runtime

installTranslators() resolves the locale as: the ui/language QSettings value (a locale name like "fr"), else QLocale::system(). It then load()s :/i18n/pse_<locale>.qm. If no catalog matches — or a particular string is untranslated — the UI falls back to the en_US source text. That graceful degradation is intentional (context/principles.md): a missing translation never blanks a label. A Qt-builtins translator (qtbase_<locale>.qm) is also loaded best-effort for standard dialog text.

There is no in-app language switcher yet (live retranslation needs engine retranslate() + re-evaluating bindings). The ui/language setting is the hook; building the switcher is a future task. To test another locale today, set the ui/language registry value (org "Twilight", app "Pokered Save Editor") or override with QT_QPA/-platform env + LANG.

What's wrapped vs. deliberately left alone

Wrapped (qsTr/tr): the bulk of visible chrome — text / title / placeholderText / ToolTip.text literals across the QML tree (≈139), a few prose concatenations rewritten as qsTr("… %1 …").arg(x) (e.g. the name byte-counter), the Router screen titles via QT_TRANSLATE_NOOP("Screen", …) (translated at point of use in router.cpp, because loadScreens() runs before the translator exists), and a handful of C++ model display strings ("None", "Clear Recent Files", the Money⇄Coins labels).

Left as-is, on purpose:

  • Logical/value strings — anything compared as a key (previewTileset !== "Cavern", internal names like "nidoran<f>", "phony", move-type keys). These are not display text; translating them would desync logic. Note the tileset menu is the well-designed case: the display text: is wrapped, the value sent to C++ stays the English literal — correct separation.
  • Mandated attributions — the wallpaper/artwork license lines (CC-BY-NC-ND credits). License text should not be translated.
  • Tiny format prefixes"L"+level, "x"+count, "No."+n, the version string. Low value, some are effectively symbols; can be revisited if a real locale wants them.
  • Game-data names — see below.

Out of scope (separate, bigger effort): game-data localization

Pokémon, move, item, and location names are data, not UI chrome. In Gen 1 they're region/ encoding-specific (a French/German/Japanese ROM encodes names differently), and they're entangled with the save format and the in-game font/character tables. Localizing them is its own project and must not be routed through Qt translations. Track it separately if/when desired.

Note — lupdate/lrelease both work fine

Both tools run normally. During the initial setup, lupdate appeared to hang from the automation shell — that turned out to be stacked Windows UAC elevation dialogs (commands launched from the agent shell can trigger a UAC prompt that blocks until accepted; several had queued up while unattended). It was not a tooling problem. Refreshing the catalog via the update_translations target (or Qt Creator → "Update Translations") works as expected; the initial pse_en.ts (200 messages / 42 contexts) was generated that way. Heads-up only: build/translation commands run from the agent shell may pop a UAC prompt that needs accepting.