From 90a3777dac6a7541d5a79f48527a737527a8df61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 18:52:10 +0000 Subject: [PATCH 1/5] Initial plan From d256517e21c77c67d56fbc9bd2cebdee8b2f820a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 19:02:44 +0000 Subject: [PATCH 2/5] feat: load forge mod translations from local jars Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/3d9090fb-cdf3-48e7-a7ce-575b54161f0e Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com> --- MinecraftClient/McClient.cs | 2 + .../Protocol/Handlers/Protocol18Forge.cs | 2 + .../Protocol/Message/ChatParser.cs | 220 +++++++++++++++++- .../ConfigComments/ConfigComments.resx | 3 + MinecraftClient/Settings.cs | 3 + docs/guide/configuration.md | 10 + 6 files changed, 231 insertions(+), 9 deletions(-) diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 136fe224..fcade4a7 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -314,6 +314,8 @@ namespace MinecraftClient client.ReceiveBufferSize = 1024 * 1024; client.ReceiveTimeout = Config.Main.Advanced.TcpTimeout * 1000; // Default: 30 seconds handler = Protocol.ProtocolHandler.GetProtocolHandler(client, protocolversion, forgeInfo, this); + if (forgeInfo is not null) + ChatParser.LoadForgeModTranslations(forgeInfo.Mods.Select(static mod => mod.ModID)); Log.Info(Translations.mcc_version_supported); timeoutdetector = new(new Thread(new ParameterizedThreadStart(TimeoutDetector)), new CancellationTokenSource()); diff --git a/MinecraftClient/Protocol/Handlers/Protocol18Forge.cs b/MinecraftClient/Protocol/Handlers/Protocol18Forge.cs index 7359d200..0e93c0bb 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18Forge.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18Forge.cs @@ -301,6 +301,8 @@ namespace MinecraftClient.Protocol.Handlers for (int i = 0; i < modCount; i++) mods.Add(dataTypes.ReadNextString(packetData)); + ChatParser.LoadForgeModTranslations(mods); + Dictionary channels = new(); int channelCount = dataTypes.ReadNextVarInt(packetData); for (int i = 0; i < channelCount; i++) diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs index 7920fa61..34e83878 100644 --- a/MinecraftClient/Protocol/Message/ChatParser.cs +++ b/MinecraftClient/Protocol/Message/ChatParser.cs @@ -5,6 +5,7 @@ using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Net.Http.Json; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -255,9 +256,9 @@ namespace MinecraftClient.Protocol.Message /// private static Dictionary TranslationRules = new(); - private sealed class ResourcePackTranslationLayer(string packIdentifier, Dictionary translations) + private sealed class TranslationLayer(string identifier, Dictionary translations) { - public string PackIdentifier { get; } = packIdentifier; + public string Identifier { get; } = identifier; public Dictionary Translations { get; } = translations; } @@ -273,8 +274,13 @@ namespace MinecraftClient.Protocol.Message private const long MaxResourcePackDownloadBytes = 256L * 1024 * 1024; private const int ResourcePackDownloadBufferSize = 81920; private const string ResourcePackTranslationCacheVersion = "1"; + private const string ForgeModTranslationDirectory = "mods"; - private static readonly List ResourcePackTranslationLayers = []; + private static readonly Regex ForgeModIdLineRegex = + new(@"^\s*modId\s*=\s*""([^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly List ForgeModTranslationLayers = []; + private static readonly List ResourcePackTranslationLayers = []; private static readonly HttpClient ResourcePackHttpClient = new(); /// @@ -283,6 +289,7 @@ namespace MinecraftClient.Protocol.Message /// public static void InitTranslations() { + ForgeModTranslationLayers.Clear(); ResourcePackTranslationLayers.Clear(); if (!RulesInitialized) @@ -464,8 +471,8 @@ namespace MinecraftClient.Protocol.Message public static void RemoveResourcePackTranslations(string packIdentifier) { - ResourcePackTranslationLayers.RemoveAll(layer => - layer.PackIdentifier.Equals(packIdentifier, StringComparison.Ordinal)); + ResourcePackTranslationLayers.RemoveAll(layer => + layer.Identifier.Equals(packIdentifier, StringComparison.Ordinal)); } public static void ClearResourcePackTranslations() @@ -473,6 +480,56 @@ namespace MinecraftClient.Protocol.Message ResourcePackTranslationLayers.Clear(); } + public static void LoadForgeModTranslations(IEnumerable modIds) + { + ArgumentNullException.ThrowIfNull(modIds); + + ForgeModTranslationLayers.Clear(); + + if (!Config.Main.Advanced.LoadForgeModTranslations || !Directory.Exists(ForgeModTranslationDirectory)) + return; + + HashSet requestedModIds = new( + modIds + .Where(static modId => !string.IsNullOrWhiteSpace(modId)) + .Select(static modId => NormalizeForgeModId(modId)), + StringComparer.OrdinalIgnoreCase); + + if (requestedModIds.Count == 0) + return; + + Dictionary> translationsByModId = + new(StringComparer.OrdinalIgnoreCase); + + foreach (string modJarPath in Directory.EnumerateFiles(ForgeModTranslationDirectory, "*.jar") + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)) + { + try + { + using FileStream modJarStream = File.OpenRead(modJarPath); + MergeForgeModTranslations(modJarStream, requestedModIds, translationsByModId); + } + catch (IOException) + { + } + catch (InvalidDataException) + { + } + catch (JsonException) + { + } + } + + foreach (string modId in requestedModIds) + { + if (translationsByModId.TryGetValue(modId, out Dictionary? translations) + && translations.Count > 0) + { + ForgeModTranslationLayers.Add(new TranslationLayer(modId, translations)); + } + } + } + /// /// Format text using a specific formatting rule. /// Example : * %s %s + ["ORelio", "is doing something"] = * ORelio is doing something @@ -532,7 +589,7 @@ namespace MinecraftClient.Protocol.Message else return "[" + rulename + "] " + string.Join(" ", using_data); } - private static bool TryGetTranslationRule(string rulename, out string? result) + private static bool TryGetTranslationRule(string rulename, [NotNullWhen(true)] out string? result) { for (int i = ResourcePackTranslationLayers.Count - 1; i >= 0; i--) { @@ -540,6 +597,12 @@ namespace MinecraftClient.Protocol.Message return true; } + for (int i = ForgeModTranslationLayers.Count - 1; i >= 0; i--) + { + if (ForgeModTranslationLayers[i].Translations.TryGetValue(rulename, out result)) + return true; + } + return TranslationRules.TryGetValue(rulename, out result); } @@ -609,7 +672,7 @@ namespace MinecraftClient.Protocol.Message return mergedTranslations; } - private static bool TryGetResourcePackLanguage(string entryPath, out string? language) + private static bool TryGetResourcePackLanguage(string entryPath, [NotNullWhen(true)] out string? language) { language = null; @@ -647,11 +710,150 @@ namespace MinecraftClient.Protocol.Message RemoveResourcePackTranslations(packIdentifier); if (translations.Count > 0) - ResourcePackTranslationLayers.Add(new ResourcePackTranslationLayer(packIdentifier, translations)); + ResourcePackTranslationLayers.Add(new TranslationLayer(packIdentifier, translations)); + } + + private static void MergeForgeModTranslations(Stream modJarStream, HashSet requestedModIds, + Dictionary> translationsByModId) + { + using ZipArchive archive = new(modJarStream, ZipArchiveMode.Read, leaveOpen: true); + + HashSet archiveModIds = GetForgeModIds(archive) + .Where(requestedModIds.Contains) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (archiveModIds.Count == 0) + return; + + Dictionary> archiveTranslations = ExtractForgeModTranslations(archive, archiveModIds); + foreach (var (modId, translations) in archiveTranslations) + translationsByModId[modId] = translations; + } + + private static HashSet GetForgeModIds(ZipArchive archive) + { + ZipArchiveEntry? modsTomlEntry = archive.Entries.FirstOrDefault(entry => + entry.FullName.Equals("META-INF/mods.toml", StringComparison.OrdinalIgnoreCase)); + + if (modsTomlEntry is null) + return []; + + HashSet modIds = new(StringComparer.OrdinalIgnoreCase); + using StreamReader reader = new(modsTomlEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + while (reader.ReadLine() is string line) + { + string trimmedLine = line.Trim(); + if (trimmedLine.StartsWith('#')) + continue; + + Match match = ForgeModIdLineRegex.Match(trimmedLine); + if (match.Success) + modIds.Add(NormalizeForgeModId(match.Groups[1].Value)); + } + + return modIds; + } + + private static Dictionary> ExtractForgeModTranslations(ZipArchive archive, HashSet requestedModIds) + { + string selectedLanguage = NormalizeLanguageCode(Config.Main.Advanced.Language); + Dictionary> fallbackTranslations = + new(StringComparer.OrdinalIgnoreCase); + Dictionary> selectedTranslations = + new(StringComparer.OrdinalIgnoreCase); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (!TryGetForgeModLanguage(entry.FullName, out string? modId, out string? language)) + continue; + + if (!requestedModIds.Contains(modId)) + continue; + + if (language.Equals("en_us", StringComparison.OrdinalIgnoreCase)) + { + if (!fallbackTranslations.TryGetValue(modId, out Dictionary? translations)) + { + translations = new Dictionary(StringComparer.Ordinal); + fallbackTranslations[modId] = translations; + } + + MergeResourcePackTranslations(entry, translations); + } + else if (language.Equals(selectedLanguage, StringComparison.OrdinalIgnoreCase)) + { + if (!selectedTranslations.TryGetValue(modId, out Dictionary? translations)) + { + translations = new Dictionary(StringComparer.Ordinal); + selectedTranslations[modId] = translations; + } + + MergeResourcePackTranslations(entry, translations); + } + } + + Dictionary> mergedTranslations = + new(StringComparer.OrdinalIgnoreCase); + + foreach (string modId in requestedModIds) + { + Dictionary modTranslations = new(StringComparer.Ordinal); + + if (fallbackTranslations.TryGetValue(modId, out Dictionary? fallback)) + { + foreach (var (key, value) in fallback) + modTranslations[key] = value; + } + + if (selectedTranslations.TryGetValue(modId, out Dictionary? selected)) + { + foreach (var (key, value) in selected) + modTranslations[key] = value; + } + + if (modTranslations.Count > 0) + mergedTranslations[modId] = modTranslations; + } + + return mergedTranslations; + } + + private static bool TryGetForgeModLanguage(string entryPath, [NotNullWhen(true)] out string? modId, + [NotNullWhen(true)] out string? language) + { + modId = null; + language = null; + + string[] pathParts = entryPath + .Replace('\\', '/') + .Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (pathParts.Length != 4 + || !pathParts[0].Equals("assets", StringComparison.OrdinalIgnoreCase) + || !pathParts[2].Equals("lang", StringComparison.OrdinalIgnoreCase) + || !pathParts[3].EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + modId = NormalizeForgeModId(pathParts[1]); + language = NormalizeLanguageCode(Path.GetFileNameWithoutExtension(pathParts[3])); + return !string.IsNullOrEmpty(modId) && !string.IsNullOrEmpty(language); + } + + private static string NormalizeForgeModId(string modId) + { + return Settings.ToLowerIfNeed(modId.Trim()); + } + + private static string NormalizeLanguageCode(string language) + { + return Settings.ToLowerIfNeed(language.Trim()).Replace('-', '_'); } private static bool TryLoadCachedResourcePackTranslations(string cacheFilePath, Uri resourcePackUri, string hash, - out Dictionary? translations) + [NotNullWhen(true)] out Dictionary? translations) { translations = null; diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index f7588f37..b5369fc7 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -687,6 +687,9 @@ Usage examples: "/tell <mybot> reco Player2", "/connect <serverip> P Load translations from server resource packs and cache extracted language entries locally for faster reuse. + + Load translations from local Forge mod jars in the mods folder when their mod IDs are announced by the server. + Use "auto", "no" or "force". Force-enabling only works for MC 1.13+. diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 6c32f9be..b5e8a562 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -778,6 +778,9 @@ namespace MinecraftClient [TomlInlineComment("$Main.Advanced.load_resourcepack_translations$")] public bool LoadResourcePackTranslations = true; + [TomlInlineComment("$Main.Advanced.load_forge_mod_translations$")] + public bool LoadForgeModTranslations = true; + // [TomlInlineComment("$Main.Advanced.console_title$")] public string ConsoleTitle = "%username%@%serverip% - Minecraft Console Client"; diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 10e4ca00..bb553988 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -279,6 +279,16 @@ Coordinate = { x = 145, y = 64, z = 2045 } - **Default:** `true` +#### `LoadForgeModTranslations` + +- **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//lang/.json` files from jars announced by the server, and falls back to `en_us` when the selected locale is missing. + +- **Type:** `boolean` + +- **Default:** `true` + #### `ConsoleTitle` - **Description:** From 67066a145d81c62f3590f2c335e0f86b80d3aa05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 19:08:22 +0000 Subject: [PATCH 3/5] refactor: parse forge mods toml for translations Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/3d9090fb-cdf3-48e7-a7ce-575b54161f0e Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com> --- .../Protocol/Message/ChatParser.cs | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs index 34e83878..1ac378f5 100644 --- a/MinecraftClient/Protocol/Message/ChatParser.cs +++ b/MinecraftClient/Protocol/Message/ChatParser.cs @@ -11,6 +11,8 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Tomlet; +using Tomlet.Models; using static MinecraftClient.Settings; namespace MinecraftClient.Protocol.Message @@ -276,9 +278,6 @@ namespace MinecraftClient.Protocol.Message private const string ResourcePackTranslationCacheVersion = "1"; private const string ForgeModTranslationDirectory = "mods"; - private static readonly Regex ForgeModIdLineRegex = - new(@"^\s*modId\s*=\s*""([^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly List ForgeModTranslationLayers = []; private static readonly List ResourcePackTranslationLayers = []; private static readonly HttpClient ResourcePackHttpClient = new(); @@ -658,11 +657,11 @@ namespace MinecraftClient.Protocol.Message if (language.Equals("en_us", StringComparison.OrdinalIgnoreCase)) { - MergeResourcePackTranslations(entry, mergedTranslations); + MergeTranslationsFromZipEntry(entry, mergedTranslations); } else if (language.Equals(selectedLanguage, StringComparison.OrdinalIgnoreCase)) { - MergeResourcePackTranslations(entry, selectedLanguageTranslations); + MergeTranslationsFromZipEntry(entry, selectedLanguageTranslations); } } @@ -692,7 +691,7 @@ namespace MinecraftClient.Protocol.Message return !string.IsNullOrEmpty(language); } - private static void MergeResourcePackTranslations(ZipArchiveEntry entry, Dictionary translations) + private static void MergeTranslationsFromZipEntry(ZipArchiveEntry entry, Dictionary translations) { using Stream entryStream = entry.Open(); Dictionary? entryTranslations = @@ -732,24 +731,26 @@ namespace MinecraftClient.Protocol.Message private static HashSet GetForgeModIds(ZipArchive archive) { - ZipArchiveEntry? modsTomlEntry = archive.Entries.FirstOrDefault(entry => - entry.FullName.Equals("META-INF/mods.toml", StringComparison.OrdinalIgnoreCase)); + ZipArchiveEntry? modsTomlEntry = archive.GetEntry("META-INF/mods.toml") + ?? archive.GetEntry("META-INF/MODS.TOML"); if (modsTomlEntry is null) return []; - HashSet modIds = new(StringComparer.OrdinalIgnoreCase); using StreamReader reader = new(modsTomlEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + TomlDocument document = new TomlParser().Parse(reader.ReadToEnd()); + if (!document.TryGetValue("mods", out TomlValue? modsValue) || modsValue is not TomlArray modsArray) + return []; - while (reader.ReadLine() is string line) + HashSet modIds = new(StringComparer.OrdinalIgnoreCase); + foreach (TomlValue modValue in modsArray) { - string trimmedLine = line.Trim(); - if (trimmedLine.StartsWith('#')) + if (modValue is not TomlTable modTable || !modTable.ContainsKey("modId")) continue; - Match match = ForgeModIdLineRegex.Match(trimmedLine); - if (match.Success) - modIds.Add(NormalizeForgeModId(match.Groups[1].Value)); + string modId = modTable.GetString("modId"); + if (!string.IsNullOrWhiteSpace(modId)) + modIds.Add(NormalizeForgeModId(modId)); } return modIds; @@ -779,7 +780,7 @@ namespace MinecraftClient.Protocol.Message fallbackTranslations[modId] = translations; } - MergeResourcePackTranslations(entry, translations); + MergeTranslationsFromZipEntry(entry, translations); } else if (language.Equals(selectedLanguage, StringComparison.OrdinalIgnoreCase)) { @@ -789,7 +790,7 @@ namespace MinecraftClient.Protocol.Message selectedTranslations[modId] = translations; } - MergeResourcePackTranslations(entry, translations); + MergeTranslationsFromZipEntry(entry, translations); } } @@ -844,12 +845,12 @@ namespace MinecraftClient.Protocol.Message private static string NormalizeForgeModId(string modId) { - return Settings.ToLowerIfNeed(modId.Trim()); + return modId.Trim().ToLowerInvariant(); } private static string NormalizeLanguageCode(string language) { - return Settings.ToLowerIfNeed(language.Trim()).Replace('-', '_'); + return language.Trim().ToLowerInvariant().Replace('-', '_'); } private static bool TryLoadCachedResourcePackTranslations(string cacheFilePath, Uri resourcePackUri, string hash, From 7ce4f82871067b3dbf7eb453cfa3316fa25c0730 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 20:11:43 +0000 Subject: [PATCH 4/5] 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> --- .../Protocol/Message/ChatParser.cs | 281 ++++++++++++++++-- .../ConfigComments/ConfigComments.resx | 8 +- MinecraftClient/Settings.cs | 10 +- docs/guide/configuration.md | 24 +- 4 files changed, 295 insertions(+), 28 deletions(-) diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs index 1ac378f5..71091c8a 100644 --- a/MinecraftClient/Protocol/Message/ChatParser.cs +++ b/MinecraftClient/Protocol/Message/ChatParser.cs @@ -273,10 +273,19 @@ namespace MinecraftClient.Protocol.Message public Dictionary 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> TranslationsByModId { get; init; } = []; + } + private const long MaxResourcePackDownloadBytes = 256L * 1024 * 1024; private const int ResourcePackDownloadBufferSize = 81920; private const string ResourcePackTranslationCacheVersion = "1"; - private const string ForgeModTranslationDirectory = "mods"; + private const string ForgeModTranslationCacheVersion = "1"; + private const string LocalForgeModTranslationDirectory = "mods"; private static readonly List ForgeModTranslationLayers = []; private static readonly List ResourcePackTranslationLayers = []; @@ -485,7 +494,7 @@ namespace MinecraftClient.Protocol.Message ForgeModTranslationLayers.Clear(); - if (!Config.Main.Advanced.LoadForgeModTranslations || !Directory.Exists(ForgeModTranslationDirectory)) + if (!Config.Main.Advanced.LoadForgeModTranslations) return; HashSet requestedModIds = new( @@ -500,22 +509,24 @@ namespace MinecraftClient.Protocol.Message Dictionary> translationsByModId = new(StringComparer.OrdinalIgnoreCase); - foreach (string modJarPath in Directory.EnumerateFiles(ForgeModTranslationDirectory, "*.jar") - .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)) + foreach (string modDirectory in GetForgeModTranslationDirectories()) { - try - { - using FileStream modJarStream = File.OpenRead(modJarPath); - MergeForgeModTranslations(modJarStream, requestedModIds, translationsByModId); - } - catch (IOException) - { - } - catch (InvalidDataException) - { - } - catch (JsonException) + foreach (string modJarPath in Directory.EnumerateFiles(modDirectory, "*.jar") + .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)) { + 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)); } - private static void MergeForgeModTranslations(Stream modJarStream, HashSet requestedModIds, + private static void MergeForgeModTranslations(string modJarPath, HashSet requestedModIds, Dictionary> translationsByModId) { - using ZipArchive archive = new(modJarStream, ZipArchiveMode.Read, leaveOpen: true); + string sourceHash = ComputeFileSha256(modJarPath); + string cacheFilePath = GetForgeModTranslationCacheFilePath(sourceHash); + if (!TryLoadCachedForgeModTranslations(cacheFilePath, sourceHash, + out Dictionary>? cachedTranslations)) + { + using FileStream modJarStream = File.OpenRead(modJarPath); + cachedTranslations = ExtractForgeModTranslations(modJarStream); + SaveCachedForgeModTranslations(cacheFilePath, sourceHash, cachedTranslations); + } - HashSet archiveModIds = GetForgeModIds(archive) - .Where(requestedModIds.Contains) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + Dictionary> archiveTranslations = cachedTranslations + .Where(static entry => entry.Value.Count > 0) + .Where(entry => requestedModIds.Contains(entry.Key)) + .ToDictionary(static entry => entry.Key, static entry => entry.Value, StringComparer.OrdinalIgnoreCase); - if (archiveModIds.Count == 0) - return; - - Dictionary> archiveTranslations = ExtractForgeModTranslations(archive, archiveModIds); foreach (var (modId, translations) in archiveTranslations) translationsByModId[modId] = translations; } + private static Dictionary> ExtractForgeModTranslations(Stream modJarStream) + { + using ZipArchive archive = new(modJarStream, ZipArchiveMode.Read, leaveOpen: true); + HashSet archiveModIds = GetForgeModIds(archive); + if (archiveModIds.Count == 0) + return new Dictionary>(StringComparer.OrdinalIgnoreCase); + + return ExtractForgeModTranslations(archive, archiveModIds); + } + private static HashSet GetForgeModIds(ZipArchive archive) { ZipArchiveEntry? modsTomlEntry = archive.GetEntry("META-INF/mods.toml") @@ -853,6 +879,211 @@ namespace MinecraftClient.Protocol.Message return language.Trim().ToLowerInvariant().Replace('-', '_'); } + private static IEnumerable 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 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 GetDefaultForgeModTranslationDirectories() + { + yield return LocalForgeModTranslationDirectory; + } + + private static IEnumerable 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 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>? translationsByModId) + { + translationsByModId = null; + + if (!File.Exists(cacheFilePath)) + return false; + + try + { + using FileStream cacheFile = File.OpenRead(cacheFilePath); + ForgeModTranslationCacheEntry? cacheEntry = + JsonSerializer.Deserialize(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(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> 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(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, [NotNullWhen(true)] out Dictionary? translations) { diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index b5369fc7..d18c32be 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -688,7 +688,13 @@ Usage examples: "/tell <mybot> reco Player2", "/connect <serverip> P Load translations from server resource packs and cache extracted language entries locally for faster reuse. - Load translations from local Forge mod jars in the mods folder when their mod IDs are announced by the server. + Load translations from local Forge mod jars when their mod IDs are announced by the server. Disabled by default. + + + Also scan standard Minecraft, Prism Launcher, and CurseForge instance mod folders for translation jars when available. + + + Optional path to a mods folder. When set, MCC loads Forge mod translations from this folder instead of automatic discovery. Use "auto", "no" or "force". Force-enabling only works for MC 1.13+. diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index b5e8a562..f1f9453e 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -680,6 +680,8 @@ namespace MinecraftClient if (Advanced.TcpTimeout < 1) Advanced.TcpTimeout = 1; + Advanced.ForgeModTranslationPath = Advanced.ForgeModTranslationPath.Trim(); + if (Advanced.MovementSpeed < 1) Advanced.MovementSpeed = 1; @@ -779,7 +781,13 @@ namespace MinecraftClient public bool LoadResourcePackTranslations = true; [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$")] public string ConsoleTitle = "%username%@%serverip% - Minecraft Console Client"; diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bb553988..4a1b594b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -283,12 +283,34 @@ Coordinate = { x = 145, y = 64, z = 2045 } - **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//lang/.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` - **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` - **Description:** From 21a52b3bcc327d28c5a5b9c1a730dcae9ddbb6ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 20:15:56 +0000 Subject: [PATCH 5/5] fix: streamline forge translation preload 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> --- MinecraftClient/McClient.cs | 2 +- MinecraftClient/Protocol/Message/ChatParser.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index fcade4a7..6d89f219 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -314,7 +314,7 @@ namespace MinecraftClient client.ReceiveBufferSize = 1024 * 1024; client.ReceiveTimeout = Config.Main.Advanced.TcpTimeout * 1000; // Default: 30 seconds handler = Protocol.ProtocolHandler.GetProtocolHandler(client, protocolversion, forgeInfo, this); - if (forgeInfo is not null) + if (forgeInfo is not null && forgeInfo.Version == FMLVersion.FML) ChatParser.LoadForgeModTranslations(forgeInfo.Mods.Select(static mod => mod.ModID)); Log.Info(Translations.mcc_version_supported); diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs index 71091c8a..e6da1e08 100644 --- a/MinecraftClient/Protocol/Message/ChatParser.cs +++ b/MinecraftClient/Protocol/Message/ChatParser.cs @@ -511,8 +511,7 @@ namespace MinecraftClient.Protocol.Message foreach (string modDirectory in GetForgeModTranslationDirectories()) { - foreach (string modJarPath in Directory.EnumerateFiles(modDirectory, "*.jar") - .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)) + foreach (string modJarPath in Directory.EnumerateFiles(modDirectory, "*.jar")) { try { @@ -1005,7 +1004,8 @@ namespace MinecraftClient.Protocol.Message private static string ComputeFileSha256(string filePath) { using FileStream stream = File.OpenRead(filePath); - return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + using SHA256 sha256 = SHA256.Create(); + return Convert.ToHexString(sha256.ComputeHash(stream)).ToLowerInvariant(); } private static bool TryLoadCachedForgeModTranslations(string cacheFilePath, string sourceHash,