Localization

We use com.unity.localization (UnityEngine.Localization). UI Toolkit screens can localize a string in two ways — declaratively in UXML, or imperatively in C# — and the two are not interchangeable for anything involving Smart Format local variables.

Entry IDs are generated, never hand-written

Table entries are keyed by a numeric m_Id, and that ID — not the key string — is what UXML bindings reference (entry="Id(23193857629360128)"). The ID is produced by Unity’s DistributedUIDGenerator, which is stored in the shared table asset itself (m_KeyGeneratorreferences.RefIds, carrying that table’s m_CustomEpoch).

Never invent an ID, and never continue an existing ID sequence by adding 1 to the highest value in the table. Sequential IDs are guaranteed to collide when two branches each add entries, and the collision is silent: Git merges the two additions as one textual conflict, and resolving it the obvious way leaves two different keys sharing an ID, or leaves a UXML binding pointing at another feature’s string.

The generator is a Snowflake-style ID — the layout is a bit-packed timestamp, machine ID and sequence number, which is exactly what makes it collision-free across machines and branches:

Bits Field Meaning

0–11

Sequence number

Per-machine counter, reset each millisecond; wraps at 4095.

12–21

Machine ID

1–1023. In the Editor, derived from the network interface’s physical address, or the KeyGenerator-MachineId EditorPref.

22–63

Timestamp

Milliseconds since the table’s own m_CustomEpoch.

So id = (millisecondsSinceCustomEpoch << 22) | (machineId << 12) | sequence.

Adding entries

Add them through Window → Asset Management → Localization Tables. Unity calls the generator for you, so the ID is correct by construction. This is the only route that needs no care.

If an entry has to be written into the YAML directly (editing the assets outside Unity, or scripted), compute the ID from the formula above using the real current time and the target table’s m_CustomEpoch — never a rounded or made-up timestamp. A hand-written ID is easy to spot after the fact: it decodes to machine ID 0 or to an implausibly round timestamp.

Two things must stay in step whenever an ID is created or changed:

  • Every locale table (UI_en, UI_cs, …) and the shared table (UI Shared Data) must list the same ID. The shared table holds the key, each locale table holds that locale’s string.

  • Every UXML entry="Id(…​)" reference must be updated with it, because bindings resolve by ID rather than by key name — a stale reference silently renders a different string instead of failing.

Static strings: UXML <Bindings>

For text that never changes at runtime (labels, tab titles, static buttons), bind the LocalizedString declaratively in the UXML <Bindings> block of the element:

<ui:Button name="tab-0" text="Description" class="agent-detail__tab">
    <Bindings>
        <UnityEngine.Localization.LocalizedString property="text"
            table="GUID:5ac0837c1f39c42bdb924c9482af5bfa" entry="Id(23193857629360128)"/>
    </Bindings>
</ui:Button>

Unity keeps this in sync with the selected locale automatically — no C# needed.

Dynamic strings with Smart Format variables

Some strings need a runtime value substituted in, e.g. "Talk to {name}". The variable ({name}) must be declared in the UXML <variables> block, not added imperatively from C#:

<pui:SlideActionElement name="slide" class="agent-detail__slide">
    <Bindings>
        <UnityEngine.Localization.LocalizedString property="SlideLabel"
            table="GUID:5ac0837c1f39c42bdb924c9482af5bfa" entry="Id(23194043567050752)">
            <variables>
                <UnityEngine.Localization.LocalVariable name="name">
                    <UnityEngine.Localization.SmartFormat.PersistentVariables.StringVariable/>
                </UnityEngine.Localization.LocalVariable>
            </variables>
        </UnityEngine.Localization.LocalizedString>
    </Bindings>
</pui:SlideActionElement>

In C#, fetch the already-declared variable through the binding’s indexer and just mutate its Value — do not call LocalizedString.Add(…​) to inject a variable that UXML never declared:

