UPDATE
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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user