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_KeyGenerator → references.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 |
22–63 |
Timestamp |
Milliseconds since the table’s own |
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 |
Bound — |
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 |
Snapshot — |
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 |
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
UIstring tables carry the AddressablesPreloadlabel (UI_enandUI_csinAssets/AddressableAssetsData/AssetGroups/), soLocalizationSettings.InitializationOperationloads 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 forInitializationOperationbefore opening the first project. Preloading is itself asynchronous, so without this wait the startup path —CurrentProjectChangedand 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.
|