Skip to content

Localisation scaffold

English only today; this is the plumbing so strings can be translated later and so the number of hardcoded strings can only go down.

ui/l10n/en.po is the source table (msgid = key, msgstr = English). Chosen over CSV because a .po is read directly by ResourceLoader (no .import sidecar and no generated .translation files for the headless gate, the tvOS symlinked tree or the export to chase), it diffs per entry, and it supports msgctxt and plurals that vendor tooling expects. It lives under ui/ so platform/tvos/ already sees it through its ui symlink. It is registered in [internationalization] locale/translations of BOTH project.godot and platform/tvos/project.godot (comments in the latter must use ;). Fallback locale stays Godot’s default en. A new language is a new ui/l10n/<locale>.po plus one more entry in that setting in both files.

  1. Add msgid "area.name" / msgstr "Text" to ui/l10n/en.po. Keys are lowercase area.name (file or panel first). Decoy drone text says “Decoy”, never “Sentinel”.
  2. In code: label.text = L10n.t("area.name") (ui/l10n.gd). Unknown keys render as the key itself.
  3. tests/test_l10n.gd fails if any L10n.t("...") in main.gd/ui/ is missing from en.po.

Worked example: ui/tutorial_skip_chip.gd (label_text), with a test that the rendered text is unchanged. Dynamic text ("%d / %d"): key the format string, then L10n.t("k") % [a, b].

tests/test_ui_string_ratchet.gd scans main.gd + ui/**/*.gd with UiStringScan (tools/l10n/ui_string_scan.gd) and compares per-file counts to tests/ui_string_allowlist.json. Counts may only go down: over = new hardcoded string; under = rerun godot --headless --path . -s res://tools/l10n/write_string_baseline.gd (it refuses to raise a count).

Rule: a non-comment line counts once if any of these hit (and the literal still has a letter after removing %d-style specs, {placeholders} and \n/\t):

  1. it assigns a literal to text, text +=, tooltip_text, placeholder_text, title, or calls set_text("...");
  2. it passes a literal as the first argument of a display helper (UiStringScan.HELPERS: _make_btn, _button, _hint_label, _lbl, _label, set_status, _toast, _row_btn, …). Add a helper name when you add a helper;
  3. it holds a sentence-case multi-word literal ("Quit to menu").

Skipped: comment lines and trailing comments, lines starting print/push_error/push_warning/assert/func, lines calling load/preload/get_node/connect/add_to_group/… (paths, signal and group names), and any line with # l10n-ok: <reason> (debug-only text). The Dev-only debug_settings_panel.gd is still counted.

Allowlist upkeep: the writer refuses to raise the TOTAL (a file rename keeps the total, so it needs no flag); the test fails on stale entries (an allowlist file that no longer exists) and on any per-file rise or drop.

Blind spots (the count under-reports or over-reports): text built from non-literals; single-word or lowercase/ALL-CAPS literals passed to unlisted helpers ("SHOP", "Retry"); text in dictionaries/arrays that is not sentence-case; multi-line string arrays only count if a line has a sentence-case literal; .tscn/.tres text; lines skipped by the connect(/load( filter that also assign text; and false positives where a sentence-case literal is not displayed (a log tag, a dict key such as "Max HP" for the console). Use # l10n-ok: for the latter. Converting these is still desirable; they just are not fully gated.

Keys are plain String literals, not &"...": tests/test_token_keys_exist.gd treats every dotted &"a.b" literal in ui/ as a UI-token key and would fail on them.