From 6a67de0a88ef3372fa797db2ba2808cccdfd3cfe Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 23 Apr 2026 13:48:12 +0200 Subject: [PATCH 1/3] chore: migrate to slnx solution format --- BepisLocaleLoader.sln | 16 ---------------- BepisLocaleLoader.slnx | 3 +++ 2 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 BepisLocaleLoader.sln create mode 100644 BepisLocaleLoader.slnx diff --git a/BepisLocaleLoader.sln b/BepisLocaleLoader.sln deleted file mode 100644 index d7300ac..0000000 --- a/BepisLocaleLoader.sln +++ /dev/null @@ -1,16 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BepisLocaleLoader", "BepisLocaleLoader.csproj", "{092FAE32-4FEE-4EA5-8415-B78E8AE4C629}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {092FAE32-4FEE-4EA5-8415-B78E8AE4C629}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {092FAE32-4FEE-4EA5-8415-B78E8AE4C629}.Debug|Any CPU.Build.0 = Debug|Any CPU - {092FAE32-4FEE-4EA5-8415-B78E8AE4C629}.Release|Any CPU.ActiveCfg = Release|Any CPU - {092FAE32-4FEE-4EA5-8415-B78E8AE4C629}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/BepisLocaleLoader.slnx b/BepisLocaleLoader.slnx new file mode 100644 index 0000000..1ceb3dc --- /dev/null +++ b/BepisLocaleLoader.slnx @@ -0,0 +1,3 @@ + + + From ae627220e922286664d747d82090bc6eb6850f1f Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 12 Sep 2026 19:21:36 +0200 Subject: [PATCH 2/3] chore: bump dependencies Signed-off-by: hazre --- BepisLocaleLoader.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BepisLocaleLoader.csproj b/BepisLocaleLoader.csproj index 96fb387..e5f5e9e 100644 --- a/BepisLocaleLoader.csproj +++ b/BepisLocaleLoader.csproj @@ -29,7 +29,7 @@ - + @@ -38,7 +38,7 @@ - + From f6fc30727a7eb70abcd19824d53bb4cec18805f9 Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 12 Sep 2026 19:39:06 +0200 Subject: [PATCH 3/3] fix: passthrough locale injection patch Signed-off-by: hazre --- LocaleInjectionPatch.cs | 354 +++++++++++++++++++--------------------- 1 file changed, 164 insertions(+), 190 deletions(-) diff --git a/LocaleInjectionPatch.cs b/LocaleInjectionPatch.cs index 0d52df3..0b9dda9 100644 --- a/LocaleInjectionPatch.cs +++ b/LocaleInjectionPatch.cs @@ -1,190 +1,164 @@ -using System.Diagnostics.CodeAnalysis; -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 -{ - 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. - /// - [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; - } - - 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})"); - - _lastInjectedLocale = targetLocale; - _lastInjectionTime = now; - - InjectAllPluginLocales(__instance.Data, targetLocale); - } - } - catch (Exception ex) - { - 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"}"); - - var candidates = LoadLocaleFiles(localeFiles); - var toInject = SelectMatchingLocales(candidates, targetLocale, out bool usingFallback); - - messageCount += InjectAndLogLocales(localeData, toInject, usingFallback); - - LocaleLoader.TrackPluginWithLocale(plugin); - pluginCount++; - } - - if (pluginCount > 0) - { - Plugin.Log.LogInfo($"Injected {messageCount} locale messages from {pluginCount} plugins"); - } - } - - /// - /// 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. - /// - private static List<(string Path, LocaleData Data)> SelectMatchingLocales( - List<(string Path, LocaleData Data)> candidates, - string targetLocale, - out bool usingFallback) - { - var matches = candidates.Where(c => IsLocaleMatch(c.Data.LocaleCode, targetLocale)).ToList(); - - usingFallback = false; - if (matches.Count == 0 && !IsLocaleMatch(targetLocale, "en")) - { - matches = candidates.Where(c => IsLocaleMatch(c.Data.LocaleCode, "en")).ToList(); - usingFallback = true; - } - - return matches; - } - - /// - /// 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 messageCount; - } - - /// - /// 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; - - string fileBase = Elements.Assets.LocaleResource.GetMainLanguage(fileLocale); - string targetBase = Elements.Assets.LocaleResource.GetMainLanguage(targetLocale); - - return fileBase == targetBase; - } -} \ No newline at end of file +using System.Reflection; +using System.Reflection.Emit; +using System.Text.Json; +using BepInEx; +using BepInEx.NET.Common; +using Elements.Assets; +using FrooxEngine; +using HarmonyLib; + +namespace BepisLocaleLoader; + +[HarmonyPatch(typeof(FrooxEngine.LocaleResource), "LoadTargetVariant")] +internal static class LocaleInjectionPatch +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + private static readonly MethodInfo OnLoadStateChangedMethod = + AccessTools.Method(typeof(FrooxEngine.Asset), "OnLoadStateChanged") + ?? throw new MissingMethodException(typeof(FrooxEngine.Asset).FullName, "OnLoadStateChanged"); + + [HarmonyPrefix] + private static void Prefix(FrooxEngine.LocaleResource __instance, out bool __state) + => __state = __instance.Data != null; + + [HarmonyTranspiler] + [HarmonyPatch(MethodType.Async)] + private static IEnumerable LoadTargetVariantMoveNextTranspiler(IEnumerable instructions) + { + foreach (var instruction in instructions) + { + if (!instruction.Calls(OnLoadStateChangedMethod)) + { + yield return instruction; + continue; + } + + var pop = new CodeInstruction(OpCodes.Pop); + pop.labels.AddRange(instruction.labels); + pop.blocks.AddRange(instruction.blocks); + yield return pop; + } + } + + [HarmonyPostfix] + private static async Task Postfix(Task __result, FrooxEngine.LocaleResource __instance, LocaleVariantDescriptor variant, bool __state) + { + try + { + await __result; + } + catch (Exception ex) + { + Plugin.Log.LogError($"LoadTargetVariant failed before locale injection: {ex}"); + return; + } + + if (__instance.Data == null) + { + Plugin.Log.LogWarning("LoadTargetVariant completed but Data is null - skipping locale injection"); + return; + } + + try + { + InjectAllPluginLocales(__instance.Data); + } + catch (Exception ex) + { + Plugin.Log.LogError($"Failed to inject mod locales: {ex}"); + } + + if (__state) + { + try + { + OnLoadStateChangedMethod.Invoke(__instance, null); + } + catch (Exception ex) + { + Plugin.Log.LogError($"Failed to notify locale observers: {ex}"); + } + } + } + + private static void InjectAllPluginLocales(Elements.Assets.LocaleResource localeData) + { + 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) + { + string? pluginDir = Path.GetDirectoryName(plugin.Location); + if (string.IsNullOrEmpty(pluginDir)) + continue; + + string localeDir = Path.Combine(pluginDir, "Locale"); + if (!Directory.Exists(localeDir)) + continue; + + Plugin.Log.LogDebug($"Loading locales from {plugin.Metadata?.GUID ?? "unknown"}"); + + foreach (string file in Directory.GetFiles(localeDir, "*.json", SearchOption.AllDirectories)) + { + LocaleData? data = LoadLocaleDataFromFile(file); + if (data == null) + continue; + + localeData.LoadDataAdditively(data); + messageCount += data.Messages.Count; + Plugin.Log.LogDebug($" - {Path.GetFileName(file)}: {data.LocaleCode}, {data.Messages.Count} messages"); + } + + LocaleLoader.PluginsWithLocales.Add(plugin); + pluginCount++; + } + + if (pluginCount > 0) + Plugin.Log.LogInfo($"Injected {messageCount} locale messages from {pluginCount} plugins"); + } + + private static LocaleData? LoadLocaleDataFromFile(string path) + { + string json; + try + { + json = File.ReadAllText(path); + } + catch (Exception ex) + { + Plugin.Log.LogError($"Error reading locale file {path}: {ex}"); + return null; + } + + LocaleData? localeData; + try + { + localeData = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) + { + Plugin.Log.LogError($"Error parsing locale file {path}: {ex}"); + return null; + } + + if (localeData?.Messages == null) + { + Plugin.Log.LogError($"Invalid locale file (missing messages): {path}"); + return null; + } + + return localeData; + } +}