Skip to content
Open
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));
}
}
134 changes: 134 additions & 0 deletions LocaleInjectionPatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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
{
/// <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;
}

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}");
}
}

/// <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"}");

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");
}
}

/// <summary>
/// Loads and injects a single locale file into the target locale resource.
/// </summary>
/// <returns>Number of messages injected, or 0 on failure</returns>
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 isMatch = IsLocaleMatch(fileLocale, targetLocale);

Plugin.Log.LogDebug($" - {Path.GetFileName(filePath)}: {fileLocale}, {data.Messages.Count} messages{(isMatch ? "" : " (fallback)")}");

return data.Messages.Count;
}

/// <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;

// 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;
}
}
Loading
Loading