diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs index 9e7d52b2..1c4cbda2 100644 --- a/MinecraftClient/Protocol/Message/ChatParser.cs +++ b/MinecraftClient/Protocol/Message/ChatParser.cs @@ -261,8 +261,18 @@ namespace MinecraftClient.Protocol.Message public Dictionary Translations { get; } = translations; } + private sealed class ResourcePackTranslationCacheEntry + { + public string CacheVersion { get; init; } = string.Empty; + public string Language { get; init; } = string.Empty; + public string SourceUrl { get; init; } = string.Empty; + public string SourceHash { get; init; } = string.Empty; + public Dictionary Translations { get; init; } = []; + } + private const long MaxResourcePackDownloadBytes = 256L * 1024 * 1024; private const int ResourcePackDownloadBufferSize = 81920; + private const string ResourcePackTranslationCacheVersion = "1"; private static readonly List ResourcePackTranslationLayers = []; private static readonly HttpClient ResourcePackHttpClient = new(); @@ -403,18 +413,30 @@ namespace MinecraftClient.Protocol.Message ArgumentException.ThrowIfNullOrEmpty(packIdentifier); ArgumentException.ThrowIfNullOrEmpty(url); + if (!Config.Main.Advanced.LoadResourcePackTranslations) + return; + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? resourcePackUri) || resourcePackUri.Scheme is not "http" and not "https") { return; } + string cacheFilePath = GetResourcePackTranslationCacheFilePath(resourcePackUri, hash); + if (TryLoadCachedResourcePackTranslations(cacheFilePath, resourcePackUri, hash, out Dictionary? cachedTranslations)) + { + ReplaceResourcePackTranslations(packIdentifier, cachedTranslations); + return; + } + string temporaryFilePath = Path.GetTempFileName(); try { DownloadResourcePack(resourcePackUri, hash, temporaryFilePath); using FileStream resourcePackFile = File.OpenRead(temporaryFilePath); - LoadResourcePackTranslations(packIdentifier, resourcePackFile); + Dictionary resourcePackTranslations = ExtractResourcePackTranslations(resourcePackFile); + ReplaceResourcePackTranslations(packIdentifier, resourcePackTranslations); + SaveCachedResourcePackTranslations(cacheFilePath, resourcePackUri, hash, resourcePackTranslations); } catch (HttpRequestException) { @@ -442,7 +464,7 @@ namespace MinecraftClient.Protocol.Message public static void RemoveResourcePackTranslations(string packIdentifier) { - ResourcePackTranslationLayers.RemoveAll(layer => + ResourcePackTranslationLayers.RemoveAll(layer => layer.PackIdentifier.Equals(packIdentifier, StringComparison.Ordinal)); } @@ -558,7 +580,7 @@ namespace MinecraftClient.Protocol.Message } } - private static void LoadResourcePackTranslations(string packIdentifier, Stream resourcePackStream) + private static Dictionary ExtractResourcePackTranslations(Stream resourcePackStream) { var mergedTranslations = new Dictionary(StringComparer.Ordinal); var selectedLanguageTranslations = new Dictionary(StringComparer.Ordinal); @@ -584,10 +606,7 @@ namespace MinecraftClient.Protocol.Message foreach (var entry in selectedLanguageTranslations) mergedTranslations[entry.Key] = entry.Value; - RemoveResourcePackTranslations(packIdentifier); - - if (mergedTranslations.Count > 0) - ResourcePackTranslationLayers.Add(new ResourcePackTranslationLayer(packIdentifier, mergedTranslations)); + return mergedTranslations; } private static bool TryGetResourcePackLanguage(string entryPath, out string? language) @@ -623,6 +642,97 @@ namespace MinecraftClient.Protocol.Message translations[key] = value; } + private static void ReplaceResourcePackTranslations(string packIdentifier, Dictionary translations) + { + RemoveResourcePackTranslations(packIdentifier); + + if (translations.Count > 0) + ResourcePackTranslationLayers.Add(new ResourcePackTranslationLayer(packIdentifier, translations)); + } + + private static bool TryLoadCachedResourcePackTranslations(string cacheFilePath, Uri resourcePackUri, string hash, + out Dictionary? translations) + { + translations = null; + + if (!File.Exists(cacheFilePath)) + return false; + + try + { + using FileStream cacheFile = File.OpenRead(cacheFilePath); + ResourcePackTranslationCacheEntry? cacheEntry = + JsonSerializer.Deserialize(cacheFile); + + if (cacheEntry is not null + && cacheEntry.CacheVersion == ResourcePackTranslationCacheVersion + && cacheEntry.Language.Equals(Config.Main.Advanced.Language, StringComparison.OrdinalIgnoreCase) + && cacheEntry.SourceUrl.Equals(resourcePackUri.AbsoluteUri, StringComparison.Ordinal) + && cacheEntry.SourceHash.Equals(hash, StringComparison.OrdinalIgnoreCase) + && cacheEntry.Translations.Count > 0) + { + translations = new Dictionary(cacheEntry.Translations, StringComparer.Ordinal); + return true; + } + } + catch (IOException) + { + } + catch (JsonException) + { + } + + try + { + File.Delete(cacheFilePath); + } + catch (IOException) + { + } + + return false; + } + + private static void SaveCachedResourcePackTranslations(string cacheFilePath, Uri resourcePackUri, string hash, + Dictionary translations) + { + if (translations.Count == 0) + return; + + Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)!); + + ResourcePackTranslationCacheEntry cacheEntry = new() + { + CacheVersion = ResourcePackTranslationCacheVersion, + Language = Config.Main.Advanced.Language, + SourceUrl = resourcePackUri.AbsoluteUri, + SourceHash = hash, + Translations = new Dictionary(translations, StringComparer.Ordinal) + }; + + File.WriteAllText(cacheFilePath, JsonSerializer.Serialize(cacheEntry), Encoding.UTF8); + } + + private static string GetResourcePackTranslationCacheFilePath(Uri resourcePackUri, string hash) + { + string cacheKey = GetResourcePackTranslationCacheKey(resourcePackUri, hash); + return Path.Combine("lang", "resourcepacks", $"{cacheKey}.{Config.Main.Advanced.Language}.json"); + } + + private static string GetResourcePackTranslationCacheKey(Uri resourcePackUri, string hash) + { + if (IsValidSha1(hash)) + return hash.ToLowerInvariant(); + + byte[] urlHash = SHA256.HashData(Encoding.UTF8.GetBytes(resourcePackUri.AbsoluteUri)); + return "url-" + Convert.ToHexString(urlHash).ToLowerInvariant(); + } + + private static bool IsValidSha1(string hash) + { + return hash.Length == 40 && hash.All(Uri.IsHexDigit); + } + /// /// Mapping from JSON/NBT property names to Minecraft formatting codes (without ยง). /// Both "underlined" (canonical Minecraft name) and "underline" (alias) are supported. diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 1495d83b..f7588f37 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -684,6 +684,9 @@ Usage examples: "/tell <mybot> reco Player2", "/connect <serverip> P Load translations applied to MCC when available, turn it off to use English only. + + Load translations from server resource packs and cache extracted language entries locally for faster reuse. + Use "auto", "no" or "force". Force-enabling only works for MC 1.13+. diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 889abe5a..6c32f9be 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -775,6 +775,9 @@ namespace MinecraftClient [TomlInlineComment("$Main.Advanced.LoadMccTrans$")] public bool LoadMccTranslation = true; + [TomlInlineComment("$Main.Advanced.load_resourcepack_translations$")] + public bool LoadResourcePackTranslations = 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 aa04e050..10e4ca00 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -269,6 +269,16 @@ Coordinate = { x = 145, y = 64, z = 2045 } - **Default:** `true` +#### `LoadResourcePackTranslations` + +- **Description:** + + Set this to `false` to ignore translations provided by server resource packs. When enabled, MCC caches extracted resource-pack translation data locally so future joins can reuse it without downloading the pack again. + +- **Type:** `boolean` + +- **Default:** `true` + #### `ConsoleTitle` - **Description:**