Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions BepisLocaleLoader.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
<PropertyGroup>
<Version>1.2.0</Version>
<Authors>ResoniteModding</Authors>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<PackageProjectUrl>https://github.com/ResoniteModding/BepisLocaleLoader</PackageProjectUrl>
<RepositoryUrl>https://github.com/ResoniteModding/BepisLocaleLoader</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageId>ResoniteModding.BepisLocaleLoader</PackageId>
<Product>Bepis Locale Loader</Product>
<RootNamespace>BepisLocaleLoader</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<Nullable>enable</Nullable>
<Deterministic>true</Deterministic>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<CopyToPlugins>true</CopyToPlugins>
Expand All @@ -29,16 +29,16 @@

<!-- Modding dependencies -->
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All"/>
<PackageReference Include="BepInEx.NET.CoreCLR" Version="6.0.0-be.*" IncludeAssets="compile"/>
<PackageReference Include="BepInEx.ResonitePluginInfoProps" Version="3.*"/>
<PackageReference Include="ResoniteModding.BepInExResoniteShim" Version="0.8.*"/>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.102" PrivateAssets="all" />
<PackageReference Include="BepInEx.NET.CoreCLR" Version="6.0.0-be.*" IncludeAssets="compile" />
<PackageReference Include="BepInEx.AutoPlugin" Version="2.1.0" PrivateAssets="all" />
<PackageReference Include="ResoniteModding.BepInExResoniteShim" Version="0.9.*" />
<PackageReference Include="ResoniteModding.BepisResoniteWrapper" Version="1.*" />
</ItemGroup>

<!-- NuGet fallback stripped game references -->
<ItemGroup Condition="!Exists('$(GamePath)')">
<PackageReference Include="Resonite.GameLibs" Version="2025.*" PrivateAssets="all"/>
<PackageReference Include="Resonite.GameLibs" Version="2025.*" PrivateAssets="all" />
</ItemGroup>

<!-- Local game references -->
Expand Down Expand Up @@ -68,12 +68,12 @@
<!-- Post-build copy to game plugins folder -->
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<ItemGroup>
<PluginFiles Include="$(TargetPath)"/>
<PluginFiles Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')"/>
<PluginFiles Include="$(TargetPath)" />
<PluginFiles Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
</ItemGroup>

<Copy SourceFiles="@(PluginFiles)" DestinationFolder="$(PluginTargetDir)" Condition="'$(CopyToPlugins)' == 'true'"/>
<Message Text="Copied plugin files to $(PluginTargetDir)" Importance="high" Condition="'$(CopyToPlugins)' == 'true'"/>
<Copy SourceFiles="@(PluginFiles)" DestinationFolder="$(PluginTargetDir)" Condition="'$(CopyToPlugins)' == 'true'" />
<Message Text="Copied plugin files to $(PluginTargetDir)" Importance="high" Condition="'$(CopyToPlugins)' == 'true'" />
</Target>

<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
Expand Down
24 changes: 10 additions & 14 deletions ConfigLocale.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -49,7 +48,7 @@ public static ConfigEntry<T> BindLocalized<T>(this ConfigFile config, string gui
/// <param name="key">Name of the setting.</param>
/// <param name="defaultValue">Value of the setting if the setting was not created yet.</param>
/// <param name="configDescription">Description and other metadata of the setting. The text description will be visible when editing config files via mod managers or manually.</param>
public static ConfigEntry<T> BindLocalized<T>(this ConfigFile config, string guid, string section, string key, T defaultValue, ConfigDescription configDescription = null)
public static ConfigEntry<T> BindLocalized<T>(this ConfigFile config, string guid, string section, string key, T defaultValue, ConfigDescription? configDescription = null)
{
return config.BindLocalized(guid, new ConfigDefinition(section, key), defaultValue, configDescription);
}
Expand All @@ -63,19 +62,16 @@ public static ConfigEntry<T> BindLocalized<T>(this ConfigFile config, string gui
/// <param name="configDefinition">Section and Key of the setting.</param>
/// <param name="defaultValue">Value of the setting if the setting was not created yet.</param>
/// <param name="configDescription">Description and other metadata of the setting. The text description will be visible when editing config files via mod managers or manually.</param>
public static ConfigEntry<T> BindLocalized<T>(this ConfigFile config, string guid, ConfigDefinition configDefinition, T defaultValue, ConfigDescription configDescription = null)
public static ConfigEntry<T> BindLocalized<T>(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));
}
}
190 changes: 190 additions & 0 deletions LocaleInjectionPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
using System.Diagnostics.CodeAnalysis;
using BepInEx;
using BepInEx.NET.Common;
using Elements.Assets;
using FrooxEngine;
using HarmonyLib;

namespace BepisLocaleLoader;

/// <summary>
/// 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.
/// </summary>
[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);

/// <summary>
/// Postfix that runs after LoadTargetVariant completes.
/// Waits for the async method to finish, then injects all mod locales.
/// </summary>
[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}");
}
}

/// <summary>
/// Discovers and injects locale files from all BepInEx plugins.
/// </summary>
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");
}
}

/// <summary>
/// Loads locale data from the specified list of locale files.
/// </summary>
private static List<(string Path, LocaleData Data)> LoadLocaleFiles(List<string> 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;
}

/// <summary>
/// Selects locale data that matches the target locale, with fallback to English if no matches are found.
/// </summary>
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;
}

/// <summary>
/// Injects the selected locale data into the locale resource and logs the results.
/// </summary>
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;
}

/// <summary>
/// Checks if the file's locale matches the target locale.
/// Handles cases like "en-US" matching "en", or exact matches.
/// </summary>
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;
}
}
Loading
Loading