Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3ceac95
Start working on auto installer.
shartte Oct 26, 2025
383f458
Fix tests
shartte Oct 26, 2025
881a66d
Fix tests
shartte Oct 26, 2025
8de2cce
Remove unused
shartte Oct 26, 2025
1e3b62e
Add dynamic game installation discovery service
marchermans Nov 28, 2025
5d8e7e2
Add parameters for better detection of the game discovery or installa…
marchermans Nov 28, 2025
279e2a2
Introduce a better design for the running of the GameDiscovery Test
marchermans Nov 30, 2025
58a2372
Add exception handling to game discovery and installation process
marchermans Nov 30, 2025
eb4f549
Properly handle the exception.
marchermans Nov 30, 2025
090bd38
Fix the license
marchermans Nov 30, 2025
ea95474
Update the documentation.
marchermans Nov 30, 2025
693c1db
Fix immaculate.
marchermans Nov 30, 2025
34ee39e
Introduce support for cause listing in the log when a mod loading exc…
marchermans Dec 1, 2025
2b41bfc
Remove the NeoForge version from the game discovery and installation …
marchermans Dec 1, 2025
0b73585
Fix formatting and introduce better logging for ModLoadingExceptions …
marchermans Dec 1, 2025
16d9111
Merge branch 'main' into feature/autoinstall
marchermans Jan 2, 2026
805b0ef
Address needed and requested Changes.
marchermans Jan 2, 2026
0ae4b67
Port to a direct variant
marchermans Jan 2, 2026
dca2167
Fix formatting and properly wire the neoforge version
marchermans Jan 2, 2026
fcc6ce8
Fix license violations
marchermans Jan 2, 2026
d6a327a
Fix tests
marchermans Jan 2, 2026
c5a21cb
Fix formatting
marchermans Jan 2, 2026
fa707a9
Mark the neoforge and minecraft jar as located by the game discovery …
marchermans Jan 3, 2026
ad0a515
Handle the case where two different path objects are not equal becaus…
marchermans Jan 3, 2026
fd62efa
Also normalize both paths
marchermans Jan 3, 2026
5b9dc7c
Update to first review, add support for purging the cache dir to the …
marchermans Jan 9, 2026
d265a00
Refactoring and cleanup
shartte Jan 10, 2026
aea89d8
Merge branch 'main' into feature/autoinstall
shartte Jan 10, 2026
e792886
Fix incorrect assumption about dedicated server-jar
shartte Jan 10, 2026
ef69cb6
Fix a minor issue / use suppresesd exceptions
shartte Jan 11, 2026
a69607a
Remove unused system property.
shartte Jan 11, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.nio.ByteBuffer;
import java.nio.IntBuffer;
import java.util.List;
import java.util.function.Supplier;
import net.neoforged.fml.earlydisplay.render.RenderContext;
import net.neoforged.fml.earlydisplay.render.SimpleFont;
import net.neoforged.fml.earlydisplay.render.Texture;
Expand All @@ -25,11 +26,15 @@ final class Button {
private final int width;
private final int height;
private final String text;
private final boolean active;
private final Supplier<Boolean> active;
private final Runnable onPress;
private boolean focused;

Button(ErrorDisplayWindow window, int x, int y, int width, int height, String text, boolean active, Runnable onPress) {
this(window, x, y, width, height, text, () -> active, onPress);
}

Button(ErrorDisplayWindow window, int x, int y, int width, int height, String text, Supplier<Boolean> active, Runnable onPress) {
this.window = window;
this.x = x;
this.y = y;
Expand All @@ -41,13 +46,13 @@ final class Button {
}

void render(RenderContext ctx, SimpleFont font, double mouseX, double mouseY) {
boolean highlighted = active && (focused || isMouseOver(mouseX, mouseY));
Texture texture = active ? (highlighted ? window.buttonTextureHover : window.buttonTexture) : window.buttonTextureInactive;
boolean highlighted = isActive() && (focused || isMouseOver(mouseX, mouseY));
Texture texture = isActive() ? (highlighted ? window.buttonTextureHover : window.buttonTexture) : window.buttonTextureInactive;
ctx.blitTexture(texture, x, y, width, height);

int w = font.stringWidth(text);
float tx = x + width / 2F - w / 2F;
int textColor = active ? 0xFFFFFFFF : 0xFFA0A0A0;
int textColor = isActive() ? 0xFFFFFFFF : 0xFFA0A0A0;
ctx.renderTextWithShadow(tx, y + 2, font, List.of(new SimpleFont.DisplayText(text, textColor)));
}

Expand All @@ -56,7 +61,7 @@ boolean isMouseOver(double mouseX, double mouseY) {
}

boolean isActive() {
return active;
return active.get();
}

boolean isFocused() {
Expand All @@ -72,7 +77,7 @@ void unfocus() {
}

void press() {
if (this.active) {
if (isActive()) {
this.onPress.run();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package net.neoforged.fml.earlydisplay.error;

import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
Expand All @@ -21,29 +22,33 @@
import net.neoforged.fml.earlydisplay.render.Texture;
import net.neoforged.fml.earlydisplay.theme.Theme;
import net.neoforged.fml.i18n.FMLTranslations;
import net.neoforged.fml.loading.FMLPaths;
import net.neoforged.fml.loading.cache.CacheUtils;
import org.jetbrains.annotations.Nullable;
import org.lwjgl.glfw.GLFW;
import org.lwjgl.opengl.GL11C;

final class ErrorDisplayWindow {
private static final int DISPLAY_WIDTH = 854;
private static final int DISPLAY_HEIGHT = 480;
private static final int BUTTON_WIDTH = 320;
private static final int SMALL_BUTTON_WIDTH = 320;
private static final int LARGE_BUTTON_WIDTH = 2 * SMALL_BUTTON_WIDTH + 20;
private static final int BUTTON_HEIGHT = 40;
private static final int LIST_BORDER_HEIGHT = 2;
private static final int SCROLLER_WIDTH = 15;
private static final int SCROLLER_HEIGHT = 60;
private static final int ENTRY_PADDING = 10;
private static final int HEADER_Y = 10;
private static final int HEADER_LINE_HEIGHT = 18;
private static final int LEFT_BTN_X = DISPLAY_WIDTH / 2 - 10 - BUTTON_WIDTH;
private static final int LEFT_BTN_X = DISPLAY_WIDTH / 2 - 10 - SMALL_BUTTON_WIDTH;
private static final int RIGHT_BTN_X = DISPLAY_WIDTH / 2 + 10;
private static final int TOP_BTN_Y = DISPLAY_HEIGHT - 92;
private static final int TOP_BTN_Y = DISPLAY_HEIGHT - 137;
private static final int MIDDLE_BTN_Y = DISPLAY_HEIGHT - 92;
private static final int BOTTOM_BTN_Y = DISPLAY_HEIGHT - 47;
private static final int LIST_Y_TOP = 70;
private static final int LIST_Y_BOTTOM = DISPLAY_HEIGHT - 100;
private static final int LIST_Y_BOTTOM = DISPLAY_HEIGHT - 145;
private static final int LIST_CONTENT_Y_TOP = 74;
private static final int LIST_CONTENT_Y_BOTTOM = DISPLAY_HEIGHT - 102;
private static final int LIST_CONTENT_Y_BOTTOM = DISPLAY_HEIGHT - 147;
private static final int LIST_BORDER_TOP_Y2 = LIST_Y_TOP - LIST_BORDER_HEIGHT;
private static final int LIST_BORDER_TOP_Y1 = LIST_BORDER_TOP_Y2 - LIST_BORDER_HEIGHT;
private static final int LIST_BORDER_BOTTOM_Y1 = LIST_Y_BOTTOM;
Expand All @@ -64,9 +69,9 @@ final class ErrorDisplayWindow {
final Texture buttonTextureHover;
final Texture buttonTextureInactive;
private final List<Button> buttons;
private final List<HeaderLine> headerTextLines;
private final List<MessageEntry> entries;
private final int totalEntryHeight;
private List<HeaderLine> headerTextLines;
private List<MessageEntry> entries;
private int totalEntryHeight;
private boolean closed = false;
private int offsetX = 0;
private int offsetY = 0;
Expand All @@ -75,6 +80,11 @@ final class ErrorDisplayWindow {
private double mouseY = -1;
private float scrollOffset = 0;
private boolean draggingScrollbar = false;
private boolean purgedCache = false;
private List<ModLoadingIssue> issues;

private final boolean translate;
private final BiFunction<String, Object[], String> translator;

ErrorDisplayWindow(
long windowHandle,
Expand All @@ -94,19 +104,26 @@ final class ErrorDisplayWindow {
this.buttonTexture = Button.loadTexture(true, false);
this.buttonTextureHover = Button.loadTexture(true, true);
this.buttonTextureInactive = Button.loadTexture(false, false);
boolean translate = mcFont != null;
BiFunction<String, Object[], String> translator = translate ? FMLTranslations::parseMessage : FMLTranslations::parseEnglishMessage;
this.translate = mcFont != null;
this.translator = translate ? FMLTranslations::parseMessage : FMLTranslations::parseEnglishMessage;
FileOpener opener = FileOpener.get();
String btnModsText = translator.apply("fml.button.open.mods.folder", new Object[0]);
String btnReportText = translator.apply("fml.button.open.crashreport", new Object[0]);
String btnLogText = translator.apply("fml.button.open.log", new Object[0]);
String btnPurgeButton = translator.apply("fml.button.purge", new Object[0]);
String btnQuitText = translator.apply("fml.button.quit", new Object[0]);
this.buttons = List.of(
new Button(this, LEFT_BTN_X, TOP_BTN_Y, BUTTON_WIDTH, BUTTON_HEIGHT, btnModsText, modsFolder != null, () -> opener.open(modsFolder)),
new Button(this, LEFT_BTN_X, BOTTOM_BTN_Y, BUTTON_WIDTH, BUTTON_HEIGHT, btnReportText, crashReportFile != null, () -> opener.open(crashReportFile)),
new Button(this, RIGHT_BTN_X, TOP_BTN_Y, BUTTON_WIDTH, BUTTON_HEIGHT, btnLogText, logFile != null, () -> opener.open(logFile)),
new Button(this, RIGHT_BTN_X, BOTTOM_BTN_Y, BUTTON_WIDTH, BUTTON_HEIGHT, btnQuitText, true, () -> closed = true));
new Button(this, LEFT_BTN_X, TOP_BTN_Y, SMALL_BUTTON_WIDTH, BUTTON_HEIGHT, btnModsText, modsFolder != null, () -> opener.open(modsFolder)),
new Button(this, LEFT_BTN_X, MIDDLE_BTN_Y, SMALL_BUTTON_WIDTH, BUTTON_HEIGHT, btnReportText, crashReportFile != null, () -> opener.open(crashReportFile)),
new Button(this, RIGHT_BTN_X, TOP_BTN_Y, SMALL_BUTTON_WIDTH, BUTTON_HEIGHT, btnLogText, logFile != null, () -> opener.open(logFile)),
new Button(this, RIGHT_BTN_X, MIDDLE_BTN_Y, SMALL_BUTTON_WIDTH, BUTTON_HEIGHT, btnPurgeButton, () -> !purgedCache, this::purgeCache),
new Button(this, LEFT_BTN_X, BOTTOM_BTN_Y, LARGE_BUTTON_WIDTH, BUTTON_HEIGHT, btnQuitText, true, () -> closed = true));

updateIssues(issues);
}

private void updateIssues(List<ModLoadingIssue> issues) {
this.issues = new ArrayList<>(issues);
List<ModLoadingIssue> warningEntries = issues.stream()
.filter(issue -> issue.severity() != ModLoadingIssue.Severity.ERROR)
.toList();
Expand Down Expand Up @@ -147,6 +164,16 @@ private static void translateEntries(List<ModLoadingIssue> issues, List<MessageE
issues.stream().map(translator).map(text -> MessageEntry.of(text, font)).forEach(entries::add);
}

private void purgeCache() {
this.purgedCache = true;
try {
CacheUtils.purgeCache();
} catch (IOException e) {
this.issues.add(ModLoadingIssue.error("fml.modloadingissue.cache.purge.failed").withCause(e).withAffectedPath(FMLPaths.CACHEDIR.get()));
updateIssues(this.issues);
}
}

void render() {
framebuffer.activate();

Expand Down
3 changes: 2 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
gradleutils_version=5.0.1

test_neoforge_version=21.10.20-beta
test_neoforge_version=21.10.37-beta
test_minecraft_version=1.21.10

mergetool_version=2.0.0
Expand All @@ -19,6 +19,7 @@ jupiter_version=5.13.4
mockito_version=5.11.0
assertj_version=3.26.0
jmh_version=1.37
installer_tools_version=4.0.16

mojang_logging_version=1.1.1
log4j_version=2.22.1
Expand Down
1 change: 1 addition & 0 deletions loader/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
api "net.neoforged:JarJarSelector:${jarjar_version}"
api "net.neoforged:JarJarMetadata:${jarjar_version}"
api("net.neoforged:bus:${eventbus_version}")
api("net.neoforged.installertools:binarypatch-applier:${installer_tools_version}")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

implementation?


implementation("net.sf.jopt-simple:jopt-simple:${jopt_simple_version}")
implementation("net.neoforged:accesstransformers:${accesstransformers_version}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public ModLoadingException(ModLoadingIssue issue) {

public ModLoadingException(List<ModLoadingIssue> issues) {
this.issues = List.copyOf(issues);
for (var issue : issues) {
if (issue.cause() != null) {
addSuppressed(issue.cause());
}
}
}

public List<ModLoadingIssue> getIssues() {
Expand Down
2 changes: 1 addition & 1 deletion loader/src/main/java/net/neoforged/fml/VersionChecker.java
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ private void process(IModInfo mod) {
Map<String, String> promos = (Map<String, String>) json.get("promos");
display_url = (String) json.get("homepage");

var mcVersion = FMLLoader.getCurrent().getVersionInfo().mcVersion();
var mcVersion = FMLLoader.getCurrent().getMinecraftVersion();
String rec = promos.get(mcVersion + "-recommended");
String lat = promos.get(mcVersion + "-latest");
ComparableVersion current = new ComparableVersion(mod.getVersion().toString());
Expand Down
164 changes: 164 additions & 0 deletions loader/src/main/java/net/neoforged/fml/loading/ClassLoaderStack.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright (c) NeoForged and contributors
* SPDX-License-Identifier: LGPL-2.1-only
*/

package net.neoforged.fml.loading;

import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.neoforged.fml.classloading.JarContentsModule;
import net.neoforged.fml.classloading.ResourceMaskingClassLoader;
import net.neoforged.fml.jarcontents.CompositeJarContents;
import net.neoforged.fml.jarcontents.EmptyJarContents;
import net.neoforged.fml.jarcontents.FolderJarContents;
import net.neoforged.fml.jarcontents.JarContents;
import net.neoforged.fml.jarcontents.JarFileContents;
import net.neoforged.fml.util.ClasspathResourceUtils;
import net.neoforged.fml.util.PathPrettyPrinting;
import net.neoforged.neoforgespi.LocatedPaths;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public final class ClassLoaderStack implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(ClassLoaderStack.class);

private final List<AutoCloseable> ownedResources = new ArrayList<>();
private final LocatedPaths locatedPaths;
/**
* The context class-loader that will be restored when the loader is closed.
*/
@Nullable
private final ClassLoader originalClassLoader;
/**
* The current tail of the class-loader chain. It is moved whenever a new set of Jars is loaded.
*/
private ClassLoader currentClassLoader;

public ClassLoaderStack(ClassLoader initialLoader, LocatedPaths locatedPaths) {
this.currentClassLoader = initialLoader;
this.locatedPaths = locatedPaths;
this.originalClassLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(currentClassLoader);
}

/**
* Loads the given services into a URL classloader.
*/
public void appendLoader(String loaderName, List<JarContents> jars) {
if (jars.isEmpty()) {
LOGGER.info("No additional classpath items for {} were found.", loaderName);
return;
}

LOGGER.info("Loading {}:", loaderName);

List<URL> rootUrls = new ArrayList<>(jars.size());
for (var jar : jars) {
if (jar instanceof CompositeJarContents compositeJarContents && compositeJarContents.isFiltered()) {
throw new IllegalArgumentException("Cannot use simple URLClassLoader for filtered content " + jar);
}

// TODO: Order on the classpath matters, we need to double-check the content roots are in the right order here
for (var contentRoot : jar.getContentRoots()) {
LOGGER.info(" - {}", PathPrettyPrinting.prettyPrint(contentRoot));
try {
rootUrls.add(contentRoot.toUri().toURL());
} catch (MalformedURLException e) {
throw new RuntimeException(e); // This should not happen for file URLs
}
locatedPaths.addLocated(contentRoot); // Prevents it from getting picked up again
}
}

var loader = new URLClassLoader(loaderName, rootUrls.toArray(URL[]::new), currentClassLoader);
ownedResources.add(loader);
currentClassLoader = loader;
Thread.currentThread().setContextClassLoader(loader);
}

public ClassLoader getCurrentClassLoader() {
return currentClassLoader;
}

public void append(ClassLoader loader) {
currentClassLoader = loader;
}

/**
* If any location being added is already on the classpath, we add a masking classloader to ensure
* that resources are not double-reported when using getResources/getResource.
* <p>
* The primary purpose of this is in mod and NeoForge development environments, where IDEs put the mod
* on the app classpath, but we also add it as content to the game layer. This method is responsible
* for setting up a classloader that prevents getResource/getResources from reporting Jar resources
* for both the jar on the App classpath and on the transforming classloader.
*/
public void maskContentAlreadyOnClasspath(List<JarContentsModule> content) {
var classpathItems = ClasspathResourceUtils.getAllClasspathItems(getCurrentClassLoader());

// Collect all paths that make up the game content, which are already on the classpath
Set<Path> needsMasking = new HashSet<>();
for (var secureJar : content) {
for (var basePath : getBasePaths(secureJar.contents(), true)) {
if (classpathItems.contains(basePath)) {
needsMasking.add(basePath);
}
}
}

if (!needsMasking.isEmpty()) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Masking classpath elements: {}", needsMasking.stream().map(PathPrettyPrinting::prettyPrint).toList());
}

var maskedLoader = new ResourceMaskingClassLoader(currentClassLoader, needsMasking);
if (Thread.currentThread().getContextClassLoader() == currentClassLoader) {
Thread.currentThread().setContextClassLoader(maskedLoader);
}
currentClassLoader = maskedLoader;
}
}

private static List<Path> getBasePaths(JarContents contents, boolean ignoreFilter) {
var result = new ArrayList<Path>();
switch (contents) {
case CompositeJarContents compositeModContainer -> {
if (!ignoreFilter && compositeModContainer.isFiltered()) {
throw new IllegalStateException("Cannot load filtered Jar content into a URL classloader");
}
for (var delegate : compositeModContainer.getDelegates()) {
result.addAll(getBasePaths(delegate, ignoreFilter));
}
}
case EmptyJarContents ignored -> {}
case FolderJarContents folderModContainer -> result.add(folderModContainer.getPrimaryPath());
case JarFileContents jarModContainer -> result.add(jarModContainer.getPrimaryPath());
default -> throw new IllegalStateException("Don't know how to handle " + contents);
}
return result;
}

@Override
public void close() {
for (var ownedResource : ownedResources) {
try {
ownedResource.close();
} catch (Exception e) {
LOGGER.error("Failed to close resource {} owned by class loader stack", ownedResource, e);
}
}
ownedResources.clear();

if (Thread.currentThread().getContextClassLoader() == currentClassLoader) {
Thread.currentThread().setContextClassLoader(originalClassLoader);
}
}
}
Loading
Loading