var talkToString = _slide.GetBinding(nameof(SlideActionElement.SlideLabel)) as LocalizedString;
_talkToName = talkToString!["name"] as StringVariable;
...
_talkToName.Value = agentInfo.Name;

See AgentDetailUI.uxml / AgentDetailController.cs for the full example.

Strings built in C#

Text the UXML cannot declare — a row built per item, a value chosen at runtime, a message handed to a dialog — is localized from C#, and there are two ways to do it. Which one is right is decided by what the string lands in, not by whether it is parameterised:

The string is Use Because

Written into an element that then stays on screen

BoundStringChanged + RefreshString

It has somewhere to live, so it can retranslate in place with no rebuild.

Consumed as a value — a dialog message, a prompt’s text, a log line, any string parameter

SnapshotGetLocalizedString()

There is no element to bind to. The call needs a string now.

Part of a subtree that is rebuilt from data anyway

Snapshot, plus a rebuild on SelectedLocaleChanged

The rebuild already exists; per-label bindings would be more code for the same result.

Bound labels

A label C# fills and leaves in place is bound, not snapshotted. Subscribe, refresh once, and drop the subscription when the label leaves the panel:

private static void BindLocalizedText(Label label, string key)
{
    var localized = new LocalizedString(LocalizationTable, key);
    LocalizedString.ChangeHandler onTextChanged = text => label.text = text;

    localized.StringChanged += onTextChanged;
    localized.RefreshString();
    label.RegisterCallback<DetachFromPanelEvent>(_ => localized.StringChanged -= onTextChanged);
}

Two properties are what make this the default rather than a preference. The handler fires when the table load lands, so the label is right even when the string was asked for before its table was resident — which is what a snapshot gets wrong, silently, by handing back the locale that happens to be loaded. And it never reaches for the synchronous path that throws on Web.

A bound string still takes runtime values: LocalizedString.Arguments is settable, and RefreshString() is the documented way to re-format after the values inside it change.

A bound label must not also be assigned from C#. The two fight over the property and the binding wins on the next refresh — the same collision as binding a property in UXML and then writing it from code.

PromptPresenter (the issue report’s type rows), SettingsController and the settings drawers all do this. The drawers are the case worth copying for a control rather than a label: they rebind on every option, so an enum picker retranslates its whole option set in place.

Snapshot values

Where the string is consumed rather than displayed, hold a LocalizedString field and read it at the point of use. Only the table name is declared as a const — the entry reference is a one-off, so it is inlined directly into the field:

private const string LocalizationTable = "UI";

private readonly LocalizedString _removeConfirmString = new(LocalizationTable, "projects.remove.confirm");
...
var result = await _dialogController.Show(_removeConfirmString.GetLocalizedString(projectName), ...);

Runtime values go in as positional Smart Format arguments — the entry reads Remove '{0}' from your list? and GetLocalizedString(params object[]) fills them in. Named local variables are reserved for the UXML-bound case above, where the binding survives across locale changes and the variable has somewhere to live.

An entry containing {0} only formats if it is flagged as a smart string. In the Localization Tables window that is the Smart toggle on the entry; in the asset it is a SmartFormatTag in the locale table’s metadata listing the entry’s id. Without it the placeholder renders literally.

Subtrees rebuilt on a locale change

GetLocalizedString() is a snapshot, so text already written into an element keeps the language it was built in until something rebuilds it. For a screen whose content is built in C# from data — cards, insight tiles, anything composed from a value that is re-read anyway — that rebuild is the answer, and it is cheaper than binding every label inside it. Without one, the UXML-bound labels around the built content retranslate on the next locale change and the built content does not, which reads as "this one string isn’t localized".

protected override void Initialize()
{
    ...
    LocalizationSettings.SelectedLocaleChanged += OnLocaleChanged;
}

protected override void OnDestroy()
{
    LocalizationSettings.SelectedLocaleChanged -= OnLocaleChanged;
    base.OnDestroy();
}

HomeController and ProjectsController both do this. Note that the locale is also assigned during startup, so the handler can fire before the screen has any content to rebuild — guard accordingly.

