diff --git a/build.gradle b/build.gradle index 0d9f692..2e992fe 100644 --- a/build.gradle +++ b/build.gradle @@ -32,10 +32,13 @@ repositories { } // Adds the Hytale server as a build dependency, allowing you to reference and -// compile against their code. This requires you to have Hytale installed using -// the official launcher for now. +// compile against their code. This requires you to have Hytale installed. +// Set hytale_server_jar in gradle.properties to override the default location. dependencies { - implementation(files("$userHome/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar")) + def hytaleJar = project.hasProperty('hytale_server_jar') + ? project.property('hytale_server_jar') + : "$userHome/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar" + implementation(files(hytaleJar)) shade "org.ow2.asm:asm:${project.asm_version}" shade "org.ow2.asm:asm-analysis:${project.asm_version}" shade "org.ow2.asm:asm-commons:${project.asm_version}" diff --git a/gradle.properties b/gradle.properties index 47ffc72..e5c6eb5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,4 +13,7 @@ version=0.0.11 description=Provides a Mixin environment for Hytale! author=Darkhax, Jaredlll08 website=build-9.com -server_version=* \ No newline at end of file +server_version=* + +# Hytale installation path (override if not using default launcher location) +hytale_server_jar=D:/Roaming/install/release/package/game/latest/Server/HytaleServer.jar \ No newline at end of file diff --git a/src/main/java/com/build_9/hyxin/HyxinTransformer.java b/src/main/java/com/build_9/hyxin/HyxinTransformer.java index 3e3ed8e..0b56c5e 100644 --- a/src/main/java/com/build_9/hyxin/HyxinTransformer.java +++ b/src/main/java/com/build_9/hyxin/HyxinTransformer.java @@ -17,7 +17,6 @@ public class HyxinTransformer implements ClassTransformer { public HyxinTransformer() { - // Captures the system class loader, and the early plugin class loader. // TODO In dev the early plugin classloader is wrong. // This is caused by Hyxin being loaded by Gradle/Idea and not through @@ -42,6 +41,9 @@ public HyxinTransformer() { MixinBootstrap.init(); + // Load Hyxin's built-in mixin config + Mixins.addConfiguration(Constants.BUILTIN_MIXIN_CONFIG); + // Load Mixin configs from plugin manifests. for (Map.Entry entry : plugins.entries().entrySet()) { if (entry.getValue().hasMixinConfigs()) { diff --git a/src/main/java/com/build_9/hyxin/LaunchEnvironment.java b/src/main/java/com/build_9/hyxin/LaunchEnvironment.java index a405277..ce1eef8 100644 --- a/src/main/java/com/build_9/hyxin/LaunchEnvironment.java +++ b/src/main/java/com/build_9/hyxin/LaunchEnvironment.java @@ -155,6 +155,13 @@ public ClassReader getClassReader(String name) throws IOException, ClassNotFound return new ClassReader(stream); } } + // Debug: Log when class can't be found + if (name.contains("Accessor") || name.contains("Mixin") || name.contains("hyxin")) { + Constants.log("[LaunchEnvironment] FAILED to find class: " + name); + Constants.log(" - runtimeLoader: " + (runtimeLoader != null ? runtimeLoader.getResource(fileName) : "null")); + Constants.log(" - earlyPluginLoader: " + (earlyPluginLoader != null ? earlyPluginLoader.getResource(fileName) : "null")); + Constants.log(" - systemLoader: " + (systemLoader != null ? systemLoader.getResource(fileName) : "null")); + } throw new ClassNotFoundException("Could not find class '" + fileName + "'."); } } \ No newline at end of file diff --git a/src/main/java/com/build_9/hyxin/impl/mixins/ExampleMixin.java b/src/main/java/com/build_9/hyxin/impl/mixins/ExampleMixin.java index 44be4da..10ba164 100644 --- a/src/main/java/com/build_9/hyxin/impl/mixins/ExampleMixin.java +++ b/src/main/java/com/build_9/hyxin/impl/mixins/ExampleMixin.java @@ -19,4 +19,4 @@ private static void onMain(CallbackInfo ci) { logger.at(Level.INFO).log("Hello from Hyxin! The server has been patched!"); logger.at(Level.INFO).log("Scanning for plugins in '" + new File("./earlyplugins").getAbsolutePath() + "'."); } -} \ No newline at end of file +} diff --git a/src/main/java/com/build_9/hyxin/mixin/BytecodeProvider.java b/src/main/java/com/build_9/hyxin/mixin/BytecodeProvider.java index ebd31fb..53f355e 100644 --- a/src/main/java/com/build_9/hyxin/mixin/BytecodeProvider.java +++ b/src/main/java/com/build_9/hyxin/mixin/BytecodeProvider.java @@ -21,10 +21,37 @@ public ClassNode getClassNode(String name, boolean runTransformers) throws Class @Override public ClassNode getClassNode(String name, boolean runTransformers, int readerFlags) throws ClassNotFoundException, IOException { - // TODO Consider how to handle runTransformers - final ClassReader reader = LaunchEnvironment.get().getClassReader(name); - final ClassNode node = new ClassNode(); - reader.accept(node, readerFlags); - return node; + // First check if this is a synthetic class + byte[] syntheticBytes = SyntheticClassRegistry.get().getSyntheticClassBytes(name); + if (syntheticBytes != null) { + final ClassReader reader = new ClassReader(syntheticBytes); + final ClassNode node = new ClassNode(); + reader.accept(node, readerFlags); + return node; + } + + // Otherwise, load from the class loaders + try { + final ClassReader reader = LaunchEnvironment.get().getClassReader(name); + final ClassNode node = new ClassNode(); + reader.accept(node, readerFlags); + return node; + } catch (ClassNotFoundException e) { + // Try to generate the class if it's a mixin-generated synthetic + if (MixinService.transformer != null) { + byte[] generatedBytes = MixinService.transformer.generateClass( + org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment(), + name + ); + if (generatedBytes != null) { + SyntheticClassRegistry.get().registerSyntheticClass(name, generatedBytes); + final ClassReader reader = new ClassReader(generatedBytes); + final ClassNode node = new ClassNode(); + reader.accept(node, readerFlags); + return node; + } + } + throw e; + } } } diff --git a/src/main/java/com/build_9/hyxin/mixin/ClassProvider.java b/src/main/java/com/build_9/hyxin/mixin/ClassProvider.java index b2891a4..097596a 100644 --- a/src/main/java/com/build_9/hyxin/mixin/ClassProvider.java +++ b/src/main/java/com/build_9/hyxin/mixin/ClassProvider.java @@ -14,16 +14,34 @@ public URL[] getClassPath() { @Override public Class findClass(String name) throws ClassNotFoundException { - return LaunchEnvironment.get().findLoaderForClass(name).loadClass(name); + return findClassInternal(name, true); } @Override public Class findClass(String name, boolean initialize) throws ClassNotFoundException { - return Class.forName(name, initialize, LaunchEnvironment.get().findLoaderForClass(name)); + return findClassInternal(name, initialize); } @Override public Class findAgentClass(String name, boolean initialize) throws ClassNotFoundException { return this.findClass(name, initialize); } + + /** + * Internal method to find a class, checking for synthetic classes if the + * class cannot be found through normal class loading. + */ + private Class findClassInternal(String name, boolean initialize) throws ClassNotFoundException { + try { + ClassLoader loader = LaunchEnvironment.get().findLoaderForClass(name); + return Class.forName(name, initialize, loader); + } catch (ClassNotFoundException e) { + // Class not found on disk - check if it's a synthetic class (accessor/invoker) + Class syntheticClass = SyntheticClassRegistry.get().findSyntheticClass(name); + if (syntheticClass != null) { + return syntheticClass; + } + throw e; + } + } } \ No newline at end of file diff --git a/src/main/java/com/build_9/hyxin/mixin/SyntheticClassRegistry.java b/src/main/java/com/build_9/hyxin/mixin/SyntheticClassRegistry.java new file mode 100644 index 0000000..4bff2a4 --- /dev/null +++ b/src/main/java/com/build_9/hyxin/mixin/SyntheticClassRegistry.java @@ -0,0 +1,470 @@ +package com.build_9.hyxin.mixin; + +import com.build_9.hyxin.Constants; +import com.build_9.hyxin.LaunchEnvironment; +import org.spongepowered.asm.mixin.MixinEnvironment; + +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.security.ProtectionDomain; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Registry for synthetic classes generated by the Mixin framework. + *

