diff --git a/README.md b/README.md index 9a8764b..d6ac438 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,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,7 +31,7 @@ 为了尽可能实用,目前本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以上,反之同理 @@ -40,4 +40,4 @@ 请使用Java 8及以上的JDK构建。 ```shell gradle clean shadowJar -``` \ No newline at end of file +``` diff --git a/build.gradle.kts b/build.gradle.kts index 12fcfd9..b9b1b1f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,16 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } +sourceSets { + named("main") { + java { + // The NeoForge entrypoint uses a compile-time annotation shim so this + // universal jar can still be built without a Java 25 NeoForge API jar. + srcDir("src/compat/java") + } + } +} + tasks.withType { options.encoding = "UTF-8" } @@ -34,6 +44,11 @@ tasks.shadowJar { exclude("LICENSE") } +tasks.withType { + // The real API is supplied by NeoForge at runtime. + exclude("net/neoforged/**") +} + repositories { mavenCentral() maven("https://libraries.minecraft.net/") @@ -48,7 +63,12 @@ configurations.configureEach { dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.3") + // This project sets isTransitive = false, so JUnit's own dependencies must be declared by hand. + testImplementation("org.junit.platform:junit-platform-commons:1.10.3") + testImplementation("org.opentest4j:opentest4j:1.3.0") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.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") compileOnly("org.jetbrains:annotations:24.1.0") @@ -103,4 +123,4 @@ curseforge { gameVersionStrings.addAll(curseForgeSpecialVersions) changelog = if (System.getenv("CHANGE_LOG") != null) System.getenv("CHANGE_LOG") else "No change log" } -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index 53d9848..e9b9d30 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 -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 +version=3.7.2 +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,Java 19,Java 20,Java 21,Java 22,Java 23,Java 24,Java 25 diff --git a/src/compat/java/net/neoforged/api/distmarker/Dist.java b/src/compat/java/net/neoforged/api/distmarker/Dist.java new file mode 100644 index 0000000..1549fc2 --- /dev/null +++ b/src/compat/java/net/neoforged/api/distmarker/Dist.java @@ -0,0 +1,7 @@ +package net.neoforged.api.distmarker; + +/** Compile-time shim for the NeoForge distribution enum. */ +public enum Dist { + CLIENT, + DEDICATED_SERVER +} diff --git a/src/compat/java/net/neoforged/fml/common/Mod.java b/src/compat/java/net/neoforged/fml/common/Mod.java new file mode 100644 index 0000000..5c843bd --- /dev/null +++ b/src/compat/java/net/neoforged/fml/common/Mod.java @@ -0,0 +1,22 @@ +package net.neoforged.fml.common; + +import net.neoforged.api.distmarker.Dist; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Compile-time shim for the NeoForge annotation. The class is excluded from + * the produced JAR, so NeoForge supplies the runtime annotation implementation. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Mod { + String value(); + + Dist[] dist() default {Dist.CLIENT, Dist.DEDICATED_SERVER}; + + String[] depends() default {}; +} diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index 3566e57..67d82c3 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -20,6 +20,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -29,7 +30,21 @@ public class I18nUpdateMod { public static final Gson GSON = new Gson(); + /** + * Older NeoForge can reach {@link #init} through both the ModLauncher service and the + * {@code @Mod} entrypoint; only the first one should do the work. + */ + private static final AtomicBoolean INITIALIZED = new AtomicBoolean(); + + public static boolean isInitialized() { + return INITIALIZED.get(); + } + public static void init(Path minecraftPath, String minecraftVersion, String loader, @NotNull HashSet modDomainsSet) { + if (!INITIALIZED.compareAndSet(false, true)) { + Log.debug("Already initialized, skipping duplicate entrypoint"); + return; + } try (InputStream is = I18nUpdateMod.class.getResourceAsStream("/i18nMetaData.json")) { MOD_VERSION = GSON.fromJson(new InputStreamReader(is), JsonObject.class).get("version").getAsString(); } catch (Exception e) { @@ -58,7 +73,7 @@ public static void init(Path minecraftPath, String minecraftVersion, String load FileUtil.setResourcePackDirPath(minecraftPath.resolve("resourcepacks")); - int minecraftMajorVersion = Integer.parseInt(minecraftVersion.split("\\.")[1]); + int minecraftMajorVersion = getMinecraftMajorVersion(minecraftVersion); try { //Get asset @@ -100,6 +115,14 @@ private static String getResourcePackDescription(List= 2 && "1".equals(parts[0])) { + return Integer.parseInt(parts[1]); + } + return Integer.parseInt(parts[0]); + } + public static String getLocalStoragePos(Path minecraftPath) { Path userHome = Paths.get(System.getProperty("user.home")); Path oldPath = userHome.resolve("." + MOD_ID); @@ -124,4 +147,4 @@ public static String getLocalStoragePos(Path minecraftPath) { ).findFirst().orElse(xdgDataHome); } -} \ No newline at end of file +} diff --git a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java index ea511e3..ada12be 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePackConverter.java +++ b/src/main/java/i18nupdatemod/core/ResourcePackConverter.java @@ -2,6 +2,8 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; import i18nupdatemod.entity.GameMetaData; import i18nupdatemod.util.FileUtil; import i18nupdatemod.util.Log; @@ -80,10 +82,11 @@ public void convert(GameMetaData metaData, String description, HashSet m private byte[] convertPackMeta(InputStream is, GameMetaData metaData, String description) { PackMeta meta = GSON.fromJson(new InputStreamReader(is, StandardCharsets.UTF_8), PackMeta.class); - meta.pack.pack_format = metaData.useNewFormat() ? null : metaData.packFormat; + meta.pack.pack_format = metaData.useNewFormat() || metaData.packFormat == null + ? null : new JsonPrimitive(metaData.packFormat); meta.pack.min_format = metaData.useNewFormat() ? metaData.minFormat : null; meta.pack.max_format = metaData.useNewFormat() ? metaData.maxFormat : null; - meta.pack.description = description; + meta.pack.description = new JsonPrimitive(description); return GSON.toJson(meta).getBytes(StandardCharsets.UTF_8); } @@ -91,10 +94,10 @@ private static class PackMeta { Pack pack; private static class Pack { - Integer pack_format; // 改为 Integer,支持 null - Integer min_format; - Integer max_format; - String description; + JsonElement pack_format; + JsonElement min_format; + JsonElement max_format; + JsonElement description; } } } diff --git a/src/main/java/i18nupdatemod/entity/GameMetaData.java b/src/main/java/i18nupdatemod/entity/GameMetaData.java index b9f11e7..988ae90 100644 --- a/src/main/java/i18nupdatemod/entity/GameMetaData.java +++ b/src/main/java/i18nupdatemod/entity/GameMetaData.java @@ -1,11 +1,18 @@ package i18nupdatemod.entity; +import com.google.gson.JsonElement; + import java.util.List; public class GameMetaData { public String gameVersions; - public Integer packFormat, minFormat, maxFormat; + public Integer packFormat; + /** Supports both pre-26 integer formats and 26.1 structured formats. */ + public JsonElement minFormat, maxFormat; public List convertFrom; - public boolean useNewFormat() { return minFormat != null && maxFormat != null; } + public boolean useNewFormat() { + return minFormat != null && maxFormat != null + && !minFormat.isJsonNull() && !maxFormat.isJsonNull(); + } } diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 33e0421..523c6a1 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -19,7 +19,14 @@ import static i18nupdatemod.I18nUpdateMod.GSON; -//1.13-latest +/** + * Forge 1.13+ (including Minecraft 26.1) and NeoForge up to Minecraft 1.21.x. + *

+ * Forge still boots through ModLauncher on 26.1 (bootstrap 2.1.8 + modlauncher 10.2.6), so this + * service remains the Forge entrypoint there. NeoForge's FML 11 dropped ModLauncher entirely, so on + * NeoForge 26.1+ this service is never loaded and {@link i18nupdatemod.neoforge.NeoForgeMod} takes + * over instead. + */ public class ModLauncherService implements ITransformationService { @Override public @NotNull String name() { @@ -67,20 +74,43 @@ private String getMinecraftVersion() { return args[i + 1]; } } - } catch (Exception e) { + } catch (Exception | LinkageError e) { Log.warning("Error getting minecraft version: %s", e); } - // MinecraftForge 1.20.3~ + // MinecraftForge 1.20.3~, and the only source on Forge 26.1 where --fml.mcversion is gone // 1.20.3: https://github.com/MinecraftForge/MinecraftForge/blob/1.20.x/fmlloader/src/main/java/net/minecraftforge/fml/loading/VersionInfo.java try { - Class clazz = Class.forName("net.minecraftforge.fml.loading.FMLLoader"); + // Resolved without running static initializers: only the class's resource root is needed, + // and initializing it can fail with an Error that would escape into the loader. + Class clazz = loadClass("net.minecraftforge.fml.loading.FMLLoader"); + if (clazz == null) { + return null; + } try (InputStream is = clazz.getResourceAsStream("/forge_version.json")) { + if (is == null) { + Log.warning("forge_version.json not found"); + return null; + } return GSON.fromJson(new InputStreamReader(is), JsonObject.class).get("mc").getAsString(); } - } catch (Exception e) { + } catch (Exception | LinkageError e) { Log.warning("Error getting minecraft version: %s", e); } return null; } + + /** + * @return the class, or null when it is absent or cannot be loaded + */ + private static Class loadClass(String name) { + try { + return Class.forName(name, false, ModLauncherService.class.getClassLoader()); + } catch (ClassNotFoundException ignored) { + return null; + } catch (LinkageError e) { + Log.warning("Error loading %s: %s", name, e); + return null; + } + } } diff --git a/src/main/java/i18nupdatemod/neoforge/NeoForgeMod.java b/src/main/java/i18nupdatemod/neoforge/NeoForgeMod.java new file mode 100644 index 0000000..184d734 --- /dev/null +++ b/src/main/java/i18nupdatemod/neoforge/NeoForgeMod.java @@ -0,0 +1,95 @@ +package i18nupdatemod.neoforge; + +import i18nupdatemod.I18nUpdateMod; +import i18nupdatemod.util.Log; +import i18nupdatemod.util.ModUtil; +import i18nupdatemod.util.Reflection; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.fml.common.Mod; + +import java.nio.file.Path; + +/** + * NeoForge javafml entrypoint. + *

+ * Required from FML 11 (Minecraft 26.1) onwards, where ModLauncher was removed and + * {@link i18nupdatemod.modlauncher.ModLauncherService} is therefore never invoked. Older NeoForge + * still loads this class from the same universal jar, and there FMLLoader exposes static + * {@code getGamePath()}/{@code versionInfo()} instead of the current instance methods, so both + * shapes are resolved reflectively. + */ +@Mod(value = I18nUpdateMod.MOD_ID, dist = Dist.CLIENT) +public final class NeoForgeMod { + private static final String FML_LOADER = "net.neoforged.fml.loading.FMLLoader"; + + public NeoForgeMod() { + try { + if (I18nUpdateMod.isInitialized()) { + // ModLauncherService already did the work on an older NeoForge. + Log.debug("Already initialized, skipping NeoForge entrypoint"); + return; + } + + Path gameDir = getGameDir(); + if (gameDir == null) { + Log.warning("Minecraft path not found"); + return; + } + Log.setMinecraftLogFile(gameDir); + String mcVersion = getMcVersion(); + if (mcVersion == null) { + Log.warning("Minecraft version not found"); + return; + } + + I18nUpdateMod.init(gameDir, mcVersion, "NeoForge", + ModUtil.getModDomainsFromModsFolder(gameDir, mcVersion, "NeoForge")); + } catch (Exception e) { + Log.warning("Failed to initialize NeoForge entrypoint: " + e); + } + } + + /** + * @return game directory, or null if neither API shape is present + */ + private static Path getGameDir() { + // FML 11+ (Minecraft 26.1+) + try { + return (Path) Reflection.clazz(FML_LOADER) + .get("getCurrent()") + .get("getGameDir()") + .get(); + } catch (Exception ignored) { + } + // FML 1~4 (Minecraft 1.20.1~1.21.x) + try { + return (Path) Reflection.clazz(FML_LOADER).get("getGamePath()").get(); + } catch (Exception ignored) { + } + return null; + } + + /** + * @return Minecraft version, or null if neither API shape is present + */ + private static String getMcVersion() { + // FML 11+ (Minecraft 26.1+) + try { + return (String) Reflection.clazz(FML_LOADER) + .get("getCurrent()") + .get("getVersionInfo()") + .get("mcVersion()") + .get(); + } catch (Exception ignored) { + } + // FML 1~4 (Minecraft 1.20.1~1.21.x) + try { + return (String) Reflection.clazz(FML_LOADER) + .get("versionInfo()") + .get("mcVersion()") + .get(); + } catch (Exception ignored) { + } + return null; + } +} diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..ccb83b0 --- /dev/null +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,12 @@ +modLoader="javafml" +loaderVersion="[3,]" +license="AGPL-3.0-only" + +[[mods]] +modId="i18nupdatemod" +version="${version}" +displayName="I18nUpdateMod" +authors="CFPAOrg" +description=''' +Automatically downloads, updates, converts, and applies the CFPA Chinese language resource pack. +''' diff --git a/src/main/resources/i18nMetaData.json b/src/main/resources/i18nMetaData.json index 3c174bd..ca85a41 100644 --- a/src/main/resources/i18nMetaData.json +++ b/src/main/resources/i18nMetaData.json @@ -183,9 +183,32 @@ "1.20", "1.19" ] + }, + { + "gameVersions": "[26.1,26.1.2]", + "minFormat": [84, 0], + "maxFormat": [84, 0], + "convertFrom": [ + "26.1", + "1.21", + "1.20", + "1.19" + ] } ], "assets": [ + { + "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" + }, { "targetVersion": "1.10.2", "loader": "Forge", @@ -253,4 +276,4 @@ "md5Filename": "1.21-fabric.md5" } ] -} \ No newline at end of file +} diff --git a/src/test/java/i18nupdatemod/core/I18nConfigTest.java b/src/test/java/i18nupdatemod/core/I18nConfigTest.java new file mode 100644 index 0000000..29bcc88 --- /dev/null +++ b/src/test/java/i18nupdatemod/core/I18nConfigTest.java @@ -0,0 +1,60 @@ +package i18nupdatemod.core; + +import i18nupdatemod.entity.GameMetaData; +import i18nupdatemod.entity.GameAssetDetail; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class I18nConfigTest { + @Test + void supportsEveryMinecraft261Patch() { + for (String version : new String[]{"26.1", "26.1.1", "26.1.2"}) { + GameMetaData metadata = I18nConfig.getPackFormat(version); + + assertEquals(84, metadata.minFormat.getAsJsonArray().get(0).getAsInt()); + assertEquals(0, metadata.minFormat.getAsJsonArray().get(1).getAsInt()); + assertEquals(metadata.minFormat, metadata.maxFormat); + assertEquals("26.1", metadata.convertFrom.get(0)); + } + } + + /** + * Forge keeps booting through ModLauncher on 26.1, so it reaches this code with loader="Forge" + * and must land on the Forge pack. NeoForge arrives via the javafml entrypoint instead, and + * Fabric must pick the separate Fabric pack. + */ + @Test + void picksTheLoaderSpecificPackFor261() { + for (String version : new String[]{"26.1", "26.1.1", "26.1.2"}) { + for (String loader : new String[]{"Forge", "NeoForge"}) { + assertEquals("Minecraft-Mod-Language-Modpack-26-1.zip", + firstDownload(version, loader).fileName, loader + " on " + version); + } + assertEquals("Minecraft-Mod-Language-Modpack-26-1-Fabric.zip", + firstDownload(version, "Fabric").fileName, "Fabric on " + version); + } + } + + /** + * 26.1 has no pack of its own for every fallback generation, so the chain must keep walking back + * to the 1.21/1.20/1.19 packs rather than stopping at the first miss. + */ + @Test + void keepsTheOlderPacksAsFallbacksOn261() { + List downloads = + I18nConfig.getAssetDetail("26.1.2", "Forge").downloads; + + assertEquals(4, downloads.size()); + assertEquals("26.1", downloads.get(0).targetVersion); + assertEquals("1.21", downloads.get(1).targetVersion); + assertEquals("1.20", downloads.get(2).targetVersion); + assertEquals("1.19", downloads.get(3).targetVersion); + } + + private static GameAssetDetail.AssetDownloadDetail firstDownload(String version, String loader) { + return I18nConfig.getAssetDetail(version, loader).downloads.get(0); + } +} diff --git a/src/test/java/i18nupdatemod/core/ResourcePackConverterTest.java b/src/test/java/i18nupdatemod/core/ResourcePackConverterTest.java new file mode 100644 index 0000000..cb4e522 --- /dev/null +++ b/src/test/java/i18nupdatemod/core/ResourcePackConverterTest.java @@ -0,0 +1,76 @@ +package i18nupdatemod.core; + +import com.google.gson.JsonParser; +import i18nupdatemod.entity.GameMetaData; +import i18nupdatemod.util.FileUtil; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.OutputStream; +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.Collections; +import java.util.HashSet; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResourcePackConverterTest { + @TempDir + Path tempDir; + + @Test + void keepsVanillaAndInstalledModDomains() throws Exception { + FileUtil.setResourcePackDirPath(tempDir.resolve("resourcepacks")); + FileUtil.setTemporaryDirPath(tempDir.resolve("temporary")); + + Path sourceFile = tempDir.resolve("source.zip"); + try (OutputStream output = Files.newOutputStream(sourceFile); + ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { + writeEntry(zip, "pack.mcmeta", "{\"pack\":{\"pack_format\":75}}\n"); + writeEntry(zip, "assets/minecraft/font/default.json", "{}\n"); + writeEntry(zip, "assets/installed_mod/lang/zh_cn.json", "{}\n"); + writeEntry(zip, "assets/uninstalled_mod/lang/zh_cn.json", "{}\n"); + } + + ResourcePack source = new ResourcePack("source.zip"); + Files.copy(sourceFile, source.getTmpFilePath(), StandardCopyOption.REPLACE_EXISTING); + + GameMetaData metadata = new GameMetaData(); + metadata.minFormat = JsonParser.parseString("[84,0]"); + metadata.maxFormat = JsonParser.parseString("[84,0]"); + + ResourcePackConverter converter = new ResourcePackConverter( + Collections.singletonList(source), "converted.zip"); + converter.convert(metadata, "test pack", new HashSet<>(Arrays.asList("minecraft", "installed_mod"))); + + try (ZipFile result = new ZipFile(tempDir.resolve("resourcepacks/converted.zip").toFile(), StandardCharsets.UTF_8)) { + assertNotNull(result.getEntry("assets/minecraft/font/default.json")); + assertNotNull(result.getEntry("assets/installed_mod/lang/zh_cn.json")); + assertFalse(result.stream().anyMatch(entry -> + entry.getName().equals("assets/uninstalled_mod/lang/zh_cn.json"))); + + String packMeta = IOUtils.toString(result.getInputStream(result.getEntry("pack.mcmeta")), + StandardCharsets.UTF_8); + assertEquals(84, JsonParser.parseString(packMeta).getAsJsonObject() + .getAsJsonObject("pack").getAsJsonArray("min_format").get(0).getAsInt()); + assertEquals(84, JsonParser.parseString(packMeta).getAsJsonObject() + .getAsJsonObject("pack").getAsJsonArray("max_format").get(0).getAsInt()); + } + } + + private static void writeEntry(ZipOutputStream zip, String name, String content) throws Exception { + zip.putNextEntry(new ZipEntry(name)); + zip.write(content.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } +} diff --git a/src/test/java/i18nupdatemod/entity/GameMetaDataTest.java b/src/test/java/i18nupdatemod/entity/GameMetaDataTest.java new file mode 100644 index 0000000..34b98e7 --- /dev/null +++ b/src/test/java/i18nupdatemod/entity/GameMetaDataTest.java @@ -0,0 +1,45 @@ +package i18nupdatemod.entity; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GameMetaDataTest { + private final Gson gson = new Gson(); + + @Test + void acceptsStructuredPackFormatsIntroducedBy261() { + // 26.1.2 client version.json: resource_major 84, resource_minor 0. + GameMetaData metadata = gson.fromJson( + "{\"minFormat\":[84,0],\"maxFormat\":[84,0]}", GameMetaData.class); + + assertTrue(metadata.useNewFormat()); + assertEquals(84, metadata.minFormat.getAsJsonArray().get(0).getAsInt()); + assertEquals(0, metadata.maxFormat.getAsJsonArray().get(1).getAsInt()); + } + + @Test + void acceptsLegacyNumericPackFormats() { + GameMetaData metadata = gson.fromJson( + "{\"packFormat\":75,\"minFormat\":69,\"maxFormat\":75}", GameMetaData.class); + + assertTrue(metadata.useNewFormat()); + assertEquals(69, metadata.minFormat.getAsInt()); + assertFalse(metadata.minFormat.isJsonArray()); + } + + @Test + void parsesBothMinecraftVersionSchemes() throws Exception { + Method method = Class.forName("i18nupdatemod.I18nUpdateMod") + .getDeclaredMethod("getMinecraftMajorVersion", String.class); + method.setAccessible(true); + + assertEquals(21, method.invoke(null, "1.21.11")); + assertEquals(26, method.invoke(null, "26.1.2")); + } +} diff --git a/src/test/java/i18nupdatemod/modlauncher/ModLauncherServiceTest.java b/src/test/java/i18nupdatemod/modlauncher/ModLauncherServiceTest.java new file mode 100644 index 0000000..67a8ddd --- /dev/null +++ b/src/test/java/i18nupdatemod/modlauncher/ModLauncherServiceTest.java @@ -0,0 +1,65 @@ +package i18nupdatemod.modlauncher; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Forge still boots through ModLauncher on Minecraft 26.1, so this service stays the Forge + * entrypoint there. What changed on that version is pinned here. + */ +class ModLauncherServiceTest { + /** + * Forge 26.1 launches with only {@code --launchTarget forge_client}; the {@code --fml.mcversion} + * argument this code reads first is gone, so the {@code /forge_version.json} fallback is now the + * only source of the version. It must degrade to null rather than throwing when neither is + * reachable, because {@code initialize} runs inside the loader. + */ + @Test + void reportsNoVersionOutsideForgeInsteadOfThrowing() throws Exception { + assertNull(invoke("getMinecraftVersion")); + } + + /** + * FMLLoader is only needed as a resource anchor. Initializing it drags in the loader's own + * runtime, and on Forge 26.1 those classes are compiled for a newer Java than older launchers + * run, which raises an Error that {@code catch (Exception)} would not stop. Loading it + * non-initializing keeps such failures out of the launch path. + */ + @Test + void looksUpClassesWithoutRunningStaticInitializers() throws Exception { + Method loadClass = ModLauncherService.class.getDeclaredMethod("loadClass", String.class); + loadClass.setAccessible(true); + + assertNull(loadClass.invoke(null, "does.not.Exist")); + assertTrue(ExplodesOnInit.class == loadClass.invoke(null, ExplodesOnInit.class.getName()), + "must return the class without initializing it"); + // Held in a separate class, since reading a field of ExplodesOnInit would itself initialize it. + assertFalse(InitFlag.reached, "static initializer must not have run"); + } + + private static Object invoke(String name) throws Exception { + Method method = ModLauncherService.class.getDeclaredMethod(name); + method.setAccessible(true); + return method.invoke(new ModLauncherService()); + } + + /** Records initialization out of band, so observing it does not cause it. */ + static class InitFlag { + static boolean reached; + } + + /** Stands in for an FMLLoader whose static initializer fails. */ + static class ExplodesOnInit { + static { + InitFlag.reached = true; + if (Boolean.parseBoolean("true")) { + throw new RuntimeException("static initializer must not be reached"); + } + } + } +} diff --git a/src/test/java/i18nupdatemod/neoforge/NeoForgeEntrypointTest.java b/src/test/java/i18nupdatemod/neoforge/NeoForgeEntrypointTest.java new file mode 100644 index 0000000..fd151a8 --- /dev/null +++ b/src/test/java/i18nupdatemod/neoforge/NeoForgeEntrypointTest.java @@ -0,0 +1,106 @@ +package i18nupdatemod.neoforge; + +import org.junit.jupiter.api.Test; +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.InputStream; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NeoForgeEntrypointTest { + @Test + void declaresTheClientOnlyNeoForgeEntrypoint() throws Exception { + AtomicBoolean foundModAnnotation = new AtomicBoolean(); + AtomicReference modId = new AtomicReference<>(); + AtomicReference dist = new AtomicReference<>(); + + try (InputStream input = NeoForgeMod.class.getResourceAsStream("/i18nupdatemod/neoforge/NeoForgeMod.class")) { + new ClassReader(input).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + if (!"Lnet/neoforged/fml/common/Mod;".equals(descriptor)) { + return null; + } + foundModAnnotation.set(visible); + return new AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visit(String name, Object value) { + if ("value".equals(name)) { + modId.set((String) value); + } + } + + @Override + public AnnotationVisitor visitArray(String name) { + if (!"dist".equals(name)) { + return null; + } + return new AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visitEnum(String name, String descriptor, String value) { + dist.set(value); + } + }; + } + }; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + + assertTrue(foundModAnnotation.get()); + assertEquals("i18nupdatemod", modId.get()); + assertEquals("CLIENT", dist.get()); + } + + /** + * FML 11 (Minecraft 26.1+) exposes instance {@code getCurrent().getGameDir()/getVersionInfo()}, + * while FML 1~4 (Minecraft 1.20.1~1.21.x) exposes static {@code getGamePath()/versionInfo()}. + * The same universal jar loads on both, so the entrypoint must not bind either shape directly. + */ + @Test + void resolvesBothFmlApiShapesWithoutHardBinding() throws Exception { + Set fmlMethods = new HashSet<>(); + try (InputStream input = NeoForgeMod.class.getResourceAsStream("/i18nupdatemod/neoforge/NeoForgeMod.class")) { + new ClassReader(input).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String name, + String descriptor, boolean isInterface) { + if (owner.startsWith("net/neoforged/fml/loading/")) { + fmlMethods.add(owner + "." + name); + } + } + + @Override + public void visitLdcInsn(Object value) { + if (value instanceof String) { + fmlMethods.add("LDC:" + value); + } + } + }; + } + }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + + // No direct call may reach FMLLoader, or loading fails on whichever FML lacks that method. + assertTrue(fmlMethods.stream().noneMatch(it -> it.startsWith("net/neoforged/fml/loading/")), + "NeoForgeMod must not link FMLLoader directly, found: " + fmlMethods); + // Both API shapes must be attempted reflectively. + assertTrue(fmlMethods.contains("LDC:getCurrent()")); + assertTrue(fmlMethods.contains("LDC:getGamePath()")); + assertTrue(fmlMethods.contains("LDC:versionInfo()")); + assertTrue(fmlMethods.contains("LDC:getVersionInfo()")); + } +}