From 56fd92577497dc92871c02e760d61e2fb89f5370 Mon Sep 17 00:00:00 2001 From: hazre <37149950+hazre@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:30:20 +0100 Subject: [PATCH 1/5] Update target framework to .NET 10 and upgrade dependencies Upgrade from .NET 9 to .NET 10 and update package references including Microsoft.SourceLink.GitHub to 10.0.102 and BepInExResoniteShim to 0.9.x. --- BepisLocaleLoader.csproj | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/BepisLocaleLoader.csproj b/BepisLocaleLoader.csproj index b0c77fb..7fc192a 100644 --- a/BepisLocaleLoader.csproj +++ b/BepisLocaleLoader.csproj @@ -3,7 +3,7 @@ 1.2.0 ResoniteModding - net9.0 + net10.0 https://github.com/ResoniteModding/BepisLocaleLoader https://github.com/ResoniteModding/BepisLocaleLoader git @@ -29,16 +29,16 @@ - - - - + + + + - + @@ -68,12 +68,12 @@ - - + + - - + + From 10783308035cd642c2b5bfc699e766b670eb9578 Mon Sep 17 00:00:00 2001 From: hazre <37149950+hazre@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:08:21 +0100 Subject: [PATCH 2/5] Replace ResonitePluginInfoProps with BepInEx.AutoPlugin source generator Switch to BepInEx.AutoPlugin 2.1.0 for automatic plugin metadata generation. Make Plugin class partial and use [BepInAutoPlugin] attribute instead of manually specifying [ResonitePlugin] with PluginMetadata constants. --- BepisLocaleLoader.csproj | 2 +- LocaleLoader.cs | 402 +++++++++++++++++++-------------------- Plugin.cs | 63 +++--- 3 files changed, 233 insertions(+), 234 deletions(-) diff --git a/BepisLocaleLoader.csproj b/BepisLocaleLoader.csproj index 7fc192a..61c7e6b 100644 --- a/BepisLocaleLoader.csproj +++ b/BepisLocaleLoader.csproj @@ -31,7 +31,7 @@ - + diff --git a/LocaleLoader.cs b/LocaleLoader.cs index 525ae18..8fd788d 100644 --- a/LocaleLoader.cs +++ b/LocaleLoader.cs @@ -1,202 +1,202 @@ -using System.Text.Json; -using BepInEx; -using Elements.Assets; -using Elements.Core; -using FrooxEngine; - -namespace BepisLocaleLoader; - -// Edited Locale Code is from - https://github.com/Xlinka/Project-Obsidian/blob/main/ProjectObsidian/Settings/LocaleHelper.cs -public static class LocaleLoader -{ - private static StaticLocaleProvider _localeProvider; - private static string _lastOverrideLocale; - private const string OverrideLocaleString = "somethingRandomJustToMakeItChange"; - - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true - }; - - public static readonly HashSet PluginsWithLocales = new HashSet(); - - public static void AddLocaleString(string rawString, string localeString, bool force = false, string authors = null) - { - List finalAuthors; - - if (!string.IsNullOrWhiteSpace(authors)) - { - finalAuthors = authors.Split(", ", StringSplitOptions.RemoveEmptyEntries).ToList(); - } - else if (!string.IsNullOrWhiteSpace(PluginMetadata.AUTHORS)) - { - finalAuthors = PluginMetadata.AUTHORS.Split(", ", StringSplitOptions.RemoveEmptyEntries).ToList(); - } - else - { - finalAuthors = new List { "BepInEx" }; - } - - LocaleData localeData = new LocaleData - { - LocaleCode = "en-US", - Authors = finalAuthors, - Messages = new Dictionary - { - { rawString, localeString } - } - }; - - Update(localeData, force); - } - - public static void AddLocaleFromPlugin(PluginInfo plugin) - { - string dir = Path.GetDirectoryName(plugin.Location); - string locale = Path.Combine(dir, "Locale"); - - if (!Path.Exists(locale)) return; - - Plugin.Log.LogDebug($"Adding locale for {plugin.Metadata.GUID}"); - ProcessPath(locale, AddLocaleFromFile); - - PluginsWithLocales.Add(plugin); - } - - public static void AddLocaleFromFile(string path) - { - if (!File.Exists(path)) return; - - string json = File.ReadAllText(path); - LocaleData localeData; - try - { - localeData = JsonSerializer.Deserialize(json, JsonOptions); - } - catch (Exception e) - { - Plugin.Log.LogError(e); - return; - } - - Plugin.Log.LogDebug($"- LocaleCode: {localeData.LocaleCode}, Message Count: {localeData.Messages.Count}"); - - Update(localeData, true); - } - - private static void Update(LocaleData localeData, bool force) - { - UpdateDelayed(localeData, force); - Settings.RegisterValueChanges(_ => UpdateDelayed(localeData, force)); - } - - private static void UpdateDelayed(LocaleData localeData, bool force) - { - Userspace.UserspaceWorld?.RunInUpdates(15, () => UpdateIntern(localeData, force)); - } - - private static void UpdateIntern(LocaleData localeData, bool force) - { - _localeProvider = Userspace.UserspaceWorld?.GetCoreLocale(); - if (_localeProvider?.Asset?.Data is null) - { - Userspace.UserspaceWorld?.RunSynchronously(() => UpdateIntern(localeData, force)); - } - else - { - UpdateLocale(localeData, force); - } - } - - private static void UpdateLocale(LocaleData localeData, bool force) - { - if (_localeProvider?.Asset?.Data != null) - { - if (!force) - { - string firstKey = localeData.Messages.Keys.FirstOrDefault(); - - bool alreadyExists = _localeProvider.Asset.Data.Messages.Any(ld => ld.Key == firstKey); - if (alreadyExists) return; - } - - _localeProvider.Asset.Data.LoadDataAdditively(localeData); - - // force asset update for locale provider - if (_localeProvider.OverrideLocale.Value != null && _localeProvider.OverrideLocale.Value != OverrideLocaleString) - { - _lastOverrideLocale = _localeProvider.OverrideLocale.Value; - } - - _localeProvider.OverrideLocale.Value = OverrideLocaleString; - Userspace.UserspaceWorld.RunInUpdates(1, () => { _localeProvider.OverrideLocale.Value = _lastOverrideLocale; }); - } - else if (_localeProvider?.Asset?.Data == null) - { - Plugin.Log.LogError("Locale data is null when it shouldn't be!"); - } - } - - private static void ProcessPath(string path, Action fileAction) - { - if (!Path.Exists(path)) - { - throw new DirectoryNotFoundException("Directory not found: " + path); - } - - ProcessDirectory(path, fileAction); - } - - private static void ProcessDirectory(string directory, Action fileAction) - { - string[] files = Directory.GetFiles(directory); - foreach (string file in files) - { - fileAction(file); - } - - string[] subdirectories = Directory.GetDirectories(directory); - foreach (string subdir in subdirectories) - { - ProcessDirectory(subdir, fileAction); - } - } - - private static string GetFormattedLocaleString(this string key, string format, Dictionary dict, (string, object)[] arguments) - { - Dictionary merged = dict != null ? new Dictionary(dict) : new Dictionary(); - - if (arguments != null) - { - foreach ((string name, object value) in arguments) - { - merged[name] = value; - } - } - - string formatted = _localeProvider?.Asset?.Format(key, merged); - - if (!string.IsNullOrWhiteSpace(format)) - { - formatted = string.Format(format, formatted); - } - - return formatted; - } - - public static string GetFormattedLocaleString(this string key) => key.GetFormattedLocaleString(null, null, null); - public static string GetFormattedLocaleString(this string key, string format) => key.GetFormattedLocaleString(format, null, null); - public static string GetFormattedLocaleString(this string key, string argName, object argField) => key.GetFormattedLocaleString(null, null, new (string, object)[] { (argName, argField) }); - public static string GetFormattedLocaleString(this string key, string format, string argName, object argField) => key.GetFormattedLocaleString(format, null, new (string, object)[] { (argName, argField) }); - public static string GetFormattedLocaleString(this string key, params (string, object)[] arguments) => key.GetFormattedLocaleString(null, null, arguments); - public static string GetFormattedLocaleString(this string key, string format, params (string, object)[] arguments) => key.GetFormattedLocaleString(format, null, arguments); - - public static LocaleString T(this string str, string argName, object argField) => str.AsLocaleKey(null, (argName, argField)); - public static LocaleString T(this string str, string format, string argName, object argField) => str.AsLocaleKey(format, (argName, argField)); - public static LocaleString T(this string str, params (string, object)[] arguments) => str.AsLocaleKey(null, arguments); - public static LocaleString T(this string str, string format, params (string, object)[] arguments) => str.AsLocaleKey(format, arguments); - public static LocaleString T(this string str, bool continuous, Dictionary arguments = null) => new LocaleString(str, null, true, continuous, arguments); - public static LocaleString T(this string str, string format = null, bool continuous = true, Dictionary arguments = null) => new LocaleString(str, format, true, continuous, arguments); +using System.Text.Json; +using BepInEx; +using Elements.Assets; +using Elements.Core; +using FrooxEngine; + +namespace BepisLocaleLoader; + +// Edited Locale Code is from - https://github.com/Xlinka/Project-Obsidian/blob/main/ProjectObsidian/Settings/LocaleHelper.cs +public static class LocaleLoader +{ + private static StaticLocaleProvider _localeProvider; + private static string _lastOverrideLocale; + private const string OverrideLocaleString = "somethingRandomJustToMakeItChange"; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public static readonly HashSet PluginsWithLocales = new HashSet(); + + public static void AddLocaleString(string rawString, string localeString, bool force = false, string authors = null) + { + List finalAuthors; + + if (!string.IsNullOrWhiteSpace(authors)) + { + finalAuthors = authors.Split(", ", StringSplitOptions.RemoveEmptyEntries).ToList(); + } + else if (!string.IsNullOrWhiteSpace(Plugin.AUTHORS)) + { + finalAuthors = Plugin.AUTHORS.Split(", ", StringSplitOptions.RemoveEmptyEntries).ToList(); + } + else + { + finalAuthors = new List { "BepInEx" }; + } + + LocaleData localeData = new LocaleData + { + LocaleCode = "en-US", + Authors = finalAuthors, + Messages = new Dictionary + { + { rawString, localeString } + } + }; + + Update(localeData, force); + } + + public static void AddLocaleFromPlugin(PluginInfo plugin) + { + string dir = Path.GetDirectoryName(plugin.Location); + string locale = Path.Combine(dir, "Locale"); + + if (!Path.Exists(locale)) return; + + Plugin.Log.LogDebug($"Adding locale for {plugin.Metadata.GUID}"); + ProcessPath(locale, AddLocaleFromFile); + + PluginsWithLocales.Add(plugin); + } + + public static void AddLocaleFromFile(string path) + { + if (!File.Exists(path)) return; + + string json = File.ReadAllText(path); + LocaleData localeData; + try + { + localeData = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception e) + { + Plugin.Log.LogError(e); + return; + } + + Plugin.Log.LogDebug($"- LocaleCode: {localeData.LocaleCode}, Message Count: {localeData.Messages.Count}"); + + Update(localeData, true); + } + + private static void Update(LocaleData localeData, bool force) + { + UpdateDelayed(localeData, force); + Settings.RegisterValueChanges(_ => UpdateDelayed(localeData, force)); + } + + private static void UpdateDelayed(LocaleData localeData, bool force) + { + Userspace.UserspaceWorld?.RunInUpdates(15, () => UpdateIntern(localeData, force)); + } + + private static void UpdateIntern(LocaleData localeData, bool force) + { + _localeProvider = Userspace.UserspaceWorld?.GetCoreLocale(); + if (_localeProvider?.Asset?.Data is null) + { + Userspace.UserspaceWorld?.RunSynchronously(() => UpdateIntern(localeData, force)); + } + else + { + UpdateLocale(localeData, force); + } + } + + private static void UpdateLocale(LocaleData localeData, bool force) + { + if (_localeProvider?.Asset?.Data != null) + { + if (!force) + { + string firstKey = localeData.Messages.Keys.FirstOrDefault(); + + bool alreadyExists = _localeProvider.Asset.Data.Messages.Any(ld => ld.Key == firstKey); + if (alreadyExists) return; + } + + _localeProvider.Asset.Data.LoadDataAdditively(localeData); + + // force asset update for locale provider + if (_localeProvider.OverrideLocale.Value != null && _localeProvider.OverrideLocale.Value != OverrideLocaleString) + { + _lastOverrideLocale = _localeProvider.OverrideLocale.Value; + } + + _localeProvider.OverrideLocale.Value = OverrideLocaleString; + Userspace.UserspaceWorld.RunInUpdates(1, () => { _localeProvider.OverrideLocale.Value = _lastOverrideLocale; }); + } + else if (_localeProvider?.Asset?.Data == null) + { + Plugin.Log.LogError("Locale data is null when it shouldn't be!"); + } + } + + private static void ProcessPath(string path, Action fileAction) + { + if (!Path.Exists(path)) + { + throw new DirectoryNotFoundException("Directory not found: " + path); + } + + ProcessDirectory(path, fileAction); + } + + private static void ProcessDirectory(string directory, Action fileAction) + { + string[] files = Directory.GetFiles(directory); + foreach (string file in files) + { + fileAction(file); + } + + string[] subdirectories = Directory.GetDirectories(directory); + foreach (string subdir in subdirectories) + { + ProcessDirectory(subdir, fileAction); + } + } + + private static string GetFormattedLocaleString(this string key, string format, Dictionary dict, (string, object)[] arguments) + { + Dictionary merged = dict != null ? new Dictionary(dict) : new Dictionary(); + + if (arguments != null) + { + foreach ((string name, object value) in arguments) + { + merged[name] = value; + } + } + + string formatted = _localeProvider?.Asset?.Format(key, merged); + + if (!string.IsNullOrWhiteSpace(format)) + { + formatted = string.Format(format, formatted); + } + + return formatted; + } + + public static string GetFormattedLocaleString(this string key) => key.GetFormattedLocaleString(null, null, null); + public static string GetFormattedLocaleString(this string key, string format) => key.GetFormattedLocaleString(format, null, null); + public static string GetFormattedLocaleString(this string key, string argName, object argField) => key.GetFormattedLocaleString(null, null, new (string, object)[] { (argName, argField) }); + public static string GetFormattedLocaleString(this string key, string format, string argName, object argField) => key.GetFormattedLocaleString(format, null, new (string, object)[] { (argName, argField) }); + public static string GetFormattedLocaleString(this string key, params (string, object)[] arguments) => key.GetFormattedLocaleString(null, null, arguments); + public static string GetFormattedLocaleString(this string key, string format, params (string, object)[] arguments) => key.GetFormattedLocaleString(format, null, arguments); + + public static LocaleString T(this string str, string argName, object argField) => str.AsLocaleKey(null, (argName, argField)); + public static LocaleString T(this string str, string format, string argName, object argField) => str.AsLocaleKey(format, (argName, argField)); + public static LocaleString T(this string str, params (string, object)[] arguments) => str.AsLocaleKey(null, arguments); + public static LocaleString T(this string str, string format, params (string, object)[] arguments) => str.AsLocaleKey(format, arguments); + public static LocaleString T(this string str, bool continuous, Dictionary arguments = null) => new LocaleString(str, null, true, continuous, arguments); + public static LocaleString T(this string str, string format = null, bool continuous = true, Dictionary arguments = null) => new LocaleString(str, format, true, continuous, arguments); } \ No newline at end of file diff --git a/Plugin.cs b/Plugin.cs index 9e40211..3e6410a 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -1,33 +1,32 @@ -using BepInEx; -using BepInEx.Logging; -using BepInEx.NET.Common; -using BepInExResoniteShim; -using BepisResoniteWrapper; -using FrooxEngine; -using HarmonyLib; - -namespace BepisLocaleLoader; - -[ResonitePlugin(PluginMetadata.GUID, PluginMetadata.NAME, PluginMetadata.VERSION, PluginMetadata.AUTHORS, PluginMetadata.REPOSITORY_URL)] -[BepInDependency(BepInExResoniteShim.PluginMetadata.GUID, BepInDependency.DependencyFlags.HardDependency)] -public class Plugin : BasePlugin -{ - internal new static ManualLogSource Log; - - public override void Load() - { - // Plugin startup logic - Log = base.Log; - - ResoniteHooks.OnEngineReady += async () => - { - await Task.Delay(5000); - - if (NetChainloader.Instance.Plugins.Count <= 0) return; - - NetChainloader.Instance.Plugins.Values.Do(LocaleLoader.AddLocaleFromPlugin); - }; - - Log.LogInfo($"Plugin {PluginMetadata.GUID} is loaded!"); - } +using BepInEx; +using BepInEx.Logging; +using BepInEx.NET.Common; +using BepInExResoniteShim; +using BepisResoniteWrapper; +using HarmonyLib; + +namespace BepisLocaleLoader; + +[BepInAutoPlugin] +[BepInDependency(BepInExResoniteShim.PluginMetadata.GUID, BepInDependency.DependencyFlags.HardDependency)] +public partial class Plugin : BasePlugin +{ + internal new static ManualLogSource Log; + + public override void Load() + { + // Plugin startup logic + Log = base.Log; + + ResoniteHooks.OnEngineReady += async () => + { + await Task.Delay(5000); + + if (NetChainloader.Instance.Plugins.Count <= 0) return; + + NetChainloader.Instance.Plugins.Values.Do(LocaleLoader.AddLocaleFromPlugin); + }; + + Log.LogInfo($"Plugin {GUID} is loaded!"); + } } \ No newline at end of file From f53b954851db8a1a1cbb9a6fde0ed7fa822bac13 Mon Sep 17 00:00:00 2001 From: hazre <37149950+hazre@users.noreply.github.com> Date: Wed, 21 Jan 2026 02:12:24 +0100 Subject: [PATCH 3/5] Fix locale injection race condition Hook directly into FrooxEngine.LocaleResource.LoadTargetVariant to inject mod locales immediately after Resonite loads base locale files. This eliminates race conditions between multiple mod loaders. --- BepisLocaleLoader.csproj | 2 +- ConfigLocale.cs | 8 +- LocaleInjectionPatch.cs | 135 ++++++++++++++++++++++++ LocaleLoader.cs | 215 ++++++++++++++++++++++----------------- Plugin.cs | 18 +--- 5 files changed, 267 insertions(+), 111 deletions(-) create mode 100644 LocaleInjectionPatch.cs diff --git a/BepisLocaleLoader.csproj b/BepisLocaleLoader.csproj index 61c7e6b..de6a0f3 100644 --- a/BepisLocaleLoader.csproj +++ b/BepisLocaleLoader.csproj @@ -11,7 +11,7 @@ Bepis Locale Loader BepisLocaleLoader enable - disable + enable true false true diff --git a/ConfigLocale.cs b/ConfigLocale.cs index 20ceef8..866affe 100644 --- a/ConfigLocale.cs +++ b/ConfigLocale.cs @@ -49,7 +49,7 @@ public static ConfigEntry BindLocalized(this ConfigFile config, string gui /// Name of the setting. /// Value of the setting if the setting was not created yet. /// Description and other metadata of the setting. The text description will be visible when editing config files via mod managers or manually. - public static ConfigEntry BindLocalized(this ConfigFile config, string guid, string section, string key, T defaultValue, ConfigDescription configDescription = null) + public static ConfigEntry BindLocalized(this ConfigFile config, string guid, string section, string key, T defaultValue, ConfigDescription? configDescription = null) { return config.BindLocalized(guid, new ConfigDefinition(section, key), defaultValue, configDescription); } @@ -63,18 +63,18 @@ public static ConfigEntry BindLocalized(this ConfigFile config, string gui /// Section and Key of the setting. /// Value of the setting if the setting was not created yet. /// Description and other metadata of the setting. The text description will be visible when editing config files via mod managers or manually. - public static ConfigEntry BindLocalized(this ConfigFile config, string guid, ConfigDefinition configDefinition, T defaultValue, ConfigDescription configDescription = null) + public static ConfigEntry BindLocalized(this ConfigFile config, string guid, ConfigDefinition configDefinition, T defaultValue, ConfigDescription? configDescription = null) { var localeName = $"Settings.{guid}.{configDefinition.Section}.{configDefinition.Key}"; var localeDescription = localeName + ".Description"; var locale = new ConfigLocale(localeName, localeDescription); - if(configDescription == null) + if (configDescription == null) { configDescription = new ConfigDescription(string.Empty, null, locale); } else { - configDescription = new ConfigDescription(configDescription.Description, configDescription.AcceptableValues, [..configDescription.Tags, locale]); + configDescription = new ConfigDescription(configDescription.Description, configDescription.AcceptableValues, [.. configDescription.Tags, locale]); } return config.Bind(configDefinition, defaultValue, configDescription); } diff --git a/LocaleInjectionPatch.cs b/LocaleInjectionPatch.cs new file mode 100644 index 0000000..51da33b --- /dev/null +++ b/LocaleInjectionPatch.cs @@ -0,0 +1,135 @@ +using BepInEx; +using BepInEx.NET.Common; +using Elements.Assets; +using FrooxEngine; +using HarmonyLib; + +namespace BepisLocaleLoader; + +/// +/// Harmony patch that injects mod locales immediately after Resonite loads base locale files. +/// This eliminates race conditions by hooking directly into the locale loading flow. +/// +[HarmonyPatch(typeof(FrooxEngine.LocaleResource), "LoadTargetVariant")] +internal static class LocaleInjectionPatch +{ + /// + /// Postfix that runs after LoadTargetVariant completes. + /// Waits for the async method to finish, then injects all mod locales. + /// + [HarmonyPostfix] + private static async void Postfix(FrooxEngine.LocaleResource __instance, Task __result, LocaleVariantDescriptor? variant) + { + try + { + await __result.ConfigureAwait(false); + + if (__instance.Data == null) + { + Plugin.Log.LogWarning("LoadTargetVariant completed but Data is null - skipping locale injection"); + return; + } + + string targetLocale = variant?.LocaleCode ?? "en"; + + // Skip injection for temporary refresh triggers (RML uses "-" to force locale reload) + if (targetLocale == "-") + { + Plugin.Log.LogDebug("Skipping locale injection for refresh trigger (target: -)"); + return; + } + + Plugin.Log.LogDebug($"Injecting mod locales after LoadTargetVariant completed (target: {targetLocale})"); + + InjectAllPluginLocales(__instance.Data, targetLocale); + } + catch (Exception ex) + { + // async void cannot propagate exceptions and unhandled ones may crash the app + Plugin.Log.LogError($"Failed to inject mod locales: {ex}"); + } + } + + /// + /// Discovers and injects locale files from all BepInEx plugins. + /// + private static void InjectAllPluginLocales(Elements.Assets.LocaleResource localeData, string targetLocale) + { + if (NetChainloader.Instance?.Plugins == null || NetChainloader.Instance.Plugins.Count == 0) + { + Plugin.Log.LogDebug("No BepInEx plugins loaded - skipping locale injection"); + return; + } + + int pluginCount = 0; + int messageCount = 0; + + foreach (var plugin in NetChainloader.Instance.Plugins.Values) + { + var localeFiles = LocaleLoader.GetPluginLocaleFiles(plugin).ToList(); + if (localeFiles.Count == 0) + continue; + + Plugin.Log.LogDebug($"Loading locales from {plugin.Metadata?.GUID ?? "unknown"}"); + + foreach (string file in localeFiles) + { + int injected = InjectLocaleFile(localeData, file, targetLocale); + if (injected > 0) + { + messageCount += injected; + } + } + + LocaleLoader.TrackPluginWithLocale(plugin); + pluginCount++; + } + + if (pluginCount > 0) + { + Plugin.Log.LogInfo($"Injected {messageCount} locale messages from {pluginCount} plugins"); + } + } + + /// + /// Loads and injects a single locale file into the target locale resource. + /// + /// Number of messages injected, or 0 on failure + private static int InjectLocaleFile(Elements.Assets.LocaleResource localeData, string filePath, string targetLocale) + { + var data = LocaleLoader.LoadLocaleDataFromFile(filePath); + if (data == null) return 0; + + localeData.LoadDataAdditively(data); + + string fileLocale = data.LocaleCode ?? "unknown"; + bool isFallback = !IsLocaleMatch(fileLocale, targetLocale); + string fallbackIndicator = isFallback ? " (fallback)" : ""; + + Plugin.Log.LogDebug($" - {Path.GetFileName(filePath)}: {fileLocale}, {data.Messages.Count} messages{fallbackIndicator}"); + + return data.Messages.Count; + } + + /// + /// Checks if the file's locale matches the target locale. + /// Handles cases like "en-US" matching "en", or exact matches. + /// + private static bool IsLocaleMatch(string fileLocale, string targetLocale) + { + if (string.IsNullOrEmpty(fileLocale) || string.IsNullOrEmpty(targetLocale)) + return false; + + fileLocale = fileLocale.ToLowerInvariant(); + targetLocale = targetLocale.ToLowerInvariant(); + + if (fileLocale == targetLocale) + return true; + + // Base language match (e.g., "en-us" matches "en") + string fileBase = Elements.Assets.LocaleResource.GetMainLanguage(fileLocale); + string targetBase = Elements.Assets.LocaleResource.GetMainLanguage(targetLocale); + + return fileBase == targetBase; + } +} diff --git a/LocaleLoader.cs b/LocaleLoader.cs index 8fd788d..06766ee 100644 --- a/LocaleLoader.cs +++ b/LocaleLoader.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using BepInEx; using Elements.Assets; using Elements.Core; @@ -6,23 +6,43 @@ namespace BepisLocaleLoader; -// Edited Locale Code is from - https://github.com/Xlinka/Project-Obsidian/blob/main/ProjectObsidian/Settings/LocaleHelper.cs +/// +/// Public API for locale loading. +/// Primary locale injection happens via Harmony patch in LocaleInjectionPatch. +/// This class provides runtime APIs for adding locales after initial load. +/// public static class LocaleLoader { - private static StaticLocaleProvider _localeProvider; - private static string _lastOverrideLocale; - private const string OverrideLocaleString = "somethingRandomJustToMakeItChange"; - - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + internal static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; - public static readonly HashSet PluginsWithLocales = new HashSet(); + private static readonly object _pluginsLock = new(); + + /// + /// Tracks which plugins have locale files. + /// + public static readonly HashSet PluginsWithLocales = new(); + + /// + /// Thread-safe method to add a plugin to the tracking set. + /// + internal static void TrackPluginWithLocale(PluginInfo plugin) + { + lock (_pluginsLock) + { + PluginsWithLocales.Add(plugin); + } + } - public static void AddLocaleString(string rawString, string localeString, bool force = false, string authors = null) + /// + /// Add a single locale string at runtime. + /// For bulk loading, use the Locale/ folder in your plugin directory. + /// + public static void AddLocaleString(string rawString, string localeString, bool force = false, string? authors = null) { List finalAuthors; @@ -36,10 +56,10 @@ public static void AddLocaleString(string rawString, string localeString, bool f } else { - finalAuthors = new List { "BepInEx" }; + finalAuthors = ["BepInEx"]; } - LocaleData localeData = new LocaleData + LocaleData localeData = new() { LocaleCode = "en-US", Authors = finalAuthors, @@ -49,123 +69,128 @@ public static void AddLocaleString(string rawString, string localeString, bool f } }; - Update(localeData, force); + InjectLocaleData(localeData, force); } + /// + /// Add locales from a plugin's Locale/ folder at runtime. + /// Note: This is automatically handled by the Harmony patch during locale loading. + /// public static void AddLocaleFromPlugin(PluginInfo plugin) { - string dir = Path.GetDirectoryName(plugin.Location); - string locale = Path.Combine(dir, "Locale"); + var localeFiles = GetPluginLocaleFiles(plugin).ToList(); + if (localeFiles.Count == 0) return; - if (!Path.Exists(locale)) return; + Plugin.Log.LogDebug($"Adding locale for {plugin.Metadata?.GUID ?? "unknown"}"); - Plugin.Log.LogDebug($"Adding locale for {plugin.Metadata.GUID}"); - ProcessPath(locale, AddLocaleFromFile); + foreach (string file in localeFiles) + { + AddLocaleFromFile(file); + } - PluginsWithLocales.Add(plugin); + TrackPluginWithLocale(plugin); } - public static void AddLocaleFromFile(string path) + /// + /// Gets all locale JSON files from a plugin's Locale/ folder. + /// + internal static IEnumerable GetPluginLocaleFiles(PluginInfo plugin) { - if (!File.Exists(path)) return; + string? pluginDir = Path.GetDirectoryName(plugin.Location); + if (string.IsNullOrEmpty(pluginDir)) + return []; - string json = File.ReadAllText(path); - LocaleData localeData; - try - { - localeData = JsonSerializer.Deserialize(json, JsonOptions); - } - catch (Exception e) - { - Plugin.Log.LogError(e); - return; - } + string localeDir = Path.Combine(pluginDir, "Locale"); - Plugin.Log.LogDebug($"- LocaleCode: {localeData.LocaleCode}, Message Count: {localeData.Messages.Count}"); + if (!Directory.Exists(localeDir)) + return []; - Update(localeData, true); + return Directory.GetFiles(localeDir, "*.json", SearchOption.AllDirectories); } - private static void Update(LocaleData localeData, bool force) + /// + /// Add locale from a specific file at runtime. + /// + public static void AddLocaleFromFile(string path) { - UpdateDelayed(localeData, force); - Settings.RegisterValueChanges(_ => UpdateDelayed(localeData, force)); - } + var localeData = LoadLocaleDataFromFile(path); + if (localeData == null) return; - private static void UpdateDelayed(LocaleData localeData, bool force) - { - Userspace.UserspaceWorld?.RunInUpdates(15, () => UpdateIntern(localeData, force)); + Plugin.Log.LogDebug($"- LocaleCode: {localeData.LocaleCode}, Message Count: {localeData.Messages.Count}"); + + InjectLocaleData(localeData, force: true); } - private static void UpdateIntern(LocaleData localeData, bool force) + /// + /// Loads and parses a locale JSON file, returning the LocaleData or null on failure. + /// + internal static LocaleData? LoadLocaleDataFromFile(string path) { - _localeProvider = Userspace.UserspaceWorld?.GetCoreLocale(); - if (_localeProvider?.Asset?.Data is null) + if (!File.Exists(path)) return null; + + string json; + try { - Userspace.UserspaceWorld?.RunSynchronously(() => UpdateIntern(localeData, force)); + json = File.ReadAllText(path); } - else + catch (Exception e) { - UpdateLocale(localeData, force); + Plugin.Log.LogError($"Error reading locale file {path}: {e}"); + return null; } - } - private static void UpdateLocale(LocaleData localeData, bool force) - { - if (_localeProvider?.Asset?.Data != null) + LocaleData? localeData; + try { - if (!force) - { - string firstKey = localeData.Messages.Keys.FirstOrDefault(); - - bool alreadyExists = _localeProvider.Asset.Data.Messages.Any(ld => ld.Key == firstKey); - if (alreadyExists) return; - } - - _localeProvider.Asset.Data.LoadDataAdditively(localeData); - - // force asset update for locale provider - if (_localeProvider.OverrideLocale.Value != null && _localeProvider.OverrideLocale.Value != OverrideLocaleString) - { - _lastOverrideLocale = _localeProvider.OverrideLocale.Value; - } - - _localeProvider.OverrideLocale.Value = OverrideLocaleString; - Userspace.UserspaceWorld.RunInUpdates(1, () => { _localeProvider.OverrideLocale.Value = _lastOverrideLocale; }); + localeData = JsonSerializer.Deserialize(json, JsonOptions); } - else if (_localeProvider?.Asset?.Data == null) + catch (Exception e) { - Plugin.Log.LogError("Locale data is null when it shouldn't be!"); + Plugin.Log.LogError($"Error parsing locale file {path}: {e}"); + return null; } - } - private static void ProcessPath(string path, Action fileAction) - { - if (!Path.Exists(path)) + if (localeData?.Messages == null) { - throw new DirectoryNotFoundException("Directory not found: " + path); + Plugin.Log.LogError($"Invalid locale file (missing messages): {path}"); + return null; } - ProcessDirectory(path, fileAction); + return localeData; } - private static void ProcessDirectory(string directory, Action fileAction) + /// + /// Inject locale data into the current locale provider. + /// + private static void InjectLocaleData(LocaleData localeData, bool force) { - string[] files = Directory.GetFiles(directory); - foreach (string file in files) + var localeProvider = Userspace.UserspaceWorld?.GetCoreLocale(); + + if (localeProvider?.Asset?.Data == null) { - fileAction(file); + Plugin.Log.LogWarning("Cannot inject locale data - locale provider not available yet"); + return; } - string[] subdirectories = Directory.GetDirectories(directory); - foreach (string subdir in subdirectories) + if (!force) { - ProcessDirectory(subdir, fileAction); + string? firstKey = localeData.Messages.Keys.FirstOrDefault(); + if (firstKey != null) + { + bool alreadyExists = localeProvider.Asset.Data.Messages.Any(ld => ld.Key == firstKey); + if (alreadyExists) return; + } } + + localeProvider.Asset.Data.LoadDataAdditively(localeData); } - private static string GetFormattedLocaleString(this string key, string format, Dictionary dict, (string, object)[] arguments) + #region String Formatting Extensions + + private static string GetFormattedLocaleString(this string key, string? format, Dictionary? dict, (string, object)[]? arguments) { + var localeProvider = Userspace.UserspaceWorld?.GetCoreLocale(); + Dictionary merged = dict != null ? new Dictionary(dict) : new Dictionary(); if (arguments != null) @@ -176,27 +201,33 @@ private static string GetFormattedLocaleString(this string key, string format, D } } - string formatted = _localeProvider?.Asset?.Format(key, merged); + string? formatted = localeProvider?.Asset?.Format(key, merged); if (!string.IsNullOrWhiteSpace(format)) { formatted = string.Format(format, formatted); } - return formatted; + return formatted ?? key; } - + public static string GetFormattedLocaleString(this string key) => key.GetFormattedLocaleString(null, null, null); public static string GetFormattedLocaleString(this string key, string format) => key.GetFormattedLocaleString(format, null, null); - public static string GetFormattedLocaleString(this string key, string argName, object argField) => key.GetFormattedLocaleString(null, null, new (string, object)[] { (argName, argField) }); - public static string GetFormattedLocaleString(this string key, string format, string argName, object argField) => key.GetFormattedLocaleString(format, null, new (string, object)[] { (argName, argField) }); + public static string GetFormattedLocaleString(this string key, string argName, object argField) => key.GetFormattedLocaleString(null, null, [(argName, argField)]); + public static string GetFormattedLocaleString(this string key, string format, string argName, object argField) => key.GetFormattedLocaleString(format, null, [(argName, argField)]); public static string GetFormattedLocaleString(this string key, params (string, object)[] arguments) => key.GetFormattedLocaleString(null, null, arguments); public static string GetFormattedLocaleString(this string key, string format, params (string, object)[] arguments) => key.GetFormattedLocaleString(format, null, arguments); + #endregion + + #region LocaleString Extensions + public static LocaleString T(this string str, string argName, object argField) => str.AsLocaleKey(null, (argName, argField)); public static LocaleString T(this string str, string format, string argName, object argField) => str.AsLocaleKey(format, (argName, argField)); public static LocaleString T(this string str, params (string, object)[] arguments) => str.AsLocaleKey(null, arguments); public static LocaleString T(this string str, string format, params (string, object)[] arguments) => str.AsLocaleKey(format, arguments); - public static LocaleString T(this string str, bool continuous, Dictionary arguments = null) => new LocaleString(str, null, true, continuous, arguments); - public static LocaleString T(this string str, string format = null, bool continuous = true, Dictionary arguments = null) => new LocaleString(str, format, true, continuous, arguments); -} \ No newline at end of file + public static LocaleString T(this string str, bool continuous, Dictionary? arguments = null) => new(str, null, true, continuous, arguments); + public static LocaleString T(this string str, string? format = null, bool continuous = true, Dictionary? arguments = null) => new(str, format, true, continuous, arguments); + + #endregion +} diff --git a/Plugin.cs b/Plugin.cs index 3e6410a..5ab0e64 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -1,8 +1,6 @@ -using BepInEx; +using BepInEx; using BepInEx.Logging; using BepInEx.NET.Common; -using BepInExResoniteShim; -using BepisResoniteWrapper; using HarmonyLib; namespace BepisLocaleLoader; @@ -11,22 +9,14 @@ namespace BepisLocaleLoader; [BepInDependency(BepInExResoniteShim.PluginMetadata.GUID, BepInDependency.DependencyFlags.HardDependency)] public partial class Plugin : BasePlugin { - internal new static ManualLogSource Log; + internal new static ManualLogSource Log = null!; public override void Load() { - // Plugin startup logic Log = base.Log; - ResoniteHooks.OnEngineReady += async () => - { - await Task.Delay(5000); - - if (NetChainloader.Instance.Plugins.Count <= 0) return; - - NetChainloader.Instance.Plugins.Values.Do(LocaleLoader.AddLocaleFromPlugin); - }; + HarmonyInstance.PatchAll(); Log.LogInfo($"Plugin {GUID} is loaded!"); } -} \ No newline at end of file +} From 9758940afd50253a57999393a38bff1f80c3f38b Mon Sep 17 00:00:00 2001 From: hazre <37149950+hazre@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:45:26 +0100 Subject: [PATCH 4/5] Simplify code --- ConfigLocale.cs | 20 ++++++++------------ LocaleInjectionPatch.cs | 5 ++--- LocaleLoader.cs | 20 +++++--------------- 3 files changed, 15 insertions(+), 30 deletions(-) diff --git a/ConfigLocale.cs b/ConfigLocale.cs index 866affe..10ca3a7 100644 --- a/ConfigLocale.cs +++ b/ConfigLocale.cs @@ -3,7 +3,6 @@ namespace BepisLocaleLoader; -// We could maybe add more things to this later if needed public struct ConfigLocale { public ConfigLocale(string name, string description) @@ -65,17 +64,14 @@ public static ConfigEntry BindLocalized(this ConfigFile config, string gui /// Description and other metadata of the setting. The text description will be visible when editing config files via mod managers or manually. public static ConfigEntry BindLocalized(this ConfigFile config, string guid, ConfigDefinition configDefinition, T defaultValue, ConfigDescription? configDescription = null) { - var localeName = $"Settings.{guid}.{configDefinition.Section}.{configDefinition.Key}"; - var localeDescription = localeName + ".Description"; + string localeName = $"Settings.{guid}.{configDefinition.Section}.{configDefinition.Key}"; + string localeDescription = $"{localeName}.Description"; var locale = new ConfigLocale(localeName, localeDescription); - if (configDescription == null) - { - configDescription = new ConfigDescription(string.Empty, null, locale); - } - else - { - configDescription = new ConfigDescription(configDescription.Description, configDescription.AcceptableValues, [.. configDescription.Tags, locale]); - } - return config.Bind(configDefinition, defaultValue, configDescription); + + string description = configDescription?.Description ?? string.Empty; + var acceptableValues = configDescription?.AcceptableValues; + object[] tags = configDescription != null ? [.. configDescription.Tags, locale] : [locale]; + + return config.Bind(configDefinition, defaultValue, new ConfigDescription(description, acceptableValues, tags)); } } \ No newline at end of file diff --git a/LocaleInjectionPatch.cs b/LocaleInjectionPatch.cs index 51da33b..1a3ff88 100644 --- a/LocaleInjectionPatch.cs +++ b/LocaleInjectionPatch.cs @@ -103,10 +103,9 @@ private static int InjectLocaleFile(Elements.Assets.LocaleResource localeData, s localeData.LoadDataAdditively(data); string fileLocale = data.LocaleCode ?? "unknown"; - bool isFallback = !IsLocaleMatch(fileLocale, targetLocale); - string fallbackIndicator = isFallback ? " (fallback)" : ""; + bool isMatch = IsLocaleMatch(fileLocale, targetLocale); - Plugin.Log.LogDebug($" - {Path.GetFileName(filePath)}: {fileLocale}, {data.Messages.Count} messages{fallbackIndicator}"); + Plugin.Log.LogDebug($" - {Path.GetFileName(filePath)}: {fileLocale}, {data.Messages.Count} messages{(isMatch ? "" : " (fallback)")}"); return data.Messages.Count; } diff --git a/LocaleLoader.cs b/LocaleLoader.cs index 06766ee..6bf01e5 100644 --- a/LocaleLoader.cs +++ b/LocaleLoader.cs @@ -44,20 +44,11 @@ internal static void TrackPluginWithLocale(PluginInfo plugin) /// public static void AddLocaleString(string rawString, string localeString, bool force = false, string? authors = null) { - List finalAuthors; + string authorsSource = !string.IsNullOrWhiteSpace(authors) ? authors + : !string.IsNullOrWhiteSpace(Plugin.AUTHORS) ? Plugin.AUTHORS + : "BepInEx"; - if (!string.IsNullOrWhiteSpace(authors)) - { - finalAuthors = authors.Split(", ", StringSplitOptions.RemoveEmptyEntries).ToList(); - } - else if (!string.IsNullOrWhiteSpace(Plugin.AUTHORS)) - { - finalAuthors = Plugin.AUTHORS.Split(", ", StringSplitOptions.RemoveEmptyEntries).ToList(); - } - else - { - finalAuthors = ["BepInEx"]; - } + List finalAuthors = authorsSource.Split(", ", StringSplitOptions.RemoveEmptyEntries).ToList(); LocaleData localeData = new() { @@ -191,8 +182,7 @@ private static string GetFormattedLocaleString(this string key, string? format, { var localeProvider = Userspace.UserspaceWorld?.GetCoreLocale(); - Dictionary merged = dict != null ? new Dictionary(dict) : new Dictionary(); - + Dictionary merged = dict != null ? new Dictionary(dict) : []; if (arguments != null) { foreach ((string name, object value) in arguments) From ec16009306fc56f1e5be1d59b525e450927a2ef9 Mon Sep 17 00:00:00 2001 From: hazre <37149950+hazre@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:57:23 +0100 Subject: [PATCH 5/5] Fix locale fallback logic and prevent duplicate injections - Fixed fallback bug where all locale files were injected instead of filtering by target locale first - Added deduplication to prevent wasteful reinjection from duplicate LoadTargetVariant calls - Fixed race condition causing incorrect message counts - Refactored injection logic into focused helper methods --- LocaleInjectionPatch.cs | 102 +++++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/LocaleInjectionPatch.cs b/LocaleInjectionPatch.cs index 1a3ff88..0d52df3 100644 --- a/LocaleInjectionPatch.cs +++ b/LocaleInjectionPatch.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using BepInEx; using BepInEx.NET.Common; using Elements.Assets; @@ -13,6 +14,11 @@ namespace BepisLocaleLoader; [HarmonyPatch(typeof(FrooxEngine.LocaleResource), "LoadTargetVariant")] internal static class LocaleInjectionPatch { + private static readonly object _injectionLock = new(); + private static string _lastInjectedLocale = string.Empty; + private static DateTime _lastInjectionTime = DateTime.MinValue; + private static readonly TimeSpan _deduplicationWindow = TimeSpan.FromMilliseconds(500); + /// /// Postfix that runs after LoadTargetVariant completes. /// Waits for the async method to finish, then injects all mod locales. @@ -39,13 +45,25 @@ private static async void Postfix(FrooxEngine.LocaleResource __instance, Task __ return; } - Plugin.Log.LogDebug($"Injecting mod locales after LoadTargetVariant completed (target: {targetLocale})"); + lock (_injectionLock) + { + var now = DateTime.UtcNow; + if (_lastInjectedLocale == targetLocale && (now - _lastInjectionTime) < _deduplicationWindow) + { + Plugin.Log.LogDebug($"Skipping duplicate injection for {targetLocale} (called {(now - _lastInjectionTime).TotalMilliseconds:F0}ms after previous)"); + return; + } + + Plugin.Log.LogDebug($"Injecting mod locales after LoadTargetVariant completed (target: {targetLocale})"); - InjectAllPluginLocales(__instance.Data, targetLocale); + _lastInjectedLocale = targetLocale; + _lastInjectionTime = now; + + InjectAllPluginLocales(__instance.Data, targetLocale); + } } catch (Exception ex) { - // async void cannot propagate exceptions and unhandled ones may crash the app Plugin.Log.LogError($"Failed to inject mod locales: {ex}"); } } @@ -72,14 +90,10 @@ private static void InjectAllPluginLocales(Elements.Assets.LocaleResource locale Plugin.Log.LogDebug($"Loading locales from {plugin.Metadata?.GUID ?? "unknown"}"); - foreach (string file in localeFiles) - { - int injected = InjectLocaleFile(localeData, file, targetLocale); - if (injected > 0) - { - messageCount += injected; - } - } + var candidates = LoadLocaleFiles(localeFiles); + var toInject = SelectMatchingLocales(candidates, targetLocale, out bool usingFallback); + + messageCount += InjectAndLogLocales(localeData, toInject, usingFallback); LocaleLoader.TrackPluginWithLocale(plugin); pluginCount++; @@ -92,22 +106,65 @@ private static void InjectAllPluginLocales(Elements.Assets.LocaleResource locale } /// - /// Loads and injects a single locale file into the target locale resource. + /// Loads locale data from the specified list of locale files. + /// + private static List<(string Path, LocaleData Data)> LoadLocaleFiles(List localeFiles) + { + var candidates = new List<(string Path, LocaleData Data)>(); + + foreach (string file in localeFiles) + { + var data = LocaleLoader.LoadLocaleDataFromFile(file); + if (data != null) + { + candidates.Add((file, data)); + } + } + + return candidates; + } + + /// + /// Selects locale data that matches the target locale, with fallback to English if no matches are found. /// - /// Number of messages injected, or 0 on failure - private static int InjectLocaleFile(Elements.Assets.LocaleResource localeData, string filePath, string targetLocale) + private static List<(string Path, LocaleData Data)> SelectMatchingLocales( + List<(string Path, LocaleData Data)> candidates, + string targetLocale, + out bool usingFallback) { - var data = LocaleLoader.LoadLocaleDataFromFile(filePath); - if (data == null) return 0; + var matches = candidates.Where(c => IsLocaleMatch(c.Data.LocaleCode, targetLocale)).ToList(); - localeData.LoadDataAdditively(data); + usingFallback = false; + if (matches.Count == 0 && !IsLocaleMatch(targetLocale, "en")) + { + matches = candidates.Where(c => IsLocaleMatch(c.Data.LocaleCode, "en")).ToList(); + usingFallback = true; + } - string fileLocale = data.LocaleCode ?? "unknown"; - bool isMatch = IsLocaleMatch(fileLocale, targetLocale); + return matches; + } - Plugin.Log.LogDebug($" - {Path.GetFileName(filePath)}: {fileLocale}, {data.Messages.Count} messages{(isMatch ? "" : " (fallback)")}"); + /// + /// Injects the selected locale data into the locale resource and logs the results. + /// + private static int InjectAndLogLocales( + Elements.Assets.LocaleResource localeData, + List<(string Path, LocaleData Data)> toInject, + bool usingFallback) + { + int messageCount = 0; + + foreach (var (file, data) in toInject) + { + localeData.LoadDataAdditively(data); + messageCount += data.Messages.Count; + + string fileLocale = data.LocaleCode ?? "unknown"; + string fallbackSuffix = usingFallback ? " (fallback)" : string.Empty; + Plugin.Log.LogDebug($" - {Path.GetFileName(file)}: {fileLocale}, {data.Messages.Count} messages{fallbackSuffix}"); + } - return data.Messages.Count; + return messageCount; } /// @@ -125,10 +182,9 @@ private static bool IsLocaleMatch(string fileLocale, string targetLocale) if (fileLocale == targetLocale) return true; - // Base language match (e.g., "en-us" matches "en") string fileBase = Elements.Assets.LocaleResource.GetMainLanguage(fileLocale); string targetBase = Elements.Assets.LocaleResource.GetMainLanguage(targetLocale); return fileBase == targetBase; } -} +} \ No newline at end of file