feat: load chat translations from server resource packs

Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/d7467a13-d1ff-4cd6-a332-1ac389c445da

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-04 12:05:55 +00:00 committed by GitHub
parent 9c47456455
commit 34f45543c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 193 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,7 @@ namespace MinecraftClient.Protocol.Handlers
var url = dataTypes.ReadNextString(packetData);
var hash = dataTypes.ReadNextString(packetData);
string packIdentifier = uuid != Guid.Empty ? uuid.ToString("D") : (hash.Length == 40 ? hash : url);
if (protocolVersion >= MC_1_17_Version)
{
@ -740,6 +743,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 +2452,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,24 @@ 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 const long MaxResourcePackDownloadBytes = 256L * 1024 * 1024;
private static readonly List<ResourcePackTranslationLayer> ResourcePackTranslationLayers = [];
/// <summary>
/// Initialize translation rules.
/// Necessary for properly printing some chat messages.
/// </summary>
public static void InitTranslations()
{
ResourcePackTranslationLayers.Clear();
if (!RulesInitialized)
{
InitRules();
@ -379,10 +393,60 @@ 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 (!Uri.TryCreate(url, UriKind.Absolute, out Uri? resourcePackUri)
|| resourcePackUri.Scheme is not "http" and not "https")
{
return;
}
string temporaryFilePath = Path.GetTempFileName();
try
{
DownloadResourcePack(resourcePackUri, hash, temporaryFilePath);
using FileStream resourcePackFile = File.OpenRead(temporaryFilePath);
LoadResourcePackTranslations(packIdentifier, resourcePackFile);
}
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 +464,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 +508,120 @@ 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 HttpClient httpClient = new();
using HttpResponseMessage response =
httpClient.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[81920];
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();
}
}
private static void LoadResourcePackTranslations(string packIdentifier, Stream resourcePackStream)
{
var fallbackTranslations = 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, fallbackTranslations);
}
else if (language.Equals(selectedLanguage, StringComparison.OrdinalIgnoreCase))
{
MergeResourcePackTranslations(entry, selectedLanguageTranslations);
}
}
foreach (var entry in selectedLanguageTranslations)
fallbackTranslations[entry.Key] = entry.Value;
RemoveResourcePackTranslations(packIdentifier);
if (fallbackTranslations.Count > 0)
ResourcePackTranslationLayers.Add(new ResourcePackTranslationLayer(packIdentifier, fallbackTranslations));
}
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;
}
/// <summary>
/// Mapping from JSON/NBT property names to Minecraft formatting codes (without §).
/// Both "underlined" (canonical Minecraft name) and "underline" (alias) are supported.
@ -633,4 +810,4 @@ namespace MinecraftClient.Protocol.Message
return formatting + message + extraBuilder.ToString();
}
}
}
}