Skip to content

fix: invoke the main entrypoint on dedicated servers below Minecraft 1.18 - #501

Open
ashwalk33r wants to merge 1 commit into
QuiltMC:developfrom
ashwalk33r:fix/pre-1.18-server-main-entrypoint
Open

fix: invoke the main entrypoint on dedicated servers below Minecraft 1.18#501
ashwalk33r wants to merge 1 commit into
QuiltMC:developfrom
ashwalk33r:fix/pre-1.18-server-main-entrypoint

Conversation

@ashwalk33r

@ashwalk33r ashwalk33r commented Aug 21, 2026

Copy link
Copy Markdown

Fixes #500.

Why a mod's main entrypoint vanishes on pre-1.18 dedicated servers

This is an explanation of a defect whose symptom looks like an entrypoint bug and whose cause is not one. It is worth reading in that order, because the obvious place to look is the wrong one, and the obvious fix does not work.

The symptom, and why it misleads

On a dedicated server running Minecraft 1.17.1 or older, the class declared in quilt_loader.entrypoints.main is never invoked. There is no crash, no exception, no warning, and no log line. Meanwhile preLaunch, declared in the same quilt.mod.json, runs normally, and the mixin config named in that same file is applied. The loader clearly read the metadata and acted on most of it.

That asymmetry is what sends people to EntrypointPatch. It is a reasonable suspicion — preLaunch reaches its call site directly from Knot, while main reaches its call site only through the Hooks.startServer call that EntrypointPatch injects into Minecraft's own main class. If exactly one of the two stages is missing, the injection is the natural culprit.

It is not the culprit. EntrypointPatch runs, finds the right class and the right method, and emits the INVOKESTATIC at the intended offset on a straight-line path with no enclosing try/catch. The patched class is produced correctly. It is simply never the class the JVM ends up defining.

The actual mechanism: two copies of one class, and the wrong one wins

Minecraft server jars before 1.18 bundle log4j. That single packaging detail is the root of the whole problem, because it makes the game jar a log jar as well as a game jar.

Because log4j must be available before anything else, MinecraftGameProvider.initialize() puts the raw, unpatched, remapped game jar on the Knot classpath very early — long before mods exist, and long before a transform cache exists — restricted to the log4j and Mojang prefixes it is there to supply (MinecraftGameProvider.java:473). Later, finishModLoading() adds the transformed minecraft root, which lands behind the raw jar in insertion order. Finally unlockClassPath() clears the prefix restriction on the raw jar, on the reasonable theory that the game is now free to load from it.

Clearing that restriction is what turns a latent ordering problem into a defect. The raw jar becomes an unrestricted source for every class it contains, and it is ahead of the transform cache. KnotClassDelegate.getClassUrl returns the first match in search order; nothing merges candidates or prefers a transformed one. So the loader reads bytes from the raw jar and defines vanilla net.minecraft.server.Main, discarding every game patch at once.

The class size at the moment of definition is the clearest way to see it:

E2E PROBE: defineClass net.minecraft.server.Main (17243 bytes)
    from jar:file:/…/remappedJars/minecraft-1.16.5-…/server-intermediary.jar!/net/minecraft/server/Main.class

17243 bytes is the vanilla class. The transform cache held the patched one, at 17479 bytes, carrying the injected call. Nothing failed, nothing threw, and so nothing was logged — which is exactly why the symptom is silent. A discarded patch is indistinguishable from a patch that was never needed.

Why the version boundary is where it is

The gap ends at 1.18, and it is tempting to attribute that to EntrypointPatch's own <=1.17 version predicate, which chooses between a legacy and a modern hook class. The two boundaries very nearly coincide, which makes the coincidence convincing.

The real boundary is the Mojang bundler split. From 1.18 onward the server ships log4j as a separate library rather than bundling it, so the game jar is never a log jar, is never added early, and is only put on the classpath at unlockClassPath() — after the transform cache. The transform cache therefore wins on 1.18+, and the same jar, the same loader and the same JVM behave correctly. Nothing about the entrypoint code differs between the two cases.

This matters beyond trivia: it means the defect is a property of how a Minecraft version packages log4j, not of anything in the entrypoint machinery, and it predicts that every pre-bundler version is affected. That prediction was then tested rather than assumed.

What the change actually does

The whole behavioural change is one call, at Knot.java:324, guarded by one condition at Knot.java:293. A classpath entry added with a non-empty allowedPrefixes exists to supply those prefixes and nothing more, so it must never outrank a general-purpose entry. It now enters through classLoader.addURL(url) rather than classLoader.addPath(...).

That distinction is the mechanism. addPath registers a root in paths; addURL feeds minimalLoader. KnotClassLoader.findResource consults paths first and only falls back to minimalLoader. Moving the raw game jar to the second tier takes it out from in front of the transform cache, so a lookup of net/minecraft/server/Main.class resolves to the patched copy, the injected call executes, Hooks.startServer runs, EntrypointUtils.invoke("main", ModInitializer.class, …) dispatches, and the mod's onInitialize() finally runs.

