Mod localization

8 days ago

Mod Translation System — Design Proposal

Captain of Industry — ProgramableNetwork modAuthor: Zdeněk Novotný (DeznekCZ)


Problem: LocStr Freezes Its Text at Construction

Mafi.Localization.LocStr (and all its variants — LocStr1, LocStr2, LocStr1Plural, …) store the translated text in plain fields, not properties. The field is written once, at the moment the LocStr is constructed:

public static readonly LocStr X = Loc.Str("MyMod_Key", "English fallback", "");
//                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  Snapshots LocalizationManager.s_data["MyMod_Key"] right now.
//  If the key is not there yet, the LocStr is permanently English.

COI's mod loader calls LocalizationManager.ScanForStaticLocStrFields(modAssembly) to force-initialise all static readonly LocStr* fields in the mod assembly. This scan happens before the ModDefinition constructor runs.So by the time our code gets its first execution slot, every LocStr field is already frozen with the English fallback — even if a translation file exists on disk.


Proposed Solution: Two-Part Infrastructure

Part 1 — ModTranslations.Load(manifest): Splice + Rebind

Note for the COI team: Ideally, the engine would invoke mod translation loading immediately after the mod DLL is loaded — before ScanForStaticLocStrFields runs on the assembly. At that point every LocStr static field is still uninitialised, so a single splice into s_data would be enough and no rebind/reflection pass would be needed. Calling it from RegisterPrototypes is too late: the static cctor scan has already frozen all LocStr fields to their English fallbacks by then.

public ModDefinition(ModManifest manifest) : base(manifest)
{
    ModTranslations.Load(manifest); // must be the very first line
    Log.Info($"{nameof(MyMod)}: constructed");
}

Load performs three steps:

Step What it does
1. Splice Reads Translations/<lang>.json from the mod root and inserts every key-value pair into LocalizationManager.s_data via reflection.
2. Forward scan Calls LocalizationManager.ScanForStaticLocStrFields(assembly) — any LocStr field that has not been initialised yet will now snapshot the correct (already-spliced) translation.
3. Rebind Walks every static field in the assembly whose type lives in Mafi.Localization, reads its Id, looks up the translation in s_data, and overwrites the frozen translation slots via reflection. Works for both struct- and class-based LocStr types using a boxed-write-back pattern.

File layout (placed next to the mod DLL):

Translations/
    en.json   ← English template, auto-generated by pn_exportTranslations
    cs.json   ← Czech translation
    de.json   ← German translation (etc.)

JSON schema — identical to the base game:

[
    ["MyMod_Key",        "Přeložený text"],
    ["MyMod_Key_Plural", "Přeložený text", "Množný tvar"]
]

Part 2 — LaterText Extension: Deferred UI Text

Even after rebind, UI components that were built before rebind completed have already captured the wrong string into their internal display state. LaterText is the safety net:

// Instead of:
new Label().Value(NewTr.Inspector.ComputingSpeed)

// Use:
new Label().LaterText(() => NewTr.Inspector.ComputingSpeed, this)

