feat: Load translation keys from server resource packs in chat parsing

This commit is contained in:
Anon 2026-05-04 20:17:38 +02:00 committed by GitHub
commit effc05aec3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 332 additions and 9 deletions

View file

@ -580,7 +580,9 @@ namespace MinecraftClient.Protocol.Handlers
case ConfigurationPacketTypesIn.RemoveResourcePack:
if (dataTypes.ReadNextBool(packetData)) // Has UUID
dataTypes.ReadNextUUID(packetData); // UUID
ChatParser.RemoveResourcePackTranslations(dataTypes.ReadNextUUID(packetData).ToString("D")); // UUID
else
ChatParser.ClearResourcePackTranslations();
break;
case ConfigurationPacketTypesIn.ResourcePack:
@ -703,6 +705,15 @@ namespace MinecraftClient.Protocol.Handlers
var url = dataTypes.ReadNextString(packetData);
var hash = dataTypes.ReadNextString(packetData);
// Use the server-provided UUID when available, then fall back to the legacy SHA-1 hash,
// and finally the URL so pre-UUID resource packs can still be replaced or cleared locally.
string packIdentifier;
if (uuid != Guid.Empty)
packIdentifier = uuid.ToString("D");
else if (hash.Length == 40)
packIdentifier = hash;
else
packIdentifier = url;
if (protocolVersion >= MC_1_17_Version)
{
@ -740,6 +751,8 @@ namespace MinecraftClient.Protocol.Handlers
SendPacket(PacketTypesOut.ResourcePackStatus, acceptedResourcePackData); // Accepted
SendPacket(PacketTypesOut.ResourcePackStatus, loadedResourcePackData); // Successfully loaded
}
ChatParser.LoadResourcePackTranslations(packIdentifier, url, hash);
}
private bool HandlePlayPackets(int packetId, Queue<byte> packetData)
@ -2447,7 +2460,9 @@ namespace MinecraftClient.Protocol.Handlers
break;
case PacketTypesIn.RemoveResourcePack:
if (dataTypes.ReadNextBool(packetData)) // Has UUID
dataTypes.ReadNextUUID(packetData); // UUID
ChatParser.RemoveResourcePackTranslations(dataTypes.ReadNextUUID(packetData).ToString("D")); // UUID
else
ChatParser.ClearResourcePackTranslations();
break;
case PacketTypesIn.ResourcePackSend:
HandleResourcePackPacket(packetData);

