Skip to content
Open
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
72 changes: 56 additions & 16 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i

private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;
private String gameVersion;
String gameVersion;

final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);

Expand Down Expand Up @@ -185,18 +185,29 @@ private void updateSupportedLoaders(ModManager modManager) {
return;
}

var gameVersionNumber = GameVersionNumber.asGameVersion(gameVersion);

for (GameComponentType type : GameComponentType.MOD_LOADERS) {
if (type.isModLoader() && analyzer.has(type)) {
ModLoaderType modLoaderType = type.getModLoaderType();
if (modLoaderType != null) {
supportedLoaders.add(modLoaderType);

if (modLoaderType == ModLoaderType.CLEANROOM)
supportedLoaders.add(ModLoaderType.FORGE);
}
}
}

if (analyzer.has(GameComponentType.CLEANROOM)) {
supportedLoaders.add(ModLoaderType.FORGE);
}

if (analyzer.has(GameComponentType.FORGE) // No cleanroom because LiteLoader cannot run on Java 21
&& modManager.hasLiteLoaderAsMod()
&& !gameVersionNumber.isAtLeast("1.13", "17w43a")
// LiteLoader indicates that it supports 1.5.2 as well in this way, but it actually does nothing
&& gameVersionNumber.isAtLeast("1.6.1", "13w36a" /* 1.7-snapshot-1 */)) {
supportedLoaders.add(ModLoaderType.LITE_LOADER);
}

