feat: add UI translation system (i18n) with live language switching#735
Open
foXaCe wants to merge 17 commits into
Open
feat: add UI translation system (i18n) with live language switching#735foXaCe wants to merge 17 commits into
foXaCe wants to merge 17 commits into
Conversation
Adds a runtime translation layer so all user-facing strings can be
localized without restarting the app. English ships as the reference
language; French is bundled as a complete second locale (425 keys).
## Architecture
- New `workspace/all/common/i18n.{c,h}` — minimal i18n core:
- Hash table (FNV-1a, 1024 buckets, 32 KB arena)
- Parser for plain `key=value` `.lang` files (no JSON dep)
- Lookup `T(key)` returns the translated string, or the input
verbatim when no entry matches → safe fallback
- `I18N_init(code)` called from `GFX_init`; `I18N_reload(code)`
hot-swaps the table at runtime
- New `skeleton/SYSTEM/res/lang/{en,fr}.lang` (425 key pairs)
- `NextUISettings.language[8]` persisted in `minuisettings.txt`
(defaults to "en"; backward-compatible read)
## Coverage
All user-visible strings are passed through `T()`:
- Launcher: `workspace/all/nextui/nextui.c`
- In-game menu: `workspace/all/minarch/{ma_menu,ma_frontend_opts,
ma_saves,ra_integration}.c`
- Settings UI (`workspace/all/settings/*.cpp/hpp`): main menus,
Appearance, Display, Audio, System, FN switch, In-Game,
RetroAchievements (including sync overlay messages), About,
Bluetooth, WiFi, color picker, keyboard prompt
- Hardware hints in `workspace/all/common/api.c`
- Tools: `battery`, `clock`, `gametime`, `ledcontrol`, `minput`,
`bootlogo` (button hints + on-screen labels)
## Hot-reload (Settings only)
`Settings → System → Language` switches locale live without exit:
- `AbstractMenuItem::getName/getDesc` and `MenuItem::getLabel/getLabels`
now call `T()` at each access (storing i18n keys, not pre-translated
strings)
- `ScopedOverlay` and `MenuList::showOverlay` apply `T()` internally
- `getRawName()` added for `selectByName` so selection survives a
language change
- Other apps (launcher, minarch, tools) load the active language on
startup — since they're short-lived processes relaunched per action,
no hot-reload is required there
## Build
- `i18n.c` linked into every app that calls `T()`: `nextui`, `minarch`,
`settings`, `battery`, `clock`, `gametime`, `ledcontrol`, `minput`,
`bootlogo` (9 makefiles updated)
- Pure C module; safe to include from C++ via `extern "C"` (already
done in `settings/menu.hpp`)
## Compatibility
- Default behavior unchanged: with no `.lang` files installed or
`language=en`, every `T(key)` falls back to the literal key — which
for legacy strings is the original English text → zero regression
- New `language=` line appended to `minuisettings.txt` (older
consumers can simply ignore it)
- Adding a new locale = drop `<code>.lang` into `.system/res/lang/`;
it is auto-discovered by Settings via `discoverLanguages()`
Covers the ~30 options of the in-game Frontend menu (MENU → Options →
Frontend during gameplay) plus their option-value labels. Adds ~90 new
key pairs to en.lang and fr.lang (total: 514 keys).
## Changes
- workspace/all/minarch/ma_config.c
- Replace literal English strings in .name / .desc with i18n keys
(e.g. `"Screen Scaling"` → `"frontend.opt.screen_scaling"`)
- Replace label arrays (onoff_labels, scaling_labels, resample_labels,
rewind_enable_labels, rewind_compression_accel_labels,
ambient_labels, effect_labels, overlay_labels) with key-based
entries
- getScreenScalingDesc() returns a key instead of a literal
- workspace/all/minarch/ma_menu.c
- Wrap rendering sites in T() so the displayed text is translated at
each frame (live-reloadable):
- item->name (3 sites)
- item->values[item->value] (1 site)
- item->values[j] (size calc, 1 site)
- list/item->desc footer (size + blit, 2 sites)
- skeleton/SYSTEM/res/lang/en.lang
- skeleton/SYSTEM/res/lang/fr.lang
- +~90 paired keys covering option names, descriptions, and value
labels for the Frontend menu
…ollections)
The main launcher built its root menu through Entry_new(path, ENTRY_DIR),
which derives the displayed name from the directory path
(getDisplayName()), bypassing translation. Switch those root entries to
Entry_newNamed with i18n keys so the labels render in the active
language.
## Changes
- workspace/all/nextui/nextui.c
- getRoot(): Tools / Recently Played / Collections now built with
Entry_newNamed + T() keys
- getQuickEntries(): same fix for the Quickswitcher entries
- skeleton/SYSTEM/res/lang/en.lang
- skeleton/SYSTEM/res/lang/fr.lang
- +3 paired keys: launcher.tools, launcher.recently_played,
launcher.collections
Brings total to 517 paired keys.
Open
8 tasks
Pass the derived display name through T() in Entry_new so well-known
pak and folder names (Settings, Updater, Pak Store, ...) can be
localised via the .lang files without renaming the actual directories
on disk.
Behaviour:
- en.lang lists each key with the original English value as its value,
so English users see no change (T() returns "Settings" → "Settings").
- fr.lang translates the entries that have a natural French equivalent
(Settings → Réglages, Updater → Mise à jour, USB Mass Storage →
Stockage USB, ...). Proper nouns (Pak Store, ScrapeGoat, LED'oh!)
are deliberately left untranslated.
- ROM folders, custom paks, and any name not present in the .lang fall
back to the raw input — zero risk of regression for third-party
content.
Extending the list = one line per locale (e.g. `MyPak=Mon Pak` in
fr.lang). No code change needed.
Brings total to 534 paired keys.
## Files
- workspace/all/nextui/nextui.c
- Entry_new now wraps the getDisplayName() result with T()
- skeleton/SYSTEM/res/lang/en.lang
- skeleton/SYSTEM/res/lang/fr.lang
- +17 pak / tool folder name keys covering the bundled and most
popular community paks
Third-party pak names are their authors' identity — translating them
would be confusing and would diverge from how the rest of the ecosystem
(Discord, README, screenshots) refers to them. Restrict Entry_new's
T() fallthrough to NextUI-bundled paks only:
Kept (NextUI core, workspace/all/*):
Settings, Battery, Clock, Game Tracker, Bootlogo, Input, LedControl
+ USB Mass Storage (third-party but a generic technical term)
Removed (third-party — keep author name):
Updater, Aesthetics, Pak Store, Files, Gallery, Remove Loading,
Artwork Scraper, Another Cheat Downloader, ScrapeGoat, LED'oh!, ...
Behaviour unchanged for these: T("Aesthetics") has no entry → falls
back to "Aesthetics" verbatim, so they render exactly as before.
The Battery tool's hints were already translated but the on-screen
labels (Since Charge, Current, Remaining, Longest), the zoom title
(Battery usage: Last N hours) and the "calculating" placeholder were
still rendered with hard-coded English strings.
## Changes
- workspace/all/battery/battery.c
- Format strings (battery.{since_charge,current,remaining,longest}_fmt)
are now resolved via T() so each label is translated at draw time.
- The zoom title goes through T("battery.usage_fmt") with the time
range itself (T("battery.range.{4h,8h,16h}")) as the format arg.
- The "calculating" placeholder in session_left is refreshed from
T("battery.calculating") right after GFX_init has loaded the
language. Buffer grown from 12 to 32 bytes to hold longer
translations.
- skeleton/SYSTEM/res/lang/{en,fr}.lang
- +9 new keys (battery.*).
- French range labels deliberately kept short ("4 dern. h" instead of
"4 dernières heures") so they fit in the title pill alongside the
"Utilisation batterie :" prefix.
… batterie') The Battery tool already shows a battery icon in the corner — having the word 'batterie' in the title is redundant in French and pushed the time- range suffix off-screen. Dropping it lets 'Utilisation : 16 dernières heures' fit on a single line, no abbreviation needed.
Refined the previous rule. Brand names (Aesthetics, ScrapeGoat, LED'oh!, Pak Store, Updater) stay untouched, but generic descriptive names that map 1:1 across languages and read as function labels (Files → Fichiers, Gallery → Galerie) should follow translation like the other system tools.
minui-presenter is shared by several third-party paks (Gallery, Artwork Scraper, Another Cheat Downloader, ...) that pass English UI labels (EXIT, OK, CANCEL, ...) via the --action-text / --cancel-text options. With i18n now wired into minui-presenter (https://github.com/josegonzalez/minui-presenter PR pending), those literals can be re-mapped to localised strings without modifying each pak's launch.sh — provided the lang file lists a key with the literal's exact spelling. Adds the matching keys to NextUI's central en/fr lang files.
foXaCe
added a commit
to foXaCe/minui-presenter
that referenced
this pull request
May 23, 2026
minui-presenter is shared by many third-party MinUI/NextUI paks (Gallery, Artwork Scraper, Another Cheat Downloader, ...) and the button labels it draws were hard-coded English. With NextUI now shipping an i18n system (see LoveRetro/NextUI#735), the presenter can read the same `language=` setting and localise its labels — both the built-in defaults and the strings passed by paks via --action-text/--cancel-text/... ## What it does - Reads `/mnt/SDCARD/.userdata/shared/minuisettings.txt` once at startup to discover the active language (`language=fr`, `language=en`, ...); falls back to English if the file or the key is missing. - Loads the matching `<code>.lang` file from `/mnt/SDCARD/.system/res/lang/` into a small open-addressed hash table (FNV-1a, 4096 buckets, 256 KB arena). Lookups are ~80 ns on a Cortex-A53. - Replaces the default button text constants (ACTION / SELECT / BACK / OTHER) with `T()` calls using `mp.btn.*` keys. - **Bonus**: after argv parsing, runs the caller-provided button texts through `T()` as well. So a pak that already passes `--cancel-text "EXIT"` doesn't need to change anything — as soon as the lang file contains `EXIT=QUITTER`, the bottom-right button reads "QUITTER" on a French system. ## Files - `include/i18n/i18n.{c,h}` — pure-C i18n core (adapted from the NextUI PR — same parser, same hash table, same fallback semantics). Depends only on `defines.h` (already in the include path via the toolchain's shared common/) for `SDCARD_PATH` and `MAX_PATH`. - `lang/en.lang`, `lang/fr.lang` — reference + French. Translators can copy en.lang to `<code>.lang` and translate the values; no code change needed. - `Makefile` — adds `include/i18n/i18n.c` to both the macOS and the Linux build SOURCE lines. - `minui-presenter.c` — bootstraps i18n right after `GFX_init`, swaps default labels for keys, and re-translates argv-provided labels with a small `MP_RETRANSLATE` macro. ## Compatibility - No-op on systems without the lang files: missing keys fall through to the input literal, so behaviour is identical to today's binary. - Behaviour is identical for paks that pass localised strings already (no matching key → no replacement). - No new dependencies; the hash table and arena are `static` BSS, so the binary grows by ~350 KB of *uninitialised* memory (i.e. not on disk) and the file size delta is just the parser code (~3 KB). ## Test plan - [x] Cross-compiled for `tg5040` via the upstream `savant/minui-toolchain:tg5040` Docker image (same path as CI) - [x] Deployed via ADB to a TrimUI Brick running NextUI v6.11.2 with `language=fr` in `minuisettings.txt` - [x] Gallery pak: bottom-right button now reads "QUITTER" (was "EXIT") - [x] Falls back cleanly to English when `language=en` or no lang files present - [ ] Other platforms: not validated on hardware (identical code path) Companion PRs: - NextUI core i18n + 534 keys: LoveRetro/NextUI#735 - NextUI Updater pak FR: LoveRetro/nextui-updater-pak#18
foXaCe
added a commit
to foXaCe/minui-presenter
that referenced
this pull request
May 23, 2026
minui-presenter is shared by many third-party MinUI/NextUI paks (Gallery, Artwork Scraper, Another Cheat Downloader, ...) and the button labels it draws were hard-coded English. With NextUI now shipping an i18n system (see LoveRetro/NextUI#735), the presenter can read the same `language=` setting and localise its labels — both the built-in defaults and the strings passed by paks via --action-text/--cancel-text/... ## What it does - Reads `/mnt/SDCARD/.userdata/shared/minuisettings.txt` once at startup to discover the active language (`language=fr`, `language=en`, ...); falls back to English if the file or the key is missing. - Loads the matching `<code>.lang` file from `/mnt/SDCARD/.system/res/lang/` into a small open-addressed hash table (FNV-1a, 4096 buckets, 256 KB arena). Lookups are ~80 ns on a Cortex-A53. - Replaces the default button text constants (ACTION / SELECT / BACK / OTHER) with `T()` calls using `mp.btn.*` keys. - **Bonus**: after argv parsing, runs the caller-provided button texts through `T()` as well. So a pak that already passes `--cancel-text "EXIT"` doesn't need to change anything — as soon as the lang file contains `EXIT=QUITTER`, the bottom-right button reads "QUITTER" on a French system. ## Files - `include/i18n/i18n.{c,h}` — pure-C i18n core (adapted from the NextUI PR — same parser, same hash table, same fallback semantics). Depends only on `defines.h` (already in the include path via the toolchain's shared common/) for `SDCARD_PATH` and `MAX_PATH`. - `lang/en.lang`, `lang/fr.lang` — reference + French. Translators can copy en.lang to `<code>.lang` and translate the values; no code change needed. - `Makefile` — adds `include/i18n/i18n.c` to both the macOS and the Linux build SOURCE lines. - `minui-presenter.c` — bootstraps i18n right after `GFX_init`, swaps default labels for keys, and re-translates argv-provided labels with a small `MP_RETRANSLATE` macro. ## Compatibility - No-op on systems without the lang files: missing keys fall through to the input literal, so behaviour is identical to today's binary. - Behaviour is identical for paks that pass localised strings already (no matching key → no replacement). - No new dependencies; the hash table and arena are `static` BSS, so the binary grows by ~350 KB of *uninitialised* memory (i.e. not on disk) and the file size delta is just the parser code (~3 KB). ## Test plan - [x] Cross-compiled for `tg5040` via the upstream `savant/minui-toolchain:tg5040` Docker image (same path as CI) - [x] Deployed via ADB to a TrimUI Brick running NextUI v6.11.2 with `language=fr` in `minuisettings.txt` - [x] Gallery pak: bottom-right button now reads "QUITTER" (was "EXIT") - [x] Falls back cleanly to English when `language=en` or no lang files present - [ ] Other platforms: not validated on hardware (identical code path) Companion PRs: - NextUI core i18n + 534 keys: LoveRetro/NextUI#735 - NextUI Updater pak FR: LoveRetro/nextui-updater-pak#18
5 tasks
Adds OK, BACK and two --message strings (No screenshots found, Screenshots directory not found) so paks using minui-presenter for status messages inherit French automatically once the upstream PR lands.
foXaCe
added a commit
to foXaCe/minui-presenter
that referenced
this pull request
May 23, 2026
minui-presenter is shared by many third-party MinUI/NextUI paks (Gallery, Artwork Scraper, Another Cheat Downloader, ...) and the button labels it draws were hard-coded English. With NextUI now shipping an i18n system (see LoveRetro/NextUI#735), the presenter can read the same `language=` setting and localise its labels — both the built-in defaults and the strings passed by paks via --action-text/--cancel-text/... ## What it does - Reads `/mnt/SDCARD/.userdata/shared/minuisettings.txt` once at startup to discover the active language (`language=fr`, `language=en`, ...); falls back to English if the file or the key is missing. - Loads the matching `<code>.lang` file from `/mnt/SDCARD/.system/res/lang/` into a small open-addressed hash table (FNV-1a, 4096 buckets, 256 KB arena). Lookups are ~80 ns on a Cortex-A53. - Replaces the default button text constants (ACTION / SELECT / BACK / OTHER) with `T()` calls using `mp.btn.*` keys. - **Bonus**: after argv parsing, runs the caller-provided button texts through `T()` as well. So a pak that already passes `--cancel-text "EXIT"` doesn't need to change anything — as soon as the lang file contains `EXIT=QUITTER`, the bottom-right button reads "QUITTER" on a French system. ## Files - `include/i18n/i18n.{c,h}` — pure-C i18n core (adapted from the NextUI PR — same parser, same hash table, same fallback semantics). Depends only on `defines.h` (already in the include path via the toolchain's shared common/) for `SDCARD_PATH` and `MAX_PATH`. - `lang/en.lang`, `lang/fr.lang` — reference + French. Translators can copy en.lang to `<code>.lang` and translate the values; no code change needed. - `Makefile` — adds `include/i18n/i18n.c` to both the macOS and the Linux build SOURCE lines. - `minui-presenter.c` — bootstraps i18n right after `GFX_init`, swaps default labels for keys, and re-translates argv-provided labels with a small `MP_RETRANSLATE` macro. ## Compatibility - No-op on systems without the lang files: missing keys fall through to the input literal, so behaviour is identical to today's binary. - Behaviour is identical for paks that pass localised strings already (no matching key → no replacement). - No new dependencies; the hash table and arena are `static` BSS, so the binary grows by ~350 KB of *uninitialised* memory (i.e. not on disk) and the file size delta is just the parser code (~3 KB). ## Test plan - [x] Cross-compiled for `tg5040` via the upstream `savant/minui-toolchain:tg5040` Docker image (same path as CI) - [x] Deployed via ADB to a TrimUI Brick running NextUI v6.11.2 with `language=fr` in `minuisettings.txt` - [x] Gallery pak: bottom-right button now reads "QUITTER" (was "EXIT") - [x] Falls back cleanly to English when `language=en` or no lang files present - [ ] Other platforms: not validated on hardware (identical code path) Companion PRs: - NextUI core i18n + 534 keys: LoveRetro/NextUI#735 - NextUI Updater pak FR: LoveRetro/nextui-updater-pak#18
The Clock pak hard-coded the YYYY/MM/DD field order in both the rendering and the cursor positioning. Drives this from a new clock.date_order key (YMD/DMY/MDY); en defaults to YMD (ISO), fr uses DMY. The cursor still navigates the logical sequence (year/month/day), just the on-screen positions follow the locale's order. Hour/minute/ second positions are unchanged since the date block keeps the same total width (100 px @1x) regardless of order.
Adds 80 ps.* keys consumed by the companion PR on LoveRetro/nextui-pak-store. Includes button labels, screen titles, settings, info screen, install/uninstall/update flows, storefront categories and the splash text. Companion PR: <pak-store-pr-url>
Adds ~150 sg.* keys consumed by the companion PR on Helaas/nextui-scrapegoat-pak. Covers main menu, library list, ROM detail, queue/progress screens, settings, dialogs, warnings. The parser now decodes \\n / \\t / \\\\ escapes so multi-line dialog strings (cancel-all confirmation, background-scraping warning, ...) can sit on a single .lang line. Existing single-line keys are unaffected. Also localises btn.up_down / btn.left_right (was hardcoded U/D and L/R literals in gametime/bootlogo/battery/ledcontrol) so the button hints render as H/B and G/D in French. New gametime.title_fmt key for the Game Tracker top title. Companion PRs: - Helaas/nextui-scrapegoat-pak#9 - Helaas/Apostrophe#47 (queue widget i18n hook)
Short labels rendered as inline status badges in ScrapeGoat's ROM list (queued / searching / downloading / cloning / matching) and as the type tags (art / cht / pdf). Companion to Helaas/nextui-scrapegoat-pak#9 review fix.
3 tasks
- ~80 ae.* keys for the Aesthetics pak (companion PR pending on redria7/nextui-aesthetics) covering main menu, settings, theme components, decoration browser, dialogs. - 6 ps.* keys for the gabagool download manager footer (Close / Cancel Download / Cancel All Downloads / Show Speed / Hide Speed) + the download title. Consumed by the updated PR on LoveRetro/nextui-pak-store and the gabagool fork BrandonKowalski/gabagool#18 that adds the override hooks.
4 tasks
Adds 30 ae.* keys (15 per locale × 2) for the Aesthetics theme
manager surface that was left untranslated by the initial i18n
pass:
- ae.btn.{details,hide_theme,unhide_theme,trash_it}
- ae.dl.{refresh_catalog,hidden_themes,help_no_entries,help_a,help_x}
- ae.msg.{delete_theme,deleted,delete_failed,error_prefix,
name_exists,theme_renamed,select_one_component,
delete_components,delete_components_failed,
delete_components_done,update_error,updates_done,
download_failed,clear_deco,deleted_path,
delete_path_failed,unsupported_action,delete_deco,
copy_image_to,copy_failed,copy_success}
Covers the download catalog screen (browse / hide / details flow),
the theme delete/rename/copy actions, and the bulk component editor
messages. French translations keep the project's tutoiement style
and reuse the existing terminology choices (« Supprimer », « Échec »,
« Sélectionner », …).
Companion to nextui-scrapegoat-pak commit 1ead06e which routed 9 more ui.c strings through T(). Adds the matching translations here so the runtime can resolve them — without these, T() returns the bare key (e.g. "sg.cheat.fallback_name_fmt") as the visible label. New sg.* keys (149 → 149 EN ↔ FR parity preserved): - sg.cheat.fallback_name_fmt Code %d / Cheat %d - sg.cheat.list_title_fmt Codes triche (%d) / Cheats (%d) - sg.tag.disabled [désactivé] / [disabled] - sg.error.cache_inspect_failed_fmt - sg.error.cache_open_failed_fmt - sg.error.cache_rmdir_failed_fmt - sg.error.cache_unlink_failed_fmt - sg.error.cheat_cache_open_failed_fmt - sg.error.cheat_cache_rmdir_failed_fmt Also rewrites 7 existing FR strings from vouvoiement to tutoiement for style consistency with the rest of the file: - sg.error.queue_active_clear Attendez → Attends - sg.error.cache_cleared_stale Redémarrez → Redémarre - sg.error.bg_start_failed Essayez → Essaie - sg.warn.bg_still_active Fermez/rouvrez → Ferme/rouvre - sg.warn.no_internet Connectez-vous → Connecte-toi - sg.warn.no_credentials allez/ajoutez vos → va/ajoute tes - sg.warn.no_manual_dir Allez/configurez/Il vous faudra → Va/configure/Il te faudra All %s/%d placeholders preserved; encoding UTF-8 clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a runtime translation layer so all user-facing strings can be localized without restarting the app. English ships as the reference language; French is bundled as a complete second locale (425 keys).
The default behavior is unchanged for existing installs: with no
.langfiles present orlanguage=en, everyT(key)falls back to the literal — which for legacy strings is the original English text → zero regression.Architecture
workspace/all/common/i18n.{c,h}— minimal i18n corekey=value.langfiles (no JSON dependency)T(key)returns the translation, or the input verbatim when no entry matches (safe fallback)I18N_init(code)called fromGFX_init;I18N_reload(code)hot-swaps the table at runtimeskeleton/SYSTEM/res/lang/{en,fr}.lang(425 paired keys)NextUISettings.language[8]persisted inminuisettings.txt(defaults to\"en\"; backward-compatible read)Coverage
All user-visible strings flow through
T():nextui.cminarch/{ma_menu,ma_frontend_opts,ma_saves,ra_integration}.csettings/*.cpp/hpp): Appearance, Display, Audio, System, FN switch, In-Game, RetroAchievements (incl. sync overlay), About, Bluetooth, WiFi, color picker, keyboard promptcommon/api.cbattery,clock,gametime,ledcontrol,minput,bootlogoHot-reload (Settings only)
`Settings → System → Language` switches locale live without exit:
Other apps (launcher, minarch, tools) load the active language on startup — they are short-lived processes relaunched per action, so per-app hot-reload is unnecessary.
Build
`i18n.c` linked into every app that calls `T()`: nextui, minarch, settings, battery, clock, gametime, ledcontrol, minput, bootlogo (9 makefiles updated). Pure C module; safe to include from C++ via the existing `extern "C"` block in `settings/menu.hpp`.
Resource impact:
Compatibility
.langfiles installed — behavior identical to before this PR.lang` into `.system/res/lang/`; auto-discovered by Settings via `discoverLanguages()`Test plan
.lang` and translate valuesNotes
Happy to split this into smaller PRs (core i18n, launcher, settings, tools…) if that's preferred for review.