Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java
Original file line number Diff line number Diff line change
Expand Up @@ -114,31 +114,40 @@ private void generateOptionsTxt() {
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
String region = locale.getCountry();

if ("en".equals(LocaleUtils.getRootLanguage(locale))) {
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
return "en_UD";
}

return "";
}

if ("zh".equals(LocaleUtils.getRootLanguage(locale))) {
if ("lzh".equals(locale.getLanguage())) {
return gameVersion.compareTo("1.16") >= 0 ? "lzh" : "zh_TW";
}

if ("Hant".equals(LocaleUtils.getScript(locale))) {
if (region.equals("HK") || region.equals("MO")) {
return gameVersion.compareTo("1.16") >= 0 ? "zh_HK" : "zh_TW";
}
return "zh_TW";
}

return "zh_CN";
}

String languageTag = LocaleUtils.getMinecraftLanguageTag(locale);
if (languageTag != null && languageTag.contains("_")) {
return languageTag;
}

return switch (LocaleUtils.getRootLanguage(locale)) {
case "ar" -> "ar_SA";
case "es" -> "es_ES";
case "ja" -> "ja_JP";
case "ru" -> "ru_RU";
case "uk" -> "uk_UA";
case "zh" -> {
if ("lzh".equals(locale.getLanguage()) && gameVersion.compareTo("1.16") >= 0)
yield "lzh";

String script = LocaleUtils.getScript(locale);
if ("Hant".equals(script)) {
if ((region.equals("HK") || region.equals("MO") && gameVersion.compareTo("1.16") >= 0))
yield "zh_HK";
yield "zh_TW";
}
yield "zh_CN";
}
case "en" -> {
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
yield "en_UD";
}

yield "";
}
default -> "";
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
Expand Down Expand Up @@ -109,7 +110,7 @@ protected Skin<?> createDefaultSkin() {
public void loadVersion(Profile profile, String version) {
this.profile = profile;
this.instanceId = version;
this.resourcePackManager = new ResourcePackManager(profile.getRepository(), version);
this.resourcePackManager = new ResourcePackManager(profile.getRepository(), version, I18n.getLocale().getLocale());
this.resourcePackDirectory = this.resourcePackManager.getDirectory();

refresh();
Expand Down
132 changes: 74 additions & 58 deletions HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,65 @@ public record PackMcMeta(@SerializedName("pack") PackInfo pack) implements Valid

private static final Gson LENIENT_GSON = JsonUtils.defaultGsonBuilder().setStrictness(Strictness.LENIENT).create();

private static List<LocalAddonFile.Description.Part> pairToPart(List<Pair<String, String>> lists, String color) {
List<LocalAddonFile.Description.Part> parts = new ArrayList<>();
for (Pair<String, String> list : lists) {
parts.add(new LocalAddonFile.Description.Part(list.getKey(), list.getValue().isEmpty() ? color : list.getValue()));
}
return parts;
}

private static void parseComponent(JsonElement element, List<LocalAddonFile.Description.Part> parts, String parentColor) throws JsonParseException {
if (parentColor == null) {
parentColor = "";
}
String color = parentColor;
if (element instanceof JsonPrimitive primitive) {
parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color));
} else if (element instanceof JsonObject jsonObj) {
if (jsonObj.get("color") instanceof JsonPrimitive primitive) {
color = primitive.getAsString();
}
if (jsonObj.get("text") instanceof JsonPrimitive primitive) {
parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color));
}
if (jsonObj.get("extra") instanceof JsonArray jsonArray) {
parseComponent(jsonArray, parts, color);
}
} else if (element instanceof JsonArray jsonArray) {
if (!jsonArray.isEmpty() && jsonArray.get(0) instanceof JsonObject jsonObj && jsonObj.get("color") instanceof JsonPrimitive primitive) {
color = primitive.getAsString();
}

for (JsonElement childElement : jsonArray) {
parseComponent(childElement, parts, color);
}
} else {
LOG.warning("Skipping unsupported element in description. Expected a string, object, or array, but got type " + element.getClass().getSimpleName() + ". Value: " + element);
}
}

