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.


Three 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.keyFallback("edit.undo", "Undo") dev-authored text pulled from a runtime list (a registered command/panel title) an explicit stable id, with the runtime value itself as the fallback programmers, in code
Locale.key("dlg.act1.intro") game content (dialogue, item names, quests) an explicit stable id designers, in a .strings asset

All three compile into the same table and are served by the same Locale service — the only difference is where the id comes from, and what a miss falls back to.

// 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. keyFallback/trKeyFallback sit between the two: like key, non-literal ids are fine (the id is a runtime value); like tr, a miss degrades to real text — the caller-supplied fallback — not a bracket marker, since that fallback is always a known-safe English default, not arbitrary designer content.

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).

Translating runtime lists

Some UI text lists runtime/data-driven items with a display string — a registered command, a dockable panel, a discovered plugin — where the string is dev-authored English, but only exists as a runtime value (a struct field) by the time it reaches the draw call. tr() can't take it: its msg parameter must be a comptime literal.

The fix is to key the lookup off the entry's own stable id instead, with the runtime string itself as the fallback:

// Registration site — an ordinary struct literal, nothing extra to write:
.{ .id = "edit.undo", .title = "Undo", .defaults = &.{...} }

// Draw site — translated by id, falling back to the (already-final) English title:
StudioLocale.trKeyFallback(entry.desc.id, entry.desc.title, &.{})

Nothing needs to be kept in sync by hand: turian-cli i18n extract walks struct literals too, harvesting any .id/.title sibling-field pair as an id-keyed catalog entry, .title's literal text becoming the translator-facing English source. This works for any struct following that .id/.title naming convention — editor.shortcuts.CommandDesc and studio/main-window/Panels.zig's PanelDesc today — not just a specific type.

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/trKeyFallback 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.


← All docs Edit this page