feat: discover forge translation mod sources

Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/3b2dd335-d070-4067-b864-5584bb94571b

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-04 20:11:43 +00:00 committed by GitHub
parent 67066a145d
commit 7ce4f82871
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 295 additions and 28 deletions

View file

@ -273,10 +273,19 @@ namespace MinecraftClient.Protocol.Message
public Dictionary<string, string> Translations { get; init; } = []; public Dictionary<string, string> Translations { get; init; } = [];
} }
private sealed class ForgeModTranslationCacheEntry
{
public string CacheVersion { get; init; } = string.Empty;
public string Language { get; init; } = string.Empty;
public string SourceHash { get; init; } = string.Empty;
public Dictionary<string, Dictionary<string, string>> TranslationsByModId { get; init; } = [];
}
private const long MaxResourcePackDownloadBytes = 256L * 1024 * 1024; private const long MaxResourcePackDownloadBytes = 256L * 1024 * 1024;
private const int ResourcePackDownloadBufferSize = 81920; private const int ResourcePackDownloadBufferSize = 81920;
private const string ResourcePackTranslationCacheVersion = "1"; private const string ResourcePackTranslationCacheVersion = "1";
private const string ForgeModTranslationDirectory = "mods"; private const string ForgeModTranslationCacheVersion = "1";
private const string LocalForgeModTranslationDirectory = "mods";
private static readonly List<TranslationLayer> ForgeModTranslationLayers = []; private static readonly List<TranslationLayer> ForgeModTranslationLayers = [];
private static readonly List<TranslationLayer> ResourcePackTranslationLayers = []; private static readonly List<TranslationLayer> ResourcePackTranslationLayers = [];
@ -485,7 +494,7 @@ namespace MinecraftClient.Protocol.Message
ForgeModTranslationLayers.Clear(); ForgeModTranslationLayers.Clear();
if (!Config.Main.Advanced.LoadForgeModTranslations || !Directory.Exists(ForgeModTranslationDirectory)) if (!Config.Main.Advanced.LoadForgeModTranslations)
return; return;
HashSet<string> requestedModIds = new( HashSet<string> requestedModIds = new(
@ -500,22 +509,24 @@ namespace MinecraftClient.Protocol.Message
Dictionary<string, Dictionary<string, string>> translationsByModId = Dictionary<string, Dictionary<string, string>> translationsByModId =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
foreach (string modJarPath in Directory.EnumerateFiles(ForgeModTranslationDirectory, "*.jar") foreach (string modDirectory in GetForgeModTranslationDirectories())
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
{ {
try foreach (string modJarPath in Directory.EnumerateFiles(modDirectory, "*.jar")
{ .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
using FileStream modJarStream = File.OpenRead(modJarPath);
MergeForgeModTranslations(modJarStream, requestedModIds, translationsByModId);
}
catch (IOException)
{
}
catch (InvalidDataException)
{
}
catch (JsonException)
{ {
try
{
MergeForgeModTranslations(modJarPath, requestedModIds, translationsByModId);
}
catch (IOException)
{
}
catch (InvalidDataException)
{
}
catch (JsonException)
{
}
} }
} }
@ -712,23 +723,38 @@ namespace MinecraftClient.Protocol.Message
ResourcePackTranslationLayers.Add(new TranslationLayer(packIdentifier, translations)); ResourcePackTranslationLayers.Add(new TranslationLayer(packIdentifier, translations));
} }
private static void MergeForgeModTranslations(Stream modJarStream, HashSet<string> requestedModIds, private static void MergeForgeModTranslations(string modJarPath, HashSet<string> requestedModIds,
Dictionary<string, Dictionary<string, string>> translationsByModId) Dictionary<string, Dictionary<string, string>> translationsByModId)
{ {
using ZipArchive archive = new(modJarStream, ZipArchiveMode.Read, leaveOpen: true); string sourceHash = ComputeFileSha256(modJarPath);
string cacheFilePath = GetForgeModTranslationCacheFilePath(sourceHash);
if (!TryLoadCachedForgeModTranslations(cacheFilePath, sourceHash,
out Dictionary<string, Dictionary<string, string>>? cachedTranslations))
{
using FileStream modJarStream = File.OpenRead(modJarPath);
cachedTranslations = ExtractForgeModTranslations(modJarStream);
SaveCachedForgeModTranslations(cacheFilePath, sourceHash, cachedTranslations);
}
HashSet<string> archiveModIds = GetForgeModIds(archive) Dictionary<string, Dictionary<string, string>> archiveTranslations = cachedTranslations
.Where(requestedModIds.Contains) .Where(static entry => entry.Value.Count > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase); .Where(entry => requestedModIds.Contains(entry.Key))
.ToDictionary(static entry => entry.Key, static entry => entry.Value, StringComparer.OrdinalIgnoreCase);
if (archiveModIds.Count == 0)
return;
Dictionary<string, Dictionary<string, string>> archiveTranslations = ExtractForgeModTranslations(archive, archiveModIds);
foreach (var (modId, translations) in archiveTranslations) foreach (var (modId, translations) in archiveTranslations)
translationsByModId[modId] = translations; translationsByModId[modId] = translations;
} }
private static Dictionary<string, Dictionary<string, string>> ExtractForgeModTranslations(Stream modJarStream)
{
using ZipArchive archive = new(modJarStream, ZipArchiveMode.Read, leaveOpen: true);
HashSet<string> archiveModIds = GetForgeModIds(archive);
if (archiveModIds.Count == 0)
return new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
return ExtractForgeModTranslations(archive, archiveModIds);
}
private static HashSet<string> GetForgeModIds(ZipArchive archive) private static HashSet<string> GetForgeModIds(ZipArchive archive)
{ {
ZipArchiveEntry? modsTomlEntry = archive.GetEntry("META-INF/mods.toml") ZipArchiveEntry? modsTomlEntry = archive.GetEntry("META-INF/mods.toml")
@ -853,6 +879,211 @@ namespace MinecraftClient.Protocol.Message
return language.Trim().ToLowerInvariant().Replace('-', '_'); return language.Trim().ToLowerInvariant().Replace('-', '_');
} }
private static IEnumerable<string> GetForgeModTranslationDirectories()
{
string? configuredPath = Config.Main.Advanced.ForgeModTranslationPath?.Trim();
if (!string.IsNullOrWhiteSpace(configuredPath))
{
string overridePath = Path.GetFullPath(configuredPath);
if (Directory.Exists(overridePath))
yield return overridePath;
yield break;
}
HashSet<string> yieldedPaths = new(PathComparer);
foreach (string candidate in GetDefaultForgeModTranslationDirectories())
{
string fullPath = Path.GetFullPath(candidate);
if (Directory.Exists(fullPath) && yieldedPaths.Add(fullPath))
yield return fullPath;
}
if (!Config.Main.Advanced.AutoDiscoverForgeModTranslationSources)
yield break;
foreach (string candidate in DiscoverLauncherForgeModTranslationDirectories())
{
string fullPath = Path.GetFullPath(candidate);
if (Directory.Exists(fullPath) && yieldedPaths.Add(fullPath))
yield return fullPath;
}
}
private static IEnumerable<string> GetDefaultForgeModTranslationDirectories()
{
yield return LocalForgeModTranslationDirectory;
}
private static IEnumerable<string> DiscoverLauncherForgeModTranslationDirectories()
{
if (TryGetOfficialMinecraftModsDirectory(out string? officialModsDirectory))
yield return officialModsDirectory;
foreach (string prismModsDirectory in EnumerateInstanceModsDirectories(GetPrismLauncherInstancesDirectory()))
yield return prismModsDirectory;
foreach (string curseForgeModsDirectory in EnumerateInstanceModsDirectories(GetCurseForgeInstancesDirectory(), "mods"))
yield return curseForgeModsDirectory;
}
private static IEnumerable<string> EnumerateInstanceModsDirectories(string? instancesDirectory, params string[] relativeModsPaths)
{
if (string.IsNullOrWhiteSpace(instancesDirectory) || !Directory.Exists(instancesDirectory))
yield break;
string[] instanceDirectories;
try
{
instanceDirectories = Directory.GetDirectories(instancesDirectory);
}
catch (IOException)
{
yield break;
}
catch (UnauthorizedAccessException)
{
yield break;
}
foreach (string instanceDirectory in instanceDirectories)
{
foreach (string relativeModsPath in relativeModsPaths.Length > 0
? relativeModsPaths
: [Path.Combine(".minecraft", "mods"), Path.Combine("minecraft", "mods"), "mods"])
{
string modsDirectory = Path.Combine(instanceDirectory, relativeModsPath);
if (Directory.Exists(modsDirectory))
yield return modsDirectory;
}
}
}
private static bool TryGetOfficialMinecraftModsDirectory([NotNullWhen(true)] out string? modsDirectory)
{
modsDirectory = null;
string? userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(userProfile))
return false;
string baseMinecraftDirectory = OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ".minecraft")
: Path.Combine(userProfile, ".minecraft");
modsDirectory = Path.Combine(baseMinecraftDirectory, "mods");
return true;
}
private static string? GetPrismLauncherInstancesDirectory()
{
string? userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(userProfile))
return null;
if (OperatingSystem.IsWindows())
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PrismLauncher", "instances");
if (OperatingSystem.IsMacOS())
return Path.Combine(userProfile, "Library", "Application Support", "PrismLauncher", "instances");
return Path.Combine(userProfile, ".local", "share", "PrismLauncher", "instances");
}
private static string? GetCurseForgeInstancesDirectory()
{
string? userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(userProfile))
return null;
return Path.Combine(userProfile, "curseforge", "minecraft", "Instances");
}
private static readonly StringComparer PathComparer =
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
private static string ComputeFileSha256(string filePath)
{
using FileStream stream = File.OpenRead(filePath);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static bool TryLoadCachedForgeModTranslations(string cacheFilePath, string sourceHash,
[NotNullWhen(true)] out Dictionary<string, Dictionary<string, string>>? translationsByModId)
{
translationsByModId = null;
if (!File.Exists(cacheFilePath))
return false;
try
{
using FileStream cacheFile = File.OpenRead(cacheFilePath);
ForgeModTranslationCacheEntry? cacheEntry =
JsonSerializer.Deserialize<ForgeModTranslationCacheEntry>(cacheFile);
if (cacheEntry is not null
&& cacheEntry.CacheVersion == ForgeModTranslationCacheVersion
&& cacheEntry.Language.Equals(Config.Main.Advanced.Language, StringComparison.OrdinalIgnoreCase)
&& cacheEntry.SourceHash.Equals(sourceHash, StringComparison.OrdinalIgnoreCase)
&& cacheEntry.TranslationsByModId.Count > 0)
{
translationsByModId = cacheEntry.TranslationsByModId.ToDictionary(
static entry => entry.Key,
static entry => new Dictionary<string, string>(entry.Value, StringComparer.Ordinal),
StringComparer.OrdinalIgnoreCase);
return true;
}
}
catch (IOException)
{
}
catch (JsonException)
{
}
try
{
File.Delete(cacheFilePath);
}
catch (IOException)
{
}
return false;
}
private static void SaveCachedForgeModTranslations(string cacheFilePath, string sourceHash,
Dictionary<string, Dictionary<string, string>> translationsByModId)
{
if (translationsByModId.Count == 0)
return;
string? cacheDirectory = Path.GetDirectoryName(cacheFilePath);
if (string.IsNullOrEmpty(cacheDirectory))
return;
Directory.CreateDirectory(cacheDirectory);
ForgeModTranslationCacheEntry cacheEntry = new()
{
CacheVersion = ForgeModTranslationCacheVersion,
Language = Config.Main.Advanced.Language,
SourceHash = sourceHash,
TranslationsByModId = translationsByModId.ToDictionary(
static entry => entry.Key,
static entry => new Dictionary<string, string>(entry.Value, StringComparer.Ordinal),
StringComparer.OrdinalIgnoreCase)
};
File.WriteAllText(cacheFilePath, JsonSerializer.Serialize(cacheEntry), Encoding.UTF8);
}
private static string GetForgeModTranslationCacheFilePath(string sourceHash)
{
return Path.Combine("lang", "forgemods", $"{sourceHash}.{NormalizeLanguageCode(Config.Main.Advanced.Language)}.json");
}
private static bool TryLoadCachedResourcePackTranslations(string cacheFilePath, Uri resourcePackUri, string hash, private static bool TryLoadCachedResourcePackTranslations(string cacheFilePath, Uri resourcePackUri, string hash,
[NotNullWhen(true)] out Dictionary<string, string>? translations) [NotNullWhen(true)] out Dictionary<string, string>? translations)
{ {

View file

@ -688,7 +688,13 @@ Usage examples: "/tell &lt;mybot&gt; reco Player2", "/connect &lt;serverip&gt; P
<value>Load translations from server resource packs and cache extracted language entries locally for faster reuse.</value> <value>Load translations from server resource packs and cache extracted language entries locally for faster reuse.</value>
</data> </data>
<data name="Main.Advanced.load_forge_mod_translations" xml:space="preserve"> <data name="Main.Advanced.load_forge_mod_translations" xml:space="preserve">
<value>Load translations from local Forge mod jars in the mods folder when their mod IDs are announced by the server.</value> <value>Load translations from local Forge mod jars when their mod IDs are announced by the server. Disabled by default.</value>
</data>
<data name="Main.Advanced.auto_discover_forge_mod_translation_sources" xml:space="preserve">
<value>Also scan standard Minecraft, Prism Launcher, and CurseForge instance mod folders for translation jars when available.</value>
</data>
<data name="Main.Advanced.forge_mod_translation_path" xml:space="preserve">
<value>Optional path to a mods folder. When set, MCC loads Forge mod translations from this folder instead of automatic discovery.</value>
</data> </data>
<data name="Main.Advanced.mc_forge" xml:space="preserve"> <data name="Main.Advanced.mc_forge" xml:space="preserve">
<value>Use "auto", "no" or "force". Force-enabling only works for MC 1.13+.</value> <value>Use "auto", "no" or "force". Force-enabling only works for MC 1.13+.</value>

View file

@ -680,6 +680,8 @@ namespace MinecraftClient
if (Advanced.TcpTimeout < 1) if (Advanced.TcpTimeout < 1)
Advanced.TcpTimeout = 1; Advanced.TcpTimeout = 1;
Advanced.ForgeModTranslationPath = Advanced.ForgeModTranslationPath.Trim();
if (Advanced.MovementSpeed < 1) if (Advanced.MovementSpeed < 1)
Advanced.MovementSpeed = 1; Advanced.MovementSpeed = 1;
@ -779,7 +781,13 @@ namespace MinecraftClient
public bool LoadResourcePackTranslations = true; public bool LoadResourcePackTranslations = true;
[TomlInlineComment("$Main.Advanced.load_forge_mod_translations$")] [TomlInlineComment("$Main.Advanced.load_forge_mod_translations$")]
public bool LoadForgeModTranslations = true; public bool LoadForgeModTranslations = false;
[TomlInlineComment("$Main.Advanced.auto_discover_forge_mod_translation_sources$")]
public bool AutoDiscoverForgeModTranslationSources = true;
[TomlInlineComment("$Main.Advanced.forge_mod_translation_path$")]
public string ForgeModTranslationPath = "";
// [TomlInlineComment("$Main.Advanced.console_title$")] // [TomlInlineComment("$Main.Advanced.console_title$")]
public string ConsoleTitle = "%username%@%serverip% - Minecraft Console Client"; public string ConsoleTitle = "%username%@%serverip% - Minecraft Console Client";

View file

@ -283,12 +283,34 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Description:** - **Description:**
Set this to `false` to ignore translations from local Forge mod jars. When enabled, MCC looks in the local `mods` folder, reads matching `assets/<modId>/lang/<locale>.json` files from jars announced by the server, and falls back to `en_us` when the selected locale is missing. Set this to `true` to load translations from local Forge mod jars for the mod IDs announced by the server. MCC first checks the folder from `ForgeModTranslationPath` when it is set. Otherwise it checks the local `mods` folder, and if `AutoDiscoverForgeModTranslationSources` is enabled it also scans standard launcher folders such as `.minecraft/mods`, Prism Launcher instances, and CurseForge instances. MCC falls back to `en_us` when the selected locale is missing, and caches parsed results by jar hash.
- **Type:** `boolean`
- **Default:** `false`
#### `AutoDiscoverForgeModTranslationSources`
- **Description:**
Set this to `false` to stop scanning launcher-managed mod folders outside the current working directory. This setting only matters when `LoadForgeModTranslations` is enabled and `ForgeModTranslationPath` is empty.
- **Type:** `boolean` - **Type:** `boolean`
- **Default:** `true` - **Default:** `true`
#### `ForgeModTranslationPath`
- **Description:**
Optional path to a mods folder. When this is set, MCC loads Forge mod translations from that folder instead of using automatic discovery.
If automatic discovery does not find the mod you need, copy that mod jar into the configured folder, or into the local `mods` folder next to MCC, and MCC will read translations from there without modifying the jar.
- **Type:** `string`
- **Default:** `""`
#### `ConsoleTitle` #### `ConsoleTitle`
- **Description:** - **Description:**