Java 24/25 support: recompute stale StackMapTables + JEP 486 launch#193
Java 24/25 support: recompute stale StackMapTables + JEP 486 launch#193GribanovIvan wants to merge 4 commits into
Conversation
…JDKs When a SpongePowered Mixin (e.g. via unimixins) rewrites a net.minecraft method, it recomputes only the operand-stack/local sizes (COMPUTE_MAXS) and leaves the method's StackMapTable stale. The split bytecode verifier rejects that with "VerifyError: Expecting a stackmap frame at branch target N" the moment the class is verified -- e.g. net.minecraft.world.gen.ChunkProviderServer.func_73153_a when Dynmap reflectively scans its fields during postInit -- so the server cannot start. Measured on the lwjgl3ify server path with a large GTNH-style pack: this happens on every modern JDK tested (21, 22, 23, 24, 25). Java 8 is unaffected because its verifier fails over to the old type-inference verifier for these class files. A bare server is unaffected; only packs whose Mixins touch net.minecraft hit it. Disabling the verifier (-Xverify:none) also lets the pack start but drops bytecode verification for every class. This is the surgical alternative: repair the affected frames and keep the verifier on. RFB's rfb-asm-safety plugin only makes getCommonSuperClass safe for transformers that already recompute frames; it does not force a recompute on a class whose stale frame was baked in by a COMPUTE_MAXS-only pass. Add AsmFrameSafetyTransformer, registered last on the LaunchClassLoader via CrucibleCoremodHook, which recomputes frames (COMPUTE_FRAMES) for net.minecraft.* classes at load time using a deobf-aware, never-throwing ClassWriter (SafeAsmClassWriter). Implementation note: ClassWriter(reader, flags) copies unchanged methods verbatim and skips COMPUTE_FRAMES; super(flags) without the reader forces a real recompute. Toggles: -Dcrucible.frameSafety=false disables it; -Dcrucible.frameSafety.scope=all (or a comma-separated prefix list) widens beyond net.minecraft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three small changes that let Crucible launch on Java 24/25, complementing the StackMapTable fix: * FMLTweaker: on Java 24+ System.setSecurityManager() always throws UnsupportedOperationException (JEP 486). FMLTweaker only caught SecurityException, so it crashed at startup. Catch the new exception, log it, and continue -- the FML SecurityManager only trapped rogue System.exit() and SM replacement, neither essential. * java24args.txt: a Java 24+ launch-args file. It is java9args.txt without -Djava.security.manager=allow (which makes a Java 24+ JVM refuse to start) and the obsolete --illegal-access=warn (ignored since Java 17). * README: bump the advertised supported range from "Java 8-21" to "Java 8-25". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
May be we can move "-Dcrucible.frameSafety=false" and "-Dcrucible.frameSafety.scope=all" to Crucible config? |
Per review feedback (gravit0): expose the StackMapTable-recompute toggle and its class scope as Crucible.yml options instead of system-properties-only. - crucible.asm.frameSafety (bool, default true) — enable the recompute transformer. - crucible.asm.frameSafetyScope (string, default "net.minecraft.") — prefix filter; "all" recomputes everything. The system properties still work and OVERRIDE the config when set, so CI/launch scripts can flip them without editing the yml. Debug-only props (crucible.frameSafety.debug/.debugClass) stay system-property-only. CrucibleCoremodHook reads the config the same way the rest of the bootstrap package does (Lwjgl3ifyIntegration) — a direct CrucibleConfigs.configs reference; crucible.jar is the launch -jar so the class resolves. The transformer reads its scope the same way.
gravit0
left a comment
There was a problem hiding this comment.
I'd like to know what problem PR solves (specifically, working with ASM). I remember there was a problematic asjcore mod with a similar issue to the one described above: https://pastebin.com/TTGVr223
|
|
||
| @SuppressWarnings("unchecked") | ||
| private void ensureRunsLast() { | ||
| final Field f = TRANSFORMERS_FIELD; |
There was a problem hiding this comment.
This is quite risky code with reflection. Even though we guarantee that the launchwrapper implementation is RFB and we can change this code if it changes, it's still a dirty hack.
There was a problem hiding this comment.
You're right, and I agree the reflective reorder is fragile, it pokes LaunchClassLoader.transformers directly and only works as long as that field stays a List we're allowed to mutate. The reason it's there at all: this transformer has to run after every other IClassTransformer, because anything that transforms a class after we recompute its frames can re-introduce the stale StackMapTable we just fixed. Registration order alone doesn't guarantee that, so ensureRunsLast() moves us to the tail of the list on each transform() call.
The obvious clean replacement seems to be an RFB RfbClassTransformer in the LCL_WITH_TRANSFORMS phase, that phase already appears to run after the legacy IClassTransformer chain (in LaunchClassLoader I see runTransformers(...) invoked first and runRfbTransformers(..., LCL_WITH_TRANSFORMS, ...) after it), so the ordering would be handled by RFB and the reflection would go away. I tried fairly hard to make that work and kept hitting frame-computation problems that I couldn't fully explain, so I'd appreciate your read on it. Here's what I tried, all on a full GTNH-style pack on GraalVM 25:
-
RFB's own recompute via ClassNodeHandle.computeFrames()
public void transformClass(ExtensibleClassLoader cl, Context ctx, Manifest m, String name, ClassNodeHandle h) {
h.computeFrames();
}
NoClassDefFoundError: adb during server bootstrap. adb seems to be the obfuscated name of a net.minecraft class, so my guess is the recompute produced a frame that's wrong for the remapped class, but I'm not certain that's the actual cause. -
The in-repo SafeAsmClassWriter on the node ASM gives me
ClassNode node = h.getNode();
SafeAsmClassWriter w = new SafeAsmClassWriter(null, ClassWriter.COMPUTE_FRAMES);
node.accept(w);
ClassNode recomputed = new ClassNode();
new ClassReader(w.toByteArray()).accept(recomputed, 0);
h.setNode(recomputed);
VerifyError: Bad type on operand stack. With debug logging the transformer does see deobfuscated names here (className=net.minecraft.world.World, node.name=net/minecraft/world/World) and the scope filter behaves, so the inputs look right to me, but the resulting frames don't verify for some net.minecraft classes. -
A writer that resolves the hierarchy via ExtensibleClassLoader.findClassMetadata instead (reasoning: findClassMetadata appears to go through untransformName, i.e. it seems to be the deobf-aware lookup for this phase)
FastClassAccessor a = cl.findClassMetadata(internalName.replace('/', '.'));
// walk a.binarySuperName() / a.binaryInterfaceNames() to compute the common supertype
NoClassDefFoundError: ahb, which appears to be net.minecraft.world.World again. -
In-repo writer + h.setWriterFlags(0) after setNode, in case RFB's final computeBytes() was re-running its own writer over my result and undoing it (it looks like computeBytes() does new SafeAsmClassWriter(writerFlags) and re-emits) → still VerifyError: Bad type on operand stack, so that doesn't seem to be it either.
-
h.getOriginalBytes() + the exact byte-level path the legacy transformer uses
byte[] orig = h.getOriginalBytes();
ClassReader r = new ClassReader(orig);
SafeAsmClassWriter w = new SafeAsmClassWriter(r, ClassWriter.COMPUTE_FRAMES);
r.accept(w, 0);
ClassNode recomputed = new ClassNode();
new ClassReader(w.toByteArray()).accept(recomputed, 0);
h.setNode(recomputed);
h.setWriterFlags(0);
still VerifyError: Bad type on operand stack.
The part I can't reconcile: the same in-repo SafeAsmClassWriter recompute boots clean (server reaches Done, no VerifyError) when it runs as a legacy IClassTransformer reordered to last, but the same writer on the LCL_WITH_TRANSFORMS phase produces a bad frame for some net.minecraft classes. So it seems less like the writer itself and more like the bytes/type-resolution context differing between "legacy chain, last position" and "the RFB phase" — but I genuinely don't know the RFB contract well enough to say which difference matters.
So for now I've kept the legacy IClassTransformer with the reflective tail-reorder, since that path boots reliably which is itself very much in the spirit of 1.7.10: code shaped by the 2014 toolchain, twelve years on, still somehow load-bearing. Doing it the clean way feels like it could mean untangling how the launch chain resolves types on that phase, and at that point that is rewriting half the server to win back one reflective field access.
Real question, if you have a minute: for recomputing StackMapTables of FML-remapped classes from an LCL_WITH_TRANSFORMS transformer — which bytes are the intended input (getOriginalBytes vs getNode), and how is getCommonSuperClass meant to resolve the deobfuscated type hierarchy on that phase? If there's a supported recipe I'll switch to it and drop the reflection.
| if (dbg) dump(transformedName + ".pre", basicClass); | ||
| try { | ||
| ClassReader reader = new ClassReader(basicClass); | ||
| SafeAsmClassWriter writer = new SafeAsmClassWriter(reader, ClassWriter.COMPUTE_FRAMES); |
There was a problem hiding this comment.
We can use com.gtnewhorizons.retrofuturabootstrap.asm.SafeAsmClassWriter. Why do we need to re-implement it here?
There was a problem hiding this comment.
Good point, and I tried that first. RFB's SafeAsmClassWriter has the flags-only constructor I need (new SafeAsmClassWriter(ClassWriter.COMPUTE_FRAMES) with no reader, so it can't fall into the "copies unchanged methods byte-for-byte and skips the recompute" trap), so on paper it's a drop-in and one less copy of this to maintain. But swapping it in regresses the full pack on GraalVM 25:
java.lang.NoClassDefFoundError: ahb
ahb is the obfuscated name of net.minecraft.world.World, so the recompute produced a class the JVM then can't define under its obf alias, i.e. the recomputed frames are wrong for that class. With the in-repo writer the same boot reaches Done cleanly.
As far as I can tell the difference is deobfuscation. The whole job of getCommonSuperClass during COMPUTE_FRAMES is to find the common supertype of two types without loading them, and on 1.7.10 those types are obfuscated and not yet loadable. The in-repo writer's getCommonSuperClass resolves the hierarchy through FMLDeobfuscatingRemapper (obf↔srg) and reads class headers off the LaunchClassLoader, so it gets the right supertype for a transformed net.minecraft class; RFB's appears to resolve names directly, which on these classes lands on the wrong supertype and emits a frame that doesn't verify. It's the same deobf-awareness gap I hit trying to move the whole pass onto the RFB phase...
This is also the exact failure mode the PR exists to fix in the first place, an imprecise getCommonSuperClass widening a type and producing a bad frame, so using a writer that isn't deobf-aware would reintroduce the bug it's meant to repair.
So I've kept the in-repo writer, but I'd rather not if RFB's can cover it. Is RFB's SafeAsmClassWriter.getCommonSuperClass meant to be deobf-aware when used on FML-remapped net.minecraft classes and if so, is there something I should be passing it (a loader, a remapper) to make it resolve the obf hierarchy correctly..?
- frameSafety read directly from CrucibleConfigs: dropped the -Dcrucible.frameSafety system-property override (config is the configuration mechanism). Debug-only -Dcrucible.frameSafety.debug/.debugClass stay system properties. - One args file: removed -Djava.security.manager=allow from java9args.txt (the actual Java-24 blocker) and deleted java24args.txt. lwjgl3ify redirects setSecurityManager so the flag isn't needed. - FMLTweaker: dropped the UnsupportedOperationException catch. RFB's DeprecatedRedirectTransformer maps System.setSecurityManager to its shim on Java 9+, so the call never throws UOE there; Java 8 runs it natively. Verified the full pack boots to Done on GraalVM 25 with the catch removed. Not changed (kept in-repo SafeAsmClassWriter, and the legacy IClassTransformer registration): RFB's SafeAsmClassWriter / an LCL_WITH_TRANSFORMS RfbClassTransformer both miscompute frames for FML-remapped net.minecraft classes here (NoClassDefFound / VerifyError); the deobf-aware in-repo writer run last boots clean. Asked upstream for the intended deobf resolution on that phase.
|
Some context on what the The problem (ASM/verifier side). The JVM has two verifiers: the old type-inference one, which tolerates What changed concretely is that the escape hatches went away. There used to be ways to keep a bad frame alive: The frames go bad in the first place because ASM transformers in the chain rewrite Those two failure modes produce different-looking VerifyErrors but have the same root:
What the PR does about it. It runs a final recompute pass ( I reproduced your log. Minimal pack: lwjgl3ify + unimixins + ASJCore on this PR's core, GraalVM 25:
So this isn't only the On whether the server should be doing this at all: I think it has to. A mod may be the one writing the bad frame, but "doesn't boot on a modern JVM" is the server's problem to solve regardless of who technically caused it. Nobody is going to go patch the ASM of every coremod in their pack. Recomputing The other half of the PR and the harder launch blocker. Separate from the frame stuff: JDK 24 (JEP 486) permanently disabled the Security Manager, and that's a stricter cutoff than the verifier change. I checked 23 vs 24: on 23, |
|
After a little research into the ASJCore issue, I discovered the following:
|
|
Yeah but in my small modpack MrTJPCore, CodeChickenCore, OpenModsLib and some other mods seem also seem to have similar launch problem. I would not focus on ASJcore or any other mod, that is general issue |
|
Why did you decide that these changes are necessary at all? I'm developing my own fork of Crucible and to support Java 24+ all I had to do was remove the use of FMLSecurityManager. VerifyError crashes are associated with bad transformers in mods, there are no such things in Crucible. Try using the GTNH fixes, they did a great job of removing/fixing all the dirty transformers in the mods. |
|
You're right about the root cause. I tested it and hit VerifyErrors only in a vanilla class ( Removing the dirty transformers in the mod jars is the correct fix and the right default cleanest bytecode without per-load cost. But when you hit a mod GTNH hasn't patched, you're cooked. I think having a fallback is good to have anyway. I agree that SecurityManager removal is the only strictly-necessary change, but why stop here? With the load-time StackMap recompute, you can run an existing pack on Java 25 without swapping every mod for a GTNH-fixed jar (even if they are better in many other ways). Maybe keep it behind a |
|
I think it's worth trying to implement a PR in Hodgepodge https://github.com/GTNewHorizons/Hodgepodge with a fix for at least ASJCore. I don't know what other mods are causing problems (Dynmap doesn't cause any issues for me). |
|
I'm not sure about that. Clean Forge+lwgl3ify works fine and does not require any changes, game client seems fine too. I think GTNH team won't accept PR only because it fixes something in Crucible, as they actively dislike this project and suggest using clean forge. However, I won't interfere if someone pulls this code into hodgepodge. |
|
The fact that this works in Forge + lwjgl3ify is pure luck, and is essentially based on the fact that this class is compiled with Java 6 |
|
in that case this PR also makes crucible lucky |
Java 24/25 support
Lets Crucible launch and run a Mixin-heavy modpack on Java 24/25. Verified by building this branch (Java 8) and booting a large GTNH-style pack (~62 mods, incl. unimixins + lwjgl3ify + Galacticraft + Dynmap) to
Doneon Oracle GraalVM 25 with the bytecode verifier on (0 VerifyErrors).Commit 1 — Recompute stale StackMapTables
A SpongePowered Mixin (via unimixins) rewrites a
net.minecraftmethod withCOMPUTE_MAXSonly, leaving its StackMapTable stale. The split verifier then rejects it withVerifyError: Expecting a stackmap frame at branch target N— e.g.ChunkProviderServer.func_73153_awhen Dynmap reflectively scans its fields in postInit — so the server can't start. Measured on every modern JDK tested (21–25); Java 8 is unaffected (its verifier fails over to the old type-inference verifier).-Xverify:nonealso works but drops verification for every class. This is the surgical alternative: a transformer (AsmFrameSafetyTransformer, registered last on the LaunchClassLoader viaCrucibleCoremodHook) force-recomputes frames fornet.minecraft.*with a deobf-aware, never-throwingSafeAsmClassWriter, keeping the verifier on. RFB'srfb-asm-safetyonly helps transformers that already recompute; it doesn't force a recompute on a class whose stale frame a COMPUTE_MAXS-only pass baked in.Toggles:
-Dcrucible.frameSafety=falseto disable;-Dcrucible.frameSafety.scope=all(or a prefix list) to widen beyondnet.minecraft.Commit 2 — Java 24+ launch plumbing
System.setSecurityManager()always throwsUnsupportedOperationException(JEP 486); FMLTweaker only caughtSecurityExceptionand crashed. Catch it, log, continue (the FML SM only trapped rogueSystem.exit()/SM-replacement).java9args.txtminus-Djava.security.manager=allow(makes a Java 24+ JVM refuse to start) and the obsolete--illegal-access=warn.8–21→8–25.🤖 Sorry for generating it with Claude Code, one day I shall learn java