From 61e193440f79241967726eeef6fc03e678c0318d Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 9 Jul 2026 20:38:51 +0800 Subject: [PATCH 1/5] fix: apply default isolation settings for new instances and enhance version handling in repository --- .../hmcl/game/HMCLGameRepository.java | 51 +++++++++++++------ .../hmcl/ui/download/DownloadPage.java | 2 +- .../VanillaInstallWizardProvider.java | 2 +- .../hmcl/setting/GameDirectoriesTest.java | 30 +++++++++++ .../org/jackhuang/hmcl/util/SettingsMap.java | 15 +++++- 5 files changed, 82 insertions(+), 18 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index b250406fa2d..c348d3f06b3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -242,6 +242,19 @@ public void clean(String id) throws IOException { clean(getRunDirectory(id)); } + /// Removes a version from disk and drops any cached instance settings for that version. + @Override + public boolean removeVersionFromDisk(String id) { + boolean removed = super.removeVersionFromDisk(id); + if (removed) { + instanceGameSettings.remove(id); + loadedInstanceGameSettings.remove(id); + readOnlyInstanceGameSettings.remove(id); + beingModpackVersions.remove(id); + } + return removed; + } + public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException { Path srcDir = getVersionRoot(srcId); Path dstDir = getVersionRoot(dstId); @@ -520,31 +533,39 @@ public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate(); } - public GameSettings.Effective getEffectiveGameSettings(String id) { - GameSettings.Instance instance = getInstanceGameSettings(id); - return GameSettings.resolve(getParentGameSettings(instance), instance); + /// Returns whether a new instance should use an isolated running directory under the default isolation settings. + public boolean shouldIsolateNewInstance(boolean modded) { + return shouldIsolateByDefault(getParentGameSettings(null), modded); } - public void applyDefaultIsolationSetting(String id) { - if (!hasVersion(id)) { + /// Applies default isolation to a new instance before the version metadata is saved. + public void applyDefaultIsolationSettingForNewInstance(String id, boolean modded) { + if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(id)) { return; } - GameSettings.Instance instanceSetting = getInstanceGameSettings(id); - GameSettings.Preset preset = getParentGameSettings(instanceSetting); + GameSettings.Instance setting = getInstanceGameSettings(id); + if (setting == null) { + setting = initInstanceGameSettings(id, new GameSettings.Instance()); + } + if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { + saveGameSettings(id); + } + } + + /// Returns whether the given preset requests default isolation for an instance with the given modded state. + private static boolean shouldIsolateByDefault(GameSettings.Preset preset, boolean modded) { DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED); - boolean isolated = switch (type) { + return switch (type) { case NEVER -> false; case ALWAYS -> true; - case MODDED -> LibraryAnalyzer.isModded(this, getVersion(id).resolve(this)); + case MODDED -> modded; }; + } - if (isolated) { - GameSettings.Instance setting = instanceSetting != null ? instanceSetting : getInstanceGameSettingsOrCreate(id); - if (setting != null) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - } - } + public GameSettings.Effective getEffectiveGameSettings(String id) { + GameSettings.Instance instance = getInstanceGameSettings(id); + return GameSettings.resolve(getParentGameSettings(instance), instance); } public Optional getVersionIconFile(String id) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index 58a3cc5fc3a..81a1a591e70 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -318,9 +318,9 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { builder.version(remoteVersion); }); + repository.applyDefaultIsolationSettingForNewInstance(name, settings.isInstallingModdedVersion()); return builder.buildAsync().whenComplete(any -> { repository.refreshVersions(); - repository.applyDefaultIsolationSetting(name); }).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name)); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java index b0289431c94..fb10de7c7a3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java @@ -61,9 +61,9 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { builder.version(remoteVersion); }); + repository.applyDefaultIsolationSettingForNewInstance(name, settings.isInstallingModdedVersion()); return builder.buildAsync().whenComplete(any -> { repository.refreshVersions(); - repository.applyDefaultIsolationSetting(name); }) .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(name)); } diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java index 3a1977d00c5..9e8205dda29 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -408,6 +408,36 @@ public void repositoryDirectoryFollowsGameDirectoryPath() throws ReflectiveOpera } } + /// Tests that new isolated installing instances resolve content directories under the version root before metadata is saved. + @Test + public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@TempDir Path tempDirectory) + throws ReflectiveOperationException { + GameDirectory gameDirectory = new GameDirectory( + GameDirectoryID.generate(), + LocalizedText.plain("Dev"), + PortablePath.of(tempDirectory.toString())); + GameDirectories localDirectories = new GameDirectories(); + localDirectories.getGameDirectories().add(gameDirectory); + GameDirectories userDirectories = new GameDirectories(); + + try (GameDirectoryEnvironment ignored = + new GameDirectoryEnvironment(localDirectories, userDirectories)) { + HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); + String id = "1.21.11-fabric"; + + assertFalse(repository.hasVersion(id)); + assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); + + repository.applyDefaultIsolationSettingForNewInstance(id, true); + + assertEquals(repository.getVersionRoot(id), repository.getRunDirectory(id)); + assertEquals(repository.getVersionRoot(id).resolve("mods"), repository.getModsDirectory(id)); + + assertTrue(repository.removeVersionFromDisk(id)); + assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); + } + } + /// Tests that instance settings without an explicit parent use the default preset. @Test public void nullInstanceParentUsesDefaultPresetInsteadOfLegacyGameDirectoryPreset() diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java index d2e64d4c3fb..af25762ae88 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.util; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.download.RemoteVersion; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -25,7 +27,7 @@ /// A wrapper for `Map`, supporting type-safe reading and writing of values. /// -/// @author Glavo +/// @author Glavo public final class SettingsMap { public record Key(String key) { } @@ -87,6 +89,17 @@ public void clear() { map.clear(); } + /// Returns whether the selected installation includes any non-vanilla component. + public boolean isInstallingModdedVersion() { + for (LibraryAnalyzer.LibraryType value : LibraryAnalyzer.LibraryType.values()) { + if (value.isModLoader() && get(value.getPatchId()) instanceof RemoteVersion) { + return true; + } + } + + return false; + } + @Override public String toString() { return "SettingsMap" + map; From 8b39f073ecea95be20d8ad94159778b86d250cf2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 9 Jul 2026 20:51:06 +0800 Subject: [PATCH 2/5] fix: simplify isolation logic for new instances in HMCLGameRepository --- .../hmcl/game/HMCLGameRepository.java | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index c348d3f06b3..08223e0ba87 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -535,7 +535,13 @@ public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance /// Returns whether a new instance should use an isolated running directory under the default isolation settings. public boolean shouldIsolateNewInstance(boolean modded) { - return shouldIsolateByDefault(getParentGameSettings(null), modded); + GameSettings.Preset preset = getParentGameSettings(null); + DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED); + return switch (type) { + case NEVER -> false; + case ALWAYS -> true; + case MODDED -> modded; + }; } /// Applies default isolation to a new instance before the version metadata is saved. @@ -553,16 +559,6 @@ public void applyDefaultIsolationSettingForNewInstance(String id, boolean modded } } - /// Returns whether the given preset requests default isolation for an instance with the given modded state. - private static boolean shouldIsolateByDefault(GameSettings.Preset preset, boolean modded) { - DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED); - return switch (type) { - case NEVER -> false; - case ALWAYS -> true; - case MODDED -> modded; - }; - } - public GameSettings.Effective getEffectiveGameSettings(String id) { GameSettings.Instance instance = getInstanceGameSettings(id); return GameSettings.resolve(getParentGameSettings(instance), instance); From 022846257d2ad2bec2bd144330498519278bb788 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 9 Jul 2026 21:17:59 +0800 Subject: [PATCH 3/5] fix: refine modded version detection logic in SettingsMap and add unit tests --- .../org/jackhuang/hmcl/util/SettingsMap.java | 4 +- .../jackhuang/hmcl/util/SettingsMapTest.java | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java index af25762ae88..f91329f0aab 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java @@ -92,7 +92,9 @@ public void clear() { /// Returns whether the selected installation includes any non-vanilla component. public boolean isInstallingModdedVersion() { for (LibraryAnalyzer.LibraryType value : LibraryAnalyzer.LibraryType.values()) { - if (value.isModLoader() && get(value.getPatchId()) instanceof RemoteVersion) { + if (value != LibraryAnalyzer.LibraryType.MINECRAFT + && value.isModLoader() + && get(value.getPatchId()) instanceof RemoteVersion) { return true; } } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java new file mode 100644 index 00000000000..3a4fda31d96 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java @@ -0,0 +1,57 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util; + +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.download.RemoteVersion; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Tests for installer state stored in [SettingsMap]. +@NotNullByDefault +public final class SettingsMapTest { + /// Tests that vanilla game selection alone is not treated as a modded installation. + @Test + public void minecraftSelectionIsNotModdedInstallation() { + SettingsMap settings = new SettingsMap(); + settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), remoteVersion("game")); + + assertFalse(settings.isInstallingModdedVersion()); + } + + /// Tests that selecting a non-vanilla installer is treated as a modded installation. + @Test + public void modLoaderSelectionIsModdedInstallation() { + SettingsMap settings = new SettingsMap(); + settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), remoteVersion("game")); + settings.put(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), remoteVersion("fabric")); + + assertTrue(settings.isInstallingModdedVersion()); + } + + /// Creates a minimal remote version for installer state tests. + private static RemoteVersion remoteVersion(String libraryId) { + return new RemoteVersion(libraryId, "1.21.11", "test", Instant.EPOCH, List.of()); + } +} From 5dd591cd596d15b9a5cd7131a04a66ab801b67fe Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 9 Jul 2026 21:24:18 +0800 Subject: [PATCH 4/5] fix: enhance GameDirectoryEnvironment to support custom presets for isolated instances --- .../jackhuang/hmcl/setting/GameDirectoriesTest.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java index 9e8205dda29..7ce88858cdc 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -412,6 +412,13 @@ public void repositoryDirectoryFollowsGameDirectoryPath() throws ReflectiveOpera @Test public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@TempDir Path tempDirectory) throws ReflectiveOperationException { + GameSettingsPresetID defaultPresetId = + GameSettingsPresetID.parse("game-settings-preset:123e4567-e89b-12d3-a456-426614174002"); + GameSettings.Preset defaultPreset = new GameSettings.Preset(defaultPresetId); + defaultPreset.defaultIsolationTypeProperty().setValue(DefaultIsolationType.MODDED); + GameSettingsPresets presets = new GameSettingsPresets(); + presets.getPresets().setAll(defaultPreset); + GameDirectory gameDirectory = new GameDirectory( GameDirectoryID.generate(), LocalizedText.plain("Dev"), @@ -421,7 +428,8 @@ public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@Tem GameDirectories userDirectories = new GameDirectories(); try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories)) { + new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) { + settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); String id = "1.21.11-fabric"; From f83744106932407bd53ce9d22070565f4b1c28b1 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 10 Jul 2026 20:26:34 +0800 Subject: [PATCH 5/5] fix: handle null versions in removeVersionFromDisk method to prevent NullPointerException --- .../java/org/jackhuang/hmcl/game/DefaultGameRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index fb3f606081e..5246c715909 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -233,7 +233,7 @@ public boolean renameVersion(String from, String to) { public boolean removeVersionFromDisk(String id) { if (EventBus.EVENT_BUS.fireEvent(new RemoveVersionEvent(this, id)) == Event.Result.DENY) return false; - if (!versions.containsKey(id)) + if (versions == null || !versions.containsKey(id)) return FileUtils.deleteDirectoryQuietly(getVersionRoot(id)); Path file = getVersionRoot(id); if (Files.notExists(file))