mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
feat: Load translation keys from server resource packs in chat parsing
This commit is contained in:
commit
effc05aec3
5 changed files with 332 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -633,4 +925,4 @@ namespace MinecraftClient.Protocol.Message
|
|||
return formatting + message + extraBuilder.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -684,6 +684,9 @@ Usage examples: "/tell <mybot> reco Player2", "/connect <serverip> 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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue