Add Linux and macOS support, batch class transformations with a disk cache, and add cross-platform release packaging. Also improve runtime namespace handling, keybind editing, and startup behavior.
This commit is contained in:
bbldrizzy committed 2026-10-27 20:30:09 +00:00
1 parent 910a126f04
commit 38ff6d387c
29 files changed
+2234 -491

No files matched your search

@@ -32,12 +32,22 @@ public final class Bootstrap {
public static synchronized void start() {
if (started) return;
started = true;
if (net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("prestige")) {
// the jar is also installed in mods/: Mixin already applied the hooks and Fabric ran the entrypoints
log("Prestige is already loaded as a Fabric mod, skipping injection");
return;
}
try {
log("waiting for the Minecraft client");
waitForClient();
Report report = install(NativeBridge::classBytes, NativeBridge::redefine);
Report report = install(NativeBridge::classBytes, NativeBridge::redefine, nativeBatch());
log("hooks installed: " + report.redefined + " classes, " + report.moved + " handlers moved" + (report.failed.isEmpty() ? "" : ", FAILED " + report.failed));
if (!report.failed.isEmpty()) throw new IllegalStateException("could not hook " + report.failed);
// Some clients (Lunar) name a few classes and methods differently. Keep going with the hooks that did apply: a missing
// hook only disables the features behind it, while stopping here would leave the client without Prestige at all.
if (!report.failed.isEmpty()) {
if (report.redefined == 0) throw new IllegalStateException("could not hook " + report.failed);
log("continuing with " + report.redefined + " hooked classes; skipped " + report.failed);
}
log("initialising Prestige on the client thread");
MinecraftClient.getInstance().execute(Bootstrap::initClient);
} catch (Throwable t) {
@@ -66,7 +76,39 @@ public final class Bootstrap {
* without the native library.
*/
public static Report install(Function<Class<?>, byte[]> classBytes, ToIntBiFunction<Class<?>, byte[]> redefine) throws Exception {
String namespace = FabricLoader.getInstance().getMappingResolver().getCurrentRuntimeNamespace();
return install(classBytes, redefine, null);
}
/** Many classes per native call: one RetransformClasses / RedefineClasses (one safepoint each) instead of one per class. */
public interface Batch {
byte[][] classBytes(Class<?>[] types);
int redefine(Class<?>[] types, byte[][] bytes);
}
/** The batch natives of {@link NativeBridge}, or null when the loaded prestige_native.dll predates them. */
private static Batch nativeBatch() {
try {
NativeBridge.classBytesBatch(new Class<?>[0]); // probes the registration; a missing native throws UnsatisfiedLinkError
} catch (UnsatisfiedLinkError e) {
return null;
} catch (Throwable ignored) {
// anything else means the native exists
}
return new Batch() {
@Override public byte[][] classBytes(Class<?>[] types) { return NativeBridge.classBytesBatch(types); }
@Override public int redefine(Class<?>[] types, byte[][] bytes) { return NativeBridge.redefineBatch(types, bytes); }
};
}
/**
* Same as {@link #install(Function, ToIntBiFunction)} with an optional batch path: all original bytes are captured with one
* call, the ClassTransform step reuses cached results where it can, and all hooked classes are redefined with one call. A
* failing batch step falls back to per-class calls, so one bad class only fails itself.
*/
public static Report install(Function<Class<?>, byte[]> classBytes, ToIntBiFunction<Class<?>, byte[]> redefine, Batch batch) throws Exception {
String namespace = EnginePlugin.runtimeNamespace();
log("runtime namespace " + namespace + " (the loader reports " + FabricLoader.getInstance().getMappingResolver().getCurrentRuntimeNamespace() + ")");
List<String> transformers = new ArrayList<>(EnginePlugin.lines("prestige-engine/transformers.list"));
transformers.addAll(EnginePlugin.lines("prestige-engine/inject-transformers.list"));
TransformerManager manager = EnginePlugin.createManager(namespace, transformers);
@@ -75,31 +117,106 @@ public final class Bootstrap {
EngineState.installed = true;
Report report = new Report();
int total = manager.getTransformedClasses().size(), done = 0;
List<String> targets = new ArrayList<>(manager.getTransformedClasses());
int total = targets.size();
log("transforming " + total + " hook targets");
for (String target : manager.getTransformedClasses()) {
if (done > 0 && done % 10 == 0) log("transformed " + done + "/" + total);
done++;
long start = System.nanoTime();
// 1. resolve the target classes
Class<?>[] types = new Class<?>[total];
for (int i = 0; i < total; i++) {
try {
Class<?> type = Class.forName(target, false, loader);
byte[] current = classBytes.apply(type);
if (current == null) throw new IllegalStateException("no class bytes");
InPlaceTransformer.Result result = transformer.transform(target, current);
for (byte[] helper : result.helpers().values()) Definer.define(helper);
int code = redefine.applyAsInt(type, result.target());
if (code != 0) throw new IllegalStateException("redefine returned JVMTI error " + code);
ORIGINALS.put(type, current);
report.redefined++;
report.moved += result.movedMethods();
types[i] = Class.forName(targets.get(i), false, loader);
} catch (Throwable t) {
report.failed.add(target);
log("hook failed for " + target + ": " + t);
t.printStackTrace(System.out);
fail(report, targets.get(i), t);
}
}
// 2. the bytes each class currently has: one native call for all of them, per-class for any that did not come back
byte[][] originals = new byte[total][];
if (batch != null) {
Class<?>[] resolved = java.util.Arrays.stream(types).filter(java.util.Objects::nonNull).toArray(Class<?>[]::new);
byte[][] captured = resolved.length == 0 ? null : batch.classBytes(resolved);
if (captured != null && captured.length == resolved.length) {
int next = 0;
for (int i = 0; i < total; i++) if (types[i] != null) originals[i] = captured[next++];
}
}
for (int i = 0; i < total; i++) {
if (types[i] != null && originals[i] == null) originals[i] = classBytes.apply(types[i]);
}
long captured = System.nanoTime();
// 3. ClassTransform, one class at a time (its mapper is not thread-safe: running it on several threads corrupts its state).
// A result that is already in the disk cache skips the transformation, which is where nearly all of the time goes.
InPlaceTransformer.Result[] results = new InPlaceTransformer.Result[total];
TransformCache cache = TransformCache.open();
int cacheHits = 0;
for (int i = 0; i < total; i++) {
if (types[i] == null) continue;
String target = targets.get(i);
if (originals[i] == null) {
fail(report, target, new IllegalStateException("no class bytes"));
continue;
}
try {
byte[] hit = cache == null ? null : cache.get(target, originals[i]);
if (hit != null) {
results[i] = new InPlaceTransformer.Result(hit, Map.of(), 0);
cacheHits++;
continue;
}
results[i] = transformer.transform(target, originals[i]);
if (cache != null && results[i].helpers().isEmpty()) cache.put(target, originals[i], results[i].target());
} catch (Throwable t) {
fail(report, target, t);
}
}
long transformed = System.nanoTime();
// 4. helper classes first, then redefine everything in one call (all or nothing); on an error redefine one by one
List<Integer> ready = new ArrayList<>();
for (int i = 0; i < total; i++) {
if (results[i] == null) continue;
try {
for (byte[] helper : results[i].helpers().values()) Definer.define(helper);
ready.add(i);
} catch (Throwable t) {
fail(report, targets.get(i), t);
}
}
boolean redefinedAll = false;
if (batch != null && !ready.isEmpty()) {
Class<?>[] batchTypes = ready.stream().map(i -> types[i]).toArray(Class<?>[]::new);
byte[][] batchBytes = ready.stream().map(i -> results[i].target()).toArray(byte[][]::new);
redefinedAll = batch.redefine(batchTypes, batchBytes) == 0;
}
for (int i : ready) {
try {
if (!redefinedAll) {
int code = redefine.applyAsInt(types[i], results[i].target());
if (code != 0) throw new IllegalStateException("redefine returned JVMTI error " + code);
}
ORIGINALS.put(types[i], originals[i]);
report.redefined++;
report.moved += results[i].movedMethods();
} catch (Throwable t) {
fail(report, targets.get(i), t);
}
}
long end = System.nanoTime();
log(String.format("timing: bytes %d ms, transform %d ms (%d of %d from cache), redefine %d ms%s",
(captured - start) / 1_000_000, (transformed - captured) / 1_000_000, cacheHits, total, (end - transformed) / 1_000_000,
batch == null ? " [per-class natives]" : redefinedAll ? "" : " [batch redefine refused, per-class]"));
return report;
}
private static void fail(Report report, String target, Throwable t) {
report.failed.add(target);
log("hook failed for " + target + ": " + t);
t.printStackTrace(System.out);
}
/** True when this client was injected, i.e. hooks were applied through JVMTI and can be taken out again. */
public static boolean isInjected() {
return !ORIGINALS.isEmpty();
@@ -39,7 +39,7 @@ public final class InPlaceTransformer {
private final TransformerManager manager;
private final ClassLoader loader;
private int sequence;
private final java.util.concurrent.atomic.AtomicInteger sequence = new java.util.concurrent.atomic.AtomicInteger();
public InPlaceTransformer(TransformerManager manager, ClassLoader loader) {
this.manager = manager;
@@ -67,7 +67,7 @@ public final class InPlaceTransformer {
if (after.interfaces.size() != before.interfaces.size())
throw new IllegalStateException(dottedName + ": transformer changed the interface list");
String helperName = HOOK_PACKAGE + "H" + (sequence++) + "_" + after.name.substring(after.name.lastIndexOf('/') + 1).replace('$', '_');
String helperName = HOOK_PACKAGE + "H" + (sequence.getAndIncrement()) + "_" + after.name.substring(after.name.lastIndexOf('/') + 1).replace('$', '_');
ClassNode helper = new ClassNode();
helper.version = after.version;
helper.access = Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER;
@@ -7,6 +7,12 @@ public final class NativeBridge {
/** Bytes of the loaded class as the JVM currently has it (including changes made by earlier transformers). */
public static native byte[] classBytes(Class<?> type);
/** classBytes for many classes with one RetransformClasses call; entries are null where nothing was captured, null on failure. */
public static native byte[][] classBytesBatch(Class<?>[] types);
/** RedefineClasses for many classes in one call, all or nothing; returns the JVMTI error code, 0 on success. */
public static native int redefineBatch(Class<?>[] types, byte[][] bytes);
/** Appends a line to prestige-native.log, which the injector tails to show progress. */
public static native void log(String message);
@@ -0,0 +1,94 @@
package dev.zprestige.prestige.inject;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.stream.Stream;
/**
* Disk cache of finished ClassTransform results. The ClassTransform step is by far the slowest part of an injection, and its
* output only depends on the payload jar and on the class bytes it is given, so a repeat injection into the same game setup can
* skip it. Entries are keyed by SHA-256(payload jar, target name, original class bytes); a rebuilt payload or a changed modpack
* simply misses. Only results that need no helper classes are stored.
*/
final class TransformCache {
private static final long MAX_AGE_MILLIS = 14L * 24 * 60 * 60 * 1000;
private final Path dir;
private final byte[] payloadId;
private TransformCache(Path dir, byte[] payloadId) {
this.dir = dir;
this.payloadId = payloadId;
}
/** The cache for the payload jar this class was loaded from, or null when it cannot be used. */
static TransformCache open() {
try {
Path jar = Path.of(Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (!Files.isRegularFile(jar)) return null;
Path dir = Path.of(System.getProperty("java.io.tmpdir"), "PrestigeLoader", "cache");
Files.createDirectories(dir);
prune(dir);
return new TransformCache(dir, MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(jar)));
} catch (Throwable t) {
return null;
}
}
/** Cached transformed bytes of {@code target}, or null. */
byte[] get(String target, byte[] original) {
try {
Path file = file(target, original);
if (!Files.isRegularFile(file)) return null;
byte[] bytes = Files.readAllBytes(file);
if (bytes.length < 4 || (bytes[0] & 0xFF) != 0xCA || (bytes[1] & 0xFF) != 0xFE || (bytes[2] & 0xFF) != 0xBA || (bytes[3] & 0xFF) != 0xBE) return null;
Files.setLastModifiedTime(file, FileTime.from(Instant.now()));
return bytes;
} catch (Throwable t) {
return null;
}
}
void put(String target, byte[] original, byte[] transformed) {
try {
Path file = file(target, original);
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(temp, transformed);
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (Throwable ignored) {
// the cache is only an optimisation
}
}
private Path file(String target, byte[] original) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(payloadId);
digest.update(target.getBytes(java.nio.charset.StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(original);
StringBuilder name = new StringBuilder();
for (byte b : digest.digest()) name.append(String.format("%02x", b));
return dir.resolve(name + ".class");
}
/** Drops entries that have not been used for two weeks (every rebuild of the payload starts a fresh set). */
private static void prune(Path dir) {
try (Stream<Path> files = Files.list(dir)) {
long limit = System.currentTimeMillis() - MAX_AGE_MILLIS;
files.forEach(file -> {
try {
if (Files.getLastModifiedTime(file).toMillis() < limit) Files.deleteIfExists(file);
} catch (IOException ignored) {
// leave it
}
});
} catch (Throwable ignored) {
// leave it
}
}
}