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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions recaf-ui/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies {
implementation(libs.reactfx)
implementation(libs.richtextfx)
implementation(libs.treemapfx)
implementation(libs.toml4j)
}

application {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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");
Copy link
Owner

Choose a reason for hiding this comment

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

I think mcmod.info is used mostly in older versions of forge (Like 1.12), and newer versions have this info at META-INF/mods.toml - Would be nice to support both

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<Object> 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.<modId>]] section and the modId of this must is "minecraft"
if (!modId.isEmpty()) {
try {
List<Object> 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<JvmClassInfo> 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")));
}
});
}
}
6 changes: 6 additions & 0 deletions recaf-ui/src/main/resources/translations/en_US.lang
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions recaf-ui/src/main/resources/translations/zh_CN.lang
Original file line number Diff line number Diff line change
Expand Up @@ -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=非法名称
Expand Down