A popup is not exempt from this because it is short-lived. Its own text is read and dismissed, but a label built into it once and then left alone is a bound label like any other — the snapshot there survives long enough to be read in the wrong language when the locale changed while its table was still loading.

WebGL: the synchronous API needs a preloaded table

GetLocalizedString() is synchronous, and the package implements that by falling back to Addressables' WaitForCompletion() whenever the requested table is not already resident (AsyncOperationUtility.SynchronousLoad). WebGL does not support WaitForCompletion — it throws Exception: WebGLPlayer does not support synchronous Addressable loading. Unlike the package’s other synchronous paths, this one is not guarded behind #if !UNITY_WEBGL, so every GetLocalizedString() call site in this repo depends on the table already being loaded. On Android and iOS the same call simply blocks, which is why this only ever surfaces on Web.

Two things keep the table resident, and both have to stay in place:

  • The UI string tables carry the Addressables Preload label (UI_en and UI_cs in Assets/AddressableAssetsData/AssetGroups/), so LocalizationSettings.InitializationOperation loads them during initialization instead of on first use. That label is what the package reads at runtime; in the editor it is the Preload toggle in the Localization Tables window.

  • AppOrchestrator.Start() waits for InitializationOperation before opening the first project. Preloading is itself asynchronous, so without this wait the startup path — CurrentProjectChanged and its card-building handlers — can still get there first.

A locale change needs no extra handling: assigning SelectedLocale releases the running initialization and starts a fresh one, and SelectedLocaleChanged is raised only once that completes, so the new locale’s table is already preloaded by the time OnLocaleChanged rebuilds a screen.

A new string table that C# reads through GetLocalizedString() needs the Preload label too, or it throws on Web the first time it is used. Setting the string database’s Asynchronous Behaviour to ForceSynchronous is not a workaround — it pushes the paths the package does guard for WebGL onto WaitForCompletion as well.

WebGL: the localization bundles must not be cached by the browser

The localization groups are local Addressables groups, so on Web their bundles are ordinary files served from /client/StreamingAssets/aa/WebGL/. Their Bundle Naming Mode is Filename, so a bundle keeps the same URL across every build while its bytes change — meaning the browser HTTP cache is the only thing deciding whether a client sees the new content.

Left to itself the browser caches those files heuristically (no Cache-Control means roughly 10% of the Last-Modified age), serves a stale bundle body against the freshly-fetched catalog.bin, and Addressables rejects the mismatch:

Error while downloading Asset Bundle: CRC Mismatch. Provided bb34f431, calculated 73457596
RemoteProviderException: Unable to load asset bundle from ...localization-string-tables-english(en)_assets_all.bundle

The reported ResponseCode is 200 because a disk-cache hit is reported as one, which makes this look like a server problem rather than a caching one. Localization then fails to initialize at all, since the string tables are preloaded during startup.

ci/deploy/web/nginx.conf therefore serves everything under /client/StreamingAssets/ with Cache-Control: no-cache, which stores the file but revalidates it on every load, so an unchanged bundle costs a 304 instead of a re-download. The player files under Build/ need no such rule — Unity already names them by content hash. Switching the groups to Filename and Hash would make the bundle URLs content-addressed and immune on their own, but catalog.bin and settings.json keep fixed names regardless, so the header rule stays load-bearing either way.

Disabling Use Asset Bundle CRC is not a fix. It silences the error and loads the stale strings instead.
Calling .Add("name", …​) on a LocalizedString retrieved via GetBinding() appears to work at first, but the variable isn’t part of the binding’s UXML-authored state. Unity’s UI Toolkit binding system rebuilds the local-variable list from the UXML declaration whenever it reprocesses a binding — which reliably happens when exiting Play Mode in the Editor — silently dropping any variable that was only added from C#. The next locale-change refresh then fails to resolve {name} and throws an uncaught FormattingException: Could not evaluate the selector "name". Declaring the variable in UXML first avoids this entirely.