public static LocalAddonFile.Description parseDescription(JsonElement json) throws JsonParseException {
List<LocalAddonFile.Description.Part> parts = new ArrayList<>();

if (json == null || json.isJsonNull()) {
return new LocalAddonFile.Description(parts);
}

try {
parseComponent(json, parts, "");
} catch (JsonParseException | IllegalStateException e) {
parts.clear();
LOG.warning("An unexpected error occurred while parsing a description component. The description may be incomplete.", e);
}

return new LocalAddonFile.Description(parts);
}

public static LocalAddonFile.Description parseDescription(String text) {
return parseDescription(new JsonPrimitive(text));
}

public static PackMcMeta fromNonNullJson(String jsonString) throws JsonParseException {
PackMcMeta parsed = LENIENT_GSON.fromJson(jsonString, PackMcMeta.class);
if (parsed == null)
Expand All @@ -58,6 +117,20 @@ public static PackMcMeta fromNonNullJsonFile(Path jsonFile) throws JsonParseExce
}
}

public PackMcMeta withDescription(LocalAddonFile.Description description) {
if (pack == null) {
return this;
}

return new PackMcMeta(new PackInfo(
pack.packFormat(),
pack.supportedFormats(),
pack.minPackVersion(),
pack.maxPackVersion(),
description
));
}

@Override
public void validate() throws JsonParseException {
if (pack == null)
Expand Down Expand Up @@ -177,62 +250,6 @@ public static PackVersion fromJson(JsonElement element, boolean anyMinor) throws
}

public static final class PackInfoDeserializer implements JsonDeserializer<PackInfo> {

private List<LocalAddonFile.Description.Part> pairToPart(List<Pair<String, String>> lists, String color) {
List<LocalAddonFile.Description.Part> parts = new ArrayList<>();
for (Pair<String, String> list : lists) {
parts.add(new LocalAddonFile.Description.Part(list.getKey(), list.getValue().isEmpty() ? color : list.getValue()));
}
return parts;
}

private void parseComponent(JsonElement element, List<LocalAddonFile.Description.Part> parts, String parentColor) throws JsonParseException {
if (parentColor == null) {
parentColor = "";
}
String color = parentColor;
if (element instanceof JsonPrimitive primitive) {
parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color));
} else if (element instanceof JsonObject jsonObj) {
if (jsonObj.get("color") instanceof JsonPrimitive primitive) {
color = primitive.getAsString();
}
if (jsonObj.get("text") instanceof JsonPrimitive primitive) {
parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color));
}
if (jsonObj.get("extra") instanceof JsonArray jsonArray) {
parseComponent(jsonArray, parts, color);
}
} else if (element instanceof JsonArray jsonArray) {
if (!jsonArray.isEmpty() && jsonArray.get(0) instanceof JsonObject jsonObj && jsonObj.get("color") instanceof JsonPrimitive primitive) {
color = primitive.getAsString();
}

for (JsonElement childElement : jsonArray) {
parseComponent(childElement, parts, color);
}
} else {
LOG.warning("Skipping unsupported element in description. Expected a string, object, or array, but got type " + element.getClass().getSimpleName() + ". Value: " + element);
}
}

private List<LocalAddonFile.Description.Part> parseDescription(JsonElement json) throws JsonParseException {
List<LocalAddonFile.Description.Part> parts = new ArrayList<>();

if (json == null || json.isJsonNull()) {
return parts;
}

try {
parseComponent(json, parts, "");
} catch (JsonParseException | IllegalStateException e) {
parts.clear();
LOG.warning("An unexpected error occurred while parsing a description component. The description may be incomplete.", e);
}

return parts;
}

