From 2e93e6b5eda9a29bd4a9c9920e8dfbd61eff8e8b Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:26:16 +0300 Subject: [PATCH 01/14] polish: bulk table reads --- UnityPackage/Core/Tables/ByteReader.cs | 65 +++++++++++++++++++++-- UnityPackage/Core/Tables/ByteWriter.cs | 6 +++ UnityPackage/Core/Tables/CompiledTable.cs | 31 ++++------- 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/UnityPackage/Core/Tables/ByteReader.cs b/UnityPackage/Core/Tables/ByteReader.cs index 9c496b8..ea73ac7 100644 --- a/UnityPackage/Core/Tables/ByteReader.cs +++ b/UnityPackage/Core/Tables/ByteReader.cs @@ -1,5 +1,6 @@ using System; using System.Buffers.Binary; +using System.Runtime.InteropServices; namespace reromanlee.ReactiveLocalizer.Tables { @@ -95,15 +96,57 @@ public bool TryReadCount(int bytesPerItem, out int count) /// Reads .Length UTF-16 code units, low byte first. public bool TryReadCharacters(Span target) { - if ((long)target.Length * 2 > Remaining) + if (!TryTake(target.Length, 2, out ReadOnlySpan source)) { return false; } + if (BitConverter.IsLittleEndian) + { + MemoryMarshal.Cast(source).CopyTo(target); + return true; + } + for (int i = 0; i < target.Length; i++) + { + target[i] = (char)BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(i * 2, 2)); + } + return true; + } + + /// Reads .Length 32-bit integers. + public bool TryReadInt32s(Span target) + { + if (!TryTake(target.Length, 4, out ReadOnlySpan source)) + { + return false; + } + if (BitConverter.IsLittleEndian) + { + MemoryMarshal.Cast(source).CopyTo(target); + return true; + } + for (int i = 0; i < target.Length; i++) + { + target[i] = BinaryPrimitives.ReadInt32LittleEndian(source.Slice(i * 4, 4)); + } + return true; + } + + /// Reads .Length unsigned 64-bit integers. + public bool TryReadUInt64s(Span target) + { + if (!TryTake(target.Length, 8, out ReadOnlySpan source)) + { + return false; + } + if (BitConverter.IsLittleEndian) + { + MemoryMarshal.Cast(source).CopyTo(target); + return true; + } for (int i = 0; i < target.Length; i++) { - target[i] = (char)BinaryPrimitives.ReadUInt16LittleEndian(_data.Slice(_position + i * 2, 2)); + target[i] = BinaryPrimitives.ReadUInt64LittleEndian(source.Slice(i * 8, 8)); } - _position += target.Length * 2; return true; } @@ -129,5 +172,21 @@ public bool TryReadString(out string value) value = new string(characters); return true; } + + /// + /// Takes the bytes of values of bytes each. Arrays are + /// copied as they are where the platform is little-endian like the data, which is every platform Unity targets. + /// + private bool TryTake(int count, int bytesPerValue, out ReadOnlySpan bytes) + { + if ((long)count * bytesPerValue > Remaining) + { + bytes = default; + return false; + } + bytes = _data.Slice(_position, count * bytesPerValue); + _position += bytes.Length; + return true; + } } } diff --git a/UnityPackage/Core/Tables/ByteWriter.cs b/UnityPackage/Core/Tables/ByteWriter.cs index a80484a..e5f1a9b 100644 --- a/UnityPackage/Core/Tables/ByteWriter.cs +++ b/UnityPackage/Core/Tables/ByteWriter.cs @@ -1,5 +1,6 @@ using System; using System.Buffers.Binary; +using System.Runtime.InteropServices; namespace reromanlee.ReactiveLocalizer.Tables { @@ -48,6 +49,11 @@ public void WriteByte(byte value) public void WriteCharacters(ReadOnlySpan characters) { Span target = Reserve(characters.Length * 2); + if (BitConverter.IsLittleEndian) + { + MemoryMarshal.AsBytes(characters).CopyTo(target); + return; + } for (int i = 0; i < characters.Length; i++) { BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(i * 2, 2), characters[i]); diff --git a/UnityPackage/Core/Tables/CompiledTable.cs b/UnityPackage/Core/Tables/CompiledTable.cs index c1bf9c4..7a924f4 100644 --- a/UnityPackage/Core/Tables/CompiledTable.cs +++ b/UnityPackage/Core/Tables/CompiledTable.cs @@ -177,11 +177,13 @@ public static bool TryRead(ReadOnlySpan data, out CompiledTable table, out ulong[] hashes = new ulong[entryCount]; int[] starts = new int[entryCount]; int[] lengths = new int[entryCount]; - for (int i = 0; i < entryCount; i++) + reader.TryReadUInt64s(hashes); + reader.TryReadInt32s(starts); + reader.TryReadInt32s(lengths); + for (int i = 1; i < entryCount; i++) { - reader.TryReadUInt64(out hashes[i]); // Binary search needs strictly ascending hashes; a file that breaks this would find wrong entries. - if (i > 0 && hashes[i] <= hashes[i - 1]) + if (hashes[i] <= hashes[i - 1]) { error = "The table's key hashes are out of order."; return false; @@ -189,11 +191,6 @@ public static bool TryRead(ReadOnlySpan data, out CompiledTable table, out } for (int i = 0; i < entryCount; i++) { - reader.TryReadInt32(out starts[i]); - } - for (int i = 0; i < entryCount; i++) - { - reader.TryReadInt32(out lengths[i]); bool isValid = lengths[i] < 0 ? ~lengths[i] < messageCount : starts[i] >= 0 && (long)starts[i] + lengths[i] <= characterCount; @@ -207,10 +204,11 @@ public static bool TryRead(ReadOnlySpan data, out CompiledTable table, out // Aliases: sorted hashes, then the entry each one points to. ulong[] aliasHashes = aliasCount == 0 ? Array.Empty() : new ulong[aliasCount]; int[] aliasTargets = aliasCount == 0 ? Array.Empty() : new int[aliasCount]; - for (int i = 0; i < aliasCount; i++) + reader.TryReadUInt64s(aliasHashes); + reader.TryReadInt32s(aliasTargets); + for (int i = 1; i < aliasCount; i++) { - reader.TryReadUInt64(out aliasHashes[i]); - if (i > 0 && aliasHashes[i] <= aliasHashes[i - 1]) + if (aliasHashes[i] <= aliasHashes[i - 1]) { error = "The table's aliases are damaged."; return false; @@ -218,7 +216,6 @@ public static bool TryRead(ReadOnlySpan data, out CompiledTable table, out } for (int i = 0; i < aliasCount; i++) { - reader.TryReadInt32(out aliasTargets[i]); if ((uint)aliasTargets[i] >= (uint)entryCount) { error = "An alias of the table points outside it."; @@ -228,15 +225,9 @@ public static bool TryRead(ReadOnlySpan data, out CompiledTable table, out // Messages: where each one starts, then every message's operations. int[] messageStarts = messageCount == 0 ? Array.Empty() : new int[messageCount]; - for (int i = 0; i < messageCount; i++) - { - reader.TryReadInt32(out messageStarts[i]); - } + reader.TryReadInt32s(messageStarts); int[] program = programLength == 0 ? Array.Empty() : new int[programLength]; - for (int i = 0; i < programLength; i++) - { - reader.TryReadInt32(out program[i]); - } + reader.TryReadInt32s(program); // Texts. char[] characters = characterCount == 0 ? Array.Empty() : new char[characterCount]; From 110e69d876b61615f1fff949595ac09e48f7733d Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:26:16 +0300 Subject: [PATCH 02/14] create: performance tests --- UnityPackage/Tests/Core/AssemblyInfo.cs | 3 + UnityPackage/Tests/Core/AssemblyInfo.cs.meta | 11 + UnityPackage/Tests/Performance.meta | 8 + .../Tests/Performance/PerformanceTests.cs | 352 ++++++++++++++++++ .../Performance/PerformanceTests.cs.meta | 11 + ...ReactiveLocalizer.Tests.Performance.asmdef | 33 ++ ...iveLocalizer.Tests.Performance.asmdef.meta | 7 + 7 files changed, 425 insertions(+) create mode 100644 UnityPackage/Tests/Core/AssemblyInfo.cs create mode 100644 UnityPackage/Tests/Core/AssemblyInfo.cs.meta create mode 100644 UnityPackage/Tests/Performance.meta create mode 100644 UnityPackage/Tests/Performance/PerformanceTests.cs create mode 100644 UnityPackage/Tests/Performance/PerformanceTests.cs.meta create mode 100644 UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef create mode 100644 UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef.meta diff --git a/UnityPackage/Tests/Core/AssemblyInfo.cs b/UnityPackage/Tests/Core/AssemblyInfo.cs new file mode 100644 index 0000000..8582fa5 --- /dev/null +++ b/UnityPackage/Tests/Core/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("reromanlee.ReactiveLocalizer.Tests.Performance")] diff --git a/UnityPackage/Tests/Core/AssemblyInfo.cs.meta b/UnityPackage/Tests/Core/AssemblyInfo.cs.meta new file mode 100644 index 0000000..b2d415b --- /dev/null +++ b/UnityPackage/Tests/Core/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2da799b695f14d8e882689561887c69b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Performance.meta b/UnityPackage/Tests/Performance.meta new file mode 100644 index 0000000..88bbbdd --- /dev/null +++ b/UnityPackage/Tests/Performance.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7e9bec9521ba45eda9b7bb52a41d6bcd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Performance/PerformanceTests.cs b/UnityPackage/Tests/Performance/PerformanceTests.cs new file mode 100644 index 0000000..8e388f3 --- /dev/null +++ b/UnityPackage/Tests/Performance/PerformanceTests.cs @@ -0,0 +1,352 @@ +using NUnit.Framework; +using reromanlee.ReactiveLocalizer.Documents; +using reromanlee.ReactiveLocalizer.Hosting; +using reromanlee.ReactiveLocalizer.Tables; +using System; +using System.Collections.Generic; +using System.Text; +using Unity.PerformanceTesting; + +namespace reromanlee.ReactiveLocalizer.Tests +{ + /// + /// Times what localization costs a game, from cached lookups to switching the language of 1,000 bound texts and + /// loading a 50,000-entry table, and counts the allocations of each. The Performance Testing package records the + /// results; nothing here asserts a speed, since that depends on the machine. + /// + public class PerformanceTests + { + private const string CatalogText = + "@source English\n" + + "[English]\nCulture = en\n" + + "[Russian]\nCulture = ru\n"; + private const string InterfaceTable = "Interface"; + private const int EntryCount = 1000; + private const int CallCount = 10000; + private const int DialogueLineCount = 50000; + private const int MeasurementCount = 10; + + private static readonly TableKey Dialogue = new("Dialogue"); + private static readonly EntryKey Coins = new("Shop", "Coins"); + private static readonly LanguageKey English = new("English"); + private static readonly LanguageKey Russian = new("Russian"); + + private readonly EntryKey[] _keys = new EntryKey[EntryCount]; + private readonly string[] _entryNames = new string[EntryCount]; + private readonly char[] _buffer = new char[256]; + private MemoryTableSource _source; + private string _dialogueText; + private PerformanceHost _host; + private Localizer _localizer; + private int _count; + + [OneTimeSetUp] + public void CreateTables() + { + StringBuilder english = new(); + StringBuilder russian = new(); + for (int i = 0; i < EntryCount; i++) + { + _entryNames[i] = "Entry" + i; + _keys[i] = new EntryKey(InterfaceTable, _entryNames[i]); + english.Append(_entryNames[i]).Append(" = Interface text number ").Append(i).Append('\n'); + russian.Append(_entryNames[i]).Append(" = Tekst interfeisa nomer ").Append(i).Append('\n'); + } + StringBuilder dialogue = new(); + for (int i = 0; i < DialogueLineCount; i++) + { + dialogue.Append("Line").Append(i).Append(" = A line of dialogue about as long as most lines are, number ").Append(i).Append(".\n"); + } + _dialogueText = dialogue.ToString(); + _source = MemoryTableSource.Imported("Localization", CatalogText, + new[] { TableLoading.Preload, TableLoading.Preload, TableLoading.OnDemand }, + (InterfaceTable, "English", english.ToString()), + ("Shop", "English", "Coins = You have {coins, plural, one {# coin} other {# coins}}."), + ("Dialogue", "English", _dialogueText), + (InterfaceTable, "Russian", russian.ToString()), + ("Shop", "Russian", "Coins = U vas {coins, plural, one {# moneta} few {# monety} many {# monet} other {# monety}}.")); + } + + [SetUp] + public void CreateLocalizer() + { + _host = new PerformanceHost(_source); + _localizer = new Localizer(_source.CatalogKey, _host); + _localizer.InitializeAsync(); + _count = 0; + } + + [TearDown] + public void DisposeLocalizer() + { + _localizer.Dispose(); + // A missing key or a failed load would mean timing the wrong path. + Assert.That(_host.Reports, Is.Empty); + } + + [Test, Performance] + public void GetText() + { + MeasureCalls(() => + { + for (int i = 0; i < CallCount; i++) + { + _localizer.Get(_keys[i % EntryCount]); + } + }); + } + + [Test, Performance] + public void GetTextByName() + { + MeasureCalls(() => + { + for (int i = 0; i < CallCount; i++) + { + _localizer.Get(InterfaceTable, _entryNames[i % EntryCount]); + } + }); + } + + [Test, Performance] + public void FormatMessageIntoBuffer() + { + MeasureCalls(() => + { + for (int i = 0; i < CallCount; i++) + { + _localizer.TryFormat(CoinsMessage(i), _buffer, out _); + } + }); + } + + [Test, Performance] + public void FormatMessageToString() + { + MeasureCalls(() => + { + for (int i = 0; i < CallCount; i++) + { + _localizer.Get(CoinsMessage(i)); + } + }); + } + + [Test, Performance] + public void SwitchLanguageOf1000Bindings() + { + Label[] labels = CreateLabels(); + TextBinding[] bindings = new TextBinding[EntryCount]; + for (int i = 0; i < EntryCount; i++) + { + bindings[i] = _localizer.Bind(_keys[i], labels[i], static (label, text) => label.Text = text); + } + + Measure.Method(() => + { + _localizer.SetLanguageAsync(++_count % 2 == 1 ? Russian : English); + _host.RunUntilIdle(); + }) + .WarmupCount(2) + .MeasurementCount(MeasurementCount) + .SampleGroup(new SampleGroup("Switch", SampleUnit.Millisecond)) + .GC() + .Run(); + + _localizer.SetLanguageAsync(Russian); + _host.RunUntilIdle(); + Assert.That(labels[EntryCount - 1].Text, Is.EqualTo("Tekst interfeisa nomer 999")); + DisposeAll(bindings); + } + + [Test, Performance] + public void UpdateMessagesOf1000Bindings() + { + Label[] labels = CreateLabels(); + TextBinding[] bindings = new TextBinding[EntryCount]; + for (int i = 0; i < EntryCount; i++) + { + bindings[i] = _localizer.Bind(CoinsMessage(0), labels[i], static (label, text) => label.Text = text); + } + + MeasureMessageUpdates(bindings); + + Assert.That(labels[0].Text, Is.EqualTo($"You have {_count} coins.")); + DisposeAll(bindings); + } + + [Test, Performance] + public void UpdateMessagesOf1000CharacterBindings() + { + Label[] labels = CreateLabels(); + TextBinding[] bindings = new TextBinding[EntryCount]; + for (int i = 0; i < EntryCount; i++) + { + bindings[i] = _localizer.BindCharacters(CoinsMessage(0), labels[i], static (label, characters) => label.Length = characters.Length); + } + + MeasureMessageUpdates(bindings); + + Assert.That(labels[0].Length, Is.EqualTo($"You have {_count} coins.".Length)); + DisposeAll(bindings); + } + + [Test, Performance] + public void Load50000EntryTable() + { + TableHandle handle = default; + int requestCount = 0; + + Measure.Method(() => + { + handle = _localizer.HoldTable(Dialogue); + _host.RunUntilIdle(); + }) + .SetUp(() => requestCount = _source.RequestCount) + .CleanUp(() => + { + // Every measurement loads the table again, rather than finding it still loaded. + Assert.That(handle.IsLoaded, Is.True); + Assert.That(_source.RequestCount, Is.EqualTo(requestCount + 1)); + handle.Dispose(); + _host.RunUntilIdle(); + }) + .WarmupCount(1) + .MeasurementCount(MeasurementCount) + .SampleGroup(new SampleGroup("Load", SampleUnit.Millisecond)) + .GC() + .Run(); + } + + [Test, Performance] + public void MemoryOf50000EntryTable() + { + long before = GC.GetTotalMemory(true); + TableHandle handle = _localizer.HoldTable(Dialogue); + _host.RunUntilIdle(); + long loaded = GC.GetTotalMemory(true); + for (int i = 0; i < DialogueLineCount; i++) + { + _localizer.Get(Dialogue.Name, "Line" + i); + } + long read = GC.GetTotalMemory(true); + + Assert.That(handle.IsLoaded, Is.True); + Measure.Custom(new SampleGroup("Loaded", SampleUnit.Megabyte), (loaded - before) / 1048576d); + Measure.Custom(new SampleGroup("Every line read", SampleUnit.Megabyte), (read - before) / 1048576d); + handle.Dispose(); + } + + [Test, Performance] + public void Compile50000EntryTable() + { + List issues = new(); + byte[] compiled = null; + + Measure.Method(() => compiled = TableCompiler.Compile(_source.CatalogKey, Dialogue, English, TableDocument.Parse(_dialogueText), issues)) + .WarmupCount(1) + .MeasurementCount(MeasurementCount) + .SampleGroup(new SampleGroup("Compile", SampleUnit.Millisecond)) + .GC() + .Run(); + + Assert.That(issues, Is.Empty); + Measure.Custom(new SampleGroup("Compiled size", SampleUnit.Megabyte), compiled.Length / 1048576d); + } + + private static EntryMessage CoinsMessage(int count) => new(Coins, new MessageArgument("coins", count)); + + private static Label[] CreateLabels() + { + Label[] labels = new Label[EntryCount]; + for (int i = 0; i < EntryCount; i++) + { + labels[i] = new Label(); + } + return labels; + } + + private static void DisposeAll(TextBinding[] bindings) + { + for (int i = 0; i < bindings.Length; i++) + { + bindings[i].Dispose(); + } + } + + /// Times , which makes 10,000 calls, and counts its allocations. + private static void MeasureCalls(Action calls) + { + Measure.Method(calls) + .WarmupCount(1) + .MeasurementCount(MeasurementCount) + .SampleGroup(new SampleGroup("10000 calls", SampleUnit.Microsecond)) + .GC() + .Run(); + } + + /// Times giving every binding a message with a new count, so each one formats and shows it. + private void MeasureMessageUpdates(TextBinding[] bindings) + { + Measure.Method(() => + { + _count++; + for (int i = 0; i < bindings.Length; i++) + { + bindings[i].SetMessage(CoinsMessage(_count)); + } + _host.RunUntilIdle(); + }) + .WarmupCount(1) + .MeasurementCount(MeasurementCount) + .SampleGroup(new SampleGroup("Update", SampleUnit.Millisecond)) + .GC() + .Run(); + } + + private sealed class Label + { + public string Text; + public int Length; + } + + /// A host on the test's thread whose updates run when the test runs them, without allocating. + private sealed class PerformanceHost : ILocalizerHost + { + private readonly ITableSource[] _sources; + private List _scheduled = new(); + private List _running = new(); + + public PerformanceHost(params ITableSource[] sources) + { + _sources = sources; + } + + public List Reports { get; } = new(); + + public IReadOnlyList TableSources => _sources; + + public bool IsHostThread => true; + + public void ScheduleUpdate(Action update) => _scheduled.Add(update); + + public void Report(in LocalizerReport report) => Reports.Add(report); + + public bool IsDestroyed(object target) => false; + + /// Runs the scheduled updates, and the ones they schedule, until none is left. + public void RunUntilIdle() + { + for (int round = 0; round < 16 && _scheduled.Count > 0; round++) + { + (_scheduled, _running) = (_running, _scheduled); + for (int i = 0; i < _running.Count; i++) + { + _running[i](); + } + _running.Clear(); + } + } + } + } +} diff --git a/UnityPackage/Tests/Performance/PerformanceTests.cs.meta b/UnityPackage/Tests/Performance/PerformanceTests.cs.meta new file mode 100644 index 0000000..1053c0d --- /dev/null +++ b/UnityPackage/Tests/Performance/PerformanceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d2642a16a794de79b808e360bd965d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef b/UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef new file mode 100644 index 0000000..6f26764 --- /dev/null +++ b/UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef @@ -0,0 +1,33 @@ +{ + "name": "reromanlee.ReactiveLocalizer.Tests.Performance", + "rootNamespace": "reromanlee.ReactiveLocalizer.Tests", + "references": [ + "reromanlee.ReactiveLocalizer", + "reromanlee.ReactiveLocalizer.Tests.Core", + "Unity.PerformanceTesting", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS", + "REACTIVE_LOCALIZER_PERFORMANCE_TESTING" + ], + "versionDefines": [ + { + "name": "com.unity.test-framework.performance", + "expression": "3.0.0", + "define": "REACTIVE_LOCALIZER_PERFORMANCE_TESTING" + } + ], + "noEngineReferences": false +} diff --git a/UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef.meta b/UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef.meta new file mode 100644 index 0000000..ee6cd8a --- /dev/null +++ b/UnityPackage/Tests/Performance/reromanlee.ReactiveLocalizer.Tests.Performance.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 88992168b75444dd9aebe225141a00e0 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 7c77610b2642bae164e2401a77bbbfdad62b603b Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:26:16 +0300 Subject: [PATCH 03/14] feature: agent guide --- UnityPackage/Authoring/AgentGuide.cs | 124 ++++++++++++++++++ UnityPackage/Authoring/AgentGuide.cs.meta | 11 ++ UnityPackage/Authoring/AgentGuideCatalog.cs | 34 +++++ .../Authoring/AgentGuideCatalog.cs.meta | 11 ++ UnityPackage/Editor/AgentGuideMenu.cs | 105 +++++++++++++++ UnityPackage/Editor/AgentGuideMenu.cs.meta | 11 ++ .../Tests/Authoring/AgentGuideTests.cs | 77 +++++++++++ .../Tests/Authoring/AgentGuideTests.cs.meta | 11 ++ 8 files changed, 384 insertions(+) create mode 100644 UnityPackage/Authoring/AgentGuide.cs create mode 100644 UnityPackage/Authoring/AgentGuide.cs.meta create mode 100644 UnityPackage/Authoring/AgentGuideCatalog.cs create mode 100644 UnityPackage/Authoring/AgentGuideCatalog.cs.meta create mode 100644 UnityPackage/Editor/AgentGuideMenu.cs create mode 100644 UnityPackage/Editor/AgentGuideMenu.cs.meta create mode 100644 UnityPackage/Tests/Authoring/AgentGuideTests.cs create mode 100644 UnityPackage/Tests/Authoring/AgentGuideTests.cs.meta diff --git a/UnityPackage/Authoring/AgentGuide.cs b/UnityPackage/Authoring/AgentGuide.cs new file mode 100644 index 0000000..9d0c895 --- /dev/null +++ b/UnityPackage/Authoring/AgentGuide.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace reromanlee.ReactiveLocalizer.Authoring +{ + /// + /// Writes the section of a project's AGENTS.md or CLAUDE.md that tells coding agents how the project localizes its + /// text, since agents read the project rather than the package cache. Writing it again replaces the section in place. + /// + internal static class AgentGuide + { + public const string StartMarker = ""; + public const string EndMarker = ""; + + /// Returns the section for , its lines ended by . + public static string Write(IReadOnlyList catalogs, string newLine) + { + string keysClass = "LocalizationKeys"; + for (int i = 0; i < catalogs.Count; i++) + { + if (!catalogs[i].IsEditorOnly) + { + keysClass = catalogs[i].KeysClassName; + break; + } + } + + StringBuilder builder = new(); + builder.Append(StartMarker).Append(newLine); + builder.Append("### Localization").Append(newLine).Append(newLine); + builder.Append("This project localizes its text with the ReactiveLocalizer package: text that players see lives in `.lang` files, ") + .Append("never in code, scenes or prefabs. *Tools > ReactiveLocalizer > Write Agent Guide* in the Unity editor rewrites this section.") + .Append(newLine).Append(newLine); + if (catalogs.Count == 0) + { + builder.Append("The project has no catalog yet: *Assets > Create > ReactiveLocalizer > Catalog* creates one.").Append(newLine); + } + else + { + builder.Append("Catalogs, each with its own languages, tables and generated keys:").Append(newLine).Append(newLine); + for (int i = 0; i < catalogs.Count; i++) + { + AppendCatalog(builder, catalogs[i], newLine); + } + } + builder.Append(newLine); + builder.Append("1. **Tables** are files named `..lang` anywhere under their catalog's folder. The source language's file ") + .Append("defines the keys, and a translation file holds only the entries translated so far; missing ones fall back.").Append(newLine); + builder.Append("2. **Add an entry** to the source-language file as `Key = Text`, with a `#` comment above it telling translators where ") + .Append("the text shows. Names use ASCII letters, digits and underscores, start with a letter, and are unique ignoring case. ") + .Append("Name an entry after its role, such as `PlayButton`, never after its text.").Append(newLine); + builder.Append("3. **Arguments** use ICU MessageFormat and are named: `Coins = You have {coins, plural, one {# coin} other {# coins}}.` ") + .Append("A literal brace is written `'{'`. Translations keep the same arguments, with the plural forms of their own language.").Append(newLine); + builder.Append("4. **Code names entries through the generated keys,** such as `").Append(keysClass).Append(".Shop.Purchase`, ") + .Append("and an entry with arguments is a method: `").Append(keysClass).Append(".Shop.Coins(coins: 5)`.").Append(newLine); + builder.Append("5. **Never edit the generated `*Keys.cs` files,** which the editor rewrites as keys change, and never write the ") + .Append("`[1a2b3c]` fingerprint tags of translation lines yourself: the editor writes them, and a translation without one ") + .Append("counts as unverified.").Append(newLine); + builder.Append("6. **To rename an entry,** rename it in every language file of its table and put `@formerly OldName` above it in the ") + .Append("source file, so references saved in scenes and data keep working.").Append(newLine); + builder.Append("7. **Unity checks each file as it imports it** and reports problems with their file and line in the Console. ") + .Append("*Tools > ReactiveLocalizer > Validate* checks the whole project, and player builds fail on errors.").Append(newLine); + builder.Append(EndMarker).Append(newLine); + return builder.ToString(); + } + + /// + /// Puts in : in place of the section it has, or after a blank + /// line at its end. False when the document has the section's start without its end, which a person has to fix, + /// since replacing everything after the start could erase their text. + /// + public static bool TryMerge(string document, string section, out string merged) + { + if (string.IsNullOrEmpty(document)) + { + merged = section; + return true; + } + int start = document.IndexOf(StartMarker, StringComparison.Ordinal); + if (start < 0) + { + string newLine = document.Contains("\r\n") ? "\r\n" : "\n"; + merged = document.TrimEnd() + newLine + newLine + section; + return true; + } + int end = document.IndexOf(EndMarker, start, StringComparison.Ordinal); + if (end < 0) + { + merged = null; + return false; + } + merged = document.Substring(0, start) + section.TrimEnd() + document.Substring(end + EndMarker.Length); + return true; + } + + private static void AppendCatalog(StringBuilder builder, AgentGuideCatalog catalog, string newLine) + { + builder.Append("- `").Append(catalog.Path).Append('`'); + if (catalog.IsEditorOnly) + { + builder.Append(", editor only"); + } + builder.Append(": "); + if (catalog.Languages.Count == 0) + { + builder.Append("no languages yet"); + } + for (int i = 0; i < catalog.Languages.Count; i++) + { + if (i > 0) + { + builder.Append(i == catalog.Languages.Count - 1 ? " and " : ", "); + } + builder.Append(catalog.Languages[i]); + if (string.Equals(catalog.Languages[i], catalog.SourceLanguage, StringComparison.OrdinalIgnoreCase)) + { + builder.Append(" (source)"); + } + } + builder.Append(". Keys: `").Append(catalog.KeysClassName).Append("` in `").Append(catalog.GeneratedCodePath).Append("`.").Append(newLine); + } + } +} diff --git a/UnityPackage/Authoring/AgentGuide.cs.meta b/UnityPackage/Authoring/AgentGuide.cs.meta new file mode 100644 index 0000000..200c2b2 --- /dev/null +++ b/UnityPackage/Authoring/AgentGuide.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6de7957e51934273b62940061449e319 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Authoring/AgentGuideCatalog.cs b/UnityPackage/Authoring/AgentGuideCatalog.cs new file mode 100644 index 0000000..da51060 --- /dev/null +++ b/UnityPackage/Authoring/AgentGuideCatalog.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace reromanlee.ReactiveLocalizer.Authoring +{ + /// What the agent guide tells about one catalog of the project. + internal readonly struct AgentGuideCatalog + { + public AgentGuideCatalog(string path, string sourceLanguage, IReadOnlyList languages, string keysClassName, + string generatedCodePath, bool isEditorOnly) + { + Path = path; + SourceLanguage = sourceLanguage; + Languages = languages; + KeysClassName = keysClassName; + GeneratedCodePath = generatedCodePath; + IsEditorOnly = isEditorOnly; + } + + /// The catalog file, such as Assets/Localization/Localization.catalog. + public string Path { get; } + + public string SourceLanguage { get; } + + /// Every language of the catalog, the source language included, in the catalog's order. + public IReadOnlyList Languages { get; } + + /// The generated class of the catalog's keys, such as LocalizationKeys. + public string KeysClassName { get; } + + public string GeneratedCodePath { get; } + + public bool IsEditorOnly { get; } + } +} diff --git a/UnityPackage/Authoring/AgentGuideCatalog.cs.meta b/UnityPackage/Authoring/AgentGuideCatalog.cs.meta new file mode 100644 index 0000000..98dcb2f --- /dev/null +++ b/UnityPackage/Authoring/AgentGuideCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b67d27cd4d9848cc818d6a4e92807502 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Editor/AgentGuideMenu.cs b/UnityPackage/Editor/AgentGuideMenu.cs new file mode 100644 index 0000000..feb5078 --- /dev/null +++ b/UnityPackage/Editor/AgentGuideMenu.cs @@ -0,0 +1,105 @@ +using reromanlee.ReactiveLocalizer.Authoring; +using reromanlee.ReactiveLocalizer.Documents; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace reromanlee.ReactiveLocalizer.Editor +{ + /// + /// Writes the localization section of the project's AGENTS.md or CLAUDE.md, the files coding agents read, after asking + /// which one. Nothing is written unless asked, and writing again updates the section in place. + /// + internal static class AgentGuideMenu + { + private const string Title = "Write Agent Guide"; + + [MenuItem("Tools/ReactiveLocalizer/Write Agent Guide", priority = 60)] + private static void WriteAgentGuide() + { + string path = ChooseFile(Path.GetDirectoryName(Application.dataPath)); + if (path == null) + { + return; + } + string name = Path.GetFileName(path); + try + { + bool hasByteOrderMark = HasByteOrderMark(path); + string document = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + string section = AgentGuide.Write(CollectCatalogs(), document.Contains("\r\n") ? "\r\n" : "\n"); + if (!AgentGuide.TryMerge(document, section, out string merged)) + { + EditorUtility.DisplayDialog(Title, + $"{name} has the start of the localization section but not its end, so writing it could erase your text. " + + "Fix or remove the section, then write it again.", "OK"); + return; + } + File.WriteAllText(path, merged, new UTF8Encoding(hasByteOrderMark)); + Debug.Log($"[ReactiveLocalizer] Wrote the localization section for coding agents into {name}."); + } + catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException) + { + EditorUtility.DisplayDialog(Title, $"Couldn't write {name}: {exception.Message}", "OK"); + } + } + + /// Asks which file gets the section, offering to create AGENTS.md when neither exists. Null when canceled. + private static string ChooseFile(string projectFolder) + { + string agents = Path.Combine(projectFolder, "AGENTS.md"); + string claude = Path.Combine(projectFolder, "CLAUDE.md"); + bool hasAgents = File.Exists(agents); + bool hasClaude = File.Exists(claude); + if (hasAgents && hasClaude) + { + int choice = EditorUtility.DisplayDialogComplex(Title, + "Which file should get the localization section for coding agents? Writing it again later updates it in place.", + "AGENTS.md", "Cancel", "CLAUDE.md"); + return choice == 0 ? agents : choice == 2 ? claude : null; + } + if (hasAgents || hasClaude) + { + string existing = hasAgents ? agents : claude; + return EditorUtility.DisplayDialog(Title, + $"Add a localization section for coding agents to {Path.GetFileName(existing)}? Writing it again later updates it in place.", + "Write", "Cancel") ? existing : null; + } + return EditorUtility.DisplayDialog(Title, + "The project has no AGENTS.md or CLAUDE.md. Create AGENTS.md with a localization section for coding agents?", + "Create", "Cancel") ? agents : null; + } + + private static List CollectCatalogs() + { + List catalogs = new(); + foreach (IndexedCatalog catalog in CatalogIndex.Catalogs) + { + List languages = new(); + foreach (CatalogDocumentLanguage language in catalog.Document.Languages) + { + languages.Add(language.Name); + } + string sourceLanguage = catalog.Info?.SourceLanguage.Name + ?? (catalog.Document.TryGetAttribute(DocumentNames.Source, out DocumentProperty source) ? source.Value : string.Empty); + catalogs.Add(new AgentGuideCatalog(catalog.Path, sourceLanguage, languages, catalog.Name + "Keys", catalog.GeneratedCodePath, + catalog.IsEditorOnly)); + } + return catalogs; + } + + private static bool HasByteOrderMark(string path) + { + if (!File.Exists(path)) + { + return false; + } + byte[] start = new byte[3]; + using FileStream stream = File.OpenRead(path); + return stream.Read(start, 0, 3) == 3 && start[0] == 0xEF && start[1] == 0xBB && start[2] == 0xBF; + } + } +} diff --git a/UnityPackage/Editor/AgentGuideMenu.cs.meta b/UnityPackage/Editor/AgentGuideMenu.cs.meta new file mode 100644 index 0000000..ab5753e --- /dev/null +++ b/UnityPackage/Editor/AgentGuideMenu.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cca04fa7e07347cdbfd63ffa9107f06f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Authoring/AgentGuideTests.cs b/UnityPackage/Tests/Authoring/AgentGuideTests.cs new file mode 100644 index 0000000..6552d4e --- /dev/null +++ b/UnityPackage/Tests/Authoring/AgentGuideTests.cs @@ -0,0 +1,77 @@ +using NUnit.Framework; +using reromanlee.ReactiveLocalizer.Authoring; + +namespace reromanlee.ReactiveLocalizer.Tests +{ + public class AgentGuideTests + { + private static readonly AgentGuideCatalog Game = new("Assets/Localization/Localization.catalog", "English", + new[] { "English", "Russian", "Pirate" }, "LocalizationKeys", "Assets/Localization/LocalizationKeys.cs", false); + + private static readonly AgentGuideCatalog Tool = new("Assets/Editor/Tool/Tool.catalog", "English", + new[] { "English" }, "ToolKeys", "Assets/Editor/Tool/ToolKeys.cs", true); + + [Test] + public void Write_ListsEveryCatalogWithItsLanguagesAndKeys() + { + string section = AgentGuide.Write(new[] { Game, Tool }, "\n"); + + Assert.That(section, Does.StartWith(AgentGuide.StartMarker + "\n")); + Assert.That(section, Does.EndWith(AgentGuide.EndMarker + "\n")); + Assert.That(section, Does.Contain("- `Assets/Localization/Localization.catalog`: English (source), Russian and Pirate. " + + "Keys: `LocalizationKeys` in `Assets/Localization/LocalizationKeys.cs`.\n")); + Assert.That(section, Does.Contain("- `Assets/Editor/Tool/Tool.catalog`, editor only: English (source).")); + Assert.That(section, Does.Contain("`LocalizationKeys.Shop.Coins(coins: 5)`")); + Assert.That(section, Does.Not.Contain("\r")); + } + + [Test] + public void Write_WithoutCatalogs_TellsHowToCreateOne() + { + string section = AgentGuide.Write(new AgentGuideCatalog[0], "\r\n"); + + Assert.That(section, Does.Contain("The project has no catalog yet")); + Assert.That(section.Replace("\r\n", string.Empty), Does.Not.Contain("\n")); + } + + [Test] + public void TryMerge_IntoAnEmptyDocument_GivesTheSection() + { + string section = AgentGuide.Write(new[] { Game }, "\n"); + + Assert.That(AgentGuide.TryMerge(string.Empty, section, out string merged), Is.True); + Assert.That(merged, Is.EqualTo(section)); + } + + [Test] + public void TryMerge_IntoADocumentWithoutTheSection_AppendsItAfterABlankLine() + { + string section = AgentGuide.Write(new[] { Game }, "\r\n"); + + Assert.That(AgentGuide.TryMerge("# Agents\r\n\r\nRun the tests.\r\n\r\n", section, out string merged), Is.True); + Assert.That(merged, Is.EqualTo("# Agents\r\n\r\nRun the tests.\r\n\r\n" + section)); + } + + [Test] + public void TryMerge_ReplacesTheSectionInPlace() + { + string old = AgentGuide.Write(new[] { Tool }, "\n"); + string section = AgentGuide.Write(new[] { Game }, "\n"); + string document = "# Agents\n\n" + old + "\n### Style\n\nUse tabs.\n"; + + Assert.That(AgentGuide.TryMerge(document, section, out string merged), Is.True); + Assert.That(merged, Is.EqualTo("# Agents\n\n" + section + "\n### Style\n\nUse tabs.\n")); + Assert.That(AgentGuide.TryMerge(merged, section, out string again), Is.True); + Assert.That(again, Is.EqualTo(merged)); + } + + [Test] + public void TryMerge_WithTheStartButNotTheEnd_ChangesNothing() + { + string document = "# Agents\n\n" + AgentGuide.StartMarker + "\n### Localization\n\nMy own notes.\n"; + + Assert.That(AgentGuide.TryMerge(document, AgentGuide.Write(new[] { Game }, "\n"), out string merged), Is.False); + Assert.That(merged, Is.Null); + } + } +} diff --git a/UnityPackage/Tests/Authoring/AgentGuideTests.cs.meta b/UnityPackage/Tests/Authoring/AgentGuideTests.cs.meta new file mode 100644 index 0000000..5eed2ef --- /dev/null +++ b/UnityPackage/Tests/Authoring/AgentGuideTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93d315281169458f99f825ddcdca7284 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 8aed12468076f70efe4b576982b452ea02eb7743 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:26:17 +0300 Subject: [PATCH 04/14] build: skip verified runs --- .github/workflows/continuous-integration.yml | 59 ++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index ae8bd31..8a4de5a 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -25,6 +25,8 @@ permissions: contents: read # The test runner reports its results as a check run. checks: write + # The gate looks for the marker an earlier run left on the same files. + actions: read # A Personal license needs UNITY_LICENSE, UNITY_EMAIL and UNITY_PASSWORD; a Pro license UNITY_SERIAL instead of the first. env: @@ -34,8 +36,38 @@ env: UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} jobs: + # A squash merge makes a new commit, but its files are exactly the ones the pull request's last run tested, as long as + # main didn't move in between. A push whose file tree already passed skips the jobs below, and the run still succeeds; + # any other push, such as one combining two pull requests, runs them all. + gate: + name: Look for a run that passed on the same files + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + is-verified: ${{ steps.lookup.outputs.is-verified }} + steps: + - name: Look for the marker of a passed run + id: lookup + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + TREE: ${{ github.event.head_commit.tree_id }} + run: | + if [ -z "$TREE" ]; then + exit 0 + fi + # Only runs of this repository count, never runs of pull requests from forks. + query='[.artifacts[] | select((.expired | not) and .workflow_run.head_repository_id == .workflow_run.repository_id)] | first | .workflow_run.id // empty' + run=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=passed-files-$TREE" --jq "$query") || run="" + if [ -n "$run" ]; then + echo "::notice::Run $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$run already passed on these exact files, so this run skips its jobs." + echo "is-verified=true" >> "$GITHUB_OUTPUT" + fi + tests: name: Tests on ${{ matrix.unityVersion }} + needs: gate + if: needs.gate.outputs.is-verified != 'true' runs-on: ubuntu-latest timeout-minutes: 60 strategy: @@ -102,6 +134,8 @@ jobs: builds: name: ${{ matrix.targetPlatform }} build on ${{ matrix.unityVersion }} + needs: gate + if: needs.gate.outputs.is-verified != 'true' runs-on: ubuntu-latest timeout-minutes: 90 strategy: @@ -173,3 +207,28 @@ jobs: projectPath: UnityProject buildMethod: reromanlee.ReactiveLocalizer.ContinuousIntegration.PlayerBuild.Build versioning: None + + # Leaves the marker the gate looks for, named after the tested file tree, once every job passed. + passed: + name: Mark the files as passed + needs: [tests, builds] + runs-on: ubuntu-latest + timeout-minutes: 5 + # A missing marker only means main runs everything again, so it never fails the run. + continue-on-error: true + steps: + - name: Find the tree of the tested commit + id: tree + env: + GH_TOKEN: ${{ github.token }} + run: | + tree=$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$GITHUB_SHA" --jq .tree.sha) + echo "$tree" > passed-files.txt + echo "tree=$tree" >> "$GITHUB_OUTPUT" + + - name: Upload the marker + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: passed-files-${{ steps.tree.outputs.tree }} + path: passed-files.txt + retention-days: 30 From 364c4aba6c863e1b291a2938d91e993bfec6959e Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:53 +0300 Subject: [PATCH 05/14] document: file format --- Documentation/FORMAT.md | 186 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 Documentation/FORMAT.md diff --git a/Documentation/FORMAT.md b/Documentation/FORMAT.md new file mode 100644 index 0000000..88acd58 --- /dev/null +++ b/Documentation/FORMAT.md @@ -0,0 +1,186 @@ +# File format + +The exact rules of `.lang` and `.catalog` files. They are a public contract: people, merge tools, AI agents and other programs read and write them, so they stay compatible across versions. Anything these rules don't allow is reported with its file, line and column, and costs only its own line. Most important first. + +### Files and names + +1. **A table is one file per language,** named `
..lang`, such as `Shop.English.lang` and `Shop.Russian.lang`. The name is the identity, so folders are free-form. +2. **A catalog is a `.catalog` file.** It owns every table in its folder and below, unless a catalog in a folder closer to the table owns it, the way an asmdef owns scripts. +3. **Tables in Assets outside every catalog's folder** belong to the default catalog: `Assets/Localization/Localization.catalog` when that file exists, else the only catalog in Assets outside an Editor folder. When neither applies, they are reported instead of guessed. +4. **A catalog inside an `Editor` folder is editor-only:** builds never pack it. +5. **Names** of catalogs, tables, entries, languages and arguments start with an ASCII letter and continue with ASCII letters, digits and underscores, up to 128 characters. They are unique ignoring case, and everything that looks names up ignores case too. +6. **Text is UTF-8.** A byte order mark is skipped when reading and never written. Lines end with CRLF, LF or CR, in any mix; writers keep the line ending a file has, and use LF for a new one. + +### Lines + +Both kinds of file are read one line at a time, and the first character that isn't a blank decides what a line is. Blanks are spaces and tabs only: every other whitespace character, the no-break space included, is content and is never trimmed. + +| A line starting with | Is | +|---|---| +| Nothing but blanks | A blank line | +| `#` | A comment: the text after `#`, without one blank right after it and without trailing blanks | +| `@` | An attribute: a name right after the `@`, then optionally blanks and a value | +| `[` | A language section header, in catalog files only | +| Anything else | An entry in table files, a field in catalog files | + +1. **Merge conflict markers are errors:** a line that starts with exactly seven `<`, `=`, `>` or `|` characters, followed by nothing or a blank, is the marker git leaves around a conflict. +2. **Comments right above an entry or a field belong to it.** In a table's source-language file, they are the context translators see in the table window and in every export. +3. **Comments at the top of a file followed by a blank line** are the file's header; comments after the last entry stay at the end. +4. **Unknown attributes and fields are kept** with a warning, so a file written by a newer version loses nothing when an older one rewrites it. + +### Table files + +``` +# Prices and purchases in the shop. + +@loading Preload +@delivery Embedded + +@formerly CoinsLabel +CoinBalance = You have {coins, plural, one {# coin} other {# coins}}. + +Disclaimer = Prices include VAT.\nRefunds within 14 days. + +# The button that confirms buying the selected item. +@maximumLength 16 +Purchase = Buy +``` + +An entry is `Key = Value`, with an optional fingerprint between the key and the `=`, and blanks allowed around both: + +``` +Purchase [3fa2c1] = Купить +``` + +1. **The key** is the run of letters, digits and underscores the line starts with, and has to follow the naming rule. A key already defined in the file, ignoring case, is an error, and the first definition stays. +2. **The value is everything after the first `=`,** with blanks at both ends trimmed before escapes are resolved. It needs no quotes, and nothing in it is syntax of the file: a `#`, `@`, `=` or `[` inside a value is text. +3. **An empty value is an intentionally empty text,** which is different from a missing entry. +4. **The source-language file defines the keys.** A translation file holds only the entries translated so far: a missing one falls back to the language's fallback, ending at the source language. A key that exists only in a translation is an orphan, reported and left out of builds. +5. **Entries are matched by key, never by position,** so their order in a file doesn't matter. + +#### Escapes + +| Written | Means | +|---|---| +| `\n` | A line break | +| `\t` | A tab | +| `\\` | A backslash | +| `\uXXXX` | The UTF-16 code unit with that hexadecimal value, such as ` ` for a no-break space | + +Any other backslash, a `\u` without four hexadecimal digits, and a backslash ending the value are kept exactly as written, with a warning. + +#### Settings + +Table settings are attributes at the top of the source-language file, before the first entry and its attributes, each written once. Translation files take the source file's settings. Values ignore case. + +| Setting | Values | Default | +|---|---|---| +| `@loading` | `Preload`: loaded with the language. `OnDemand`: loaded while bindings or `HoldTable` handles use it. | `Preload` | +| `@delivery` | `Embedded`: packed inside the build. `Streaming`: packed into StreamingAssets and read when needed. `Custom`: not packed, since a table source of the project delivers it. | `Embedded` | +| `@generateCode` | `true` or `false`: whether the table gets generated keys. | `true` | + +#### Entry attributes + +Entry attributes are written on the lines right above the entry they belong to, among its comments. + +| Attribute | Means | +|---|---| +| `@formerly OldName` | The entry used to be called `OldName`, which still finds it: at runtime, in saved references and as an `[Obsolete]` member of generated code. `@formerly OldTable.OldName` is an entry moved from another table. An entry can have several. | +| `@maximumLength 16` | Translations longer than this many characters are reported. | + +#### Fingerprints + +A translation line carries a fingerprint of the source text it was translated from: six hexadecimal digits in brackets after the key. When the source text changes, the fingerprints stop matching and the translations are reported as outdated. A translation without one counts as unverified, and brackets that don't hold six hexadecimal digits are ignored with a warning. + +The fingerprint is the top 24 bits of the 64-bit FNV-1a hash of the source entry's text, after its escapes are resolved, taken over its UTF-16 code units, low byte first. The offset basis is 14695981039346656037 and the prime 1099511628211. Writers write it in lower case, and readers take either case. + +### Messages + +Values use ICU MessageFormat, the classic syntax (MF1) that translation tools support. Messages are compiled at import, so every problem is reported with its line and column. + +| Syntax | Example | +|---|---| +| An argument shown as it is | `Hello, {name}!` | +| A number | `{distance, number}`, `{count, number, integer}`, `{ratio, number, percent}` | +| A plural | `{coins, plural, =0 {no coins} one {# coin} other {# coins}}` | +| A plural with an offset | `{count, plural, offset:1 =0 {Nobody} =1 {Only you} one {You and # other} other {You and # others}}` | +| An ordinal | `{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}` | +| A select | `{gender, select, female {her} male {his} other {their}}` | +| A type the project formats | `{deadline, date}`, `{deadline, date, short}`, `{price, currency}` | + +1. **Arguments are named,** since names become the parameters of generated code. ICU's numbered arguments, such as `{0}`, and its deprecated `choice` are rejected with an explanation. +2. **Plurals, ordinals and selects need an `other` form.** A plural or ordinal is warned about when it lacks a form its language uses, unless exact forms such as `=1` stand in for it, and when it has a form its language never uses. Russian uses `one`, `few`, `many` and `other`; English `one` and `other`. +3. **`#` inside a plural form** is the number, formatted as the language writes numbers. +4. **Translations use the same arguments as the source text,** each in a way its value supports: a number may also be shown as it is, but text can't choose a plural form. A translation that doesn't match is left out with an error, so it shows in its fallback language. +5. **Types other than `number`, `plural`, `selectordinal` and `select`** call a formatter the project registers, with the style after the second comma. Without one, the value shows as it is and the problem is reported once. + +#### Quoting + +An apostrophe starts quoted text only right before a brace, or before `#` inside a plural form, and two apostrophes always show one. Everything else is plain text. + +| Written | Shows | +|---|---| +| `Don't` | Don't | +| `It''s` | It's | +| `Use '{'braces'}'` | Use {braces} | +| `Write '{name}' literally` | Write {name} literally | + +### Catalog files + +``` +# The game's languages. Pirate is invented: it has no culture, and falls back to English. + +@source English +@namespace MyGame + +[English] +DisplayName = English +Culture = en + +[Russian] +DisplayName = Русский +Culture = ru +Required = true + +[Pirate] +DisplayName = Pirate Speak +Fallback = English +``` + +1. **Attributes come first,** before the first section, each written once: + 1. `@source` names the source language, which has to be one of the sections. It is required. + 2. `@namespace` is the namespace of the generated code. Without it, the code takes the root namespace of the asmdef owning the folder, then the project's root namespace setting, then none. +2. **A section is a language:** its name in brackets, on a line of its own. Blanks inside the brackets are allowed, so `[ English ]` names English. A name already used, ignoring case, is an error, and its section is skipped. +3. **Fields** are `Field = Value` lines inside a section, with the same escapes as table values, each written once. + +| Field | Value | Without it | +|---|---|---| +| `DisplayName` | What a language picker shows, such as `Português (Brasil)` | Empty | +| `Culture` | A language tag such as `en` or `pt-BR`, which plural rules, number formatting and exports follow | An invented language: plural rules and number symbols come from the fallback language, and without one every count takes the `other` form | +| `Fallback` | Another language of the catalog, which missing entries come from | Only the source language | +| `Direction` | `LeftToRight` or `RightToLeft`, a hint for whatever renders the text | `LeftToRight` | +| `Required` | `true` or `false`: whether builds fail while an entry is missing in this language | `false` | +| `Digits` | The ten digits from zero to nine, in order, such as `٠١٢٣٤٥٦٧٨٩` | The culture's | +| `DecimalSeparator` | The decimal separator; it can't be empty | The culture's | +| `GroupSeparator` | The digit group separator; empty never groups | The culture's | + +1. **A culture CLDR doesn't know** is reported as a warning, and the language then takes its plural rules and number symbols from its fallback, as if it had no culture. +2. **Fallbacks that lead back to their language** are an error, and the fallback is ignored. +3. **Every language falls back to the source language last,** so a missing entry always shows some text. + +### Canonical layout + +Every tool of the package writes table files the same way, so any edit, by a person, a tool or an agent, gives the same text and the smallest diff: + +1. Header comments, then a blank line. +2. Settings, in the order `@loading`, `@delivery`, `@generateCode`, then a blank line. +3. Entries in natural order, ignoring case, so `Line9` comes before `Line10`, each with its comments and attributes above it. A blank line separates the entries from what comes before them, and sets apart every entry that has comments or attributes. +4. Trailing comments, after a blank line. +5. `Key [fingerprint] = Value`, with one space on each side of the `=`, and `Key =` for an empty value. +6. In values, line breaks as `\n`, tabs as `\t`, backslashes as `\\`, spaces at either end as ` `, and characters that are invisible in a review as `\uXXXX`: control characters, unusual spaces such as the no-break space, zero-width characters, bidirectional marks, the soft hyphen and the byte order mark. +7. UTF-8 without a byte order mark, with the file's own line endings. + +### What is generated from the files + +1. **Compiled tables** live in Unity's import cache and in builds, never in the repository. Their binary format is internal and carries a version, so a table compiled by another version is reported, never misread. +2. **Generated code** is one `Keys.cs` file next to each catalog, holding the `Keys` and `Languages` classes. It changes only when a key, alias, argument, table or language changes, never when only a text does, and it says so in its header. Edits to it are lost. From fcefc2f23c3ff7cc8d90d531f7a0142d1b061f3a Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:54 +0300 Subject: [PATCH 06/14] document: features --- Documentation/FEATURES.md | 207 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 Documentation/FEATURES.md diff --git a/Documentation/FEATURES.md b/Documentation/FEATURES.md new file mode 100644 index 0000000..9030682 --- /dev/null +++ b/Documentation/FEATURES.md @@ -0,0 +1,207 @@ +# Features + +Everything ReactiveLocalizer does, most important first. How to use each part is in [USAGE.md](USAGE.md), and the exact rules of the files in [FORMAT.md](FORMAT.md). + +### One model at every scale + +A prototype and a production game with hundreds of thousands of entries use the same files, the same runtime and the same tools, so growing never needs a migration. + +1. **A five-minute start:** add a table, write `Play = Play`, and read it from code or pick it in the Inspector. Embedded tables load within the first call, so text can be read on the first frame without awaiting anything. +2. **Room to grow:** tables split by feature, load on demand, stream from StreamingAssets or come from Addressables, and a 50,000-line dialogue table only ever creates strings for the lines actually shown. +3. **No dependencies:** `package.json` declares none. Integration code lives in samples that compile only when their package is installed, so importing one never breaks compilation. +4. **Every platform:** a pure C# core with no engine references runs the same in the Editor, Mono and IL2CPP builds, WebGL included, and builds with plain .NET. + +### Text files as the source of truth + +Every table is one `.lang` file per language, in a line-based `Key = Value` format that diffs, merges and reviews like code, and that people and AI agents can edit with any text editor. + +1. **Translation files hold only translated entries,** so adding a key changes only the source-language file, and translators' deliveries never touch the lines developers edit. +2. **Comments above an entry** are the context translators see in the table window and in every export. +3. **Fingerprints** mark which source text each translation was made from, so a changed source text flags its translations as outdated in every language. +4. **One canonical layout,** written by every tool: keys in natural order, so `Line9` comes before `Line10`, invisible characters as `\u` escapes that show up in reviews, and the file's own line endings. +5. **Hand edits are supported, not forbidden.** Merge conflict resolution, AI agents and quick fixes are validated like any other edit. +6. **The format is a public contract,** specified in [FORMAT.md](FORMAT.md), so outside tools can read and write it. + +### Keys the compiler checks + +The editor writes one plain C# file per catalog, so code names entries through members that exist or fail to compile. + +```csharp +LocalizationKeys.Shop.Purchase // a plain entry: a field +LocalizationKeys.Shop.CoinBalance(coins: 5) // an entry with arguments: a method returning EntryMessage +LocalizationKeys.Shop.BuyButton // a renamed entry: an [Obsolete] forwarder to Purchase +LocalizationLanguages.Russian // a language +``` + +1. **Argument types come from the text:** plural and number arguments take any numeric type without boxing, select arguments take strings, and parameters are sorted by name, so rewording a text never reorders them. +2. **Changing an argument breaks its call sites at compile time,** never at runtime. +3. **Editing text never recompiles.** The file changes only when a key, alias, argument, table or language changes, and edits made in the editor's own tools wait until Unity loses focus, so working in the editor never triggers a recompile. +4. **A typo never breaks the build:** while a text's message has errors, the file keeps its current members. +5. **Plain files, not a source generator,** so the code is visible to reviews, IDEs and agents. Tables referenced from data rather than code opt out with `@generateCode false` and are read by name without allocating. + +### ICU messages + +Messages use ICU MessageFormat, the syntax translation tools understand, compiled into instructions at import and formatted without allocating. + +1. **Plurals, ordinals, selects, numbers and nesting:** `{coins, plural, one {# coin} other {# coins}}`, `{place, selectordinal, ...}`, `{gender, select, ...}`, `{ratio, number, percent}`, with `=N` forms and `offset:`. +2. **Plural rules and number formats for every CLDR 48 locale,** generated into the core and used without `CultureInfo`, so a message formats identically on every device. Numbers group as their language groups them, Indian grouping and minimum grouping digits included. +3. **Digits and separators any language can override,** such as Arabic with Latin digits, or an invented language with its own symbols. +4. **Dates, times and currency** go through formatters the project registers per type, which write into a span without allocating. +5. **Checked at import:** syntax errors with their line and column, translations using other arguments than their source, plural forms a language lacks or never uses, and likely mistakes such as `=#` inside a plural form. +6. **Text from a fallback language formats with that language's rules,** since the words around the number are in that language. + +### Bindings that follow every switch + +`Bind` calls a callback with an entry's text right away and again every time it changes: a language switch, an on-demand table arriving, a table file saved in the editor, or new arguments. + +1. **Nothing allocates in steady state.** The binding handle is a struct, the callback a static lambda that receives its target, and `BindCharacters` hands over characters without creating strings, which TextMeshPro copies straight into its buffer. +2. **Messages format again only when their arguments change,** so setting the same arguments every frame costs nothing. +3. **A language switch is all or nothing:** the new language's tables load first, then every lookup and binding switches in the same frame, so no frame ever shows two languages. Rapid switches collapse into the latest one. +4. **Inactive UI costs nothing.** Components bind while enabled only, and text enabled later arrives in the current language. +5. **A failing callback is reported and skipped,** and the other bindings still update. Bindings whose target was destroyed are released and reported. +6. **`ReactiveText`** wraps a binding as an observable value with a `Changed` event and `INotifyPropertyChanged`, for view models, data binding and reactive libraries. + +### The table window + +**Window > ReactiveLocalizer > Tables** shows every table of every catalog in one grid, a column per language, built on UI Toolkit's list view, which renders only the rows in view, however long the table. + +1. **Status tints** for missing, outdated, broken, too long and orphaned texts, filters for what is missing, outdated or broken, and badges with the counts on every table in the sidebar. +2. **Search** across keys, text in every language and comments. +3. **A detail pane** with multi-line editors for every language, the translator context, the maximum length, and a live preview of a plural message with sample values, such as Russian with 1, 2, 5 and 21. +4. **Add, rename, move and delete** entries across every language file at once, each one step that Undo takes back. +5. **Edits save when committed,** with no unsaved state, and bound text updates live. +6. **Show source change** finds the source text an outdated translation was made from in git and marks the words that changed; **Mark as current** confirms a translation that still fits. + +### Inspector fields + +`EntryReference` is the field type for components that show an entry. It stores the catalog, table and key as names, so references survive reordering, merges and code changes. + +1. **A search popup** over keys and texts, with recent picks first, rendering only the visible results. +2. **Entries created on the spot:** when nothing matches, the popup creates the entry, suggesting a table named after the scene or prefab and a key named after the GameObject's role, such as `Title` for `TitleLabel`. Creating and assigning it is one Undo step, and it writes only the source file, so nothing recompiles. +3. **A preview of the text** in the preview language below the field. +4. **Broken references show red,** and a reference to a renamed entry offers to update itself. +5. **UI Toolkit and IMGUI Inspectors alike,** and `[EntryCatalog(typeof(ToolKeys))]` limits a field to one catalog. + +### Edit Mode preview + +The **Preview Language** overlay of the Scene view switches the language every bound text in the open scenes shows, live, in Edit Mode. + +1. **Scenes and prefabs always save the text they were authored with,** never the preview, and text typed over the preview counts as the new authored text. +2. **Entry fields and the table window** preview in the same language. +3. **Components opt in** through `EditModePreview`, as the samples' components do, and the TextMeshPro sample switches fonts per language in the preview too. +4. **Saving a table file updates bound text at once,** in Edit Mode and Play Mode. + +### Validation and the build gate + +Every file is checked as it imports, and **Tools > ReactiveLocalizer > Validate** checks the whole project. Problems are reported with their file, line and column, and the table window shows the same problems the build gate does. + +1. **Errors fail player builds:** malformed files, merge conflict markers, duplicate keys, orphans, broken messages, arguments that differ between languages, entries missing in required languages, and references to keys that don't exist from the scenes, prefabs and Resources in the build. +2. **Warnings don't:** outdated and unverified translations, entries missing in other languages, texts over their maximum length, unused aliases, and the optional PascalCase naming rule. +3. **Orphans suggest the key they most likely meant,** such as `Purchase` for `Purchse`. +4. **Translations re-validate automatically** whenever their source file changes. + +### Loading and delivery + +1. **Only the current language is loaded.** Fallback languages load only for tables with gaps, so a complete translation costs no extra memory. +2. **`@loading OnDemand` tables** stay in memory only while bindings or `HoldTable` handles use them, follow every language switch while they do, and unload two updates after the last user lets go, so a panel toggled within a frame keeps its table. +3. **`@delivery Embedded`** packs a table inside the build, readable on the first frame; **`Streaming`** packs it into StreamingAssets, read without blocking the main thread, through UnityWebRequest on Android and WebGL; **`Custom`** leaves it to a source of the project, such as the Addressables sample's. +4. **Sources stack:** a mod folder asked first patches a shipped language entry by entry, the game's own tables fill in the rest, and a language a source doesn't have falls through to the next. +5. **Failures never throw into game code.** A table that fails to load falls back to its fallback language, and the task reports what failed. + +### Languages + +1. **Languages are records the project defines,** with a name, a display name and an optional culture, so invented and fan-made languages work as well as real ones. +2. **Any language can be the source,** such as `@source Russian` for a Russian-first project. +3. **Fallback chains** per language, always ending at the source language. +4. **Languages added at runtime** with `RegisterLanguage`, such as a fan translation from a mods folder, which a language picker lists like any other. +5. **Required languages** fail builds while any entry is missing in them. +6. **Several catalogs per project:** packages and editor tools ship their own, so their tables and languages never mix with the game's, and a catalog in an Editor folder never reaches a build. + +### Exchange with translators + +**Tools > ReactiveLocalizer > Export** and **Import** exchange translations in the formats translators use, all holding the same thing: the key, the context, the maximum length, the source text, and each language's text and state. + +1. **XLSX:** one workbook, a sheet per table, read and written without dependencies, with the header row frozen, long texts wrapped, outdated texts tinted, and text cells that never turn into numbers or formulas. +2. **CSV:** a file per table, saved so Excel reads non-Latin text correctly, with the separator and encoding detected when importing. +3. **XLIFF 1.2 and 2.0:** a file per language, with states for missing, outdated and translated texts, read from any translation tool's output. +4. **An LLM handoff:** missing or outdated entries copied to the clipboard as a text bundle with rules for the model, its plural forms included, and the model's whole reply imported from the clipboard, with no API keys. +5. **Imports change translations only,** never keys. A preview lists every new, updated, outdated, confirmed and rejected text before anything is written, every text is compiled like the files are, and the whole import is one step Undo takes back. +6. **The source text in the file becomes the fingerprint,** so a translation of an older text arrives already marked outdated. +7. **Exports pick** the tables, the languages, and all entries, only missing ones, only outdated ones, or both. + +### Pseudo-localization + +A `Pseudo` language made from the source text, accented, a third longer and bracketed, so hardcoded text, truncation and overflow show up before any translation exists: `Buy now` shows as `[Bûý ñöŵ one]`. + +1. **Arguments, rich text tags and escapes stay intact,** and every text is checked like a real translation. +2. **Accents come from Latin-1 and Latin Extended-A,** which nearly every font has. +3. **The editor's preview always offers it,** Play Mode and development builds list it as one more language, and release builds never ship it. + +### Character sets + +**Tools > ReactiveLocalizer > Character Sets** lists every character a language can show, for the font atlases of TextMeshPro's Font Asset Creator. + +1. **Everything that can appear:** the language's own text, the fallback text its missing entries show, every plural and select form, and its digits and number symbols, without rich text tags or control characters. +2. **Several languages at once** give the set a shared font needs, and printable ASCII or extra characters can be added for text that arguments bring at runtime. +3. **Copied or saved** in code point order. `CharacterSets.Collect` is the editor API behind it. + +### Renames and moves + +1. **Renaming an entry** updates every language file of its table and leaves `@formerly OldName`, so scenes, data and code that use the old name keep working. +2. **Moving an entry** to another table leaves `@formerly OldTable.OldName`, so it resolves at runtime, in saved references and in generated code. +3. **Validation lists what still uses each alias,** so it can be removed once nothing does. + +### Safe by construction + +1. **A missing key shows as `[Shop.Purchase]`** in every build and is reported once per key, as an error, so a test that touches one fails. `MissingKeyCount` lets a smoke test assert there are none. +2. **Lookups are lock-free from any thread.** Changes requested from any thread apply in order on the main thread, where bindings are notified, and nothing needs threads of its own, so WebGL works the same. +3. **Damaged data fails with a reason,** never a crash: compiled tables are checked on load, and a file with errors costs only its broken lines. +4. **Idle localization costs one flag check per frame.** + +### Samples + +Samples are thin adapters with their own folders and asmdefs, imported from the Package Manager, each compiling only when its package is installed. + +| Sample | What it adds | +|---|---| +| Quick Start | `GlobalLocalizer`, a project-wide access point that remembers the player's language and maps the OS language; a language picker; `LocalizedBehaviour`, the base of the UI components; a demo scene in English, Russian and an invented Pirate language | +| TextMeshPro | `LocalizedTextMeshPro`, which shows text without creating strings; `LanguageFonts`, a font per language; a check of which characters each font can't draw; a *Localize* action in the text's context menu | +| UI Toolkit | `LocalizedTextBinding` for UXML and UI Builder on Unity 6, `BindText` from code and `LocalizedDocument` on every version | +| uGUI Text | `LocalizedLegacyText` for legacy `Text`, with its own *Localize* action | +| Addressables | `AddressablesTableSource` for `@delivery Custom` tables, and a menu that sets up their addresses | +| VContainer | `LocalizerInstaller`, which registers a catalog's localizer and initializes it as the container starts | +| R3 | Observables of text, of messages fed by other observables, and of the current language | +| Google Sheets | Two-way sync with a private spreadsheet through an Apps Script that answers only a secret token | +| Mods | `ModTranslations`, fan translations from the player's mods folder that patch shipped languages and add new ones, with an example mod | +| Editor Tool | An editor window localized with a catalog of its own, as tool and package authors ship it | +| Unity Localization Migration | Converts locales, string tables, comments and plain placeholders, and lists what needs converting by hand | + +### Coding agents + +**Tools > ReactiveLocalizer > Write Agent Guide** adds a short section to the project's `AGENTS.md` or `CLAUDE.md`, since agents read the project rather than the package cache: where the catalogs and generated keys are, how to add and rename entries, the naming rules, and what never to edit by hand. Writing it again updates the section in place. + +### Quality + +1. **675 tests** run on Unity 2022.3 and Unity 6, and the 593 of them that cover the pure assemblies also run under plain .NET on every push. +2. **Allocation tests** hold cached lookups, binding updates, unchanged arguments and formatting into a buffer to zero allocations. +3. **Generated tests** check the plural rules against CLDR's sample numbers for every form of every language, and golden values pin the hash that saved references depend on. +4. **Player builds** for WebGL, Android, iOS, macOS and Linux run on every pull request, catching what only IL2CPP and code stripping break. +5. **Performance tests** measure lookups, formatting, language switches and huge tables; the numbers are in [PERFORMANCE.md](PERFORMANCE.md). + +### Why it works this way + +The choices that shape the package, and what they were chosen over: + +| Instead of | ReactiveLocalizer uses | Because | +|---|---|---| +| Enums as keys | Names, hashed once | Unity saves enums as integers, so adding or reordering members silently re-points every saved reference, and every new key would need a recompile | +| An enum of languages | Language records in the catalog | Mods, DLC and downloaded translations can't add enum members | +| ScriptableObject tables | Text files per table and language | Long YAML lists conflict constantly in merges, import slowly, and are opaque to reviews and agents | +| A hidden ID on every entry | The table and key | IDs would have to be invented for every hand edit, and two branches adding the same key would collide when merged | +| One file per table with every language | One file per table and language | A translator's delivery would touch every line developers edit, and adding a language would rewrite every file | +| A Roslyn source generator | A plain generated file | Unity limits a generator's inputs to specially named files, and its output is invisible to reviews and agents | +| `CultureInfo` | CLDR data generated into the core | Platform-specific failures in IL2CPP builds would make the same message format differently on different devices | +| A singleton in the package | The Quick Start sample's access point | Global access is a project's choice, and a DI container hands out its own instance | +| Updating bindings over several frames | Every binding in one frame | Spreading a switch trades a one-frame hitch for several frames of mixed languages; the cost of a switch is loading and rebuilding text meshes, not lookups | +| A log line on every missing read | One error per missing key | Stack traces and strings every frame would flood logs and crash reporting, and bury real problems | +| MessageFormat 2 | MessageFormat 1 | MessageFormat 2 is a stable specification, but translation tools don't support it yet; messages compile at import, so it can be added as a second syntax | From accc7d4b40d7b4039d284355d71bc4d6fe2ed7ab Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:54 +0300 Subject: [PATCH 07/14] document: usage --- Documentation/USAGE.md | 431 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 Documentation/USAGE.md diff --git a/Documentation/USAGE.md b/Documentation/USAGE.md new file mode 100644 index 0000000..f9b4613 --- /dev/null +++ b/Documentation/USAGE.md @@ -0,0 +1,431 @@ +# Usage + +How to use ReactiveLocalizer, from the first table to sources of your own. What each part does is in [FEATURES.md](FEATURES.md), and the exact rules of the files in [FORMAT.md](FORMAT.md). + +### Catalogs + +A catalog is a localization unit: its languages, its settings and its tables. Most games need one; packages and editor tools ship their own, so their text never mixes with the game's. + +1. **Create one** with **Assets > Create > ReactiveLocalizer > Catalog**. It asks for the name, the source language, its culture and display name, and checks the culture against CLDR. A catalog named `Localization` generates the classes `LocalizationKeys` and `LocalizationLanguages`. +2. **Or let the first table create it:** in a project without a catalog, creating a table first creates the default catalog, `Assets/Localization/Localization.catalog`, asking for its source language. +3. **Add a language** with a section in the catalog file. `Culture` gives it plural rules and number formats; leave it out for an invented language, which then takes them from its `Fallback`. + +``` +@source English + +[English] +DisplayName = English +Culture = en + +[Russian] +DisplayName = Русский +Culture = ru +Required = true + +[Pirate] +DisplayName = Pirate Speak +Fallback = English +``` + +`Required = true` fails builds while an entry is missing in the language. Every field is in [FORMAT.md](FORMAT.md#catalog-files). + +### Tables and entries + +A table is one `.lang` file per language, named `
..lang`, anywhere under its catalog's folder: next to the feature that uses it, or all in one place. + +1. **Create one** with **Assets > Create > ReactiveLocalizer > Table** in a folder of the catalog, then rename `NewTable.English.lang`, or with **New Table** in the table window. +2. **Add entries** in the table window, from an Inspector field, or by writing `Key = Text` lines in the source-language file. Comments right above an entry are what translators see. +3. **Name entries after their role,** such as `PlayButton` or `Intro`, never after their text: the text changes, the role doesn't. Names use ASCII letters, digits and underscores, start with a letter, and are unique ignoring case. +4. **Translations** go in the same table window, or in `Shop.Russian.lang` holding only the translated entries. The editor's tools stamp each translation with a fingerprint of its source text, which flags it as outdated once the source changes. + +``` +# Shop.English.lang + +# How many coins the player has, on the shop's top bar. +CoinBalance = You have {coins, plural, one {# coin} other {# coins}}. + +# The button that buys the selected item. +@maximumLength 16 +Purchase = Buy +``` + +#### Messages + +Text with arguments uses ICU MessageFormat, with named arguments: + +| You want | Write | +|---|---| +| A value as it is | `Hello, {name}!` | +| A count with plural forms | `{coins, plural, =0 {no coins} one {# coin} other {# coins}}` | +| An ordinal | `{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}` | +| A choice by text | `{gender, select, female {her} male {his} other {their}}` | +| A number | `{distance, number}`, `{count, number, integer}`, `{ratio, number, percent}` | +| A date or price | `{deadline, date}`, with a formatter the project registers | +| A literal brace | `'{'` | + +Each translation writes the plural forms of its own language: Russian uses `one`, `few`, `many` and `other`. The table window's preview shows a message with sample values in every language, and the importer reports forms a language lacks or never uses. + +### Reading text from code + +Create one localizer per catalog and keep it for as long as the game runs. The Quick Start sample's `GlobalLocalizer` does that for you, as does a DI container with the VContainer sample. + +```csharp +using reromanlee.ReactiveLocalizer; +using reromanlee.ReactiveLocalizer.Unity; + +Localizer localizer = new Localizer(LocalizationKeys.CatalogKey, new UnityHost()); +// Embedded tables load within the call, so text can be read right away. +localizer.InitializeAsync(); + +string play = localizer.Get(LocalizationKeys.MainMenu.Play); +string coins = localizer.Get(LocalizationKeys.Shop.CoinBalance(coins: 5)); +``` + +1. **`Get(key)`** returns the same cached string on every call, so reading text allocates nothing. A missing translation falls back along the language's fallbacks to the source language. +2. **`Get(message)`** formats a message in the language its text was found in, allocating the returned string. **`TryFormat(message, buffer, out written)`** writes it into a buffer instead, without allocating. +3. **`Get(tableName, entryName)`** reads an entry named by data, such as dialogue lines a script refers to, hashing the names without allocating. +4. **`TryGet(key, out text)`** is for keys that may legitimately be absent: it reports nothing when they are. +5. **`GetMemory(key)`** returns the characters of a text without creating a string. +6. **A key that exists nowhere** shows as `[Shop.Purchase]` and is reported once. An entry with arguments read with `Get(key)` shows them as `{coins}`; read it as a message. +7. **`InitializeAsync`** completes within the call for embedded tables. Await it when tables come from somewhere slower, such as Addressables. + +Lookups work from any thread without locks. Everything that changes the localizer, such as switching languages, applies on the main thread in the order it was asked for, and bindings and events are notified there. + +### Binding text + +A binding calls its callback with an entry's text right away, and again every time it changes, until it is disposed: a language switch, an on-demand table arriving, a table file saved in the editor, or new arguments. Bind while enabled, so hidden UI costs nothing during a switch. + +```csharp +using reromanlee.ReactiveLocalizer; +using reromanlee.ReactiveLocalizer.Samples.QuickStart; +using TMPro; +using UnityEngine; + +public sealed class ShopBar : MonoBehaviour +{ + [SerializeField] private TMP_Text _purchase; + [SerializeField] private TMP_Text _coins; + + private TextBinding _purchaseBinding; + private TextBinding _coinsBinding; + + public int Coins { get; set; } + + private void OnEnable() + { + // Quick Start's access point, or the project's own. + ILocalizer localizer = GlobalLocalizer.Instance; + // A static lambda takes its target as a parameter, so binding allocates nothing. + _purchaseBinding = localizer.Bind(LocalizationKeys.Shop.Purchase, _purchase, static (label, text) => label.text = text); + _coinsBinding = localizer.Bind(LocalizationKeys.Shop.CoinBalance(coins: Coins), _coins, static (label, text) => label.text = text); + } + + private void OnDisable() + { + _purchaseBinding.Dispose(); + _coinsBinding.Dispose(); + } + + private void Update() + { + // Formats again only when the count changed, so calling it every frame costs nothing. + _coinsBinding.SetMessage(LocalizationKeys.Shop.CoinBalance(coins: Coins)); + } +} +``` + +1. **`TextBinding` is a struct.** Disposing it twice, or disposing a copy, is safe. +2. **`BindCharacters`** hands the callback `ReadOnlyMemory` instead of a string, valid during the call, and formats messages into a buffer it reuses, so even a changing message allocates nothing. TextMeshPro takes the characters with `SetCharArray`, as the TextMeshPro sample's component does: + + ```csharp + _binding = localizer.BindCharacters(key, _text, static (text, characters) => + { + if (MemoryMarshal.TryGetArray(characters, out ArraySegment segment)) + { + text.SetCharArray(segment.Array, segment.Offset, segment.Count); + } + }); + ``` + +3. **A binding keeps its entry's table loaded,** so a table loaded on demand arrives with it; until then, the callback receives an empty string, or keeps its previous text during a switch. +4. **A callback that throws** is reported and skipped, and the other bindings still update. Bindings whose Unity target was destroyed are released and reported. +5. **Register formatters before binding:** text formatted before keeps its text until it is formatted again. + +`ReactiveText` wraps a binding for code that wants a value rather than a callback, such as view models, UI Toolkit data binding and reactive libraries: + +```csharp +ReactiveText title = new ReactiveText(localizer, LocalizationKeys.MainMenu.Title); +title.Changed += text => Debug.Log(text); +// title.Value is always the current text; dispose it when done. +``` + +### Switching languages + +```csharp +await localizer.SetLanguageAsync(LocalizationLanguages.Russian); +``` + +1. **A switch is all or nothing:** the new language's tables load, then every lookup and binding switches in the same frame. Bindings keep their previous text while the tables load. +2. **Rapid switches collapse,** so a player scrolling through a language list loads only the last one, and every task completes once it lands. +3. **`localizer.Languages`** lists the languages to offer, with the `DisplayName` a picker shows, and **`CurrentLanguage`** is the one shown. +4. **`LanguageChanging`** is raised when a switch starts loading, for a loading screen, and **`LanguageChanged`** once every text has switched. +5. **Before initialization,** `SetLanguageAsync` chooses the language the localizer starts in; without it, it starts in the source language. +6. **A language the catalog doesn't have** is reported and changes nothing. The tasks never fail. + +The package leaves remembering the player's choice and matching the OS language to the project: the Quick Start sample's `GlobalLocalizer` saves every switch and starts in the saved language, or in the OS language when the catalog has it, through a switch in `SystemLanguageMapping` that maps Unity's `SystemLanguage` to the catalog's languages. + +### Components and the Inspector + +`EntryReference` is the serializable field for an entry picked in the Inspector, and `CatalogReference` for a whole catalog, such as a language picker's. + +```csharp +[SerializeField] private EntryReference _title; +[SerializeField, EntryCatalog(typeof(ToolWindowKeys))] private EntryReference _toolTitle; + +private void OnEnable() +{ + _binding = localizer.Bind(_title.ToKey(), _label, static (label, text) => label.text = text); +} +``` + +1. **Click the field** to search keys and texts, with recent picks first. When nothing matches, the popup creates the entry: a search written as `Table.Entry` names it, and any other search becomes its text. The table defaults to the one named after the scene or prefab, and the key to the GameObject's role. +2. **The field shows `Shop.Purchase`** with the text in the preview language below it, red when the key doesn't exist, and an **Update** button when it points to an entry that was renamed. +3. **`[EntryCatalog(typeof(...))]`** limits a field to the catalog of a generated keys class. Otherwise the field saves whichever catalog its entry comes from, so a reusable component works with any catalog. +4. **`ToCatalogKey()`** gives the field's catalog, to pick the localizer, as Quick Start's components do. + +#### Ready-made components + +The Quick Start sample brings `LocalizedBehaviour`, the base of the samples' text components, which binds while enabled, previews in Edit Mode, and saves the authored text with the scene. The TextMeshPro, uGUI Text and UI Toolkit samples build on it. + +1. **Add *ReactiveLocalizer > Localized TextMeshPro*** to a GameObject with a TextMeshPro text and pick its entry, or right-click the text component and choose **Localize**, which turns its current text into a new entry and wires the component in one step. +2. **`LanguageFonts`** gives each language its own TextMeshPro font, such as one with Cyrillic or CJK glyphs, and its Inspector's **Check Font Coverage** lists the characters each language's font, fallbacks included, can't draw. +3. **A component of your own** derives from `LocalizedBehaviour` and binds its own kind of text: + +```csharp +[RequireComponent(typeof(Text))] +public sealed class LocalizedLegacyText : LocalizedBehaviour +{ + private Text _text; + + private Text Target => _text != null ? _text : _text = GetComponent(); + + protected override string Text + { + get => Target != null ? Target.text : null; + set + { + if (Target != null) + { + Target.text = value; + } + } + } + + protected override TextBinding Bind(ILocalizer localizer, in EntryKey key) + { + return localizer.Bind(in key, this, static (component, text) => component.Text = text); + } +} +``` + +#### Edit Mode preview + +The **Preview Language** overlay of the Scene view picks the language bound text shows in Edit Mode, the `Pseudo` language included. Entry fields and the table window preview in the same language. + +Code outside the samples opts in through `EditModePreview`: outside Play Mode, `EditModePreview.For(catalog)` hands out a localizer in the preview language. A component that shows preview text calls `EditModePreview.Track(this, restore, show)`, so it puts its authored text back before its scene or prefab saves and shows the preview again right after; `LocalizedBehaviour` already does this. + +### The table window + +**Window > ReactiveLocalizer > Tables** edits every table of every catalog. + +1. **The sidebar** lists catalogs and tables, with counts of problems. **New Table** adds one. +2. **The grid** has a row per entry, with the key, the translator context and a column per language; **Languages** hides columns. Missing, outdated, broken, too long and orphaned texts are tinted, and the filter menu shows only the entries missing in a language, outdated, or with problems. The search field searches keys, texts and comments. +3. **Double-click a cell** to edit it in place; Enter or leaving the cell saves it. Text with line breaks opens in the detail pane, where Enter starts a new line and Ctrl+Enter saves. +4. **The detail pane** of the selected entry has every language's text, the context, the maximum length, and a preview of a message with sample values, such as `1, 2, 5, 21`. An outdated translation has **Show source change**, which finds the source text it was made from in git and marks the words that changed, and **Mark as current**. +5. **New Entry, Rename, Move and Delete** change every language file at once. Renaming and moving leave `@formerly`, so everything that used the old name keeps working. Every change is one step Undo takes back. +6. **Validate** checks the whole project, and **Exchange** exports and imports translations. + +Generated code waits until Unity loses focus, or Play Mode ends, so editing tables in the editor never triggers a recompile. + +### Validation + +Every file is checked as Unity imports it, and problems go to the Console with their file and line. **Tools > ReactiveLocalizer > Validate** checks the whole project, references from scenes, prefabs and Resources included. + +1. **Errors fail player builds:** broken files and messages, merge conflict markers, duplicate keys, orphans, arguments that differ between languages, entries missing in required languages, and references to keys that don't exist. +2. **Warnings don't:** outdated and unverified translations, entries missing in other languages, texts over their maximum length, unused aliases and, when turned on, names that aren't PascalCase. +3. **Project Settings > ReactiveLocalizer** turns off failing builds on errors, turns on the PascalCase rule, and chooses whether Play Mode and development builds offer the pseudo language. +4. **A smoke test** can assert that a session never asked for a key that doesn't exist: + +```csharp +Assert.That(localizer.MissingKeyCount, Is.Zero); +``` + +Missing keys are reported as errors, so Unity's Test Framework already fails any test that touches one. + +### Loading and delivery + +Two settings at the top of a table's source-language file choose when it is in memory and how it ships: + +``` +@loading OnDemand +@delivery Streaming +``` + +1. **`@loading Preload`** (the default) loads the table with its language, so reads always work. **`OnDemand`** loads it only while something uses it, for large tables such as dialogue or item descriptions. +2. **`@delivery Embedded`** (the default) packs it inside the build. **`Streaming`** packs it into StreamingAssets, read without blocking the main thread, which keeps huge content out of memory and, on WebGL, out of the initial download. **`Custom`** leaves it out of builds, for a source of your own such as the Addressables sample's. + +Bindings hold their tables on their own. Code that reads an on-demand table directly, such as a dialogue system, holds it while it needs it: + +```csharp +private TableHandle _dialogue; + +private async void OnEnable() +{ + _dialogue = localizer.HoldTable(LocalizationKeys.Dialogue.TableKey); + if (await _dialogue.WhenLoaded) + { + ShowLine(localizer.Get("Dialogue", _lineKey)); + } +} + +private void OnDisable() +{ + _dialogue.Dispose(); +} +``` + +1. **A held table follows every language switch** and is unloaded two updates after nothing holds it, so a panel toggled within a frame keeps it. +2. **Holding a preloaded table changes nothing,** so code never needs to know how a table loads. +3. **An on-demand table read while nothing holds it** returns empty text, with one warning per table. +4. **Only what the current language needs is loaded:** a fallback language loads only for tables the current language leaves gaps in. + +### Formatters + +Dates, times and currencies follow the project's own conventions, so the package leaves them to formatters registered per type name. A formatter writes into the span it is given, so formatting allocates nothing, and returns false when the span is too small, to be called again with more room. + +```csharp +// {deadline, date} and {deadline, date, short}, in the conventions of the text's language. +localizer.SetFormatter("date", static (in MessageValue value, ReadOnlySpan style, LanguageInfo language, Span destination, out int written) => +{ + if (!value.TryGetDateTime(out DateTime date)) + { + written = 0; + return true; + } + string format = style.SequenceEqual("short".AsSpan()) ? "d" : "D"; + return date.TryFormat(destination, out written, format, CultureInfo.GetCultureInfo(language.Culture)); +}); +``` + +A formatter runs on whichever thread formats the message. A missing one shows the value as it is, dates as `yyyy-MM-dd HH:mm:ss`, and one that throws shows `{deadline}`; both are reported once. + +### Table sources and mods + +A localizer asks its host's table sources for each table, in order. `UnityHost` asks the sources it is given first, then the built-in ones: the imported files in the editor, and the tables packed into the build and into StreamingAssets in players. + +**Fan translations and downloaded text** come from `FolderTableSource`, which compiles `.lang` files from a folder when they are asked for. Asked first, it patches the languages the game ships entry by entry, and the game's tables fill in the rest. Languages it defines in a `.catalog` file are registered at runtime: + +```csharp +FolderTableSource mods = new FolderTableSource(LocalizationKeys.CatalogKey, modsFolder); +Localizer localizer = new Localizer(LocalizationKeys.CatalogKey, new UnityHost(mods)); + +List problems = new List(); +foreach (LanguageInfo language in mods.ReadLanguages(problems)) +{ + localizer.RegisterLanguage(language); +} +localizer.InitializeAsync(); +``` + +**A source of your own** implements `ITableSource`. It returns false at once for what it doesn't have, so the next source is asked, or true and then answers exactly once, from any thread, now or later: `receiver.Receive` with the compiled table, `receiver.Fail` with a reason, or `receiver.Decline` when it learns only later that it doesn't have the table, which passes the request on with nothing reported. A compiled table is the `Data` of the `TableAsset` a `.lang` file imports as, which is what the Addressables sample loads. + +```csharp +public sealed class DownloadedTables : ITableSource +{ + private readonly Dictionary<(ulong Table, ulong Language), byte[]> _tables = new(); + + public bool TryLoad(in TableRequest request, TableReceiver receiver) + { + if (request.IsCatalog || !_tables.TryGetValue((request.Table.Hash, request.Language.Hash), out byte[] compiled)) + { + return false; + } + receiver.Receive(compiled); + return true; + } +} +``` + +### Exchanging translations + +**Tools > ReactiveLocalizer > Export**, or **Exchange** in the table window, writes translations for translators, and the import commands read them back. + +1. **Pick the format:** an XLSX workbook with a sheet per table, CSV files per table, XLIFF 1.2 or 2.0 files per language, or an LLM handoff. +2. **Pick what goes out:** the tables, the languages, and all entries, only missing ones, only outdated ones, or both. +3. **Import** with **Import File** or **Import Folder**. The preview lists what is new, updated, outdated, confirmed, rejected and unchanged before anything is written, lets you change the catalog and the languages the file matched, and the whole import is one step Undo takes back. +4. **Imports change translations only.** They never create, rename or delete keys, an empty cell never erases a translation, and changes to the source column are ignored unless **Import source changes** is ticked. +5. **Every imported text is compiled like the files are,** so a translation with broken syntax or other arguments is rejected with the reason. +6. **The source text in the file becomes the fingerprint,** so a translation made from an older source text arrives already marked outdated. + +**With a language model:** export with the **LLM handoff** format and one language, which copies a text bundle to the clipboard: rules for the model, the language's plural forms, and the entries to translate with their context and maximum length. Paste it into a chat, copy the model's whole reply, and choose **Tools > ReactiveLocalizer > Import LLM Reply**, which goes through the same preview. No API key is involved. + +### Pseudo-localization + +Pick **Pseudo** in the Scene view's **Preview Language** overlay, or in a language picker in Play Mode, to see every text accented, a third longer and bracketed: `[Bûý ñöŵ one]`. Text that stays plain is hardcoded, a cut-off bracket is truncation, and the extra length shows overflow, before any translation exists. + +Play Mode and development builds offer it as one more language of every catalog, unless **Project Settings > ReactiveLocalizer** turns that off; release builds never do. A catalog that defines a language named `Pseudo` keeps its own. + +### Character sets + +**Tools > ReactiveLocalizer > Character Sets** collects every character a catalog's languages can show, for TextMeshPro font atlases: + +1. Pick the catalog and one or more languages; several languages give the set a font they share needs. +2. Add printable ASCII or extra characters for text that arguments bring at runtime, such as player names. +3. Copy the set, or save it as a text file, and use it in TextMeshPro's **Font Asset Creator** with **Custom Characters** or **Characters from File**. + +`CharacterSets.Collect(catalog, language)` is the editor API behind it, for build scripts that make font atlases. + +### Samples + +Import samples from **Window > Package Manager > ReactiveLocalizer > Samples**. The UI samples build on Quick Start, so import it first. + +| Sample | How to use it | +|---|---| +| Quick Start | Open its scene and enter Play Mode. `GlobalLocalizer.Instance` is the default catalog's localizer, and `GlobalLocalizer.For(catalog)` any other's; both remember the player's language. Add **ReactiveLocalizer > Language Picker** for a picker. | +| TextMeshPro | Add **Localized TextMeshPro**, or right-click a TextMeshPro text and choose **Localize**. **Assets > Create > ReactiveLocalizer > Language Fonts** gives each language its font. | +| UI Toolkit | On Unity 6, add a `LocalizedTextBinding` to an element's property in UI Builder or UXML. From code, `label.BindText(LocalizationKeys.Menu.Title)`. Without code, add **Localized Document** next to a UIDocument and list element names with their entries. | +| uGUI Text | Add **Localized Text (Legacy)**, or right-click a Text and choose **Localize**. | +| Addressables | Mark tables `@delivery Custom`, run **Tools > ReactiveLocalizer > Samples > Mark Custom Tables Addressable**, and build Addressables content as usual. A table Addressables don't have falls back as usual. | +| VContainer | In a `LifetimeScope`, `new LocalizerInstaller(LocalizationKeys.CatalogKey).Install(builder);`, then inject `ILocalizer`. | +| R3 | `localizer.ObserveText(key)`, `coins.SelectText(localizer, count => LocalizationKeys.Shop.CoinBalance(coins: count))` and `localizer.ObserveLanguage()`. | +| Google Sheets | Follow its README to deploy the Apps Script, then use **Tools > ReactiveLocalizer > Samples > Google Sheets Sync** to push and pull. | +| Mods | **Tools > ReactiveLocalizer > Samples > Install Example Mod** puts a mod in the player's mods folder; enter Play Mode and pick Elvish. | +| Editor Tool | Open **Tools > ReactiveLocalizer > Samples > Localized Tool Window** and read its code, a template for localizing a tool or package. | +| Unity Localization Migration | **Tools > ReactiveLocalizer > Samples > Migrate from Unity Localization**, described in [MIGRATION.md](MIGRATION.md). | + +### Coding agents + +**Tools > ReactiveLocalizer > Write Agent Guide** adds a section to the project's `AGENTS.md` or `CLAUDE.md`, asking which one, or creates `AGENTS.md` when neither exists. It tells agents where the catalogs and generated keys are, how to add, translate and rename entries, and what never to edit by hand. Run it again after adding catalogs or languages: it replaces its own section and leaves the rest of the file alone. + +### Assemblies + +Scripts in Assembly-CSharp see the package without any setup. An asmdef of your own references what it uses: + +| Assembly | Namespace | What it holds | Reference it from | +|---|---|---|---| +| `reromanlee.ReactiveLocalizer` | `reromanlee.ReactiveLocalizer` | The localizer, keys, messages, bindings and table sources, with no engine references | Game code | +| `reromanlee.ReactiveLocalizer.Unity` | `reromanlee.ReactiveLocalizer.Unity` | `UnityHost`, `EntryReference`, `CatalogReference` and `EditModePreview` | Game code | +| `reromanlee.ReactiveLocalizer.Authoring` | `reromanlee.ReactiveLocalizer.Authoring` | Reading, validating and writing files, exchange formats and pseudo-localization, with no engine references | Editor tools | +| `reromanlee.ReactiveLocalizer.Editor` | `reromanlee.ReactiveLocalizer.Editor` | The importers, windows and editor APIs such as `EntryAuthoring` | Editor tools | + +The generated keys compile into the assembly that owns the catalog's folder, so put a catalog where the code that uses it can see it. + +### Editor scripting + +Project tools use the same editor APIs the package's windows do: + +1. **`EntryAuthoring.TryCreateEntry`** creates an entry in a table's source file, as the Inspector field does, with `SuggestKey` and `SuggestTable` for names. +2. **`TranslationExchange.Collect`** gathers a catalog's entries for an export, and **`TranslationExchange.PreviewImport`** opens the import preview for files a tool fetched, as the Google Sheets sample does. +3. **`CharacterSets.Collect`** returns a language's character set. +4. **`MessageText.Quote`** turns any text into a message that shows it as written, and **`MessageText.IsValid`** checks a message, for tools that write entries. +5. **`NameRules.IsValid`** checks a name against the naming rule. From 828879d6a1a475b0619bcbca9f63cbea8eef3b69 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:54 +0300 Subject: [PATCH 08/14] document: performance --- Documentation/PERFORMANCE.md | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 Documentation/PERFORMANCE.md diff --git a/Documentation/PERFORMANCE.md b/Documentation/PERFORMANCE.md new file mode 100644 index 0000000..e6a49c7 --- /dev/null +++ b/Documentation/PERFORMANCE.md @@ -0,0 +1,56 @@ +# Performance + +What ReactiveLocalizer costs, measured, and the design behind the numbers. Tips to keep it light are at the end. + +### How it stays fast + +1. **A lookup is a binary search and a cached string.** Every name is hashed once, generated keys when their class initializes, and a loaded table keeps its key hashes sorted, so finding an entry is a binary search over 64-bit numbers. Its string is created the first time it's read, and every later read returns the same one. +2. **Steady state allocates nothing,** which tests enforce for cached lookups, binding updates, unchanged arguments and formatting into a buffer. +3. **Idle localization costs one flag check per frame.** Bindings never poll: they're called only when their text changes. +4. **A language switch touches only what is active.** Components bind while enabled, so hidden UI costs nothing during a switch and reads the current language when it shows. +5. **Messages format only when their arguments change,** so a binding given the same arguments every frame does nothing. +6. **Only the current language is loaded.** Fallback languages load only for tables the current language leaves gaps in, and on-demand tables only while something uses them. +7. **A loaded table is a handful of arrays,** whatever its size, read from the build with one copy per array. + +### CPU time + +Measured by the package's performance tests in the Editor, on an Intel Core i7-12700K, as the median of 10 measurements, and by the same operations in an IL2CPP release build for Windows made with Unity 2022.3, on the same machine. The Editor runs scripts on Mono, slower than IL2CPP player builds. Reads and formatting are timed over 10,000 calls and given per call, and allocations are counted by the Editor's tests. + +| Operation | IL2CPP build | Unity 6000.5 Editor | Unity 2022.3 Editor | Allocations | +|---|---|---|---|---| +| Read a text by key | 55 ns | 73 ns | 71 ns | None | +| Read a text by table and entry name | 85 ns | 90 ns | 90 ns | None | +| Format a plural message into a buffer | 0.55 µs | 0.79 µs | 0.79 µs | None | +| Format a plural message into a string | 0.66 µs | 0.99 µs | 1.00 µs | The string | +| Switch the language of 1,000 bound texts | 0.46 ms | 0.36 ms | 0.35 ms | The 1,000 new strings and about 75 more | +| Give 1,000 bound messages new arguments | 0.92 ms | 1.23 ms | 1.28 ms | The 1,000 new strings | +| Give 1,000 character-bound messages new arguments | 0.87 ms | 1.12 ms | 1.14 ms | None | +| Load a 50,000-entry table | 3.87 ms | 4.35 ms | 4.51 ms | 29 | +| Compile a 50,000-entry table, as its import does | Editor only | 244 ms | 247 ms | Editor only | + +1. **Lookups** read a table of 1,000 entries; by name, the two names are hashed on every call. +2. **The message** is `You have {coins, plural, one {# coin} other {# coins}}.` with a new count on every call. +3. **The switch** goes between English and Russian tables of 1,000 entries, every one of them bound; it loads the new tables, gives every binding its text, and releases the old tables. In the build, the tables come from the build's resources, while the Editor's tests hand them over from memory. +4. **The 50,000-entry table** holds lines of about 65 characters, loaded on demand, and is unloaded between measurements. + +### Memory + +1. **A loaded table** takes 2 bytes per character of its texts plus 24 bytes per entry: the 50,000 lines above take 7.3 MB. +2. **Strings are created only for the texts actually read,** at about 30 bytes plus 2 bytes per character each: reading every one of those 50,000 lines adds 7.8 MB, while a dialogue that shows a hundred lines adds about 16 KB. +3. **A compiled table** in a build is about as large as it is in memory: 6.9 MB for those lines. +4. **A complete translation costs no extra memory,** since its fallback languages are never loaded. + +### Where the numbers come from + +The Editor numbers come from the tests in `UnityPackage/Tests/Performance`, which run with every other test on both Unity versions in continuous integration, where the results are part of the test results artifacts. They need the Performance Testing package, `com.unity.test-framework.performance`, and compile only when it's installed. Nothing in them asserts a speed, since that depends on the machine; the release checklist compares them with the previous release's. + +### Tips + +1. **Bind text that stays on screen,** and read it with `Get` only where it's read once. A binding costs nothing until its text changes. +2. **Use `BindCharacters` for text that changes often,** such as a counter, with TextMeshPro: it formats into a buffer it reuses and creates no strings at all. +3. **Mark large tables `@loading OnDemand`,** such as dialogue, quests and item descriptions, and split them by chapter or area, so a load stays small and memory holds only what's in use. +4. **Use `@delivery Streaming` for large content on WebGL,** where it stays out of the initial download and loads only when asked for. +5. **Keep shipped translations complete,** with `Required = true` on their languages, so no fallback table is ever loaded. +6. **Set the language before initializing** with `SetLanguageAsync`, so the localizer starts in the player's language instead of loading the source language first. +7. **Opt data tables out of code generation** with `@generateCode false`, so a 50,000-line table doesn't become 50,000 fields to compile. +8. **Register formatters before binding,** so no text is formatted twice. From 4fe5e1ed1462299d61d605f700add82176f35f72 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:54 +0300 Subject: [PATCH 09/14] document: migration --- Documentation/MIGRATION.md | 69 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 Documentation/MIGRATION.md diff --git a/Documentation/MIGRATION.md b/Documentation/MIGRATION.md new file mode 100644 index 0000000..6072fb4 --- /dev/null +++ b/Documentation/MIGRATION.md @@ -0,0 +1,69 @@ +# Migrating from Unity Localization + +ReactiveLocalizer replaces Unity Localization rather than building on it. The two can stay installed side by side while a project moves over, since none of their type names collide. The Unity Localization Migration sample converts the data in one step; the code changes are listed after it. Most important first. + +### Converting the tables + +1. **Import the sample** from **Window > Package Manager > ReactiveLocalizer > Samples > Unity Localization Migration**. It needs Unity Localization 1.4 or newer, still installed. +2. **Run Tools > ReactiveLocalizer > Samples > Migrate from Unity Localization** and pick a folder in Assets without localization files, such as `Assets/Localization`. It says how many tables, texts and languages it will write, and writes nothing until you confirm. +3. **Read the report in the Console:** the names it changed to follow the naming rule, the placeholders it turned into named arguments, and the texts it left for you to convert. +4. **Search the new files for `MIGRATE`,** the comment above every text to convert by hand, convert them, and delete the comments. + +The migration writes `Localization.catalog` and a `.lang` file per table and language, so the generated classes are `LocalizationKeys` and `LocalizationLanguages`. A project that wrapped Unity Localization in its own generated keys class of that name keeps its call sites, such as `LocalizationKeys.SettingsPage.Title`: only the `using` lines change. + +| Unity Localization | Becomes | +|---|---| +| A locale | A language named after its language in English, such as `English`, or with its region when two share a language, such as `PortugueseBrazil`; its code becomes the `Culture` | +| The project locale | The source language, or the first locale without Localization Settings | +| A fallback locale | The language's `Fallback` | +| A string table collection | A table, named in PascalCase: `Settings Page` becomes `SettingsPage` | +| A key | An entry, named in PascalCase: `menu/play_button` becomes `MenuPlayButton` | +| A comment on a shared entry | The comment above the entry, which translators see | +| An empty translation | A missing one, which falls back as before | +| `{0}` in any string | `{arg0}`, listed so you can rename it after what it shows | +| `{name}` in a Smart string | `{name}` | +| `{{` and `}}` in a Smart string | Literal braces | +| Any other Smart Format syntax | Kept so it shows as written, and marked `MIGRATE` | + +Translations are stamped with the fingerprint of the migrated source text, so they start out current, and a later change to a source text flags them as outdated. + +### Converting Smart Format by hand + +Smart Format's formatters have ICU counterparts, which translation tools also understand. Each translation writes the plural forms of its own language. + +| Smart Format | ICU | +|---|---| +| `{count:plural:One apple\|{} apples}` | `{count, plural, one {One apple} other {# apples}}` | +| `{gender:choose(male\|female):He\|She\|They}` | `{gender, select, male {He} female {She} other {They}}` | +| `{count:cond:=0?No apples\|Some apples}` | `{count, plural, =0 {No apples} other {Some apples}}` | +| `{player.Name}` | `{playerName}`, passed by the code as an argument | +| `{0:N2}` | `{arg0, number}`, which shows up to three fraction digits; for an exact count, format the number in code and pass it as text | +| `{0:P}` | `{arg0, number, percent}` | +| A date with a format, such as `{0:d}` | `{arg0, date}` with a date formatter the project registers | + +### Changing the code + +| Unity Localization | ReactiveLocalizer | +|---|---| +| `await LocalizationSettings.InitializationOperation` | `await localizer.InitializeAsync()`, which completes within the call for embedded tables | +| `LocalizationSettings.StringDatabase.GetLocalizedString("Shop", "Purchase")` | `localizer.Get(LocalizationKeys.Shop.Purchase)`, or `localizer.Get("Shop", "Purchase")` for names from data | +| `GetLocalizedStringAsync(...)` | `Get`, which is synchronous: preloaded tables are always in memory | +| A `LocalizedString` field | An `EntryReference` field, picked in the Inspector the same way | +| `localizedString.StringChanged += ...` | `localizer.Bind(key, target, static (target, text) => ...)`, disposed in `OnDisable` | +| Smart string arguments | Messages from generated code, such as `LocalizationKeys.Shop.CoinBalance(coins: 5)`, and `binding.SetMessage(...)` to change them | +| A `LocalizeStringEvent` component | The samples' `LocalizedTextMeshPro`, `LocalizedLegacyText` or UI Toolkit bindings | +| `LocalizationSettings.SelectedLocale = locale` | `await localizer.SetLanguageAsync(LocalizationLanguages.Russian)`, or Quick Start's `GlobalLocalizer.SetLanguage` for every catalog | +| `LocalizationSettings.SelectedLocaleChanged` | `localizer.LanguageChanged` | +| `LocalizationSettings.AvailableLocales.Locales` | `localizer.Languages` | +| Tables loaded on demand | `@loading OnDemand` on the table, held by bindings or `HoldTable` | +| String tables delivered by Addressables | Embedded in the build by default; `@delivery Custom` with the Addressables sample keeps delivering them through Addressables | +| A pseudo-locale | The `Pseudo` language every catalog has in the editor, Play Mode and development builds | + +The selected locale's persistence and the system locale selector become the Quick Start sample's `GlobalLocalizer`, which saves the player's choice and maps the OS language through `SystemLanguageMapping`. + +### What stays with Unity Localization + +1. **Asset tables,** such as localized sprites, audio and fonts, aren't part of ReactiveLocalizer 1.0, which localizes strings. Keep Unity Localization for them while the string tables move over; TextMeshPro fonts per language come from the TextMeshPro sample's `LanguageFonts`. +2. **Smart Format's other features,** such as list formatting, templates and custom sources, have no ICU counterpart: build those texts in code from several entries. + +Once nothing uses its types, remove the Unity Localization package; the migrated files stand on their own. From a3b730b1520cc2a9ef48a3a56a23510ddd15d6f2 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:54 +0300 Subject: [PATCH 10/14] document: readme --- .github/table-window.png | Bin 0 -> 93471 bytes README.md | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 .github/table-window.png diff --git a/.github/table-window.png b/.github/table-window.png new file mode 100644 index 0000000000000000000000000000000000000000..6140a85970fbba09bb4016020ec4b5b6e17dcba2 GIT binary patch literal 93471 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

S@#$beP?J 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index e69de29..6f3a451 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,108 @@ +### About ReactiveLocalizer + +Localization for Unity that scales from a five-minute prototype to hundreds of thousands of entries, with no dependencies. + +The table window with the Quick Start sample's catalog + +Text lives in plain `.lang` files, one per table and language, that diff, merge and review like code. The editor compiles them into compact tables, generates keys the compiler checks, and gives messages with arguments typed methods, so a mistyped key or a renamed argument fails at compile time, never in front of players. + +Bindings follow every language switch without allocating or polling, invented and fan-made languages work like real ones, and a table window, Inspector fields, validation, and exchange with spreadsheets, translation tools and language models cover the whole workflow, from the first entry to a shipped translation. + +### Features + +1. Text files that diff and merge. +2. Keys the compiler checks. +3. ICU plurals, selects and numbers. +4. Bindings that allocate nothing. +5. A table window for all languages. +6. Inspector fields that create entries. +7. Live preview in Edit Mode. +8. Validation that gates builds. +9. XLSX, CSV, XLIFF and LLM exchange. +10. Invented and runtime languages. +11. Tables loaded on demand or streamed. +12. Renames that never break references. +13. Pseudo-localization and font sets. +14. CLDR plural rules, no CultureInfo. +15. Every platform, WebGL included. +16. 11 samples, from TextMeshPro to mods. + +Detailed about features - see [FEATURES.md](Documentation/FEATURES.md). + +### Performance + +**Reading a text takes about 55 ns and allocates nothing,** a plural message formats into a buffer in about 0.55 µs, and switching the language of 1,000 bound texts takes 0.46 ms, in an IL2CPP build on a desktop with an Intel Core i7-12700K. A frame where no text changes costs one flag check. + +**A 50,000-entry dialogue table loads in about 4 ms** and takes 7.3 MB, 2 bytes per character plus 24 per entry, and strings are created only for the lines actually shown. Compiling it, which its import does in the editor, takes about a quarter of a second. + +Detailed about performance - see [PERFORMANCE.md](Documentation/PERFORMANCE.md). + +### Requirements + +1. Unity 2022.3 or later. +2. No other packages; samples need theirs. +3. Git, only for *Show source change*. +4. Unity Web Request, to stream on Android and WebGL. + +### Limitations + +1. Strings only, no localized assets. +2. No right-to-left shaping, only a hint. +3. Dates and currency need formatters. +4. Number styles: plain, integer, percent. +5. ICU MessageFormat 1, not 2. +6. XLIFF 1.2 skips control characters. +7. Custom-delivered tables have no pseudo text. + +### How to install + +**From the git URL:** in **Window > Package Manager**, choose **+ > Install package from git URL** and enter the URL below. Add `#1.0.0` to its end to stay on that version. + +``` +https://github.com/reromanlee/ReactiveLocalizer.git?path=/UnityPackage +``` + +**From a tarball:** download the `.tgz` file of the [latest release](https://github.com/reromanlee/ReactiveLocalizer/releases/latest), then in **Window > Package Manager** choose **+ > Install package from tarball** and pick it. + +**Then import the samples you need** from the package's **Samples** tab, Quick Start first. + +Migrating from Unity Localization - see [MIGRATION.md](Documentation/MIGRATION.md). + +### How to use + +**Add a table, then write entries in the table window or in the file itself.** **Assets > Create > ReactiveLocalizer > Table** creates `NewTable.English.lang`, and in a new project the default catalog with it; rename it after its feature, such as `MainMenu.English.lang`. Each entry is a line, the comment above it is what translators see, and translations go in `MainMenu.Russian.lang`. + +``` +# The button that starts a new game. +Play = Play + +# How many coins the player has, under the button. +Coins = You have {coins, plural, one {# coin} other {# coins}}. +``` + +**Read or bind text through the keys the editor generates.** A binding calls its callback right away, and again whenever the text changes, a language switch included, without allocating; dispose it when the UI hides. A switch loads the new language first and then changes every text in the same frame. + +```csharp +using reromanlee.ReactiveLocalizer; +using reromanlee.ReactiveLocalizer.Unity; + +Localizer localizer = new Localizer(LocalizationKeys.CatalogKey, new UnityHost()); +await localizer.InitializeAsync(); + +TextBinding play = localizer.Bind(LocalizationKeys.MainMenu.Play, _playLabel, static (label, text) => label.text = text); +string coins = localizer.Get(LocalizationKeys.MainMenu.Coins(coins: 5)); + +await localizer.SetLanguageAsync(LocalizationLanguages.Russian); +``` + +**Or skip the code:** import the Quick Start and TextMeshPro samples, add **Localized TextMeshPro** to a text, and pick its entry in the Inspector, which searches every key and text and creates missing entries on the spot. The Scene view's **Preview Language** overlay shows every language, `Pseudo` included, live in Edit Mode, and **Tools > ReactiveLocalizer > Export** hands translators an XLSX, CSV or XLIFF file, or a language model the entries it should translate. + +Detailed about usage - see [USAGE.md](Documentation/USAGE.md). + +The exact rules of `.lang` and `.catalog` files - see [FORMAT.md](Documentation/FORMAT.md). + +### License + +Made by Roman Likhadievski ([reromanlee.com](https://reromanlee.com)). + +Licensed under MIT - see [LICENSE.md](LICENSE.md). From a3110f1869d6379f66ce2c859f6aebac735c8640 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:55 +0300 Subject: [PATCH 11/14] document: release steps --- .github/RELEASE_CHECKLIST.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/RELEASE_CHECKLIST.md diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..d52c183 --- /dev/null +++ b/.github/RELEASE_CHECKLIST.md @@ -0,0 +1,21 @@ +# Release checklist + +Continuous integration checks what a machine without a display or a device can: the pure assemblies under .NET, every test on the oldest and newest supported Unity, and player builds for WebGL, Android, iOS, macOS and Linux. The rest is checked by hand before each release, most important first. + +### Before merging the release pull request + +1. Every check of the pull request passes: the fast lane, the tests on both Unity versions, and every player build. +2. The Quick Start sample runs in Windows, Android and WebGL builds: every language switch shows at once, the saved language comes back after a restart, Pirate's missing hint falls back to English, and a development build lists Pseudo while a release build doesn't. +3. The timings in the test results artifacts are no worse than the previous release's, and when the runtime changed, the IL2CPP numbers in `Documentation/PERFORMANCE.md` are measured again on the same machine. +4. In the Editor, on both Unity versions, the table window adds, edits, renames, moves and deletes entries, and Undo takes each back; the Inspector field searches and creates entries; the Preview Language overlay previews every language in Edit Mode while scenes and prefabs save their authored text; and Validate and the build gate report a broken file, an orphan and a missing key. +5. An exchange round trip works: XLSX through Excel or LibreOffice, CSV, XLIFF 2.0 through a translation tool, and an LLM handoff through a chat, each imported through the preview. +6. Every sample imports and compiles with its package on both Unity versions, and runs: TextMeshPro with fonts per language and the coverage check, UI Toolkit with the UXML binding on Unity 6, uGUI Text, Addressables loading a custom table in a build, VContainer starting the localizer, R3, Mods with the example mod in a build, the Editor Tool window, the Unity Localization migration, and the Google Sheets sync pushing to and pulling from a test spreadsheet. +7. A Profiler capture of an Android development build switching languages shows no allocations in steady frames and no new spikes. +8. *Write Agent Guide* writes a section that matches the release, into a project with and without `AGENTS.md`. +9. `package.json` has the new version, `CHANGELOG.md` has its section under the version instead of *Unreleased*, and the README and `Documentation/` describe the release; a release that changes the file format updates `Documentation/FORMAT.md`, and a major release adds a section to `Documentation/MIGRATION.md`. + +### After merging + +1. Tag the squashed commit on `main` with the version alone, such as `1.0.0`. +2. Export the signed tarball of the package from the Package Manager. +3. Draft the GitHub release from the tag, named like `v1.0.0 (signed)`, with the changelog section as its notes and the tarball attached. From c0d5305d6f23133d5ae4103e96cb613531488806 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:44:55 +0300 Subject: [PATCH 12/14] document: changelog --- CHANGELOG.md | 61 ++++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 699a7d5..57f4eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,37 +12,36 @@ 8. Exchange with translators: an XLSX workbook, CSV files or XLIFF 1.2 and 2.0 files, exported with a choice of tables, languages, and all entries or only missing or outdated ones. Keys are written `Shop.Purchase`, so renaming a file or sheet changes nothing. 9. Imports that change translations only, never keys: a preview lists every new, updated, outdated, confirmed and rejected text before anything is written, each compiled like the files, and the whole import is one step Undo can take back. The source text in the file becomes the fingerprint, so a translation of an older text arrives outdated. 10. An LLM handoff: missing or outdated entries copied as a text bundle with rules for the model, its plural forms included, and the model's reply imported from the clipboard, with no API keys. -11. Pseudo-localization: a `Pseudo` language made from the source text, accented, a third longer and bracketed, so hardcoded text, truncation and overflow show before any translation exists. The editor previews it, Play Mode and development builds list it, and release builds never ship it. -12. Character sets, `Tools/ReactiveLocalizer/Character Sets`: every character a language can show, the fallback text of missing entries and number symbols included, copied or saved for TextMeshPro's Font Asset Creator. -13. Samples for every common integration: TextMeshPro with a font per language and a check of which characters each font can't draw, UI Toolkit with a UXML data binding on Unity 6 and code binding on 2022.3, uGUI Text, Addressables, VContainer, R3, Google Sheets two-way sync, mods, a localized editor tool, and a migration from Unity Localization. -14. Migration from Unity Localization: locales become languages and string tables become .lang files, with keys, comments and plain placeholders kept, and a report of what needs converting by hand. -15. `@delivery Custom` tables that builds don't pack, for a project's own source such as Addressables, and sources that answer asynchronously can decline a request so the next source is asked with nothing reported. -16. Bindings that update text on every language switch without allocating, and messages that are formatted again only when their arguments change. -17. Live reload: saving a table file updates bound text at once, in Play Mode and Edit Mode. -18. A Scene view overlay that picks the preview language. Components preview their text live in Edit Mode through `EditModePreview`, and scenes and prefabs always save the text they were authored with. -19. Rename, move and delete entries across every language at once. Moved entries keep working through `@formerly Table.Entry`, at runtime and in generated code. -20. Tables loaded on demand: `@loading OnDemand` tables stay in memory only while bindings or `HoldTable` handles use them, and follow every language switch while they do. -21. Streaming delivery: `@delivery Streaming` tables ship in StreamingAssets and load without blocking the main thread, through UnityWebRequest on Android and WebGL. -22. Fan translations and mods: `FolderTableSource` compiles `.lang` files from a folder at runtime, `RegisterLanguage` adds new languages, and sources asked first patch shipped languages entry by entry. -23. Plural rules and number formats for every CLDR 48 locale, built in without `CultureInfo`, with digits and separators each language can override. -24. Fallback languages loaded only for tables a translation leaves gaps in, so a complete translation costs no extra memory. -25. `ReactiveText` for view models and data binding, and `BindCharacters` for text that updates without allocating, such as TextMeshPro's. -26. Import checks that report problems at their line and column, check every translation against its source text and the plural forms of its language, and suggest the key an orphan most likely meant. -27. *Show source change* finds the source text an outdated translation was made from in git, and marks the words that changed. -28. One canonical layout for table files, written by every tool, with keys in natural order, such as `Line9` before `Line10`. -29. Creating a catalog asks for its source language and culture, and checks the culture against CLDR. -30. Lock-free lookups from any thread, with language switches applied all at once on the main thread, and rapid switches sharing what they load. -31. Missing keys shown as `[Table.Key]` in every build and reported once, and failures that never throw into game code. -32. Unity integration: `UnityHost`, tables packed into builds, the editor reading table files directly, and `EntryReference` and `CatalogReference` Inspector fields. -33. Public helpers for project tools: `NameRules`, `MessageText`, `TranslationExchange` and `CharacterSets`. +11. An agent guide, `Tools/ReactiveLocalizer/Write Agent Guide`: a section of the project's `AGENTS.md` or `CLAUDE.md` telling coding agents where the catalogs and generated keys are, how to add and rename entries, and what never to edit by hand, updated in place when written again. +12. Pseudo-localization: a `Pseudo` language made from the source text, accented, a third longer and bracketed, so hardcoded text, truncation and overflow show before any translation exists. The editor previews it, Play Mode and development builds list it, and release builds never ship it. +13. Character sets, `Tools/ReactiveLocalizer/Character Sets`: every character a language can show, the fallback text of missing entries and number symbols included, copied or saved for TextMeshPro's Font Asset Creator. +14. Samples for every common integration: TextMeshPro with a font per language and a check of which characters each font can't draw, UI Toolkit with a UXML data binding on Unity 6 and code binding on 2022.3, uGUI Text, Addressables, VContainer, R3, Google Sheets two-way sync, mods, a localized editor tool, and a migration from Unity Localization. +15. Migration from Unity Localization: locales become languages and string tables become .lang files, with keys, comments and plain placeholders kept, and a report of what needs converting by hand. +16. `@delivery Custom` tables that builds don't pack, for a project's own source such as Addressables, and sources that answer asynchronously can decline a request so the next source is asked with nothing reported. +17. Bindings that update text on every language switch without allocating, and messages that are formatted again only when their arguments change. +18. Live reload: saving a table file updates bound text at once, in Play Mode and Edit Mode. +19. A Scene view overlay that picks the preview language. Components preview their text live in Edit Mode through `EditModePreview`, and scenes and prefabs always save the text they were authored with. +20. Rename, move and delete entries across every language at once. Moved entries keep working through `@formerly Table.Entry`, at runtime and in generated code. +21. Tables loaded on demand: `@loading OnDemand` tables stay in memory only while bindings or `HoldTable` handles use them, and follow every language switch while they do. +22. Streaming delivery: `@delivery Streaming` tables ship in StreamingAssets and load without blocking the main thread, through UnityWebRequest on Android and WebGL. +23. Fan translations and mods: `FolderTableSource` compiles `.lang` files from a folder at runtime, `RegisterLanguage` adds new languages, and sources asked first patch shipped languages entry by entry. +24. Plural rules and number formats for every CLDR 48 locale, built in without `CultureInfo`, with digits and separators each language can override. +25. Fallback languages loaded only for tables a translation leaves gaps in, so a complete translation costs no extra memory. +26. `ReactiveText` for view models and data binding, and `BindCharacters` for text that updates without allocating, such as TextMeshPro's. +27. Import checks that report problems at their line and column, check every translation against its source text and the plural forms of its language, and suggest the key an orphan most likely meant. +28. *Show source change* finds the source text an outdated translation was made from in git, and marks the words that changed. +29. One canonical layout for table files, written by every tool, with keys in natural order, such as `Line9` before `Line10`. +30. Creating a catalog asks for its source language and culture, and checks the culture against CLDR. +31. Lock-free lookups from any thread, with language switches applied all at once on the main thread, and rapid switches sharing what they load. +32. Missing keys shown as `[Table.Key]` in every build and reported once, and failures that never throw into game code. +33. Unity integration: `UnityHost`, tables packed into builds, the editor reading table files directly, and `EntryReference` and `CatalogReference` Inspector fields. +34. Guides in `Documentation/`: usage, every feature, the exact grammar of `.lang` and `.catalog` files, migrating from Unity Localization, and performance measured in the Editor and in an IL2CPP build by performance tests that run with every other test. +35. Public helpers for project tools: `NameRules`, `MessageText`, `TranslationExchange` and `CharacterSets`. ### Known issues -1. The README and the guides in Documentation/ aren't written yet. -2. Tables delivered by custom sources, such as Addressables, have no pseudo version, so pseudo-localization shows their source text. -3. Undo keeps whole copies of the table files each edit changes, so many edits of one very large table take a lot of memory. -4. Spreadsheet applications take CSV texts starting with = for formulas, so exchange XLSX with spreadsheets; formula errors in an imported CSV are rejected. -5. XLIFF 1.2 can't hold control characters, so entries with one are left out of it and reported; XLIFF 2.0 and XLSX keep them. -6. Character sets can't know text that arguments bring at runtime, such as player names; the window adds printable ASCII or extra characters for it. -7. The Google Sheets sample was checked offline only, since a live check needs a Google account; its requests follow Apps Script's documented web app behavior. -8. The migration converts Smart Format's plain placeholders only; its plural, choose and other formatters are kept as written and marked for converting by hand. +1. Right-to-left text isn't shaped: a language's `Direction` is only a hint, and shaping comes from the text renderer, such as UI Toolkit's advanced text generator, or RTLTMPro for TextMeshPro. +2. Spreadsheet applications take CSV texts starting with `=`, `+`, `-` or `@` for formulas, so exchange XLSX with spreadsheets; formula errors in an imported CSV are rejected. +3. XLIFF 1.2 can't hold control characters, so entries with one are left out of it and reported; XLIFF 2.0 and XLSX keep them. +4. Character sets can't know text that arguments bring at runtime, such as player names; the window adds printable ASCII or extra characters for it. +5. Fingerprints are six hexadecimal digits, so about one change of a source text in 16.7 million keeps its fingerprint and leaves its translations marked current. From dbbddd23dfca98c48023b956c88cffe33f83985d Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:45:05 +0300 Subject: [PATCH 13/14] remove: concept folder --- ...eactiveLocalizer (design specification).md | 580 ------------------ .../ReactiveLocalizer (initial concept).md | 88 --- .../ReactiveLocalizer (revised concept).md | 63 -- 3 files changed, 731 deletions(-) delete mode 100644 Concept/ReactiveLocalizer (design specification).md delete mode 100644 Concept/ReactiveLocalizer (initial concept).md delete mode 100644 Concept/ReactiveLocalizer (revised concept).md diff --git a/Concept/ReactiveLocalizer (design specification).md b/Concept/ReactiveLocalizer (design specification).md deleted file mode 100644 index 2658f86..0000000 --- a/Concept/ReactiveLocalizer (design specification).md +++ /dev/null @@ -1,580 +0,0 @@ -# ReactiveLocalizer: Design Specification - -Locked on 2026-10-04 after a full design review. It supersedes `ReactiveLocalizer (initial concept).md` and `ReactiveLocalizer (revised concept).md`, which stay as history. Implementation follows this document, and any change to it is a deliberate decision. - -## 1. Principles - -1. **Headless core.** The package defines, organizes and retrieves localized text. Everything that touches a concrete package (TextMeshPro, uGUI, UI Toolkit glue, Addressables, DI containers, R3) or a project policy (detecting the OS language, saving the player's choice, global access) lives in project code or in `Samples~`. -2. **No dependencies.** `package.json` declares none. Integration code compiles only when its package is present, so importing a sample can never break compilation. -3. **One data model at every scale.** A prototype and a production project with hundreds of thousands of entries use the same files, runtime and tools. Growing never needs a migration. -4. **Foolproof by construction.** Mistakes are made impossible where possible and loud everywhere else. Nothing crashes, nothing blocks, and every problem is reported with its location. -5. **A precise performance contract.** Steady-state lookups and binding updates allocate nothing, and idle localization costs no CPU. Formatted output allocates one string at the UI boundary, or nothing when written into a caller's buffer. Tests enforce all of it. -6. **Text files are the truth.** They are diffable, mergeable, reviewable, and editable by humans and AI agents. Everything else is compiled from them. - -## 2. Scope - -1. v1 localizes strings with ICU MessageFormat. The key, table and loading model is independent of the value type, so asset tables (sprites, voice-over, video) can be added later without reshaping it. -2. The package is standalone and has no dependency on `com.unity.localization`; it replaces it. LockInGoals is the first migration target. -3. The minimum Unity version is 2022.3, which stays the preferred version for WebGL projects. CI covers the oldest and the newest supported Unity. -4. Out of scope for the core: - 1. **Right-to-left shaping.** UI Toolkit's Advanced Text Generator or RTLTMPro shape the text. The core only carries a `Direction` hint, and samples may post-process text per language. - 2. **Built-in machine translation.** It is replaced by the LLM handoff (section 13). - 3. **Date, time and currency formatting.** These go through formatter functions the project registers (section 10). - -## 3. Package structure - -| Assembly | Folder | References | Contents | -|---|---|---|---| -| `reromanlee.ReactiveLocalizer` | `UnityPackage/Core/` | None, `noEngineReferences` | Catalogs, languages, identity, compiled tables, the text parser, lookup, fallback, ICU, CLDR data, reactive bindings, loading coordination, the host contract | -| `reromanlee.ReactiveLocalizer.Unity` | `UnityPackage/Unity/` | Core, UnityEngine only | `UnityHost`, built-in table sources, the frame hook, Console diagnostics, destroyed-object checks, `EntryReference` | -| `reromanlee.ReactiveLocalizer.Authoring` | `UnityPackage/Authoring/` | Core, `noEngineReferences`, Editor platform only | The canonical writer, validator, code emitter, CSV, XLSX, XLIFF and LLM exchange, pseudo-localization, character sets | -| `reromanlee.ReactiveLocalizer.Editor` | `UnityPackage/Editor/` | All of the above, UnityEditor | Importers, build steps, the table window, the inspector field, the Scene view overlay, menus | - -1. Samples live in `UnityPackage/Samples~//`, each with its own asmdef. -2. Tests live in `UnityPackage/Tests//`, named `reromanlee.ReactiveLocalizer.Tests.`. -3. The pure assemblies (core and authoring) and their tests also build with plain `dotnet` from `DotNet/`, whose projects link the package sources. A Unity-only API can then never slip in unnoticed, and a future .NET (NuGet) release stays possible. `DotNet/CldrGenerator` writes the core's CLDR data (section 10). -4. `UnityProject/` is the host project for tests and player builds, as in Wireframes. - -## 4. Languages and catalogs - -### 4.1 Languages - -A language is a record the project defines. Standard codes are optional metadata, so invented and fan-made languages work as well as real ones. - -| Field | Example | Purpose | -|---|---|---| -| `Name` | `English`, `PortugueseBrazil`, `Pirate` | Identity: the generated constant (`LocalizationLanguages.Pirate`), file names, the player's saved choice | -| `DisplayName` | `Português (Brasil)`, `Pirate Speak` | What a language picker shows | -| `Culture` *(optional)* | `pt-BR`, empty for invented languages | Plural rules, number formatting, the language tag in exports | -| `Fallback` *(optional)* | `English` for `Pirate` | Where missing entries come from | -| `Direction` | `LeftToRight`, `RightToLeft` | A hint for whatever renders the text | -| `Required` *(optional)* | `true` | A build fails while any entry is missing in this language | -| `Digits`, `DecimalSeparator`, `GroupSeparator` *(optional)* | `0123456789`, `,`, `\u00A0` | Number symbols that replace the culture's | - -1. **The source language** is the one the project writes in, and can be any language (`@source Russian` for a Russian-first project). It defines the key set, is the last fallback for every language, and is the language the localizer starts in until project code picks another. -2. **Changing the source language later** is a tool operation, never a core change: - 1. The tool checks that the new source has every key. - 2. It swaps the roles of the files. - 3. It re-stamps the fingerprints, keeping anything that was already outdated flagged. -3. **Plural rules** come from `Culture`. Without one they come from the fallback language, and without either, every count uses ICU's `other` form. -4. **Number symbols** (decimal and grouping separators, digits) come from `Culture` the same way, and any language can override them in its catalog section with `Digits`, `DecimalSeparator` and `GroupSeparator`. An empty `GroupSeparator` turns grouping off. Arabic with Latin digits is `Culture = ar` and `Digits = 0123456789`. -5. **A culture CLDR doesn't know** is reported as a warning, and the language then takes its plural rules and number symbols from its fallback, as if it had no culture. A known culture without plural data of its own uses CLDR's root rules, as CLDR does. -6. **Runtime languages.** Languages can be registered at runtime, for example a fan translation a player drops into a mods folder. Generated constants are only a convenience for the languages known at compile time. -7. **The OS language** is mapped to a project language by project code (a switch). The Quick Start sample shows the pattern. - -### 4.2 Catalogs - -A catalog is a self-contained localization unit: its languages, its settings and its tables. - -1. A `.catalog` file claims every table in its folder and below, the way an asmdef claims scripts. -2. Tables outside any catalog belong to the project's default catalog, the way scripts outside any asmdef end up in Assembly-CSharp. -3. The default catalog is created on first use as `Assets/Localization/Localization.catalog`. Its generated classes are `LocalizationKeys` and `LocalizationLanguages`. Creating it asks for the source language, defaulting to `English` with culture `en`. -4. The default catalog is the one at `Assets/Localization/Localization.catalog` when that file exists, so importing a sample that brings its own catalog changes nothing. Otherwise it is the only catalog in Assets outside an Editor folder. When neither applies, tables outside every catalog folder are reported instead of guessed. -5. Packages and editor tools ship their own catalog, so their tables and languages never mix with the game's. -6. A catalog inside an `Editor` folder is editor-only and is never packed into player builds. -7. Table names are unique within a catalog. - -The catalog file uses the table syntax (section 5.3), with one `[Language]` section per language: - -``` -@source English -@namespace MyGame - -[English] -DisplayName = English -Culture = en - -[Russian] -DisplayName = Русский -Culture = ru -Required = true - -[Pirate] -DisplayName = Pirate Speak -Fallback = English -``` - -## 5. Table files - -### 5.1 Layout - -1. Each table is stored as one file per language, named `

..lang` (for example `Shop.English.lang` and `Shop.Russian.lang`). -2. Identity comes from the file name, so folders are free-form. A table can sit next to the feature that owns it, or every table can live in one place. -3. A translation file holds only translated entries. A missing key means untranslated, and an empty value means intentionally empty. Adding a key changes only the source-language file. -4. Every table uses the same layout. There is no per-table choice between one combined file and split files. - -### 5.2 Identity - -1. An entry is identified by `Table` + `Key` everywhere: in files, generated code, serialized component fields and lookups from project data. Files carry no hidden IDs. -2. Names (catalogs, tables, keys, languages) are ASCII letters, digits and underscores, start with a letter, and are unique regardless of case. Any table can therefore turn on code generation at any time. Numeric data IDs need a prefix, such as `Item10234`. -3. The PascalCase, no-underscore convention from LockInGoals is an optional validator rule, not a package-wide restriction. -4. At runtime every name becomes a 64-bit FNV-1a hash of its UTF-16LE bytes, with ASCII letters lowercased first. Lookups therefore ignore case, which matches the uniqueness rule. Generated keys compute their hashes once, when their class initializes, never per lookup. String lookups hash a `ReadOnlySpan` without allocating. Import fails if two names in one scope produce the same hash. Golden-value tests pin the function. -5. **Renames never break anything.** - 1. The rename tool updates every language file of the table. - 2. It leaves `@formerly OldName` on the entry. Moving an entry to another table leaves `@formerly OldTable.OldName`. - 3. Old references (scenes, data, the generated `[Obsolete]` forwarder in code) keep resolving. - 4. The validator lists what still uses each alias, so the alias can be removed once nothing does. - -### 5.3 Format - -A strict line-based `Key = Value` format, modeled on `.properties` and INI files. - -`Shop.English.lang`: - -``` -@loading Preload -@delivery Embedded - -@formerly CoinsLabel -CoinBalance = You have {coins, plural, one {# coin} other {# coins}}. - -Disclaimer = Prices include VAT.\nRefunds within 14 days. - -# Button that confirms buying the selected item. -@maximumLength 16 -Purchase = Buy -``` - -`Shop.Russian.lang`: - -``` -CoinBalance [8b0e47] = У вас {coins, plural, one {# монета} few {# монеты} many {# монет} other {# монеты}}. -Purchase [3fa2c1] = Купить -``` - -1. **One entry per line.** The value is everything after the first `=`, and needs no quotes. -2. **Whitespace** around `=` and at both ends of the line is trimmed. Intentional spaces at the ends of a value are written as escapes. -3. **Escapes:** `\n`, `\t`, `\\` and `\uXXXX`. An unknown escape is reported and kept literally. -4. **Values are always plain text** and are never interpreted as types. -5. **Characters that are syntax elsewhere are plain text inside a value.** Only the first `=` splits the line. Only lines that *start* with `#` or `@` are comments or attributes. The fingerprint tag can only appear before the `=`. -6. **Comments.** `#` lines are comments. In the source file, comments above an entry are the context translators see in the grid and in exports. -7. **Attributes.** `@` lines are attributes attached to the next entry: `@formerly`, `@maximumLength`. -8. **Table settings** are attributes at the top of the source file, before the first entry: `@loading`, `@delivery`, `@generateCode`. -9. **Fingerprints.** A translation line carries a fingerprint of the source text it was made from: `Key [8b0e47] = ...`. The tag is the first 6 hex digits of the 64-bit hash of the source text. - 1. When the source changes, the fingerprints stop matching and those translations are flagged as outdated. - 2. The tool writes the tags itself. A line added by hand without one counts as unverified. -10. **The canonical writer** always produces the same output: - 1. keys sorted in natural order (`Line9` before `Line10`), with their comments and attributes moving with them; - 2. UTF-8 without BOM; - 3. newlines escaped; - 4. invisible characters (non-breaking spaces, zero-width and bidi marks) written as `\u` escapes so they show up in reviews; - 5. the file's existing line endings kept, and LF for new files. -11. **ICU syntax** inside values follows ICU's own rules. A literal `{` is written `'{'`, and an ordinary apostrophe like in `Don't` needs nothing. Entries created from text a project already shows, such as the *Localize* action does, are quoted so they show exactly that text. -12. **Editing outside the tool** (merge conflict resolution, AI agents, hand fixes) is supported and validated, not forbidden. The editor is the primary path but not the only one. - -### 5.4 Foolproofing - -1. **Keys exist only in the source language.** A key that appears in a translation file but not in the source is an *orphan*. - 1. The importer reports it with file and line and leaves it out of the build. - 2. The editor offers a fix. When an orphan looks like a misspelling of a missing key (`Purchse` next to a missing `Purchase`), the fix suggests renaming it. -2. **Translation files re-validate automatically** whenever their source file changes. -3. **Entries are matched by key, never by position.** -4. **The importer reports problems with file, line and column:** duplicate keys, leftover merge-conflict markers, malformed lines and unknown languages. -5. **A missing translation is a visible state:** highlighted in the grid, counted by the validator, and falling back at runtime. -6. **Operations that touch several files** (add, rename, move, delete) update every language file in one step, with Undo. - -## 6. Compiled tables and memory - -1. Each table-and-language file compiles into one binary blob: - 1. a versioned header (format version, catalog, table, language), so a format change or a mismatched file is detected, never misread; - 2. the source keys hash: a hash of the source-language file's keys, carried by the source table and by every translation that has all of them, and zero otherwise; - 3. sorted 64-bit key hashes, aliases included, searched by binary search; - 4. start positions and lengths into one UTF-16 character buffer that holds every value; - 5. pre-parsed ICU instructions, only for entries with arguments. -2. A translation takes over the source text's aliases, so a renamed entry resolves in every language without its fallback. -3. A loaded table is a handful of arrays, whatever its entry count. -4. **Strings are created on first use.** - 1. The first request for an entry creates its `string` and caches it. - 2. Every later read returns the same instance without allocating, including from several threads at once. - 3. A 50,000-line dialogue table only ever creates strings for the lines actually shown. -5. **Callers that need zero allocations** read values as `ReadOnlyMemory`, and bind them with `BindCharacters`. The TextMeshPro sample passes them to `SetCharArray` through `MemoryMarshal.TryGetArray` without copying. -6. **UTF-16, not UTF-8.** UTF-16 is .NET's native string encoding, so creating a string is one copy and nothing needs decoding. UTF-8 would only save memory for Latin-alphabet text, and costs more for Chinese, Japanese and Korean. - -## 7. The localizer runtime - -### 7.1 Host - -1. There is one implementation, the core `Localizer`. What changes per platform is its host, which provides: - 1. the table sources, in priority order; - 2. the once-per-frame hook that applies queued changes; - 3. the diagnostics sink; - 4. the check that tells whether a target object was destroyed. -2. The core defines `ILocalizerHost`, and the Unity layer implements it as `UnityHost`: - - ```csharp - Localizer localizer = new Localizer(LocalizationKeys.CatalogKey, new UnityHost()); - await localizer.InitializeAsync(); - ``` - -3. With embedded tables, `InitializeAsync` finishes within the call. A prototype can read text on its first frame without awaiting anything. -4. Extra sources stack in priority order. `new UnityHost(new FolderTableSource(LocalizationKeys.CatalogKey, path))` asks a mods folder first, so a fan translation can also patch languages the game already ships: its entries come first, and the game's fill in the rest (section 8). -5. A .NET server would pass a host with a `FolderTableSource`, and a test would pass one that holds tables in memory. -6. Bridges are named by their role (`UnityHost`, `EmbeddedTableSource`, `EntryReference`), never as another "Localizer". - -### 7.2 Threading - -1. **Reads** work from any thread without locks. The localizer publishes its state (the current language and the loaded tables) as one object that never changes after it is built, and replaces it by swapping a single reference. A read takes that object once, so it can never see a half-switched language. -2. **Changes** can be requested from any thread and are applied in order on the host thread: Unity's main thread, through the frame hook. That covers switching the language, loading or unloading tables, and registering a language at runtime with `RegisterLanguage`. -3. **Bindings are notified** on that same thread, so UI code never has to switch threads itself. -4. **Nothing requires extra threads.** On WebGL everything runs on the main thread, and loading uses the platform's own async loading. -5. **Idle cost.** While idle, the frame hook costs one flag check per frame. - -### 7.3 Lookup and fallback - -1. A lookup resolves the entry in the current language. If it's missing there, the lookup walks the fallback chain and ends at the source language. -2. Synchronous reads never block. A read from an `OnDemand` table that isn't loaded returns empty text, with one warning per table. -3. Async operations (startup, language switches, explicit loads) return `Task`. They are rare, several systems can await the same switch, and `Task` works on 2022.3 and in plain .NET. UniTask and `Awaitable` users convert with one call. -4. Failures never throw into game code. A task's result reports what failed. - -### 7.4 What shows up when text can't be shown - -| Situation | Player sees | Developer gets | -|---|---|---| -| Entry missing in the current language | Text from the fallback chain, ending at the source | Counted by the validator | -| Key doesn't exist (deleted, stale reference, typo in data) | `[Shop.Purchase]`, in every build | One error per unique key | -| Table still loading | The binding keeps its previous text; empty only on the very first load | Nothing | -| An `OnDemand` table read while nothing holds it | Empty text | One warning per table | -| Table failed to load | Fallback-language text, loaded as a recovery | An error with the reason; the task reports the failure | -| Broken ICU in a translation | Fallback-language text | An import error with file, line and column | -| Broken ICU in the source language | The text as written | An import error with file, line and column | -| A message missing an argument, or given one of the wrong kind | `{coins}` for a missing one; a wrong one as it is, choosing the `other` form | One error per unique problem | -| No formatter registered for a type such as `{deadline, date}` | The value as it is, dates as `yyyy-MM-dd HH:mm:ss` | One error per argument | -| An entry with arguments read as plain text | `{coins}` in place of each argument | One warning per entry | - -1. **One error per unique missing key per session.** Errors are used rather than warnings, so Unity Test Framework fails any test that touches a missing key. -2. **Missing-key counters** can be read from code, so a pre-release smoke test can assert "zero missing keys". -3. **The build gate** fails builds whose scenes, prefabs or assets reference a key that doesn't exist (section 12.2). - -## 8. Loading and delivery - -1. **Only the current language is loaded.** Fallback languages are loaded only for tables with gaps in the current language. A fully translated language costs no extra memory. - 1. A table is loaded one language of its fallback chain at a time, and stops at the first table that has every key of the source language: one whose source keys hash (section 6) matches the hash the catalog carries for the table. - 2. A translation compiled for other source keys, such as remote content from an older release or a mod compiled at runtime, never matches, so its fallbacks always load and a newer key never shows as missing. -2. **Sources stack.** For each language, every source is asked in priority order, and each table found is searched before the ones after it, until one has every key. A mod's partial patch of Russian therefore comes first, the game's Russian fills in the rest, and English isn't loaded at all. -3. **Loading mode,** set per table with `@loading`: - 1. **`Preload` (default):** loaded together with the language, so synchronous reads always work. - 2. **`OnDemand`:** loaded while something holds it, and unloaded two host updates after nothing does, so a panel toggled within a frame keeps its table. Bindings hold their tables automatically, and code holds one with `localizer.HoldTable(...)`, which returns a disposable `TableHandle` whose `WhenLoaded` task completes once the table can be read. -4. **A binding that needs an unloaded table** starts the load and updates when the data arrives, keeping its previous text meanwhile. -5. **Language switch:** - 1. load the new language for every preloaded table and every held one; - 2. swap everything in one frame; - 3. release what the new language doesn't use. - - Rapid switches collapse: only the latest request loads, loads already started for an earlier one are shared rather than repeated, and every caller's task completes once it lands. A table held while a switch loads joins it. `LanguageChanging` and `LanguageChanged` events serve projects that want a loading screen. -6. **Delivery,** set per table with `@delivery`: - 1. **In the editor,** tables are read straight from the imported assets. There is no build step, and they're always current. - 2. **`Embedded` (default):** a build step packs the table inside the build, readable synchronously on every platform. - 3. **`Streaming`:** a build step packs the table into StreamingAssets, read on demand by `StreamingTableSource`, which keeps huge content out of memory and, on WebGL, out of the initial download. Files are read on a thread pool thread where StreamingAssets is a folder, and through UnityWebRequest where it's a URL (Android, WebGL), which needs the built-in Unity Web Request module. A list of the packed files sits next to the catalog, so a table a language doesn't have is never requested. - 4. **`Custom`:** builds don't pack the table, since a table source of the project delivers it, such as the Addressables sample's. The editor still reads its files directly. - 5. **Anything else** (Addressables, a CDN) plugs into the same table-source interface. A source can attach warnings to what it delivers, which reach the host's diagnostics. A source that learns only later that it doesn't have a table, as an asynchronous lookup does, declines the request, and the next source is asked with nothing reported. -7. **Mods and downloaded translations** come from `FolderTableSource`: `
..lang` files anywhere under a folder, compiled when they're asked for, with their problems reported by file and line. The language sections of the folder's `.catalog` files define new languages, which `localizer.RegisterLanguage(...)` adds at runtime, before or after initialization. - -## 9. Reactive layer - -1. **`Bind` is the primitive everything else uses:** - - ```csharp - TextBinding binding = localizer.Bind(LocalizationKeys.MainMenu.Play, label, static (label, text) => label.text = text); - binding.Dispose(); - ``` - - 1. It applies the text immediately, then again whenever the text changes: a language switch, an `OnDemand` table arriving, a table file edited in the editor (live, in Play and Edit Mode, arriving with the editor tooling of step 4), or new arguments via `binding.SetMessage(...)`, which formats only when the arguments differ, so calling it every frame costs nothing. - 2. Binding and updating allocate nothing. The handle is a struct, and the callback is a static lambda that receives its target as state. `Bind` hands the callback a `string`; `BindCharacters` hands it `ReadOnlyMemory`, valid during the call, and formats messages into a buffer it reuses, so even a changing message allocates nothing. - 3. Disposing a stale or copied handle is a safe no-op. - 4. A binding keeps its `OnDemand` table loaded while it's alive. - 5. A callback that throws is reported and skipped, and the remaining bindings still update. That's the same rule as EventAggregator. - 6. Bindings whose Unity target was destroyed without being disposed are released automatically and reported. -2. **`ReactiveText`** wraps a binding with `Value`, a `Changed` event and `INotifyPropertyChanged`, for view models, UI Toolkit data binding and the R3 adapter. Its events are raised only when the text actually differs. -3. **Refreshing inactive text costs nothing.** Component samples bind in `OnEnable` and dispose in `OnDisable`, so a language switch only touches what's active, and anything enabled later reads the current language as it binds. -4. **No time-sliced update mode in v1.** It can be added if profiling ever shows a need. - -## 10. Messages - -1. **The syntax is classic ICU MessageFormat (MF1).** - 1. MF2 has been a stable Unicode specification since March 2025, but as of May 2026 the major translation platforms didn't support it, and the major JavaScript libraries were still MF1-only. - 2. Messages compile into instructions at import, so MF2 can be added later as a second syntax without touching the runtime. -2. **Supported syntax:** - 1. `{name}`; - 2. `{n, number}` with the `integer` and `percent` styles; - 3. `{n, plural, …}` with `=N`, `offset:` and `#`; - 4. `{n, selectordinal, …}` and `{x, select, …}`; - 5. nesting and apostrophe quoting, which follows ICU: an apostrophe only starts quoted text before a brace, or before `#` inside a plural form. - - Arguments are named, since the names become the parameters of generated code. ICU's numbered arguments (`{0}`) and its deprecated `choice` are rejected with an explanation. -3. **Dates, times and currency** are not built in. `{deadline, date}` calls the formatter the project registers for the type: `localizer.SetFormatter("date", formatter)`. - 1. An `ArgumentFormatter` writes into a span it's given, so formatting allocates nothing, and is called again with more room when it reports the span too small. - 2. It receives the style text (`short` in `{deadline, date, short}`) and the language of the text, whose culture names the conventions to follow. - 3. A missing or throwing formatter never breaks the text; it's reported once. -4. **Language data comes from CLDR (the Unicode Common Locale Data Repository).** - 1. Plural rules (cardinal and ordinal) and number symbols are generated from CLDR into the core. The rules become code, one case per distinct rule set; the symbols are kept only for the locales whose symbols differ from the locale their tag shortens to, because a lookup shortens the tag (`de-CH`, then `de`) until it finds one. - 2. Nothing depends on `CultureInfo`, which has a history of platform-specific failures in IL2CPP builds. Output is identical on every device. - 3. The generator is a repository tool pinned to one CLDR release (48.2.3), and its output is committed. Moving to another release is changing the version, running it, and reviewing the diff. -5. **Numbers** are grouped as the language groups them (including Indian grouping and minimum grouping digits, so Spanish writes 1234 but 12.345), show up to three fraction digits rounded half to even, and use the language's digits. A plural form is chosen from the digits shown, so 1.0004 shows as 1 and takes the `one` form. -6. **Text found in a fallback language** formats with that language's plural rules and symbols, because the words around the number are in that language. -7. **At runtime** an `EntryMessage` struct carries the entry and its arguments, up to four without allocating. Arguments are matched by name, so a translation may use them in any order. `Get` returns the formatted string, `TryFormat` writes into a caller's buffer without allocating, and `Bind` keeps a message current. -8. **Import checks:** - 1. syntax errors, reported with line and column, counting the escapes written before them; - 2. every translation uses the same argument names as the source, each in a way its value supports: a number may also be shown plainly, but text can't choose a plural form. A translation that doesn't match is left out, so it shows in its fallback language, with an error; - 3. plural messages need `other`, and get a warning when they lack a form their language uses (Russian: one, few, many, other; English: one, other), unless exact forms stand in for it, as `=1` does for English `one`, and when they have a form their language never uses; - 4. likely mistakes are warned about: `=#` inside a plural form, which turns a rich-text color into the number, and `#` inside a select within a plural, which ICU shows as is. - -## 11. Code generation - -1. **The editor writes one plain `.cs` file per catalog,** next to the catalog file, so it compiles into whichever assembly owns that folder. - 1. It is not a Roslyn source generator: in Unity those only read files under `Assets` with a special `.additionalfile` name, and their output is invisible to code review and AI agents. - 2. The namespace is `@namespace` from the catalog. Without it, the owning asmdef's root namespace, then the project's root namespace setting, then none. -2. **It runs automatically.** - 1. The file is regenerated when keys, aliases or argument lists change. - 2. Text edits never touch it, so text edits never cause a recompile. - 3. A result identical to the existing file is never rewritten. - 4. While a source text's message has errors, the file keeps its current members, so a typo in a text never breaks the code that calls it. - 5. **Edits made with the editor's own tools wait until the editor loses focus.** That covers the table window and creating entries in the Inspector. Code needs the new keys only once someone switches to an IDE to write it, so working in the editor never triggers a recompile. Changes from outside the editor, such as version control or a text editor, regenerate right away. Code still pending when the editor closes is caught up the next time it starts. -3. **Shape:** - - ```csharp - LocalizationKeys.CatalogKey // the catalog, for new Localizer(...) - LocalizationKeys.Shop.TableKey // the table, for TableHandle and exports - LocalizationKeys.Shop.Purchase // plain entry: a field - LocalizationKeys.Shop.CoinBalance(coins: 5) // entry with arguments: a method returning EntryMessage - LocalizationKeys.Shop.BuyButton // alias: an [Obsolete] forwarder to Purchase - LocalizationLanguages.Russian // languages, for the project's OS-language switch - ``` - - 1. **Argument types come from the source text.** Plural and number arguments take a `MessageNumber`, which every numeric type converts to without boxing. Select arguments take strings. Anything shown as is or through a formatter takes a `MessageValue`. - 2. **Parameters are sorted by name,** so rewording a text never reorders them. - 3. **Argument changes break call sites at compile time.** That covers renaming, removing or adding an argument in the source. - 4. The validator reserves `TableKey` and `CatalogKey` as entry names. -4. **Opting out.** A table opts out with `@generateCode false`, for example a 50,000-line dialogue table referenced from data rather than code. It stays reachable by name (`localizer.Get(dialogueTable, line.Key)`, hashed without allocating). -5. **Read-only packages.** Catalogs inside read-only packages ship their generated file. The tool never writes into a package it can't modify. - -## 12. Editor tooling - -### 12.1 Importers - -1. `ScriptedImporter`s compile `.lang` and `.catalog` files. -2. Translation files declare a dependency on their source file, so they re-validate whenever it changes, and every table file on its catalog, whose cultures decide which plural forms are checked. A translation imported before its source file existed is imported again once it arrives. - -### 12.2 Validation and the build gate - -1. Validation runs on every import and on demand (`Tools/ReactiveLocalizer/Validate`). -2. **Errors fail the build:** - 1. malformed files and merge-conflict markers; - 2. duplicate keys and hash collisions; - 3. orphans and broken ICU; - 4. argument mismatches between languages; - 5. missing entries in required languages; - 6. references to nonexistent keys from scenes, prefabs or assets in the build. -3. **Warnings don't fail the build:** - 1. outdated or unverified translations; - 2. missing entries in other languages; - 3. entries over their maximum length; - 4. unused aliases; - 5. naming-convention rules, when enabled. - -### 12.3 Table window (`Window/ReactiveLocalizer/Tables`) - -1. **Built on UI Toolkit's `MultiColumnListView`,** which only renders visible rows, so 100,000-entry tables scroll smoothly. -2. **Sidebar:** catalogs and their tables, with badges for missing, outdated and broken entries. -3. **Grid:** one row per entry, with columns for the key, the translator context, then one per language. Each language can be shown or hidden. -4. **Detail pane** for the selected entry: every language stacked vertically with full multi-line editors. This is where long paragraphs live. It also has: - 1. an ICU preview with editable sample values (for example, Russian with `1`, `2`, `5` and `21`); - 2. the attributes; - 3. the outdated state, with *Show source change* and *Mark as current*. A fingerprint holds no text, so *Show source change* finds the source text a translation was made from in git. It searches the source file's recent history, following renames, and diffs that text against the current one word by word. Without git, or when no recent version matches, it says so. -5. **Status tints** for missing, outdated, broken ICU, over maximum length and orphaned. Filters for *missing in X* or in any language, *outdated* (which includes translations without a fingerprint) and *problems*. Search covers keys, text in every language and comments. Problems are what compiling each file reports, mapped to the entry they concern, so the window and the build gate never disagree. -6. **Add, rename, delete and move to another table,** all with Undo through a stand-in object registered with Unity's Undo. Moving leaves a cross-table alias. -7. **Edits save when committed** (Enter or leaving the cell). There's no unsaved state, and bound text updates live. Double-clicking a cell edits it in place; a text with line breaks opens in the detail pane, where Enter starts a new line and Ctrl+Enter commits. -8. **Generated code waits until the editor loses focus** (section 11), never in the middle of typing, and waits until Play Mode ends. - -### 12.4 Inspector field - -1. **`EntryReference`** is the serializable field type. It lives in the Unity layer and stores the catalog, table and key as names. The Inspector fills the catalog in when an entry is picked, so a reusable component works with whichever catalog its entry comes from. `[EntryCatalog(typeof(ToolWindowKeys))]` limits a field's dropdown to one catalog. -2. **`CatalogReference`** is the field type for components that work with a whole catalog, such as a language picker. Empty means the default catalog. -3. **The field shows `Shop.Purchase`** as a popup, with a text preview in the current preview language below it. The key is written the same way everywhere else: in validation messages, in search, and in generated code. -4. **Clicking it opens a search popup.** It searches keys *and* text, shows recent picks first, and only renders visible results. -5. **Creating entries on the spot.** When nothing matches, the popup offers to create the entry. - 1. The table defaults to the one named after the prefab or scene being edited, then the last used one, or the user types a new table name. - 2. The key is suggested from the GameObject's name, with UI suffixes stripped (`TitleLabel` → `Title`). The field's own role follows when it has one (`_description` on `Sword` → `SwordDescription`). It's never suggested from the English text, because entries are named after their role. - 3. A search written as `Table.Entry` names the new entry; any other search becomes its text. - 4. Creating an entry writes only the source file, so there's no recompile (section 11). Creating the entry and assigning it to the field are one Undo step. - 5. `EntryAuthoring` is the public editor API behind it, so samples and project tools create entries the same way. -6. **Broken references show red.** When a renamed key's alias resolves, the field offers to update itself. -7. **The field is drawn in both UI Toolkit and IMGUI inspectors.** - -### 12.5 Scene view overlay - -The overlay switches the preview language, the pseudo language included (section 14), and every bound text in the open scenes updates live in Edit Mode. Entry field previews and the table window's preview use the same language. - -1. **Components opt in through `EditModePreview`.** Outside Play Mode it hands out one localizer per catalog in the preview language. It falls back to the catalog's source language when the catalog lacks that one. Those localizers are disposed before Play Mode and before scripts reload. -2. **Preview text is never saved.** A component showing it registers with `EditModePreview.Track`. Before its scene or prefab saves, the component puts its authored text back, and it shows the preview again right after. Text typed over the preview counts as the new authored text. -3. The Quick Start access point returns the preview localizer in Edit Mode (section 15), and the TextMeshPro component previews through it. - -### 12.6 Menus - -1. `Window/ReactiveLocalizer/Tables`, whose *Exchange* menu exports the open table or the whole catalog and imports files and replies. -2. `Tools/ReactiveLocalizer/…`: *Validate*, *Export…*, *Import File…*, *Import Folder…*, *Import LLM Reply*, *Character Sets…*, and in a later step the agent guide. -3. `Assets/Create/ReactiveLocalizer/…` (catalog, table). Creating a catalog asks for its name, source language, culture and display name, and checks the culture against CLDR. Creating the first table of a project without a catalog asks the same first. - -## 13. Exchange formats - -1. **Every format holds the same thing:** per entry, the key, the context (the comments above the source entry), the maximum length, the source text, and the text and state in each exported language. Spreadsheet formats write the key as `Shop.Purchase`, the same way as everywhere else, so renaming a file or sheet changes nothing. A key without its table belongs to the table the file or sheet is named after. -2. **XLSX:** one workbook, one sheet per table, with the columns key, context, maximum length, source, then one per language headed by its name. - 1. The authoring assembly reads and writes the zip and XML itself, with no dependencies. The zip container uses .NET's Deflate and checks every entry's checksum when reading. - 2. Sheets keep the header row in view, wrap long texts, gray the columns translators only read, and tint outdated and unverified translations. Text cells are formatted as text, so a translation typed as `100` stays text, and a text starting with `=` is never a formula. - 3. Reading takes what Excel, LibreOffice and Google Sheets save: shared or inline strings, rich text, numbers, and `_xHHHH_` escapes. A cell holding a spreadsheet error is reported and skipped. - 4. A text longer than an Excel cell holds (32,767 characters) is left out of the workbook and reported. -3. **CSV:** one file per table, RFC 4180, with the XLSX columns. - 1. It's saved as UTF-8 with a BOM, so Excel doesn't garble non-Latin text, and with CRLF between rows. - 2. Import detects a comma, semicolon or tab separator, and reads UTF-8 and UTF-16 (Excel's "Unicode text"). A file in another encoding is refused rather than read with its letters lost. - 3. Spreadsheet applications take CSV cells starting with `=`, `+`, `-` or `@` for formulas, so XLSX is the format for spreadsheets. A spreadsheet error such as `#NAME?` in an imported cell is reported and skipped. -4. **XLIFF 1.2 and 2.0:** one file per target language, `..xlf`, with a file element per table. - 1. Missing entries have no target, outdated and unverified ones are marked as needing review, and current ones as translated. The context is a note, and the maximum length is `maxwidth` in 1.2 and a note in 2.0. - 2. Attributes in the package's own namespace name the catalog and both languages exactly, since culture tags can't name invented languages. The culture tag is the language's culture, else the first culture along its fallback chain (its plural rules come from there too), else `und`. - 3. A character XML can't hold is a `` element in 2.0. XLIFF 1.2 has no way to write one, so those entries are left out and reported. - 4. Reading takes either version, the inline markup translation tools add (`g`, `mrk`, `pc`, codes and their original data), and units split into segments. A unit translated in only some of its segments is skipped. A translation the tool marks translated, reviewed or final counts as checked. -5. **LLM handoff:** - 1. The tool exports entries in one language at a time as a text bundle, copied to the clipboard. A rules header (reply in the same format, keep keys and tags, keep ICU syntax, the language's plural forms, keep escapes and markup, respect the maximum length) is followed by `@catalog`, `@language` and `@table` lines and the entries in `.lang` syntax: the source text to translate, its fingerprint tag, its context, its maximum length, and the current translation when there is one. - 2. The model's whole reply is imported from the clipboard. A code block is read on its own, so the model's words around it are left alone, and an entry before any `@table` line is reported. - 3. No API keys and no provider integration are needed. -6. **Google Sheets:** two-way sync, shipped as a sample. It works through an Apps Script the sample provides, with a secret token, so the sheet stays private. The "publish to web" shortcut is never used, because it makes the sheet public. -7. **Import rules, the same for every format:** - 1. Imports change translations only by default. They never create, rename or delete keys. Rows with unknown keys are rejected as orphans, with the key they most likely meant, and an empty cell never erases an existing translation. - 2. Changes to the source column are ignored unless *import source changes* is ticked, and the preview says how many source texts differ. - 3. The source text in the file is what the translator saw, so it becomes the fingerprint. If the source changed since the export, the imported translation arrives already marked outdated. A fingerprint tag the file carries, as an LLM reply does, wins, and a file without source texts brings unverified translations. - 4. A text equal to the current one changes nothing, so an outdated translation a translator left as it was stays outdated. Only XLIFF can confirm it as checked, through its state. - 5. Everything goes through the same ICU checks as the files: each changed file is compiled as its import compiles it, and texts that fail are rejected with the reason. A file that has lines with errors is never rewritten. - 6. The catalog is the one the file names, else the one that has most of its tables. Languages are matched by name, then by culture, then by the base culture (`ru-RU` finds `ru`). The preview lets the person change both, or skip a language. - 7. A preview shows what will change (new, updated, outdated, confirmed, source texts, rejected, and how many are unchanged) before anything is written. The import is one step Undo can take back. -8. **Export options:** which tables, which languages, and all entries, only missing ones, only outdated ones (unverified included), or both. - -## 14. Developer tools - -1. **Pseudo-localization:** a virtual language named `Pseudo`, made from the source text and never stored in files. - 1. It's accented so hardcoded strings stand out, padded by about 35% with words so overflow shows up, and bracketed so truncation and glued-together strings are obvious: `Buy now` shows as `[Bûý ñöŵ one]`. - 2. ICU syntax, rich text tags and escapes stay intact. The accented letters all come from Latin-1 and Latin Extended-A, which nearly every font has, so letters without such a form stay plain, and so do other scripts, which still get the padding and brackets. - 3. It writes and counts like the source language, whose culture and number symbols it takes, and falls back to it. - 4. Each table's pseudo text is compiled as a translation of its source-language file, so the same checks prove it keeps every argument, and it has every key and alias. - 5. The editor's preview always offers it. Play Mode and development builds have it as one more language of every catalog, so a language picker lists it, unless the project settings turn that off. Release builds never have it. - 6. A catalog that defines a language named `Pseudo` itself keeps that language and gets no pseudo language. - 7. Tables delivered by a custom source, such as Addressables, have no pseudo version, so they show their source text. -2. **Character sets** (`Tools/ReactiveLocalizer/Character Sets…`): for each language, the exact set of characters any table can display. - 1. That's the language's own text, the fallback text its missing entries show, every plural and select form, its digits and number symbols, without rich text tags or control and formatting characters. Argument names and select keywords never show, so they don't count. - 2. Picking several languages gives the set a font they share needs. Printable ASCII and extra characters can be added for text that arguments bring at runtime, such as player names. - 3. The set is copied to the clipboard or saved as a text file in code point order, for the *Custom Characters* or *Characters from File* options of TextMeshPro's Font Asset Creator. `CharacterSets.Collect` is the editor API behind it. -3. **Font coverage check** (TextMeshPro sample): lists every character a language's assigned font would render as an empty box. - -## 15. Samples - -Each sample is a thin adapter with its own folder and asmdef. Code that integrates with another package compiles only when that package is installed. - -| Sample | Contents | -|---|---| -| Quick Start | The `GlobalLocalizer` access point, which a DI setup can also hand its own instance, and which other samples extend with table sources and runtime languages; saving the chosen language; a template for mapping the OS language; a language picker; `LocalizedBehaviour`, the base of the UI components; a demo scene | -| TextMeshPro | `LocalizedTextMeshPro` with the zero-allocation path; `LanguageFonts`, a font per language that the Edit Mode preview switches too; the font coverage check in its Inspector; a *Localize* context-menu action that turns the current text into an entry and wires the component | -| UI Toolkit | `LocalizedTextBinding`, a data binding declared in UXML or UI Builder (Unity 6); `BindText` from code and `LocalizedDocument`, which binds a UIDocument's elements by name, on every version | -| uGUI Text | `LocalizedLegacyText` for legacy `Text`, with its own *Localize* action | -| Addressables | `AddressablesTableSource` for `@delivery Custom` tables, and a menu that makes their files Addressables at the addresses it loads | -| VContainer | `LocalizerInstaller`, which registers a catalog's localizer and initializes it asynchronously as the container starts | -| R3 | `LocalizerObservables`: text, messages fed by other observables, the language, and `ReactiveText` as observables | -| Google Sheets | Two-way sync through Apps Script, with a README that sets it up | -| Editor Tool | An `EditorWindow` localized with its own catalog, for tool authors | -| Mods | `ModTranslations`: fan translations read from a mods folder through the core's `FolderTableSource`, with their languages registered at runtime, and an example mod | -| Unity Localization Migration | Converts locales to languages and string table collections to `.lang` files, converts simple placeholders to ICU, and flags the rest for review | - -1. **UniTask needs no sample:** it can already await `Task` directly. -2. **UI samples find the localizer through Quick Start's access point,** so import Quick Start first. -3. **In Edit Mode,** the access point returns the editor's preview instance, so components show the preview language live. Scenes and prefabs still save the text and font they were authored with. -4. **The migration keeps meaning, not syntax:** a numbered placeholder such as `{0}`, which projects fill with `string.Format`, becomes a named argument such as `{arg0}`, listed for renaming. Smart Format's plural and choose formatters and nested selectors stay as written, marked with a `MIGRATE` comment. Names become PascalCase, and the catalog is named `Localization`. - -## 16. Naming reference - -| Concept | Name | -|---|---| -| Table files | `Shop.English.lang` | -| Catalog files | `Localization.catalog` | -| The localizer | `Localizer`, `ILocalizer` | -| Host | `ILocalizerHost`, `UnityHost` | -| Identity handles | `CatalogKey`, `TableKey`, `EntryKey`, `LanguageKey` | -| Language record | `LanguageInfo` | -| Entry with arguments | `EntryMessage`, `MessageArgument`, `MessageValue`, `MessageNumber` | -| Formatter of a registered type | `ArgumentFormatter` | -| Binding and observable | `Bind`, `BindCharacters`, `TextBinding` (struct), `ReactiveText` (class) | -| On-demand table hold | `HoldTable`, `TableHandle` (struct) | -| Runtime languages | `RegisterLanguage` | -| Table sources | `EmbeddedTableSource`, `StreamingTableSource`, `FolderTableSource`, and in samples `AddressablesTableSource` | -| Inspector fields | `EntryReference`, `CatalogReference`, `[EntryCatalog(typeof(...))]` | -| Editor authoring API | `EntryAuthoring`, `CharacterSets`, `TranslationExchange` | -| Message text for tools | `MessageText`, `NameRules` | -| Sample components | `LocalizedBehaviour`, `LocalizedTextMeshPro`, `LocalizedLegacyText`, `LocalizedTextBinding`, `LocalizedDocument` | -| Developer tools (authoring) | `PseudoLocalization`, `CharacterSet` | -| Exchange (authoring) | `ExportBook`, `CsvExchange`, `XlsxExchange`, `XliffExchange`, `LlmHandoff`, `ImportedFile`, `ImportPlanner`, `ImportPlan` | -| Generated code | `LocalizationKeys`, `LocalizationLanguages` | -| Sample access point | `GlobalLocalizer.Instance` | -| File attributes | `@source`, `@namespace`, `@formerly`, `@maximumLength`, `@loading`, `@delivery`, `@generateCode` | - -1. Names use full words, one suffix per family of types, and lowerCamelCase for file attributes so they never look like keys. -2. They avoid names projects are likely to have already, like `Language`, `Table` or `Catalog`. -3. They avoid Unity Localization's type names (`LocalizedString`, `LocalizedReference`, `Locale`, `StringTable`, `TableReference`), so both packages can coexist while a project migrates. - -## 17. Quality - -1. **Fast lane:** `dotnet test` on the core and authoring assemblies on every push. It takes seconds and needs no Unity license. -2. **Unity lane,** mirroring Wireframes: - 1. GameCI tests in Edit and Play Mode on the oldest (2022.3) and newest Unity; - 2. compiler warnings from the package fail the run; - 3. player builds for WebGL, Android, iOS, macOS and Linux catch problems that only show up in IL2CPP builds or after code stripping. The Linux build also compiles the samples for Addressables, Unity Localization and VContainer. -3. **What the tests cover:** - 1. reading then writing a file gives byte-identical output, and randomized input (escapes, invisible characters, merge markers) never crashes the parser and always reports its position; - 2. plural tests are generated from CLDR's sample numbers for every plural form of every language; - 3. golden values pin the hash function, since a change would silently break every saved reference; - 4. the generated code and the XLSX, CSV and XLIFF output are compared against reference files kept in the repository, and the fast lane also compiles generated code with Roslyn as C# 9, the language Unity compiles; - 5. language switches stay all-or-nothing under concurrent reads from other threads; - 6. `OnDemand` tables load and unload as their users come and go, translations with every key load no fallback, and stacked sources patch the languages behind them; - 7. bindings whose target was destroyed get released; - 8. Play Mode works with domain reload disabled. -4. **No allocations in steady state:** `Is.Not.AllocatingGCMemory()` checks cached lookups, binding updates, re-setting identical arguments, and formatting into a caller's buffer. -5. **Performance:** Unity Performance Testing measures lookups, formatting, switching 1,000 bindings, and loading a 50,000-entry table. - 1. The numbers are published in `Documentation/PERFORMANCE.md`. - 2. A release checklist compares them with the previous release. -6. **Test style:** tests use only NUnit's `Assert.That` constraint syntax, so the same test sources run under Unity's NUnit and under plain `dotnet test`. - -## 18. Documentation - -1. `README.md` covers install via git URL and a 5-minute start. `Documentation/` holds `USAGE.md`, `FEATURES.md`, `PERFORMANCE.md`, `MIGRATION.md`, and `FORMAT.md`, the exact grammar of `.lang` and `.catalog` files, which are a public contract. -2. `Tools/ReactiveLocalizer/Write Agent Guide` adds a short, opt-in section to the project's `AGENTS.md` or `CLAUDE.md`, since agents read the project, not the package cache. It covers where catalogs live, how to add an entry, the naming rules, never editing generated files, and how to validate. -3. Generated files carry a header saying what produced them and how to regenerate them. -4. Public APIs carry brief, contract-focused `` documentation. -5. Migrating from Unity Localization: name the catalog `Localization` and the generated class is `LocalizationKeys`, so call sites like `LocalizationKeys.SettingsPage.Title` stay as they are and only the `using` lines change. - -## 19. Build plan - -Steps 0 to 2 were branches squashed into `create/working-prototype`, which reached `main` as the working prototype. Every later step is a working branch of its own, squashed into `main`. - -| Step | Branch | Delivers | -|---|---|---| -| 0 | `create/working-prototype` | This specification, the package skeleton (four assemblies plus tests), both CI lanes | -| 1 | `feature/vertical-slice` | `.lang` and `.catalog` import, compiled tables, `Localizer` lookups with fallback, `Bind`, the Quick Start and TextMeshPro samples: the prototype workflow end to end | -| 2 | `feature/message-formatting` | ICU parsing and formatting, the CLDR data generator, typed `EntryMessage` code generation | -| 3 | `feature/table-loading` | `OnDemand` handles, `Streaming` delivery, language-switch coordination, the mods source | -| 4 | `feature/editor-tooling` | The table window, inspector search and creation, rename, move and aliases, validation with the build gate, the Scene view overlay | -| 5 | `feature/exchange-formats` | CSV, XLSX, XLIFF, LLM handoff | -| 6 | `feature/developer-tools` | Pseudo-localization, character sets | -| 7 | `feature/integration-samples` | The remaining samples | -| 8 | `generate/documentation` | README, `Documentation/`, release checklist, `CHANGELOG.md` | - -v1.0 covers every step. The core is finished after step 3, and from step 4 onward each step only adds tooling and integrations on top. - -## Appendix: rejected alternatives - -| Alternative | Why it was rejected | -|---|---| -| Enums as entry keys | Unity saves enums as integers, so inserting or reordering generated members silently re-points every scene reference. Every new key would also need a recompile before use. | -| Languages as an enum | DLC, mods and remote translations can't add enum members. | -| ScriptableObject table storage | Huge YAML lists conflict constantly in merges, import slowly, and are opaque to reviews and agents. | -| Permanent random IDs per entry | An opaque ID on every line, IDs to invent for every hand edit, and two branches adding the same key collide when merged. | -| One combined file per table | A translator's delivery would touch every line developers edit, the whole table would reimport for any change, and adding a language would rewrite every file. | -| Updating bindings across several frames | It trades a one-frame hitch for several frames of mixed languages. The cost of a switch is loading and text mesh rebuilds, not lookups. | -| A singleton inside the package | It stays out of the headless core and is provided by the Quick Start sample instead. | -| A log line on every missing read | Stack trace capture and string allocations every frame, floods of crash-reporting events, and real warnings buried under repeats. | -| MessageFormat 2 | Stable since 2025, but not yet supported by translators' tools. It can be added later as a second syntax. | -| `CultureInfo` for formatting | Platform-specific failures in IL2CPP builds would make the same message format differently on different devices. | -| A Roslyn source generator for keys | Unity limits its inputs to specially named files under `Assets`, and its output is invisible to reviews and agents. | diff --git a/Concept/ReactiveLocalizer (initial concept).md b/Concept/ReactiveLocalizer (initial concept).md deleted file mode 100644 index de102a7..0000000 --- a/Concept/ReactiveLocalizer (initial concept).md +++ /dev/null @@ -1,88 +0,0 @@ -### About ReactiveLocalizer - -An extremely reliable, production grade, highly efficient to handle any size of translation base, highly polished, cross-platform, reactive localization package for Unity, with clean and pretty API and a very convenient and easy integration for many different usage cases. - -### Import/Export spreadsheets - -The key advantage of most localization packages is the ability to edit localization via Microsoft Excel, Google Sheets or any other external solution, import the `.xlsx` or `.csv` into their custom Editor window and being able to use those new edits right away. - -Another very convenient feature is to be able to edit imported tables in Unity, then export them as `.csv` or `.xlsx` and again open them in external software and view/edit those there. - -### Works anywhere - -Under any context, whether it is by running it in a single threaded environment with exceptional amount of limitations like WebGL platform, or running it in multiple threads and in async context on some other platform - it has to be flawless under any usage cases possible to ensure that this package is not the cause of any problem when it comes to debugging of exceptions happening in development. - -Virtually any platform, from Windows, Mac OS, Linux, Android, iOS, WebGL or anything else that can be built with Unity - has to be fully supported and safe to implement localization with this package. - -From a simple MonoBehaviour script in a Unity thread or a non-MonoBehaviour class running outside Unity thread, all should work as expected (except the reasonable cases when we make a Unity Component for localizing something in a scene, which is virtually only accessible within Unity thread). - -To take this advantage to the next level, we can introduce Editor Mode support, which might be good for scene sync during edit mode, but it also would be great feature for custom Editor tools to provide them their own packaged localization for a multi-language user base. - -### Human factor considered - -Not even a single string literal being involved to access tables or their entries: every table is backed by their named value in `TableName` enum; every entry within that table is backed by their `EntryName` enum which is issued individually per each table. Languages added are also having their own enum called `LanguageName`. - -### Automatic in-Editor translation - -Using service APIs like Google Translate or Yandex Translate (suggest more options) - we can fill in all the missing neighbor entries, considering that we have default language entries filled in. As simple as a click of one button within table viewer after we select translation provider of our preference. - -### Extreme efficiency - -Zero-allocation awareness of tables and entries. Completely bypassing string allocations via `ReadOnlySpan` or anything that would lets us minimize allocations as much as possible while maintaining access to tables and their entries. - -This solution has to consider the scalability of a project, since rapid prototyping is easy to handle, it won't be more than hundred entries and a few tables - we have to consider the ability to handle tens of thousands of entries per table while having hundreds of tables for a high-end triple A project cases. If we manage to find a solution to scale translation base without affecting any of the runtime performance or any significant memory allocations involved - that is going to be the best ever flexible and scalable solution used by virtually everyone who learns about it. - -It also has to function flawlessly under a very strict and low end environment such as WebGL which has the worst compatibility and the worst hardware access and therefore, least amount of resources available to work. - -Besides zero-allocation or minimal allocations where it is logically impossible to avoid them, we need to ensure zero CPU usage at runtime when we don't expect any changes localization wise. Reactive observers and properties have to be lightweight on CPU and only work when they have to as expected from them. - -### Convenient and pretty - -What makes a great production ready package is how beautiful it's code is, or how virtually presentable its code is so that it can be shown to others and perceived like a human readable and clean set of instructions, intuitively understood by everyone who briefly reads it. - -### AI convenience considered - -Take in consideration the accessibility and ease of understanding for agents which are going to work with this package and user written code based on it. Especially nowadays it makes more sense to provide summaries and comments within the code for a very clear understanding and an instant understanding of how to add new values, edit existing ones, and other things like navigate package APIs. - -### Reactive nature - -Besides a simple read entry via API that returns you the localized value that you asked for, you can also define a local reactive field in your class, which is, when initialized with its table and entry addresses, automatically synchronizes their target once when started, and every time global language changes or when its table value changes. - -```csharp -using reromanlee.ReactiveLocalizer; - -public class MyClass -{ - private LocalizerText _playText; - private LocalizerText _settingsText; - private LocalizerText _exitText; -} -``` - -To solve the problem where we have to process thousands or more of those instances syncing up after a language switch, we do the same approach as `R3` solution does: each property is being synced up in order by Localizer instance itself, so there is no simultaneous overhead on resources drawn upon language changes or any other event. - -Components that are attached to GameObjects are also reactive by their design, and they are being reactively synced up with current language state. - -### Scene components - -The most important feature for quick and easy access of desired localization entry is by searching it. Not so many localization tools provide a quick and easy to access search bar in component where you start typing the name and immediately see options which you can click and it automatically is being chosen for this component to sync target fields with. - -### Dependency Injection - -Since we're going to be dealing with many different architectures, from a production grade high end project with strict rules and conventions to a small prototype that demands rapid prototyping, we have to be flexible. - -One way to make such a thing is by providing an API for creating a Singleton and then using it via API and no DI at all. Or creating another way which is to create an instance and then work with it within Dependency scope like VContainer or virtually anything else. - -Another way is to avoid anti patterns at all cost, and to provide a middle ground solution by creating a Singleton workaround within Samples folder of this package. - -### Custom languages - -Languages are collected from tables and compared between each other to make a list of unique languages available. - -### Code generation - -Code generation is the foundation of how tables are being processed and enums being generated to avoid string literals when accessing tables, languages and entries within the tables with their according language. - -### Other highly valuable utilites - -1. Based on currently added tables, we can generate a fixed string of characters which can be used to cut off unnecessary font glyphs. One string per each language added. \ No newline at end of file diff --git a/Concept/ReactiveLocalizer (revised concept).md b/Concept/ReactiveLocalizer (revised concept).md deleted file mode 100644 index e12488d..0000000 --- a/Concept/ReactiveLocalizer (revised concept).md +++ /dev/null @@ -1,63 +0,0 @@ -# ReactiveLocalizer: Architecture & Implementation Specification - -### 1. Package Overview - -**ReactiveLocalizer** is an enterprise-grade, highly efficient, cross-platform reactive localization package for Unity. It is designed to handle any scale of translation data—from rapid prototypes to AAA data-heavy projects—while maintaining strict zero-allocation internals, graceful memory management, and developer-friendly APIs. - -**Core Directives for AI Implementation:** - -* **Performance First:** Internals must prioritize `ReadOnlySpan`, struct-based argument passing, and zero-boxing logic. -* **Interface-Driven:** The core engine must be decoupled from MonoBehaviours and rely on interfaces (e.g., `IReactiveLocalizer`) to support arbitrary Dependency Injection frameworks (VContainer, Zenject). -* **Scalable Memory:** Must support asynchronous on-demand table loading and disposing to fit within strict memory budgets (e.g., WebGL). - -### 2. Import/Export & Translation Workflow - -* **Formats:** Supports importing/exporting `.xlsx` and `.csv` files via a custom Editor window. -* **External Translation:** The package does not include built-in machine translation APIs (to avoid rate limits and poor context). Instead, the exported `.csv`/`.xlsx` files are designed to be easily fed into external LLMs or passed to localization teams, then re-imported. -* **Editor Syncing:** Imported tables can be previewed and edited directly within Unity, then exported back out. Editor Mode support ensures localization can be used inside custom Editor tools and UI Toolkit extensions. - -### 3. Hybrid Access System (Code-Gen + String Keys) - -To support both type-safety for UI and infinite scalability for massive datasets (e.g., RPG dialogues), the package uses a hybrid access model: - -* **Optional Code Generation:** Developers can flag specific tables (e.g., `UI`, `Settings`) to generate `TableName`, `EntryName`, and `LanguageName` enums. This eliminates magic strings and human error for static UI. -* **String/Hash Key Access:** For massive tables containing tens of thousands of entries, developers can bypass code-gen and access entries via string or hashed integer keys, preventing assembly bloat and compile-time degradation. - -### 4. Smart Formatting & Pluralization (ICU Message Format) - -The package must support the **ICU Message Format** standard to handle dynamic variables, pluralization, and gender rules directly within the string, avoiding complex C# logic. - -* **Pre-Parsing:** To save runtime CPU cycles, ICU strings (e.g., `You have {coinCount, plural, one {one coin} other {# coins}}.`) are pre-parsed into an internal instruction tree during the Unity Editor import phase. -* **Zero-Allocation Variables:** Variables passed into the localizer must avoid boxing. Use generic structs, `ref` parameters, or a custom argument buffer interface (e.g., `new PluralArg("coinCount", 5)`). - -### 5. Extreme Efficiency & Zero-Allocation Internals - -* **Internal Pipeline:** String allocations must be completely bypassed internally using `ReadOnlySpan` and custom lexers. -* **Output Boundary:** Standard Unity components (Text, TextMeshPro, UI Toolkit) ultimately require a `string` assignment. The engine must ensure that the *only* allocation that occurs is the final string generation at the UI boundary. -* **Idle CPU:** Reactive observers and properties must be completely dormant when localization state is static. - -### 6. Memory Management & Addressables Readiness - -* **On-Demand Loading:** Tables must be chunked. The package must not load all tables into memory at once. -* **Async Streaming:** The architecture must support asynchronous loading of requested language tables via standard Resource providers or Unity Addressables. -* **Disposal:** Unused tables must be cleanly disposable to free up memory on strict platforms like WebGL and mobile. - -### 7. Reactive Nature & Tearing Mitigation - -* **Reactive Properties:** Developers can define local reactive fields (e.g., `LocalizerText _playText`). When initialized, they sync automatically and listen for global language changes. -* **Ordered Syncing:** To prevent CPU spiking during a language switch, the `Localizer` instance sequentially updates registered properties. -* **Tearing Handling:** Because sequential updates across thousands of UI elements might cause visual "tearing" (updating over several frames), the API must expose async callbacks/events so the developer can trigger a loading screen before the language switch begins and hide it when the queue finishes. - -### 8. Dependency Injection & Rapid Prototyping - -* **Core Architecture:** The core logic is instantiated via standard constructors and implements `IReactiveLocalizer`, making it perfectly suited for composition roots and DI frameworks like VContainer. -* **Prototyping Convenience:** To avoid anti-patterns in the core package while remaining accessible to beginners, a pre-built Singleton wrapper (`ReactiveLocalizerManager.cs`) must be included strictly within the `Samples~` folder. - -### 9. Scene Components & Developer Experience - -* **Searchable Dropdowns:** MonoBehaviour or UI Toolkit components must feature a highly polished custom Inspector with an instant-search dropdown to quickly find and assign target entries. -* **AI & Human Readability:** The internal codebase must feature explicit XML documentation, full-word variable naming, and clear code structure to ensure AI coding agents can easily parse, extend, and navigate the APIs. - -### 10. Utilities - -* **Glyph Extraction:** The package includes a utility to scan all loaded tables and generate a fixed string of unique characters per language, which can be used to generate optimized, stripped font atlases (e.g., TMPro Font Asset Creator). \ No newline at end of file From 7be486f74e4de76dce7a0e11923216c708b6159a Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 18:53:11 +0300 Subject: [PATCH 14/14] release: version 1.0.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f4eff..7601fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# ReactiveLocalizer (Unreleased) +# ReactiveLocalizer 1.0.0 ### What's new