ZOA Localization
Runtime string resolution with composed tables, fallback chains, translator exchange, and build-gate validation.
Localization here is a production pipeline. The runtime composes many tables per locale, resolves a key through a fallback chain, and formats plural and select blocks. Around that sit the pieces a shipping game actually needs: content-pack registration for DLC, a CSV codec for translator handoff, pseudolocalization for layout QA, and a validator whose build gate can block a release.
The package carries no Workbench dependency. Everything here is runtime, a Unity binding, a table asset, or a standalone editor API. The producer-facing authoring UI lives in com.zoa.localization.authoring, and the split is enforced: this package must not depend on Workbench, so a game can localize without installing Foundry's authoring surfaces.
The scope has known edges. CSV is the only exchange codec implemented, and the default registry reports XLIFF, PO, and Google Sheets as unsupported. Font ids resolve through a plan, but applying concrete Font or TextMeshPro assets stays project-specific, and right-to-left shaping is not claimed for this version.
Depends on (2)
Depended on by (2)
How it works
Concepts
Keys, locales, and the two normalisations
LocaleId and LocalizationKey are the same shape: readonly structs wrapping a string, both lowercasing on construction and comparing case-insensitively, both throwing on null or whitespace. Both convert implicitly to and from string, so a call site can pass a literal without ceremony while the type still stops a locale being handed where a key belongs.
Keys are hierarchical by convention, dotted paths such as ui.menu.start or weapons.rifle.name. Nothing in the runtime parses the hierarchy, but namespace-prefixed conflict policies and key browsing both lean on the convention.
One locale, many tables, one composition
One locale is assembled from as many tables as register against it. RegisterTable accepts LocalizationTableRegistrationOptions carrying a TableId, a ContentPackId, a version, a Priority, a LocalizationConflictPolicy, and an optional namespace prefix, and the service composes every registration for a locale into one lookup.
The conflict policy decides what happens when two tables claim the same key. Error rejects the duplicate. WarnKeepFirst records a composition issue and keeps the earlier value. WarnOverride records an issue and takes the incoming value. NamespacedOverride overrides only when the key falls inside the configured namespace, which is how a DLC pack overrides its own strings without reaching into the base game's.
GetCompositionIssues surfaces those duplicate-key diagnostics so a tool or a build report can show them, and GetRegisteredTables exposes the registration metadata for validation.
The fallback chain, and what counts as a usable entry
Resolve tries the requested locale first, then walks a chain. If LocaleInfo was registered with an explicit FallbackLocale, that edge is followed. Otherwise the locale's own parent is tried by trimming at the last dash, so fr-CA falls to fr. Every visited locale is recorded, so a circular fallback configuration terminates instead of looping.
The out parameters are the useful part: resolvedLocale says which locale actually answered and wasFallback says whether the requested one did. A tool can distinguish a translated string from a fallback that happens to render.
An entry only counts as usable when it has a non-null, non-empty value and is not flagged as a placeholder. LocalizationTableAsset therefore writes empty values as placeholders, so an untranslated row falls through the chain instead of rendering as blank text.
When nothing in the chain has the key, GetString returns the key itself. A visible key in the UI reads as a bug report; an empty string reads as a mystery.
Message formatting is a documented subset
GetMessage runs an ICU-inspired subset over the resolved template. Named variables are written as a brace-wrapped name. Plural blocks take a count and select among named cases, and select blocks do the same for a discrete value such as gender.
Plural category resolution covers English-style one and other, Japanese other-only, and Russian one, few, many, and other. Broader CLDR coverage and culture-specific date, time, and number formatting are planned, not implemented.
GetStringFormatted is the simpler sibling, using positional string.Format arguments, which is enough for strings that only interpolate values and have no plural or select shape.
Content packs make DLC text a runtime operation
LocalizationContentPack is a manifest: a pack id, a display name, a version, its tables, an optional required DLC id, and any fallback edges the pack introduces. RegisterContentPack registers all of it under a shared conflict policy and priority; UnregisterContentPack removes every table and fallback the pack owned, by id.
Registration after boot is supported and raises TableRegistered, which the UI Toolkit adapter subscribes to. When a campaign's text arrives mid-session, live bindings refresh.
Validation with a real build gate
LocalizationValidator has two entry points. ValidateCoverage compares tables against a reference locale and reports coverage percentages, missing keys per locale, placeholder entries, and orphaned keys that exist in a translation but not in the reference.
ValidateBuildReadiness is the release check. It takes required locales, the set of reachable keys, and switches for approved-translation and character-limit enforcement, and produces a report whose PassesBuildGate is false when any production-blocking issue was found. Treat that as a blocker.
Reachable keys matter because coverage against the whole table over-reports the problem. A key that no screen can reach does not need a translation to ship, so the gate measures against the reachable set.
In the editor
Screens
Screenshot pending
/screenshots/localization-table-asset-inspector.png
The inspector for an en-US table showing the locale code and table name, and several expanded entry rows where the key, value, translator context, notes, character limit, placeholder flag, and review status are all visible on one row.
Screenshot pending
/screenshots/localization-pseudoloc-layout.png
One UI screen captured twice from the same state: first in en-US, then with the qps-ploc pseudo table active so accented, expanded text overflows a button or clips a label, demonstrating what the layout QA pass is for.
Setup
Workflow
- 01
Author tables per locale
Create a LocalizationTableAsset for each locale and fill in its rows. Write a row's translator context and notes at the same time as the row, and set a character limit where the UI constrains the string. Both travel through the CSV handoff to the translator.
- 02
Install the service
Drop ZOALocalizationSceneInstaller on a stable scene root and drag the table assets into its list. Set the content pack id, priority, and conflict policy that describe this set of tables. Multiple installers across scene transitions are safe: one that reuses an existing service does not own it and will not unregister it on destroy.
- 03
Register locale metadata and fallback edges
Call RegisterLocaleInfo for each supported locale so display names, direction, and explicit fallbacks are known. Register an edge such as fr-CA to fr-FR where the parent-locale trim would not reach the locale you actually want.
- 04
Read strings through the service or a LocalizedString
Call GetString for plain text and GetMessage where plural or select blocks are involved. Prefer a serialized LocalizedString on components and definitions, since it carries source text that renders sensibly even before a table exists.
- 05
Bind UI Toolkit once, refresh automatically
Construct a ZoaLocalizationAdapter over your root element with a set of element bindings. It applies them immediately and refreshes on both LocaleChanged and TableRegistered, so a locale switch or a late content-pack registration updates the interface without any per-screen code.
- 06
Hand off to translators and re-import
Build a LocalizationDocument with FromTables, export it through the CSV codec, and send it out. The round trip preserves key, locale, source, target, context, notes, screenshot path, character limit, and review status, so a returning file carries the workflow state the build gate reads.
- 07
Gate the build
Call ValidateBuildReadiness with the required locales and the reachable key set from a build step, and fail the build when PassesBuildGate is false. Run pseudolocalization ahead of that to catch layout breakage before real translations arrive.
Surface
Key types
ILocalizationService
interface
The runtime surface: read strings, switch locale, register tables and content packs, and read composition diagnostics. Registered against FoundryServiceRegistry by the scene installer.
- string GetString(LocalizationKey key)
- string GetString(LocalizationKey key, LocaleId locale)
- string GetStringFormatted(LocalizationKey key, params object[] args)
- string GetMessage(LocalizationKey key, IReadOnlyDictionary<string, object> args)
- LocaleId GetActiveLocale() void SetActiveLocale(LocaleId locale)
- void RegisterTable(LocalizationTable table, LocalizationTableRegistrationOptions options)
- void RegisterLocaleInfo(LocaleInfo localeInfo) void RegisterFallback(LocaleId locale, LocaleId fallbackLocale)
- void RegisterContentPack(LocalizationContentPack contentPack, LocalizationConflictPolicy conflictPolicy, int priority = 0)
- bool UnregisterContentPack(string contentPackId)
- IEnumerable<LocalizationCompositionIssue> GetCompositionIssues()
- event Action<LocaleId> LocaleChanged event Action<LocaleId> TableRegistered
LocalizationService
service
The default implementation, which also implements ILocalizationFallbackResolver. Composes registered tables per locale, resolves through explicit and parent-locale fallbacks with cycle protection, and optionally persists the active locale through a preference provider.
ILocalizationFallbackResolver
interface
Resolution with provenance. Reports the value, which locale actually answered, and whether a fallback was used, so tooling can tell a real translation from a chain hit.
- bool Resolve(LocalizationKey key, LocaleId locale, out string value, out LocaleId resolvedLocale, out bool wasFallback)
LocalizationKey
struct
A case-insensitive hierarchical key, lowercased on construction, with implicit string conversion in both directions. Throws on null or whitespace rather than silently producing an empty key.
- string Value { get; }
LocaleId
struct
A case-insensitive locale code such as en-US or fr-FR, lowercased on construction. The parent-locale fallback trims at the dash.
- string Value { get; }
LocalizationTable
class
A per-locale dictionary of LocalizedEntry. Add writes an entry with its metadata; AddEntry takes a pre-built one. Each entry carries production metadata beyond the string, and both translator handoff and build gating read it.
- LocaleId LocaleId { get; } int Count { get; } IEnumerable<LocalizationKey> Keys { get; }
- void Add(LocalizationKey key, string value, bool isPlaceholder = false, string notes = null, string context = null, LocalizationReviewStatus reviewStatus = LocalizationReviewStatus.New, int? characterLimit = null, string screenshotPath = null)
- bool TryGet(LocalizationKey key, out LocalizedEntry entry)
- IEnumerable<LocalizedEntry> Entries { get; }
LocalizedEntry
struct
One row with its production metadata: value, placeholder flag, notes, translator context, review status, character limit, and a screenshot reference.
- LocalizationKey Key { get; } string Value { get; } bool IsPlaceholder { get; }
- string Notes { get; } string Context { get; }
- LocalizationReviewStatus ReviewStatus { get; } int? CharacterLimit { get; } string ScreenshotPath { get; }
LocalizedString
struct
The serializable reference dropped on MonoBehaviours and ScriptableObjects. Holds a key plus source text and context, and resolves in a fixed order: service translation, then source text, then the key.
- string Key; string SourceText; string Context;
- bool HasKey { get; }
- string Resolve(ILocalizationService service)
- string Resolve(ILocalizationService service, IReadOnlyDictionary<string, object> arguments)
LocalizationTableRegistrationOptions
class
The composition metadata for one registration. An omitted TableId gets a generated GUID and an omitted ContentPackId defaults to "base", so a casual registration still participates correctly.
- string TableId { get; } string ContentPackId { get; } string ContentPackVersion { get; }
- int Priority { get; } LocalizationConflictPolicy ConflictPolicy { get; } string NamespacePrefix { get; }
LocalizationConflictPolicy
enum
How same-locale duplicate keys resolve: Error rejects, WarnKeepFirst keeps the earlier value, WarnOverride takes the incoming one, and NamespacedOverride overrides only inside the configured namespace.
LocalizationContentPack
class
A DLC or campaign manifest: pack id, display name, version, its tables, an optional required DLC id, and the fallback edges it introduces. Registerable after boot and removable by id.
- string PackId { get; } string DisplayName { get; } string Version { get; } string RequiredDlcId { get; }
- IReadOnlyList<LocalizationTable> Tables { get; }
- IReadOnlyDictionary<LocaleId, LocaleId> Fallbacks { get; }
LocaleInfo
class
Metadata for a supported locale: display name, native name, right-to-left flag, and the explicit fallback locale that takes priority over parent-locale trimming.
- LocaleId Id { get; } string DisplayName { get; } string NativeName { get; }
- bool IsRightToLeft { get; } LocaleId? FallbackLocale { get; }
ILocalizationValidator
interface
Coverage analysis and production build gating over a set of tables.
- LocalizationValidationReport ValidateCoverage(IEnumerable<LocalizationTable> tables, LocaleId referenceLocale)
- LocalizationValidationReport ValidateBuildReadiness(IEnumerable<LocalizationTable> tables, LocalizationBuildValidationOptions options)
LocalizationValidationReport
class
The validation result: per-locale coverage percentages, missing keys per locale, placeholder entries, orphaned keys, and the production issues. PassesBuildGate is true only when the issue list is empty.
- IReadOnlyDictionary<LocaleId, float> CoverageByLocale { get; }
- IReadOnlyDictionary<LocaleId, IReadOnlyList<LocalizationKey>> MissingKeysByLocale { get; }
- IReadOnlyList<(LocaleId Locale, LocalizationKey Key)> PlaceholderEntries { get; }
- IReadOnlyList<(LocaleId Locale, LocalizationKey Key)> OrphanedKeys { get; }
- bool PassesBuildGate { get; }
LocalizationBuildValidationOptions
class
What the build gate requires: the reference locale, the locales that must ship, the reachable key set to measure against, and switches for approved translations and character limits.
- LocaleId ReferenceLocale { get; }
- IReadOnlyList<LocaleId> RequiredLocales { get; }
- IReadOnlyList<LocalizationKey> ReachableKeys { get; }
- bool RequireApprovedTranslations { get; } bool EnforceCharacterLimits { get; }
ILocalizationExchangeCodec
interface
Encodes and decodes a LocalizationDocument for translator handoff. LocalizationCsvCodec is the one shipped implementation.
- LocalizationExchangeFormat Format { get; }
- string Export(LocalizationDocument document, LocalizationExportOptions options)
- LocalizationDocument Import(string payload, LocalizationImportOptions options)
LocalizationExchangeCodecRegistry
class
Discovery for exchange formats. CreateDefault returns a registry supporting CSV only; XLIFF, PO, and Google Sheets report as unsupported until a project registers its own codec.
- static LocalizationExchangeCodecRegistry CreateDefault()
- IReadOnlyList<LocalizationExchangeFormat> GetSupportedFormats()
- bool TryGetCodec(LocalizationExchangeFormat format, out ILocalizationExchangeCodec codec)
- ILocalizationExchangeCodec GetRequiredCodec(LocalizationExchangeFormat format)
LocalizationDocument
class
The normalised transport shape sitting between tables and any codec. FromTables pairs a source table against target tables to produce one row per key and target locale.
- LocaleId SourceLocale { get; } string ContentPackId { get; }
- IReadOnlyList<LocalizationDocumentRow> Rows { get; }
- static LocalizationDocument FromTables(LocalizationTable sourceTable, IEnumerable<LocalizationTable> targetTables, string contentPackId = null)
LocalizationPseudolocalizer
class
Generates a pseudo table from a source table so layout QA can start before translations exist. Expands text by a configurable ratio and preserves placeholders and markup.
- LocalizationTable Generate(LocalizationTable source, PseudolocalizationOptions options = null)
- string Transform(string value, PseudolocalizationOptions options = null)
ZoaLocalizationAdapter
class
The UI Toolkit binder. Applies a set of element-name-to-key bindings and refreshes them on LocaleChanged and TableRegistered, so late DLC text updates live UI. Disposable, and unsubscribes on dispose.
- void Refresh()
- void Dispose()
ZoaLocalizedElementBinding
class
One declarative binding: an element name, a key, the target property, and optional message arguments. The target is Text, Tooltip, FieldLabel, or Value.
- string ElementName { get; } LocalizationKey Key { get; }
- ZoaLocalizedBindingTarget Target { get; }
- IReadOnlyDictionary<string, object> Arguments { get; }
ILocalizationPreferenceProvider
interface
Optional persistence bridge for the player's chosen locale. PlayerPrefsLocalizationPreferenceProvider is the shipped implementation; a project with its own profile system implements the two members instead.
- bool TryLoadLocale(out LocaleId locale)
- void SaveLocale(LocaleId locale)
Surface
Authoring assets
LocalizationTableAsset
asset
The authoring asset for one locale's strings. Rows carry the full production metadata, and ToRuntimeTable materialises them, writing empty values as placeholders so the fallback resolver treats an untranslated row as missing rather than blank.
- Tools/ZOA/Localization/Localization Table
- string LocaleCode { get; } string TableName { get; }
- IReadOnlyList<SerializedLocalizationEntry> Entries { get; }
- LocaleId ToLocaleId()
- LocalizationTable ToRuntimeTable()
ZOALocalizationSceneInstaller
component
Scene-root bootstrap. Creates or reuses one ILocalizationService, registers its configured table assets with a shared content-pack id, priority, and conflict policy, optionally installs PlayerPrefs persistence, and applies an initial locale. Execution order -9000.
- ZOA/Localization/Localization Scene Installer
- ILocalizationService Service { get; }
- void EnsureRegistered()
Usage
Examples
using System.Collections.Generic;
using ZOA.Localization.Core;
using ZOA.Messaging;
var loc = FoundryServiceRegistry.Get<ILocalizationService>();
// Plain lookup. Falls back through the chain, then returns the key itself.
string start = loc.GetString("ui.menu.start");
// Named variables plus a plural block.
string files = loc.GetMessage("ui.inventory.selected", new Dictionary<string, object>
{
["name"] = playerName,
["count"] = selectedCount,
});
// Positional formatting for templates with no plural or select shape.
string version = loc.GetStringFormatted("ui.about.version", buildNumber);using ZOA.Localization.Core;
// The base game's UI strings.
service.RegisterTable(uiTable, new LocalizationTableRegistrationOptions(
tableId: "base:en-us:ui",
contentPackId: "base",
priority: 0,
conflictPolicy: LocalizationConflictPolicy.Error));
// A campaign pack that may only override inside its own namespace.
service.RegisterTable(campaignTable, new LocalizationTableRegistrationOptions(
tableId: "helios:en-us:act2",
contentPackId: "helios",
priority: 10,
conflictPolicy: LocalizationConflictPolicy.NamespacedOverride,
namespacePrefix: "helios"));
foreach (var issue in service.GetCompositionIssues())
{
// Duplicate-key diagnostics, surfaced rather than swallowed.
}using System.Collections.Generic;
using ZOA.Localization.Core;
var pack = new LocalizationContentPack(
packId: "helios.act2",
displayName: "Helios: Act II",
version: "1.1.0",
tables: new[] { act2English, act2French },
requiredDlcId: "dlc.helios.act2",
fallbacks: new Dictionary<LocaleId, LocaleId>
{
[new LocaleId("fr-CA")] = new LocaleId("fr-FR"),
});
// Registration after boot raises TableRegistered, so live bindings refresh.
service.RegisterContentPack(pack, LocalizationConflictPolicy.WarnKeepFirst, priority: 20);
// Uninstalling the DLC removes every table and fallback the pack owned.
service.UnregisterContentPack("helios.act2");using ZOA.Localization.Core;
using ZOA.Localization.Unity.Bindings;
var adapter = new ZoaLocalizationAdapter(
service,
rootVisualElement,
new[]
{
new ZoaLocalizedElementBinding("start-button", "ui.menu.start"),
new ZoaLocalizedElementBinding("start-button", "ui.menu.start.tooltip",
ZoaLocalizedBindingTarget.Tooltip),
new ZoaLocalizedElementBinding("volume-slider", "ui.settings.volume",
ZoaLocalizedBindingTarget.FieldLabel),
});
// On teardown:
adapter.Dispose();using ZOA.Localization.Core;
// Export: one row per key and target locale, carrying the workflow metadata.
var document = LocalizationDocument.FromTables(englishTable, new[] { frenchTable, germanTable });
var codec = LocalizationExchangeCodecRegistry.CreateDefault()
.GetRequiredCodec(LocalizationExchangeFormat.Csv);
string csv = codec.Export(document, exportOptions);
// Gate: fail the build when a required locale is short on reachable keys.
var validator = new LocalizationValidator();
var report = validator.ValidateBuildReadiness(
new[] { englishTable, frenchTable, germanTable },
new LocalizationBuildValidationOptions(
referenceLocale: DefaultLocales.English,
requiredLocales: new[] { DefaultLocales.French, DefaultLocales.German },
reachableKeys: reachableKeys,
requireApprovedTranslations: true));
if (!report.PassesBuildGate)
{
// Treat as a release blocker, not a warning.
}using ZOA.Localization.Core;
var pseudo = new LocalizationPseudolocalizer().Generate(
englishTable,
new PseudolocalizationOptions(
mode: PseudolocalizationMode.AccentedLatin,
expansionRatio: 0.35f,
preservePlaceholders: true,
preserveMarkup: true));
// Default pseudo locale is qps-ploc. Register it like any other table
// and switch to it to see where the layout breaks under expansion.
service.RegisterTable(pseudo);
service.SetActiveLocale(new LocaleId("qps-ploc"));Tooling
Editor tools
LocalizationHardcodedStringExtractor
Standalone editor API that scans C# and UXML sources for user-facing string literals and returns candidates with a proposed key, the source text, the file and line, and the detector that found it. Suppress an intentional literal with a ZOA_LOCALIZATION_IGNORE comment token.
LocalizationKeyInventoryBuilder
Builds a project-wide inventory from every LocalizationTableAsset, producing one row per key and locale with its value, table name, asset path, review status, and placeholder and duplicate flags. The inventory answers Coverage and MissingRows for a reference and target locale pair.
LocalizationTableAssetEmitter
Assets/ZOA/Generated/Localization
Writes a LocalizationTableAsset from a draft or from an imported LocalizationDocument, which is the path a returning translator file takes back into the project.
LocalizedString property drawer
Inspector drawer for the serialized LocalizedString struct, so a key, its source text, and its context are editable inline on any component or definition that carries one.
Read this
Notes and caveats
See also