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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Advantages over Thermos:
+ Performance improvements
+ Updated libraries for newer plugin support
* Implemented TimingsV2
* Java 8–21 supported (using an integrated version of [lwjgl3ify](https://github.com/GTNewHorizons/lwjgl3ify))
* Java 8–25 supported (using an integrated version of [lwjgl3ify](https://github.com/GTNewHorizons/lwjgl3ify))
+ Backported Bukkit APIs (With some APIs requiring the companion mod [NecroTempus](https://github.com/CrucibleMC/NecroTempus))
+ You can see more changes in the [releases](https://github.com/CrucibleMC/Crucible/releases) changelog.

Expand Down
3 changes: 1 addition & 2 deletions java9args.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
--illegal-access=warn
-Djava.security.manager=allow
-Dfile.encoding=UTF-8
-Dcrucible.weAreJava9=true
--add-opens
Expand Down Expand Up @@ -35,4 +34,4 @@ java.desktop/com.sun.imageio.plugins.png=ALL-UNNAMED
jdk.dynalink/jdk.dynalink.beans=ALL-UNNAMED
--add-modules java.sql.rowset
--add-opens
java.sql.rowset/javax.sql.rowset.serial=ALL-UNNAMED
java.sql.rowset/javax.sql.rowset.serial=ALL-UNNAMED
14 changes: 14 additions & 0 deletions src/main/java/io/github/crucible/CrucibleConfigs.java
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ public class CrucibleConfigs extends YamlConfig {
@Comment("Prevents grass tick from loading Chunks!")
public boolean crucible_noGrassChunkLoading = true;

@Comments({"Recompute stale StackMapTables as the last transform step so Mixin-heavy packs verify",
"on modern JDKs (Java 21+). COMPUTE_MAXS-only coremod/Mixin passes can leave frames the",
"split verifier rejects (VerifyError: Expecting a stackmap frame at branch target N).",
"Safe to leave on; only Java 8 (old type-inference verifier) does not need it.",
"The -Dcrucible.frameSafety=true|false system property overrides this if set."})
public boolean crucible_asm_frameSafety = true;

@Comments({"Only recompute classes whose deobf name starts with one of these comma-separated",
"prefixes. The stale frames come from passes that target net.minecraft.*, so that is the",
"default; recomputing everything would needlessly rewrite thousands of mod classes at load.",
"Use 'all' (or empty) to recompute every class, or add prefixes to widen to a mod package.",
"The -Dcrucible.frameSafety.scope=... system property overrides this if set."})
public String crucible_asm_frameSafetyScope = "net.minecraft.";

@Comment("Let timings be turned on since the server statup!")
public boolean timings_enabledSinceServerStartup = false;

Expand Down
143 changes: 143 additions & 0 deletions src/main/java/io/github/crucible/asm/AsmFrameSafetyTransformer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package io.github.crucible.asm;

import java.io.File;
import java.io.FileOutputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

import io.github.crucible.CrucibleConfigs;
import net.minecraft.launchwrapper.IClassTransformer;
import net.minecraft.launchwrapper.Launch;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;

/**
* Recomputes StackMapTables ({@code COMPUTE_FRAMES}) as the <b>last</b> transform-chain step so classes
* left with stale frames by upstream {@code COMPUTE_MAXS} transformers pass the split bytecode
* verifier. Uses the deobf-aware, never-throwing {@link SafeAsmClassWriter}. Runs last by self-reordering
* to the tail of the LaunchClassLoader transformer list (whole-list copy-and-swap). Never breaks a class:
* any failure returns the input unchanged.
*
* <p>This complements RFB's {@code rfb-asm-safety} plugin, which only makes {@code getCommonSuperClass}
* safe <em>when a transformer already recomputes frames</em>; it does not force a recompute on a class
* (like Cauldron's patched {@code ChunkProviderServer.func_73153_a}) whose stale StackMapTable was left by
* a {@code COMPUTE_MAXS}-only coremod/Mixin pass. Reflectively loading that class (e.g. Dynmap's field
* scan) throws {@code VerifyError: Expecting a stackmap frame at branch target N}. Measured on the
* lwjgl3ify server path: the full pack fails this way on every modern JDK tested (21-25); Java 8 is
* unaffected because its verifier fails over to the old type-inference verifier for these class files.
* This transformer forces the repair.
*
* <p>Set {@code -Dcrucible.frameSafety.debug=true} to log every recompute outcome and dump the pre/post
* bytes of classes named in {@code -Dcrucible.frameSafety.debugClass=...} (default: ChunkProviderServer)
* to /tmp. Disable the whole transformer with {@code -Dcrucible.frameSafety=false}.
*/
public final class AsmFrameSafetyTransformer implements IClassTransformer {

private static final Field TRANSFORMERS_FIELD = resolveTransformersField();
private static final boolean DEBUG = Boolean.getBoolean("crucible.frameSafety.debug");
private static final String DEBUG_CLASS =
System.getProperty("crucible.frameSafety.debugClass", "ChunkProviderServer");
/**
* Only recompute classes whose (deobf) name starts with one of these comma-separated prefixes —
* the stale frames the verifier rejects come from COMPUTE_MAXS-only Mixin/coremod passes,
* which overwhelmingly target {@code net.minecraft.*}. Recomputing every class would needlessly read+
* rewrite thousands of mod classes at load time. Configured via {@code crucible.asm.frameSafetyScope}
* in Crucible.yml ({@code all} or empty = everything).
*/
private static final String[] SCOPE = parseScope(CrucibleConfigs.configs.crucible_asm_frameSafetyScope);

private static String[] parseScope(String raw) {
if (raw == null) return new String[0];
raw = raw.trim();
if (raw.isEmpty() || raw.equalsIgnoreCase("all")) return new String[0]; // empty = everything
String[] parts = raw.split(",");
for (int i = 0; i < parts.length; i++) parts[i] = parts[i].trim();
return parts;
}

private static boolean inScope(String transformedName) {
if (SCOPE.length == 0) return true; // "all"
if (transformedName == null) return false;
for (String p : SCOPE) if (!p.isEmpty() && transformedName.startsWith(p)) return true;
return false;
}

private static Field resolveTransformersField() {
try {
Field f = Launch.classLoader.getClass().getDeclaredField("transformers");
f.setAccessible(true);
return f;
} catch (Throwable t) {
return null;
}
}

@Override
public byte[] transform(String name, String transformedName, byte[] basicClass) {
ensureRunsLast();
if (basicClass == null) return null;
// never recompute our own writer/transformer, nor the external lwjgl3ify classes (which carry
// transformerExclusions and must reach the verifier untouched).
if (name != null && (name.startsWith("io.github.crucible.asm")
|| name.startsWith("me.eigenraven.lwjgl3ify"))) return basicClass;
if (!inScope(transformedName)) return basicClass;

final boolean dbg = DEBUG && transformedName != null && transformedName.contains(DEBUG_CLASS);
if (dbg) dump(transformedName + ".pre", basicClass);
try {
ClassReader reader = new ClassReader(basicClass);
SafeAsmClassWriter writer = new SafeAsmClassWriter(reader, ClassWriter.COMPUTE_FRAMES);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can use com.gtnewhorizons.retrofuturabootstrap.asm.SafeAsmClassWriter. Why do we need to re-implement it here?

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.

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..?

reader.accept(writer, 0);
byte[] out = writer.toByteArray();
if (dbg) {
dump(transformedName + ".post", out);
System.out.println("[frameSafety] recomputed OK: " + transformedName);
}
return out;
} catch (Throwable t) {
if (dbg) {
System.out.println("[frameSafety] recompute THREW for " + transformedName + ": " + t);
t.printStackTrace(System.out);
}
return basicClass;
}
}

private static void dump(String tag, byte[] bytes) {
try {
File f = new File("/tmp/framedbg-" + tag.replace('/', '.') + ".class");
FileOutputStream fos = new FileOutputStream(f);
try {
fos.write(bytes);
} finally {
fos.close();
}
System.out.println("[frameSafety] dumped " + f + " (" + bytes.length + " bytes)");
} catch (Throwable ignored) {
// no-op
}
}

@SuppressWarnings("unchecked")
private void ensureRunsLast() {
final Field f = TRANSFORMERS_FIELD;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 (f == null) return;
try {
final List<IClassTransformer> current = (List<IClassTransformer>) f.get(Launch.classLoader);
if (current == null || current.isEmpty()) return;
if (current.get(current.size() - 1) == this) return;
final int idx = current.indexOf(this);
if (idx < 0) return;
final List<IClassTransformer> next = new ArrayList<IClassTransformer>(current.size());
for (int i = 0; i < current.size(); i++) {
if (i != idx) next.add(current.get(i));
}
next.add(this);
f.set(Launch.classLoader, next);
} catch (Throwable ignored) {
// best-effort
}
}
}
Loading