-
Notifications
You must be signed in to change notification settings - Fork 516
feat: Minecraft mod summarization (Forge/Fabric) #966
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
luiox
wants to merge
7
commits into
Col-E:master
Choose a base branch
from
luiox:feat/minecraft-summerize
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
cb600da
Add MinecraftModSummarizer for mod info detection
luiox c394b37
Add localization for Minecraft mod summary UI
luiox 57c11cd
Improve Minecraft version label formatting and add Chinese translations
luiox bb120a6
Improve Minecraft mod summarizer entrypoint and version parsing
luiox 586005a
Refactor Minecraft mod summarizer logic
luiox cec9a1c
Add support for high version forge mod detection
luiox 78019fb
Merge branch 'master' into feat/minecraft-summerize
luiox File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
305 changes: 305 additions & 0 deletions
305
.../main/java/software/coley/recaf/services/info/summary/builtin/MinecraftModSummarizer.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| 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"))); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think
mcmod.infois used mostly in older versions of forge (Like1.12), and newer versions have this info atMETA-INF/mods.toml- Would be nice to support both