From 59d4ac0f0696fca032034f0c73e72e1420b37db6 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:12:46 +0800 Subject: [PATCH 01/10] refactor: separate resource planning download and local assembly --- .../java/i18nupdatemod/I18nUpdateMod.java | 39 +++------- .../java/i18nupdatemod/core/I18nConfig.java | 12 ++- .../java/i18nupdatemod/core/ResourcePack.java | 18 ++--- .../core/ResourcePackConverter.java | 77 ++++++++++--------- .../core/ResourcePackDownloader.java | 28 +++++++ .../i18nupdatemod/entity/GameAssetDetail.java | 4 +- .../java/i18nupdatemod/util/FileUtil.java | 66 ++-------------- 7 files changed, 104 insertions(+), 140 deletions(-) create mode 100644 src/main/java/i18nupdatemod/core/ResourcePackDownloader.java diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index 3566e57..cab674e 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -4,11 +4,9 @@ import com.google.gson.JsonObject; import i18nupdatemod.core.GameConfig; import i18nupdatemod.core.I18nConfig; -import i18nupdatemod.core.ResourcePack; +import i18nupdatemod.core.ResourcePackDownloader; import i18nupdatemod.core.ResourcePackConverter; import i18nupdatemod.entity.GameAssetDetail; -import i18nupdatemod.entity.GameMetaData; -import i18nupdatemod.util.FileUtil; import i18nupdatemod.util.Log; import org.jetbrains.annotations.NotNull; @@ -16,11 +14,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; import java.util.stream.Stream; public class I18nUpdateMod { @@ -56,34 +52,27 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } catch (ClassNotFoundException ignored) { } - FileUtil.setResourcePackDirPath(minecraftPath.resolve("resourcepacks")); - int minecraftMajorVersion = Integer.parseInt(minecraftVersion.split("\\.")[1]); try { //Get asset - GameAssetDetail assets = I18nConfig.getAssetDetail(minecraftVersion, loader); + GameAssetDetail plan = I18nConfig.getAssetDetail(minecraftVersion, loader); //Update resource pack - List languagePacks = new ArrayList<>(); - for (GameAssetDetail.AssetDownloadDetail it : assets.downloads) { - FileUtil.setTemporaryDirPath(Paths.get(localStorage, "." + MOD_ID, it.targetVersion)); - ResourcePack languagePack = new ResourcePack(it.fileName); - languagePack.checkUpdate(it.fileUrl, it.md5Url); - languagePacks.add(languagePack); - } + Path resourcePackDirectory = minecraftPath.resolve("resourcepacks"); + Path cacheRoot = Paths.get(localStorage, "." + MOD_ID); + List sourcePaths = ResourcePackDownloader.download(plan.downloads, resourcePackDirectory, cacheRoot); //Convert resourcepack - FileUtil.setTemporaryDirPath(Paths.get(localStorage, "." + MOD_ID, minecraftVersion)); - String applyFileName = assets.covertFileName; - GameMetaData metaData = I18nConfig.getPackFormat(minecraftVersion); - ResourcePackConverter converter = new ResourcePackConverter(languagePacks, applyFileName); - converter.convert(metaData, getResourcePackDescription(assets.downloads), modDomainsSet); + Path convertedCache = cacheRoot.resolve(minecraftVersion).resolve(plan.convertedFileName); + Path convertedOutput = resourcePackDirectory.resolve(plan.convertedFileName); + ResourcePackConverter converter = new ResourcePackConverter(sourcePaths, convertedCache, convertedOutput); + Path convertedPack = converter.convert(plan.packMetaData, plan.description, modDomainsSet); //Apply resource pack GameConfig config = new GameConfig(minecraftPath.resolve("options.txt")); config.addResourcePack("Minecraft-Mod-Language-Modpack", - (minecraftMajorVersion <= 12 ? "" : "file/") + applyFileName); + (minecraftMajorVersion <= 12 ? "" : "file/") + convertedPack.getFileName().toString()); config.writeToFile(); } catch (Exception e) { Log.warning(String.format("Failed to update resource pack: %s", e)); @@ -91,14 +80,6 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } } - private static String getResourcePackDescription(List downloads) { - return downloads.size() > 1 ? - String.format("该包由%s版本合并\n作者:CFPA团队及汉化项目贡献者", - downloads.stream().map(it -> it.targetVersion).collect(Collectors.joining("和"))) : - String.format("该包对应的官方支持版本为%s\n作者:CFPA团队及汉化项目贡献者", - downloads.get(0).targetVersion); - - } public static String getLocalStoragePos(Path minecraftPath) { Path userHome = Paths.get(System.getProperty("user.home")); diff --git a/src/main/java/i18nupdatemod/core/I18nConfig.java b/src/main/java/i18nupdatemod/core/I18nConfig.java index 56203d1..8e1bcd3 100644 --- a/src/main/java/i18nupdatemod/core/I18nConfig.java +++ b/src/main/java/i18nupdatemod/core/I18nConfig.java @@ -71,13 +71,19 @@ public static GameAssetDetail getAssetDetail(String minecraftVersion, String loa ret.downloads = createDownloadDetails(convert, loader, assetRoot); } - ret.covertFileName = + ret.packMetaData = convert; + ret.description = getResourcePackDescription(ret.downloads); + ret.convertedFileName = String.format("Minecraft-Mod-Language-Modpack-Converted-%s.zip", minecraftVersion); return ret; } - public static GameMetaData getPackFormat(String minecraftVersion) { - return getGameMetaData(minecraftVersion); + private static String getResourcePackDescription(List downloads) { + return downloads.size() > 1 ? + String.format("该包由%s版本合并\n作者:CFPA团队及汉化项目贡献者", + downloads.stream().map(it -> it.targetVersion).collect(Collectors.joining("和"))) : + String.format("该包对应的官方支持版本为%s\n作者:CFPA团队及汉化项目贡献者", + downloads.get(0).targetVersion); } private static List createDownloadDetails(GameMetaData convert, String loader, String assetRoot) { diff --git a/src/main/java/i18nupdatemod/core/ResourcePack.java b/src/main/java/i18nupdatemod/core/ResourcePack.java index 91e1700..7e864f8 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePack.java +++ b/src/main/java/i18nupdatemod/core/ResourcePack.java @@ -19,18 +19,15 @@ public class ResourcePack { * Limit update check frequency */ private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); - private final String filename; private final Path filePath; private final Path tmpFilePath; private String remoteMd5; - public ResourcePack(String filename) { - //If target version is not current version, not save - this.filename = filename; - this.filePath = FileUtil.getResourcePackPath(filename); - this.tmpFilePath = FileUtil.getTemporaryPath(filename); + public ResourcePack(Path filePath, Path tmpFilePath) { + this.filePath = filePath; + this.tmpFilePath = tmpFilePath; try { - FileUtil.syncTmpFile(filePath, tmpFilePath); + FileUtil.syncIfNewer(filePath, tmpFilePath); } catch (Exception e) { Log.warning( String.format("Error while sync temp file %s <-> %s: %s", filePath, tmpFilePath, e)); @@ -74,7 +71,7 @@ private boolean checkMd5(Path localFile, String md5Url) throws IOException, URIS private void downloadFull(String fileUrl, String md5Url) throws IOException { try { - Path downloadTmp = FileUtil.getTemporaryPath(filename + ".tmp"); + Path downloadTmp = tmpFilePath.resolveSibling(tmpFilePath.getFileName().toString() + ".tmp"); AssetUtil.download(fileUrl, downloadTmp); if (!checkMd5(downloadTmp, md5Url)) { throw new IOException("Download MD5 not match"); @@ -87,14 +84,11 @@ private void downloadFull(String fileUrl, String md5Url) throws IOException { if (!Files.exists(tmpFilePath)) { throw new FileNotFoundException("Tmp file not found."); } - FileUtil.syncTmpFile(filePath, tmpFilePath); + FileUtil.syncIfNewer(filePath, tmpFilePath); } public Path getTmpFilePath() { return tmpFilePath; } - public String getFilename() { - return filename; - } } diff --git a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java index ea511e3..2c1cc19 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java +++ b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java @@ -16,7 +16,6 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; @@ -27,52 +26,56 @@ public class ResourcePackConverter { private final Path filePath; private final Path tmpFilePath; - public ResourcePackConverter(List resourcePack, String filename) { - this.sourcePath = resourcePack.stream().map(ResourcePack::getTmpFilePath).collect(Collectors.toList()); - this.filePath = FileUtil.getResourcePackPath(filename); - this.tmpFilePath = FileUtil.getTemporaryPath(filename); + public ResourcePackConverter(List sourcePaths, Path tmpFilePath, Path filePath) { + this.sourcePath = sourcePaths; + this.tmpFilePath = tmpFilePath; + this.filePath = filePath; } - public void convert(GameMetaData metaData, String description, HashSet modDomainsSet) throws Exception { + public Path convert(GameMetaData metaData, String description, HashSet modDomainsSet) throws Exception { + FileUtil.safeCreateDir(tmpFilePath.getParent()); + FileUtil.safeCreateDir(filePath.getParent()); Set fileList = new HashSet<>(); - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tmpFilePath), StandardCharsets.UTF_8)) { -// zos.setMethod(ZipOutputStream.STORED); - for (Path p : sourcePath) { - Log.info("Converting: " + p); - try (ZipFile zf = new ZipFile(p.toFile(), StandardCharsets.UTF_8)) { - for (Enumeration e = zf.entries(); e.hasMoreElements(); ) { - ZipEntry ze = e.nextElement(); - String name = ze.getName(); - String[] parts = name.split("/"); - // 正在筛选的是assets/modDomain/** && 当前的modDomain不需要 - if (parts.length >= 2 && !modDomainsSet.contains(parts[1])) { - continue; - } + try { + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tmpFilePath), StandardCharsets.UTF_8)) { + // zos.setMethod(ZipOutputStream.STORED); + for (Path p : sourcePath) { + Log.info("Converting: " + p); + try (ZipFile zf = new ZipFile(p.toFile(), StandardCharsets.UTF_8)) { + for (Enumeration e = zf.entries(); e.hasMoreElements(); ) { + ZipEntry ze = e.nextElement(); + String name = ze.getName(); + String[] parts = name.split("/"); + // 正在筛选的是assets/modDomain/** && 当前的modDomain不需要 + if (parts.length >= 2 && !modDomainsSet.contains(parts[1])) { + continue; + } - // Don't put same file - if (fileList.contains(name)) { -// Log.debug(name + ": DUPLICATE"); - continue; - } - fileList.add(name); + // Don't put same file + if (fileList.contains(name)) { + // Log.debug(name + ": DUPLICATE"); + continue; + } + fileList.add(name); - // Put file into new zip - zos.putNextEntry(new ZipEntry(name)); - InputStream is = zf.getInputStream(ze); - if (name.equalsIgnoreCase("pack.mcmeta")) { - //Convert pack.mcmeta - zos.write(convertPackMeta(is, metaData, description)); - } else { - //Copy other file - IOUtils.copy(is, zos); + // Put file into new zip + zos.putNextEntry(new ZipEntry(name)); + InputStream is = zf.getInputStream(ze); + if (name.equalsIgnoreCase("pack.mcmeta")) { + //Convert pack.mcmeta + zos.write(convertPackMeta(is, metaData, description)); + } else { + //Copy other file + IOUtils.copy(is, zos); + } + zos.closeEntry(); } - zos.closeEntry(); } } } - zos.close(); Log.info("Converted: %s -> %s", sourcePath, tmpFilePath); - FileUtil.syncTmpFile(tmpFilePath, filePath); + FileUtil.syncIfNewer(tmpFilePath, filePath); + return filePath; } catch (Exception e) { throw new Exception(String.format("Error converting %s to %s: %s", sourcePath, tmpFilePath, e)); } diff --git a/src/main/java/i18nupdatemod/core/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/ResourcePackDownloader.java new file mode 100644 index 0000000..7eb79af --- /dev/null +++ b/src/main/java/i18nupdatemod/core/ResourcePackDownloader.java @@ -0,0 +1,28 @@ +package i18nupdatemod.core; + +import i18nupdatemod.entity.GameAssetDetail; +import i18nupdatemod.util.FileUtil; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; + +public class ResourcePackDownloader { + public static List download(List downloads, + Path resourcePackDirectory, Path cacheRoot) + throws IOException, URISyntaxException, NoSuchAlgorithmException { + FileUtil.safeCreateDir(resourcePackDirectory); + List sourcePaths = new ArrayList<>(downloads.size()); + for (GameAssetDetail.AssetDownloadDetail item : downloads) { + Path cachePath = cacheRoot.resolve(item.targetVersion).resolve(item.fileName); + FileUtil.safeCreateDir(cachePath.getParent()); + ResourcePack resourcePack = new ResourcePack(resourcePackDirectory.resolve(item.fileName), cachePath); + resourcePack.checkUpdate(item.fileUrl, item.md5Url); + sourcePaths.add(resourcePack.getTmpFilePath()); + } + return sourcePaths; + } +} diff --git a/src/main/java/i18nupdatemod/entity/GameAssetDetail.java b/src/main/java/i18nupdatemod/entity/GameAssetDetail.java index 89bf31a..2b53779 100644 --- a/src/main/java/i18nupdatemod/entity/GameAssetDetail.java +++ b/src/main/java/i18nupdatemod/entity/GameAssetDetail.java @@ -4,7 +4,9 @@ public class GameAssetDetail { public List downloads; - public String covertFileName; + public String convertedFileName; + public GameMetaData packMetaData; + public String description; public static class AssetDownloadDetail { public String fileName; diff --git a/src/main/java/i18nupdatemod/util/FileUtil.java b/src/main/java/i18nupdatemod/util/FileUtil.java index 8c5f818..f06ae65 100644 --- a/src/main/java/i18nupdatemod/util/FileUtil.java +++ b/src/main/java/i18nupdatemod/util/FileUtil.java @@ -6,20 +6,7 @@ import java.nio.file.StandardCopyOption; public class FileUtil { - private static Path resourcePackDirPath; - private static Path temporaryDirPath; - - public static void setResourcePackDirPath(Path path) { - safeCreateDir(path); - resourcePackDirPath = path; - } - - public static void setTemporaryDirPath(Path temporaryDirPath) { - safeCreateDir(temporaryDirPath); - FileUtil.temporaryDirPath = temporaryDirPath; - } - - private static void safeCreateDir(Path path) { + public static void safeCreateDir(Path path) { try { if (!Files.isDirectory(path)) { Files.createDirectories(path); @@ -29,54 +16,17 @@ private static void safeCreateDir(Path path) { } } - public static Path getResourcePackPath(String filename) { - return resourcePackDirPath.resolve(filename); - } - - public static Path getTemporaryPath(String filename) { - return temporaryDirPath.resolve(filename); - } - - public static void syncTmpFile(Path filePath, Path tmpFilePath) throws IOException { - //Both temp and current file not found - if (!Files.exists(filePath) && !Files.exists(tmpFilePath)) { - Log.debug("Both temp and current file not found"); + public static void syncIfNewer(Path source, Path target) throws IOException { + if (!Files.exists(source)) { return; } - - int cmp = compareTmpFile(filePath, tmpFilePath); - Path from, to; - if (cmp == 0) { + if (Files.exists(target) + && Files.getLastModifiedTime(target).compareTo(Files.getLastModifiedTime(source)) >= 0) { Log.debug("Temp and current file has already been synchronized"); return; - } else if (cmp < 0) { - //Current file is newer - from = filePath; - to = tmpFilePath; - } else { - //Temp file is newer - from = tmpFilePath; - to = filePath; - } - - if (to == filePath) { - //Don't save to game - return; - } - - Files.copy(from, to, StandardCopyOption.REPLACE_EXISTING); - //Ensure same last modified time - Files.setLastModifiedTime(to, Files.getLastModifiedTime(from)); - Log.info(String.format("Synchronized: %s -> %s", from, to)); - } - - private static int compareTmpFile(Path filePath, Path tmpFilePath) throws IOException { - if (!Files.exists(filePath)) { - return 1; - } - if (!Files.exists(tmpFilePath)) { - return -1; } - return Files.getLastModifiedTime(tmpFilePath).compareTo(Files.getLastModifiedTime(filePath)); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + Files.setLastModifiedTime(target, Files.getLastModifiedTime(source)); + Log.info(String.format("Synchronized: %s -> %s", source, target)); } } From 03322ba0affef795a4ee0d964b23a2fc2aa2faa2 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:48:42 +0800 Subject: [PATCH 02/10] feat: add per-mod resource pipeline with isolated v1 and v2 flows --- .github/workflows/beta.yml | 2 + .github/workflows/common.yml | 18 +- .github/workflows/pull-request.yml | 6 +- .github/workflows/release.yml | 2 + build.gradle.kts | 49 +- .../java/i18nupdatemod/I18nUpdateMod.java | 24 +- .../java/i18nupdatemod/core/I18nConfig.java | 90 +- .../core/ResourcePackConverter.java | 44 +- .../core/ResourcePackDownloader.java | 28 - .../core/ResourcePackUpdater.java | 21 + .../core/v1/AssetDownloadDetail.java | 8 + .../{util => core/v1}/AssetUtil.java | 6 +- .../i18nupdatemod/core/v1/LegacyConfig.java | 73 ++ .../i18nupdatemod/core/v1/LegacyFileUtil.java | 24 + .../core/v1/LegacyResourcePackDownloader.java | 31 + .../core/{ => v1}/ResourcePack.java | 8 +- .../i18nupdatemod/core/v1/ResourcePackV1.java | 29 + .../i18nupdatemod/core/v2/ModIdentity.java | 165 +++ .../core/v2/ResourcePackDownloader.java | 481 +++++++++ .../i18nupdatemod/core/v2/ResourcePackV2.java | 54 + .../core/v2/TranslationArchive.java | 943 ++++++++++++++++++ .../i18nupdatemod/entity/GameAssetDetail.java | 10 +- .../i18nupdatemod/entity/ModTranslation.java | 35 + .../fabricloader/FabricLoaderMod.java | 30 +- .../launchwrapper/LaunchWrapperTweaker.java | 2 +- .../modlauncher/ModLauncherService.java | 2 +- .../java/i18nupdatemod/util/FileUtil.java | 15 - src/main/java/i18nupdatemod/util/ModUtil.java | 440 +++++++- 28 files changed, 2389 insertions(+), 251 deletions(-) delete mode 100644 src/main/java/i18nupdatemod/core/ResourcePackDownloader.java create mode 100644 src/main/java/i18nupdatemod/core/ResourcePackUpdater.java create mode 100644 src/main/java/i18nupdatemod/core/v1/AssetDownloadDetail.java rename src/main/java/i18nupdatemod/{util => core/v1}/AssetUtil.java (96%) create mode 100644 src/main/java/i18nupdatemod/core/v1/LegacyConfig.java create mode 100644 src/main/java/i18nupdatemod/core/v1/LegacyFileUtil.java create mode 100644 src/main/java/i18nupdatemod/core/v1/LegacyResourcePackDownloader.java rename src/main/java/i18nupdatemod/core/{ => v1}/ResourcePack.java (93%) create mode 100644 src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java create mode 100644 src/main/java/i18nupdatemod/core/v2/ModIdentity.java create mode 100644 src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java create mode 100644 src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java create mode 100644 src/main/java/i18nupdatemod/core/v2/TranslationArchive.java create mode 100644 src/main/java/i18nupdatemod/entity/ModTranslation.java diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index c95d11e..8c37067 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -10,4 +10,6 @@ jobs: uses: ./.github/workflows/common.yml with: type: Beta + build-task: buildRelease + artifact-path: build/libs/I18nUpdateMod-*-all.jar secrets: inherit \ No newline at end of file diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index fb41375..a14ed59 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -12,7 +12,14 @@ on: change-log: required: false type: string - + build-task: + required: false + type: string + default: buildRelease + artifact-path: + required: false + type: string + default: build/libs/I18nUpdateMod-*-all.jar jobs: build-common: name: Build Common @@ -34,10 +41,10 @@ jobs: env: IS_SNAPSHOT: ${{ inputs.is-snapshot }} run: | - ./gradlew clean shadowJar --info --stacktrace + ./gradlew clean ${{ inputs.build-task }} --info --stacktrace - name: Publish Modrinth - if: ${{ !inputs.is-snapshot }} + if: ${{ !inputs.is-snapshot && inputs.build-task == 'buildRelease' }} env: IS_SNAPSHOT: ${{ inputs.is-snapshot }} CHANGE_LOG: ${{ inputs.change-log }} @@ -46,7 +53,7 @@ jobs: ./gradlew modrinth modrinthSyncBody --info --stacktrace - name: Publish CurseForge - if: ${{ !inputs.is-snapshot }} + if: ${{ !inputs.is-snapshot && inputs.build-task == 'buildRelease' }} env: IS_SNAPSHOT: ${{ inputs.is-snapshot }} CHANGE_LOG: ${{ inputs.change-log }} @@ -57,4 +64,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: I18nUpdateMod-${{ inputs.type }}-${{ github.run_number }} - path: build/libs \ No newline at end of file + path: ${{ inputs.artifact-path }} + if-no-files-found: error \ No newline at end of file diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 39ccceb..b5602a0 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -7,10 +7,12 @@ on: - reopened branches: - 'main' - + workflow_dispatch: jobs: build-pull-request: name: Build Pull Request uses: ./.github/workflows/common.yml with: - type: PullRequest \ No newline at end of file + type: PullRequest + build-task: buildDebug + artifact-path: build/libs/I18nUpdateMod-*-debug.jar \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 492f174..e15fc75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,4 +11,6 @@ jobs: type: Release is-snapshot: false change-log: ${{ github.event.release.body }} + build-task: buildRelease + artifact-path: build/libs/I18nUpdateMod-*-all.jar secrets: inherit \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 12fcfd9..ffe18dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + plugins { id("java") id("com.github.johnrengelman.shadow") version "8.1.1" @@ -16,8 +18,7 @@ java { tasks.withType { options.encoding = "UTF-8" } - -tasks.shadowJar { +fun ShadowJar.configureI18nPackaging() { manifest { attributes( "TweakClass" to "i18nupdatemod.launchwrapper.LaunchWrapperTweaker", @@ -28,12 +29,52 @@ tasks.shadowJar { minimize() archiveBaseName.set("I18nUpdateMod") relocate("com.google.archivepatcher", "include.com.google.archivepatcher") + relocate("org.tukaani.xz", "include.org.tukaani.xz") + relocate("com.moandjiezana.toml", "include.com.moandjiezana.toml") dependencies { include(dependency("net.runelite.archive-patcher:archive-patcher-applier:.*")) + include(dependency("org.tukaani:xz:.*")) + include(dependency("com.moandjiezana.toml:toml4j:.*")) } exclude("LICENSE") } +tasks.shadowJar { + configureI18nPackaging() +} + +tasks.register("shadowJarDebug") { + from(sourceSets.main.get().output) + configurations = listOf(project.configurations.runtimeClasspath.get()) + archiveClassifier.set("debug") + configureI18nPackaging() +} + +mapOf( + "Release" to "http://downloader1.meitangdehulu.com:22943/", + "Debug" to "https://i18dl.imc.wiki/", +).forEach { (variant, baseUrl) -> + val configFile = layout.buildDirectory.file("generated/buildConfig/$variant/i18n-build.properties") + val generateConfig = tasks.register("generate${variant}Config") { + inputs.property("assetBaseUrl", baseUrl) + outputs.file(configFile) + doLast { + configFile.get().asFile.apply { + parentFile.mkdirs() + writeText("assetBaseUrl=$baseUrl\n") + } + } + } + val archive = tasks.named(if (variant == "Release") "shadowJar" else "shadowJarDebug") { + from(generateConfig) + } + tasks.register("build$variant") { + group = "build" + description = "Build the ${variant.lowercase()} artifact." + dependsOn(archive) + } +} + repositories { mavenCentral() maven("https://libraries.minecraft.net/") @@ -50,6 +91,8 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.3") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.3") implementation("net.runelite.archive-patcher:archive-patcher-applier:1.2") + implementation("org.tukaani:xz:1.10") + implementation("com.moandjiezana.toml:toml4j:0.7.2") compileOnly("org.jetbrains:annotations:24.1.0") implementation("net.fabricmc:fabric-loader:0.15.9") @@ -61,12 +104,12 @@ dependencies { implementation("com.google.code.gson:gson:2.11.0") } - tasks.test { useJUnitPlatform() } tasks.processResources { + exclude("i18n-build.properties") filesMatching("**") { expand( "version" to project.version, diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index cab674e..11694d2 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -3,10 +3,8 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import i18nupdatemod.core.GameConfig; -import i18nupdatemod.core.I18nConfig; -import i18nupdatemod.core.ResourcePackDownloader; -import i18nupdatemod.core.ResourcePackConverter; -import i18nupdatemod.entity.GameAssetDetail; +import i18nupdatemod.core.ResourcePackUpdater; +import i18nupdatemod.entity.ModTranslation; import i18nupdatemod.util.Log; import org.jetbrains.annotations.NotNull; @@ -14,7 +12,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.stream.Stream; @@ -25,14 +22,14 @@ public class I18nUpdateMod { public static final Gson GSON = new Gson(); - public static void init(Path minecraftPath, String minecraftVersion, String loader, @NotNull HashSet modDomainsSet) { + public static void init(Path minecraftPath, String minecraftVersion, String loader, @NotNull List mods) { try (InputStream is = I18nUpdateMod.class.getResourceAsStream("/i18nMetaData.json")) { MOD_VERSION = GSON.fromJson(new InputStreamReader(is), JsonObject.class).get("version").getAsString(); } catch (Exception e) { Log.warning("Error getting version: " + e); } - modDomainsSet.remove("i18nupdatemod"); + mods.removeIf(mod -> MOD_ID.equals(mod.namespace)); Log.info(String.format("I18nUpdate Mod %s is loaded in %s with %s", MOD_VERSION, minecraftVersion, loader)); Log.debug(String.format("Minecraft path: %s", minecraftPath)); @@ -55,19 +52,10 @@ public static void init(Path minecraftPath, String minecraftVersion, String load int minecraftMajorVersion = Integer.parseInt(minecraftVersion.split("\\.")[1]); try { - //Get asset - GameAssetDetail plan = I18nConfig.getAssetDetail(minecraftVersion, loader); - - //Update resource pack Path resourcePackDirectory = minecraftPath.resolve("resourcepacks"); Path cacheRoot = Paths.get(localStorage, "." + MOD_ID); - List sourcePaths = ResourcePackDownloader.download(plan.downloads, resourcePackDirectory, cacheRoot); - - //Convert resourcepack - Path convertedCache = cacheRoot.resolve(minecraftVersion).resolve(plan.convertedFileName); - Path convertedOutput = resourcePackDirectory.resolve(plan.convertedFileName); - ResourcePackConverter converter = new ResourcePackConverter(sourcePaths, convertedCache, convertedOutput); - Path convertedPack = converter.convert(plan.packMetaData, plan.description, modDomainsSet); + Path convertedPack = ResourcePackUpdater.update( + minecraftVersion, loader, mods, resourcePackDirectory, cacheRoot); //Apply resource pack GameConfig config = new GameConfig(minecraftPath.resolve("options.txt")); diff --git a/src/main/java/i18nupdatemod/core/I18nConfig.java b/src/main/java/i18nupdatemod/core/I18nConfig.java index 8e1bcd3..dff119b 100644 --- a/src/main/java/i18nupdatemod/core/I18nConfig.java +++ b/src/main/java/i18nupdatemod/core/I18nConfig.java @@ -1,7 +1,6 @@ package i18nupdatemod.core; import com.google.gson.Gson; -import i18nupdatemod.entity.AssetMetaData; import i18nupdatemod.entity.GameAssetDetail; import i18nupdatemod.entity.GameMetaData; import i18nupdatemod.entity.I18nMetaData; @@ -12,17 +11,10 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; -import static i18nupdatemod.util.AssetUtil.getFastestUrl; -import static i18nupdatemod.util.AssetUtil.getGitIndex; public class I18nConfig { - /** - * CFPAOrg/Minecraft-Mod-Language-Package - */ - private static final String CFPA_ASSET_ROOT = "http://downloader1.meitangdehulu.com:22943/"; private static final Gson GSON = new Gson(); private static I18nMetaData i18nMetaData; @@ -30,6 +22,7 @@ public class I18nConfig { init(); } + private static void init() { try (InputStream is = I18nConfig.class.getResourceAsStream("/i18nMetaData.json")) { if (is != null) { @@ -42,89 +35,40 @@ private static void init() { } } + public static I18nMetaData getMetaData() { + return i18nMetaData; + } + private static GameMetaData getGameMetaData(String minecraftVersion) { Version version = Version.from(minecraftVersion); - return i18nMetaData.games.stream().filter(it -> { + return getMetaData().games.stream().filter(it -> { VersionRange range = new VersionRange(it.gameVersions); return range.contains(version); }).findFirst().orElseThrow(() -> new IllegalStateException(String.format("Version %s not found in i18n meta", minecraftVersion))); } - private static AssetMetaData getAssetMetaData(String minecraftVersion, String loader) { - List current = i18nMetaData.assets.stream() - .filter(it -> it.targetVersion.equals(minecraftVersion)) - .collect(Collectors.toList()); - return current.stream() - .filter(it -> it.loader.equalsIgnoreCase(loader)).findFirst().orElseGet(() -> current.get(0)); - } - public static GameAssetDetail getAssetDetail(String minecraftVersion, String loader) { + /** + * Builds the local resource-pack plan without probing or resolving any legacy source. + */ + public static GameAssetDetail getAssetDetail(String minecraftVersion) { GameMetaData convert = getGameMetaData(minecraftVersion); GameAssetDetail ret = new GameAssetDetail(); - - String assetRoot = getFastestUrl(); - Log.debug("Using asset root: " + assetRoot); - - if (assetRoot.equals("https://raw.githubusercontent.com/")) { - ret.downloads = createDownloadDetailsFromGit(convert, loader); - } else { - ret.downloads = createDownloadDetails(convert, loader, assetRoot); - } - + ret.targetVersion = convert.convertFrom.get(0); ret.packMetaData = convert; - ret.description = getResourcePackDescription(ret.downloads); + ret.description = getResourcePackDescription(convert.convertFrom); ret.convertedFileName = String.format("Minecraft-Mod-Language-Modpack-Converted-%s.zip", minecraftVersion); return ret; } - private static String getResourcePackDescription(List downloads) { - return downloads.size() > 1 ? + + private static String getResourcePackDescription(List sourceVersions) { + return sourceVersions.size() > 1 ? String.format("该包由%s版本合并\n作者:CFPA团队及汉化项目贡献者", - downloads.stream().map(it -> it.targetVersion).collect(Collectors.joining("和"))) : + sourceVersions.stream().collect(Collectors.joining("和"))) : String.format("该包对应的官方支持版本为%s\n作者:CFPA团队及汉化项目贡献者", - downloads.get(0).targetVersion); - } - - private static List createDownloadDetails(GameMetaData convert, String loader, String assetRoot) { - return convert.convertFrom.stream().map(it -> getAssetMetaData(it, loader)).map(it -> { - GameAssetDetail.AssetDownloadDetail adi = new GameAssetDetail.AssetDownloadDetail(); - adi.fileName = it.filename; - adi.fileUrl = assetRoot + it.filename; - adi.md5Url = assetRoot + it.md5Filename; - adi.targetVersion = it.targetVersion; - return adi; - }).collect(Collectors.toList()); + sourceVersions.get(0)); } - private static List createDownloadDetailsFromGit(GameMetaData convert, String loader) { - try { - Map index = getGitIndex(); - String releaseTag; - String version = convert.convertFrom.get(0); - - if (loader.toLowerCase().contains("fabric")) { - releaseTag = index.get(version + "-fabric"); - } else { - releaseTag = index.get(version); - } - if (releaseTag == null) { - Log.debug("Error getting index: " + version + "-" + loader); - Log.debug(index.toString()); - throw new Exception(); - } - String assetRoot = "https://github.com/CFPAOrg/Minecraft-Mod-Language-Package/releases/download/" + releaseTag + "/"; - - return convert.convertFrom.stream().map(it -> getAssetMetaData(it, loader)).map(it -> { - GameAssetDetail.AssetDownloadDetail adi = new GameAssetDetail.AssetDownloadDetail(); - adi.fileName = it.filename; - adi.fileUrl = assetRoot + it.filename; - adi.md5Url = assetRoot + it.md5Filename; - adi.targetVersion = it.targetVersion; - return adi; - }).collect(Collectors.toList()); - } catch (Exception ignore) { - return createDownloadDetails(convert, loader, CFPA_ASSET_ROOT); - } - } } diff --git a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java index 2c1cc19..ba51c4b 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java +++ b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java @@ -24,23 +24,24 @@ public class ResourcePackConverter { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private final List sourcePath; private final Path filePath; - private final Path tmpFilePath; + private final Boolean enableLog; - public ResourcePackConverter(List sourcePaths, Path tmpFilePath, Path filePath) { + public ResourcePackConverter(List sourcePaths, Path filePath, Boolean enableLog) { this.sourcePath = sourcePaths; - this.tmpFilePath = tmpFilePath; this.filePath = filePath; + this.enableLog = enableLog; } - public Path convert(GameMetaData metaData, String description, HashSet modDomainsSet) throws Exception { - FileUtil.safeCreateDir(tmpFilePath.getParent()); + public void convert(GameMetaData metaData, String description, HashSet modDomainsSet, Path iconPath) throws Exception { FileUtil.safeCreateDir(filePath.getParent()); Set fileList = new HashSet<>(); try { - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tmpFilePath), StandardCharsets.UTF_8)) { - // zos.setMethod(ZipOutputStream.STORED); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(filePath), StandardCharsets.UTF_8)) { + // zos.setMethod(ZipOutputStream.STORED); for (Path p : sourcePath) { - Log.info("Converting: " + p); + if (enableLog) { + Log.info("Converting: " + p); + } try (ZipFile zf = new ZipFile(p.toFile(), StandardCharsets.UTF_8)) { for (Enumeration e = zf.entries(); e.hasMoreElements(); ) { ZipEntry ze = e.nextElement(); @@ -53,7 +54,7 @@ public Path convert(GameMetaData metaData, String description, HashSet m // Don't put same file if (fileList.contains(name)) { - // Log.debug(name + ": DUPLICATE"); + // Log.debug(name + ": DUPLICATE"); continue; } fileList.add(name); @@ -63,7 +64,8 @@ public Path convert(GameMetaData metaData, String description, HashSet m InputStream is = zf.getInputStream(ze); if (name.equalsIgnoreCase("pack.mcmeta")) { //Convert pack.mcmeta - zos.write(convertPackMeta(is, metaData, description)); + PackMeta meta = GSON.fromJson(new InputStreamReader(is, StandardCharsets.UTF_8), PackMeta.class); + zos.write(convertPackMeta(meta, metaData, description)); } else { //Copy other file IOUtils.copy(is, zos); @@ -72,17 +74,27 @@ public Path convert(GameMetaData metaData, String description, HashSet m } } } + if (!fileList.contains("pack.mcmeta")) { + zos.putNextEntry(new ZipEntry("pack.mcmeta")); + PackMeta meta = new PackMeta(); + meta.pack = new PackMeta.Pack(); + zos.write(convertPackMeta(meta, metaData, description)); + zos.closeEntry(); + } + if (iconPath != null && Files.isRegularFile(iconPath) && !fileList.contains("pack.png")) { + zos.putNextEntry(new ZipEntry("pack.png")); + Files.copy(iconPath, zos); + zos.closeEntry(); + } } - Log.info("Converted: %s -> %s", sourcePath, tmpFilePath); - FileUtil.syncIfNewer(tmpFilePath, filePath); - return filePath; + + Log.info("Converted: %s -> %s", sourcePath, filePath); } catch (Exception e) { - throw new Exception(String.format("Error converting %s to %s: %s", sourcePath, tmpFilePath, e)); + throw new Exception(String.format("Error converting %s to %s: %s", sourcePath, filePath, e)); } } - private byte[] convertPackMeta(InputStream is, GameMetaData metaData, String description) { - PackMeta meta = GSON.fromJson(new InputStreamReader(is, StandardCharsets.UTF_8), PackMeta.class); + private byte[] convertPackMeta(PackMeta meta, GameMetaData metaData, String description) { meta.pack.pack_format = metaData.useNewFormat() ? null : metaData.packFormat; meta.pack.min_format = metaData.useNewFormat() ? metaData.minFormat : null; meta.pack.max_format = metaData.useNewFormat() ? metaData.maxFormat : null; diff --git a/src/main/java/i18nupdatemod/core/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/ResourcePackDownloader.java deleted file mode 100644 index 7eb79af..0000000 --- a/src/main/java/i18nupdatemod/core/ResourcePackDownloader.java +++ /dev/null @@ -1,28 +0,0 @@ -package i18nupdatemod.core; - -import i18nupdatemod.entity.GameAssetDetail; -import i18nupdatemod.util.FileUtil; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Path; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.List; - -public class ResourcePackDownloader { - public static List download(List downloads, - Path resourcePackDirectory, Path cacheRoot) - throws IOException, URISyntaxException, NoSuchAlgorithmException { - FileUtil.safeCreateDir(resourcePackDirectory); - List sourcePaths = new ArrayList<>(downloads.size()); - for (GameAssetDetail.AssetDownloadDetail item : downloads) { - Path cachePath = cacheRoot.resolve(item.targetVersion).resolve(item.fileName); - FileUtil.safeCreateDir(cachePath.getParent()); - ResourcePack resourcePack = new ResourcePack(resourcePackDirectory.resolve(item.fileName), cachePath); - resourcePack.checkUpdate(item.fileUrl, item.md5Url); - sourcePaths.add(resourcePack.getTmpFilePath()); - } - return sourcePaths; - } -} diff --git a/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java b/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java new file mode 100644 index 0000000..e2edc42 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java @@ -0,0 +1,21 @@ +package i18nupdatemod.core; + +import i18nupdatemod.core.v1.ResourcePackV1; +import i18nupdatemod.core.v2.ResourcePackV2; +import i18nupdatemod.entity.ModTranslation; +import i18nupdatemod.util.Log; + +import java.nio.file.Path; +import java.util.List; + +public class ResourcePackUpdater { + public static Path update(String minecraftVersion, String loader, List mods, + Path resourcePackDirectory, Path cacheRoot) throws Exception { + try { + return ResourcePackV2.update(minecraftVersion, mods, resourcePackDirectory, cacheRoot); + } catch (Exception e) { + Log.warning("V2 resource pipeline failed; falling back to V1: %s", e); + } + return ResourcePackV1.update(minecraftVersion, loader, mods, resourcePackDirectory, cacheRoot); + } +} diff --git a/src/main/java/i18nupdatemod/core/v1/AssetDownloadDetail.java b/src/main/java/i18nupdatemod/core/v1/AssetDownloadDetail.java new file mode 100644 index 0000000..35c8325 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v1/AssetDownloadDetail.java @@ -0,0 +1,8 @@ +package i18nupdatemod.core.v1; + +public class AssetDownloadDetail { + public String fileName; + public String fileUrl; + public String md5Url; + public String targetVersion; +} diff --git a/src/main/java/i18nupdatemod/util/AssetUtil.java b/src/main/java/i18nupdatemod/core/v1/AssetUtil.java similarity index 96% rename from src/main/java/i18nupdatemod/util/AssetUtil.java rename to src/main/java/i18nupdatemod/core/v1/AssetUtil.java index d75c840..195501b 100644 --- a/src/main/java/i18nupdatemod/util/AssetUtil.java +++ b/src/main/java/i18nupdatemod/core/v1/AssetUtil.java @@ -1,4 +1,5 @@ -package i18nupdatemod.util; +package i18nupdatemod.core.v1; +import i18nupdatemod.util.Log; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; @@ -26,11 +27,8 @@ public class AssetUtil { private static final List MIRRORS; static { - // 镜像地址可以改成服务器下发 MIRRORS = new ArrayList<>(); MIRRORS.add("https://raw.githubusercontent.com/"); - // 此镜像源维护者:502y - MIRRORS.add("http://8.137.167.65:64684/"); } public static void download(String url, Path localFile) throws IOException, URISyntaxException { diff --git a/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java b/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java new file mode 100644 index 0000000..89c2e0b --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java @@ -0,0 +1,73 @@ +package i18nupdatemod.core.v1; + +import i18nupdatemod.core.I18nConfig; +import i18nupdatemod.entity.AssetMetaData; +import i18nupdatemod.entity.GameMetaData; +import i18nupdatemod.util.Log; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static i18nupdatemod.core.v1.AssetUtil.getFastestUrl; +import static i18nupdatemod.core.v1.AssetUtil.getGitIndex; + +public class LegacyConfig { + /** + * CFPAOrg/Minecraft-Mod-Language-Package + */ + private static final String CFPA_ASSET_ROOT = "http://downloader1.meitangdehulu.com:22943/"; + + public static List getLegacyDownloads(GameMetaData convert, String loader) { + String assetRoot = getFastestUrl(); + Log.debug("Using asset root: " + assetRoot); + + if (assetRoot.equals("https://raw.githubusercontent.com/")) { + return createDownloadDetailsFromGit(convert, loader); + } + return createDownloadDetails(convert, loader, assetRoot); + } + + private static AssetMetaData getAssetMetaData(String minecraftVersion, String loader) { + List current = I18nConfig.getMetaData().assets.stream() + .filter(it -> it.targetVersion.equals(minecraftVersion)) + .collect(Collectors.toList()); + return current.stream() + .filter(it -> it.loader.equalsIgnoreCase(loader)).findFirst().orElseGet(() -> current.get(0)); + } + + private static List createDownloadDetails(GameMetaData convert, String loader, String assetRoot) { + return convert.convertFrom.stream().map(it -> getAssetMetaData(it, loader)).map(it -> { + AssetDownloadDetail adi = new AssetDownloadDetail(); + adi.fileName = it.filename; + adi.fileUrl = assetRoot + it.filename; + adi.md5Url = assetRoot + it.md5Filename; + adi.targetVersion = it.targetVersion; + return adi; + }).collect(Collectors.toList()); + } + + private static List createDownloadDetailsFromGit(GameMetaData convert, String loader) { + try { + Map index = getGitIndex(); + String releaseTag; + String version = convert.convertFrom.get(0); + + if (loader.toLowerCase().contains("fabric")) { + releaseTag = index.get(version + "-fabric"); + } else { + releaseTag = index.get(version); + } + if (releaseTag == null) { + Log.debug("Error getting index: " + version + "-" + loader); + Log.debug(index.toString()); + throw new Exception(); + } + String assetRoot = "https://github.com/CFPAOrg/Minecraft-Mod-Language-Package/releases/download/" + releaseTag + "/"; + + return createDownloadDetails(convert, loader, assetRoot); + } catch (Exception ignore) { + return createDownloadDetails(convert, loader, CFPA_ASSET_ROOT); + } + } +} diff --git a/src/main/java/i18nupdatemod/core/v1/LegacyFileUtil.java b/src/main/java/i18nupdatemod/core/v1/LegacyFileUtil.java new file mode 100644 index 0000000..ff236cc --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v1/LegacyFileUtil.java @@ -0,0 +1,24 @@ +package i18nupdatemod.core.v1; + +import i18nupdatemod.util.Log; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +public class LegacyFileUtil { + public static void syncIfNewer(Path source, Path target) throws IOException { + if (!Files.exists(source)) { + return; + } + if (Files.exists(target) + && Files.getLastModifiedTime(target).compareTo(Files.getLastModifiedTime(source)) >= 0) { + Log.debug("Temp and current file has already been synchronized"); + return; + } + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + Files.setLastModifiedTime(target, Files.getLastModifiedTime(source)); + Log.info(String.format("Synchronized: %s -> %s", source, target)); + } +} diff --git a/src/main/java/i18nupdatemod/core/v1/LegacyResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v1/LegacyResourcePackDownloader.java new file mode 100644 index 0000000..6fcf8d5 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v1/LegacyResourcePackDownloader.java @@ -0,0 +1,31 @@ +package i18nupdatemod.core.v1; + +import i18nupdatemod.entity.GameMetaData; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public class LegacyResourcePackDownloader { + private LegacyResourcePackDownloader() { + } + + public static List download(GameMetaData metadata, String loader, + Path resourcePackDirectory, Path cacheRoot) throws Exception { + Files.createDirectories(resourcePackDirectory); + + List downloads = LegacyConfig.getLegacyDownloads(metadata, loader); + List sourcePaths = new ArrayList<>(downloads.size()); + for (AssetDownloadDetail item : downloads) { + Path cachePath = cacheRoot.resolve(item.targetVersion).resolve(item.fileName); + Files.createDirectories(cachePath.getParent()); + + ResourcePack resourcePack = new ResourcePack( + resourcePackDirectory.resolve(item.fileName), cachePath); + resourcePack.checkUpdate(item.fileUrl, item.md5Url); + sourcePaths.add(resourcePack.getTmpFilePath()); + } + return sourcePaths; + } +} diff --git a/src/main/java/i18nupdatemod/core/ResourcePack.java b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java similarity index 93% rename from src/main/java/i18nupdatemod/core/ResourcePack.java rename to src/main/java/i18nupdatemod/core/v1/ResourcePack.java index 7e864f8..89ce353 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePack.java +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java @@ -1,8 +1,6 @@ -package i18nupdatemod.core; +package i18nupdatemod.core.v1; -import i18nupdatemod.util.AssetUtil; import i18nupdatemod.util.DigestUtil; -import i18nupdatemod.util.FileUtil; import i18nupdatemod.util.Log; import java.io.FileNotFoundException; @@ -27,7 +25,7 @@ public ResourcePack(Path filePath, Path tmpFilePath) { this.filePath = filePath; this.tmpFilePath = tmpFilePath; try { - FileUtil.syncIfNewer(filePath, tmpFilePath); + LegacyFileUtil.syncIfNewer(filePath, tmpFilePath); } catch (Exception e) { Log.warning( String.format("Error while sync temp file %s <-> %s: %s", filePath, tmpFilePath, e)); @@ -84,7 +82,7 @@ private void downloadFull(String fileUrl, String md5Url) throws IOException { if (!Files.exists(tmpFilePath)) { throw new FileNotFoundException("Tmp file not found."); } - FileUtil.syncIfNewer(filePath, tmpFilePath); + LegacyFileUtil.syncIfNewer(filePath, tmpFilePath); } public Path getTmpFilePath() { diff --git a/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java b/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java new file mode 100644 index 0000000..29e2d80 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java @@ -0,0 +1,29 @@ +package i18nupdatemod.core.v1; + +import i18nupdatemod.core.I18nConfig; +import i18nupdatemod.core.ResourcePackConverter; +import i18nupdatemod.entity.GameAssetDetail; +import i18nupdatemod.entity.ModTranslation; + +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; + +public class ResourcePackV1 { + public static Path update(String minecraftVersion, String loader, List mods, + Path resourcePackDirectory, Path cacheRoot) throws Exception { + GameAssetDetail plan = I18nConfig.getAssetDetail(minecraftVersion); + HashSet modDomains = new HashSet<>(); + for (ModTranslation mod : mods) { + modDomains.add(mod.namespace); + } + List sources = LegacyResourcePackDownloader.download( + plan.packMetaData, loader, resourcePackDirectory, cacheRoot); + Path convertedCache = cacheRoot.resolve(minecraftVersion).resolve(plan.convertedFileName); + Path convertedOutput = resourcePackDirectory.resolve(plan.convertedFileName); + new ResourcePackConverter(sources, convertedCache, true) + .convert(plan.packMetaData, plan.description, modDomains, null); + LegacyFileUtil.syncIfNewer(convertedCache, convertedOutput); + return convertedOutput; + } +} diff --git a/src/main/java/i18nupdatemod/core/v2/ModIdentity.java b/src/main/java/i18nupdatemod/core/v2/ModIdentity.java new file mode 100644 index 0000000..581ccac --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v2/ModIdentity.java @@ -0,0 +1,165 @@ +package i18nupdatemod.core.v2; + +import i18nupdatemod.entity.ModTranslation; +import i18nupdatemod.util.DigestUtil; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Identifies a mod by hashing a named file in its archive. + * + *

A descriptor can represent a nested archive while retaining the source + * path of the outermost mod. This class keeps that version-specific lookup + * logic out of the shared descriptor.

+ */ +public final class ModIdentity { + private ModIdentity() { + } + + /** + * Calculate the MD5 of a file inside a mod archive without allowing a + * caller to leave the archive. Archive paths are resolved through the + * ordered nested-jar chain and never extracted to the filesystem. + */ + public static String getFileMd5(ModTranslation mod, String path) + throws IOException, NoSuchAlgorithmException { + String resourcePath = normalizeRelativePath(path); + Path source = mod.source; + if (source == null) { + throw new IOException("Mod source is missing"); + } + if (!Files.isRegularFile(source)) { + throw new IOException("Mod source does not exist: " + source); + } + return digest(openArchiveResource(source, mod.nestedJars, resourcePath)); + } + + private static InputStream openArchiveResource(Path source, List nestedJars, + String resourcePath) throws IOException { + InputStream current = Files.newInputStream(source); + boolean closeCurrent = true; + try { + for (String nestedJar : nestedJars) { + String nestedPath = normalizeRelativePath(nestedJar); + ZipInputStream archive = new ZipInputStream(current); + closeCurrent = false; + try { + ZipEntry nestedEntry = findEntry(archive, nestedPath); + if (nestedEntry == null || nestedEntry.isDirectory()) { + throw new IOException("Nested mod archive is missing: " + nestedPath); + } + ByteArrayOutputStream nestedBytes = new ByteArrayOutputStream(); + copy(archive, nestedBytes); + archive.close(); + current = new ByteArrayInputStream(nestedBytes.toByteArray()); + closeCurrent = true; + } catch (IOException e) { + try { + archive.close(); + } catch (IOException ignored) { + } + throw e; + } + } + + ZipInputStream archive = new ZipInputStream(current); + closeCurrent = false; + ZipEntry resourceEntry = findEntry(archive, resourcePath); + if (resourceEntry == null || resourceEntry.isDirectory()) { + try { + archive.close(); + } catch (IOException ignored) { + } + throw new IOException("Mod resource is missing: " + resourcePath); + } + // The returned stream owns the archive stream and therefore the + // current input. digest() closes it after consuming the entry. + return archive; + } finally { + if (closeCurrent) { + try { + current.close(); + } catch (IOException ignored) { + } + } + } + } + + private static ZipEntry findEntry(ZipInputStream archive, String wanted) throws IOException { + ZipEntry entry; + while ((entry = archive.getNextEntry()) != null) { + String entryName; + try { + entryName = normalizeRelativePath(entry.getName()); + } catch (IOException ignored) { + // An unrelated malformed entry must not make a safe lookup + // read outside the requested archive member. + continue; + } + if (wanted.equals(entryName)) { + return entry; + } + } + return null; + } + + private static String digest(InputStream input) throws IOException, NoSuchAlgorithmException { + try (InputStream is = input) { + MessageDigest digest = MessageDigest.getInstance("MD5"); + byte[] buffer = new byte[8192]; + int read; + while ((read = is.read(buffer)) != -1) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + return DigestUtil.hexString(digest.digest()); + } + } + + private static void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + if (read > 0) { + output.write(buffer, 0, read); + } + } + } + + private static String normalizeRelativePath(String path) throws IOException { + if (path == null || path.length() == 0 || path.indexOf('\u0000') >= 0) { + throw new IOException("Mod resource path is missing"); + } + + String normalized = path.replace('\\', '/'); + if (normalized.startsWith("/") || normalized.startsWith("//") + || (normalized.length() > 1 && normalized.charAt(1) == ':')) { + throw new IOException("Unsafe mod resource path: " + path); + } + + String[] components = normalized.split("/", -1); + StringBuilder result = new StringBuilder(normalized.length()); + for (String component : components) { + if (component.isEmpty() || ".".equals(component) || "..".equals(component)) { + throw new IOException("Unsafe mod resource path: " + path); + } + if (result.length() > 0) { + result.append('/'); + } + result.append(component); + } + return result.toString(); + } +} diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java new file mode 100644 index 0000000..8e47155 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -0,0 +1,481 @@ +package i18nupdatemod.core.v2; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import i18nupdatemod.entity.ModTranslation; +import i18nupdatemod.util.DigestUtil; +import i18nupdatemod.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class ResourcePackDownloader { + private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); + private static final long ICON_UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(30); + + public static Manifest loadManifest(String baseUrl, String version) throws IOException { + String root = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + String url = root + encode(version) + "/Manifest.json"; + try (InputStream input = fetch(url)) { + return parseManifest(input); + } + } + + private static Manifest parseManifest(InputStream input) throws IOException { + final JsonObject json; + try { + JsonElement root = JsonParser.parseReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + if (root == null || !root.isJsonObject()) { + throw new IOException("Manifest root must be an object"); + } + json = root.getAsJsonObject(); + } catch (JsonParseException | IllegalStateException e) { + throw new IOException("Invalid manifest JSON", e); + } + + Manifest result = new Manifest(); + if (!json.has("blackList") || !json.has("rules")) { + throw new IOException("Manifest must contain blackList and rules"); + } + JsonElement blackList = json.get("blackList"); + if (blackList != null) { + if (!blackList.isJsonArray()) { + throw new IOException("Manifest blackList must be an array"); + } + for (JsonElement item : blackList.getAsJsonArray()) { + if (!item.isJsonPrimitive() || !item.getAsJsonPrimitive().isString()) { + throw new IOException("Manifest blackList must contain strings"); + } + result.blackList.add(item.getAsString()); + } + } + + JsonElement rules = json.get("rules"); + if (rules != null) { + if (!rules.isJsonObject()) { + throw new IOException("Manifest rules must be an object"); + } + for (Map.Entry entry : rules.getAsJsonObject().entrySet()) { + JsonElement value = entry.getValue(); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IOException("Manifest rules must map strings to strings"); + } + result.rules.put(entry.getKey(), value.getAsString()); + } + } + return result; + } + + /** + * 解析Manifest,根据规则排除模组、生成NS + */ + public static Map selectNamespaces(List mods, Manifest manifest) { + if (mods == null) { + return new LinkedHashMap<>(); + } + if (manifest == null) { + throw new NullPointerException("manifest"); + } + + List blackList = manifest.blackList == null + ? Collections.emptyList() : manifest.blackList; + Map rules = manifest.rules == null + ? Collections.emptyMap() : manifest.rules; + Map selected = new LinkedHashMap<>(); + for (ModTranslation mod : mods) { + if (mod == null) { + continue; + } + String rawNamespace = mod.namespace; + if (rawNamespace == null || !rawNamespace.matches("[a-z0-9_.-]+")) { + Log.warning("Invalid translation namespace: %s", rawNamespace); + continue; + } + if (blackList.contains(rawNamespace)) { + continue; + } + + String namespace = resolveNamespace(mod, rules.get(rawNamespace)); + if (selected.containsKey(namespace)) { + // 太多了,没事别看 + Log.debug("Duplicate namespace %s, rawNamespace %s", namespace, rawNamespace); + continue; + } + selected.put(namespace, rawNamespace); + } + return selected; + } + + /** + * 下载新流程的资源 + * + *

NS不存在、下载失败、MD5不匹配、解压失败时跳过更新,已有缓存仍参与组包。其他错误回滚到旧流程。

+ */ + public static List download(String version, Map namespaces, + List blackList, Path cacheRoot, + String baseUrl) throws IOException, NoSuchAlgorithmException { + if (namespaces == null) { + throw new NullPointerException("namespaces"); + } + if (cacheRoot == null) { + throw new NullPointerException("cacheRoot"); + } + + List blocked = blackList == null + ? Collections.emptyList() : blackList; + String root = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + Path modCache = cacheRoot.resolve(version).resolve("mods"); + Files.createDirectories(modCache); + deleteBlacklisted(modCache, blocked); + + String versionUrl = root + encode(version) + "/"; + List sourcePaths = new ArrayList<>(); + for (Map.Entry entry : namespaces.entrySet()) { + String namespace = entry.getKey(); + String rawNamespace = entry.getValue(); + if (namespace == null || rawNamespace == null + || blocked.contains(namespace) || blocked.contains(rawNamespace)) { + continue; + } + + Path cached = modCache.resolve(encode(namespace) + ".zip"); + Path md5File = modCache.resolve(encode(namespace) + ".md5"); + String assetUrl = versionUrl + "assets/" + encode(namespace); + try { + updateMod(assetUrl, rawNamespace, cached, md5File); + } catch (HttpStatusException e) { + if (e.status == 404 || e.status == 410) { + // 太多了,没事别看( + //Log.debug("No exact translation asset for %s/%s; keeping local cache if present", version, namespace); + } else { + Log.warning("Translation asset %s/%s returned HTTP %s; aborting new pipeline", + version, namespace, e.status); + throw e; + } + } catch (AssetFailure e) { + Log.warning("Failed to update translation %s; keeping local cache if present: %s", + namespace, e.getMessage()); + } + if (Files.isRegularFile(cached)) { + sourcePaths.add(cached); + } + } + return sourcePaths; + } + + public static Path downloadIcon(String baseUrl, String version, Path cacheRoot) { + Path cached = cacheRoot.resolve("shared").resolve("pack.png"); + Path temporary = null; + try { + if (Files.isRegularFile(cached) + && Files.getLastModifiedTime(cached).toMillis() > System.currentTimeMillis() - ICON_UPDATE_TIME_GAP) { + return cached; + } + Files.createDirectories(cached.getParent()); + temporary = Files.createTempFile(cached.getParent(), "pack-icon-", ".tmp"); + String root = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + try (InputStream input = fetch(root + encode(version) + "/pack.png")) { + Files.copy(input, temporary, StandardCopyOption.REPLACE_EXISTING); + } + byte[] signature = new byte[]{(byte) 137, 80, 78, 71, 13, 10, 26, 10}; + try (InputStream input = Files.newInputStream(temporary)) { + for (byte expected : signature) { + if (input.read() != (expected & 255)) throw new IOException("Invalid pack.png signature"); + } + } + Files.move(temporary, cached, StandardCopyOption.REPLACE_EXISTING); + Log.info("Updated shared resource pack icon: %s", cached); + } catch (Exception e) { + Log.warning("Failed to update resource pack icon; retaining cached icon if present: %s", e); + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException e) { + Log.warning("Failed to remove temporary icon %s: %s", temporary, e); + } + } + } + return Files.isRegularFile(cached) ? cached : null; + } + + private static String resolveNamespace(ModTranslation mod, String identifier) { + if (identifier == null) { + return mod.namespace; + } + try { + String value; + if ("author".equals(identifier)) { + value = mod.authors == null || mod.authors.isEmpty() + ? null : Collections.min(mod.authors); + } else if ("displayName".equals(identifier)) { + value = mod.displayName; + } else { + value = ModIdentity.getFileMd5(mod, identifier); + } + if (value != null && !value.trim().isEmpty()) { + return mod.namespace + "-CFPA-" + value; + } + } catch (Exception e) { + Log.warning("Cannot identify translation %s using %s; using raw namespace: %s", + mod.namespace, identifier, e); + } + return mod.namespace; + } + + private static void updateMod(String assetUrl, String rawNamespace, + Path cached, Path md5File) + throws IOException, AssetFailure, NoSuchAlgorithmException { + if (Files.isRegularFile(cached) && Files.isRegularFile(md5File) + && Files.getLastModifiedTime(cached).toMillis() + > System.currentTimeMillis() - UPDATE_TIME_GAP) { + return; + } + + String remoteMd5 = readRemoteText(assetUrl + ".md5").trim(); + if (!remoteMd5.matches("[0-9a-fA-F]{32}")) { + throw new AssetFailure("Invalid asset MD5: " + assetUrl); + } + if (Files.isRegularFile(cached) && Files.isRegularFile(md5File) + && remoteMd5.equalsIgnoreCase( + new String(Files.readAllBytes(md5File), StandardCharsets.UTF_8).trim())) { + return; + } + + Path archive = Files.createTempFile(cached.getParent(), "translation-", ".tar.lzma"); + Path decoded = Files.createTempFile(cached.getParent(), "translation-", ".zip.tmp"); + try { + downloadRemote(assetUrl + ".tar.lzma", archive); + if (!remoteMd5.equalsIgnoreCase(DigestUtil.md5Hex(archive))) { + throw new AssetFailure("Download MD5 not match: " + assetUrl); + } + try { + TranslationArchive.unpack(archive, decoded, rawNamespace); + } catch (TranslationArchive.LocalIoException e) { + throw e; + } catch (IOException | RuntimeException e) { + throw new AssetFailure("Failed to decode translation archive: " + assetUrl, e); + } + + Files.move(decoded, cached, StandardCopyOption.REPLACE_EXISTING); + Files.write(md5File, remoteMd5.getBytes(StandardCharsets.UTF_8)); + } finally { + Files.deleteIfExists(archive); + Files.deleteIfExists(decoded); + } + } + + private static String readRemoteText(String url) throws IOException, AssetFailure { + InputStream input = fetchAsset(url); + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + while (true) { + int count; + try { + count = input.read(buffer); + } catch (IOException e) { + throw new AssetFailure("Failed to read " + url, e); + } + if (count < 0) { + break; + } + if (count > 0) { + output.write(buffer, 0, count); + } + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } finally { + try { + input.close(); + } catch (IOException e) { + throw new AssetFailure("Failed to close " + url, e); + } + } + } + + private static void downloadRemote(String url, Path destination) + throws IOException, AssetFailure { + InputStream input = fetchAsset(url); + OutputStream output = null; + IOException localFailure = null; + AssetFailure assetFailure = null; + try { + try { + output = Files.newOutputStream(destination); + } catch (IOException e) { + localFailure = e; + } + if (output != null) { + try { + byte[] buffer = new byte[32 * 1024]; + while (true) { + int count; + try { + count = input.read(buffer); + } catch (IOException e) { + throw new AssetFailure("Failed to read " + url, e); + } + if (count < 0) { + break; + } + if (count > 0) { + output.write(buffer, 0, count); + } + } + } catch (AssetFailure e) { + assetFailure = e; + } catch (IOException e) { + localFailure = e; + } + try { + output.close(); + } catch (IOException e) { + if (localFailure == null) { + localFailure = e; + } else { + localFailure.addSuppressed(e); + } + } + } + } finally { + try { + input.close(); + } catch (IOException e) { + AssetFailure closeFailure = new AssetFailure("Failed to close " + url, e); + if (localFailure != null) { + localFailure.addSuppressed(closeFailure); + } else if (assetFailure != null) { + assetFailure.addSuppressed(closeFailure); + } else { + assetFailure = closeFailure; + } + } + } + if (localFailure != null) { + if (assetFailure != null) { + localFailure.addSuppressed(assetFailure); + } + throw localFailure; + } + if (assetFailure != null) { + throw assetFailure; + } + } + + private static InputStream fetchAsset(String url) throws IOException, AssetFailure { + try { + return fetch(url); + } catch (HttpStatusException e) { + throw e; + } catch (IOException | RuntimeException e) { + throw new AssetFailure("Failed to fetch " + url, e); + } + } + + private static void deleteBlacklisted(Path cache, List blackList) throws IOException { + if (blackList.isEmpty()) { + return; + } + try (DirectoryStream files = Files.newDirectoryStream(cache)) { + for (Path file : files) { + String name = file.getFileName().toString(); + if (!name.endsWith(".zip") && !name.endsWith(".md5")) { + continue; + } + String encoded = name.substring(0, name.lastIndexOf('.')); + final String namespace; + try { + namespace = URLDecoder.decode(encoded, "UTF-8"); + } catch (IllegalArgumentException e) { + continue; + } + int marker = namespace.indexOf("-CFPA-"); + String rawNamespace = marker < 0 ? namespace : namespace.substring(0, marker); + if (blackList.contains(namespace) || blackList.contains(rawNamespace)) { + Files.deleteIfExists(file); + Log.info("Deleted blacklisted translation cache: %s", file); + } + } + } + } + + private static String encode(String segment) throws IOException { + return URLEncoder.encode(segment, "UTF-8").replace("+", "%20"); + } + + private static InputStream fetch(String url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(3000); + connection.setReadTimeout(33000); + try { + int status = connection.getResponseCode(); + if (status >= 400 && status <= 599) { + throw new HttpStatusException(url, status); + } + if (status < 200 || status >= 300) { + throw new IOException("Unexpected HTTP " + status + ": " + url); + } + return new FilterInputStream(connection.getInputStream()) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + connection.disconnect(); + } + } + }; + } catch (IOException | RuntimeException e) { + connection.disconnect(); + throw e; + } + } + + private static class HttpStatusException extends IOException { + final int status; + + HttpStatusException(String url, int status) { + super("HTTP " + status + ": " + url); + this.status = status; + } + } + + public static class Manifest { + public List blackList = new ArrayList<>(); + public Map rules = new LinkedHashMap<>(); + } + + private static class AssetFailure extends Exception { + AssetFailure(String message) { + super(message); + } + + AssetFailure(String message, Throwable cause) { + super(message, cause); + } + } +} + diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java new file mode 100644 index 0000000..543bd79 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java @@ -0,0 +1,54 @@ +package i18nupdatemod.core.v2; + +import i18nupdatemod.core.I18nConfig; +import i18nupdatemod.core.ResourcePackConverter; +import i18nupdatemod.entity.GameAssetDetail; +import i18nupdatemod.entity.ModTranslation; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +public class ResourcePackV2 { + private static String getAssetBaseUrl() { + Properties config = new Properties(); + try (InputStream input = ResourcePackV2.class.getResourceAsStream("/i18n-build.properties")) { + if (input == null) throw new IOException("Missing bundled build configuration"); + config.load(input); + String baseUrl = config.getProperty("assetBaseUrl"); + if (baseUrl == null || baseUrl.isEmpty()) + throw new IOException("Missing assetBaseUrl in build configuration"); + return baseUrl; + } catch (IOException e) { + throw new IllegalStateException("Cannot load bundled build configuration", e); + } + } + + public static Path update(String minecraftVersion, List mods, + + Path resourcePackDirectory, Path cacheRoot) throws Exception { + GameAssetDetail plan = I18nConfig.getAssetDetail(minecraftVersion); + String baseUrl = getAssetBaseUrl(); + + ResourcePackDownloader.Manifest manifest = ResourcePackDownloader.loadManifest(baseUrl, plan.targetVersion); + Map namespaces = ResourcePackDownloader.selectNamespaces(mods, manifest); + + Files.createDirectories(resourcePackDirectory); + List sources = ResourcePackDownloader.download( + plan.targetVersion, namespaces, manifest.blackList, cacheRoot, baseUrl); + Path icon = ResourcePackDownloader.downloadIcon(baseUrl, plan.targetVersion, cacheRoot); + + Path convertedCache = cacheRoot.resolve(minecraftVersion).resolve(plan.convertedFileName); + Path convertedOutput = resourcePackDirectory.resolve(plan.convertedFileName); + new ResourcePackConverter(sources, convertedCache, false) + .convert(plan.packMetaData, plan.description, new HashSet<>(namespaces.values()), icon); + Files.copy(convertedCache, convertedOutput, StandardCopyOption.REPLACE_EXISTING); + return convertedOutput; + } +} diff --git a/src/main/java/i18nupdatemod/core/v2/TranslationArchive.java b/src/main/java/i18nupdatemod/core/v2/TranslationArchive.java new file mode 100644 index 0000000..c970b87 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/v2/TranslationArchive.java @@ -0,0 +1,943 @@ +package i18nupdatemod.core.v2; + +import org.tukaani.xz.LZMAInputStream; + +import java.io.FileInputStream; +import java.io.FilterInputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PushbackInputStream; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Converts the tar.lzma translation archives served by the translation index + * into resource-pack ZIP files. The input is decoded as a lzip stream when it + * has the lzip signature; legacy .lzma-alone files are accepted as well. + */ +public final class TranslationArchive { + + /** + * Signals a local archive read/write failure rather than malformed + * translation content. The downloader propagates this to the + * whole-pipeline legacy fallback. + */ + public static final class LocalIoException extends IOException { + public LocalIoException(String message, Throwable cause) { + super(message, cause); + } + } + + private static final int LZIP_HEADER_SIZE = 6; + private static final int LZIP_TRAILER_SIZE = 20; + private static final int TAR_BLOCK_SIZE = 512; + private static final int COPY_BUFFER_SIZE = 32 * 1024; + private static final int MAX_METADATA_SIZE = 1024 * 1024; + private static final int MAX_ZIP_NAME_BYTES = 65535; + private static final int MAX_XZ_MEMORY_KIB = 64 * 1024; + private static final long MAX_LZIP_DICTIONARY_SIZE = 64L * 1024L * 1024L; + private static final byte[] LZIP_MAGIC = new byte[]{'L', 'Z', 'I', 'P'}; + + private TranslationArchive() { + } + + /** + * Decompresses and converts one translation archive. + * + * @param archive compressed lzip or legacy lzma archive + * @param outputZip destination ZIP path + * @param rawNamespace mod namespace to place below {@code assets/} + * @throws IOException when the archive is malformed or cannot be written + */ + public static void unpack(Path archive, Path outputZip, String rawNamespace) throws IOException { + if (archive == null || outputZip == null) { + throw new NullPointerException("archive and outputZip must not be null"); + } + String namespace = normalizeNamespace(rawNamespace); + Path parent = outputZip.getParent(); + if (parent != null) { + createDirectoriesLocal(parent); + } + + Path temporary = outputZip.resolveSibling(outputZip.getFileName().toString() + ".tmp"); + deleteLocal(temporary); + try { + try (LocalInputStream fileInput = openInput(archive); + CountingInputStream counted = new CountingInputStream(fileInput); + PushbackInputStream probeInput = new PushbackInputStream(counted, LZIP_HEADER_SIZE); + LocalOutputStream temporaryOutput = openOutput(temporary); + ZipOutputStream zip = new ZipOutputStream(temporaryOutput, StandardCharsets.UTF_8)) { + byte[] probeBytes = new byte[LZIP_HEADER_SIZE]; + int probeLength = readAtMost(probeInput, probeBytes, 0, probeBytes.length); + boolean looksLikeLzip = probeLength >= LZIP_MAGIC.length + && hasLzipMagic(probeBytes); + + InputStream decompressed; + if (looksLikeLzip) { + if (probeLength != LZIP_HEADER_SIZE) { + throw new IOException("Truncated lzip header"); + } + decompressed = new LzipInputStream(probeInput, counted, probeBytes); + } else { + if (probeLength > 0) { + probeInput.unread(probeBytes, 0, probeLength); + } + decompressed = new LZMAInputStream(probeInput, MAX_XZ_MEMORY_KIB); + } + + try (InputStream decompressedStream = decompressed) { + TarReader reader = new TarReader(decompressedStream, zip, namespace); + reader.readArchive(); + + // A valid tar may have zero padding after its two end + // blocks, but no other bytes may follow. This also + // forces the lzip decoder to consume and verify every + // member trailer. + reader.drainZeroTail(); + if (probeInput.read() != -1) { + throw new IOException("Trailing bytes after compressed archive"); + } + } + } + + moveLocal(temporary, outputZip); + } catch (IOException | RuntimeException e) { + LocalIoException localFailure = findLocalIo(e); + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanup) { + if (localFailure != null) { + localFailure.addSuppressed(cleanup); + throw localFailure; + } + LocalIoException cleanupFailure = new LocalIoException( + "Failed to clean temporary translation archive", cleanup); + cleanupFailure.addSuppressed(e); + throw cleanupFailure; + } + if (localFailure != null) { + throw localFailure; + } + throw e; + } + } + + private static LocalIoException findLocalIo(Throwable error) { + if (error instanceof LocalIoException) { + return (LocalIoException) error; + } + for (Throwable suppressed : error.getSuppressed()) { + LocalIoException local = findLocalIo(suppressed); + if (local != null) { + return local; + } + } + Throwable cause = error.getCause(); + return cause == null ? null : findLocalIo(cause); + } + + private static LocalInputStream openInput(Path archive) throws IOException { + try { + return new LocalInputStream(new FileInputStream(archive.toFile())); + } catch (IOException e) { + throw wrapLocal("Cannot open translation archive: " + archive, e); + } + } + + private static LocalOutputStream openOutput(Path output) throws IOException { + try { + return new LocalOutputStream(Files.newOutputStream(output)); + } catch (IOException e) { + throw wrapLocal("Cannot create decoded translation archive: " + output, e); + } + } + + private static void createDirectoriesLocal(Path path) throws IOException { + try { + Files.createDirectories(path); + } catch (IOException e) { + throw wrapLocal("Cannot create decoded archive directory: " + path, e); + } + } + + private static void deleteLocal(Path path) throws IOException { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw wrapLocal("Cannot remove temporary translation archive: " + path, e); + } + } + + private static void moveLocal(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw wrapLocal("Cannot publish decoded translation archive: " + target, e); + } + } + + private static LocalIoException wrapLocal(String message, IOException cause) { + return cause instanceof LocalIoException + ? (LocalIoException) cause : new LocalIoException(message, cause); + } + + private static final class LocalInputStream extends FilterInputStream { + private LocalInputStream(InputStream input) { + super(input); + } + + @Override + public int read() throws IOException { + try { + return super.read(); + } catch (IOException e) { + throw wrapLocal("Cannot read translation archive", e); + } + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + try { + return super.read(bytes, offset, length); + } catch (IOException e) { + throw wrapLocal("Cannot read translation archive", e); + } + } + + @Override + public long skip(long amount) throws IOException { + try { + return super.skip(amount); + } catch (IOException e) { + throw wrapLocal("Cannot read translation archive", e); + } + } + + @Override + public void close() throws IOException { + try { + super.close(); + } catch (IOException e) { + throw wrapLocal("Cannot close translation archive", e); + } + } + } + + private static final class LocalOutputStream extends FilterOutputStream { + private LocalOutputStream(OutputStream output) { + super(output); + } + + @Override + public void write(int value) throws IOException { + try { + super.write(value); + } catch (IOException e) { + throw wrapLocal("Cannot write decoded translation archive", e); + } + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + try { + super.write(bytes, offset, length); + } catch (IOException e) { + throw wrapLocal("Cannot write decoded translation archive", e); + } + } + + @Override + public void flush() throws IOException { + try { + super.flush(); + } catch (IOException e) { + throw wrapLocal("Cannot flush decoded translation archive", e); + } + } + + @Override + public void close() throws IOException { + try { + super.close(); + } catch (IOException e) { + throw wrapLocal("Cannot close decoded translation archive", e); + } + } + } + + private static boolean hasLzipMagic(byte[] bytes) { + return bytes[0] == LZIP_MAGIC[0] + && bytes[1] == LZIP_MAGIC[1] + && bytes[2] == LZIP_MAGIC[2] + && bytes[3] == LZIP_MAGIC[3]; + } + + private static int readAtMost(InputStream input, byte[] buffer, int offset, int length) + throws IOException { + int total = 0; + while (total < length) { + int count = input.read(buffer, offset + total, length - total); + if (count < 0) { + break; + } + if (count == 0) { + continue; + } + total += count; + } + return total; + } + + private static String normalizeNamespace(String namespace) throws IOException { + if (namespace == null || namespace.isEmpty() + || namespace.indexOf('/') >= 0 || namespace.indexOf('\\') >= 0 + || namespace.indexOf('\0') >= 0 || namespace.indexOf(':') >= 0 + || ".".equals(namespace) || "..".equals(namespace)) { + throw new IOException("Unsafe translation namespace: " + namespace); + } + if (StandardCharsets.UTF_8.encode(namespace).remaining() > MAX_ZIP_NAME_BYTES) { + throw new IOException("Translation namespace is too long"); + } + return namespace; + } + + private static String decodeUtf8(byte[] bytes, int offset, int length) throws IOException { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(bytes, offset, length)) + .toString(); + } catch (CharacterCodingException e) { + throw new IOException("Invalid UTF-8 in tar metadata", e); + } + } + + private static String decodeTarString(byte[] header, int offset, int length) throws IOException { + int end = offset + length; + int nul = offset; + while (nul < end && header[nul] != 0) { + ++nul; + } + return decodeUtf8(header, offset, nul - offset); + } + + private static String normalizeTarPath(String path) throws IOException { + if (path == null || path.length() == 0 || path.indexOf('\0') >= 0 + || path.indexOf('\\') >= 0 || path.startsWith("/") + || path.startsWith("\\") || path.indexOf(':') >= 0) { + throw new IOException("Unsafe tar path: " + path); + } + + String[] pieces = path.split("/", -1); + StringBuilder normalized = new StringBuilder(path.length()); + for (String piece : pieces) { + if (piece.length() == 0 || ".".equals(piece)) { + continue; + } + if ("..".equals(piece)) { + throw new IOException("Traversal in tar path: " + path); + } + if (normalized.length() > 0) { + normalized.append('/'); + } + normalized.append(piece); + } + if (normalized.length() == 0) { + throw new IOException("Empty tar path: " + path); + } + return normalized.toString(); + } + + private static long parseTarNumber(byte[] bytes, int offset, int length, String field) + throws IOException { + int end = offset + length; + if (offset < 0 || length < 0 || end > bytes.length) { + throw new IOException("Invalid tar " + field + " field"); + } + + if ((bytes[offset] & 0x80) != 0) { + long value = bytes[offset] & 0x7f; + for (int i = offset + 1; i < end; ++i) { + int next = bytes[i] & 0xff; + if (value > (Long.MAX_VALUE - next) / 256L) { + throw new IOException("Tar " + field + " is too large"); + } + value = value * 256L + next; + } + return value; + } + + long value = 0; + boolean foundDigit = false; + boolean trailing = false; + for (int i = offset; i < end; ++i) { + int current = bytes[i] & 0xff; + if (current == 0 || current == ' ') { + if (foundDigit) { + trailing = true; + } + continue; + } + if (current < '0' || current > '7' || trailing) { + throw new IOException("Invalid tar " + field + " field"); + } + foundDigit = true; + int digit = current - '0'; + if (value > (Long.MAX_VALUE - digit) / 8L) { + throw new IOException("Tar " + field + " is too large"); + } + value = value * 8L + digit; + } + return value; + } + + private static boolean isZeroBlock(byte[] block) { + for (byte value : block) { + if (value != 0) { + return false; + } + } + return true; + } + + private static void requireZipNameLength(String name) throws IOException { + if (StandardCharsets.UTF_8.encode(name).remaining() > MAX_ZIP_NAME_BYTES) { + throw new IOException("Tar path is too long for a ZIP entry"); + } + } + + private static void skipFully(InputStream input, long amount) throws IOException { + if (amount < 0) { + throw new IOException("Negative tar entry size"); + } + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + long remaining = amount; + while (remaining > 0) { + int requested = (int) Math.min((long) buffer.length, remaining); + int count = input.read(buffer, 0, requested); + if (count < 0) { + throw new IOException("Truncated tar entry"); + } + if (count == 0) { + continue; + } + remaining -= count; + } + } + + private static void skipPadding(InputStream input, long amount) throws IOException { + long padding = (TAR_BLOCK_SIZE - (amount % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE; + skipFully(input, padding); + } + + private static final class TarReader { + private final InputStream input; + private final ZipOutputStream output; + private final String namespace; + private final byte[] copyBuffer = new byte[COPY_BUFFER_SIZE]; + private final byte[] header = new byte[TAR_BLOCK_SIZE]; + private String longName; + private PaxAttributes localPax = new PaxAttributes(); + private PaxAttributes globalPax = new PaxAttributes(); + private boolean reachedEnd; + + private TarReader(InputStream input, ZipOutputStream output, String namespace) { + this.input = input; + this.output = output; + this.namespace = namespace; + } + + private void readArchive() throws IOException { + while (!reachedEnd) { + if (!readBlock(header)) { + throw new IOException("Truncated tar header"); + } + if (isZeroBlock(header)) { + if (!readBlock(header) || !isZeroBlock(header)) { + throw new IOException("Tar archive has only one end block"); + } + reachedEnd = true; + continue; + } + readEntry(header); + } + } + + private void readEntry(byte[] entryHeader) throws IOException { + verifyChecksum(entryHeader); + String headerName = decodeTarString(entryHeader, 0, 100); + String prefix = decodeTarString(entryHeader, 345, 155); + if (prefix.length() > 0) { + headerName = prefix + "/" + headerName; + } + long headerSize = parseTarNumber(entryHeader, 124, 12, "size"); + int type = entryHeader[156] & 0xff; + + if (type == 'L') { + longName = readMetadataString(headerSize, "GNU long name"); + skipPadding(input, headerSize); + return; + } + if (type == 'x' || type == 'g') { + PaxAttributes attributes = readPaxAttributes(headerSize); + skipPadding(input, headerSize); + if (type == 'g') { + globalPax.merge(attributes); + } else { + localPax.merge(attributes); + } + return; + } + if (type == 'K') { + throw new IOException("GNU long link entries are not supported"); + } + if (type == '1' || type == '2' || type == '3' || type == '4' + || type == '6' || type == '7') { + throw new IOException("Tar links and device entries are not supported"); + } + if (type != 0 && type != '0' && type != '5') { + throw new IOException("Unsupported tar entry type: " + type); + } + + String path = localPax.path != null ? localPax.path + : (globalPax.path != null ? globalPax.path : longName); + if (path == null) { + path = headerName; + } + String normalizedPath = normalizeTarPath(path); + long size = localPax.size != null ? localPax.size + : (globalPax.size != null ? globalPax.size : headerSize); + if (size < 0) { + throw new IOException("Negative tar entry size"); + } + + String zipName = "assets/" + namespace + "/" + normalizedPath; + requireZipNameLength(zipName); + if (type == '5') { + if (size != 0) { + skipFully(input, size); + } + skipPadding(input, size); + if (!zipName.endsWith("/")) { + zipName += "/"; + } + requireZipNameLength(zipName); + output.putNextEntry(new ZipEntry(zipName)); + output.closeEntry(); + } else { + if (path.endsWith("/")) { + throw new IOException("Regular tar entry has a directory path"); + } + output.putNextEntry(new ZipEntry(zipName)); + copyEntry(size); + output.closeEntry(); + skipPadding(input, size); + } + longName = null; + localPax = new PaxAttributes(); + } + + private void copyEntry(long size) throws IOException { + long remaining = size; + while (remaining > 0) { + int requested = (int) Math.min((long) copyBuffer.length, remaining); + int count = input.read(copyBuffer, 0, requested); + if (count < 0) { + throw new IOException("Truncated tar entry"); + } + if (count == 0) { + continue; + } + output.write(copyBuffer, 0, count); + remaining -= count; + } + } + + private String readMetadataString(long size, String description) throws IOException { + byte[] metadata = readMetadata(size, description); + int length = metadata.length; + while (length > 0 && metadata[length - 1] == 0) { + --length; + } + return decodeUtf8(metadata, 0, length); + } + + private byte[] readMetadata(long size, String description) throws IOException { + if (size < 0 || size > MAX_METADATA_SIZE) { + throw new IOException(description + " is too large"); + } + byte[] metadata = new byte[(int) size]; + readFully(metadata); + return metadata; + } + + private PaxAttributes readPaxAttributes(long size) throws IOException { + byte[] metadata = readMetadata(size, "PAX header"); + PaxAttributes attributes = new PaxAttributes(); + int offset = 0; + while (offset < metadata.length) { + int space = indexOf(metadata, offset, metadata.length, (byte) ' '); + if (space <= offset) { + throw new IOException("Malformed PAX record"); + } + long recordLength = parseDecimal(metadata, offset, space); + if (recordLength < 3 || recordLength > metadata.length - offset) { + throw new IOException("Malformed PAX record length"); + } + int end = offset + (int) recordLength; + if (metadata[end - 1] != '\n') { + throw new IOException("Malformed PAX record terminator"); + } + int equals = indexOf(metadata, space + 1, end - 1, (byte) '='); + if (equals <= space + 1) { + throw new IOException("Malformed PAX record"); + } + String key = new String(metadata, space + 1, equals - space - 1, StandardCharsets.US_ASCII); + String value = decodeUtf8(metadata, equals + 1, end - equals - 2); + if ("path".equals(key)) { + attributes.path = value; + } else if ("size".equals(key)) { + attributes.size = parseDecimalString(value, "PAX size"); + } + offset = end; + } + return attributes; + } + + private void drainZeroTail() throws IOException { + if (!reachedEnd) { + throw new IOException("Tar archive did not reach its end"); + } + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + int count; + while ((count = input.read(buffer, 0, buffer.length)) != -1) { + for (int i = 0; i < count; ++i) { + if (buffer[i] != 0) { + throw new IOException("Non-zero bytes after tar end blocks"); + } + } + } + } + + private boolean readBlock(byte[] block) throws IOException { + int count = 0; + while (count < block.length) { + int read = input.read(block, count, block.length - count); + if (read < 0) { + return count == 0 ? false : throwTruncatedBlock(); + } + if (read == 0) { + continue; + } + count += read; + } + return true; + } + + private boolean throwTruncatedBlock() throws IOException { + throw new IOException("Truncated tar block"); + } + + private void readFully(byte[] bytes) throws IOException { + int offset = 0; + while (offset < bytes.length) { + int count = input.read(bytes, offset, bytes.length - offset); + if (count < 0) { + throw new IOException("Truncated tar metadata"); + } + if (count == 0) { + continue; + } + offset += count; + } + } + + private static void verifyChecksum(byte[] entryHeader) throws IOException { + long actual = parseTarNumber(entryHeader, 148, 8, "checksum"); + long sum = 0; + for (int i = 0; i < entryHeader.length; ++i) { + sum += i >= 148 && i < 156 ? 0x20 : entryHeader[i] & 0xff; + } + if (actual != sum) { + throw new IOException("Invalid tar header checksum"); + } + } + + private static int indexOf(byte[] bytes, int start, int end, byte needle) { + for (int i = start; i < end; ++i) { + if (bytes[i] == needle) { + return i; + } + } + return -1; + } + + private static long parseDecimal(byte[] bytes, int start, int end) throws IOException { + long value = 0; + if (start >= end) { + throw new IOException("Empty decimal value"); + } + for (int i = start; i < end; ++i) { + int digit = bytes[i] - '0'; + if (digit < 0 || digit > 9 + || value > (Long.MAX_VALUE - digit) / 10L) { + throw new IOException("Invalid decimal value"); + } + value = value * 10L + digit; + } + return value; + } + + private static long parseDecimalString(String value, String description) throws IOException { + if (value.length() == 0) { + throw new IOException("Empty " + description); + } + long result = 0; + for (int i = 0; i < value.length(); ++i) { + char current = value.charAt(i); + if (current < '0' || current > '9' + || result > (Long.MAX_VALUE - (current - '0')) / 10L) { + throw new IOException("Invalid " + description); + } + result = result * 10L + current - '0'; + } + return result; + } + } + + private static final class PaxAttributes { + private String path; + private Long size; + + private void merge(PaxAttributes other) { + if (other.path != null) { + path = other.path; + } + if (other.size != null) { + size = other.size; + } + } + } + + private static final class CountingInputStream extends FilterInputStream { + private long count; + + private CountingInputStream(InputStream input) { + super(input); + } + + @Override + public int read() throws IOException { + int value = super.read(); + if (value >= 0) { + increment(1); + } + return value; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + int read = super.read(bytes, offset, length); + if (read > 0) { + increment(read); + } + return read; + } + + private void increment(long amount) throws IOException { + if (Long.MAX_VALUE - count < amount) { + throw new IOException("Compressed archive is too large"); + } + count += amount; + } + + private long getCount() { + return count; + } + } + + private static final class LzipInputStream extends InputStream { + private final InputStream input; + private final byte[] singleByte = new byte[1]; + private final CountingInputStream counted; + private byte[] firstHeader; + private LZMAInputStream decoder; + private long memberStart; + private long memberOutputSize; + private CRC32 memberCrc; + private boolean finished; + + private LzipInputStream(InputStream input, CountingInputStream counted, byte[] firstHeader) { + this.input = input; + this.counted = counted; + this.firstHeader = Arrays.copyOf(firstHeader, firstHeader.length); + } + + @Override + public int read() throws IOException { + return read(singleByte, 0, 1) < 0 ? -1 : singleByte[0] & 0xff; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + if (bytes == null) { + throw new NullPointerException("bytes"); + } + if (offset < 0 || length < 0 || offset > bytes.length - length) { + throw new IndexOutOfBoundsException(); + } + if (length == 0) { + return 0; + } + if (finished) { + return -1; + } + + while (true) { + if (decoder == null) { + if (!startMember()) { + return -1; + } + } + int count = decoder.read(bytes, offset, length); + if (count > 0) { + memberCrc.update(bytes, offset, count); + if (Long.MAX_VALUE - memberOutputSize < count) { + throw new IOException("Lzip member is too large"); + } + memberOutputSize += count; + return count; + } + finishMember(); + } + } + + private boolean startMember() throws IOException { + byte[] header = firstHeader; + if (header != null) { + firstHeader = null; + memberStart = counted.getCount() - LZIP_HEADER_SIZE; + } else { + memberStart = counted.getCount(); + header = new byte[LZIP_HEADER_SIZE]; + int count = readAtMost(input, header, 0, header.length); + if (count == 0) { + finished = true; + return false; + } + if (count != header.length) { + throw new IOException("Truncated lzip header"); + } + } + validateHeader(header); + int dictionarySize = getDictionarySize(header[5] & 0xff); + if (LZMAInputStream.getMemoryUsage(dictionarySize, (byte) 0x5d) > MAX_XZ_MEMORY_KIB) { + throw new IOException("Lzip dictionary exceeds memory limit"); + } + decoder = new LZMAInputStream(input, -1L, (byte) 0x5d, dictionarySize); + memberOutputSize = 0; + memberCrc = new CRC32(); + return true; + } + + private void finishMember() throws IOException { + byte[] trailer = new byte[LZIP_TRAILER_SIZE]; + readFully(input, trailer); + long expectedCrc = readLe32(trailer, 0); + long expectedDataSize = readLe64(trailer, 4); + long expectedMemberSize = readLe64(trailer, 12); + long actualMemberSize = counted.getCount() - memberStart; + if (expectedCrc != memberCrc.getValue()) { + throw new IOException("Invalid lzip member CRC"); + } + if (expectedDataSize != memberOutputSize) { + throw new IOException("Invalid lzip uncompressed size"); + } + if (expectedMemberSize != actualMemberSize || expectedMemberSize < 26) { + throw new IOException("Invalid lzip member size"); + } + decoder = null; + memberCrc = null; + } + + private static void validateHeader(byte[] header) throws IOException { + if (!hasLzipMagic(header) || header[4] != 1) { + throw new IOException("Invalid lzip header"); + } + int dictionaryCode = header[5] & 0xff; + int exponent = dictionaryCode & 0x1f; + if (exponent < 12 || exponent > 29) { + throw new IOException("Invalid lzip dictionary size"); + } + long dictionarySize = getDictionarySize(dictionaryCode); + if (dictionarySize < 4096 || dictionarySize > MAX_LZIP_DICTIONARY_SIZE) { + throw new IOException("Invalid lzip dictionary size"); + } + } + + private static int getDictionarySize(int dictionaryCode) { + int exponent = dictionaryCode & 0x1f; + int fraction = dictionaryCode >>> 5; + long dictionarySize = 1L << exponent; + dictionarySize -= dictionarySize / 16L * fraction; + if (dictionarySize > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Lzip dictionary is too large"); + } + return (int) dictionarySize; + } + + private static long readLe32(byte[] bytes, int offset) { + return (bytes[offset] & 0xffL) + | ((bytes[offset + 1] & 0xffL) << 8) + | ((bytes[offset + 2] & 0xffL) << 16) + | ((bytes[offset + 3] & 0xffL) << 24); + } + + private static long readLe64(byte[] bytes, int offset) throws IOException { + long value = 0; + for (int i = 7; i >= 0; --i) { + int next = bytes[offset + i] & 0xff; + if (value > (Long.MAX_VALUE - next) / 256L) { + throw new IOException("Lzip value is too large"); + } + value = value * 256L + next; + } + return value; + } + + private static void readFully(InputStream input, byte[] bytes) throws IOException { + int offset = 0; + while (offset < bytes.length) { + int count = input.read(bytes, offset, bytes.length - offset); + if (count < 0) { + throw new IOException("Truncated lzip trailer"); + } + if (count == 0) { + continue; + } + offset += count; + } + } + + @Override + public void close() throws IOException { + if (decoder != null) { + decoder.close(); + decoder = null; + } + input.close(); + finished = true; + } + } +} diff --git a/src/main/java/i18nupdatemod/entity/GameAssetDetail.java b/src/main/java/i18nupdatemod/entity/GameAssetDetail.java index 2b53779..d847bdd 100644 --- a/src/main/java/i18nupdatemod/entity/GameAssetDetail.java +++ b/src/main/java/i18nupdatemod/entity/GameAssetDetail.java @@ -1,17 +1,9 @@ package i18nupdatemod.entity; -import java.util.List; - public class GameAssetDetail { - public List downloads; + public String targetVersion; public String convertedFileName; public GameMetaData packMetaData; public String description; - public static class AssetDownloadDetail { - public String fileName; - public String fileUrl; - public String md5Url; - public String targetVersion; - } } diff --git a/src/main/java/i18nupdatemod/entity/ModTranslation.java b/src/main/java/i18nupdatemod/entity/ModTranslation.java new file mode 100644 index 0000000..9e1952b --- /dev/null +++ b/src/main/java/i18nupdatemod/entity/ModTranslation.java @@ -0,0 +1,35 @@ +package i18nupdatemod.entity; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Metadata for one resource namespace supplied by a mod. + * + *

The source is the original mod archive. For nested Fabric or Forge + * jars, {@code nestedJars} contains the entry names from the outer archive to + * the innermost archive.

+ */ +public class ModTranslation { + public String namespace; + public List authors; + public String displayName; + + public final Path source; + public final List nestedJars; + + public ModTranslation(String namespace, List authors, String displayName, + Path source, List nestedJars) { + this.namespace = namespace; + this.authors = authors == null + ? null + : Collections.unmodifiableList(new ArrayList<>(authors)); + this.displayName = displayName; + this.source = source; + this.nestedJars = nestedJars == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(nestedJars)); + } +} diff --git a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java index e2bf81c..5979711 100644 --- a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java +++ b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java @@ -2,13 +2,12 @@ import i18nupdatemod.I18nUpdateMod; import i18nupdatemod.util.Log; +import i18nupdatemod.util.ModUtil; import i18nupdatemod.util.Reflection; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.loader.api.FabricLoader; import java.nio.file.Path; -import java.util.HashSet; -import java.util.Map; //1.14-latest public class FabricLoaderMod implements ClientModInitializer { @@ -22,7 +21,7 @@ public void onInitializeClient() { Log.warning("Minecraft version not found"); return; } - I18nUpdateMod.init(gameDir, mcVersion, "Fabric", getMods()); + I18nUpdateMod.init(gameDir, mcVersion, "Fabric", ModUtil.getModsFromModsFolder(gameDir)); } private String getMcVersion() { @@ -47,29 +46,4 @@ private String getMcVersion() { return null; } - - private HashSet getMods() { - HashSet modList = new HashSet<>(); - try { - // Fabric - @SuppressWarnings("unchecked") final Map instance = (Map) Reflection.clazz("net.fabricmc.loader.impl.FabricLoaderImpl") - .get("INSTANCE") - .get("modMap").get(); - modList = new HashSet<>(instance.keySet()); - return modList; - } catch (Exception ignored) { - - } - try { - // Quilt - @SuppressWarnings("unchecked") final Map instance = (Map) Reflection.clazz("org.quiltmc.loader.impl.QuiltLoaderImpl") - .get("INSTANCE") - .get("modMap").get(); - modList = new HashSet<>(instance.keySet()); - return modList; - } catch (Exception ignored) { - - } - return modList; - } } diff --git a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java index dab5b80..e3ea9a1 100644 --- a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java +++ b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java @@ -21,7 +21,7 @@ public void acceptOptions(List args, File gameDir, File assetsDir, Strin Log.warning("Failed to get minecraft version."); return; } - I18nUpdateMod.init(gameDir.toPath(), mcVersion, "Forge", ModUtil.getModDomainsFromModsFolder(gameDir.toPath(), mcVersion, "Forge")); + I18nUpdateMod.init(gameDir.toPath(), mcVersion, "Forge", ModUtil.getModsFromModsFolder(gameDir.toPath())); } @Override diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 33e0421..6d58622 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -39,7 +39,7 @@ public void initialize(IEnvironment environment) { Log.warning("Minecraft version not found"); return; } - I18nUpdateMod.init(minecraftPath.get(), minecraftVersion, "Forge", ModUtil.getModDomainsFromModsFolder(minecraftPath.get(), minecraftVersion, "Forge")); + I18nUpdateMod.init(minecraftPath.get(), minecraftVersion, "Forge", ModUtil.getModsFromModsFolder(minecraftPath.get())); } @Override diff --git a/src/main/java/i18nupdatemod/util/FileUtil.java b/src/main/java/i18nupdatemod/util/FileUtil.java index f06ae65..cbe2baf 100644 --- a/src/main/java/i18nupdatemod/util/FileUtil.java +++ b/src/main/java/i18nupdatemod/util/FileUtil.java @@ -1,9 +1,7 @@ package i18nupdatemod.util; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; public class FileUtil { public static void safeCreateDir(Path path) { @@ -16,17 +14,4 @@ public static void safeCreateDir(Path path) { } } - public static void syncIfNewer(Path source, Path target) throws IOException { - if (!Files.exists(source)) { - return; - } - if (Files.exists(target) - && Files.getLastModifiedTime(target).compareTo(Files.getLastModifiedTime(source)) >= 0) { - Log.debug("Temp and current file has already been synchronized"); - return; - } - Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); - Files.setLastModifiedTime(target, Files.getLastModifiedTime(source)); - Log.info(String.format("Synchronized: %s -> %s", source, target)); - } } diff --git a/src/main/java/i18nupdatemod/util/ModUtil.java b/src/main/java/i18nupdatemod/util/ModUtil.java index b798c82..529be0c 100644 --- a/src/main/java/i18nupdatemod/util/ModUtil.java +++ b/src/main/java/i18nupdatemod/util/ModUtil.java @@ -1,67 +1,421 @@ package i18nupdatemod.util; -import java.io.*; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.moandjiezana.toml.Toml; +import i18nupdatemod.entity.ModTranslation; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.jar.JarEntry; import java.util.jar.JarInputStream; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class ModUtil { - public static HashSet getModDomainsFromModsFolder(Path minecraftPath, String minecraftVersion, String loader) { - HashSet modDomainSet = new HashSet<>(); + /** + * Discover local mod JARs for all loaders, including their nested JARs. + * This deliberately scans installed candidates rather than a loader's + * resolved mod list; development directories are not included. + */ + public static List getModsFromModsFolder(Path minecraftPath) { + List result = new ArrayList<>(); + if (minecraftPath == null) { + return result; + } + Path modsPath = minecraftPath.resolve("mods"); - String[] modsNamesList = modsPath.toFile().list((dir, name) -> name.endsWith(".jar")); - if (modsNamesList != null) { - for (String name : modsNamesList) { - modDomainSet.addAll(getModDomainFromJar(modsPath.resolve(name).toFile())); + if (!Files.isDirectory(modsPath)) { + return result; + } + + List entries; + try (Stream stream = Files.list(modsPath)) { + entries = stream + .filter(path -> Files.isRegularFile(path) + && path.getFileName().toString().toLowerCase().endsWith(".jar")) + .collect(Collectors.toList()); + } catch (IOException e) { + Log.warning("Failed to list mods directory %s: %s", modsPath, e); + return result; + } + + for (Path entry : entries) { + try { + scanArchive(entry, Collections.emptyList(), result); + } catch (Exception e) { + Log.warning("Failed to read mod %s: %s", entry, e); } } - return modDomainSet; + return result; } - private static HashSet getModDomainFromJar(File modPath) { - Log.debug(String.format("Get mod domain from %s", modPath)); - HashSet modList = new HashSet<>(); - try (FileInputStream fis = new FileInputStream(modPath)) { - modList.addAll(getModDomainFromStream(fis, modPath.getName())); - } catch (Exception e) { - Log.warning(String.format("Failed to read jar %s: %s", modPath, e)); + private static void scanArchive(Path source, List nestedJars, + List output) throws IOException { + try (InputStream input = Files.newInputStream(source)) { + scanArchive(input, source, nestedJars, output); } - return modList; } - private static HashSet getModDomainFromStream(InputStream input, String sourceName) throws IOException { - HashSet modList = new HashSet<>(); - try (JarInputStream jis = new JarInputStream(input)) { + private static void scanArchive(InputStream input, Path source, List nestedJars, + List output) throws IOException { + ParsedMod parsed = new ParsedMod(source, nestedJars); + List nestedArchives = new ArrayList<>(); + try (JarInputStream jar = new JarInputStream(input)) { JarEntry entry; - byte[] buffer = new byte[8192]; - while ((entry = jis.getNextJarEntry()) != null) { - String path = entry.getName(); - - // 匹配 assets// - if (path.startsWith("assets/")) { - String[] parts = path.split("/"); - if (parts.length >= 2) { - modList.add(parts[1]); - } - } else if (path.endsWith(".jar")) { - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - int bytesRead; - while ((bytesRead = jis.read(buffer)) != -1) { - baos.write(buffer, 0, bytesRead); - } + while ((entry = jar.getNextJarEntry()) != null) { + String path = safeArchivePath(entry.getName()); + if (path == null) { + continue; + } + collectNamespace(parsed.namespaces, path); - byte[] innerJarBytes = baos.toByteArray(); - try (ByteArrayInputStream innerStream = new ByteArrayInputStream(innerJarBytes)) { - modList.addAll(getModDomainFromStream(innerStream, path)); + String metadataKind = metadataKind(path); + boolean nestedArchive = !entry.isDirectory() && path.toLowerCase().endsWith(".jar"); + if (metadataKind != null || nestedArchive) { + byte[] bytes = readCurrentEntry(jar); + if (metadataKind != null) { + try { + parseMetadata(parsed.metadata, metadataKind, bytes); + } catch (Exception e) { + Log.warning("Failed to parse metadata %s in %s: %s", path, source, e); } - } catch (Exception innerEx) { - Log.warning(String.format("Failed to parse nested jar %s inside %s: %s", path, sourceName, innerEx)); } + if (nestedArchive) { + List chain = new ArrayList<>(nestedJars); + chain.add(path); + nestedArchives.add(new NestedArchive(bytes, chain, path)); + } + } + } + } + + addTranslations(parsed, output); + for (NestedArchive nestedArchive : nestedArchives) { + try (InputStream nestedInput = new ByteArrayInputStream(nestedArchive.bytes)) { + scanArchive(nestedInput, source, nestedArchive.chain, output); + } catch (Exception e) { + Log.warning("Failed to parse nested jar %s inside %s: %s", nestedArchive.name, source, e); + } + } + } + + private static void addTranslations(ParsedMod parsed, List output) { + List namespaces = new ArrayList<>(parsed.namespaces); + Collections.sort(namespaces); + for (String namespace : namespaces) { + MetadataRecord metadata = chooseMetadata(parsed, namespace); + output.add(new ModTranslation( + namespace, + metadata == null ? null : metadata.authors, + metadata == null ? null : metadata.displayName, + parsed.source, + parsed.nestedJars)); + } + } + + private static MetadataRecord chooseMetadata(ParsedMod parsed, String namespace) { + List exact = new ArrayList<>(); + for (MetadataRecord record : parsed.metadata) { + if (record.ownerIds.contains(namespace)) { + exact.add(record); + } + } + if (!exact.isEmpty()) { + return mergeMetadata(exact); + } + + // A single metadata document and a single discovered namespace are an + // unambiguous owner even when a loader uses an alias for its mod id. + if (parsed.metadata.size() == 1 && parsed.namespaces.size() == 1) { + return parsed.metadata.get(0); + } + return null; + } + + private static MetadataRecord mergeMetadata(List records) { + MetadataRecord merged = new MetadataRecord(); + for (MetadataRecord record : records) { + for (String ownerId : record.ownerIds) { + if (!merged.ownerIds.contains(ownerId)) { + merged.ownerIds.add(ownerId); } } + merged.displayName = mergeString(merged.displayName, record.displayName); + merged.authors = mergeAuthors(merged.authors, record.authors); + } + return merged; + } + + private static String mergeString(String left, String right) { + if (left == null) { + return right; + } + if (right == null || left.equals(right)) { + return left; + } + return null; + } + + private static List mergeAuthors(List left, List right) { + if (left == null) { + return right; + } + if (right == null || left.equals(right)) { + return left; + } + return null; + } + + private static void parseMetadata(List records, String kind, byte[] bytes) { + if ("json".equals(kind)) { + parseJsonMetadata(records, bytes); + } else { + parseTomlMetadata(records, bytes); + } + } + + private static void parseJsonMetadata(List records, byte[] bytes) { + JsonElement root = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + collectJsonRecords(records, root); + } + + private static void collectJsonRecords(List records, JsonElement element) { + if (element == null || element.isJsonNull()) { + return; + } + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + collectJsonRecords(records, child); + } + return; + } + if (!element.isJsonObject()) { + return; + } + + JsonObject object = element.getAsJsonObject(); + JsonElement modList = object.get("modList"); + if (modList != null && modList.isJsonArray()) { + collectJsonRecords(records, modList); + return; + } + + MetadataRecord record = new MetadataRecord(); + addOwner(record.ownerIds, stringValue(object.get("modid"))); + addOwner(record.ownerIds, stringValue(object.get("modId"))); + addOwner(record.ownerIds, stringValue(object.get("id"))); + JsonElement provides = object.get("provides"); + if (provides != null && provides.isJsonArray()) { + for (JsonElement provided : provides.getAsJsonArray()) { + addOwner(record.ownerIds, stringValue(provided)); + } } - return modList; + + record.displayName = firstString(object, "displayName", "name"); + record.authors = firstAuthors(object, "authors", "authorList"); + records.add(record); + } + + private static void parseTomlMetadata(List records, byte[] bytes) { + Toml root = new Toml().read(new ByteArrayInputStream(bytes)); + List mods = root.getTables("mods"); + if (mods == null) { + return; + } + for (Toml mod : mods) { + Map values = mod.toMap(); + MetadataRecord record = new MetadataRecord(); + addOwner(record.ownerIds, valueString(values.get("modId"))); + addOwner(record.ownerIds, valueString(values.get("modid"))); + record.displayName = firstValueString(values, "displayName", "name"); + record.authors = authorsValue(values.get("authors")); + records.add(record); + } + } + + private static String firstString(JsonObject object, String first, String second) { + String value = stringValue(object.get(first)); + return value == null ? stringValue(object.get(second)) : value; + } + + private static List firstAuthors(JsonObject object, String first, String second) { + JsonElement value = object.get(first); + if (value != null) { + return authorsJson(value); + } + return authorsJson(object.get(second)); + } + + private static List authorsJson(JsonElement value) { + if (value == null || value.isJsonNull()) { + return null; + } + List authors = new ArrayList<>(); + if (value.isJsonArray()) { + for (JsonElement author : value.getAsJsonArray()) { + if (author != null && author.isJsonObject()) { + addAuthor(authors, stringValue(author.getAsJsonObject().get("name"))); + } else { + addAuthor(authors, stringValue(author)); + } + } + } else { + addAuthor(authors, stringValue(value)); + } + return authors; + } + + private static List authorsValue(Object value) { + if (value == null) { + return null; + } + List authors = new ArrayList<>(); + if (value instanceof Iterable) { + for (Object author : (Iterable) value) { + if (author instanceof Map) { + addAuthor(authors, valueString(((Map) author).get("name"))); + } else { + addAuthor(authors, valueString(author)); + } + } + } else { + addAuthor(authors, valueString(value)); + } + return authors; + } + + private static void addAuthor(List authors, String author) { + if (author != null && !author.isEmpty() && !authors.contains(author)) { + authors.add(author); + } + } + + private static void addOwner(List ownerIds, String ownerId) { + if (ownerId != null && !ownerId.isEmpty() && !ownerIds.contains(ownerId)) { + ownerIds.add(ownerId); + } + } + + private static String stringValue(JsonElement element) { + if (element == null || element.isJsonNull() || !element.isJsonPrimitive() + || !element.getAsJsonPrimitive().isString()) { + return null; + } + return element.getAsString(); + } + + private static String valueString(Object value) { + return value instanceof String ? (String) value : null; + } + + private static String firstValueString(Map values, String first, String second) { + String value = valueString(values.get(first)); + return value == null ? valueString(values.get(second)) : value; + } + + private static String safeArchivePath(String path) { + if (path == null || path.isEmpty() || path.indexOf('\u0000') >= 0) { + return null; + } + String normalized = path.replace('\\', '/'); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (normalized.isEmpty() || normalized.startsWith("/")) { + return null; + } + String[] components = normalized.split("/", -1); + StringBuilder result = new StringBuilder(normalized.length()); + for (String component : components) { + if (component.isEmpty() || ".".equals(component) || "..".equals(component)) { + return null; + } + if (result.length() > 0) { + result.append('/'); + } + result.append(component); + } + return result.toString(); + } + + private static void collectNamespace(Set namespaces, String path) { + if (path == null || !path.startsWith("assets/")) { + return; + } + String remainder = path.substring("assets/".length()); + int separator = remainder.indexOf('/'); + String namespace = separator < 0 ? remainder : remainder.substring(0, separator); + if (!namespace.isEmpty()) { + namespaces.add(namespace); + } + } + + private static String metadataKind(String path) { + String normalized = path == null ? "" : path.toLowerCase(); + if ("mcmod.info".equals(normalized) || "meta-inf/mcmod.info".equals(normalized)) { + return "json"; + } + if ("fabric.mod.json".equals(normalized)) { + return "json"; + } + if ("meta-inf/mods.toml".equals(normalized) || "meta-inf/neoforge.mods.toml".equals(normalized)) { + return "toml"; + } + return null; + } + + private static byte[] readCurrentEntry(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + if (read > 0) { + output.write(buffer, 0, read); + } + } + return output.toByteArray(); + } + + private static class ParsedMod { + final Path source; + final List nestedJars; + final Set namespaces = new LinkedHashSet<>(); + final List metadata = new ArrayList<>(); + + ParsedMod(Path source, List nestedJars) { + this.source = source; + this.nestedJars = new ArrayList<>(nestedJars); + } + } + + private static class NestedArchive { + final byte[] bytes; + final List chain; + final String name; + + NestedArchive(byte[] bytes, List chain, String name) { + this.bytes = bytes; + this.chain = chain; + this.name = name; + } + } + + private static class MetadataRecord { + final List ownerIds = new ArrayList<>(); + List authors; + String displayName; } } From cf15a14e587a2d5c94add9dc38614df2e3613ac7 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:05:11 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E9=80=BB=E8=BE=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/v2/ResourcePackDownloader.java | 5 +- .../core/v2/TranslationArchive.java | 37 +-- .../i18nupdatemod/entity/ModTranslation.java | 10 +- src/main/java/i18nupdatemod/util/ModUtil.java | 231 ++++++------------ 4 files changed, 102 insertions(+), 181 deletions(-) diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 8e47155..7d3ab1a 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -120,7 +120,7 @@ public static Map selectNamespaces(List mods, Ma String namespace = resolveNamespace(mod, rules.get(rawNamespace)); if (selected.containsKey(namespace)) { // 太多了,没事别看 - Log.debug("Duplicate namespace %s, rawNamespace %s", namespace, rawNamespace); + //Log.debug("Duplicate namespace %s, rawNamespace %s", namespace, rawNamespace); continue; } selected.put(namespace, rawNamespace); @@ -228,8 +228,7 @@ private static String resolveNamespace(ModTranslation mod, String identifier) { try { String value; if ("author".equals(identifier)) { - value = mod.authors == null || mod.authors.isEmpty() - ? null : Collections.min(mod.authors); + value = mod.author; } else if ("displayName".equals(identifier)) { value = mod.displayName; } else { diff --git a/src/main/java/i18nupdatemod/core/v2/TranslationArchive.java b/src/main/java/i18nupdatemod/core/v2/TranslationArchive.java index c970b87..e94d605 100644 --- a/src/main/java/i18nupdatemod/core/v2/TranslationArchive.java +++ b/src/main/java/i18nupdatemod/core/v2/TranslationArchive.java @@ -1,8 +1,8 @@ package i18nupdatemod.core.v2; +import org.jetbrains.annotations.NotNull; import org.tukaani.xz.LZMAInputStream; -import java.io.FileInputStream; import java.io.FilterInputStream; import java.io.FilterOutputStream; import java.io.IOException; @@ -148,7 +148,7 @@ private static LocalIoException findLocalIo(Throwable error) { private static LocalInputStream openInput(Path archive) throws IOException { try { - return new LocalInputStream(new FileInputStream(archive.toFile())); + return new LocalInputStream(Files.newInputStream(archive.toFile().toPath())); } catch (IOException e) { throw wrapLocal("Cannot open translation archive: " + archive, e); } @@ -250,7 +250,7 @@ public void write(int value) throws IOException { @Override public void write(byte[] bytes, int offset, int length) throws IOException { try { - super.write(bytes, offset, length); + out.write(bytes, offset, length); } catch (IOException e) { throw wrapLocal("Cannot write decoded translation archive", e); } @@ -332,8 +332,8 @@ private static String decodeTarString(byte[] header, int offset, int length) thr return decodeUtf8(header, offset, nul - offset); } - private static String normalizeTarPath(String path) throws IOException { - if (path == null || path.length() == 0 || path.indexOf('\0') >= 0 + private static String normalizeTarPath(String path, boolean directory) throws IOException { + if (path == null || path.isEmpty() || path.indexOf('\0') >= 0 || path.indexOf('\\') >= 0 || path.startsWith("/") || path.startsWith("\\") || path.indexOf(':') >= 0) { throw new IOException("Unsafe tar path: " + path); @@ -342,7 +342,7 @@ private static String normalizeTarPath(String path) throws IOException { String[] pieces = path.split("/", -1); StringBuilder normalized = new StringBuilder(path.length()); for (String piece : pieces) { - if (piece.length() == 0 || ".".equals(piece)) { + if (piece.isEmpty() || ".".equals(piece)) { continue; } if ("..".equals(piece)) { @@ -353,7 +353,7 @@ private static String normalizeTarPath(String path) throws IOException { } normalized.append(piece); } - if (normalized.length() == 0) { + if (normalized.length() == 0 && !directory) { throw new IOException("Empty tar path: " + path); } return normalized.toString(); @@ -424,7 +424,7 @@ private static void skipFully(InputStream input, long amount) throws IOException byte[] buffer = new byte[COPY_BUFFER_SIZE]; long remaining = amount; while (remaining > 0) { - int requested = (int) Math.min((long) buffer.length, remaining); + int requested = (int) Math.min(buffer.length, remaining); int count = input.read(buffer, 0, requested); if (count < 0) { throw new IOException("Truncated tar entry"); @@ -478,7 +478,7 @@ private void readEntry(byte[] entryHeader) throws IOException { verifyChecksum(entryHeader); String headerName = decodeTarString(entryHeader, 0, 100); String prefix = decodeTarString(entryHeader, 345, 155); - if (prefix.length() > 0) { + if (!prefix.isEmpty()) { headerName = prefix + "/" + headerName; } long headerSize = parseTarNumber(entryHeader, 124, 12, "size"); @@ -515,7 +515,7 @@ private void readEntry(byte[] entryHeader) throws IOException { if (path == null) { path = headerName; } - String normalizedPath = normalizeTarPath(path); + String normalizedPath = normalizeTarPath(path, type == '5'); long size = localPax.size != null ? localPax.size : (globalPax.size != null ? globalPax.size : headerSize); if (size < 0) { @@ -529,12 +529,15 @@ private void readEntry(byte[] entryHeader) throws IOException { skipFully(input, size); } skipPadding(input, size); - if (!zipName.endsWith("/")) { - zipName += "/"; + // TAR commonly includes "." or "./" for its root directory. + if (!normalizedPath.isEmpty()) { + if (!zipName.endsWith("/")) { + zipName += "/"; + } + requireZipNameLength(zipName); + output.putNextEntry(new ZipEntry(zipName)); + output.closeEntry(); } - requireZipNameLength(zipName); - output.putNextEntry(new ZipEntry(zipName)); - output.closeEntry(); } else { if (path.endsWith("/")) { throw new IOException("Regular tar entry has a directory path"); @@ -551,7 +554,7 @@ private void readEntry(byte[] entryHeader) throws IOException { private void copyEntry(long size) throws IOException { long remaining = size; while (remaining > 0) { - int requested = (int) Math.min((long) copyBuffer.length, remaining); + int requested = (int) Math.min(copyBuffer.length, remaining); int count = input.read(copyBuffer, 0, requested); if (count < 0) { throw new IOException("Truncated tar entry"); @@ -700,7 +703,7 @@ private static long parseDecimal(byte[] bytes, int start, int end) throws IOExce } private static long parseDecimalString(String value, String description) throws IOException { - if (value.length() == 0) { + if (value.isEmpty()) { throw new IOException("Empty " + description); } long result = 0; diff --git a/src/main/java/i18nupdatemod/entity/ModTranslation.java b/src/main/java/i18nupdatemod/entity/ModTranslation.java index 9e1952b..cefb649 100644 --- a/src/main/java/i18nupdatemod/entity/ModTranslation.java +++ b/src/main/java/i18nupdatemod/entity/ModTranslation.java @@ -14,22 +14,20 @@ */ public class ModTranslation { public String namespace; - public List authors; + public String author; public String displayName; public final Path source; public final List nestedJars; - public ModTranslation(String namespace, List authors, String displayName, + public ModTranslation(String namespace, String author, String displayName, Path source, List nestedJars) { this.namespace = namespace; - this.authors = authors == null - ? null - : Collections.unmodifiableList(new ArrayList<>(authors)); + this.author = author; this.displayName = displayName; this.source = source; this.nestedJars = nestedJars == null - ? Collections.emptyList() + ? Collections.emptyList() : Collections.unmodifiableList(new ArrayList<>(nestedJars)); } } diff --git a/src/main/java/i18nupdatemod/util/ModUtil.java b/src/main/java/i18nupdatemod/util/ModUtil.java index 529be0c..7ed1e43 100644 --- a/src/main/java/i18nupdatemod/util/ModUtil.java +++ b/src/main/java/i18nupdatemod/util/ModUtil.java @@ -54,7 +54,7 @@ public static List getModsFromModsFolder(Path minecraftPath) { for (Path entry : entries) { try { - scanArchive(entry, Collections.emptyList(), result); + scanArchive(entry, Collections.emptyList(), result); } catch (Exception e) { Log.warning("Failed to read mod %s: %s", entry, e); } @@ -84,20 +84,18 @@ private static void scanArchive(InputStream input, Path source, List nes String metadataKind = metadataKind(path); boolean nestedArchive = !entry.isDirectory() && path.toLowerCase().endsWith(".jar"); - if (metadataKind != null || nestedArchive) { - byte[] bytes = readCurrentEntry(jar); - if (metadataKind != null) { - try { - parseMetadata(parsed.metadata, metadataKind, bytes); - } catch (Exception e) { - Log.warning("Failed to parse metadata %s in %s: %s", path, source, e); - } - } - if (nestedArchive) { - List chain = new ArrayList<>(nestedJars); - chain.add(path); - nestedArchives.add(new NestedArchive(bytes, chain, path)); + if (metadataKind != null && !entry.isDirectory()) { + boolean rootMetadata = path.indexOf('/') < 0; + if (parsed.metadataPath == null + || (rootMetadata && parsed.metadataPath.indexOf('/') >= 0)) { + parsed.metadataPath = path; + parsed.metadataBytes = readCurrentEntry(jar); } + } else if (nestedArchive) { + byte[] bytes = readCurrentEntry(jar); + List chain = new ArrayList<>(nestedJars); + chain.add(path); + nestedArchives.add(new NestedArchive(bytes, chain, path)); } } } @@ -113,137 +111,66 @@ private static void scanArchive(InputStream input, Path source, List nes } private static void addTranslations(ParsedMod parsed, List output) { + MetadataRecord metadata = null; + if (parsed.metadataPath != null) { + try { + metadata = parseMetadata(metadataKind(parsed.metadataPath), parsed.metadataBytes); + } catch (Exception e) { + Log.warning("Failed to parse metadata %s in %s: %s", parsed.metadataPath, parsed.source, e); + } + } List namespaces = new ArrayList<>(parsed.namespaces); Collections.sort(namespaces); for (String namespace : namespaces) { - MetadataRecord metadata = chooseMetadata(parsed, namespace); output.add(new ModTranslation( namespace, - metadata == null ? null : metadata.authors, + metadata == null ? null : metadata.author, metadata == null ? null : metadata.displayName, parsed.source, parsed.nestedJars)); } } - private static MetadataRecord chooseMetadata(ParsedMod parsed, String namespace) { - List exact = new ArrayList<>(); - for (MetadataRecord record : parsed.metadata) { - if (record.ownerIds.contains(namespace)) { - exact.add(record); - } - } - if (!exact.isEmpty()) { - return mergeMetadata(exact); - } - - // A single metadata document and a single discovered namespace are an - // unambiguous owner even when a loader uses an alias for its mod id. - if (parsed.metadata.size() == 1 && parsed.namespaces.size() == 1) { - return parsed.metadata.get(0); - } - return null; - } - - private static MetadataRecord mergeMetadata(List records) { - MetadataRecord merged = new MetadataRecord(); - for (MetadataRecord record : records) { - for (String ownerId : record.ownerIds) { - if (!merged.ownerIds.contains(ownerId)) { - merged.ownerIds.add(ownerId); - } - } - merged.displayName = mergeString(merged.displayName, record.displayName); - merged.authors = mergeAuthors(merged.authors, record.authors); - } - return merged; - } - - private static String mergeString(String left, String right) { - if (left == null) { - return right; - } - if (right == null || left.equals(right)) { - return left; - } - return null; - } - - private static List mergeAuthors(List left, List right) { - if (left == null) { - return right; - } - if (right == null || left.equals(right)) { - return left; - } - return null; - } - - private static void parseMetadata(List records, String kind, byte[] bytes) { + private static MetadataRecord parseMetadata(String kind, byte[] bytes) { if ("json".equals(kind)) { - parseJsonMetadata(records, bytes); - } else { - parseTomlMetadata(records, bytes); + return parseJsonMetadata(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))); } + List mods = new Toml().read(new ByteArrayInputStream(bytes)).getTables("mods"); + if (mods == null || mods.isEmpty()) { + return null; + } + Map values = mods.get(0).toMap(); + MetadataRecord record = new MetadataRecord(); + record.displayName = firstValueString(values, "displayName", "name"); + record.author = authorValue(values.get("authors")); + return record; } - private static void parseJsonMetadata(List records, byte[] bytes) { - JsonElement root = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); - collectJsonRecords(records, root); - } - - private static void collectJsonRecords(List records, JsonElement element) { + private static MetadataRecord parseJsonMetadata(JsonElement element) { if (element == null || element.isJsonNull()) { - return; + return null; } if (element.isJsonArray()) { for (JsonElement child : element.getAsJsonArray()) { - collectJsonRecords(records, child); + MetadataRecord record = parseJsonMetadata(child); + if (record != null) { + return record; + } } - return; + return null; } if (!element.isJsonObject()) { - return; + return null; } - JsonObject object = element.getAsJsonObject(); JsonElement modList = object.get("modList"); if (modList != null && modList.isJsonArray()) { - collectJsonRecords(records, modList); - return; + return parseJsonMetadata(modList); } - MetadataRecord record = new MetadataRecord(); - addOwner(record.ownerIds, stringValue(object.get("modid"))); - addOwner(record.ownerIds, stringValue(object.get("modId"))); - addOwner(record.ownerIds, stringValue(object.get("id"))); - JsonElement provides = object.get("provides"); - if (provides != null && provides.isJsonArray()) { - for (JsonElement provided : provides.getAsJsonArray()) { - addOwner(record.ownerIds, stringValue(provided)); - } - } - record.displayName = firstString(object, "displayName", "name"); - record.authors = firstAuthors(object, "authors", "authorList"); - records.add(record); - } - - private static void parseTomlMetadata(List records, byte[] bytes) { - Toml root = new Toml().read(new ByteArrayInputStream(bytes)); - List mods = root.getTables("mods"); - if (mods == null) { - return; - } - for (Toml mod : mods) { - Map values = mod.toMap(); - MetadataRecord record = new MetadataRecord(); - addOwner(record.ownerIds, valueString(values.get("modId"))); - addOwner(record.ownerIds, valueString(values.get("modid"))); - record.displayName = firstValueString(values, "displayName", "name"); - record.authors = authorsValue(values.get("authors")); - records.add(record); - } + record.author = firstAuthor(object, "authors", "authorList"); + return record; } private static String firstString(JsonObject object, String first, String second) { @@ -251,63 +178,55 @@ private static String firstString(JsonObject object, String first, String second return value == null ? stringValue(object.get(second)) : value; } - private static List firstAuthors(JsonObject object, String first, String second) { + private static String firstAuthor(JsonObject object, String first, String second) { JsonElement value = object.get(first); if (value != null) { - return authorsJson(value); + return authorJson(value); } - return authorsJson(object.get(second)); + return authorJson(object.get(second)); } - private static List authorsJson(JsonElement value) { + private static String authorJson(JsonElement value) { if (value == null || value.isJsonNull()) { return null; } - List authors = new ArrayList<>(); + String selected = null; if (value.isJsonArray()) { for (JsonElement author : value.getAsJsonArray()) { - if (author != null && author.isJsonObject()) { - addAuthor(authors, stringValue(author.getAsJsonObject().get("name"))); - } else { - addAuthor(authors, stringValue(author)); - } + String name = author != null && author.isJsonObject() + ? stringValue(author.getAsJsonObject().get("name")) : stringValue(author); + selected = minAuthor(selected, name); } } else { - addAuthor(authors, stringValue(value)); + selected = minAuthor(null, stringValue(value)); } - return authors; + return selected; } - private static List authorsValue(Object value) { + private static String authorValue(Object value) { if (value == null) { return null; } - List authors = new ArrayList<>(); + String selected = null; if (value instanceof Iterable) { for (Object author : (Iterable) value) { - if (author instanceof Map) { - addAuthor(authors, valueString(((Map) author).get("name"))); - } else { - addAuthor(authors, valueString(author)); - } + String name = author instanceof Map + ? valueString(((Map) author).get("name")) : valueString(author); + selected = minAuthor(selected, name); } } else { - addAuthor(authors, valueString(value)); + selected = minAuthor(null, valueString(value)); } - return authors; + return selected; } - private static void addAuthor(List authors, String author) { - if (author != null && !author.isEmpty() && !authors.contains(author)) { - authors.add(author); + private static String minAuthor(String selected, String candidate) { + if (candidate == null || candidate.isEmpty()) { + return selected; } + return selected == null || candidate.compareTo(selected) < 0 ? candidate : selected; } - private static void addOwner(List ownerIds, String ownerId) { - if (ownerId != null && !ownerId.isEmpty() && !ownerIds.contains(ownerId)) { - ownerIds.add(ownerId); - } - } private static String stringValue(JsonElement element) { if (element == null || element.isJsonNull() || !element.isJsonPrimitive() @@ -365,14 +284,16 @@ private static void collectNamespace(Set namespaces, String path) { private static String metadataKind(String path) { String normalized = path == null ? "" : path.toLowerCase(); - if ("mcmod.info".equals(normalized) || "meta-inf/mcmod.info".equals(normalized)) { - return "json"; - } - if ("fabric.mod.json".equals(normalized)) { - return "json"; + if (normalized.startsWith("meta-inf/")) { + normalized = normalized.substring("meta-inf/".length()); } - if ("meta-inf/mods.toml".equals(normalized) || "meta-inf/neoforge.mods.toml".equals(normalized)) { - return "toml"; + switch (normalized) { + case "mcmod.info": + case "fabric.mod.json": + return "json"; + case "mods.toml": + case "neoforge.mods.toml": + return "toml"; } return null; } @@ -393,7 +314,8 @@ private static class ParsedMod { final Path source; final List nestedJars; final Set namespaces = new LinkedHashSet<>(); - final List metadata = new ArrayList<>(); + String metadataPath; + byte[] metadataBytes; ParsedMod(Path source, List nestedJars) { this.source = source; @@ -414,8 +336,7 @@ private static class NestedArchive { } private static class MetadataRecord { - final List ownerIds = new ArrayList<>(); - List authors; + String author; String displayName; } } From b75fa7ecf8403a914fb51539d269742b9ba37a46 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:55:03 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(json):=20=E4=BF=AE=E5=A4=8D1.7.10?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 ++- .../java/i18nupdatemod/core/v2/ResourcePackDownloader.java | 5 +++-- src/main/java/i18nupdatemod/util/ModUtil.java | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index ffe18dd..7bfed25 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -101,7 +101,8 @@ dependencies { implementation("commons-io:commons-io:2.16.1") implementation("org.ow2.asm:asm:9.7") - implementation("com.google.code.gson:gson:2.11.0") + // Minecraft supplies Gson at runtime; compile against the 1.7.10 API baseline. + implementation("com.google.code.gson:gson:2.2.4") } tasks.test { diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 7d3ab1a..dea5e6c 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -1,9 +1,9 @@ package i18nupdatemod.core.v2; +import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; -import com.google.gson.JsonParser; import i18nupdatemod.entity.ModTranslation; import i18nupdatemod.util.DigestUtil; import i18nupdatemod.util.Log; @@ -32,6 +32,7 @@ import java.util.concurrent.TimeUnit; public class ResourcePackDownloader { + private static final Gson GSON = new Gson(); private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); private static final long ICON_UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(30); @@ -46,7 +47,7 @@ public static Manifest loadManifest(String baseUrl, String version) throws IOExc private static Manifest parseManifest(InputStream input) throws IOException { final JsonObject json; try { - JsonElement root = JsonParser.parseReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + JsonElement root = GSON.fromJson(new InputStreamReader(input, StandardCharsets.UTF_8), JsonElement.class); if (root == null || !root.isJsonObject()) { throw new IOException("Manifest root must be an object"); } diff --git a/src/main/java/i18nupdatemod/util/ModUtil.java b/src/main/java/i18nupdatemod/util/ModUtil.java index 7ed1e43..f7a7ac7 100644 --- a/src/main/java/i18nupdatemod/util/ModUtil.java +++ b/src/main/java/i18nupdatemod/util/ModUtil.java @@ -1,8 +1,8 @@ package i18nupdatemod.util; +import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import com.moandjiezana.toml.Toml; import i18nupdatemod.entity.ModTranslation; @@ -25,6 +25,8 @@ import java.util.stream.Stream; public class ModUtil { + private static final Gson GSON = new Gson(); + /** * Discover local mod JARs for all loaders, including their nested JARs. * This deliberately scans installed candidates rather than a loader's @@ -133,7 +135,7 @@ private static void addTranslations(ParsedMod parsed, List outpu private static MetadataRecord parseMetadata(String kind, byte[] bytes) { if ("json".equals(kind)) { - return parseJsonMetadata(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))); + return parseJsonMetadata(GSON.fromJson(new String(bytes, StandardCharsets.UTF_8), JsonElement.class)); } List mods = new Toml().read(new ByteArrayInputStream(bytes)).getTables("mods"); if (mods == null || mods.isEmpty()) { From 3a7da70f43197954234a73e426badc09b6b22d37 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:51:02 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat:=201.10.2-=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=90=88=E5=B9=B61.12.2=E7=9A=84=E7=BF=BB?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/i18nupdatemod/util/ModUtil.java | 13 +++++++++++++ src/main/resources/i18nMetaData.json | 8 +++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/i18nupdatemod/util/ModUtil.java b/src/main/java/i18nupdatemod/util/ModUtil.java index f7a7ac7..d04668a 100644 --- a/src/main/java/i18nupdatemod/util/ModUtil.java +++ b/src/main/java/i18nupdatemod/util/ModUtil.java @@ -61,9 +61,22 @@ public static List getModsFromModsFolder(Path minecraftPath) { Log.warning("Failed to read mod %s: %s", entry, e); } } + printMods(result); return result; } + private static void printMods(List result) { + StringBuilder mods = new StringBuilder("Found mods: ["); + for (int i = 0; i < result.size(); i++) { + mods.append(result.get(i).displayName); + if (i != result.size() - 1) { + mods.append(", "); + } + } + mods.append("]"); + Log.info(mods.toString()); + } + private static void scanArchive(Path source, List nestedJars, List output) throws IOException { try (InputStream input = Files.newInputStream(source)) { diff --git a/src/main/resources/i18nMetaData.json b/src/main/resources/i18nMetaData.json index 3c174bd..81de2cb 100644 --- a/src/main/resources/i18nMetaData.json +++ b/src/main/resources/i18nMetaData.json @@ -5,16 +5,14 @@ "gameVersions": "[1.6.1,1.8.9]", "packFormat": 1, "convertFrom": [ - "1.10.2", - "1.12.2" + "1.10.2" ] }, { "gameVersions": "[1.9,1.10.2]", "packFormat": 2, "convertFrom": [ - "1.10.2", - "1.12.2" + "1.10.2" ] }, { @@ -178,7 +176,7 @@ "gameVersions": "[1.21.9,1.21.11]", "minFormat": 69, "maxFormat": 75, - "convertFrom":[ + "convertFrom": [ "1.21", "1.20", "1.19" From 48f9ec1c0b843447b25611e7164e1ce325fd7ad9 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:14:49 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(1.6.4):=20=E4=BF=AE=E5=A4=8D=E4=BD=8E?= =?UTF-8?q?=E7=89=88=E6=9C=ACMC=E4=BD=BF=E7=94=A8Java=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E8=AF=BB=E5=8F=96=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/ResourcePackConverter.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java index ba51c4b..715e36a 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java +++ b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java @@ -7,6 +7,7 @@ import i18nupdatemod.util.Log; import org.apache.commons.io.IOUtils; +import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; @@ -99,7 +100,23 @@ private byte[] convertPackMeta(PackMeta meta, GameMetaData metaData, String desc meta.pack.min_format = metaData.useNewFormat() ? metaData.minFormat : null; meta.pack.max_format = metaData.useNewFormat() ? metaData.maxFormat : null; meta.pack.description = description; - return GSON.toJson(meta).getBytes(StandardCharsets.UTF_8); + // Older clients read pack.mcmeta using the default charset, not UTF-8. + // ASCII JSON escapes also work on snapshots without version-specific checks. + String json = GSON.toJson(meta); + ByteArrayOutputStream output = new ByteArrayOutputStream(json.length()); + for (int i = 0; i < json.length(); i++) { + char character = json.charAt(i); + if (character <= 0x7F) { + output.write(character); + } else { + output.write('\\'); + output.write('u'); + for (int shift = 12; shift >= 0; shift -= 4) { + output.write(Character.forDigit((character >>> shift) & 0xF, 16)); + } + } + } + return output.toByteArray(); } private static class PackMeta { From 81eae52eaf90373c33a27e1f6c87613445e3d074 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:01:48 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=8126.x=E3=80=81?= =?UTF-8?q?=E6=94=AF=E6=8C=811.21.11+neoforge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +++--- build.gradle.kts | 9 +++++ gradle.properties | 2 +- .../java/i18nupdatemod/I18nUpdateMod.java | 6 +-- .../modlauncher/ModLauncherService.java | 5 ++- .../neoforgeloader/NeoForgeBootstrap.java | 40 +++++++++++++++++++ src/main/java/i18nupdatemod/util/ModUtil.java | 4 +- ...oforgespi.earlywindow.GraphicsBootstrapper | 1 + src/main/resources/i18nMetaData.json | 22 ++++++++++ 9 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java create mode 100644 src/main/resources/META-INF/services/net.neoforged.neoforgespi.earlywindow.GraphicsBootstrapper diff --git a/README.md b/README.md index 9a8764b..62fb1d0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ 更现代的自动汉化更新模组。 -「[简体中文资源包(Minecraft Mod Language Package)](https://github.com/CFPAOrg/Minecraft-Mod-Language-Package)」是由「[CFPAOrg](http://cfpa.team/)」团队维护的「自动汉化资源包」,可以将一些Mod中的文本翻译为中文。 +「[简体中文资源包(Minecraft Mod Language Package)](https://github.com/CFPAOrg/Minecraft-Mod-Language-Package) +」是由「[CFPAOrg](http://cfpa.team/)」团队维护的「自动汉化资源包」,可以将一些Mod中的文本翻译为中文。 本Mod用于自动下载、更新、应用「简体中文资源包」。 ## 下载 @@ -21,9 +22,9 @@ ## 支持的版本 -- Minecraft:1.6.1~1.21.10 都支持 +- Minecraft:1.6.1~1.21.11、26.1~26.1.2 都支持 - Mod加载器:MinecraftForge、NeoForge、Fabric、Quilt 都支持 -- Java:8~21 都支持 +- Java:8~25 都支持 仅仅需要在mods文件夹中放置本Mod的jar文件即可,Mod本身与各主流Minecraft版本、Mod Loader、Java版本均兼容,Mod本身不需要进行任何版本隔离。 @@ -31,13 +32,14 @@ 为了尽可能实用,目前本Mod会根据游戏版本自动下载、合并、转换「简体中文资源包」。 -- 官方资源:1.10.2、1.12.2、1.16、1.18、1.19、1.20、1.21 +- 官方资源:1.10.2、1.12.2、1.16、1.18、1.19、1.20、1.21、26.1 - 合并转换:会合并加转换最近版本的一些资源包,尽可能做最大化的支持 - 特别说明:1.13开始将语言文件变化为json格式,所以不能将1.12.2的资源包用于1.13以上,反之同理 ## 开发环境 -请使用Java 8及以上的JDK构建。 +构建需要 JDK 21(用于编译新版 NeoForge 的服务接口);产物仍使用 Java 8 字节码,旧版游戏的 Java 运行要求不变。 + ```shell gradle clean shadowJar ``` \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7bfed25..0ccb7b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,12 +11,14 @@ group = "i18nupdatemod" version = project.properties["version"].toString() + if ("false" == System.getenv("IS_SNAPSHOT")) "" else "-SNAPSHOT" java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } tasks.withType { options.encoding = "UTF-8" + options.release.set(8) } fun ShadowJar.configureI18nPackaging() { manifest { @@ -80,6 +82,7 @@ repositories { maven("https://libraries.minecraft.net/") maven("https://maven.fabricmc.net/") maven("https://files.minecraftforge.net/maven") + maven("https://maven.neoforged.net/releases/") maven("https://repo.runelite.net/") } @@ -94,6 +97,12 @@ dependencies { implementation("org.tukaani:xz:1.10") implementation("com.moandjiezana.toml:toml4j:0.7.2") compileOnly("org.jetbrains:annotations:24.1.0") + // Only the early-service interface is linked; never bundle loader implementation classes. + compileOnly("net.neoforged.fancymodloader:loader:10.0.34") { + attributes { + attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21) + } + } implementation("net.fabricmc:fabric-loader:0.15.9") implementation("cpw.mods:modlauncher:8.1.3") diff --git a/gradle.properties b/gradle.properties index 53d9848..435a08c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ version=3.7.1 -minecraft=1.6.1,1.6.2,1.6.4,1.7.2,1.7.10,1.8,1.8.8,1.8.9,1.9,1.9.4,1.10,1.10.2,1.11,1.11.2,1.12,1.12.1,1.12.2,1.13.2,1.14,1.14.1,1.14.2,1.14.3,1.14.4,1.15,1.15.1,1.15.2,1.16,1.16.1,1.16.2,1.16.3,1.16.4,1.16.5,1.17,1.17.1,1.18,1.18.1,1.18.2,1.19,1.19.1,1.19.2,1.19.3,1.19.4,1.20,1.20.1,1.20.2,1.20.3,1.20.4,1.20.5,1.20.6,1.21,1.21.1,1.21.2,1.21.3,1.21.4,1.21.5,1.21.6,1.21.7,1.21.8,1.21.9,1.21.10,1.21.11 +minecraft=1.6.1,1.6.2,1.6.4,1.7.2,1.7.10,1.8,1.8.8,1.8.9,1.9,1.9.4,1.10,1.10.2,1.11,1.11.2,1.12,1.12.1,1.12.2,1.13.2,1.14,1.14.1,1.14.2,1.14.3,1.14.4,1.15,1.15.1,1.15.2,1.16,1.16.1,1.16.2,1.16.3,1.16.4,1.16.5,1.17,1.17.1,1.18,1.18.1,1.18.2,1.19,1.19.1,1.19.2,1.19.3,1.19.4,1.20,1.20.1,1.20.2,1.20.3,1.20.4,1.20.5,1.20.6,1.21,1.21.1,1.21.2,1.21.3,1.21.4,1.21.5,1.21.6,1.21.7,1.21.8,1.21.9,1.21.10,1.21.11,26.1,26.1.1,26.1.2 curseforge=NeoForge,Forge,Fabric,Quilt,Client,Java 8,Java 9,Java 10,Java 11,Java 12,Java 13,Java 14,Java 15,Java 16,Java 17,Java 18 \ No newline at end of file diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index 11694d2..c67b55b 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -6,6 +6,7 @@ import i18nupdatemod.core.ResourcePackUpdater; import i18nupdatemod.entity.ModTranslation; import i18nupdatemod.util.Log; +import i18nupdatemod.util.Version; import org.jetbrains.annotations.NotNull; import java.io.*; @@ -49,8 +50,6 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } catch (ClassNotFoundException ignored) { } - int minecraftMajorVersion = Integer.parseInt(minecraftVersion.split("\\.")[1]); - try { Path resourcePackDirectory = minecraftPath.resolve("resourcepacks"); Path cacheRoot = Paths.get(localStorage, "." + MOD_ID); @@ -60,7 +59,8 @@ public static void init(Path minecraftPath, String minecraftVersion, String load //Apply resource pack GameConfig config = new GameConfig(minecraftPath.resolve("options.txt")); config.addResourcePack("Minecraft-Mod-Language-Modpack", - (minecraftMajorVersion <= 12 ? "" : "file/") + convertedPack.getFileName().toString()); + (Version.from(minecraftVersion).compareTo(Version.from("1.13")) < 0 ? "" : "file/") + + convertedPack.getFileName().toString()); config.writeToFile(); } catch (Exception e) { Log.warning(String.format("Failed to update resource pack: %s", e)); diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 6d58622..74075ed 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -19,7 +19,8 @@ import static i18nupdatemod.I18nUpdateMod.GSON; -//1.13-latest +//MinecraftForge: 1.13-latest +//NeoForge: 1.20.1-1.21.8 public class ModLauncherService implements ITransformationService { @Override public @NotNull String name() { @@ -59,7 +60,7 @@ public void onLoad(IEnvironment env, Set otherServices) throws Incompati private String getMinecraftVersion() { // MinecraftForge 1.13~1.20.2 - // NeoForge 1.20.1~ + // NeoForge 1.20.1~1.21.8 try { String[] args = (String[]) Reflection.clazz(Launcher.INSTANCE).get("argumentHandler").get("args").get(); for (int i = 0; i < args.length - 1; ++i) { diff --git a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java new file mode 100644 index 0000000..999f05f --- /dev/null +++ b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java @@ -0,0 +1,40 @@ +package i18nupdatemod.neoforgeloader; + +import i18nupdatemod.I18nUpdateMod; +import i18nupdatemod.util.Log; +import i18nupdatemod.util.ModUtil; +import i18nupdatemod.util.Reflection; +import net.neoforged.neoforgespi.earlywindow.GraphicsBootstrapper; + +import java.nio.file.Path; + +//NeoForge: 1.21.9-latest +public class NeoForgeBootstrap implements GraphicsBootstrapper { + @Override + public String name() { + return "I18nUpdateMod"; + } + + @Override + public void bootstrap(String[] arguments) { + try { + Reflection loader; + try { + loader = Reflection.clazz("net.neoforged.fml.loading.FMLLoader").get("getCurrent()"); + } catch (NoSuchMethodException ignored) { + // Older FML also discovers this SPI, but ModLauncher already runs our update there. + return; + } + if (!"CLIENT".equals(loader.get("getDist()").get().toString())) { + return; + } + Path gameDir = (Path) loader.get("getGameDir()").get(); + Log.setMinecraftLogFile(gameDir); + // FML consumes --fml.mcVersion before calling bootstrappers; do not parse arguments. + String version = (String) loader.get("getVersionInfo()").get("mcVersion()").get(); + I18nUpdateMod.init(gameDir, version, "Forge", ModUtil.getModsFromModsFolder(gameDir)); + } catch (Exception e) { + Log.warning("Failed to initialize NeoForge resource pack update: %s", e); + } + } +} diff --git a/src/main/java/i18nupdatemod/util/ModUtil.java b/src/main/java/i18nupdatemod/util/ModUtil.java index d04668a..92fa01c 100644 --- a/src/main/java/i18nupdatemod/util/ModUtil.java +++ b/src/main/java/i18nupdatemod/util/ModUtil.java @@ -66,9 +66,9 @@ public static List getModsFromModsFolder(Path minecraftPath) { } private static void printMods(List result) { - StringBuilder mods = new StringBuilder("Found mods: ["); + StringBuilder mods = new StringBuilder("Found resource namespaces: ["); for (int i = 0; i < result.size(); i++) { - mods.append(result.get(i).displayName); + mods.append(result.get(i).namespace); if (i != result.size() - 1) { mods.append(", "); } diff --git a/src/main/resources/META-INF/services/net.neoforged.neoforgespi.earlywindow.GraphicsBootstrapper b/src/main/resources/META-INF/services/net.neoforged.neoforgespi.earlywindow.GraphicsBootstrapper new file mode 100644 index 0000000..c985cb4 --- /dev/null +++ b/src/main/resources/META-INF/services/net.neoforged.neoforgespi.earlywindow.GraphicsBootstrapper @@ -0,0 +1 @@ +i18nupdatemod.neoforgeloader.NeoForgeBootstrap diff --git a/src/main/resources/i18nMetaData.json b/src/main/resources/i18nMetaData.json index 81de2cb..67d57b4 100644 --- a/src/main/resources/i18nMetaData.json +++ b/src/main/resources/i18nMetaData.json @@ -181,6 +181,16 @@ "1.20", "1.19" ] + }, + { + "gameVersions": "[26.1,26.3]", + "minFormat": 76, + "maxFormat": 97, + "convertFrom": [ + "26.1", + "1.21", + "1.20" + ] } ], "assets": [ @@ -249,6 +259,18 @@ "loader": "Fabric", "filename": "Minecraft-Mod-Language-Modpack-1-21-Fabric.zip", "md5Filename": "1.21-fabric.md5" + }, + { + "targetVersion": "26.1", + "loader": "Forge", + "filename": "Minecraft-Mod-Language-Modpack-26-1.zip", + "md5Filename": "26.1.md5" + }, + { + "targetVersion": "26.1", + "loader": "Fabric", + "filename": "Minecraft-Mod-Language-Modpack-26-1-Fabric.zip", + "md5Filename": "26.1-fabric.md5" } ] } \ No newline at end of file From ee973c4109687a9c5839cfef4954f8f6b7c3c598 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:44:08 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(toml):=20=E4=BF=AE=E5=A4=8Dtoml?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E9=97=AE=E9=A2=98=20feat(download):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A016=E5=B9=B6=E8=A1=8C=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + build.gradle.kts | 14 +- .../java/i18nupdatemod/core/GameConfig.java | 2 + .../core/v2/ResourcePackDownloader.java | 203 ++++++++++++++++-- src/main/java/i18nupdatemod/util/Log.java | 2 +- src/main/java/i18nupdatemod/util/ModUtil.java | 11 +- 6 files changed, 208 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 62fb1d0..5e7993d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ - 合并转换:会合并加转换最近版本的一些资源包,尽可能做最大化的支持 - 特别说明:1.13开始将语言文件变化为json格式,所以不能将1.12.2的资源包用于1.13以上,反之同理 +V2 按模组下载翻译时最多使用 16 路并行,结果按完成顺序收集,不维护组包顺序,缓存策略不变;旧版整包下载回退流程保持原策略。 + ## 开发环境 构建需要 JDK 21(用于编译新版 NeoForge 的服务接口);产物仍使用 Java 8 字节码,旧版游戏的 Java 运行要求不变。 diff --git a/build.gradle.kts b/build.gradle.kts index 0ccb7b3..5b6f3da 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,11 +32,12 @@ fun ShadowJar.configureI18nPackaging() { archiveBaseName.set("I18nUpdateMod") relocate("com.google.archivepatcher", "include.com.google.archivepatcher") relocate("org.tukaani.xz", "include.org.tukaani.xz") - relocate("com.moandjiezana.toml", "include.com.moandjiezana.toml") + relocate("com.electronwill.nightconfig", "include.com.electronwill.nightconfig") dependencies { include(dependency("net.runelite.archive-patcher:archive-patcher-applier:.*")) include(dependency("org.tukaani:xz:.*")) - include(dependency("com.moandjiezana.toml:toml4j:.*")) + include(dependency("com.electronwill.night-config:core:.*")) + include(dependency("com.electronwill.night-config:toml:.*")) } exclude("LICENSE") } @@ -93,9 +94,16 @@ configurations.configureEach { dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.3") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.3") + // Transitive resolution is disabled, including for the test configurations. + testImplementation("org.apiguardian:apiguardian-api:1.1.2") + testImplementation("org.opentest4j:opentest4j:1.3.0") + testImplementation("org.junit.platform:junit-platform-commons:1.10.3") + testRuntimeOnly("org.junit.platform:junit-platform-engine:1.10.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.3") implementation("net.runelite.archive-patcher:archive-patcher-applier:1.2") implementation("org.tukaani:xz:1.10") - implementation("com.moandjiezana.toml:toml4j:0.7.2") + implementation("com.electronwill.night-config:core:3.6.7") + implementation("com.electronwill.night-config:toml:3.6.7") compileOnly("org.jetbrains:annotations:24.1.0") // Only the early-service interface is linked; never bundle loader implementation classes. compileOnly("net.neoforged.fancymodloader:loader:10.0.34") { diff --git a/src/main/java/i18nupdatemod/core/GameConfig.java b/src/main/java/i18nupdatemod/core/GameConfig.java index 4a844b0..dd20e71 100644 --- a/src/main/java/i18nupdatemod/core/GameConfig.java +++ b/src/main/java/i18nupdatemod/core/GameConfig.java @@ -51,4 +51,6 @@ public void addResourcePack(String baseName, String resourcePack) { Log.info(String.format("Resource Packs: %s", configs.get("resourcePacks"))); // configs.put("lang", "zh_cn"); } + } + diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index dea5e6c..7e02557 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.InterruptedIOException; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; @@ -26,15 +27,26 @@ import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.concurrent.CompletionService; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; public class ResourcePackDownloader { private static final Gson GSON = new Gson(); private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); private static final long ICON_UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(30); + private static final int MAX_CONCURRENT_DOWNLOADS = 16; public static Manifest loadManifest(String baseUrl, String version) throws IOException { String root = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; @@ -152,7 +164,8 @@ public static List download(String version, Map namespaces deleteBlacklisted(modCache, blocked); String versionUrl = root + encode(version) + "/"; - List sourcePaths = new ArrayList<>(); + List requests = new ArrayList<>(); + Map cacheLocks = new HashMap<>(); for (Map.Entry entry : namespaces.entrySet()) { String namespace = entry.getKey(); String rawNamespace = entry.getValue(); @@ -163,29 +176,185 @@ public static List download(String version, Map namespaces Path cached = modCache.resolve(encode(namespace) + ".zip"); Path md5File = modCache.resolve(encode(namespace) + ".md5"); + String cacheKey = cached.toAbsolutePath().normalize().toString().toLowerCase(Locale.ROOT); + ReentrantLock cacheLock = cacheLocks.get(cacheKey); + if (cacheLock == null) { + cacheLock = new ReentrantLock(); + cacheLocks.put(cacheKey, cacheLock); + } String assetUrl = versionUrl + "assets/" + encode(namespace); - try { - updateMod(assetUrl, rawNamespace, cached, md5File); - } catch (HttpStatusException e) { - if (e.status == 404 || e.status == 410) { - // 太多了,没事别看( - //Log.debug("No exact translation asset for %s/%s; keeping local cache if present", version, namespace); - } else { - Log.warning("Translation asset %s/%s returned HTTP %s; aborting new pipeline", - version, namespace, e.status); - throw e; + requests.add(new DownloadRequest( + version, namespace, rawNamespace, cached, md5File, assetUrl, cacheLock)); + } + if (requests.isEmpty()) { + return new ArrayList<>(); + } + + ExecutorService executor = Executors.newFixedThreadPool( + Math.min(MAX_CONCURRENT_DOWNLOADS, requests.size()), + downloadThreadFactory()); + CompletionService completions = new ExecutorCompletionService<>(executor); + List> futures = new ArrayList<>(requests.size()); + List sourcePaths = new ArrayList<>(requests.size()); + try { + for (DownloadRequest request : requests) { + futures.add(completions.submit(() -> downloadOne(request))); + } + executor.shutdown(); + + for (int completed = 0; completed < requests.size(); completed++) { + Path source = completions.take().get(); + if (source != null) { + sourcePaths.add(source); } - } catch (AssetFailure e) { - Log.warning("Failed to update translation %s; keeping local cache if present: %s", - namespace, e.getMessage()); } - if (Files.isRegularFile(cached)) { - sourcePaths.add(cached); + awaitTermination(executor); + + return sourcePaths; + } catch (InterruptedException e) { + cancelAndJoin(executor, futures); + Thread.currentThread().interrupt(); + InterruptedIOException interrupted = new InterruptedIOException( + "Interrupted while downloading translations"); + interrupted.initCause(e); + throw interrupted; + } catch (ExecutionException e) { + cancelAndJoin(executor, futures); + return rethrowTaskFailure(e.getCause()); + } catch (RuntimeException e) { + cancelAndJoin(executor, futures); + throw e; + } catch (Error e) { + cancelAndJoin(executor, futures); + throw e; + } + } + + private static Path downloadOne(DownloadRequest request) + throws IOException, NoSuchAlgorithmException { + ensureWorkerNotInterrupted(); + try { + request.cacheLock.lockInterruptibly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + InterruptedIOException interrupted = new InterruptedIOException( + "Interrupted while waiting for translation cache"); + interrupted.initCause(e); + throw interrupted; + } + try { + return downloadOneLocked(request); + } finally { + request.cacheLock.unlock(); + } + } + + private static Path downloadOneLocked(DownloadRequest request) + throws IOException, NoSuchAlgorithmException { + ensureWorkerNotInterrupted(); + try { + updateMod(request.assetUrl, request.rawNamespace, request.cached, request.md5File); + } catch (HttpStatusException e) { + if (e.status == 404 || e.status == 410) { + // 太多了,没事别看( + //Log.debug("No exact translation asset for %s/%s; keeping local cache if present", version, namespace); + } else { + Log.warning("Translation asset %s/%s returned HTTP %s; aborting new pipeline", + request.version, request.namespace, e.status); + throw e; + } + } catch (AssetFailure e) { + Log.warning("Failed to update translation %s; keeping local cache if present: %s", + request.namespace, e.getMessage()); + } + ensureWorkerNotInterrupted(); + return Files.isRegularFile(request.cached) ? request.cached : null; + } + + private static void ensureWorkerNotInterrupted() throws InterruptedIOException { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException("Translation download interrupted"); + } + } + + private static ThreadFactory downloadThreadFactory() { + return new ThreadFactory() { + private int nextId; + + @Override + public synchronized Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, + "i18nupdatemod-v2-download-" + (++nextId)); + thread.setDaemon(false); + return thread; + } + }; + } + + private static void awaitTermination(ExecutorService executor) throws InterruptedException { + while (!executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) { + // A fixed executor with a finite submission set eventually terminates. + } + } + + private static void cancelAndJoin(ExecutorService executor, + List> futures) { + for (Future future : futures) { + future.cancel(true); + } + executor.shutdownNow(); + boolean interrupted = false; + while (!executor.isTerminated()) { + try { + executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + interrupted = true; } } - return sourcePaths; + if (interrupted) { + Thread.currentThread().interrupt(); + } } + private static List rethrowTaskFailure(Throwable failure) + throws IOException, NoSuchAlgorithmException { + if (failure instanceof IOException) { + throw (IOException) failure; + } + if (failure instanceof NoSuchAlgorithmException) { + throw (NoSuchAlgorithmException) failure; + } + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; + } + if (failure instanceof Error) { + throw (Error) failure; + } + throw new IOException("Translation download failed", failure); + } + + private static final class DownloadRequest { + final String version; + final String namespace; + final String rawNamespace; + final Path cached; + final Path md5File; + final String assetUrl; + final ReentrantLock cacheLock; + + DownloadRequest(String version, String namespace, String rawNamespace, Path cached, + Path md5File, String assetUrl, ReentrantLock cacheLock) { + this.version = version; + this.namespace = namespace; + this.rawNamespace = rawNamespace; + this.cached = cached; + this.md5File = md5File; + this.assetUrl = assetUrl; + this.cacheLock = cacheLock; + } + } + + public static Path downloadIcon(String baseUrl, String version, Path cacheRoot) { Path cached = cacheRoot.resolve("shared").resolve("pack.png"); Path temporary = null; diff --git a/src/main/java/i18nupdatemod/util/Log.java b/src/main/java/i18nupdatemod/util/Log.java index d3a2185..f851026 100644 --- a/src/main/java/i18nupdatemod/util/Log.java +++ b/src/main/java/i18nupdatemod/util/Log.java @@ -39,7 +39,7 @@ enum Out { STD_ERR } - private static void log(Level level, String message) { + private static synchronized void log(Level level, String message) { String out = String.format("[%s] [%s]: %s\r\n", DATE_FORMAT.format(new Date()), level.name(), message); if (fileWriter != null) { try { diff --git a/src/main/java/i18nupdatemod/util/ModUtil.java b/src/main/java/i18nupdatemod/util/ModUtil.java index 92fa01c..d601d3d 100644 --- a/src/main/java/i18nupdatemod/util/ModUtil.java +++ b/src/main/java/i18nupdatemod/util/ModUtil.java @@ -3,7 +3,8 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.moandjiezana.toml.Toml; +import com.electronwill.nightconfig.core.UnmodifiableConfig; +import com.electronwill.nightconfig.toml.TomlParser; import i18nupdatemod.entity.ModTranslation; import java.io.ByteArrayInputStream; @@ -150,11 +151,11 @@ private static MetadataRecord parseMetadata(String kind, byte[] bytes) { if ("json".equals(kind)) { return parseJsonMetadata(GSON.fromJson(new String(bytes, StandardCharsets.UTF_8), JsonElement.class)); } - List mods = new Toml().read(new ByteArrayInputStream(bytes)).getTables("mods"); + List mods = new TomlParser().parse(new ByteArrayInputStream(bytes)).get("mods"); if (mods == null || mods.isEmpty()) { return null; } - Map values = mods.get(0).toMap(); + Map values = mods.get(0).valueMap(); MetadataRecord record = new MetadataRecord(); record.displayName = firstValueString(values, "displayName", "name"); record.author = authorValue(values.get("authors")); @@ -225,8 +226,8 @@ private static String authorValue(Object value) { String selected = null; if (value instanceof Iterable) { for (Object author : (Iterable) value) { - String name = author instanceof Map - ? valueString(((Map) author).get("name")) : valueString(author); + String name = author instanceof UnmodifiableConfig + ? valueString(((UnmodifiableConfig) author).get("name")) : valueString(author); selected = minAuthor(selected, name); } } else { From 9b3ecb546b64e3baeb682832d4aa1fead7e25cb0 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:36:32 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0neoforge?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8C=85=E6=8E=92=E5=BA=8F=E3=80=81=E9=9A=94?= =?UTF-8?q?=E7=A6=BBnightConfig=E3=80=81V2=E4=B8=8D=E5=86=8D=E4=BF=9D?= =?UTF-8?q?=E7=95=99tmp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 11 +- .../java/i18nupdatemod/I18nUpdateMod.java | 7 + .../core/PackSelectionTransformer.java | 366 +++++++++++ .../core/RuntimePackActivation.java | 607 ++++++++++++++++++ .../core/v2/ResourcePackDownloader.java | 2 +- .../i18nupdatemod/core/v2/ResourcePackV2.java | 20 +- .../ModLauncherPackTransformer.java | 83 +++ .../modlauncher/ModLauncherService.java | 9 +- .../neoforgeloader/NeoForgeBootstrap.java | 2 + .../neoforgeloader/NeoForgePackProcessor.java | 89 +++ src/main/java/i18nupdatemod/util/ModUtil.java | 50 +- ....neoforgespi.transformation.ClassProcessor | 1 + 12 files changed, 1214 insertions(+), 33 deletions(-) create mode 100644 src/main/java/i18nupdatemod/core/PackSelectionTransformer.java create mode 100644 src/main/java/i18nupdatemod/core/RuntimePackActivation.java create mode 100644 src/main/java/i18nupdatemod/modlauncher/ModLauncherPackTransformer.java create mode 100644 src/main/java/i18nupdatemod/neoforgeloader/NeoForgePackProcessor.java create mode 100644 src/main/resources/META-INF/services/net.neoforged.neoforgespi.transformation.ClassProcessor diff --git a/build.gradle.kts b/build.gradle.kts index 5b6f3da..edeba13 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,12 +32,9 @@ fun ShadowJar.configureI18nPackaging() { archiveBaseName.set("I18nUpdateMod") relocate("com.google.archivepatcher", "include.com.google.archivepatcher") relocate("org.tukaani.xz", "include.org.tukaani.xz") - relocate("com.electronwill.nightconfig", "include.com.electronwill.nightconfig") dependencies { include(dependency("net.runelite.archive-patcher:archive-patcher-applier:.*")) include(dependency("org.tukaani:xz:.*")) - include(dependency("com.electronwill.night-config:core:.*")) - include(dependency("com.electronwill.night-config:toml:.*")) } exclude("LICENSE") } @@ -102,8 +99,11 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.3") implementation("net.runelite.archive-patcher:archive-patcher-applier:1.2") implementation("org.tukaani:xz:1.10") - implementation("com.electronwill.night-config:core:3.6.7") - implementation("com.electronwill.night-config:toml:3.6.7") + // Forge 1.13.x provides NightConfig 3.6.0; keep that API baseline without bundling it. + compileOnly("com.electronwill.night-config:core:3.6.0") + compileOnly("com.electronwill.night-config:toml:3.6.0") + testRuntimeOnly("com.electronwill.night-config:core:3.6.0") + testRuntimeOnly("com.electronwill.night-config:toml:3.6.0") compileOnly("org.jetbrains:annotations:24.1.0") // Only the early-service interface is linked; never bundle loader implementation classes. compileOnly("net.neoforged.fancymodloader:loader:10.0.34") { @@ -118,6 +118,7 @@ dependencies { implementation("commons-io:commons-io:2.16.1") implementation("org.ow2.asm:asm:9.7") + implementation("org.ow2.asm:asm-tree:9.7") // Minecraft supplies Gson at runtime; compile against the 1.7.10 API baseline. implementation("com.google.code.gson:gson:2.2.4") diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index c67b55b..03e9e93 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -4,6 +4,7 @@ import com.google.gson.JsonObject; import i18nupdatemod.core.GameConfig; import i18nupdatemod.core.ResourcePackUpdater; +import i18nupdatemod.core.RuntimePackActivation; import i18nupdatemod.entity.ModTranslation; import i18nupdatemod.util.Log; import i18nupdatemod.util.Version; @@ -56,6 +57,12 @@ public static void init(Path minecraftPath, String minecraftVersion, String load Path convertedPack = ResourcePackUpdater.update( minecraftVersion, loader, mods, resourcePackDirectory, cacheRoot); + // NeoForge applies the selection after its resource repository is populated. + if (RuntimePackActivation.isEnabled()) { + RuntimePackActivation.prepare(minecraftPath, convertedPack); + return; + } + //Apply resource pack GameConfig config = new GameConfig(minecraftPath.resolve("options.txt")); config.addResourcePack("Minecraft-Mod-Language-Modpack", diff --git a/src/main/java/i18nupdatemod/core/PackSelectionTransformer.java b/src/main/java/i18nupdatemod/core/PackSelectionTransformer.java new file mode 100644 index 0000000..2ce24be --- /dev/null +++ b/src/main/java/i18nupdatemod/core/PackSelectionTransformer.java @@ -0,0 +1,366 @@ +package i18nupdatemod.core; + +import i18nupdatemod.util.Log; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.Handle; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InvokeDynamicInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; + +/** + * Adds runtime resource-pack selection callbacks to Minecraft's Options class. + * + *

Game method names are deliberately not part of the binding. Mappings and + * obfuscation change those names between loaders and versions, while the + * repository, pack, and collection descriptors remain stable. This transformer + * binds both lifecycle methods from their bytecode shape and passes the discovered + * names to the runtime activation code.

+ */ +public final class PackSelectionTransformer { + private static final String OPTIONS = "net/minecraft/client/Options"; + private static final String PACK_REPOSITORY = "net/minecraft/server/packs/repository/PackRepository"; + private static final String PACK = "net/minecraft/server/packs/repository/Pack"; + private static final String COLLECTION = "Ljava/util/Collection;"; + private static final String LIST = "Ljava/util/List;"; + private static final String STRING = "Ljava/lang/String;"; + private static final String LIST_FIELD = "Ljava/util/List;"; + + private static final String CALLBACK_OWNER = "i18nupdatemod/core/RuntimePackActivation"; + private static final String BEFORE_NAME = "beforeSelection"; + private static final String BEFORE_DESC = "(Ljava/lang/Object;Ljava/lang/String;)V"; + private static final String AFTER_NAME = "afterSelection"; + private static final String AFTER_DESC = "(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"; + + private static final String REPOSITORY_ARGUMENT = "(L" + PACK_REPOSITORY + ";)V"; + private static final String SET_SELECTED_DESC = "(" + COLLECTION + ")V"; + + /** + * Transforms exactly one class: the client Options class. A false result means + * either that the class is not Options or that its current bytecode did not offer + * an unambiguous binding. + */ + public boolean transform(ClassNode classNode) { + if (classNode == null || !OPTIONS.equals(classNode.name)) { + return false; + } + + Binding binding = bind(classNode); + if (binding == null) { + Log.warning("Unable to bind runtime resource-pack selection; enable the generated pack manually."); + return false; + } + + boolean changed = false; + if (!containsCallback(binding.selectionMethod, BEFORE_NAME, BEFORE_DESC)) { + binding.selectionMethod.instructions.insert(newBeforeInstructions(binding.selectedFieldName)); + changed = true; + } + if (!containsCallback(binding.selectionMethod, AFTER_NAME, AFTER_DESC)) { + InsnList after = newAfterInstructions(binding); + for (AbstractInsnNode instruction = binding.selectionMethod.instructions.getLast(); + instruction != null; + instruction = instruction.getPrevious()) { + if (instruction.getOpcode() == Opcodes.RETURN) { + binding.selectionMethod.instructions.insertBefore(instruction, cloneInstructions(after)); + changed = true; + } + } + } + + if (changed) { + // The largest injected sequence has seven object references on the stack. + // No locals, branches, or frames are introduced. + binding.selectionMethod.maxStack = Math.max(binding.selectionMethod.maxStack, 7); + } + return changed; + } + + private static Binding bind(ClassNode classNode) { + MethodNode selection = null; + String setSelectedName = null; + MethodNode update = null; + String selectedPacksName = null; + String packIdName = null; + String fixedPositionName = null; + String saveOptionsName = null; + + for (MethodNode method : classNode.methods) { + if (!REPOSITORY_ARGUMENT.equals(method.desc)) { + continue; + } + + String setName = findSetSelectedName(method); + if (setName != null) { + if (selection != null) { + // An ambiguous shape is safer to leave untouched than to hook + // the wrong lifecycle method. + return null; + } + selection = method; + setSelectedName = setName; + continue; + } + + RepositorySelectionBinding repositoryBinding = + findRepositorySelectionBinding(method, classNode.name); + if (repositoryBinding != null) { + if (update != null) { + return null; + } + update = method; + selectedPacksName = repositoryBinding.selectedPacksName; + packIdName = repositoryBinding.packIdName; + fixedPositionName = repositoryBinding.fixedPositionName; + saveOptionsName = repositoryBinding.saveOptionsName; + } + } + + if (selection == null || update == null || setSelectedName == null + || selectedPacksName == null || packIdName == null + || fixedPositionName == null || saveOptionsName == null) { + return null; + } + + String selectedFieldName = findSelectedFieldName(update, classNode.name); + if (selectedFieldName == null) { + return null; + } + + return new Binding(selection, selectedFieldName, selectedPacksName, packIdName, + fixedPositionName, setSelectedName, saveOptionsName); + } + + private static String findSetSelectedName(MethodNode method) { + for (AbstractInsnNode instruction = method.instructions.getFirst(); + instruction != null; + instruction = instruction.getNext()) { + if (!(instruction instanceof MethodInsnNode)) { + continue; + } + MethodInsnNode invocation = (MethodInsnNode) instruction; + if (PACK_REPOSITORY.equals(invocation.owner) + && SET_SELECTED_DESC.equals(invocation.desc) + && invocation.getOpcode() != Opcodes.INVOKESTATIC) { + return invocation.name; + } + } + return null; + } + + private static RepositorySelectionBinding findRepositorySelectionBinding( + MethodNode method, String optionsOwner) { + String selectedPacksName = null; + String packIdName = null; + String fixedPositionName = null; + String saveOptionsName = null; + + for (AbstractInsnNode instruction = method.instructions.getFirst(); + instruction != null; + instruction = instruction.getNext()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode invocation = (MethodInsnNode) instruction; + if (PACK_REPOSITORY.equals(invocation.owner) + && isPackCollectionGetter(invocation.desc) + && invocation.getOpcode() != Opcodes.INVOKESTATIC) { + selectedPacksName = invocation.name; + } + if (PACK.equals(invocation.owner) + && ("()" + STRING).equals(invocation.desc) + && invocation.getOpcode() != Opcodes.INVOKESTATIC) { + packIdName = invocation.name; + } + if (PACK.equals(invocation.owner) + && "()Z".equals(invocation.desc) + && invocation.getOpcode() != Opcodes.INVOKESTATIC + && fixedPositionName == null) { + // Vanilla's update loop tests Pack.isFixedPosition before the + // NeoForge-only isHidden test. Binding the first Pack boolean + // accessor avoids hardcoding either mapped method name. + fixedPositionName = invocation.name; + } + if (optionsOwner.equals(invocation.owner) + && "()V".equals(invocation.desc) + && invocation.getOpcode() != Opcodes.INVOKESTATIC + && saveOptionsName == null) { + saveOptionsName = invocation.name; + } + } else if (instruction instanceof InvokeDynamicInsnNode) { + // A compiler is allowed to express Pack::getId or a boolean pack + // accessor through a method reference. Keep the same structural + // binding in that case. + InvokeDynamicInsnNode dynamic = (InvokeDynamicInsnNode) instruction; + for (Object argument : dynamic.bsmArgs) { + if (argument instanceof Handle) { + Handle handle = (Handle) argument; + if (PACK.equals(handle.getOwner()) + && ("()" + STRING).equals(handle.getDesc())) { + packIdName = handle.getName(); + } + if (PACK.equals(handle.getOwner()) + && "()Z".equals(handle.getDesc()) + && fixedPositionName == null) { + fixedPositionName = handle.getName(); + } + } + } + } + } + + if (selectedPacksName == null || packIdName == null + || fixedPositionName == null || saveOptionsName == null) { + return null; + } + return new RepositorySelectionBinding(selectedPacksName, packIdName, + fixedPositionName, saveOptionsName); + } + + private static boolean isPackCollectionGetter(String descriptor) { + return ("()" + COLLECTION).equals(descriptor) || ("()" + LIST).equals(descriptor); + } + + /** + * The first list cleared while rebuilding Options' selections is resourcePacks; + * incompatibleResourcePacks is cleared afterwards. Restricting the match to a + * GETFIELD immediately consumed by List.clear avoids binding a different List + * field used elsewhere in the same method. + */ + private static String findSelectedFieldName(MethodNode update, String optionsOwner) { + for (AbstractInsnNode instruction = update.instructions.getFirst(); + instruction != null; + instruction = instruction.getNext()) { + if (instruction.getOpcode() != Opcodes.INVOKEINTERFACE + && instruction.getOpcode() != Opcodes.INVOKEVIRTUAL) { + continue; + } + if (!(instruction instanceof MethodInsnNode)) { + continue; + } + MethodInsnNode clear = (MethodInsnNode) instruction; + if (!"java/util/List".equals(clear.owner) + || !"clear".equals(clear.name) + || !"()V".equals(clear.desc)) { + continue; + } + + AbstractInsnNode previous = previousRealInstruction(instruction); + if (!(previous instanceof FieldInsnNode)) { + continue; + } + FieldInsnNode field = (FieldInsnNode) previous; + if (field.getOpcode() == Opcodes.GETFIELD + && optionsOwner.equals(field.owner) + && LIST_FIELD.equals(field.desc)) { + return field.name; + } + } + return null; + } + + private static AbstractInsnNode previousRealInstruction(AbstractInsnNode instruction) { + AbstractInsnNode previous = instruction.getPrevious(); + while (previous != null && (previous.getType() == AbstractInsnNode.LABEL + || previous.getType() == AbstractInsnNode.LINE + || previous.getType() == AbstractInsnNode.FRAME)) { + previous = previous.getPrevious(); + } + return previous; + } + + private static boolean containsCallback(MethodNode method, String name, String descriptor) { + for (AbstractInsnNode instruction = method.instructions.getFirst(); + instruction != null; + instruction = instruction.getNext()) { + if (instruction instanceof MethodInsnNode) { + MethodInsnNode invocation = (MethodInsnNode) instruction; + if (CALLBACK_OWNER.equals(invocation.owner) + && name.equals(invocation.name) + && descriptor.equals(invocation.desc) + && invocation.getOpcode() == Opcodes.INVOKESTATIC) { + return true; + } + } + } + return false; + } + + private static InsnList newBeforeInstructions(String selectedFieldName) { + InsnList instructions = new InsnList(); + instructions.add(new VarInsnNode(Opcodes.ALOAD, 0)); + instructions.add(new LdcInsnNode(selectedFieldName)); + instructions.add(new MethodInsnNode(Opcodes.INVOKESTATIC, CALLBACK_OWNER, + BEFORE_NAME, BEFORE_DESC, false)); + return instructions; + } + + private static InsnList newAfterInstructions(Binding binding) { + InsnList instructions = new InsnList(); + instructions.add(new VarInsnNode(Opcodes.ALOAD, 0)); + instructions.add(new VarInsnNode(Opcodes.ALOAD, 1)); + instructions.add(new LdcInsnNode(binding.selectedPacksMethodName)); + instructions.add(new LdcInsnNode(binding.packIdMethodName)); + instructions.add(new LdcInsnNode(binding.fixedPositionMethodName)); + instructions.add(new LdcInsnNode(binding.setSelectedMethodName)); + instructions.add(new LdcInsnNode(binding.saveOptionsMethodName)); + instructions.add(new MethodInsnNode(Opcodes.INVOKESTATIC, CALLBACK_OWNER, + AFTER_NAME, AFTER_DESC, false)); + return instructions; + } + + /** + * InsnList nodes cannot be shared between multiple insertion points. Cloning is + * intentionally limited to the node kinds emitted by newAfterInstructions. + */ + private static InsnList cloneInstructions(InsnList source) { + InsnList clone = new InsnList(); + for (AbstractInsnNode instruction = source.getFirst(); + instruction != null; + instruction = instruction.getNext()) { + clone.add(instruction.clone(null)); + } + return clone; + } + + private static final class RepositorySelectionBinding { + private final String selectedPacksName; + private final String packIdName; + private final String fixedPositionName; + private final String saveOptionsName; + + private RepositorySelectionBinding(String selectedPacksName, String packIdName, + String fixedPositionName, String saveOptionsName) { + this.selectedPacksName = selectedPacksName; + this.packIdName = packIdName; + this.fixedPositionName = fixedPositionName; + this.saveOptionsName = saveOptionsName; + } + } + + private static final class Binding { + private final MethodNode selectionMethod; + private final String selectedFieldName; + private final String selectedPacksMethodName; + private final String packIdMethodName; + private final String fixedPositionMethodName; + private final String setSelectedMethodName; + private final String saveOptionsMethodName; + + private Binding(MethodNode selectionMethod, String selectedFieldName, + String selectedPacksMethodName, String packIdMethodName, + String fixedPositionMethodName, String setSelectedMethodName, + String saveOptionsMethodName) { + this.selectionMethod = selectionMethod; + this.selectedFieldName = selectedFieldName; + this.selectedPacksMethodName = selectedPacksMethodName; + this.packIdMethodName = packIdMethodName; + this.fixedPositionMethodName = fixedPositionMethodName; + this.setSelectedMethodName = setSelectedMethodName; + this.saveOptionsMethodName = saveOptionsMethodName; + } + } +} diff --git a/src/main/java/i18nupdatemod/core/RuntimePackActivation.java b/src/main/java/i18nupdatemod/core/RuntimePackActivation.java new file mode 100644 index 0000000..e59ea44 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/RuntimePackActivation.java @@ -0,0 +1,607 @@ +package i18nupdatemod.core; + +import i18nupdatemod.util.Log; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Defers NeoForge resource-pack activation until the real client resource-pack + * repository has been constructed. + * + *

This class deliberately has no Minecraft linkage. The transformer passes + * the names it discovered from the actual Options and Pack classes, and all + * calls into those objects are reflective. A failed binding is reported and + * ignored rather than preventing the client from starting.

+ */ +public final class RuntimePackActivation { + private static final Object STATE_LOCK = new Object(); + private static final Map CAPTURES = + new WeakHashMap<>(); + + private static volatile boolean enabled; + private static PendingActivation pending; + + /* The generated file name is stable across game versions. */ + private static final String I18N_FILE_PREFIX = "Minecraft-Mod-Language-Modpack-Converted-"; + private static final String FILE_ID_PREFIX = "file/"; + private static final String HIDDEN_METHOD = "isHidden"; + + private RuntimePackActivation() { + } + + /** + * Enables the runtime hook for a NeoForge client. Calling this more than + * once is harmless and does not discard an activation already prepared by + * another early entry point. + */ + public static void enable() { + synchronized (STATE_LOCK) { + enabled = true; + } + } + + public static boolean isEnabled() { + return enabled; + } + + /** + * Records a successfully generated pack for the next Options selection + * pass. This method intentionally never opens options.txt. + * + * @return false when runtime activation is disabled or the generated file + * is not an available, non-empty regular file + */ + public static boolean prepare(Path gameDirectory, Path completedPack) { + if (!enabled) { + return false; + } + if (completedPack == null) { + reportFailure("completed pack path is null", null); + return false; + } + + final String fileName; + try { + Path normalized = completedPack.toAbsolutePath().normalize(); + fileName = normalized.getFileName() == null + ? "" + : normalized.getFileName().toString(); + if (fileName.isEmpty() || !Files.isRegularFile(normalized) + || !Files.isReadable(normalized) || Files.size(normalized) <= 0L) { + reportFailure("completed pack is unavailable: " + normalized, null); + return false; + } + } catch (Throwable failure) { + reportFailure("unable to inspect completed pack: " + completedPack, failure); + return false; + } + + /* gameDirectory is part of the public lifecycle contract. The file + * itself is authoritative; callers may use a custom resource-pack + * directory below the game directory, so do not require a particular + * parent here. */ + String targetId = FILE_ID_PREFIX + fileName; + synchronized (STATE_LOCK) { + if (!enabled) { + return false; + } + pending = new PendingActivation(targetId); + } + return true; + } + + /** + * Runs at the head of Options.loadSelectedResourcePacks. It changes only + * the selected resource-pack list and leaves persistence to afterSelection. + */ + public static void beforeSelection(Object options, String selectedFieldName) { + PendingActivation activation; + synchronized (STATE_LOCK) { + if (!enabled || options == null || pending == null) { + return; + } + /* Consume before doing reflection. The capture below is the + * hand-off that lets afterSelection run even after this token is + * consumed, including when a reload re-enters this method. */ + activation = pending; + pending = null; + } + + SelectionCapture capture = new SelectionCapture(activation.targetId); + try { + Object selectedObject = readField(options, selectedFieldName); + if (!(selectedObject instanceof List)) { + throw new IllegalStateException("selected field is not a List"); + } + + @SuppressWarnings("unchecked") + List selected = (List) selectedObject; + capture.selectedReference = new WeakReference<>(selected); + capture.original = new ArrayList<>(selected); + + List desired = desiredSelection(capture.original, activation.targetId); + capture.firstRegistration = !containsI18nPack(capture.original); + capture.changed = !capture.original.equals(desired); + if (capture.changed) { + replaceList(selected, desired); + } + capture.beforeSucceeded = true; + } catch (Throwable failure) { + capture.beforeSucceeded = false; + reportFailure("unable to prepare in-memory resource-pack selection", failure); + } + + synchronized (STATE_LOCK) { + if (enabled) { + CAPTURES.put(options, capture); + } + } + } + + /** + * Runs immediately before loadSelectedResourcePacks returns. The method + * receives names discovered by the transformer, so no Minecraft mappings + * are linked from this class. + * + *

For a first registration, the generated pack is moved to the end of + * the repository's visible selection and the repository is updated. For + * a filename migration, only the options list is synchronized with the + * repository's existing order. In both cases persistence is a direct + * Options.save() call; invoking updateResourcePacks here would reload + * packs recursively while Options is still being constructed.

+ */ + public static void afterSelection(Object options, + Object repository, + String selectedPacksMethodName, + String packIdMethodName, + String fixedPositionMethodName, + String setSelectedMethodName, + String saveOptionsMethodName) { + SelectionCapture capture; + synchronized (STATE_LOCK) { + capture = options == null ? null : CAPTURES.remove(options); + } + if (capture == null) { + return; + } + if (!capture.beforeSucceeded || !capture.changed) { + return; + } + if (repository == null) { + restoreSelection(capture, "resource-pack repository is null"); + return; + } + + try { + List entries = readSelectedPacks( + repository, selectedPacksMethodName, packIdMethodName, fixedPositionMethodName); + if (capture.firstRegistration) { + activateFirstSelection(capture, options, repository, entries, + packIdMethodName, fixedPositionMethodName, + setSelectedMethodName, selectedPacksMethodName, saveOptionsMethodName); + } else { + activateMigration(capture, options, entries, saveOptionsMethodName); + } + } catch (Throwable failure) { + restoreSelection(capture, "runtime selection failed", failure); + } + } + + private static void activateFirstSelection(SelectionCapture capture, + Object options, + Object repository, + List entries, + String packIdMethodName, + String fixedPositionMethodName, + String setSelectedMethodName, + String selectedPacksMethodName, + String saveOptionsMethodName) throws Exception { + PackEntry target = findVisibleTarget(entries, capture.targetId); + if (target == null) { + restoreSelection(capture, "generated pack was not selected by the repository"); + return; + } + + List reordered = new ArrayList(); + for (PackEntry entry : entries) { + /* PackRepository.setSelected accepts selected root IDs, not Pack + * instances. Hidden children are re-expanded by the repository; + * fixed roots remain in the repository's existing relative order. */ + if (entry.hidden || entry.id == null + || capture.targetId.equals(entry.id)) { + continue; + } + reordered.add(entry.id); + } + reordered.add(target.id); + + invokeWithCollection(repository, setSelectedMethodName, reordered); + + List finalEntries = readSelectedPacks( + repository, selectedPacksMethodName, packIdMethodName, fixedPositionMethodName); + if (findVisibleTarget(finalEntries, capture.targetId) == null) { + restoreSelection(capture, "generated pack disappeared after repository selection"); + return; + } + + List visibleIds = visibleIds(finalEntries); + if (!containsString(visibleIds, capture.targetId)) { + restoreSelection(capture, "generated pack is not a visible selected root"); + return; + } + replaceList(selectedList(capture), visibleIds); + invokeNoArg(options, saveOptionsMethodName); + } + + private static void activateMigration(SelectionCapture capture, + Object options, + List entries, + String saveOptionsMethodName) throws Exception { + if (findVisibleTarget(entries, capture.targetId) == null) { + restoreSelection(capture, "generated pack was not selected during filename migration"); + return; + } + + /* Match vanilla Options.updateResourcePacks: only visible, movable + * packs are represented in resourcePacks. Repository order remains + * untouched, preserving manual interleaving. */ + List visibleIds = visibleIds(entries); + if (!containsString(visibleIds, capture.targetId)) { + restoreSelection(capture, "generated pack is not a visible migrated root"); + return; + } + replaceList(selectedList(capture), visibleIds); + invokeNoArg(options, saveOptionsMethodName); + } + + private static List readSelectedPacks(Object repository, + String selectedPacksMethodName, + String packIdMethodName, + String fixedPositionMethodName) + throws Exception { + Object selected = invokeNoArg(repository, selectedPacksMethodName); + if (!(selected instanceof Iterable)) { + throw new IllegalStateException("selected packs method did not return an Iterable"); + } + + List entries = new ArrayList(); + Iterator iterator = ((Iterable) selected).iterator(); + while (iterator.hasNext()) { + Object pack = iterator.next(); + if (pack == null) { + entries.add(new PackEntry(null, null, false, false)); + continue; + } + String id = readId(pack, packIdMethodName); + boolean hidden = readBoolean(pack, HIDDEN_METHOD, false); + boolean fixed = readBoolean(pack, fixedPositionMethodName, false); + entries.add(new PackEntry(pack, id, hidden, fixed)); + } + return entries; + } + + private static PackEntry findVisibleTarget(List entries, String targetId) { + for (PackEntry entry : entries) { + if (targetId.equals(entry.id) && !entry.hidden && !entry.fixed) { + return entry; + } + } + return null; + } + + private static List visibleIds(List entries) { + List ids = new ArrayList(); + for (PackEntry entry : entries) { + if (entry.hidden || entry.fixed || entry.id == null) { + continue; + } + ids.add(entry.id); + } + return ids; + } + + private static List desiredSelection(List original, String targetId) { + boolean hasExact = false; + int firstFamily = -1; + for (int i = 0; i < original.size(); i++) { + Object value = original.get(i); + if (targetId.equals(value)) { + hasExact = true; + } + if (isI18nPackId(value)) { + if (firstFamily < 0) { + firstFamily = i; + } + } + } + + if (hasExact) { + List desired = new ArrayList(original.size()); + boolean retained = false; + for (Object value : original) { + if (!isI18nPackId(value)) { + desired.add(value); + } else if (targetId.equals(value) && !retained) { + desired.add(value); + retained = true; + } + } + return desired; + } + + if (firstFamily >= 0) { + List desired = new ArrayList(original.size()); + boolean replaced = false; + for (Object value : original) { + if (!isI18nPackId(value)) { + desired.add(value); + } else if (!replaced) { + desired.add(targetId); + replaced = true; + } + } + return desired; + } + + List desired = new ArrayList(original.size() + 1); + desired.addAll(original); + desired.add(targetId); + return desired; + } + + private static boolean containsI18nPack(List selected) { + for (Object value : selected) { + if (isI18nPackId(value)) { + return true; + } + } + return false; + } + + private static boolean isI18nPackId(Object value) { + if (!(value instanceof String)) { + return false; + } + String id = (String) value; + return id.startsWith(FILE_ID_PREFIX + I18N_FILE_PREFIX); + } + + private static String readId(Object pack, String methodName) throws Exception { + Object id = invokeNoArg(pack, methodName); + return id == null ? null : String.valueOf(id); + } + + private static boolean readBoolean(Object target, String methodName, boolean defaultValue) { + if (target == null || methodName == null || methodName.isEmpty()) { + return defaultValue; + } + try { + Object result = invokeNoArg(target, methodName); + return result instanceof Boolean ? ((Boolean) result).booleanValue() : defaultValue; + } catch (Throwable ignored) { + return defaultValue; + } + } + + private static void replaceList(List target, List replacement) throws Exception { + @SuppressWarnings("unchecked") + List mutable = (List) target; + List old = new ArrayList(mutable); + try { + mutable.clear(); + mutable.addAll(replacement); + } catch (Throwable failure) { + try { + mutable.clear(); + mutable.addAll(old); + } catch (Throwable ignored) { + // The original failure is more useful to the caller. + } + if (failure instanceof Exception) { + throw (Exception) failure; + } + throw new Exception(failure); + } + } + + private static List selectedList(SelectionCapture capture) throws Exception { + List selected = capture.selectedReference == null + ? null : capture.selectedReference.get(); + if (selected == null) { + throw new IllegalStateException("selected resource-pack list is no longer available"); + } + return selected; + } + + + private static void restoreSelection(SelectionCapture capture, String reason) { + restoreSelection(capture, reason, null); + } + + private static void restoreSelection(SelectionCapture capture, String reason, Throwable failure) { + List selected = capture.selectedReference == null + ? null : capture.selectedReference.get(); + if (selected != null && capture.original != null) { + try { + replaceList(selected, capture.original); + } catch (Throwable restoreFailure) { + reportFailure(reason + "; unable to restore selected list", restoreFailure); + return; + } + } + reportFailure(reason, failure); + } + + private static Object readField(Object target, String fieldName) throws Exception { + if (target == null || fieldName == null || fieldName.isEmpty()) { + throw new IllegalArgumentException("selected field name is empty"); + } + Field field = findField(target.getClass(), fieldName); + if (field == null) { + throw new NoSuchFieldException(fieldName); + } + makeAccessible(field); + return field.get(target); + } + + private static Object invokeNoArg(Object target, String methodName) throws Exception { + if (target == null || methodName == null || methodName.isEmpty()) { + throw new IllegalArgumentException("method name is empty"); + } + Method method = findMethod(target.getClass(), methodName, 0, null); + if (method == null) { + throw new NoSuchMethodException(target.getClass().getName() + "." + methodName + "()"); + } + makeAccessible(method); + try { + return method.invoke(target); + } catch (java.lang.reflect.InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw failure; + } + } + + private static void invokeWithCollection(Object target, String methodName, List value) throws Exception { + if (target == null || methodName == null || methodName.isEmpty()) { + throw new IllegalArgumentException("set-selected method name is empty"); + } + Method method = findMethod(target.getClass(), methodName, 1, value); + if (method == null) { + throw new NoSuchMethodException(target.getClass().getName() + "." + methodName + "(Collection)"); + } + makeAccessible(method); + try { + method.invoke(target, value); + } catch (java.lang.reflect.InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw failure; + } + } + + private static Field findField(Class type, String name) { + for (Class current = type; current != null; current = current.getSuperclass()) { + try { + return current.getDeclaredField(name); + } catch (NoSuchFieldException ignored) { + // Continue through inherited fields. + } + } + try { + return type.getField(name); + } catch (NoSuchFieldException ignored) { + return null; + } + } + + private static Method findMethod(Class type, String name, int parameterCount, Object argument) { + Method[] publicMethods = type.getMethods(); + Method candidate = chooseMethod(publicMethods, name, parameterCount, argument); + if (candidate != null) { + return candidate; + } + for (Class current = type; current != null; current = current.getSuperclass()) { + candidate = chooseMethod(current.getDeclaredMethods(), name, parameterCount, argument); + if (candidate != null) { + return candidate; + } + } + return null; + } + + private static Method chooseMethod(Method[] methods, String name, int parameterCount, Object argument) { + for (Method method : methods) { + if (!method.getName().equals(name) + || method.getParameterTypes().length != parameterCount + || Modifier.isStatic(method.getModifiers())) { + continue; + } + if (parameterCount == 0) { + return method; + } + Class parameter = method.getParameterTypes()[0]; + if (argument == null || parameter.isAssignableFrom(argument.getClass())) { + return method; + } + } + return null; + } + + private static void makeAccessible(java.lang.reflect.AccessibleObject object) { + try { + object.setAccessible(true); + } catch (RuntimeException ignored) { + // Public methods/fields may still be invocable without it. + } + } + + private static boolean containsString(List values, String target) { + return values.contains(target); + } + + private static void reportFailure(String message, Throwable failure) { + try { + if (failure == null) { + Log.warning("Runtime pack activation failed: %s", message); + } else { + String detail = failure.getClass().getSimpleName(); + if (failure.getMessage() != null && failure.getMessage().length() > 0) { + detail += ": " + failure.getMessage(); + } + Log.warning("Runtime pack activation failed: %s (%s)", message, detail); + } + } catch (Throwable ignored) { + // Logging must never make a loader mismatch fatal. + } + } + + private static final class PendingActivation { + final String targetId; + + PendingActivation(String targetId) { + this.targetId = targetId; + } + } + + private static final class SelectionCapture { + final String targetId; + WeakReference> selectedReference; + List original; + boolean firstRegistration; + boolean changed; + boolean beforeSucceeded; + + SelectionCapture(String targetId) { + this.targetId = targetId; + } + } + + private static final class PackEntry { + final Object pack; + final String id; + final boolean hidden; + final boolean fixed; + + PackEntry(Object pack, String id, boolean hidden, boolean fixed) { + this.pack = pack; + this.id = id; + this.hidden = hidden; + this.fixed = fixed; + } + } +} diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 7e02557..0d4f093 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -46,7 +46,7 @@ public class ResourcePackDownloader { private static final Gson GSON = new Gson(); private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); private static final long ICON_UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(30); - private static final int MAX_CONCURRENT_DOWNLOADS = 16; + private static final int MAX_CONCURRENT_DOWNLOADS = 64; public static Manifest loadManifest(String baseUrl, String version) throws IOException { String root = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java index 543bd79..25b259f 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -44,11 +45,20 @@ public static Path update(String minecraftVersion, List mods, plan.targetVersion, namespaces, manifest.blackList, cacheRoot, baseUrl); Path icon = ResourcePackDownloader.downloadIcon(baseUrl, plan.targetVersion, cacheRoot); - Path convertedCache = cacheRoot.resolve(minecraftVersion).resolve(plan.convertedFileName); Path convertedOutput = resourcePackDirectory.resolve(plan.convertedFileName); - new ResourcePackConverter(sources, convertedCache, false) - .convert(plan.packMetaData, plan.description, new HashSet<>(namespaces.values()), icon); - Files.copy(convertedCache, convertedOutput, StandardCopyOption.REPLACE_EXISTING); - return convertedOutput; + Path temporary = Files.createTempFile(resourcePackDirectory, plan.convertedFileName + ".", ".tmp"); + try { + new ResourcePackConverter(sources, temporary, false) + .convert(plan.packMetaData, plan.description, new HashSet<>(namespaces.values()), icon); + try { + Files.move(temporary, convertedOutput, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, convertedOutput, StandardCopyOption.REPLACE_EXISTING); + } + return convertedOutput; + } finally { + Files.deleteIfExists(temporary); + } } } diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherPackTransformer.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherPackTransformer.java new file mode 100644 index 0000000..7ba3cc0 --- /dev/null +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherPackTransformer.java @@ -0,0 +1,83 @@ +package i18nupdatemod.modlauncher; + +import cpw.mods.modlauncher.api.ITransformer; +import cpw.mods.modlauncher.api.ITransformerVotingContext; +import cpw.mods.modlauncher.api.TransformerVoteResult; +import i18nupdatemod.core.PackSelectionTransformer; +import i18nupdatemod.core.RuntimePackActivation; +import org.objectweb.asm.tree.ClassNode; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.Set; + +/** + * Adapts the legacy generic transformer contract and ModLauncher 11's explicit + * target type without linking newer API classes on Java 8 loaders. + */ +public final class ModLauncherPackTransformer implements ITransformer { + private static final Set TARGETS = Collections.singleton( + Target.targetClass("net.minecraft.client.Options")); + private static final PackSelectionTransformer TRANSFORMER = new PackSelectionTransformer(); + + public static ITransformer create() { + ModLauncherPackTransformer delegate = new ModLauncherPackTransformer(); + final Method targetTypeMethod; + try { + targetTypeMethod = ITransformer.class.getMethod("getTargetType"); + } catch (NoSuchMethodException legacyApi) { + // ModLauncher 8–10 infer ClassNode from this concrete generic interface. + return delegate; + } + final Object classTargetType; + try { + classTargetType = targetTypeMethod.getReturnType().getField("CLASS").get(null); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Unable to resolve ModLauncher class target type", failure); + } + // The runtime interface supplies the exact return descriptor introduced in 11. + @SuppressWarnings("unchecked") + ITransformer adapter = (ITransformer) Proxy.newProxyInstance( + ITransformer.class.getClassLoader(), new Class[]{ITransformer.class}, + (proxy, method, arguments) -> { + if (method.equals(targetTypeMethod)) { + return classTargetType; + } + if (method.getDeclaringClass() == Object.class) { + if ("equals".equals(method.getName())) return proxy == arguments[0]; + if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy); + } + try { + return method.invoke(delegate, arguments); + } catch (InvocationTargetException failure) { + throw failure.getCause(); + } + }); + return adapter; + } + + @Override + public ClassNode transform(ClassNode input, ITransformerVotingContext context) { + if (RuntimePackActivation.isEnabled()) { + TRANSFORMER.transform(input); + } + return input; + } + + @Override + public TransformerVoteResult castVote(ITransformerVotingContext context) { + return TransformerVoteResult.YES; + } + + @Override + public Set targets() { + return TARGETS; + } + + @Override + public String[] labels() { + return new String[]{"i18nupdatemod.pack_selection"}; + } +} diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 74075ed..324a992 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -8,6 +8,7 @@ import cpw.mods.modlauncher.api.IncompatibleEnvironmentException; import i18nupdatemod.I18nUpdateMod; import i18nupdatemod.util.Log; +import i18nupdatemod.core.RuntimePackActivation; import i18nupdatemod.util.ModUtil; import i18nupdatemod.util.Reflection; import org.jetbrains.annotations.NotNull; @@ -40,6 +41,12 @@ public void initialize(IEnvironment environment) { Log.warning("Minecraft version not found"); return; } + try { + Class.forName("net.neoforged.fml.loading.FMLLoader", false, getClass().getClassLoader()); + RuntimePackActivation.enable(); + } catch (ClassNotFoundException ignored) { + // Forge keeps the existing file-based activation path. + } I18nUpdateMod.init(minecraftPath.get(), minecraftVersion, "Forge", ModUtil.getModsFromModsFolder(minecraftPath.get())); } @@ -55,7 +62,7 @@ public void onLoad(IEnvironment env, Set otherServices) throws Incompati @Override public @NotNull List transformers() { - return Collections.emptyList(); + return Collections.singletonList(ModLauncherPackTransformer.create()); } private String getMinecraftVersion() { diff --git a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java index 999f05f..28ddc10 100644 --- a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java +++ b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java @@ -1,6 +1,7 @@ package i18nupdatemod.neoforgeloader; import i18nupdatemod.I18nUpdateMod; +import i18nupdatemod.core.RuntimePackActivation; import i18nupdatemod.util.Log; import i18nupdatemod.util.ModUtil; import i18nupdatemod.util.Reflection; @@ -32,6 +33,7 @@ public void bootstrap(String[] arguments) { Log.setMinecraftLogFile(gameDir); // FML consumes --fml.mcVersion before calling bootstrappers; do not parse arguments. String version = (String) loader.get("getVersionInfo()").get("mcVersion()").get(); + RuntimePackActivation.enable(); I18nUpdateMod.init(gameDir, version, "Forge", ModUtil.getModsFromModsFolder(gameDir)); } catch (Exception e) { Log.warning("Failed to initialize NeoForge resource pack update: %s", e); diff --git a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgePackProcessor.java b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgePackProcessor.java new file mode 100644 index 0000000..9f356e4 --- /dev/null +++ b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgePackProcessor.java @@ -0,0 +1,89 @@ +package i18nupdatemod.neoforgeloader; + +import i18nupdatemod.core.PackSelectionTransformer; +import i18nupdatemod.core.RuntimePackActivation; +import net.neoforged.neoforgespi.transformation.ClassProcessor; +import net.neoforged.neoforgespi.transformation.ClassProcessorIds; +import net.neoforged.neoforgespi.transformation.ProcessorName; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +/** + * NeoForge 10+ class-processor adapter. It intentionally keeps the shared + * transformer free of FML classes so the same core logic can run through old + * ModLauncher and the modern early-service SPI. + */ +public final class NeoForgePackProcessor implements ClassProcessor { + private static final String OPTIONS = "net/minecraft/client/Options"; + private static final ProcessorName NAME = new ProcessorName("i18nupdatemod", "pack_selection"); + private static final PackSelectionTransformer TRANSFORMER = new PackSelectionTransformer(); + + private static volatile Method selectionTypeMethod; + private static volatile Class selectionContextClass; + + @Override + public ProcessorName name() { + return NAME; + } + + @Override + public Set runsAfter() { + // Let NeoForge's own mixin/core processors finish first so this hook is + // attached to the final Options selection method. + Set predecessors = new HashSet<>(); + predecessors.add(ClassProcessorIds.COMPUTING_FRAMES); + predecessors.add(ClassProcessorIds.MIXIN); + return predecessors; + } + + @Override + public boolean handlesClass(ClassProcessor.SelectionContext context) { + if (!RuntimePackActivation.isEnabled() || context == null) { + return false; + } + try { + Object typeValue = getSelectionTypeMethod(context).invoke(context); + return typeValue instanceof Type + && OPTIONS.equals(((Type) typeValue).getInternalName()); + } catch (Exception ignored) { + // SelectionContext is a Java record in FML10. Calling its accessor + // reflectively keeps this class Java-8-linkable without a Record API. + return false; + } + } + + @Override + public ClassProcessor.ComputeFlags processClass(ClassProcessor.TransformationContext context) { + if (!RuntimePackActivation.isEnabled() || context == null) { + return ClassProcessor.ComputeFlags.NO_REWRITE; + } + ClassNode node = context.node(); + if (node == null || !TRANSFORMER.transform(node)) { + return ClassProcessor.ComputeFlags.NO_REWRITE; + } + // Only maxStack changes; no locals, branches, or frames are introduced. + return ClassProcessor.ComputeFlags.COMPUTE_MAXS; + } + + private static Method getSelectionTypeMethod(Object context) throws NoSuchMethodException { + Class contextClass = context.getClass(); + Method method = selectionTypeMethod; + if (method == null || selectionContextClass != contextClass) { + synchronized (NeoForgePackProcessor.class) { + method = selectionTypeMethod; + if (method == null || selectionContextClass != contextClass) { + // Do not call context.type() directly: javac --release 8 + // cannot resolve an accessor declared on a Java record. + method = ((Object) context).getClass().getMethod("type"); + selectionContextClass = contextClass; + selectionTypeMethod = method; + } + } + } + return method; + } +} diff --git a/src/main/java/i18nupdatemod/util/ModUtil.java b/src/main/java/i18nupdatemod/util/ModUtil.java index d601d3d..0a02433 100644 --- a/src/main/java/i18nupdatemod/util/ModUtil.java +++ b/src/main/java/i18nupdatemod/util/ModUtil.java @@ -151,15 +151,7 @@ private static MetadataRecord parseMetadata(String kind, byte[] bytes) { if ("json".equals(kind)) { return parseJsonMetadata(GSON.fromJson(new String(bytes, StandardCharsets.UTF_8), JsonElement.class)); } - List mods = new TomlParser().parse(new ByteArrayInputStream(bytes)).get("mods"); - if (mods == null || mods.isEmpty()) { - return null; - } - Map values = mods.get(0).valueMap(); - MetadataRecord record = new MetadataRecord(); - record.displayName = firstValueString(values, "displayName", "name"); - record.author = authorValue(values.get("authors")); - return record; + return TomlMetadata.parse(bytes); } private static MetadataRecord parseJsonMetadata(JsonElement element) { @@ -219,21 +211,37 @@ private static String authorJson(JsonElement value) { return selected; } - private static String authorValue(Object value) { - if (value == null) { - return null; + // Loaded only for TOML metadata. Keep every NightConfig type reference here + // so old Forge and Fabric can scan JSON without a TOML library present. + private static final class TomlMetadata { + private static MetadataRecord parse(byte[] bytes) { + List mods = new TomlParser().parse(new ByteArrayInputStream(bytes)).get("mods"); + if (mods == null || mods.isEmpty()) { + return null; + } + Map values = mods.get(0).valueMap(); + MetadataRecord record = new MetadataRecord(); + record.displayName = firstValueString(values, "displayName", "name"); + record.author = authorValue(values.get("authors")); + return record; } - String selected = null; - if (value instanceof Iterable) { - for (Object author : (Iterable) value) { - String name = author instanceof UnmodifiableConfig - ? valueString(((UnmodifiableConfig) author).get("name")) : valueString(author); - selected = minAuthor(selected, name); + + private static String authorValue(Object value) { + if (value == null) { + return null; } - } else { - selected = minAuthor(null, valueString(value)); + String selected = null; + if (value instanceof Iterable) { + for (Object author : (Iterable) value) { + String name = author instanceof UnmodifiableConfig + ? valueString(((UnmodifiableConfig) author).get("name")) : valueString(author); + selected = minAuthor(selected, name); + } + } else { + selected = minAuthor(null, valueString(value)); + } + return selected; } - return selected; } private static String minAuthor(String selected, String candidate) { diff --git a/src/main/resources/META-INF/services/net.neoforged.neoforgespi.transformation.ClassProcessor b/src/main/resources/META-INF/services/net.neoforged.neoforgespi.transformation.ClassProcessor new file mode 100644 index 0000000..ba979f8 --- /dev/null +++ b/src/main/resources/META-INF/services/net.neoforged.neoforgespi.transformation.ClassProcessor @@ -0,0 +1 @@ +i18nupdatemod.neoforgeloader.NeoForgePackProcessor From 586718c474ec5c3b07293b2a64e9e9624ca889c8 Mon Sep 17 00:00:00 2001 From: 502y <53784463+502y@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:27:32 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/i18nupdatemod/core/ResourcePackConverter.java | 7 +++++-- .../java/i18nupdatemod/core/v2/ResourcePackDownloader.java | 5 +++++ src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java index 715e36a..32ae57a 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java +++ b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java @@ -48,8 +48,11 @@ public void convert(GameMetaData metaData, String description, HashSet m ZipEntry ze = e.nextElement(); String name = ze.getName(); String[] parts = name.split("/"); - // 正在筛选的是assets/modDomain/** && 当前的modDomain不需要 - if (parts.length >= 2 && !modDomainsSet.contains(parts[1])) { + // Keep Minecraft's baseline fixes, but do not implicitly select its language files. + boolean minecraftFix = parts.length >= 2 + && "assets".equals(parts[0]) && "minecraft".equals(parts[1]) + && (parts.length < 3 || !"lang".equals(parts[2])); + if (parts.length >= 2 && !minecraftFix && !modDomainsSet.contains(parts[1])) { continue; } diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 0d4f093..626d1b9 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -158,6 +158,11 @@ public static List download(String version, Map namespaces List blocked = blackList == null ? Collections.emptyList() : blackList; + // Minecraft's baseline fixes are not optional mod translations. + if (blocked.contains("minecraft")) { + blocked = new ArrayList<>(blocked); + blocked.removeIf("minecraft"::equals); + } String root = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; Path modCache = cacheRoot.resolve(version).resolve("mods"); Files.createDirectories(modCache); diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java index 25b259f..c8a6fe7 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java @@ -39,6 +39,9 @@ public static Path update(String minecraftVersion, List mods, ResourcePackDownloader.Manifest manifest = ResourcePackDownloader.loadManifest(baseUrl, plan.targetVersion); Map namespaces = ResourcePackDownloader.selectNamespaces(mods, manifest); + // Capture language eligibility before adding Minecraft's baseline fixes. + HashSet modDomains = new HashSet<>(namespaces.values()); + namespaces.put("minecraft", "minecraft"); Files.createDirectories(resourcePackDirectory); List sources = ResourcePackDownloader.download( @@ -49,7 +52,7 @@ public static Path update(String minecraftVersion, List mods, Path temporary = Files.createTempFile(resourcePackDirectory, plan.convertedFileName + ".", ".tmp"); try { new ResourcePackConverter(sources, temporary, false) - .convert(plan.packMetaData, plan.description, new HashSet<>(namespaces.values()), icon); + .convert(plan.packMetaData, plan.description, modDomains, icon); try { Files.move(temporary, convertedOutput, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);