if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
supportedLoaders.add(ModLoaderType.FORGE);
}
Expand Down Expand Up @@ -888,18 +899,47 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) {

content.setSubtitle(joiner.toString());

if (modLoaderType == ModLoaderType.UNKNOWN) {
content.addTagWarning(i18n("mods.unknown"));
} else if (!page.supportedLoaders.contains(modLoaderType)) {
warning.add(i18n("mods.warning.loader_mismatch"));
switch (dataItem.getModInfo().getModLoaderType()) {
case FORGE -> content.addTagWarning(i18n("install.installer.forge"));
case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric"));
case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom"));
case NEO_FORGE -> content.addTagWarning(i18n("install.installer.neoforge"));
case FABRIC -> content.addTagWarning(i18n("install.installer.fabric"));
case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader"));
case QUILT -> content.addTagWarning(i18n("install.installer.quilt"));
{
Set<ModLoaderType> modFileLoaders = EnumSet.noneOf(ModLoaderType.class);
GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(Optional.ofNullable(page.gameVersion));
// Uses 1.7 snapshot as there's no snapshots for 1.6 after its first release
boolean wrongCoreModDir = !gameVersionNumber.isAtLeast("1.6.1", "13w36a")
&& modInfo.getCoreModInfo().isLegacy()
&& !"coremods".equals(modInfo.getSubfolderName());

if (modLoaderType != ModLoaderType.UNKNOWN) modFileLoaders.add(modLoaderType);
modFileLoaders.addAll(modInfo.getCoreModInfo().getModLoaders(gameVersionNumber));

if (modFileLoaders.stream().anyMatch(page.supportedLoaders::contains)) {
if (modInfo.isCoreMod()) {
if (wrongCoreModDir) {
content.addTagWarning("CoreMod");
warning.add(i18n("mods.coremods.check_dir"));
} else {
content.addTag("CoreMod");
}
}
} else {
if (modFileLoaders.isEmpty()) {
content.addTagWarning(i18n("mods.unknown"));
} else {
warning.add(i18n("mods.warning.loader_mismatch"));
for (var loaderType : modFileLoaders) {
switch (loaderType) {
case FORGE -> content.addTagWarning(i18n("install.installer.forge"));
case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric"));
case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom"));
case NEO_FORGE -> content.addTagWarning(i18n("install.installer.neoforge"));
case FABRIC -> content.addTagWarning(i18n("install.installer.fabric"));
case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader"));
case QUILT -> content.addTagWarning(i18n("install.installer.quilt"));
}
}
}
if (modInfo.isCoreMod()) {
content.addTagWarning("CoreMod");
if (wrongCoreModDir) warning.add(i18n("mods.coremods.check_dir"));
}
}
}

Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,7 @@ mods.add=Add
mods.add.failed=Failed to add mod %s.
mods.add.success=%s was successfully added.
mods.add.title=Choose mod file you want to add
mods.coremods.check_dir=Should put into 'coremods' directory
mods.disable=Disable
mods.download=Download Mod
mods.download.title=Download Mod - %1s
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,7 @@ mods.add=新增模組
mods.add.failed=新增模組「%s」失敗。
mods.add.success=成功新增模組「%s」。
mods.add.title=選取要新增的模組檔案
mods.coremods.check_dir=應放入 "coremods" 資料夾
mods.disable=停用
mods.download=下載模組
mods.download.title=模組下載 - %1s
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,7 @@ mods.add=添加模组
mods.add.failed=添加模组“%s”失败。\n如遇到问题,你可以点击右上角帮助按钮进行求助。
mods.add.success=成功添加模组 %s。
mods.add.title=选择要添加的模组文件
mods.coremods.check_dir=应放入 "coremods" 文件夹
mods.disable=禁用
mods.download=下载模组
mods.download.title=模组下载 - %1s
Expand Down
179 changes: 179 additions & 0 deletions HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/CoreModInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* 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.addon.meta;

import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import kala.compress.archivers.zip.ZipArchiveEntry;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.util.Immutable;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.tree.ZipFileTree;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jackhuang.hmcl.util.versioning.VersionRange;
import org.jetbrains.annotations.NotNull;

import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.*;
import java.util.jar.Attributes;
import java.util.jar.Manifest;

import static org.jackhuang.hmcl.util.logging.Logger.LOG;

/// @see <a href="https://github.com/xfl03/CoreModTutor">CoreModTutor by xfl03 and contributors</a>
@Immutable
public final class CoreModInfo {

public static final CoreModInfo EMPTY = new CoreModInfo();

private final Map<ModLoaderType, List<VersionRange<GameVersionNumber>>> coreMods;
private final boolean liteLoaderAsMod;

private CoreModInfo() {
this.coreMods = Collections.emptyMap();
this.liteLoaderAsMod = false;
}

private CoreModInfo(List<CoreMod> coreModList, boolean liteloaderAsMod) {
EnumMap<ModLoaderType, List<VersionRange<GameVersionNumber>>> map = new EnumMap<>(ModLoaderType.class);
for (var coreMod : coreModList) {
map.computeIfAbsent(coreMod.modLoaderType(), k -> new ArrayList<>()).add(coreMod.mcVersionRange());
}
this.coreMods = Collections.unmodifiableMap(map);
this.liteLoaderAsMod = liteloaderAsMod;
}

public boolean isEmpty() {
return coreMods.isEmpty();
}

public boolean isLiteLoaderAsMod() {
return liteLoaderAsMod;
}

public EnumSet<ModLoaderType> getModLoaders(GameVersionNumber gameVersionNumber) {
EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
if (gameVersionNumber == GameVersionNumber.unknown()) return supportedLoaders;
for (var entry : coreMods.entrySet())
if (entry.getValue().stream().anyMatch(r -> r.contains(gameVersionNumber)))
supportedLoaders.add(entry.getKey());
return supportedLoaders;
}

/// @return whether this coremod supports Forge 1.5.2-
public boolean isLegacy() {
return !getModLoaders(GameVersionNumber.asGameVersion("1.5.2")).isEmpty();
}

@NotNull
public static CoreModInfo fromFile(Path modFile, ZipFileTree tree) {
List<CoreMod> coreModList = new ArrayList<>();
boolean liteloaderAsMod = false;
{
// Below 1.13
ZipArchiveEntry mf = tree.getEntry("META-INF/MANIFEST.MF");
if (mf != null) {
Attributes attr = null;
try (var in = tree.getInputStream(mf)) {
attr = new Manifest(in).getMainAttributes();
} catch (IOException e) {
LOG.warning("Failed to load jar manifest for jar: " + modFile, e);
}
if (attr != null) {
String fmlCorePlg = attr.getValue("FMLCorePlugin");
String tweakClass = attr.getValue("TweakClass");
if (StringUtils.isNotBlank(fmlCorePlg))
coreModList.addAll(List.of(
new CoreMod(ModLoaderType.FORGE, "1.3.2", "1.12.2"),
new CoreMod(ModLoaderType.CLEANROOM, "1.12.2", "1.12.2") // TODO further testing
));
if (StringUtils.isNotBlank(tweakClass)) {
// I have no idea why LiteLoader tries to use TweakClass in 1.5.2, when it is not supported
if (tweakClass.equals("com.mumfrey.liteloader.launch.LiteLoaderTweaker")) liteloaderAsMod = true;
coreModList.addAll(List.of(
new CoreMod(ModLoaderType.FORGE, "1.6.1", "1.12.2"),
new CoreMod(ModLoaderType.LITE_LOADER, "1.6.1", "1.12.2"),
new CoreMod(ModLoaderType.CLEANROOM, "1.12.2", "1.12.2")
));
}
}
}
}
{
// coremods.json in Forge 1.13-1.21.10 & NeoForge 1.21.4-
// TODO Find a sample
ZipArchiveEntry coreModsJson = tree.getEntry("META-INF/coremods.json");
if (coreModsJson != null) {
try (var in = new InputStreamReader(tree.getInputStream(coreModsJson))) {
var map = JsonUtils.fromJson(in, new TypeToken<Map<String, String>>() {
});
if (map != null && !map.isEmpty())
coreModList.addAll(List.of(
// Removed in https://github.com/MinecraftForge/MinecraftForge/pull/10746
new CoreMod(ModLoaderType.FORGE, "1.13", "1.21.10"),
// Removed in https://github.com/neoforged/NeoForge/pull/2072
new CoreMod(ModLoaderType.NEO_FORGE, "1.20.1", "1.21.4")
));
} catch (IOException | JsonIOException e) {
LOG.warning("Failed to read coremods.json for jar: " + modFile, e);
} catch (JsonSyntaxException ignored) {
}
}
}
{
// ITransformationService
if (tree.getEntry("META-INF/services/cpw.mods.modlauncher.api.ITransformationService") != null)
coreModList.addAll(List.of(
new CoreMod(ModLoaderType.FORGE, "1.13.2"),
new CoreMod(ModLoaderType.NEO_FORGE, "1.20.1", "1.21.8") // Replaced by ClassProcessorProvider
));
}
{
// ICoreMod for NeoForge
// https://github.com/neoforged/FancyModLoader/pull/79
// https://neoforged.net/news/2024-retrospection/#the-changes
// TODO Find a sample
if (tree.getEntry("META-INF/services/net.neoforged.neoforgespi.coremod.ICoreMod") != null)
coreModList.add(new CoreMod(ModLoaderType.NEO_FORGE, "1.20.5", "1.21.8")); // Replaced by ClassProcessorProvider
}
{
// ClassProcessorProvider for NeoForge, replaces ITransformationService & ICoreMod
// https://github.com/neoforged/NeoForge/pull/2655
// https://github.com/neoforged/FancyModLoader/pull/358
if (tree.getEntry("META-INF/services/net.neoforged.neoforgespi.transformation.ClassProcessorProvider") != null)
coreModList.add(new CoreMod(ModLoaderType.NEO_FORGE, "1.21.9"));
}
return new CoreModInfo(coreModList, liteloaderAsMod);
}

private record CoreMod(ModLoaderType modLoaderType, VersionRange<GameVersionNumber> mcVersionRange) {

private CoreMod(ModLoaderType modLoaderType, String min) {
this(modLoaderType, GameVersionNumber.atLeast(min));
}

private CoreMod(ModLoaderType modLoaderType, String min, String max) {
this(modLoaderType, GameVersionNumber.between(min, max));
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ public FabricModMetadata(String id, String name, String version, String icon, St
this.contact = contact;
}

public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException {
public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree, CoreModInfo coreModInfo) throws IOException, JsonParseException {
ZipArchiveEntry mcmod = tree.getEntry("fabric.mod.json");
if (mcmod == null)
throw new IOException("File " + modFile + " is not a Fabric mod.");
FabricModMetadata metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), FabricModMetadata.class);
String authors = metadata.authors == null ? "" : metadata.authors.stream().map(author -> author.name).collect(Collectors.joining(", "));
return new LocalModFile(modManager, modManager.getLocalMod(metadata.id, ModLoaderType.FABRIC), modFile, metadata.name, new LocalAddonFile.Description(metadata.description),
authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon);
authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon, coreModInfo);
}

@JsonAdapter(FabricModAuthorSerializer.class)
Expand Down
Loading