Localization
Turian's localization system (ADR 0011) is shared by the engine, the shipped game, and Studio itself — one runtime, one authoring format, one CLI. This page covers the workflow: author → extract → translate → compile → ship.
Two ways to reach a string
| Entry point | For | Key | Authored by |
|---|---|---|---|
tr("Open Scene…") |
UI chrome (menus, buttons, HUD labels) | the source string itself | programmers, in code |
Locale.key("dlg.act1.intro") |
game content (dialogue, item names, quests) | an explicit stable id | designers, in a .strings asset |
Both compile into the same table and are served by the same Locale service — the only difference is where the id comes from.
// Simple text
frame.tr("Open Scene…")
// Context-disambiguated (two identical English strings, different meaning)
frame.trc("door", "Open")
// Plural (CLDR cardinal rule picks the right form for the active language)
frame.trn("# file", "# files", count, &.{})
// Interpolated (ICU-subset named placeholders, not std.fmt specifiers)
frame.trArgs("Save changes to \"{title}\" before closing?", &.{
.{ .name = "title", .value = .{ .text = doc_title } },
})
// Designer-authored game content, looked up by id
frame.trKey("dlg.act1.intro", &.{})
tr/trc/trn take their message as a comptime parameter — a non-literal argument is a compile error, not a review comment. A missing translation degrades to the English source, never to a raw key. key/trKey degrade to ⟦the.key⟧ in debug builds and the bare key in release, so a missing designer-content translation is never silently invisible.
Message syntax is a documented subset of ICU MessageFormat: {name}, {count, plural, one {# file} other {# files}} (with an optional =0 {...} exact-value branch), and {gender, select, male {He} other {They}}.
Registering the service
var locale = engine.Locale.init("en"); // default_locale
locale.loadTable(embedded_pt_br_strtab) catch {};
frame.services.register(engine.Locale, &locale);
Switch languages at runtime — no scene reload needed, since UI is re-rendered every frame:
locale.setLocale("pt-BR");
Loaded tables are cached for the session and never freed on switch, so any string slice already handed out stays valid (it just shows the old locale until re-fetched).
Authoring translations
A .strings file is the source of truth — JSON, one file per locale:
{
"version": 1,
"locale": "pt-BR",
"units": [
{ "id": "Open Scene…", "source": "Open Scene…", "target": "Abrir Cena…", "state": "translated" }
]
}
Extract → translate → compile
# Walk a source tree for tr/trc/trn/trKey call sites, writing (or updating,
# preserving existing translations) a .strings file:
turian-cli i18n extract <src-dir> pt-BR my-project/i18n/pt-BR.strings.json
# Fill in "target" for each unit, then bake to the compiled runtime format:
turian-cli i18n compile my-project/i18n/pt-BR.strings.json my-project/i18n/pt-BR.strtab
extract is safe to re-run any time: matching ids keep their existing target; new call sites are added with state: "new". compile only emits units with a non-empty target — an untranslated string simply isn't in the table, and falls back to the English source at runtime, so partial translation coverage is never a hard error.
Translating Studio itself
Studio's own UI uses the identical mechanism, through studio/services/StudioLocale.zig (a thin wrapper exposing tr/trc/trn/trArgs backed by one Locale instance). Studio isn't a project, so its catalogs are @embedFiled rather than going through the asset pipeline: studio/i18n/<locale>.strings.json (source) and studio/i18n/<locale>.strtab (compiled, embedded).
To update Studio's pt-BR catalog after touching UI code:
turian-cli i18n extract studio pt-BR studio/i18n/pt-BR.strings.json
# translate any new units (state: "new") in the .strings.json
turian-cli i18n compile studio/i18n/pt-BR.strings.json studio/i18n/pt-BR.strtab
The language picker lives in Settings → UI → Language; switching takes effect immediately (Studio persists the choice to editor.ui.language).
Plurals
Plural category is resolved from CLDR cardinal rules for the active language — one/few/many/other (and zero/two for languages like Arabic) — not hand-written per-language conditionals. trn/{n, plural, ...} cover the common case; an explicit =0 {...}-style branch takes priority over the category when present.
Out of scope
RTL layout mirroring, BiDi shaping, and machine translation are not part of this system. A direction field is reserved on locale metadata for future RTL support.