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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -520,31 +533,35 @@ 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);
}

public void applyDefaultIsolationSetting(String id) {
if (!hasVersion(id)) {
return;
}

GameSettings.Instance instanceSetting = getInstanceGameSettings(id);
GameSettings.Preset preset = getParentGameSettings(instanceSetting);
/// Returns whether a new instance should use an isolated running directory under the default isolation settings.
public boolean shouldIsolateNewInstance(boolean modded) {
GameSettings.Preset preset = getParentGameSettings(null);
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);
}
/// 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 setting = getInstanceGameSettings(id);
if (setting == null) {
setting = initInstanceGameSettings(id, new GameSettings.Instance());
}
if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) {
saveGameSettings(id);
}
}

public GameSettings.Effective getEffectiveGameSettings(String id) {
GameSettings.Instance instance = getInstanceGameSettings(id);
return GameSettings.resolve(getParentGameSettings(instance), instance);
}

public Optional<Path> getVersionIconFile(String id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,9 @@ private Task<Void> 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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ private Task<Void> 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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,44 @@ 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 {
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"),
PortablePath.of(tempDirectory.toString()));
GameDirectories localDirectories = new GameDirectories();
localDirectories.getGameDirectories().add(gameDirectory);
GameDirectories userDirectories = new GameDirectories();

try (GameDirectoryEnvironment ignored =
new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) {
settings().defaultGameSettingsPresetProperty().set(defaultPresetId);
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 16 additions & 1 deletion HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,7 +27,7 @@

/// A wrapper for `Map<String, Object>`, supporting type-safe reading and writing of values.
///
/// @author Glavo
/// @author Glavo
public final class SettingsMap {
public record Key<T>(String key) {
}
Expand Down Expand Up @@ -87,6 +89,19 @@ 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 != LibraryAnalyzer.LibraryType.MINECRAFT
&& value.isModLoader()
&& get(value.getPatchId()) instanceof RemoteVersion) {
Comment on lines +96 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve isolation for OptiFine-only installs

When the user installs only OptiFine with the default isolation mode set to MODDED, OPTIFINE.isModLoader() is false, so this method returns false and applyDefaultIsolationSettingForNewInstance skips creating the per-instance running directory. The previous post-install check used LibraryAnalyzer.isModded(...), which treats OptiFine's LaunchWrapper main class as modded, so OptiFine-only installations now regress to sharing the base .minecraft directory.

Useful? React with 👍 / 👎.

return true;
}
}

return false;
}

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