From 3e22f5edb5bb4e7ba84200cd7237f002e8277c97 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:51:27 +0800 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20options.txt=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E5=86=99=EF=BC=8C=E6=9C=AA=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=86=8D=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删掉整文件覆盖写的那半套(FileUtils.writeLines 先截断再写),改用已有的 readResourcePacks + updateResourcePacks(temp + ATOMIC_MOVE)。已选中同一资源包时不再触碰文件;重复的 resourcePacks 行也统一按最后一行生效重写。 --- .../java/i18nupdatemod/I18nUpdateMod.java | 4 +- .../java/i18nupdatemod/core/GameConfig.java | 46 ++++++------------- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index 0e76a42..623813e 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -66,11 +66,9 @@ 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", + GameConfig.addResourcePack(minecraftPath.resolve("options.txt"), "Minecraft-Mod-Language-Modpack", (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)); // e.printStackTrace(); diff --git a/src/main/java/i18nupdatemod/core/GameConfig.java b/src/main/java/i18nupdatemod/core/GameConfig.java index 37ea9c1..6272a08 100644 --- a/src/main/java/i18nupdatemod/core/GameConfig.java +++ b/src/main/java/i18nupdatemod/core/GameConfig.java @@ -3,7 +3,6 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import i18nupdatemod.util.Log; -import org.apache.commons.io.FileUtils; import java.io.ByteArrayOutputStream; import java.lang.reflect.Type; @@ -13,12 +12,10 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; public class GameConfig { private static final Gson GSON = new Gson(); @@ -26,23 +23,21 @@ public class GameConfig { }.getType(); private static final byte[] RESOURCE_PACKS_KEY = "resourcePacks".getBytes(StandardCharsets.US_ASCII); - protected Map configs = new LinkedHashMap<>(); - private final Path configFile; - public GameConfig(Path configFile) throws Exception { - this.configFile = configFile; - if (!Files.exists(configFile)) { - return; - } - this.configs = FileUtils.readLines(configFile.toFile(), StandardCharsets.UTF_8).stream() - .map(it -> it.split(":", 2)) - .filter(it -> it.length == 2) - .collect(Collectors.toMap(it -> it[0], it -> it[1], (a, b) -> a, LinkedHashMap::new)); + private GameConfig() { } - public void writeToFile() throws Exception { - FileUtils.writeLines(configFile.toFile(), "UTF-8", configs.entrySet().stream() - .map(it -> it.getKey() + ":" + it.getValue()).collect(Collectors.toList())); + public static void addResourcePack(Path configFile, String baseName, String resourcePack) + throws Exception { + List resourcePacks = new ArrayList<>(readResourcePacks(configFile)); + if (resourcePacks.contains(resourcePack)) { + return; + } + // 换版本时清掉旧的同类包 + resourcePacks.removeIf(it -> it.contains(baseName)); + resourcePacks.add(resourcePack); + updateResourcePacks(configFile, resourcePacks); + Log.info("Resource Packs: %s", resourcePacks); } /** @@ -206,20 +201,5 @@ private static boolean containsCrlf(byte[] bytes) { return false; } - public void addResourcePack(String baseName, String resourcePack) { - List resourcePacks = GSON.fromJson( - configs.computeIfAbsent("resourcePacks", it -> "[]"), STRING_LIST_TYPE); - //If resource packs already contains target resource pack, nothing to do - if (resourcePacks.contains(resourcePack)) { - return; - } - //Remove other Minecraft Mod Language Pack - resourcePacks = resourcePacks.stream().filter(it -> !it.contains(baseName)).collect(Collectors.toList()); - resourcePacks.add(resourcePack); - configs.put("resourcePacks", GSON.toJson(resourcePacks)); - Log.info(String.format("Resource Packs: %s", configs.get("resourcePacks"))); -// configs.put("lang", "zh_cn"); - } - } From f143ef0ac895e4f771c943b4b6a9f2304f4cdd84 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:51:28 +0800 Subject: [PATCH 02/16] =?UTF-8?q?fix:=20=E4=B8=93=E7=94=A8=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E8=B7=B3=E8=BF=87=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E6=B1=89=E5=8C=96=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModLauncherService 读 LAUNCHTARGET,含 server 即返回。Forge/NeoForge 专用服务器常把客户端 mods 整个复制过去,原来每次开服都会联网下载并重建资源包,Forge 分支还会在服务端根目录写 options.txt。 --- .../i18nupdatemod/modlauncher/ModLauncherService.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 324a992..5231111 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -35,6 +35,10 @@ public void initialize(IEnvironment environment) { Log.warning("Minecraft path not found"); return; } + if (isDedicatedServer(environment)) { + Log.info("Dedicated server detected, skipping the client resource-pack update."); + return; + } Log.setMinecraftLogFile(minecraftPath.get()); String minecraftVersion = getMinecraftVersion(); if (minecraftVersion == null) { @@ -65,6 +69,13 @@ public void onLoad(IEnvironment env, Set otherServices) throws Incompati return Collections.singletonList(ModLauncherPackTransformer.create()); } + private static boolean isDedicatedServer(IEnvironment environment) { + // Forge and NeoForge name every dedicated-server launch target "*server". + return environment.getProperty(IEnvironment.Keys.LAUNCHTARGET.get()) + .map(target -> target.toLowerCase(Locale.ROOT).contains("server")) + .orElse(false); + } + private String getMinecraftVersion() { // MinecraftForge 1.13~1.20.2 // NeoForge 1.20.1~1.21.8 From 51c77dba2d92cefb469cce95f57060ea5019e12e Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:51:28 +0800 Subject: [PATCH 03/16] =?UTF-8?q?fix(launchwrapper):=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E8=B7=B3=E8=BF=87=E6=9B=B4=E6=96=B0=EF=BC=8CgameDir?= =?UTF-8?q?=20=E7=BC=BA=E5=A4=B1=E6=97=B6=E5=9B=9E=E9=80=80=E5=88=B0?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchwrapper 在没传 --gameDir 时给 null(服务端启动就是这样),原来直接 gameDir.toPath() 会 NPE 让整个启动失败;FML 自己也用 new File(".") 兜这种情况。side 判定用 FMLLaunchHandler.side(),1.6~1.7.10 在 cpw.mods.fml、1.8~1.12.2 在 net.minecraftforge.fml,判不出来保持原行为。 --- .../launchwrapper/LaunchWrapperTweaker.java | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java index e3ea9a1..348eb6e 100644 --- a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java +++ b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java @@ -8,6 +8,7 @@ import net.minecraft.launchwrapper.LaunchClassLoader; import java.io.File; +import java.nio.file.Path; import java.util.List; //1.6-1.12.2 @@ -15,13 +16,18 @@ public class LaunchWrapperTweaker implements ITweaker { @Override public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { - Log.setMinecraftLogFile(gameDir.toPath()); + if (isDedicatedServer()) { + Log.info("Dedicated server detected, skipping the client resource-pack update."); + return; + } + Path gameDirPath = (gameDir == null ? new File(".") : gameDir).toPath(); + Log.setMinecraftLogFile(gameDirPath); String mcVersion = getMcVersion(); if (mcVersion == null) { Log.warning("Failed to get minecraft version."); return; } - I18nUpdateMod.init(gameDir.toPath(), mcVersion, "Forge", ModUtil.getModsFromModsFolder(gameDir.toPath())); + I18nUpdateMod.init(gameDirPath, mcVersion, "Forge", ModUtil.getModsFromModsFolder(gameDirPath)); } @Override @@ -39,6 +45,24 @@ public String[] getLaunchArguments() { return new String[0]; } + private boolean isDedicatedServer() { + // 1.6~1.7.10 + if (isServerSide("cpw.mods.fml.relauncher.FMLLaunchHandler")) { + return true; + } + // 1.8~1.12.2 + return isServerSide("net.minecraftforge.fml.relauncher.FMLLaunchHandler"); + } + + private boolean isServerSide(String launchHandler) { + try { + return "SERVER".equalsIgnoreCase( + String.valueOf(Reflection.clazz(launchHandler).get("side()").get())); + } catch (Exception ignored) { + return false; + } + } + private String getMcVersion() { try { // 1.6~1.7.10 From 7ea0819f618465401b504037f2c76bb04f0c0422 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:25:51 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E9=85=8D=E7=BD=AE=20config/i18nupdatemod.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次启动生成带说明的模板,读取失败一律回落默认值,且绝不覆盖已有文件。cacheTtlHours 是更新检查间隔(小时),0 表示每次启动都校验,默认 24,即原先硬编码的 UPDATE_TIME_GAP;excludeMods 是不参与汉化的模组命名空间,并入 Manifest 的 blackList,跳过下载并删除已缓存汉化。两者都只对 v2 新流程生效。顺带把 GameConfig 的多行注释压成一行。 --- .../java/i18nupdatemod/I18nUpdateMod.java | 3 + .../java/i18nupdatemod/core/GameConfig.java | 4 +- .../i18nupdatemod/core/I18nUserConfig.java | 105 ++++++++++++++++++ .../core/v2/ResourcePackDownloader.java | 4 +- .../i18nupdatemod/core/v2/ResourcePackV2.java | 2 + 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/main/java/i18nupdatemod/core/I18nUserConfig.java diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index 623813e..4f02b79 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -3,6 +3,7 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import i18nupdatemod.core.GameConfig; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.ResourcePackUpdater; import i18nupdatemod.core.RuntimePackActivation; import i18nupdatemod.entity.ModTranslation; @@ -51,6 +52,8 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } catch (ClassNotFoundException ignored) { } + I18nUserConfig.load(minecraftPath); + try { Path resourcePackDirectory = minecraftPath.resolve("resourcepacks"); Path cacheRoot = Paths.get(localStorage, "." + MOD_ID); diff --git a/src/main/java/i18nupdatemod/core/GameConfig.java b/src/main/java/i18nupdatemod/core/GameConfig.java index 6272a08..c069251 100644 --- a/src/main/java/i18nupdatemod/core/GameConfig.java +++ b/src/main/java/i18nupdatemod/core/GameConfig.java @@ -40,9 +40,7 @@ public static void addResourcePack(Path configFile, String baseName, String reso Log.info("Resource Packs: %s", resourcePacks); } - /** - * 避免NeoForge修改资源包顺序导致的按键配置丢失问题。 - */ + /** 避免NeoForge修改资源包顺序导致的按键配置丢失问题。 */ static void updateResourcePacks(Path configFile, List resourcePacks) throws Exception { Path absolute = configFile.toAbsolutePath().normalize(); diff --git a/src/main/java/i18nupdatemod/core/I18nUserConfig.java b/src/main/java/i18nupdatemod/core/I18nUserConfig.java new file mode 100644 index 0000000..faf1c87 --- /dev/null +++ b/src/main/java/i18nupdatemod/core/I18nUserConfig.java @@ -0,0 +1,105 @@ +package i18nupdatemod.core; + +import com.google.gson.Gson; +import i18nupdatemod.util.Log; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * User-editable options read once per launch from {@code config/i18nupdatemod.json}; never throws, always falls back to defaults. + */ +public class I18nUserConfig { + private static final Gson GSON = new Gson(); + private static final String FILE_NAME = "i18nupdatemod.json"; + private static final long DEFAULT_CACHE_TTL_HOURS = 24; + private static final long MAX_CACHE_TTL_HOURS = TimeUnit.DAYS.toHours(365); + private static final String TEMPLATE = "{\n" + + " \"_comment_cacheTtlHours\": \"更新检查间隔(小时):多久之内不再向服务器校验汉化是否有新版本,0 表示每次启动都校验。默认 24。\",\n" + + " \"cacheTtlHours\": 24,\n" + + " \"_comment_excludeMods\": \"不参与汉化的模组命名空间(例如 jei、create):跳过下载,其已缓存的汉化也会被删除。\",\n" + + " \"excludeMods\": []\n" + + "}\n"; + + private static volatile long cacheTtlMillis = TimeUnit.HOURS.toMillis(DEFAULT_CACHE_TTL_HOURS); + private static volatile List excludedMods = Collections.emptyList(); + + public static void load(Path gameDirectory) { + Path file = gameDirectory.resolve("config").resolve(FILE_NAME); + if (!Files.isRegularFile(file)) { + writeTemplate(file); + return; + } + Data data; + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + data = GSON.fromJson(reader, Data.class); + if (data == null) { + throw new IOException("configuration is empty"); + } + } catch (Exception e) { + Log.warning("Failed to read %s, keeping defaults: %s", file, e); + return; + } + cacheTtlMillis = TimeUnit.HOURS.toMillis(validTtlHours(data.cacheTtlHours, file)); + excludedMods = normalize(data.excludeMods, file); + Log.debug("User config: cacheTtlHours=%s excludeMods=%s", TimeUnit.MILLISECONDS.toHours(cacheTtlMillis), excludedMods); + } + + public static long cacheTtlMillis() { + return cacheTtlMillis; + } + + public static List excludedMods() { + return excludedMods; + } + + private static long validTtlHours(long hours, Path file) { + if (hours < 0 || hours > MAX_CACHE_TTL_HOURS) { + Log.warning("cacheTtlHours %s out of range in %s, using %s", hours, file, DEFAULT_CACHE_TTL_HOURS); + return DEFAULT_CACHE_TTL_HOURS; + } + return hours; + } + + private static List normalize(List names, Path file) { + if (names == null || names.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(names.size()); + for (String name : names) { + if (name == null || name.trim().isEmpty()) { + Log.warning("Ignoring empty excludeMods entry in %s", file); + continue; + } + result.add(name.trim().toLowerCase(Locale.ROOT)); + } + return Collections.unmodifiableList(result); + } + + private static void writeTemplate(Path file) { + try { + Files.createDirectories(file.getParent()); + Files.write(file, TEMPLATE.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE_NEW); + Log.info("Created default configuration: %s", file); + } catch (FileAlreadyExistsException e) { + Log.debug("Configuration already created by another thread: %s", file); + } catch (IOException e) { + Log.warning("Failed to create %s: %s", file, e); + } + } + + private static class Data { + long cacheTtlHours = DEFAULT_CACHE_TTL_HOURS; + List excludeMods; + } +} diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 7d1dc36..75008b9 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -4,6 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.net.ResourcePackHttp; import i18nupdatemod.entity.ModTranslation; import i18nupdatemod.util.DigestUtil; @@ -41,7 +42,6 @@ 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); public static Manifest loadManifest(ResourcePackHttp source, String version) throws IOException { @@ -396,7 +396,7 @@ private static void updateMod(ResourcePackHttp source, String assetPath, String discardUnverifiedCache(cached, md5File, zipMd5File); if (Files.isRegularFile(cached) && Files.isRegularFile(md5File) && Files.getLastModifiedTime(cached).toMillis() - > System.currentTimeMillis() - UPDATE_TIME_GAP) { + > System.currentTimeMillis() - I18nUserConfig.cacheTtlMillis()) { return; } diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java index c7ff498..081d2b3 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java @@ -1,6 +1,7 @@ package i18nupdatemod.core.v2; import i18nupdatemod.core.I18nConfig; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.ResourcePackConverter; import i18nupdatemod.core.net.ResourcePackHttp; import i18nupdatemod.entity.GameAssetDetail; @@ -22,6 +23,7 @@ public static Path update(String minecraftVersion, List mods, ResourcePackHttp source = ResourcePackHttp.configured(); ResourcePackDownloader.Manifest manifest = ResourcePackDownloader.loadManifest(source, plan.targetVersion); + manifest.blackList.addAll(I18nUserConfig.excludedMods()); Map namespaces = ResourcePackDownloader.selectNamespaces(mods, manifest); // Capture language eligibility before adding Minecraft's baseline fixes. HashSet modDomains = new HashSet<>(namespaces.values()); From 01e5f14e1c87a2e704d933115ee813e0da8cf579 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:49:10 +0800 Subject: [PATCH 05/16] =?UTF-8?q?fix:=20v1=20=E6=95=B4=E5=8C=85=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=A2=9E=E5=8A=A0=20GitHub=20autobuild=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 镜像站不可用时回退到 CFPAOrg 仓库的 autobuild release:构建配置新增 githubBaseUrl 一并打进 i18n-build.properties,ResourcePackHttp 新增 openLegacy,先走原有 open(),只在抛 IOException 时兜底,全失败时抛 GitHub 的异常并把镜像异常挂成 suppressed。v2 的按模组下载不受影响。 --- build.gradle.kts | 4 ++- .../core/net/ResourcePackHttp.java | 30 ++++++++++++++++++- .../i18nupdatemod/core/v1/ResourcePack.java | 4 +-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1cb1a4a..c1f50e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -57,15 +57,17 @@ mapOf( val bmclBaseUrl = if (variant == "Release") { "https://bmclapi2.bangbang93.com/mirrors/i18n-update-mod/" } else "" + val githubBaseUrl = "https://github.com/CFPAOrg/Minecraft-Mod-Language-Package/releases/download/autobuild/" val configFile = layout.buildDirectory.file("generated/buildConfig/$variant/i18n-build.properties") val generateConfig = tasks.register("generate${variant}Config") { inputs.property("assetBaseUrl", baseUrl) inputs.property("bmclBaseUrl", bmclBaseUrl) + inputs.property("githubBaseUrl", githubBaseUrl) outputs.file(configFile) doLast { configFile.get().asFile.apply { parentFile.mkdirs() - writeText("assetBaseUrl=$baseUrl\nbmclBaseUrl=$bmclBaseUrl\n") + writeText("assetBaseUrl=$baseUrl\nbmclBaseUrl=$bmclBaseUrl\ngithubBaseUrl=$githubBaseUrl\n") } } } diff --git a/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java b/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java index bcec7a7..4bcfbe4 100644 --- a/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java +++ b/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java @@ -21,17 +21,26 @@ public final class ResourcePackHttp { private final String i18nBaseUrl; private final String bmclBaseUrl; + private final String githubBaseUrl; private final Semaphore bmclRequests = new Semaphore(BMCL_PARALLELISM, true); private final Semaphore baseRequests = new Semaphore(I18N_PARALLELISM, true); public ResourcePackHttp(String i18nBaseUrl, String bmclBaseUrl) { + this(i18nBaseUrl, bmclBaseUrl, null); + } + + public ResourcePackHttp(String i18nBaseUrl, String bmclBaseUrl, String githubBaseUrl) { this.i18nBaseUrl = root(i18nBaseUrl); this.bmclBaseUrl = bmclBaseUrl == null || bmclBaseUrl.isEmpty() ? null : root(bmclBaseUrl); + this.githubBaseUrl = githubBaseUrl == null || githubBaseUrl.isEmpty() ? null : root(githubBaseUrl); try { httpUrl(this.i18nBaseUrl); if (this.bmclBaseUrl != null) { httpUrl(this.bmclBaseUrl); } + if (this.githubBaseUrl != null) { + httpUrl(this.githubBaseUrl); + } } catch (IOException e) { throw new IllegalArgumentException("Invalid resource source URL", e); } @@ -57,7 +66,8 @@ private static ResourcePackHttp loadConfiguration() { properties.load(input); String base = properties.getProperty("assetBaseUrl"); if (base == null || base.isEmpty()) throw new IOException("Missing assetBaseUrl in build configuration"); - return new ResourcePackHttp(base, properties.getProperty("bmclBaseUrl")); + return new ResourcePackHttp(base, properties.getProperty("bmclBaseUrl"), + properties.getProperty("githubBaseUrl")); } catch (IOException e) { throw new IllegalStateException("Cannot load bundled build configuration", e); } @@ -79,6 +89,24 @@ public InputStream open(String relativePath) throws IOException { return request(i18nBaseUrl + relativePath, baseRequests); } + /** V1 whole packs: mirrors first, then GitHub's fixed autobuild release. */ + public InputStream openLegacy(String relativePath) throws IOException { + Objects.requireNonNull(relativePath, "relativePath"); + try { + return open(relativePath); + } catch (IOException mirrorFailure) { + if (githubBaseUrl == null) { + throw mirrorFailure; + } + try { + return request(githubBaseUrl + relativePath, baseRequests); + } catch (IOException githubFailure) { + githubFailure.addSuppressed(mirrorFailure); + throw githubFailure; + } + } + } + private InputStream request(String address, Semaphore limit) throws IOException { URL url = httpUrl(address); acquire(limit); diff --git a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java index ddf617d..7f4cf0f 100644 --- a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java @@ -67,7 +67,7 @@ private boolean checkMd5(ResourcePackHttp source, Path localFile, String md5File throws IOException, NoSuchAlgorithmException { String localMd5 = DigestUtil.md5Hex(localFile); if (remoteMd5 == null) { - try (InputStream input = source.open(md5FileName)) { + try (InputStream input = source.openLegacy(md5FileName)) { ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; int count; @@ -87,7 +87,7 @@ private void downloadFull(ResourcePackHttp source, String fileName, String md5Fi throws IOException { try { Path downloadTmp = tmpFilePath.resolveSibling(tmpFilePath.getFileName().toString() + ".tmp"); - try (InputStream input = source.open(fileName)) { + try (InputStream input = source.openLegacy(fileName)) { Files.copy(input, downloadTmp, StandardCopyOption.REPLACE_EXISTING); } if (!checkMd5(source, downloadTmp, md5FileName)) { From 0455649177aebb8952d8590362f0813f5799b931 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:49:17 +0800 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20=E4=B8=8A=E6=8A=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=90=8D=EF=BC=8CNeoForge/?= =?UTF-8?q?Quilt=20=E5=9B=9E=E9=80=80=E5=88=B0=20Forge/Fabric=20=E6=B1=89?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18nMetaData.json 的 assets[].loader 只有 Forge/Fabric 两个取值,NeoForge 与 Quilt 之前分别上报 Forge 与 Fabric,用真实名筛选时拿不到资源。入口改为上报 NeoForge/Quilt(NeoForgeBootstrap 与 ModLauncherService 复用已有的 FMLLoader 探测,Fabric 侧探测 QuiltLoader),LegacyConfig 再做一次别名回退,未知加载器保持取第一条。 --- .../java/i18nupdatemod/core/v1/LegacyConfig.java | 16 +++++++++++++++- .../fabricloader/FabricLoaderMod.java | 12 +++++++++++- .../modlauncher/ModLauncherService.java | 7 +++++-- .../neoforgeloader/NeoForgeBootstrap.java | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java b/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java index e3c7e85..98f86ab 100644 --- a/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java +++ b/src/main/java/i18nupdatemod/core/v1/LegacyConfig.java @@ -31,6 +31,20 @@ private static AssetMetaData getAssetMetaData(String minecraftVersion, String lo return current.stream() .filter(it -> it.loader.equalsIgnoreCase(loader)) .findFirst() - .orElseGet(() -> current.get(0)); + .orElseGet(() -> current.stream() + .filter(it -> it.loader.equalsIgnoreCase(downloadKey(loader))) + .findFirst() + .orElseGet(() -> current.get(0))); + } + + /** The metadata only ever names Forge and Fabric assets. */ + private static String downloadKey(String loader) { + if ("NeoForge".equalsIgnoreCase(loader)) { + return "Forge"; + } + if ("Quilt".equalsIgnoreCase(loader)) { + return "Fabric"; + } + return loader; } } diff --git a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java index 5979711..64e934e 100644 --- a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java +++ b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java @@ -21,7 +21,17 @@ public void onInitializeClient() { Log.warning("Minecraft version not found"); return; } - I18nUpdateMod.init(gameDir, mcVersion, "Fabric", ModUtil.getModsFromModsFolder(gameDir)); + I18nUpdateMod.init(gameDir, mcVersion, isQuilt() ? "Quilt" : "Fabric", + ModUtil.getModsFromModsFolder(gameDir)); + } + + private boolean isQuilt() { + try { + Class.forName("org.quiltmc.loader.api.QuiltLoader", false, getClass().getClassLoader()); + return true; + } catch (ClassNotFoundException ignored) { + return false; + } } private String getMcVersion() { diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 5231111..5e584f2 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -45,13 +45,16 @@ public void initialize(IEnvironment environment) { Log.warning("Minecraft version not found"); return; } + boolean neoForge; try { Class.forName("net.neoforged.fml.loading.FMLLoader", false, getClass().getClassLoader()); + neoForge = true; RuntimePackActivation.enable(); } catch (ClassNotFoundException ignored) { - // Forge keeps the existing file-based activation path. + neoForge = false; } - I18nUpdateMod.init(minecraftPath.get(), minecraftVersion, "Forge", ModUtil.getModsFromModsFolder(minecraftPath.get())); + I18nUpdateMod.init(minecraftPath.get(), minecraftVersion, neoForge ? "NeoForge" : "Forge", + ModUtil.getModsFromModsFolder(minecraftPath.get())); } @Override diff --git a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java index 28ddc10..5c56dd2 100644 --- a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java +++ b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java @@ -34,7 +34,7 @@ public void bootstrap(String[] arguments) { // 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)); + I18nUpdateMod.init(gameDir, version, "NeoForge", ModUtil.getModsFromModsFolder(gameDir)); } catch (Exception e) { Log.warning("Failed to initialize NeoForge resource pack update: %s", e); } From 3fcbee3b0f785922e1bba0f22ed00e9d8f956107 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:49:23 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20onlyWhenChin?= =?UTF-8?q?eseSystem=EF=BC=8C=E9=9D=9E=E4=B8=AD=E6=96=87=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=B8=8B=E6=A8=A1=E7=BB=84=E5=AE=8C=E5=85=A8=E4=B8=8D=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置新增 onlyWhenChineseSystem(默认 false,保持原行为)。为 true 时按 JVM/系统区域设置判定,非中文系统在 I18nUserConfig.load() 之后直接返回:不联网、不下载、不写 options.txt,同时 RuntimePackActivation.disable() 关掉运行时钩子,避免类变换器仍去改资源包选择。整合包作者可设为 true 后随包分发。判定读的是操作系统区域设置,不是游戏内语言。 --- src/main/java/i18nupdatemod/I18nUpdateMod.java | 10 ++++++++++ .../java/i18nupdatemod/core/I18nUserConfig.java | 14 ++++++++++++-- .../i18nupdatemod/core/RuntimePackActivation.java | 7 +++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index 4f02b79..fea2d70 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -16,6 +16,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.stream.Stream; @@ -53,6 +54,11 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } I18nUserConfig.load(minecraftPath); + if (I18nUserConfig.onlyWhenChineseSystem() && !isChineseSystem()) { + RuntimePackActivation.disable(); + Log.info("System language is not Chinese; I18nUpdateMod stays idle."); + return; + } try { Path resourcePackDirectory = minecraftPath.resolve("resourcepacks"); @@ -79,6 +85,10 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } + private static boolean isChineseSystem() { + return "zh".equalsIgnoreCase(Locale.getDefault().getLanguage()); + } + public static String getLocalStoragePos(Path minecraftPath) { Path userHome = Paths.get(System.getProperty("user.home")); Path oldPath = userHome.resolve("." + MOD_ID); diff --git a/src/main/java/i18nupdatemod/core/I18nUserConfig.java b/src/main/java/i18nupdatemod/core/I18nUserConfig.java index faf1c87..bffbc09 100644 --- a/src/main/java/i18nupdatemod/core/I18nUserConfig.java +++ b/src/main/java/i18nupdatemod/core/I18nUserConfig.java @@ -28,11 +28,14 @@ public class I18nUserConfig { + " \"_comment_cacheTtlHours\": \"更新检查间隔(小时):多久之内不再向服务器校验汉化是否有新版本,0 表示每次启动都校验。默认 24。\",\n" + " \"cacheTtlHours\": 24,\n" + " \"_comment_excludeMods\": \"不参与汉化的模组命名空间(例如 jei、create):跳过下载,其已缓存的汉化也会被删除。\",\n" - + " \"excludeMods\": []\n" + + " \"excludeMods\": [],\n" + + " \"_comment_onlyWhenChineseSystem\": \"为 true 时,只在系统语言(操作系统区域设置,不是游戏内语言)为中文时才启动:非中文系统下模组完全不动作,不联网、不下载、不写任何文件。整合包作者可设为 true 后随包分发。默认 false。\",\n" + + " \"onlyWhenChineseSystem\": false\n" + "}\n"; private static volatile long cacheTtlMillis = TimeUnit.HOURS.toMillis(DEFAULT_CACHE_TTL_HOURS); private static volatile List excludedMods = Collections.emptyList(); + private static volatile boolean onlyWhenChineseSystem; public static void load(Path gameDirectory) { Path file = gameDirectory.resolve("config").resolve(FILE_NAME); @@ -52,7 +55,9 @@ public static void load(Path gameDirectory) { } cacheTtlMillis = TimeUnit.HOURS.toMillis(validTtlHours(data.cacheTtlHours, file)); excludedMods = normalize(data.excludeMods, file); - Log.debug("User config: cacheTtlHours=%s excludeMods=%s", TimeUnit.MILLISECONDS.toHours(cacheTtlMillis), excludedMods); + onlyWhenChineseSystem = data.onlyWhenChineseSystem; + Log.debug("User config: cacheTtlHours=%s excludeMods=%s onlyWhenChineseSystem=%s", + TimeUnit.MILLISECONDS.toHours(cacheTtlMillis), excludedMods, onlyWhenChineseSystem); } public static long cacheTtlMillis() { @@ -63,6 +68,10 @@ public static List excludedMods() { return excludedMods; } + public static boolean onlyWhenChineseSystem() { + return onlyWhenChineseSystem; + } + private static long validTtlHours(long hours, Path file) { if (hours < 0 || hours > MAX_CACHE_TTL_HOURS) { Log.warning("cacheTtlHours %s out of range in %s, using %s", hours, file, DEFAULT_CACHE_TTL_HOURS); @@ -101,5 +110,6 @@ private static void writeTemplate(Path file) { private static class Data { long cacheTtlHours = DEFAULT_CACHE_TTL_HOURS; List excludeMods; + boolean onlyWhenChineseSystem; } } diff --git a/src/main/java/i18nupdatemod/core/RuntimePackActivation.java b/src/main/java/i18nupdatemod/core/RuntimePackActivation.java index e35e25c..96a9e57 100644 --- a/src/main/java/i18nupdatemod/core/RuntimePackActivation.java +++ b/src/main/java/i18nupdatemod/core/RuntimePackActivation.java @@ -51,6 +51,13 @@ public static boolean isEnabled() { return enabled; } + /** Keeps a gated-off launch from hooking the client or writing a pack selection. */ + public static void disable() { + synchronized (STATE_LOCK) { + enabled = false; + } + } + /** * Records an explicit Options binding failure, not a generic no-change transform result. * A prepared pack is persisted now; a later prepare call uses the same file fallback. From 9c99a828bcf7ad3c4a45986efc1b358a8d3efce1 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:54:05 +0800 Subject: [PATCH 08/16] =?UTF-8?q?docs:=20README=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=AF=B4=E6=98=8E=E3=80=81=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=2026.1=20=E6=94=AF=E6=8C=81=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「配置」段,说明 config/i18nupdatemod.json 的三个键(cacheTtlHours、excludeMods、onlyWhenChineseSystem);支持范围按 i18nMetaData.json 修正为 26.1~26.3。顺手把版本号里的 ASCII ~ 换成全角 ~:GitHub 把单个 ~ 当删除线,原来那行是带删除线显示的。 --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62fb1d0..46ddea0 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ ## 支持的版本 -- Minecraft:1.6.1~1.21.11、26.1~26.1.2 都支持 +- Minecraft:1.6.1~1.21.11、26.1~26.3 都支持 - Mod加载器:MinecraftForge、NeoForge、Fabric、Quilt 都支持 -- Java:8~25 都支持 +- Java:8~25 都支持 仅仅需要在mods文件夹中放置本Mod的jar文件即可,Mod本身与各主流Minecraft版本、Mod Loader、Java版本均兼容,Mod本身不需要进行任何版本隔离。 @@ -36,6 +36,14 @@ - 合并转换:会合并加转换最近版本的一些资源包,尽可能做最大化的支持 - 特别说明:1.13开始将语言文件变化为json格式,所以不能将1.12.2的资源包用于1.13以上,反之同理 +## 配置 + +首次启动会在 `config/i18nupdatemod.json` 生成带说明的模板。 + +- `cacheTtlHours`:多久之内不再向服务器校验汉化是否有新版本,默认 24 小时,`0` 表示每次启动都校验。 +- `excludeMods`:不参与汉化的模组命名空间(例如 `jei`、`create`),跳过下载,其已缓存的汉化也会被删除。 +- `onlyWhenChineseSystem`:为 `true` 时只在系统语言为中文时才启动;非中文系统下本Mod完全不动作(不联网、不下载、不写任何文件)。默认 `false`,整合包作者可设为 `true` 后随包分发。判定读的是操作系统区域设置,不是游戏内语言。 + ## 开发环境 构建需要 JDK 21(用于编译新版 NeoForge 的服务接口);产物仍使用 Java 8 字节码,旧版游戏的 Java 运行要求不变。 From 1115829002ab4bd1450c5570edbbbd9838e0333b Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:36:29 +0800 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20=E9=9D=9E=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=B8=8B=E5=AE=8C=E5=85=A8=E4=B8=8D=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=EF=BC=88onlyWhenChineseSystem=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 I18nUpdateMod.prepare():先读配置与语言门禁,通过后才打开日志文件 - Fabric/Quilt、launchwrapper、ModLauncher、NeoForge 引导四个入口改调 prepare() - 配置逐字段解析:某个字段类型写错只回它自己的默认值,不再连带把门禁打回默认 - 配置相关日志攒到日志文件打开后输出,DEBUG 摘要不再丢 --- .../java/i18nupdatemod/I18nUpdateMod.java | 23 +++- .../i18nupdatemod/core/I18nUserConfig.java | 121 +++++++++++++++--- .../fabricloader/FabricLoaderMod.java | 4 +- .../launchwrapper/LaunchWrapperTweaker.java | 4 +- .../modlauncher/ModLauncherService.java | 4 +- .../neoforgeloader/NeoForgeBootstrap.java | 4 +- 6 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/main/java/i18nupdatemod/I18nUpdateMod.java b/src/main/java/i18nupdatemod/I18nUpdateMod.java index fea2d70..77e179e 100644 --- a/src/main/java/i18nupdatemod/I18nUpdateMod.java +++ b/src/main/java/i18nupdatemod/I18nUpdateMod.java @@ -26,6 +26,22 @@ public class I18nUpdateMod { public static final Gson GSON = new Gson(); + /** Runs before any file-backed logging: loads the user config, applies the system-language gate, opens the log file. Returns false to stay completely idle. */ + public static boolean prepare(Path minecraftPath) { + I18nUserConfig.load(minecraftPath); + boolean idle = I18nUserConfig.onlyWhenChineseSystem() && !isChineseSystem(); + if (!idle) { + Log.setMinecraftLogFile(minecraftPath); + } + I18nUserConfig.flushLogMessages(); + if (idle) { + RuntimePackActivation.disable(); + Log.info("System language is not Chinese; I18nUpdateMod stays idle."); + return false; + } + return true; + } + 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(); @@ -53,13 +69,6 @@ public static void init(Path minecraftPath, String minecraftVersion, String load } catch (ClassNotFoundException ignored) { } - I18nUserConfig.load(minecraftPath); - if (I18nUserConfig.onlyWhenChineseSystem() && !isChineseSystem()) { - RuntimePackActivation.disable(); - Log.info("System language is not Chinese; I18nUpdateMod stays idle."); - return; - } - try { Path resourcePackDirectory = minecraftPath.resolve("resourcepacks"); Path cacheRoot = Paths.get(localStorage, "." + MOD_ID); diff --git a/src/main/java/i18nupdatemod/core/I18nUserConfig.java b/src/main/java/i18nupdatemod/core/I18nUserConfig.java index bffbc09..d7257ba 100644 --- a/src/main/java/i18nupdatemod/core/I18nUserConfig.java +++ b/src/main/java/i18nupdatemod/core/I18nUserConfig.java @@ -1,6 +1,9 @@ package i18nupdatemod.core; import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.stream.JsonReader; import i18nupdatemod.util.Log; import java.io.IOException; @@ -16,9 +19,7 @@ import java.util.Locale; import java.util.concurrent.TimeUnit; -/** - * User-editable options read once per launch from {@code config/i18nupdatemod.json}; never throws, always falls back to defaults. - */ +/** User-editable options read once per launch from {@code config/i18nupdatemod.json}; never throws, always falls back to defaults. */ public class I18nUserConfig { private static final Gson GSON = new Gson(); private static final String FILE_NAME = "i18nupdatemod.json"; @@ -36,6 +37,8 @@ public class I18nUserConfig { private static volatile long cacheTtlMillis = TimeUnit.HOURS.toMillis(DEFAULT_CACHE_TTL_HOURS); private static volatile List excludedMods = Collections.emptyList(); private static volatile boolean onlyWhenChineseSystem; + private static final List pendingDebug = new ArrayList<>(); + private static final List pendingWarnings = new ArrayList<>(); public static void load(Path gameDirectory) { Path file = gameDirectory.resolve("config").resolve(FILE_NAME); @@ -43,23 +46,36 @@ public static void load(Path gameDirectory) { writeTemplate(file); return; } - Data data; - try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { - data = GSON.fromJson(reader, Data.class); - if (data == null) { - throw new IOException("configuration is empty"); - } - } catch (Exception e) { - Log.warning("Failed to read %s, keeping defaults: %s", file, e); + JsonObject data = read(file); + if (data == null) { return; } - cacheTtlMillis = TimeUnit.HOURS.toMillis(validTtlHours(data.cacheTtlHours, file)); - excludedMods = normalize(data.excludeMods, file); - onlyWhenChineseSystem = data.onlyWhenChineseSystem; - Log.debug("User config: cacheTtlHours=%s excludeMods=%s onlyWhenChineseSystem=%s", + // 逐字段读取:某个字段写错不能让 onlyWhenChineseSystem 跟着回默认,否则门禁会静默失效 + cacheTtlMillis = TimeUnit.HOURS.toMillis(validTtlHours(longValue(data, "cacheTtlHours", file), file)); + excludedMods = normalize(stringList(data, "excludeMods", file), file); + onlyWhenChineseSystem = booleanValue(data, "onlyWhenChineseSystem", file); + debug("User config: cacheTtlHours=%s excludeMods=%s onlyWhenChineseSystem=%s", TimeUnit.MILLISECONDS.toHours(cacheTtlMillis), excludedMods, onlyWhenChineseSystem); } + /** 读配置早于日志文件打开,攒下的消息由 prepare() 在开完日志后输出。 */ + public static void flushLogMessages() { + List debugs; + List warnings; + synchronized (pendingDebug) { + debugs = new ArrayList<>(pendingDebug); + warnings = new ArrayList<>(pendingWarnings); + pendingDebug.clear(); + pendingWarnings.clear(); + } + for (String line : debugs) { + Log.debug(line); + } + for (String line : warnings) { + Log.warning(line); + } + } + public static long cacheTtlMillis() { return cacheTtlMillis; } @@ -87,7 +103,7 @@ private static List normalize(List names, Path file) { List result = new ArrayList<>(names.size()); for (String name : names) { if (name == null || name.trim().isEmpty()) { - Log.warning("Ignoring empty excludeMods entry in %s", file); + warning("Ignoring empty excludeMods entry in %s", file); continue; } result.add(name.trim().toLowerCase(Locale.ROOT)); @@ -107,9 +123,74 @@ private static void writeTemplate(Path file) { } } - private static class Data { - long cacheTtlHours = DEFAULT_CACHE_TTL_HOURS; - List excludeMods; - boolean onlyWhenChineseSystem; + private static JsonObject read(Path file) { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + JsonReader json = new JsonReader(reader); + // 宽松模式:容忍手写配置常见的不带引号键名 + json.setLenient(true); + JsonObject data = GSON.fromJson(json, JsonObject.class); + if (data == null) { + throw new IOException("configuration is empty"); + } + return data; + } catch (Exception e) { + warning("Failed to read %s, keeping defaults: %s", file, e); + return null; + } + } + + private static long longValue(JsonObject data, String key, Path file) { + JsonElement value = data.get(key); + if (value == null || value.isJsonNull()) { + return DEFAULT_CACHE_TTL_HOURS; + } + try { + return value.getAsLong(); + } catch (RuntimeException e) { + warning("%s in %s must be a whole number, using %s", key, file, DEFAULT_CACHE_TTL_HOURS); + return DEFAULT_CACHE_TTL_HOURS; + } + } + + private static List stringList(JsonObject data, String key, Path file) { + JsonElement value = data.get(key); + if (value == null || value.isJsonNull()) { + return Collections.emptyList(); + } + try { + List names = new ArrayList<>(); + for (JsonElement name : value.getAsJsonArray()) { + names.add(name == null || name.isJsonNull() ? null : name.getAsString()); + } + return names; + } catch (RuntimeException e) { + warning("%s in %s must be an array of strings, ignoring it", key, file); + return Collections.emptyList(); + } + } + + private static boolean booleanValue(JsonObject data, String key, Path file) { + JsonElement value = data.get(key); + if (value == null || value.isJsonNull()) { + return false; + } + try { + return value.getAsBoolean(); + } catch (RuntimeException e) { + warning("%s in %s must be true or false, keeping false", key, file); + return false; + } + } + + private static void debug(String format, Object... args) { + synchronized (pendingDebug) { + pendingDebug.add(String.format(format, args)); + } + } + + private static void warning(String format, Object... args) { + synchronized (pendingDebug) { + pendingWarnings.add(String.format(format, args)); + } } } diff --git a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java index 64e934e..eb5fc83 100644 --- a/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java +++ b/src/main/java/i18nupdatemod/fabricloader/FabricLoaderMod.java @@ -15,7 +15,9 @@ public class FabricLoaderMod implements ClientModInitializer { @Override public void onInitializeClient() { Path gameDir = FabricLoader.getInstance().getGameDir(); - Log.setMinecraftLogFile(gameDir); + if (!I18nUpdateMod.prepare(gameDir)) { + return; + } String mcVersion = getMcVersion(); if (mcVersion == null) { Log.warning("Minecraft version not found"); diff --git a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java index 348eb6e..212a9a2 100644 --- a/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java +++ b/src/main/java/i18nupdatemod/launchwrapper/LaunchWrapperTweaker.java @@ -21,7 +21,9 @@ public void acceptOptions(List args, File gameDir, File assetsDir, Strin return; } Path gameDirPath = (gameDir == null ? new File(".") : gameDir).toPath(); - Log.setMinecraftLogFile(gameDirPath); + if (!I18nUpdateMod.prepare(gameDirPath)) { + return; + } String mcVersion = getMcVersion(); if (mcVersion == null) { Log.warning("Failed to get minecraft version."); diff --git a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java index 5e584f2..ba5d214 100644 --- a/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java +++ b/src/main/java/i18nupdatemod/modlauncher/ModLauncherService.java @@ -39,7 +39,9 @@ public void initialize(IEnvironment environment) { Log.info("Dedicated server detected, skipping the client resource-pack update."); return; } - Log.setMinecraftLogFile(minecraftPath.get()); + if (!I18nUpdateMod.prepare(minecraftPath.get())) { + return; + } String minecraftVersion = getMinecraftVersion(); if (minecraftVersion == null) { Log.warning("Minecraft version not found"); diff --git a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java index 5c56dd2..5455f12 100644 --- a/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java +++ b/src/main/java/i18nupdatemod/neoforgeloader/NeoForgeBootstrap.java @@ -30,7 +30,9 @@ public void bootstrap(String[] arguments) { return; } Path gameDir = (Path) loader.get("getGameDir()").get(); - Log.setMinecraftLogFile(gameDir); + if (!I18nUpdateMod.prepare(gameDir)) { + return; + } // FML consumes --fml.mcVersion before calling bootstrappers; do not parse arguments. String version = (String) loader.get("getVersionInfo()").get("mcVersion()").get(); RuntimePackActivation.enable(); From 5808f657c043840c1b41328eb932edee1f957d72 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:36:29 +0800 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20options.txt=20=E7=9A=84=20resource?= =?UTF-8?q?Packs=20=E4=B8=BA=E7=A9=BA=E5=80=BC=E6=97=B6=E6=8C=89=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E8=B5=84=E6=BA=90=E5=8C=85=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gson 对空值返回 null,不再抛异常,直接追加汉化包并保留其它选项行 --- .../java/i18nupdatemod/core/GameConfig.java | 6 ++-- .../i18nupdatemod/core/GameConfigTest.java | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 src/test/java/i18nupdatemod/core/GameConfigTest.java diff --git a/src/main/java/i18nupdatemod/core/GameConfig.java b/src/main/java/i18nupdatemod/core/GameConfig.java index c069251..2bed40f 100644 --- a/src/main/java/i18nupdatemod/core/GameConfig.java +++ b/src/main/java/i18nupdatemod/core/GameConfig.java @@ -108,10 +108,8 @@ static List readResourcePacks(Path configFile) throws Exception { } List selected = GSON.fromJson( new String(bytes, valueStart, valueEnd - valueStart, StandardCharsets.UTF_8), STRING_LIST_TYPE); - if (selected == null) { - throw new IllegalStateException("resourcePacks must be a JSON array"); - } - return selected; + // 空值(如 `resourcePacks:` 后面什么都没有)Gson 返回 null,等同于没有任何资源包 + return selected == null ? Collections.emptyList() : selected; } private static byte[] replaceResourcePacksLines(byte[] original, byte[] encodedLine) { diff --git a/src/test/java/i18nupdatemod/core/GameConfigTest.java b/src/test/java/i18nupdatemod/core/GameConfigTest.java new file mode 100644 index 0000000..9a33841 --- /dev/null +++ b/src/test/java/i18nupdatemod/core/GameConfigTest.java @@ -0,0 +1,33 @@ +package i18nupdatemod.core; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GameConfigTest { + @TempDir + Path gameDir; + + @Test + void emptyResourcePacksValueAddsThePackInsteadOfAborting() throws Exception { + Path options = gameDir.resolve("options.txt"); + Files.write(options, "lang:zh_cn\nresourcePacks:\nfov:70\n".getBytes(StandardCharsets.UTF_8)); + + assertEquals(Collections.emptyList(), GameConfig.readResourcePacks(options)); + + GameConfig.addResourcePack(options, "Minecraft-Mod-Language-Modpack", + "file/Minecraft-Mod-Language-Modpack-Converted-1.9.zip"); + assertEquals(Collections.singletonList("file/Minecraft-Mod-Language-Modpack-Converted-1.9.zip"), + GameConfig.readResourcePacks(options)); + String content = new String(Files.readAllBytes(options), StandardCharsets.UTF_8); + assertTrue(content.contains("lang:zh_cn"), content); + assertTrue(content.contains("fov:70"), content); + } +} From a0f8ee27d6646ca33af177dcc4b3fb89536312c1 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:36:30 +0800 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20v1=20=E6=95=B4=E5=8C=85=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E6=94=B9=E4=B8=BA=20BMCL=E3=80=81i18n=E3=80=81GitHub?= =?UTF-8?q?=20=E9=80=90=E7=BA=A7=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三个来源各自独立尝试,失败原因 addSuppressed 到最终异常 --- .../core/net/ResourcePackHttp.java | 21 ++++- .../core/net/LegacyFallbackTest.java | 93 +++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/test/java/i18nupdatemod/core/net/LegacyFallbackTest.java diff --git a/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java b/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java index 4bcfbe4..6d29e94 100644 --- a/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java +++ b/src/main/java/i18nupdatemod/core/net/ResourcePackHttp.java @@ -89,19 +89,30 @@ public InputStream open(String relativePath) throws IOException { return request(i18nBaseUrl + relativePath, baseRequests); } - /** V1 whole packs: mirrors first, then GitHub's fixed autobuild release. */ + /** V1 whole packs: every mirror in turn, then GitHub's fixed autobuild release. */ public InputStream openLegacy(String relativePath) throws IOException { Objects.requireNonNull(relativePath, "relativePath"); + IOException bmclFailure = null; + if (bmclBaseUrl != null) { + try { + return request(bmclBaseUrl + relativePath, bmclRequests); + } catch (IOException failure) { + bmclFailure = failure; + } + } try { - return open(relativePath); - } catch (IOException mirrorFailure) { + return request(i18nBaseUrl + relativePath, baseRequests); + } catch (IOException i18nFailure) { + if (bmclFailure != null) { + i18nFailure.addSuppressed(bmclFailure); + } if (githubBaseUrl == null) { - throw mirrorFailure; + throw i18nFailure; } try { return request(githubBaseUrl + relativePath, baseRequests); } catch (IOException githubFailure) { - githubFailure.addSuppressed(mirrorFailure); + githubFailure.addSuppressed(i18nFailure); throw githubFailure; } } diff --git a/src/test/java/i18nupdatemod/core/net/LegacyFallbackTest.java b/src/test/java/i18nupdatemod/core/net/LegacyFallbackTest.java new file mode 100644 index 0000000..694fdfb --- /dev/null +++ b/src/test/java/i18nupdatemod/core/net/LegacyFallbackTest.java @@ -0,0 +1,93 @@ +package i18nupdatemod.core.net; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LegacyFallbackTest { + @Test + void bmclStatusFailureStillTriesTheI18nMirrorBeforeGithub() throws Exception { + try (Mirror bmcl = new Mirror(404, "not here"); + Mirror i18n = new Mirror(200, "i18n pack"); + Mirror github = new Mirror(200, "github pack")) { + ResourcePackHttp source = new ResourcePackHttp(i18n.baseUrl(), bmcl.baseUrl(), github.baseUrl()); + assertEquals("i18n pack", read(source.openLegacy("pack.zip"))); + assertEquals(0, github.hits.get()); + } + } + + @Test + void bmclConnectionFailureStillTriesTheI18nMirrorBeforeGithub() throws Exception { + Mirror bmcl = new Mirror(200, "unreachable"); + bmcl.close(); + try (Mirror i18n = new Mirror(200, "i18n pack"); + Mirror github = new Mirror(200, "github pack")) { + ResourcePackHttp source = new ResourcePackHttp(i18n.baseUrl(), bmcl.baseUrl(), github.baseUrl()); + assertEquals("i18n pack", read(source.openLegacy("pack.zip"))); + assertEquals(0, github.hits.get()); + } + } + + @Test + void githubIsUsedOnlyAfterBothMirrorsFail() throws Exception { + try (Mirror bmcl = new Mirror(500, "broken"); + Mirror i18n = new Mirror(404, "not here"); + Mirror github = new Mirror(200, "github pack")) { + ResourcePackHttp source = new ResourcePackHttp(i18n.baseUrl(), bmcl.baseUrl(), github.baseUrl()); + assertEquals("github pack", read(source.openLegacy("pack.zip"))); + } + } + + private static String read(InputStream input) throws IOException { + try (InputStream stream = input) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int count; + while ((count = stream.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + } + + private static final class Mirror implements AutoCloseable { + private final HttpServer server; + private final int status; + private final String body; + private final AtomicInteger hits = new AtomicInteger(); + + Mirror(int status, String body) throws IOException { + this.status = status; + this.body = body; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + hits.incrementAndGet(); + byte[] payload = this.body.getBytes(StandardCharsets.UTF_8); + try { + exchange.sendResponseHeaders(this.status, payload.length); + exchange.getResponseBody().write(payload); + } finally { + exchange.close(); + } + }); + server.start(); + } + + String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/"; + } + + @Override + public void close() { + server.stop(0); + } + } +} From 226d58afa4b1fed7461598ef46cee5ab43c98e79 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:37:01 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20excludeMods=20=E5=8F=AA=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E6=9C=AC=E6=AC=A1=E9=80=89=E5=8C=85=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=88=A0=E9=99=A4=E5=B7=B2=E6=9C=89=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectNamespaces 接收排除项,仅用于跳过;缓存清理只认 Manifest 的 blackList - V1 用 isExcluded 过滤 modDomains,README 与配置模板同步措辞 --- README.md | 2 +- .../i18nupdatemod/core/I18nUserConfig.java | 8 ++++-- .../i18nupdatemod/core/v1/ResourcePackV1.java | 5 +++- .../core/v2/ResourcePackDownloader.java | 7 +++-- .../i18nupdatemod/core/v2/ResourcePackV2.java | 5 ++-- .../core/v2/ResourcePackSelectionTest.java | 28 +++++++++++++++++++ 6 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 src/test/java/i18nupdatemod/core/v2/ResourcePackSelectionTest.java diff --git a/README.md b/README.md index 46ddea0..a7d015d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ 首次启动会在 `config/i18nupdatemod.json` 生成带说明的模板。 - `cacheTtlHours`:多久之内不再向服务器校验汉化是否有新版本,默认 24 小时,`0` 表示每次启动都校验。 -- `excludeMods`:不参与汉化的模组命名空间(例如 `jei`、`create`),跳过下载,其已缓存的汉化也会被删除。 +- `excludeMods`:不参与汉化的模组命名空间(例如 `jei`、`create`),跳过下载、也不会进入汉化包;已有缓存不动。 - `onlyWhenChineseSystem`:为 `true` 时只在系统语言为中文时才启动;非中文系统下本Mod完全不动作(不联网、不下载、不写任何文件)。默认 `false`,整合包作者可设为 `true` 后随包分发。判定读的是操作系统区域设置,不是游戏内语言。 ## 开发环境 diff --git a/src/main/java/i18nupdatemod/core/I18nUserConfig.java b/src/main/java/i18nupdatemod/core/I18nUserConfig.java index d7257ba..1542c15 100644 --- a/src/main/java/i18nupdatemod/core/I18nUserConfig.java +++ b/src/main/java/i18nupdatemod/core/I18nUserConfig.java @@ -28,7 +28,7 @@ public class I18nUserConfig { private static final String TEMPLATE = "{\n" + " \"_comment_cacheTtlHours\": \"更新检查间隔(小时):多久之内不再向服务器校验汉化是否有新版本,0 表示每次启动都校验。默认 24。\",\n" + " \"cacheTtlHours\": 24,\n" - + " \"_comment_excludeMods\": \"不参与汉化的模组命名空间(例如 jei、create):跳过下载,其已缓存的汉化也会被删除。\",\n" + + " \"_comment_excludeMods\": \"不参与汉化的模组命名空间(例如 jei、create):跳过下载,也不会进入汉化包;已有缓存不动。\",\n" + " \"excludeMods\": [],\n" + " \"_comment_onlyWhenChineseSystem\": \"为 true 时,只在系统语言(操作系统区域设置,不是游戏内语言)为中文时才启动:非中文系统下模组完全不动作,不联网、不下载、不写任何文件。整合包作者可设为 true 后随包分发。默认 false。\",\n" + " \"onlyWhenChineseSystem\": false\n" @@ -84,13 +84,17 @@ public static List excludedMods() { return excludedMods; } + public static boolean isExcluded(String namespace) { + return namespace != null && excludedMods.contains(namespace.toLowerCase(Locale.ROOT)); + } + public static boolean onlyWhenChineseSystem() { return onlyWhenChineseSystem; } private static long validTtlHours(long hours, Path file) { if (hours < 0 || hours > MAX_CACHE_TTL_HOURS) { - Log.warning("cacheTtlHours %s out of range in %s, using %s", hours, file, DEFAULT_CACHE_TTL_HOURS); + warning("cacheTtlHours %s out of range in %s, using %s", hours, file, DEFAULT_CACHE_TTL_HOURS); return DEFAULT_CACHE_TTL_HOURS; } return hours; diff --git a/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java b/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java index 29e2d80..300b462 100644 --- a/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePackV1.java @@ -1,6 +1,7 @@ package i18nupdatemod.core.v1; import i18nupdatemod.core.I18nConfig; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.ResourcePackConverter; import i18nupdatemod.entity.GameAssetDetail; import i18nupdatemod.entity.ModTranslation; @@ -15,7 +16,9 @@ public static Path update(String minecraftVersion, String loader, List modDomains = new HashSet<>(); for (ModTranslation mod : mods) { - modDomains.add(mod.namespace); + if (!I18nUserConfig.isExcluded(mod.namespace)) { + modDomains.add(mod.namespace); + } } List sources = LegacyResourcePackDownloader.download( plan.packMetaData, loader, resourcePackDirectory, cacheRoot); diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 75008b9..0323235 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -100,7 +100,8 @@ private static Manifest parseManifest(InputStream input) throws IOException { /** * 解析Manifest,根据规则排除模组、生成NS */ - public static Map selectNamespaces(List mods, Manifest manifest) { + public static Map selectNamespaces(List mods, Manifest manifest, + List excludedMods) { if (mods == null) { return new LinkedHashMap<>(); } @@ -110,6 +111,8 @@ public static Map selectNamespaces(List mods, Ma List blackList = manifest.blackList == null ? Collections.emptyList() : manifest.blackList; + List excluded = excludedMods == null + ? Collections.emptyList() : excludedMods; Map rules = manifest.rules == null ? Collections.emptyMap() : manifest.rules; Map selected = new LinkedHashMap<>(); @@ -122,7 +125,7 @@ public static Map selectNamespaces(List mods, Ma Log.warning("Invalid translation namespace: %s", rawNamespace); continue; } - if (blackList.contains(rawNamespace)) { + if (blackList.contains(rawNamespace) || excluded.contains(rawNamespace)) { continue; } diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java index 081d2b3..f2c2858 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackV2.java @@ -23,8 +23,9 @@ public static Path update(String minecraftVersion, List mods, ResourcePackHttp source = ResourcePackHttp.configured(); ResourcePackDownloader.Manifest manifest = ResourcePackDownloader.loadManifest(source, plan.targetVersion); - manifest.blackList.addAll(I18nUserConfig.excludedMods()); - Map namespaces = ResourcePackDownloader.selectNamespaces(mods, manifest); + // 用户排除项只影响本次选包,绝不参与缓存清理 + Map namespaces = ResourcePackDownloader.selectNamespaces( + mods, manifest, I18nUserConfig.excludedMods()); // Capture language eligibility before adding Minecraft's baseline fixes. HashSet modDomains = new HashSet<>(namespaces.values()); namespaces.put("minecraft", "minecraft"); diff --git a/src/test/java/i18nupdatemod/core/v2/ResourcePackSelectionTest.java b/src/test/java/i18nupdatemod/core/v2/ResourcePackSelectionTest.java new file mode 100644 index 0000000..7167edf --- /dev/null +++ b/src/test/java/i18nupdatemod/core/v2/ResourcePackSelectionTest.java @@ -0,0 +1,28 @@ +package i18nupdatemod.core.v2; + +import i18nupdatemod.entity.ModTranslation; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ResourcePackSelectionTest { + @Test + void excludedNamespacesAreSkippedBySelectionOnly() { + ResourcePackDownloader.Manifest manifest = new ResourcePackDownloader.Manifest(); + manifest.blackList.add("create"); + + Map selected = ResourcePackDownloader.selectNamespaces( + Arrays.asList(mod("jei"), mod("create"), mod("other")), manifest, + Collections.singletonList("jei")); + + assertEquals(Collections.singletonMap("other", "other"), selected); + } + + private static ModTranslation mod(String namespace) { + return new ModTranslation(namespace, null, null, null, null); + } +} From f6a8960fb1f9b6814c17c7f1fd8b6d3b31ce5374 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:37:02 +0800 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20v1=20=E6=95=B4=E5=8C=85=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=9F=A5=E9=97=B4=E9=9A=94=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20cacheTtlHours=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 由固定 1 天改为 I18nUserConfig.cacheTtlHours(),0 表示每次启动都校验 --- src/main/java/i18nupdatemod/core/v1/ResourcePack.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java index 7f4cf0f..fa69cd7 100644 --- a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java @@ -1,5 +1,6 @@ package i18nupdatemod.core.v1; +import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.net.ResourcePackHttp; import i18nupdatemod.util.DigestUtil; import i18nupdatemod.util.Log; @@ -16,10 +17,6 @@ import java.util.concurrent.TimeUnit; public class ResourcePack { - /** - * Limit update check frequency - */ - private static final long UPDATE_TIME_GAP = TimeUnit.DAYS.toMillis(1); private final Path filePath; private final Path tmpFilePath; private String remoteMd5; @@ -55,7 +52,7 @@ private boolean isUpToDate(ResourcePackHttp source, String md5FileName) } //Last update time not exceed gap -> Not Update if (Files.getLastModifiedTime(tmpFilePath).to(TimeUnit.MILLISECONDS) - > System.currentTimeMillis() - UPDATE_TIME_GAP) { + > System.currentTimeMillis() - I18nUserConfig.cacheTtlMillis()) { Log.debug("Local file %s has been updated recently.", tmpFilePath); return true; } From d77ef1f5f8cce00f1e778df0776995c5397f08b9 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:37:02 +0800 Subject: [PATCH 14/16] =?UTF-8?q?chore:=20gradle.properties=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=2026.2=E3=80=8126.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 435a08c..94167e1 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,26.1,26.1.1,26.1.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,26.2,26.3 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 From fc0cfef59c0d1fae5de6499c569a0878b699b1de Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:37:52 +0800 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=E9=85=8D=E7=BD=AE=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=88=9B=E5=BB=BA=E6=97=A5=E5=BF=97=E6=94=B9=E8=B5=B0?= =?UTF-8?q?=E7=BC=93=E5=86=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/i18nupdatemod/core/I18nUserConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/i18nupdatemod/core/I18nUserConfig.java b/src/main/java/i18nupdatemod/core/I18nUserConfig.java index 1542c15..daf03df 100644 --- a/src/main/java/i18nupdatemod/core/I18nUserConfig.java +++ b/src/main/java/i18nupdatemod/core/I18nUserConfig.java @@ -119,7 +119,7 @@ private static void writeTemplate(Path file) { try { Files.createDirectories(file.getParent()); Files.write(file, TEMPLATE.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE_NEW); - Log.info("Created default configuration: %s", file); + debug("Created default configuration: %s", file); } catch (FileAlreadyExistsException e) { Log.debug("Configuration already created by another thread: %s", file); } catch (IOException e) { From 9e43c233363fbd5a28e141d6533bc3934b733e16 Mon Sep 17 00:00:00 2001 From: SlimeSB <86453765+SlimeSB@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:37:53 +0800 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20cacheTtlHours=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8C=89=E8=BE=93=E5=85=A5=E6=8C=87=E7=BA=B9=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E7=9A=84=E6=A3=80=E6=9F=A5=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../i18nupdatemod/core/I18nUserConfig.java | 2 +- .../core/ResourcePackUpdater.java | 126 +++++++++++++++++- .../i18nupdatemod/core/v1/ResourcePack.java | 8 -- .../core/v2/ResourcePackDownloader.java | 7 - .../core/ResourcePackUpdaterTest.java | 105 +++++++++++++++ .../core/v2/ResourcePackCacheTest.java | 8 +- 7 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 src/test/java/i18nupdatemod/core/ResourcePackUpdaterTest.java diff --git a/README.md b/README.md index a7d015d..bbb80ab 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ 首次启动会在 `config/i18nupdatemod.json` 生成带说明的模板。 -- `cacheTtlHours`:多久之内不再向服务器校验汉化是否有新版本,默认 24 小时,`0` 表示每次启动都校验。 +- `cacheTtlHours`:两次联网检查之间的最短间隔,默认 `24` 小时,`0` 表示每次启动都检查。间隔内本地没有变化就完全不联网、不重新组包;新增/删除模组、模组文件更新或修改 `excludeMods` 会立即触发检查。 - `excludeMods`:不参与汉化的模组命名空间(例如 `jei`、`create`),跳过下载、也不会进入汉化包;已有缓存不动。 - `onlyWhenChineseSystem`:为 `true` 时只在系统语言为中文时才启动;非中文系统下本Mod完全不动作(不联网、不下载、不写任何文件)。默认 `false`,整合包作者可设为 `true` 后随包分发。判定读的是操作系统区域设置,不是游戏内语言。 diff --git a/src/main/java/i18nupdatemod/core/I18nUserConfig.java b/src/main/java/i18nupdatemod/core/I18nUserConfig.java index daf03df..a06fa49 100644 --- a/src/main/java/i18nupdatemod/core/I18nUserConfig.java +++ b/src/main/java/i18nupdatemod/core/I18nUserConfig.java @@ -26,7 +26,7 @@ public class I18nUserConfig { private static final long DEFAULT_CACHE_TTL_HOURS = 24; private static final long MAX_CACHE_TTL_HOURS = TimeUnit.DAYS.toHours(365); private static final String TEMPLATE = "{\n" - + " \"_comment_cacheTtlHours\": \"更新检查间隔(小时):多久之内不再向服务器校验汉化是否有新版本,0 表示每次启动都校验。默认 24。\",\n" + + " \"_comment_cacheTtlHours\": \"更新检查间隔(小时):上次联网检查后多久之内不再联网,0 表示每次启动都检查。本地模组或 excludeMods 有变化会立即重新检查。默认 24。\",\n" + " \"cacheTtlHours\": 24,\n" + " \"_comment_excludeMods\": \"不参与汉化的模组命名空间(例如 jei、create):跳过下载,也不会进入汉化包;已有缓存不动。\",\n" + " \"excludeMods\": [],\n" diff --git a/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java b/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java index e2edc42..eab6c99 100644 --- a/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java +++ b/src/main/java/i18nupdatemod/core/ResourcePackUpdater.java @@ -1,21 +1,143 @@ package i18nupdatemod.core; +import com.google.gson.Gson; import i18nupdatemod.core.v1.ResourcePackV1; import i18nupdatemod.core.v2.ResourcePackV2; +import i18nupdatemod.entity.GameAssetDetail; import i18nupdatemod.entity.ModTranslation; +import i18nupdatemod.util.DigestUtil; import i18nupdatemod.util.Log; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.concurrent.TimeUnit; public class ResourcePackUpdater { + private static final Gson GSON = new Gson(); + // 资源文件的 mtime 是内容更新时间,不是检查时间,所以检查时间单独存 + private static final String CHECK_STAMP = "check-stamp.json"; + public static Path update(String minecraftVersion, String loader, List mods, Path resourcePackDirectory, Path cacheRoot) throws Exception { + GameAssetDetail plan = I18nConfig.getAssetDetail(minecraftVersion); + Path convertedPack = resourcePackDirectory.resolve(plan.convertedFileName); + long ttl = I18nUserConfig.cacheTtlMillis(); + String fingerprint = fingerprint(minecraftVersion, mods); + if (checkedRecently(cacheRoot, convertedPack, ttl, fingerprint)) { + Log.debug("Skipping the update check: unchanged inputs, last check within %s hours.", + TimeUnit.MILLISECONDS.toHours(ttl)); + return convertedPack; + } try { - return ResourcePackV2.update(minecraftVersion, mods, resourcePackDirectory, cacheRoot); + Path updated = ResourcePackV2.update(minecraftVersion, mods, resourcePackDirectory, cacheRoot); + markChecked(cacheRoot, fingerprint); + return updated; } catch (Exception e) { Log.warning("V2 resource pipeline failed; falling back to V1: %s", e); } - return ResourcePackV1.update(minecraftVersion, loader, mods, resourcePackDirectory, cacheRoot); + Path updated = ResourcePackV1.update(minecraftVersion, loader, mods, resourcePackDirectory, cacheRoot); + markChecked(cacheRoot, fingerprint); + return updated; + } + + static boolean checkedRecently(Path cacheRoot, Path convertedPack, long ttlMillis, String fingerprint) { + if (ttlMillis <= 0L) { + return false; + } + try { + if (!Files.isRegularFile(convertedPack) || Files.size(convertedPack) <= 0L) { + return false; + } + Path stampFile = cacheRoot.resolve(CHECK_STAMP); + if (!Files.isRegularFile(stampFile)) { + return false; + } + CheckStamp stamp = GSON.fromJson( + new String(Files.readAllBytes(stampFile), StandardCharsets.UTF_8), CheckStamp.class); + if (stamp == null) { + return false; + } + long age = System.currentTimeMillis() - stamp.checkedAt; + return fingerprint.equals(stamp.fingerprint) && age >= 0L && age < ttlMillis; + } catch (Exception e) { + return false; + } + } + + static void markChecked(Path cacheRoot, String fingerprint) { + try { + Files.createDirectories(cacheRoot); + Path stampFile = cacheRoot.resolve(CHECK_STAMP); + Path temporary = Files.createTempFile(cacheRoot, ".check-stamp-", ".tmp"); + try { + CheckStamp stamp = new CheckStamp(); + stamp.checkedAt = System.currentTimeMillis(); + stamp.fingerprint = fingerprint; + Files.write(temporary, GSON.toJson(stamp).getBytes(StandardCharsets.UTF_8)); + try { + Files.move(temporary, stampFile, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, stampFile, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } catch (IOException e) { + Log.warning("Cannot record the update check in %s: %s", cacheRoot, e); + } + } + + // 指纹覆盖所有影响选包的本地输入:游戏版本、模组文件、排除项 + static String fingerprint(String minecraftVersion, List mods) + throws NoSuchAlgorithmException { + List excluded = new ArrayList<>(I18nUserConfig.excludedMods()); + Collections.sort(excluded); + List lines = new ArrayList<>(); + lines.add("version=" + minecraftVersion); + lines.add("excluded=" + excluded); + if (mods != null) { + for (ModTranslation mod : mods) { + if (mod == null) { + continue; + } + lines.add(mod.namespace + "|" + mod.source.getFileName() + + "|" + sizeOf(mod.source) + "|" + modifiedOf(mod.source)); + } + } + Collections.sort(lines); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return DigestUtil.hexString( + digest.digest(String.join("\n", lines).getBytes(StandardCharsets.UTF_8))); + } + + private static long sizeOf(Path path) { + try { + return Files.size(path); + } catch (IOException e) { + return -1L; + } + } + + private static long modifiedOf(Path path) { + try { + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + return -1L; + } + } + + private static final class CheckStamp { + long checkedAt; + String fingerprint; } } diff --git a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java index fa69cd7..5d69982 100644 --- a/src/main/java/i18nupdatemod/core/v1/ResourcePack.java +++ b/src/main/java/i18nupdatemod/core/v1/ResourcePack.java @@ -1,6 +1,5 @@ package i18nupdatemod.core.v1; -import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.net.ResourcePackHttp; import i18nupdatemod.util.DigestUtil; import i18nupdatemod.util.Log; @@ -14,7 +13,6 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.security.NoSuchAlgorithmException; -import java.util.concurrent.TimeUnit; public class ResourcePack { private final Path filePath; @@ -50,12 +48,6 @@ private boolean isUpToDate(ResourcePackHttp source, String md5FileName) Log.debug("Local file %s not exist.", tmpFilePath); return false; } - //Last update time not exceed gap -> Not Update - if (Files.getLastModifiedTime(tmpFilePath).to(TimeUnit.MILLISECONDS) - > System.currentTimeMillis() - I18nUserConfig.cacheTtlMillis()) { - Log.debug("Local file %s has been updated recently.", tmpFilePath); - return true; - } //Check Update return checkMd5(source, tmpFilePath, md5FileName); } diff --git a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java index 0323235..d34bfd4 100644 --- a/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java +++ b/src/main/java/i18nupdatemod/core/v2/ResourcePackDownloader.java @@ -4,7 +4,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; -import i18nupdatemod.core.I18nUserConfig; import i18nupdatemod.core.net.ResourcePackHttp; import i18nupdatemod.entity.ModTranslation; import i18nupdatemod.util.DigestUtil; @@ -397,12 +396,6 @@ private static void updateMod(ResourcePackHttp source, String assetPath, String // .zip.md5 would collide with the source digest of a namespace ending in ".zip". Path zipMd5File = cached.resolveSibling(cached.getFileName() + "md5"); discardUnverifiedCache(cached, md5File, zipMd5File); - if (Files.isRegularFile(cached) && Files.isRegularFile(md5File) - && Files.getLastModifiedTime(cached).toMillis() - > System.currentTimeMillis() - I18nUserConfig.cacheTtlMillis()) { - return; - } - String remoteMd5 = readRemoteText(source, assetPath + ".md5").trim(); if (!remoteMd5.matches("[0-9a-fA-F]{32}")) { throw new AssetFailure("Invalid asset MD5: " + assetPath); diff --git a/src/test/java/i18nupdatemod/core/ResourcePackUpdaterTest.java b/src/test/java/i18nupdatemod/core/ResourcePackUpdaterTest.java new file mode 100644 index 0000000..c545437 --- /dev/null +++ b/src/test/java/i18nupdatemod/core/ResourcePackUpdaterTest.java @@ -0,0 +1,105 @@ +package i18nupdatemod.core; + +import i18nupdatemod.entity.ModTranslation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResourcePackUpdaterTest { + @TempDir + Path gameDir; + + @Test + void unchangedInputsWithinWindowSkip() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + ResourcePackUpdater.markChecked(cache, "fp"); + assertTrue(ResourcePackUpdater.checkedRecently(cache, pack, hours(24), "fp")); + } + + @Test + void changedFingerprintChecksImmediately() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + ResourcePackUpdater.markChecked(cache, "before"); + assertFalse(ResourcePackUpdater.checkedRecently(cache, pack, hours(24), "after")); + } + + @Test + void expiredWindowChecksAgain() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + writeStamp(cache, System.currentTimeMillis() - hours(25), "fp"); + assertFalse(ResourcePackUpdater.checkedRecently(cache, pack, hours(24), "fp")); + } + + @Test + void zeroWindowAlwaysChecks() throws Exception { + Path cache = gameDir.resolve("cache"); + Path pack = pack(); + ResourcePackUpdater.markChecked(cache, "fp"); + assertFalse(ResourcePackUpdater.checkedRecently(cache, pack, 0L, "fp")); + } + + @Test + void missingPackChecksAgain() throws Exception { + Path cache = gameDir.resolve("cache"); + ResourcePackUpdater.markChecked(cache, "fp"); + assertFalse(ResourcePackUpdater.checkedRecently( + cache, gameDir.resolve("missing.zip"), hours(24), "fp")); + } + + @Test + void addingAModChangesTheFingerprint() throws Exception { + ModTranslation first = mod("jei", "jei.jar"); + ModTranslation second = mod("create", "create.jar"); + String before = ResourcePackUpdater.fingerprint("1.20.1", Collections.singletonList(first)); + String after = ResourcePackUpdater.fingerprint("1.20.1", Arrays.asList(first, second)); + assertNotEquals(before, after); + assertEquals(after, ResourcePackUpdater.fingerprint("1.20.1", Arrays.asList(second, first))); + } + + @Test + void updatingAModFileChangesTheFingerprint() throws Exception { + ModTranslation mod = mod("jei", "jei.jar"); + String before = ResourcePackUpdater.fingerprint("1.20.1", Collections.singletonList(mod)); + Files.write(mod.source, new byte[]{1, 2, 3}); + assertNotEquals(before, + ResourcePackUpdater.fingerprint("1.20.1", Collections.singletonList(mod))); + } + + private ModTranslation mod(String namespace, String fileName) throws Exception { + Path mods = Files.createDirectories(gameDir.resolve("mods")); + Path jar = mods.resolve(fileName); + Files.write(jar, new byte[]{1}); + return new ModTranslation(namespace, null, null, jar, null); + } + + private Path pack() throws Exception { + Path pack = gameDir.resolve("pack.zip"); + Files.write(pack, new byte[]{1}); + return pack; + } + + private static void writeStamp(Path cache, long checkedAt, String fingerprint) throws Exception { + Files.createDirectories(cache); + Files.write(cache.resolve("check-stamp.json"), + ("{\"checkedAt\":" + checkedAt + ",\"fingerprint\":\"" + fingerprint + "\"}") + .getBytes(StandardCharsets.UTF_8)); + } + + private static long hours(long value) { + return TimeUnit.HOURS.toMillis(value); + } +} diff --git a/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java b/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java index 5939e7d..28abf6e 100644 --- a/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java +++ b/src/test/java/i18nupdatemod/core/v2/ResourcePackCacheTest.java @@ -47,7 +47,8 @@ void repairsChangedBytesEvenWithUnchangedSizeAndFreshTimestamp() throws Exceptio assertEquals(2, server.archives.get()); assertEquals(2, server.checksums.get()); fetch(server); - assertEquals(2, server.checksums.get(), "Verified fresh cache retains its network TTL"); + assertEquals(3, server.checksums.get(), "Each run re-validates the cached ZIP against the remote digest"); + assertEquals(2, server.archives.get()); } } @@ -77,7 +78,8 @@ void missingDecodedDigestRedownloadsRatherThanSigningUnknownBytes() throws Excep assertTranslation(zip); assertEquals(2, server.archives.get()); fetch(server); - assertEquals(2, server.checksums.get(), "Only the first migration requires redownloading"); + assertEquals(3, server.checksums.get(), "Every run re-validates; only a missing digest forces a redownload"); + assertEquals(2, server.archives.get()); } } @@ -135,7 +137,7 @@ void namespaceEndingInZipDoesNotOverwriteAnotherCachesDigest() throws Exception fetch(server); ResourcePackDownloader.download("test", Collections.singletonMap("fixture.zip", "second"), Collections.emptyList(), cache, server.source); - assertEquals(2, server.checksums.get(), "Both distinct namespaces must retain valid fresh caches"); + assertEquals(4, server.checksums.get(), "Each run re-validates both namespaces against their remote digests"); assertEquals(2, server.archives.get()); } }