+ * Accessor and Invoker mixins require synthetic classes to be generated at runtime. + * Since Hytale's TransformingClassLoader only transforms classes that exist on disk, + * we need to intercept class loading for these synthetic classes and define them + * ourselves. + *

+ *

+ * This registry uses multiple strategies to define classes, falling back through + * each one if the previous fails: + *

    + *
  1. MethodHandles.Lookup.defineClass() - preferred, requires a host class in the same package
  2. + *
  3. Reflection on ClassLoader.defineClass() - fallback for module access issues
  4. + *
+ *

+ * + *

Thread Safety

+ *

+ * This class is thread-safe. All public methods can be safely called from multiple threads. + * A lock is used during class definition to prevent duplicate definitions when multiple + * threads race to define the same class. + *

+ */ +public class SyntheticClassRegistry { + + private static final SyntheticClassRegistry INSTANCE = new SyntheticClassRegistry(); + + /** + * Cache of generated synthetic class bytecode, keyed by fully qualified class name. + */ + private final Map syntheticClasses = new ConcurrentHashMap<>(); + + /** + * Cache of already-defined classes to avoid duplicate definition attempts. + */ + private final Map> definedClasses = new ConcurrentHashMap<>(); + + /** + * Per-class locks to prevent race conditions during class definition. + * Using a map of locks allows different classes to be defined concurrently + * while preventing duplicate definitions of the same class. + */ + private final Map classLocks = new ConcurrentHashMap<>(); + + /** + * Cached reflection method for ClassLoader.defineClass. + * Lazily initialized on first use. + */ + private volatile Method defineClassMethod; + + private SyntheticClassRegistry() { + } + + /** + * Returns the singleton instance of the registry. + * + * @return The global SyntheticClassRegistry instance. + */ + public static SyntheticClassRegistry get() { + return INSTANCE; + } + + /** + * Registers a synthetic class with its generated bytecode. + *

+ * This method is called by the BytecodeProvider when Mixin generates + * a synthetic class. The bytecode is cached for later definition. + *

+ * + * @param name The fully qualified class name (e.g., "com.example.MyAccessor"). + * @param bytes The generated bytecode. Must not be null or empty. + * @throws IllegalArgumentException if name is null or empty. + */ + public void registerSyntheticClass(String name, byte[] bytes) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("Class name cannot be null or empty"); + } + if (bytes != null && bytes.length > 0) { + Constants.log("Registering synthetic class: " + name); + syntheticClasses.put(name, bytes.clone()); // Defensive copy + } + } + + /** + * Checks if a class is registered as synthetic. + * + * @param name The fully qualified class name. + * @return true if the class is a registered synthetic class. + */ + public boolean isSyntheticClass(String name) { + return name != null && syntheticClasses.containsKey(name); + } + + /** + * Gets the bytecode for a synthetic class. + * + * @param name The fully qualified class name. + * @return A copy of the bytecode, or null if not found. + */ + public byte[] getSyntheticClassBytes(String name) { + byte[] bytes = name != null ? syntheticClasses.get(name) : null; + return bytes != null ? bytes.clone() : null; // Defensive copy + } + + /** + * Attempts to find or define a synthetic class. + *

+ * This method first checks if the class has already been defined. If not, + * it attempts to generate the class via Mixin's transformer, then defines + * it into the JVM. + *

+ *

+ * This method is thread-safe - multiple threads calling this method with + * the same class name will result in only one definition attempt. + *

+ * + * @param name The fully qualified class name. + * @return The Class object, or null if this is not a synthetic class. + * @throws ClassNotFoundException if the class is synthetic but cannot be defined. + */ + public Class findSyntheticClass(String name) throws ClassNotFoundException { + if (name == null || name.isEmpty()) { + return null; + } + + // Fast path: check if already defined (no locking needed) + Class existing = definedClasses.get(name); + if (existing != null) { + return existing; + } + + // Get or create a lock for this specific class + ReentrantLock lock = classLocks.computeIfAbsent(name, k -> new ReentrantLock()); + lock.lock(); + try { + // Double-check after acquiring lock + existing = definedClasses.get(name); + if (existing != null) { + return existing; + } + + // Try to generate the class via Mixin + byte[] bytes = tryGenerateClass(name); + + if (bytes != null) { + return defineClassWithFallback(name, bytes); + } + + // Check if we have cached bytecode (registered earlier) + byte[] cachedBytes = syntheticClasses.get(name); + if (cachedBytes != null) { + return defineClassWithFallback(name, cachedBytes); + } + + // Not a synthetic class + return null; + + } finally { + lock.unlock(); + // Clean up lock if no longer needed (optional, prevents memory leak for many classes) + // We leave it in the map for potential reuse + } + } + + /** + * Attempts to generate a synthetic class via Mixin's transformer. + * + * @param name The fully qualified class name. + * @return The generated bytecode, or null if generation failed or not applicable. + */ + private byte[] tryGenerateClass(String name) { + if (MixinService.transformer == null) { + Constants.log("Warning: MixinService.transformer is null, cannot generate synthetic class: " + name); + return null; + } + + try { + MixinEnvironment env = MixinEnvironment.getCurrentEnvironment(); + if (env == null) { + Constants.log("Warning: MixinEnvironment is null, cannot generate synthetic class: " + name); + return null; + } + + byte[] bytes = MixinService.transformer.generateClass(env, name); + + if (bytes != null && bytes.length > 0) { + // Cache the generated bytecode + syntheticClasses.put(name, bytes.clone()); + Constants.log("Generated synthetic class bytecode: " + name + " (" + bytes.length + " bytes)"); + } + + return bytes; + + } catch (Exception e) { + Constants.log("Warning: Failed to generate synthetic class " + name + ": " + e.getMessage()); + return null; + } + } + + /** + * Defines a class using the best available method, with fallbacks. + * + * @param name The fully qualified class name. + * @param bytes The bytecode. + * @return The defined Class. + * @throws ClassNotFoundException if all definition methods fail. + */ + private Class defineClassWithFallback(String name, byte[] bytes) throws ClassNotFoundException { + ClassLoader targetLoader = getTargetClassLoader(); + Exception lastException = null; + + // Strategy 1: Try MethodHandles.Lookup.defineClass() + try { + Class result = defineViaMethodHandles(name, bytes, targetLoader); + if (result != null) { + definedClasses.put(name, result); + Constants.log("Defined synthetic class via MethodHandles: " + name); + return result; + } + } catch (Exception e) { + lastException = e; + Constants.log("MethodHandles defineClass failed for " + name + ": " + e.getMessage()); + } + + // Strategy 2: Try reflection on ClassLoader.defineClass() + try { + Class result = defineViaReflection(name, bytes, targetLoader); + if (result != null) { + definedClasses.put(name, result); + Constants.log("Defined synthetic class via reflection: " + name); + return result; + } + } catch (Exception e) { + lastException = e; + Constants.log("Reflection defineClass failed for " + name + ": " + e.getMessage()); + } + + // All strategies failed + String message = "Failed to define synthetic class: " + name; + if (lastException != null) { + throw new ClassNotFoundException(message, lastException); + } + throw new ClassNotFoundException(message); + } + + /** + * Gets the target class loader for defining synthetic classes. + * + * @return The appropriate ClassLoader, never null. + */ + private ClassLoader getTargetClassLoader() { + LaunchEnvironment env = LaunchEnvironment.get(); + + // Prefer the runtime loader (Hytale's TransformingClassLoader) + ClassLoader loader = env.getRuntimeLoader(); + if (loader != null) { + return loader; + } + + // Fall back to early plugin loader + loader = env.getEarlyPluginLoader(); + if (loader != null) { + return loader; + } + + // Last resort: context class loader + loader = Thread.currentThread().getContextClassLoader(); + if (loader != null) { + return loader; + } + + // Ultimate fallback: system class loader + return ClassLoader.getSystemClassLoader(); + } + + /** + * Defines a class using MethodHandles.Lookup.defineClass(). + *

+ * This is the preferred method on modern JVMs as it respects module boundaries + * and doesn't require illegal reflective access. + *

+ * + * @param name The fully qualified class name. + * @param bytes The bytecode. + * @param loader The target class loader. + * @return The defined Class, or null if this strategy is not applicable. + * @throws Exception if definition fails. + */ + private Class defineViaMethodHandles(String name, byte[] bytes, ClassLoader loader) throws Exception { + // Find a host class in the same package to get a Lookup with proper access + Class hostClass = findHostClass(name, loader); + if (hostClass == null) { + return null; + } + + try { + MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(hostClass, MethodHandles.lookup()); + return lookup.defineClass(bytes); + } catch (IllegalAccessException | IllegalArgumentException e) { + // These exceptions indicate this strategy won't work + throw e; + } + } + + /** + * Finds a suitable host class in the same package as the target class. + *

+ * For accessor mixins, the target class (e.g., HytaleServer) should already + * be loaded and can serve as the host class. + *

+ * + * @param name The fully qualified class name of the synthetic class. + * @param loader The class loader to search. + * @return A host class in the same package, or null if none found. + */ + private Class findHostClass(String name, ClassLoader loader) { + String packageName = getPackageName(name); + if (packageName.isEmpty()) { + return null; // Default package, can't use MethodHandles approach + } + + // For accessor mixins, try to find the target class + // The synthetic accessor class is usually in the mixin's package, but + // implements an interface that the target class will also implement. + // We can try loading known classes from the package. + + // Strategy 1: Try package-info class + try { + return Class.forName(packageName + ".package-info", false, loader); + } catch (ClassNotFoundException ignored) { + } + + // Strategy 2: For Hyxin mixins, try finding a class we know exists + // This is a heuristic - accessor mixins are typically in the mixin package + if (packageName.startsWith("com.build_9.hyxin")) { + try { + return Class.forName("com.build_9.hyxin.Constants", false, loader); + } catch (ClassNotFoundException ignored) { + } + } + + // Strategy 3: Try the parent package with a common class + // This helps when the accessor is in a sub-package + int lastDot = packageName.lastIndexOf('.'); + if (lastDot > 0) { + String parentPackage = packageName.substring(0, lastDot); + try { + return Class.forName(parentPackage + ".package-info", false, loader); + } catch (ClassNotFoundException ignored) { + } + } + + return null; + } + + /** + * Defines a class using reflection on ClassLoader.defineClass(). + *

+ * This is a fallback method that uses reflection to access the protected + * defineClass method. It may trigger illegal reflective access warnings + * on modern JVMs. + *

+ * + * @param name The fully qualified class name. + * @param bytes The bytecode. + * @param loader The target class loader. + * @return The defined Class. + * @throws Exception if definition fails. + */ + private Class defineViaReflection(String name, byte[] bytes, ClassLoader loader) throws Exception { + Method method = getDefineClassMethod(); + + // Make the method accessible (may trigger warnings on Java 9+) + method.setAccessible(true); + + return (Class) method.invoke( + loader, + name, + bytes, + 0, + bytes.length, + (ProtectionDomain) null + ); + } + + /** + * Gets or initializes the cached reflection Method for ClassLoader.defineClass. + * + * @return The defineClass Method. + * @throws NoSuchMethodException if the method cannot be found. + */ + private Method getDefineClassMethod() throws NoSuchMethodException { + Method method = defineClassMethod; + if (method == null) { + synchronized (this) { + method = defineClassMethod; + if (method == null) { + // Use the version with ProtectionDomain for better compatibility + method = ClassLoader.class.getDeclaredMethod( + "defineClass", + String.class, + byte[].class, + int.class, + int.class, + ProtectionDomain.class + ); + defineClassMethod = method; + } + } + } + return method; + } + + /** + * Extracts the package name from a fully qualified class name. + * + * @param className The fully qualified class name. + * @return The package name, or empty string if in the default package. + */ + private static String getPackageName(String className) { + int lastDot = className.lastIndexOf('.'); + return lastDot > 0 ? className.substring(0, lastDot) : ""; + } + + /** + * Clears all cached data from the registry. + *

+ * Warning: This method is primarily intended for testing purposes. + * Clearing the registry while the application is running may cause + * ClassNotFoundException errors for already-referenced synthetic classes. + *

+ */ + public void clear() { + syntheticClasses.clear(); + definedClasses.clear(); + classLocks.clear(); + } + + /** + * Returns the number of registered synthetic classes (for debugging/monitoring). + * + * @return The number of registered synthetic class bytecodes. + */ + public int getRegisteredCount() { + return syntheticClasses.size(); + } + + /** + * Returns the number of defined synthetic classes (for debugging/monitoring). + * + * @return The number of classes that have been defined into the JVM. + */ + public int getDefinedCount() { + return definedClasses.size(); + } +} diff --git a/src/main/resources/hyxin.mixin.json b/src/main/resources/hyxin.mixin.json index 72d4cd5..b7ad6bc 100644 --- a/src/main/resources/hyxin.mixin.json +++ b/src/main/resources/hyxin.mixin.json @@ -4,5 +4,8 @@ "package": "com.build_9.hyxin.impl.mixins", "mixins": [ "ExampleMixin" - ] + ], + "injectors": { + "defaultRequire": 1 + } } \ No newline at end of file