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
+ * 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:
+ *
+ *
+ *
+ * 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+ * 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