@Override
public PackInfo deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
JsonObject packInfo = json.getAsJsonObject();
Expand All @@ -246,8 +263,7 @@ public PackInfo deserialize(JsonElement json, Type typeOfT, JsonDeserializationC
PackVersion minVersion = PackVersion.fromJson(packInfo.get("min_format"), false);
PackVersion maxVersion = PackVersion.fromJson(packInfo.get("max_format"), true);

List<LocalAddonFile.Description.Part> parts = parseDescription(packInfo.get("description"));
return new PackInfo(packFormat, supportedFormats, minVersion, maxVersion, new LocalAddonFile.Description(parts));
return new PackInfo(packFormat, supportedFormats, minVersion, maxVersion, PackMcMeta.parseDescription(packInfo.get("description")));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.resourcepack;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.mod.LocalAddonFile;
import org.jackhuang.hmcl.mod.modinfo.PackMcMeta;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.i18n.MinecraftTranslatedTextResolver;
import org.jackhuang.hmcl.util.tree.ArchiveFileTree;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;

final class ResourcePackDescriptionResolver {
private ResourcePackDescriptionResolver() {
}

static @Nullable LocalAddonFile.Description resolveFromFolder(Path root, Locale locale) throws IOException {
Path mcmeta = root.resolve("pack.mcmeta");
String mcmetaText = Files.readString(mcmeta);
Comment thread
pynickle marked this conversation as resolved.
return resolve(mcmetaText, locale, new FolderTranslationLookup(root));
}

static @Nullable LocalAddonFile.Description resolveFromZip(ZipFileTree tree, Locale locale) throws IOException {
String mcmetaText = tree.readTextEntry("/pack.mcmeta");
return resolve(mcmetaText, locale, new ZipTranslationLookup(tree));
}

static @Nullable LocalAddonFile.Description resolve(String mcmetaText, Locale locale, MinecraftTranslatedTextResolver.TranslationLookup translationLookup) {
JsonObject json = JsonUtils.fromMaybeMalformedJson(mcmetaText, JsonObject.class);
if (json == null) {
return null;
}

JsonObject pack = getJsonObject(json, "pack");
if (pack == null) {
return null;
}

JsonElement description = pack.get("description");
if (description instanceof JsonObject descriptionObject && descriptionObject.has("translate")) {
String translated = MinecraftTranslatedTextResolver.resolve(descriptionObject, locale, translationLookup);
return translated != null ? PackMcMeta.parseDescription(translated) : null;
}

return PackMcMeta.parseDescription(description);
}

private static @Nullable JsonObject getJsonObject(JsonObject object, String memberName) {
JsonElement element = object.get(memberName);
return element instanceof JsonObject jsonObject ? jsonObject : null;
}

private static final class FolderTranslationLookup implements MinecraftTranslatedTextResolver.TranslationLookup {
private final Path root;

private FolderTranslationLookup(Path root) {
this.root = root;
}

@Override
public @Unmodifiable List<String> listNamespaces() throws IOException {
Path assets = root.resolve("assets");
if (!Files.isDirectory(assets)) {
return List.of();
}

try (var stream = Files.list(assets)) {
return stream
.filter(Files::isDirectory)
.map(path -> path.getFileName().toString())
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
}

@Override
public @Nullable String findTranslation(String namespace, String languageFileName, String key) throws IOException {
Path langFile = root.resolve("assets").resolve(namespace).resolve("lang").resolve(languageFileName);
if (!Files.isRegularFile(langFile)) {
return null;
}

Map<String, String> translations = JsonUtils.fromJsonFile(langFile, JsonUtils.mapTypeOf(String.class, String.class));
return translations != null ? translations.get(key) : null;
}
}

private static final class ZipTranslationLookup implements MinecraftTranslatedTextResolver.TranslationLookup {
private final ZipFileTree tree;

private ZipTranslationLookup(ZipFileTree tree) {
this.tree = tree;
}

@Override
public @Unmodifiable List<String> listNamespaces() {
ArchiveFileTree.Dir<ZipArchiveEntry> assets = tree.getDirectory("assets");
if (assets == null) {
return List.of();
}

return assets.getSubDirs().keySet().stream()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}

@Override
public @Nullable String findTranslation(String namespace, String languageFileName, String key) throws IOException {
String path = "assets/" + namespace + "/lang/" + languageFileName;
ZipArchiveEntry entry = tree.getEntry(path);
if (entry == null) {
return null;
}

Map<String, String> translations = JsonUtils.fromNonNullJson(tree.readTextEntry(entry), JsonUtils.mapTypeOf(String.class, String.class));
return translations.get(key);
Comment thread
pynickle marked this conversation as resolved.
Comment thread
pynickle marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ public ResourcePackFolder(ResourcePackManager manager, Path path) {

PackMcMeta meta = null;
try {
meta = PackMcMeta.fromNonNullJsonFile(path.resolve("pack.mcmeta"));
meta = PackMcMeta.fromNonNullJsonFile(path.resolve("pack.mcmeta"))
.withDescription(ResourcePackDescriptionResolver.resolveFromFolder(path, manager.getLocale()));
} catch (Exception e) {
LOG.warning("Failed to parse resource pack meta", e);
}
Expand Down
Loading