Nothing is being added here. EntrypointPatch, Hooks and EntrypointUtils are untouched, and they were already correct — they work on 1.18.2 today with the same loader and the same mod jar. The fix removes a shadowing, it does not implement a missing feature. Reverting line 324 to addPath makes the entrypoint silent again on every affected version, which is the cleanest available demonstration that this line and not its neighbours is load-bearing.

Log4j still resolves from that jar, which is the reason it was added early in the first place. At initialize() time nothing else is on the Knot classpath, so the first tier is empty and the second tier is the only source; log4j classes are simply found one tier later. The prefix restriction itself is untouched, so a non-log4j game class requested that early still fails exactly as before.

Why this seam, rather than the one the evidence points at

The obvious local fix is to stop unlockClassPath() from clearing the restriction. It cannot work. By the time that method runs, the raw jar's entries are already ahead of the transform cache in QuiltClassPath's insertion-ordered map, and that structure exposes only addRoot — no removal, no reordering — and resolves collisions first-wins. Ordering can only be decided where an entry is added, which is why the change lives in the generic Knot.addToClassPath rather than in the Minecraft game provider.

One consequence should be stated rather than discovered: the invariant is enforced at one of two doors. Knot.setAllowedPrefixes can still restrict an entry that already went in via addPath, and nothing repositions it afterwards. No caller in the tree does that today, but the patch does not make it impossible.

KnotCompatibilityClassLoader is the other honest limit. It has no second search tier — addURL and addPath are both plain URLClassLoader.addURL there — so it cannot honour this ordering at all. Rather than let it fail silently in the same way the original bug did, it now logs a warning naming the entry and the consequence. That path is selected only by a ModLoader-era mod or -Dfabric.loader.useCompatibilityClassLoader=true, and it was not booted here; fixing it properly means giving it a two-tier search, which is a separate and unmeasured change.

How this is known

Boots of real dedicated servers, each at its own Java floor in Docker, with a mod declaring only a main entrypoint:

Minecraft before this fix after this fix
1.14.4 silent (predicted, not directly measured before) invokes main, exit 0
1.15.2 silent (predicted, not directly measured before) invokes main, exit 0
1.16.5 silent (measured) invokes main, exit 0
1.17.1 silent (predicted, not directly measured before) invokes main, exit 0
1.18.2 (control) invokes main unchanged, invokes main, exit 0

The runner that produces this table is deliberately kept on a separate branch, ashwalk33r#4, so that this diff stays one file: src/main/java/org/quiltmc/loader/impl/launch/knot/Knot.java, 37 insertions and 1 deletion. ./gradlew build succeeds.

Two limits on that evidence, stated plainly. The change is side-agnostic in code but server-only in measurement — no pre-1.18 client was booted, and while the changed path is not environment-specific, a client is not claimed to be fixed. And a mod added with restricted prefixes now throws IllegalArgumentException naming the mod id rather than silently landing last; no caller in the tree does this today, so it is a guard against future regression rather than a live check.

A step-by-step reproduction, runnable without this repository, is at ashwalk33r#2. The version tables and environment details are at ashwalk33r#1.

…1.18

On a dedicated server running Minecraft 1.17.1 or older, the class declared in
quilt_loader.entrypoints.main was never invoked — silently, with no crash, no
exception and no log line — while preLaunch from the same quilt.mod.json ran
and its mixin config applied.

The entrypoint machinery was never at fault. EntrypointPatch injected the hook
correctly, but the JVM never ran the patched bytecode: pre-1.18 server jars
bundle log4j, so the raw remapped game jar is added to the Knot classpath early
and restricted to log4j prefixes. finishModLoading later adds the transformed
minecraft root behind it, and unlockClassPath then clears the restriction,
making the raw jar an unrestricted source for every class it holds. Since
getClassUrl returns the first match, the JVM defined vanilla
net.minecraft.server.Main (17243 bytes) instead of the patched copy (17479),
discarding every game patch without a single error. 1.18+ is unaffected: the
Mojang bundler ships log4j separately, so the game jar is never a log jar.

Decide precedence where the entry is added: a prefix-restricted entry is a
limited-purpose source, so it now enters via classLoader.addURL — searched only
after every addPath root — instead of addPath. KnotCompatibilityClassLoader has
no second tier and cannot honour that ordering, so it warns rather than
silently loading the untransformed copy.

Verified end-to-end on real dedicated servers: 1.14.4, 1.15.2, 1.16.5 and
1.17.1 go from silent to invoking the entrypoint; 1.18.2 is unchanged.
@ashwalk33r

ashwalk33r commented Aug 21, 2026

Copy link
Copy Markdown
Author

I found it out while porting my mod to older Quilt MC versions (while tooling with CICD E2E test matrix) - by accident it turned out that it never invokes the said main entry point.

Log line that the mod was loaded is missing - for me it means my test assertions fail, for user not that much I guess...

@ashwalk33r

Copy link
Copy Markdown
Author

Here, automated tests showing that the fix is actually working (fork) - ashwalk33r#4

@MrCat12-Studios

Copy link
Copy Markdown
Contributor

It is probably going to take a while for the bug to get fixed so if you want immediate results you can build from your fork, go to .minecraft/libraries/or/quiltmc/quilt-loader/, put the newly built jar into the folder, delete the old jar and rename the new jar using the old jar's name.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: main entrypoint not invoked on server below Minecraft 1.18

2 participants