View file

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
@ -253,12 +255,36 @@ namespace MinecraftClient.Protocol.Message
/// </summary>
private static Dictionary<string, string> TranslationRules = new();
private sealed class ResourcePackTranslationLayer(string packIdentifier, Dictionary<string, string> translations)
{
public string PackIdentifier { get; } = packIdentifier;
public Dictionary<string, string> 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<string, string> Translations { get; init; } = [];
}
private const long MaxResourcePackDownloadBytes = 256L * 1024 * 1024;
private const int ResourcePackDownloadBufferSize = 81920;
private const string ResourcePackTranslationCacheVersion = "1";
private static readonly List<ResourcePackTranslationLayer> ResourcePackTranslationLayers = [];
private static readonly HttpClient ResourcePackHttpClient = new();
/// <summary>
/// Initialize translation rules.
/// Necessary for properly printing some chat messages.
/// </summary>
public static void InitTranslations()
{
ResourcePackTranslationLayers.Clear();
if (!RulesInitialized)
{
InitRules();
@ -379,10 +405,72 @@ namespace MinecraftClient.Protocol.Message
public static string? TranslateString(string rulename)
{
if (TranslationRules.TryGetValue(rulename, out string? result))
return result;
else
return null;
return TryGetTranslationRule(rulename, out string? result) ? result : null;
}
public static void LoadResourcePackTranslations(string packIdentifier, string url, string hash)
{
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<string, string>? cachedTranslations))
{
ReplaceResourcePackTranslations(packIdentifier, cachedTranslations);
return;
}
string temporaryFilePath = Path.GetTempFileName();
try
{
DownloadResourcePack(resourcePackUri, hash, temporaryFilePath);
using FileStream resourcePackFile = File.OpenRead(temporaryFilePath);
Dictionary<string, string> resourcePackTranslations = ExtractResourcePackTranslations(resourcePackFile);
ReplaceResourcePackTranslations(packIdentifier, resourcePackTranslations);
SaveCachedResourcePackTranslations(cacheFilePath, resourcePackUri, hash, resourcePackTranslations);
}
catch (HttpRequestException)
{
}
catch (IOException)
{
}
catch (InvalidDataException)
{
}
catch (JsonException)
{
}
finally
{
try
{
File.Delete(temporaryFilePath);
}
catch (IOException)
{
}
}
}
public static void RemoveResourcePackTranslations(string packIdentifier)
{
ResourcePackTranslationLayers.RemoveAll(layer =>
layer.PackIdentifier.Equals(packIdentifier, StringComparison.Ordinal));
}
public static void ClearResourcePackTranslations()
{
ResourcePackTranslationLayers.Clear();
}
/// <summary>
@ -400,10 +488,9 @@ namespace MinecraftClient.Protocol.Message
RulesInitialized = true;
}
if (TranslationRules.ContainsKey(rulename))
if (TryGetTranslationRule(rulename, out string? rule))
{
int using_idx = 0;
string rule = TranslationRules[rulename];
StringBuilder result = new();
for (int i = 0; i < rule.Length; i++)
{
@ -445,6 +532,211 @@ namespace MinecraftClient.Protocol.Message
else return "[" + rulename + "] " + string.Join(" ", using_data);
}
private static bool TryGetTranslationRule(string rulename, out string? result)
{
for (int i = ResourcePackTranslationLayers.Count - 1; i >= 0; i--)
{
if (ResourcePackTranslationLayers[i].Translations.TryGetValue(rulename, out result))
return true;
}
return TranslationRules.TryGetValue(rulename, out result);
}
private static void DownloadResourcePack(Uri resourcePackUri, string hash, string temporaryFilePath)
{
using HttpResponseMessage response =
ResourcePackHttpClient.GetAsync(resourcePackUri, HttpCompletionOption.ResponseHeadersRead).GetAwaiter().GetResult();
response.EnsureSuccessStatusCode();
using Stream resourcePackStream = response.Content.ReadAsStream();
using FileStream temporaryFile = File.Create(temporaryFilePath);
using IncrementalHash incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
byte[] buffer = new byte[ResourcePackDownloadBufferSize];
long totalBytes = 0;
while (true)
{
int bytesRead = resourcePackStream.Read(buffer, 0, buffer.Length);
if (bytesRead <= 0)
break;
totalBytes += bytesRead;
if (totalBytes > MaxResourcePackDownloadBytes)
throw new InvalidDataException();
temporaryFile.Write(buffer, 0, bytesRead);
if (hash.Length == 40)
incrementalHash.AppendData(buffer, 0, bytesRead);
}
if (hash.Length == 40)
{
string downloadedHash = Convert.ToHexString(incrementalHash.GetHashAndReset());
if (!downloadedHash.Equals(hash, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"Resource pack hash mismatch for {resourcePackUri}. Expected {hash}, got {downloadedHash}.");
}
}
private static Dictionary<string, string> ExtractResourcePackTranslations(Stream resourcePackStream)
{
var mergedTranslations = new Dictionary<string, string>(StringComparer.Ordinal);
var selectedLanguageTranslations = new Dictionary<string, string>(StringComparer.Ordinal);
string selectedLanguage = Config.Main.Advanced.Language;
using ZipArchive archive = new(resourcePackStream, ZipArchiveMode.Read, leaveOpen: true);
foreach (ZipArchiveEntry entry in archive.Entries)
{
if (!TryGetResourcePackLanguage(entry.FullName, out string? language))
continue;
if (language.Equals("en_us", StringComparison.OrdinalIgnoreCase))
{
MergeResourcePackTranslations(entry, mergedTranslations);
}
else if (language.Equals(selectedLanguage, StringComparison.OrdinalIgnoreCase))
{
MergeResourcePackTranslations(entry, selectedLanguageTranslations);
}
}
foreach (var entry in selectedLanguageTranslations)
mergedTranslations[entry.Key] = entry.Value;
return mergedTranslations;
}
private static bool TryGetResourcePackLanguage(string entryPath, out string? language)
{
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;
}
language = Path.GetFileNameWithoutExtension(pathParts[3]);
return !string.IsNullOrEmpty(language);
}
private static void MergeResourcePackTranslations(ZipArchiveEntry entry, Dictionary<string, string> translations)
{
using Stream entryStream = entry.Open();
Dictionary<string, string>? entryTranslations =
JsonSerializer.Deserialize<Dictionary<string, string>>(entryStream);
if (entryTranslations is null)
return;
foreach (var (key, value) in entryTranslations)
translations[key] = value;
}
private static void ReplaceResourcePackTranslations(string packIdentifier, Dictionary<string, string> 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<string, string>? translations)
{
translations = null;
if (!File.Exists(cacheFilePath))
return false;
try
{
using FileStream cacheFile = File.OpenRead(cacheFilePath);
ResourcePackTranslationCacheEntry? cacheEntry =
JsonSerializer.Deserialize<ResourcePackTranslationCacheEntry>(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<string, string>(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<string, string> translations)
{
if (translations.Count == 0)
return;
string? cacheDirectory = Path.GetDirectoryName(cacheFilePath);
if (string.IsNullOrEmpty(cacheDirectory))
return;
Directory.CreateDirectory(cacheDirectory);
ResourcePackTranslationCacheEntry cacheEntry = new()
{
CacheVersion = ResourcePackTranslationCacheVersion,
Language = Config.Main.Advanced.Language,
SourceUrl = resourcePackUri.AbsoluteUri,
SourceHash = hash,
Translations = new Dictionary<string, string>(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);
}
/// <summary>
/// Mapping from JSON/NBT property names to Minecraft formatting codes (without §).
/// Both "underlined" (canonical Minecraft name) and "underline" (alias) are supported.

View file

@ -684,6 +684,9 @@ Usage examples: "/tell &lt;mybot&gt; reco Player2", "/connect &lt;serverip&gt; P
<data name="Main.Advanced.LoadMccTrans" xml:space="preserve">
<value>Load translations applied to MCC when available, turn it off to use English only.</value>
</data>
<data name="Main.Advanced.load_resourcepack_translations" xml:space="preserve">
<value>Load translations from server resource packs and cache extracted language entries locally for faster reuse.</value>
</data>
<data name="Main.Advanced.mc_forge" xml:space="preserve">
<value>Use "auto", "no" or "force". Force-enabling only works for MC 1.13+.</value>
</data>

View file

@ -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";

View file

@ -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:**