The lambda is invoked:

  1. Immediately — so the component is not blank (may show English if rebind hasn't run yet).
  2. Once on the first OnShow of the host — by this time rebind is guaranteed to have run, so the correct language is applied.
  3. Never again — the pending list is cleared after first show; no performance overhead on subsequent opens.

Available overloads:

// Label — LocStrFormatted getter
Label  .LaterText(Func<LocStrFormatted> getter, UiComponent host)

// Label — LocStr getter (implicit conversion)
Label  .LaterText(Func<LocStr> getter,          UiComponent host)

// Any UiComponent — custom setter (e.g. for tooltips, displays)
T      .LaterText(Func<LocStrFormatted> getter, UiComponent host, Action<T, LocStrFormatted> setter)
T      .LaterText(Func<LocStr> getter,          UiComponent host, Action<T, LocStrFormatted> setter)

Verification

When the mod loads with a non-English language, the log should contain:

[MyMod] Loaded 142 translations for 'cs-CZ' from '...\Translations\cs.json'
[MyMod] Rebound 87 static LocStr fields (12 keys not in s_data)
  • Loaded N — N entries spliced into s_data.
  • Rebound M — M frozen fields successfully overwritten.
  • K keys not in s_data — K fields have no translation entry and remain English (expected for any key intentionally untranslated).

For LaterText registrations:

[LaterText] Registry created  host=MyDialog#1
[LaterText] Registered        host=MyDialog#1 freshHost=true totalPending=1
[LaterText] OnShow fired      host=MyDialog#1 pending=1
[LaterText] OnShow finished   host=MyDialog#1 cleared

English Template Export

A console command (pn_exportTranslations) writes Translations/en.json containing every key registered by the mod (filtered by known mod-key prefixes) with its source English text. Translators copy the file, rename it to the target language (e.g. cs.json) and replace the right-hand strings.

Run it after all prototypes are registered so that proto names and descriptions are included:

> pn_exportTranslations
Exported English translation template to '...\Translations\en.json'.

Summary: What the Game Engine Would Need to Support Natively

The root cause of this complexity is that LocalizationManager does not expose a public API for mods to splice additional translations after the initial load. The following engine-side change would eliminate the need for this entire workaround:

Requested API Purpose
LocalizationManager.RegisterModTranslations(Assembly, Dict<string,LocData>) Splices mod keys and re-scans the assembly's LocStr fields in one atomic step.
Call site: after each mod DLL is loaded, before any static cctor scan Ensures every LocStr in the mod assembly is constructed with the correct language already present in s_data.

With that API, mod translations would work identically to base-game translations with zero reflection and zero deferred-text machinery.


Appendix — Source Code

Based on example implementation in Programable Network mod.

ModDefinition.cs (entry point)

// src/ProgramableNetwork/ModDefinition.cs
using Mafi;
using Mafi.Base;
using Mafi.Core;
using Mafi.Core.Mods;
using ProgramableNetwork.Data.Mod;
using ProgramableNetwork.Data.Modules;
using System;
using System.IO;
using ProgramableNetwork.Data.DisplayEntity;
using UnityEngine;

namespace ProgramableNetwork
{
    public sealed class ModDefinition : DataOnlyMod {

        // Mod constructor that lists mod dependencies as parameters.
        // This guarantee that all listed mods will be loaded before this mod.
        // It is a good idea to depend on both `Mafi.Core.CoreMod` and `Mafi.Base.BaseMod`.
        public ModDefinition(ModManifest manifest) : base(manifest) {
            // Load translations as early as possible: must run before any Loc.Str / Proto.CreateStr
            // call from this assembly (including ones triggered by static cctors during prototype
            // registration). Otherwise those LocStr instances may snapshot the English fallback.
            ModTranslations.Load(manifest);

            Log.Info($"{nameof(ProgramableNetwork)}: constructed");
        }


        public override void RegisterPrototypes(ProtoRegistrator registrator) {
            Log.Info($"{nameof(ProgramableNetwork)}: registering prototypes");

            registrator.PrototypesDb.RegisterPhantom(ModuleProto.Phantom);

            registrator.RegisterAllProducts();
            // Other mod data

            // To dump every mod-registered en-US string to <modRoot>/Translations/en.json, run the
            // `pn_exportTranslations` console command (see ModConsoleCommands).
        }
    }
}

ModTranslations.cs (splice + rebind + export)

// src/ProgramableNetwork/Data/Translate/ModTranslations.cs
using Mafi;
using Mafi.Collections;
using Mafi.Core.Mods;
using Mafi.Localization;
using Mafi.Serialization;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;

namespace ProgramableNetwork
{
    /// <summary>
    /// Loads mod-specific translation files into <c>LocalizationManager.s_data</c> via reflection.
    ///
    /// COI loads exactly one translation JSON (from <c>&lt;WorkDir&gt;/Translations/&lt;lang&gt;.json</c>)
    /// at startup, and <c>LocalizationManager.TryLoadTranslationsFrom</c> rejects further calls once
    /// any <c>Loc.Str</c> has run — which is true by the time our mod loads. To still ship per-mod
    /// translations we splice our entries into the already-loaded dictionary.
    ///
    /// File layout (next to the mod DLL): <c>Translations/&lt;LangFileName&gt;.json</c>, same JSON
    /// schema as the base game (<c>[ ["key", "translation"], ... ]</c>).
    /// </summary>
    public static class ModTranslations
    {
        private const string TRANSLATIONS_DIR = "Translations";
        private const string ENGLISH_TEMPLATE_FILE = "en.json";

        /// <summary>
        /// Key prefixes recognized as belonging to this mod when exporting an English template.
        /// All Loc.Str / Proto.CreateStr ids registered by this mod start with one of these.
        /// </summary>
        private static readonly string[] MOD_KEY_PREFIXES =
        {
            "ProgramableNetwork_",
            "ResearchProgramableNetwork_",
            "__PHANTOM_CONTROLLER",
            "__PHANTOM_DISPLAY",
            "__PHANTOM__MODULE__"
        };

        private static bool s_loaded;

        /// <summary>
        /// Idempotent. Loads from <c>&lt;manifest.RootDirectoryPath&gt;/Translations/&lt;lang.FileName&gt;</c>.
        /// Preferred entry point — call from <c>RegisterPrototypes</c> with the manifest passed to the
        /// <see cref="ModDefinition"/> constructor.
        /// </summary>
        public static bool Load(ModManifest manifest)
        {
            if (s_loaded) {
                return true;
            }
            s_loaded = true;
            string modRoot = manifest?.RootDirectoryPath;
            try
            {
                LocalizationManager.LangInfo lang = LocalizationManager.CurrentLangInfo;
                if (lang.CultureInfoId == "en-US")
                {
                    // English uses the second arg of Loc.Str(...) directly; nothing to inject.
                    return true;
                }

                if (string.IsNullOrEmpty(modRoot))
                {
                    return true;
                }

                string filePath = Path.Combine(modRoot, TRANSLATIONS_DIR, lang.FileName);
                if (!File.Exists(filePath))
                {
                    Log.Info($"[ProgramableNetwork] No translation file for '{lang.CultureInfoId}' at '{filePath}'.");
                    return true;
                }

                string json = File.ReadAllText(filePath);
                if (!LocalizationUtils.TryParseJsonFileData(json, out Dict<string, LocalizationManager.LocData> parsed, out string error))
                {
                    Log.Error($"[ProgramableNetwork] Failed to parse '{filePath}': {error}");
                    return true;
                }

                FieldInfo sDataField = typeof(LocalizationManager).GetField("s_data", BindingFlags.NonPublic | BindingFlags.Static);
                if (sDataField == null)
                {
                    Log.Error("[ProgramableNetwork] LocalizationManager.s_data field not found via reflection.");
                    return true;
                }

                Dict<string, LocalizationManager.LocData> sData = sDataField.GetValue(null) as Dict<string, LocalizationManager.LocData>;
                if (sData == null)
                {
                    sData = new Dict<string, LocalizationManager.LocData>();
                    sDataField.SetValue(null, sData);
                }

                int count = 0;
                foreach (KeyValuePair<string, LocalizationManager.LocData> kvp in parsed)
                {
                    sData[kvp.Key] = kvp.Value;
                    count++;
                }

                Log.Info($"[ProgramableNetwork] Loaded {count} translations for '{lang.CultureInfoId}' from '{filePath}'.");

                // LocStr captures its TranslatedString at construction. Any static LocStr field that
                // was initialized by the mod loader before our splice ran is permanently English.
                // Force-init any not-yet-touched static LocStr fields so they get the (now-current)
                // Czech, then reach into the already-frozen ones and overwrite their TranslatedString
                // from s_data. Both passes together cover the whole assembly.
                LocalizationManager.ScanForStaticLocStrFields(typeof(ModTranslations).Assembly);
                RebindStaticLocStrs(typeof(ModTranslations).Assembly, sData);
            }
            catch (Exception ex)
            {
                Log.Exception(ex, "[ProgramableNetwork] Failed to load mod translations.");
            }

            return true;
        }

        /// <summary>
        /// Walks every static field in <paramref name="asm"/> whose declaring type lives in
        /// <c>Mafi.Localization</c> and looks like a localized-string carrier (has an <c>Id</c>
        /// string field plus at least one other string field). For each such static field reads
        /// its <c>Id</c>, looks up <paramref name="sData"/>[Id], and copies the translation strings
        /// from <c>LocData</c>'s <c>ImmutableArray&lt;string&gt;</c> into the matching slots on the
        /// LocStr instance — index 0 to the first non-<c>Id</c> string field, index 1 to the second
        /// (covers plural variants), etc.
        ///
        /// Boxed-write-back pattern works for both struct- and class-based LocStr types.
        /// </summary>
        private static void RebindStaticLocStrs(Assembly asm, Dict<string, LocalizationManager.LocData> sData)
        {
            // Find the ImmutableArray<string> field on LocData — that's where the parsed translations
            // live (one entry per JSON value: index 0 = singular translation, index 1 = plural, etc.).
            Type locDataType = typeof(LocalizationManager.LocData);
            FieldInfo locDataArrayField = null;
            foreach (FieldInfo f in locDataType.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
            {
                Type ft = f.FieldType;
                if (!ft.IsGenericType) {
                    continue;
                }
                if (!ft.Name.StartsWith("ImmutableArray", StringComparison.Ordinal)) {
                    continue;
                }
                Type[] args = ft.GetGenericArguments();
                if (args.Length != 1 || args[0] != typeof(string)) {
                    continue;
                }
                locDataArrayField = f;
                break;
            }
            if (locDataArrayField == null)
            {
                Log.Warning("[ProgramableNetwork] RebindStaticLocStrs: could not find ImmutableArray<string> field on LocData.");
                return;
            }

            // Reflect Length + indexer once (ImmutableArray<T> exposes both publicly).
            Type immutableArrayType = locDataArrayField.FieldType;
            PropertyInfo lengthProp = immutableArrayType.GetProperty("Length", BindingFlags.Public | BindingFlags.Instance);
            PropertyInfo itemProp = null;
            foreach (PropertyInfo p in immutableArrayType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
            {
                ParameterInfo[] indexers = p.GetIndexParameters();
                if (indexers.Length == 1 && indexers[0].ParameterType == typeof(int) && p.PropertyType == typeof(string))
                {
                    itemProp = p;
                    break;
                }
            }
            if (lengthProp == null || itemProp == null)
            {
                Log.Warning("[ProgramableNetwork] RebindStaticLocStrs: ImmutableArray<string> shape lacks expected Length/indexer.");
                return;
            }

            // Discover LocStr-shaped types: any Mafi.Localization type with an Id string field plus
            // one or more other string fields (those are the translation slots we'll overwrite).
            Assembly mafiAsm = typeof(LocStr).Assembly;
            Dictionary<Type, LocStrShape> shapesByType = new Dictionary<Type, LocStrShape>();
            foreach (Type t in mafiAsm.GetTypes())
            {
                if (t.Namespace != "Mafi.Localization") {
                    continue;
                }
                FieldInfo[] stringFields = t
                    .GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic)
                    .Where(f => f.FieldType == typeof(string))
                    .ToArray();
                FieldInfo idField = stringFields.FirstOrDefault(f => f.Name == "Id");
                if (idField == null) {
                    continue;
                }
                FieldInfo[] translationSlots = stringFields.Where(f => f != idField).ToArray();
                if (translationSlots.Length == 0) {
                    continue;
                }
                shapesByType[t] = new LocStrShape(idField, translationSlots);
            }

            int rebound = 0;
            int missing = 0;
            object[] indexBuf = new object[1];
            foreach (Type type in asm.GetTypes())
            {
                FieldInfo[] fields;
                try
                {
                    fields = type.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
                }
                catch
                {
                    continue;
                }

                foreach (FieldInfo field in fields)
                {
                    if (!shapesByType.TryGetValue(field.FieldType, out LocStrShape shape)) {
                        continue;
                    }

                    object boxed;
                    try { boxed = field.GetValue(null); }
                    catch { continue; }
                    if (boxed == null) {
                        continue;
                    }

                    string id = shape.IdField.GetValue(boxed) as string;
                    if (string.IsNullOrEmpty(id)) {
                        continue;
                    }

                    if (!sData.TryGetValue(id, out LocalizationManager.LocData data))
                    {
                        missing++;
                        continue;
                    }

                    object array = locDataArrayField.GetValue(data);
                    if (array == null) {
                        continue;
                    }
                    int length = (int)lengthProp.GetValue(array);
                    if (length == 0) {
                        continue;
                    }

                    try
                    {
                        bool wrote = false;
                        int slotsToFill = Math.Min(length, shape.Slots.Length);
                        for (int i = 0; i < slotsToFill; i++)
                        {
                            indexBuf[0] = i;
                            string translation = itemProp.GetValue(array, indexBuf) as string;
                            if (string.IsNullOrEmpty(translation)) {
                                continue;
                            }
                            shape.Slots[i].SetValue(boxed, translation);
                            wrote = true;
                        }
                        if (wrote)
                        {
                            field.SetValue(null, boxed); // write back — required for struct LocStr, harmless for class
                            rebound++;
                        }
                    }
                    catch (Exception ex)
                    {
                        Log.Exception(ex, $"[ProgramableNetwork] Rebind failed for {type.FullName}.{field.Name} (id={id}).");
                    }
                }
            }
            Log.Info($"[ProgramableNetwork] Rebound {rebound} static LocStr fields ({missing} keys not in s_data).");
        }

        private readonly struct LocStrShape
        {
            public readonly FieldInfo IdField;
            public readonly FieldInfo[] Slots;
            public LocStrShape(FieldInfo id, FieldInfo[] slots)
            {
                IdField = id;
                Slots = slots;
            }
        }

        /// <summary>
        /// Writes an English translation template to <c>&lt;manifest.RootDirectoryPath&gt;/Translations/en.json</c>
        /// containing every key registered by this mod (filtered by <see cref="MOD_KEY_PREFIXES"/>) with
        /// its source en-US string. Translators copy this file, rename it to the target language
        /// (e.g. <c>cs.json</c>) and replace the right-hand strings.
        ///
        /// Call from <c>RegisterPrototypes</c> AFTER all <c>RegisterData&lt;...&gt;</c> calls so every
        /// proto's name/desc has been registered into <c>s_enUsData</c>.
        /// </summary>
        public static void ExportEnglish(ModManifest manifest)
        {
            try
            {
                if (manifest == null || string.IsNullOrEmpty(manifest.RootDirectoryPath))
                {
                    Log.Error("[ProgramableNetwork] ExportEnglish: manifest unavailable.");
                    return;
                }

                LocalizationManager.ScanForStaticLocStrFields(typeof(ModTranslations).Assembly);

                FieldInfo enUsField = typeof(LocalizationManager).GetField("s_enUsData", BindingFlags.NonPublic | BindingFlags.Static);
                if (enUsField == null)
                {
                    Log.Error("[ProgramableNetwork] ExportEnglish: LocalizationManager.s_enUsData field not found via reflection.");
                    return;
                }

                FieldInfo skipExportField = typeof(LocalizationManager).GetField("s_skipForExport", BindingFlags.NonPublic | BindingFlags.Static);
                HashSet<string> skipExport = new HashSet<string>(StringComparer.Ordinal);
                if (skipExportField?.GetValue(null) is IEnumerable skipEnum)
                {
                    foreach (object item in skipEnum)
                    {
                        if (item is string s) {
                            skipExport.Add(s);
                        }
                    }
                }

                IEnumerable enUsEnumerable = enUsField.GetValue(null) as IEnumerable;
                if (enUsEnumerable == null)
                {
                    Log.Error("[ProgramableNetwork] ExportEnglish: s_enUsData is null or not enumerable.");
                    return;
                }

                FieldInfo enUsValueField = null;
                FieldInfo pluralField = null;
                PropertyInfo kvpKeyProp = null;
                PropertyInfo kvpValueProp = null;
                PropertyInfo pluralHasValueProp = null;
                PropertyInfo pluralValueProp = null;

                List<KeyValuePair<string, string[]>> entries = new List<KeyValuePair<string, string[]>>();
                foreach (object kvp in enUsEnumerable)
                {
                    if (kvpKeyProp == null)
                    {
                        Type kvpType = kvp.GetType();
                        kvpKeyProp = kvpType.GetProperty("Key");
                        kvpValueProp = kvpType.GetProperty("Value");
                    }

                    string key = kvpKeyProp.GetValue(kvp) as string;
                    if (string.IsNullOrEmpty(key)) {
                        continue;
                    }
                    if (!HasModPrefix(key)) {
                        continue;
                    }
                    if (skipExport.Contains(key)) {
                        continue;
                    }

                    object locData = kvpValueProp.GetValue(kvp);
                    if (locData == null) {
                        continue;
                    }

                    if (enUsValueField == null)
                    {
                        Type locDataType = locData.GetType();
                        enUsValueField = locDataType.GetField("EnUs", BindingFlags.Public | BindingFlags.Instance);
                        pluralField = locDataType.GetField("Plural", BindingFlags.Public | BindingFlags.Instance);
                        if (enUsValueField == null)
                        {
                            Log.Error("[ProgramableNetwork] ExportEnglish: could not resolve LocDataEnUs.EnUs field.");
                            return;
                        }
                    }

                    string enUs = enUsValueField.GetValue(locData) as string ?? "";
                    if (string.IsNullOrEmpty(enUs)) {
                        continue;
                    }
                    if (enUs.IndexOf("TODO", StringComparison.Ordinal) >= 0) {
                        continue;
                    }
                    if (enUs.IndexOf("HIDE", StringComparison.Ordinal) >= 0) {
                        continue;
                    }

                    string plural = null;
                    if (pluralField != null)
                    {
                        object pluralOpt = pluralField.GetValue(locData);
                        if (pluralOpt != null)
                        {
                            if (pluralHasValueProp == null)
                            {
                                pluralHasValueProp = pluralOpt.GetType().GetProperty("HasValue");
                                pluralValueProp = pluralOpt.GetType().GetProperty("Value");
                            }
                            if (pluralHasValueProp != null && (bool)pluralHasValueProp.GetValue(pluralOpt))
                            {
                                plural = pluralValueProp?.GetValue(pluralOpt) as string;
                            }
                        }
                    }

                    entries.Add(new KeyValuePair<string, string[]>(key,
                        plural != null ? new[] { enUs, plural } : new[] { enUs }));
                }

                entries.Sort((a, b) => string.CompareOrdinal(a.Key, b.Key));

                StringBuilder sb = new StringBuilder(64 * entries.Count);
                sb.AppendLine("[");
                for (int i = 0; i < entries.Count; i++)
                {
                    sb.Append("\t[\"").Append(JsonWriter.JsonEscapeString(entries[i].Key)).Append("\", ");
                    string[] values = entries[i].Value;
                    for (int j = 0; j < values.Length; j++)
                    {
                        if (j > 0) {
                            sb.Append(", ");
                        }
                        sb.Append('"').Append(JsonWriter.JsonEscapeString(values[j])).Append('"');
                    }
                    sb.Append(']');
                    if (i < entries.Count - 1) {
                        sb.Append(',');
                    }
                    sb.AppendLine();
                }
                sb.AppendLine("]");

                string outDir = Path.Combine(manifest.RootDirectoryPath, TRANSLATIONS_DIR);
                Directory.CreateDirectory(outDir);
                string outPath = Path.Combine(outDir, ENGLISH_TEMPLATE_FILE);
                File.WriteAllText(outPath, sb.ToString(), Encoding.UTF8);

                Log.Info($"[ProgramableNetwork] Exported {entries.Count} English keys to '{outPath}'.");
            }
            catch (Exception ex)
            {
                Log.Exception(ex, "[ProgramableNetwork] Failed to export English translations.");
            }
        }

        private static bool HasModPrefix(string key)
        {
            for (int i = 0; i < MOD_KEY_PREFIXES.Length; i++)
            {
                if (key.StartsWith(MOD_KEY_PREFIXES[i], StringComparison.Ordinal)) {
                    return true;
                }
            }
            return false;
        }
    }
}

LaterTextExtensions.cs (deferred UI text)

// src/ProgramableNetwork/Data/Translate/LaterTextExtensions.cs
using Mafi;
using Mafi.Localization;
using Mafi.Unity.UiToolkit.Component;
using Mafi.Unity.UiToolkit.Library;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;

namespace ProgramableNetwork
{
    /// <summary>
    /// Defers text application on UI components until the host is shown.
    ///
    /// Why: <see cref="LocStr"/> snapshots its <c>TranslatedString</c> at construction. If a UI
    /// component is built before the underlying string is correctly translated, the rendered text
    /// is permanently the wrong (usually English) value. Capturing a *getter* and re-invoking it on
    /// every <c>OnShow</c> refreshes whatever the component displays.
    /// </summary>
    public static class LaterTextExtensions
    {
        private static readonly ConditionalWeakTable<UiComponent, List<Action>> s_registry
            = new ConditionalWeakTable<UiComponent, List<Action>>();

        private static int s_nextHostId;
        private static readonly ConditionalWeakTable<UiComponent, HostTag> s_hostTags
            = new ConditionalWeakTable<UiComponent, HostTag>();

        private sealed class HostTag { public int Id; public string TypeName; }

        public static Label LaterText(this Label label, Func<LocStrFormatted> getter, UiComponent host)
        {
            Register(host, () => label.Value(getter()));
            return label;
        }

        public static Label LaterText(this Label label, Func<LocStr> getter, UiComponent host)
        {
            Register(host, () => label.Value(getter()));
            return label;
        }

        public static T LaterText<T>(this T component, Func<LocStrFormatted> getter, UiComponent host,
            Action<T, LocStrFormatted> setter) where T : UiComponent
        {
            Register(host, () => setter(component, getter()));
            return component;
        }

        public static T LaterText<T>(this T component, Func<LocStr> getter, UiComponent host,
            Action<T, LocStrFormatted> setter) where T : UiComponent
        {
            Register(host, () => setter(component, getter()));
            return component;
        }

        private static void Register(UiComponent host, Action apply)
        {
            HostTag tag = GetTag(host);
            bool freshHost = false;
            List<Action> list = s_registry.GetValue(host, h =>
            {
                freshHost = true;
                List<Action> newList = new List<Action>();
                h.OnShow(() =>
                {
                    int count = newList.Count;
                    Log.Info($"[LaterText] OnShow fired host={tag.TypeName}#{tag.Id} pending={count}");
                    for (int i = 0; i < count; i++)
                    {
                        try { newList[i](); }
                        catch (Exception ex) { Log.Exception(ex, $"[LaterText] apply #{i} threw on host={tag.TypeName}#{tag.Id}"); }
                    }
                    newList.Clear();
                    Log.Info($"[LaterText] OnShow finished host={tag.TypeName}#{tag.Id} cleared");
                });
                Log.Info($"[LaterText] Registry created host={tag.TypeName}#{tag.Id}");
                return newList;
            });
            list.Add(apply);
            Log.Info($"[LaterText] Registered host={tag.TypeName}#{tag.Id} freshHost={freshHost} totalPending={list.Count}");
            try { apply(); }
            catch (Exception ex) { Log.Exception(ex, $"[LaterText] initial apply threw on host={tag.TypeName}#{tag.Id}"); }
        }

        private static HostTag GetTag(UiComponent host)
        {
            return s_hostTags.GetValue(host, h => new HostTag
            {
                Id = Interlocked.Increment(ref s_nextHostId),
                TypeName = h.GetType().Name
            });
        }
    }
}

ModConsoleCommands.cs (export trigger)

// src/ProgramableNetwork/Data/Translate/ModConsoleCommands.cs
using Mafi.Core;
using Mafi.Core.Console;
using Mafi.Core.Mods;
using System.IO;
using System.Linq;
using Mafi;

namespace ProgramableNetwork
{
    [GlobalDependency(RegistrationMode.AsSelf, false, false)]
    public class ModConsoleCommands
    {
        private const string MOD_ID = "ProgramableNetwork";

        [ConsoleCommand(
            invokeOnMainThread: false,
            invokeDuringSync: false,
            documentation: "Exports every en-US translation key registered by the ProgramableNetwork mod " +
                           "to <modRoot>/Translations/en.json. Translators copy that file, rename it to the " +
                           "target language (e.g. cs.json, de.json) and replace the right-hand strings.",
            customCommandName: "pn_exportTranslations")]
        private string ExportTranslations()
        {
            ModManifest manifest = ModsLoader.LoadedAndFailedMods
                .AsEnumerable()
                .FirstOrDefault(x => x.Manifest.Id == MOD_ID)
                ?.Manifest;

            if (manifest == null)
            {
                return $"Mod '{MOD_ID}' not found in loaded mods.";
            }

            ModTranslations.ExportEnglish(manifest);
            return $"Exported English translation template to '{Path.Combine(manifest.RootDirectoryPath, "Translations", "en.json")}'.";
        }
    }
}
6 days ago

I didn’t dip into localizing my mods yet and I didn’t understand every nuance of this, but it sounds like mod localization needs some attention if we want modders to bother with localization.

61 Showing 12 of 2
Log in to reply.