diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0abd05c88..df103fe49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -144,6 +144,8 @@ vineflower = { module = "org.vineflower:vineflower", version.ref = "vineflower" sourcesolver = { module = "software.coley:source-solver", version.ref = "sourcesolver" } +toml4j = { module = "io.hotmoka:toml4j", version = "0.7.3" } + [bundles] asm = [ "asm-core", diff --git a/recaf-ui/build.gradle b/recaf-ui/build.gradle index 572d81e27..b099febb1 100644 --- a/recaf-ui/build.gradle +++ b/recaf-ui/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation(libs.reactfx) implementation(libs.richtextfx) implementation(libs.treemapfx) + implementation(libs.toml4j) } application { diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/builtin/MinecraftModSummarizer.java b/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/builtin/MinecraftModSummarizer.java new file mode 100644 index 000000000..428ce122c --- /dev/null +++ b/recaf-ui/src/main/java/software/coley/recaf/services/info/summary/builtin/MinecraftModSummarizer.java @@ -0,0 +1,305 @@ +package software.coley.recaf.services.info.summary.builtin; + +import atlantafx.base.theme.Styles; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.moandjiezana.toml.Toml; +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Label; +import software.coley.recaf.info.FileInfo; +import software.coley.recaf.info.JvmClassInfo; +import software.coley.recaf.services.cell.icon.IconProviderService; +import software.coley.recaf.services.cell.text.TextProviderService; +import software.coley.recaf.services.info.summary.ResourceSummarizer; +import software.coley.recaf.services.info.summary.SummaryConsumer; +import software.coley.recaf.services.navigation.Actions; +import software.coley.recaf.ui.control.BoundLabel; +import software.coley.recaf.util.FxThreadUtil; +import software.coley.recaf.util.Lang; +import software.coley.recaf.util.threading.Batch; +import software.coley.recaf.workspace.model.Workspace; +import software.coley.recaf.workspace.model.resource.WorkspaceResource; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.jar.Manifest; + +/** + * Summarizer that finds Minecraft mods main mod classes. + * + * @Author Canrad + */ +@ApplicationScoped +public class MinecraftModSummarizer implements ResourceSummarizer { + private final TextProviderService textService; + private final IconProviderService iconService; + private final Actions actions; + private String loaderName = ""; + private String mcVersion = ""; + private final List mainClasses = new ArrayList<>(); + + @Inject + public MinecraftModSummarizer(@Nonnull TextProviderService textService, + @Nonnull IconProviderService iconService, + @Nonnull Actions actions) { + this.textService = textService; + this.iconService = iconService; + this.actions = actions; + } + + @Override + public boolean summarize(@Nonnull Workspace workspace, + @Nonnull WorkspaceResource resource, + @Nonnull SummaryConsumer consumer) { + Batch batch = FxThreadUtil.batch(); + + loaderName = ""; + mcVersion = ""; + mainClasses.clear(); + + // 1. Try to find Fabric mod information + boolean foundAny = detectFabricMod(resource); + + // 2. Try to find Forge mod information + foundAny |= detectLowVersionForgeMod(resource); + foundAny |= detectHighVersionForgeMod(resource); + + if (foundAny) { + renderSummary(workspace, resource, consumer, batch); + } else { + batch.add(() -> consumer.appendSummary(new BoundLabel(Lang.getBinding("service.analysis.no-minecraft-mod-found")))); + } + + batch.execute(); + return foundAny; + } + + /** + * Find a class in the resource by its name. + * Converts dot notation to slash notation for lookup. + */ + private JvmClassInfo findClassInResource(@Nonnull WorkspaceResource resource, String className) { + String classPath = className.replace('.', '/'); + return resource.getJvmClassBundle().get(classPath); + } + + private boolean detectFabricMod(@Nonnull WorkspaceResource resource) { + FileInfo fabricFileInfo = resource.getFileBundle().get("fabric.mod.json"); + if (fabricFileInfo != null) { + + try { + String jsonText = fabricFileInfo.asTextFile().getText(); + JsonObject json = JsonParser.parseString(jsonText).getAsJsonObject(); + // reference: https://fabricmc.net/wiki/documentation:fabricmodjson + // we need consider 'main', 'client', 'server' + if (json.has("entrypoints")) { + JsonObject entrypoints = json.getAsJsonObject("entrypoints"); + if (entrypoints.has("main") && entrypoints.get("main").isJsonArray()) { + JsonArray mainArray = entrypoints.getAsJsonArray("main"); + for (int i = 0; i < mainArray.size(); i++) { + String mainClass = mainArray.get(i).getAsString(); + mainClasses.add(mainClass); + } + } else if (entrypoints.has("client") && entrypoints.get("client").isJsonArray()) { + JsonArray clientArray = entrypoints.getAsJsonArray("client"); + for (int i = 0; i < clientArray.size(); i++) { + String mainClass = clientArray.get(i).getAsString(); + mainClasses.add(mainClass); + } + } else if (entrypoints.has("server") && entrypoints.get("server").isJsonArray()) { + JsonArray serverArray = entrypoints.getAsJsonArray("server"); + for (int i = 0; i < serverArray.size(); i++) { + String mainClass = serverArray.get(i).getAsString(); + mainClasses.add(mainClass); + } + } + } + + if (json.has("depends")) { + JsonObject depends = json.getAsJsonObject("depends"); + if (depends.has("minecraft")) { + if (!depends.get("minecraft").isJsonArray()) { + mcVersion = depends.get("minecraft").getAsString(); + } else { + // sometimes the minecraft version is an array + // we connect them with ', ' + JsonArray mcArray = depends.getAsJsonArray("minecraft"); + if (!mcArray.isEmpty()) { + StringBuilder sb = new StringBuilder(mcArray.get(0).getAsString()); + for (int i = 1; i < mcArray.size(); i++) { + sb.append(", ").append(mcArray.get(i).getAsString()); + } + mcVersion = sb.toString(); + } + } + } + } + } catch (Exception e) { + // Ignore JSON parsing errors + } + + loaderName = Lang.getBinding("service.analysis.is-fabric-mod").get(); + + return true; + } + return false; + } + + private boolean detectLowVersionForgeMod(@Nonnull WorkspaceResource resource) { + FileInfo forgeFileInfo = resource.getFileBundle().get("mcmod.info"); + if (forgeFileInfo != null) { + // reference: https://docs.minecraftforge.net/en/1.13.x/gettingstarted/structuring/ + try { + // Parse mcmod.info to get mcversion + String jsonText = forgeFileInfo.asTextFile().getText(); + JsonArray modArray = JsonParser.parseString(jsonText).getAsJsonArray(); + if (!modArray.isEmpty()) { + JsonObject modInfo = modArray.get(0).getAsJsonObject(); + if (modInfo.has("mcversion")) { + mcVersion = modInfo.get("mcversion").getAsString(); + } + } + } catch (Exception e) { + // Ignore JSON parsing errors + } + + // Parse manifest to get FMLCorePlugin + FileInfo manifestFileInfo = resource.getFileBundle().get("META-INF/MANIFEST.MF"); + if (manifestFileInfo != null) { + try { + String manifest = manifestFileInfo.asTextFile().getText(); + Manifest mf = new Manifest(new ByteArrayInputStream(manifest.getBytes())); + String corePlugin = mf.getMainAttributes().getValue("FMLCorePlugin"); + if (corePlugin != null && !corePlugin.isEmpty()) { + mainClasses.add(corePlugin); + } + } catch (Exception e) { + // Ignore manifest parsing errors + } + } + + loaderName = Lang.getBinding("service.analysis.is-forge-mod").get(); + return true; + } + return false; + } + + private boolean detectHighVersionForgeMod(@Nonnull WorkspaceResource resource) { + FileInfo forgeFileInfo = resource.getFileBundle().get("META-INF/mods.toml"); + if (forgeFileInfo != null) { + loaderName = Lang.getBinding("service.analysis.is-forge-mod").get(); + + // reference: https://mcforge.readthedocs.io/en/latest/gettingstarted/modfiles/ + // 1. find modId in the [[mods]] section + String modId = ""; + Toml toml = new Toml(); + try { + toml.read(forgeFileInfo.asTextFile().getText()); + List mods = toml.getList("mods"); + if (mods != null && !mods.isEmpty()) { + Object firstMod = mods.getFirst(); + if (firstMod instanceof Map map) { + if (map.containsKey("modId")) { + modId = map.get("modId").toString(); + } + } + } + } catch (Exception e) { + // Ignore TOML parsing errors + } + + // 2. find versionRange in the [[dependencies.]] section and the modId of this must is "minecraft" + if (!modId.isEmpty()) { + try { + List dependencies = toml.getList("dependencies." + modId); + if (dependencies != null && !dependencies.isEmpty()) { + for (Object dep : dependencies) { + if (dep instanceof Map map) { + if (map.containsKey("modId") && map.get("modId").toString().equals("minecraft")) { + if (map.containsKey("versionRange")) { + mcVersion = map.get("versionRange").toString(); + break; + } + } + } + } + } + } catch (Exception e) { + // Ignore TOML parsing errors + } + } + + // we can not find main class in mods.toml, so we scan all class files for @Mod annotation + resource.jvmClassBundleStream().forEach(bundle -> { + bundle.forEach(cls -> { + Supplier classLookup = () -> Objects.requireNonNullElse(bundle.get(cls.getName()), cls); + classLookup.get().getAnnotations().forEach(annotationInfo -> { + if (annotationInfo.getDescriptor().equals("Lnet/minecraftforge/fml/common/Mod;")) { + mainClasses.add(cls.getName()); + } + }); + }); + }); + + return true; + } + return false; + } + + private void renderSummary(@Nonnull Workspace workspace, + @Nonnull WorkspaceResource resource, + @Nonnull SummaryConsumer consumer, + @Nonnull Batch batch) { + // Add title + batch.add(() -> { + Label title = new BoundLabel(Lang.getBinding("service.analysis.minecraft-mod-info")); + title.getStyleClass().addAll(Styles.TITLE_4); + consumer.appendSummary(title); + }); + + + batch.add(() -> { + Label title = new Label(loaderName); + consumer.appendSummary(title); + + if (!mcVersion.isEmpty()) { + consumer.appendSummary(new BoundLabel(Lang.format("service.analysis.minecraft-version", mcVersion))); + } else { + consumer.appendSummary(new BoundLabel(Lang.getBinding("service.analysis.minecraft-version-unknown"))); + } + + if (!mainClasses.isEmpty()) { + consumer.appendSummary(new BoundLabel(Lang.getBinding("service.analysis.entry-points"))); + for (String mainClass : mainClasses) { + // Try to find the main class in JVM class bundle + JvmClassInfo classInfo = findClassInResource(resource, mainClass); + if (classInfo != null) { + // Found class, create label with icon + String classDisplay = textService.getJvmClassInfoTextProvider(workspace, resource, + resource.getJvmClassBundle(), classInfo).makeText(); + Node classIcon = iconService.getJvmClassInfoIconProvider(workspace, resource, + resource.getJvmClassBundle(), classInfo).makeIcon(); + Label classLabel = new Label(classDisplay, classIcon); + classLabel.setCursor(Cursor.HAND); + classLabel.setOnMouseEntered(e -> classLabel.getStyleClass().add(Styles.TEXT_UNDERLINED)); + classLabel.setOnMouseExited(e -> classLabel.getStyleClass().remove(Styles.TEXT_UNDERLINED)); + classLabel.setOnMouseClicked(e -> actions.gotoDeclaration(workspace, resource, + resource.getJvmClassBundle(), classInfo)); + consumer.appendSummary(classLabel); + } + } + } else { + consumer.appendSummary(new BoundLabel(Lang.getBinding("service.analysis.entry-points.none"))); + } + }); + } +} diff --git a/recaf-ui/src/main/resources/translations/en_US.lang b/recaf-ui/src/main/resources/translations/en_US.lang index 54fafdc3b..d401fd73e 100644 --- a/recaf-ui/src/main/resources/translations/en_US.lang +++ b/recaf-ui/src/main/resources/translations/en_US.lang @@ -559,6 +559,12 @@ service.analysis.jphantom-generator-config.generate-workspace-phantoms=Generate service.analysis.search-config=Search service.analysis.entry-points=Entry points service.analysis.entry-points.none=No entries found +service.analysis.minecraft-mod-info=Minecraft Mod Info +service.analysis.is-fabric-mod=Mod Loader: Fabric +service.analysis.is-forge-mod=Mod Loader: Forge +service.analysis.minecraft-version=Minecraft Version: %s +service.analysis.minecraft-version-unknown=Minecraft Version: (not specified) +service.analysis.no-minecraft-mod-found=No Minecraft mod entry points found service.analysis.anti-decompile=Anti-Decompilation service.analysis.anti-decompile.illegal-attr=Illegal attributes service.analysis.anti-decompile.illegal-name=Illegal names diff --git a/recaf-ui/src/main/resources/translations/zh_CN.lang b/recaf-ui/src/main/resources/translations/zh_CN.lang index de1702c20..20b283fa8 100644 --- a/recaf-ui/src/main/resources/translations/zh_CN.lang +++ b/recaf-ui/src/main/resources/translations/zh_CN.lang @@ -559,6 +559,12 @@ service.analysis.jphantom-generator-config.generate-workspace-phantoms=生成并 service.analysis.search-config=搜索 service.analysis.entry-points=入口点 service.analysis.entry-points.none=未找到入口 +service.analysis.minecraft-mod-info=Minecraft Mod信息 +service.analysis.is-fabric-mod=Mod加载器: Fabric +service.analysis.is-forge-mod=Mod加载器: Forge +service.analysis.minecraft-version=Minecraft版本: %s +service.analysis.minecraft-version-unknown=Minecraft版本: 不确定 +service.analysis.no-minecraft-mod-found=没有找到Minecraft mod入口点 service.analysis.anti-decompile=反反编译 service.analysis.anti-decompile.illegal-attr=非法属性 service.analysis.anti-decompile.illegal-name=非法名称