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();
|
||||
|
||||
Reference in new issue
Block a user