diff --git a/common.gradle b/common.gradle index 05400cdaac..f70c792ae9 100644 --- a/common.gradle +++ b/common.gradle @@ -24,6 +24,8 @@ java { } } +apply from: rootProject.file('gradle/native-image-metadata.gradle') + tasks.withType(JavaCompile) { // compile-time options: //options.compilerArgs << '-Xlint:deprecation' // to show deprecation warnings options.compilerArgs << '-Xlint:unchecked' diff --git a/gradle/native-image-metadata.gradle b/gradle/native-image-metadata.gradle new file mode 100644 index 0000000000..3604995b2c --- /dev/null +++ b/gradle/native-image-metadata.gradle @@ -0,0 +1,567 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.jvm.toolchain.JvmVendorSpec + +def extraProperties = project.extensions.extraProperties +[ + 'jmeNativeMetadataAdditionalTargetTypes', + 'jmeNativeMetadataAdditionalTargetAnnotations', + 'jmeNativeMetadataAdditionalProxyInterfaceSets', + 'jmeNativeMetadataAdditionalCloneMethodTypes', + 'jmeNativeMetadataAdditionalResourceGlobs', + 'jmeNativeMetadataGraalToolchainLanguageVersion', + 'jmeNativeImageIncludeResourcesPattern', + 'jmeNativeImageExcludeResourcesPattern' +].each { propertyName -> + if (!extraProperties.has(propertyName)) { + if (propertyName == 'jmeNativeMetadataGraalToolchainLanguageVersion') { + extraProperties.set(propertyName, 21) + } else if (propertyName == 'jmeNativeImageIncludeResourcesPattern') { + extraProperties.set(propertyName, '.*') + } else if (propertyName == 'jmeNativeImageExcludeResourcesPattern') { + extraProperties.set(propertyName, '(?i).*\\.(class|jar|dylib|so|dll|jnilib)$') + } else { + extraProperties.set(propertyName, []) + } + } +} + +if (!extraProperties.has('jmeApplyDefaultNativeImageResourceSettings')) { + extraProperties.set('jmeApplyDefaultNativeImageResourceSettings', { Object nativeBinary -> + if (nativeBinary == null) { + return + } + + String includeResourcesPattern = project.ext.jmeNativeImageIncludeResourcesPattern?.toString() ?: '.*' + String excludeResourcesPattern = project.ext.jmeNativeImageExcludeResourcesPattern?.toString() ?: '(?i).*\\.(class|jar|dylib|so|dll|jnilib)$' + + nativeBinary.resources.autodetect() + nativeBinary.buildArgs.add("-H:IncludeResources=${includeResourcesPattern}") + nativeBinary.buildArgs.add("-H:ExcludeResources=${excludeResourcesPattern}") + }) +} + +def defaultTargetTypes = [ + 'java.lang.Cloneable', + 'com.jme3.export.Savable', + 'com.jme3.asset.AssetLoader', + 'com.jme3.asset.AssetLocator', + 'com.jme3.asset.cache.AssetCache', + 'com.jme3.asset.AssetProcessor', + 'com.jme3.scene.control.Control', + 'com.jme3.post.Filter', + 'com.jme3.post.SceneProcessor', + 'com.jme3.util.clone.JmeCloneable', + 'com.jme3.anim.util.JointModelTransform', + 'com.jme3.util.struct.Struct', + 'com.jme3.material.logic.TechniqueDefLogic', + 'com.jme3.system.JmeSystemDelegate', + 'com.jme3.system.JmeContext', + 'com.jme3.system.JmeDialogsFactory', + 'com.jme3.util.BufferAllocator', + 'com.jme3.util.res.ResourceLoader', + 'com.jme3.plugins.json.JsonParser', + 'com.jme3.audio.openal.AL', + 'com.jme3.audio.openal.ALC', + 'com.jme3.audio.openal.EFX', + 'com.jme3.opencl.PlatformChooser', + 'com.jme3.network.Message', + 'com.jme3.network.serializing.Serializer', + 'com.jme3.app.Application' +] + +def defaultTargetAnnotations = [ + 'com.jme3.network.serializing.Serializable' +] + +def defaultCloneMethodTypes = [ + 'java.util.ArrayList', + 'java.util.BitSet', + 'java.util.Date', + 'java.util.EnumMap', + 'java.util.EnumSet', + 'java.util.HashMap', + 'java.util.HashSet', + 'java.util.Hashtable', + 'java.util.IdentityHashMap', + 'java.util.LinkedHashMap', + 'java.util.LinkedHashSet', + 'java.util.LinkedList', + 'java.util.PriorityQueue', + 'java.util.Stack', + 'java.util.TreeMap', + 'java.util.TreeSet', + 'java.util.Vector', + 'java.util.concurrent.CopyOnWriteArrayList', + 'java.util.concurrent.CopyOnWriteArraySet', + 'com.jme3.util.SafeArrayList' +] + +def defaultProxyInterfaceSets = [ + [ + 'com.jme3.renderer.opengl.GL', + 'com.jme3.renderer.opengl.GL2', + 'com.jme3.renderer.opengl.GL3', + 'com.jme3.renderer.opengl.GL4' + ], + [ + 'com.jme3.renderer.opengl.GL', + 'com.jme3.renderer.opengl.GLES_30', + 'com.jme3.renderer.opengl.GLFbo', + 'com.jme3.renderer.opengl.GLExt' + ], + [ + 'com.jme3.renderer.opengl.GL', + 'com.jme3.renderer.opengl.GLExt', + 'com.jme3.renderer.opengl.GLFbo' + ], + [ + 'com.jme3.renderer.opengl.GLExt' + ], + [ + 'com.jme3.renderer.opengl.GLFbo' + ] +] + +def asStringList = { Object value -> + if (value == null) { + return [] + } + if (value instanceof CharSequence) { + return [value.toString()] + } + if (value.getClass().isArray()) { + return value.toList().findAll { it != null }.collect { it.toString() } + } + if (value instanceof Collection) { + return value.findAll { it != null }.collect { it.toString() } + } + return [value.toString()] +} + +def asStringMatrix = { Object value -> + if (!(value instanceof Collection) && !(value != null && value.getClass().isArray())) { + return [] + } + def outer = value instanceof Collection ? value : value.toList() + return outer.collect { entry -> + asStringList(entry).findAll { !it.isEmpty() } + }.findAll { !it.isEmpty() } +} + +def loadClassSafely = { ClassLoader loader, String className -> + try { + return Class.forName(className, false, loader) + } catch (Throwable ignored) { + return null + } +} + +def isConstructible = { Class clazz -> + int modifiers = clazz.modifiers + return !clazz.isInterface() && !clazz.isAnnotation() && !clazz.isAnonymousClass() && !clazz.isLocalClass() && !java.lang.reflect.Modifier.isAbstract(modifiers) +} + +def safeAssignableFrom = { Class target, Class clazz -> + try { + return target.isAssignableFrom(clazz) + } catch (Throwable ignored) { + return false + } +} + +def safeAnnotationPresent = { Class clazz, Class annotationClass -> + try { + return clazz.isAnnotationPresent(annotationClass) + } catch (Throwable ignored) { + return false + } +} + +def safeSuperclass = { Class clazz -> + try { + return clazz.superclass + } catch (Throwable ignored) { + return null + } +} + +def hasPublicNoArgCloneMethod = { Class clazz -> + try { + clazz.getMethod('clone') + return true + } catch (Throwable ignored) { + return false + } +} + +def binaryClassName = { File classesDir, File classFile -> + String relativePath = classesDir.toPath().relativize(classFile.toPath()).toString().replace(File.separatorChar, (char) '/') + if (!relativePath.endsWith('.class')) { + return null + } + String className = relativePath.substring(0, relativePath.length() - '.class'.length()).replace('/', '.') + if (className.endsWith('module-info') || className.endsWith('package-info')) { + return null + } + return className +} + +def configured = false +project.afterEvaluate { + if (configured || project.extensions.findByName('sourceSets') == null) { + return + } + configured = true + + def mainSourceSet = project.sourceSets.findByName('main') + if (mainSourceSet == null) { + return + } + + File generatedResourcesRoot = project.layout.buildDirectory.dir('generated/native-image-metadata/resources').get().asFile + File nativeImageMetadataDir = new File(generatedResourcesRoot, "META-INF/native-image/${project.group}/${project.name}") + File metadataFile = new File(nativeImageMetadataDir, 'reachability-metadata.json') + File reflectConfigFile = new File(nativeImageMetadataDir, 'reflect-config.json') + File nativeRuntimeLibsDir = project.layout.buildDirectory.dir('native/nativeCompile/libs').get().asFile + Set originalResourceDirs = mainSourceSet.resources.srcDirs.collect { it.absoluteFile } as LinkedHashSet + mainSourceSet.resources.srcDir(generatedResourcesRoot) + int graalToolchainLanguageVersion = (project.ext.jmeNativeMetadataGraalToolchainLanguageVersion as Integer) + def graalVmLauncherProvider = project.javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(graalToolchainLanguageVersion) + vendor = JvmVendorSpec.GRAAL_VM + } + + def generateTaskProvider = project.tasks.register('generateNativeImageMetadata') { + group = 'build' + description = 'Generates GraalVM Native Image reachability metadata for the main source set.' + outputs.file(metadataFile) + outputs.file(reflectConfigFile) + + [mainSourceSet.compileJavaTaskName, 'compileGroovy', 'compileKotlin'].each { taskName -> + def compileTask = project.tasks.findByName(taskName) + if (compileTask != null) { + dependsOn(compileTask) + } + } + + doLast { + metadataFile.parentFile.mkdirs() + + Set scanPathFiles = new LinkedHashSet<>() + scanPathFiles.addAll(mainSourceSet.output.classesDirs.files.findAll { it.exists() }) + try { + scanPathFiles.addAll(mainSourceSet.compileClasspath.files.findAll { it.exists() }) + } catch (Throwable ignored) { + } + try { + scanPathFiles.addAll(mainSourceSet.runtimeClasspath.files.findAll { it.exists() }) + } catch (Throwable ignored) { + } + + URLClassLoader scanLoader = new URLClassLoader( + scanPathFiles.collect { it.toURI().toURL() } as URL[], + Thread.currentThread().contextClassLoader + ) + + try { + SortedSet discoveredClassNames = new TreeSet<>() + mainSourceSet.output.classesDirs.files.findAll { it.exists() }.sort { it.absolutePath }.each { classesDir -> + project.fileTree(classesDir).matching { + include '**/*.class' + exclude '**/module-info.class' + exclude '**/package-info.class' + }.files.sort { it.absolutePath }.each { classFile -> + String className = binaryClassName(classesDir, classFile) + if (className != null) { + discoveredClassNames.add(className) + } + } + } + + def targetTypes = (defaultTargetTypes + asStringList(project.ext.jmeNativeMetadataAdditionalTargetTypes)).toSorted().unique() + def targetAnnotations = (defaultTargetAnnotations + asStringList(project.ext.jmeNativeMetadataAdditionalTargetAnnotations)).toSorted().unique() + def proxyInterfaceSets = (defaultProxyInterfaceSets + asStringMatrix(project.ext.jmeNativeMetadataAdditionalProxyInterfaceSets)).collect { interfaceSet -> + new ArrayList<>(new LinkedHashSet<>(interfaceSet)) + }.findAll { !it.isEmpty() } + + List> targetTypeClasses = targetTypes.collect { loadClassSafely(scanLoader, it) }.findAll { it != null } + List> targetAnnotationClasses = targetAnnotations.collect { loadClassSafely(scanLoader, it) }.findAll { it != null } + + Map> classEntries = new TreeMap<>() + def updateClassEntry = { String className, boolean includeConstructors, boolean includeFields, boolean includeMethods -> + Map entry = classEntries.computeIfAbsent(className) { + [type: className] as LinkedHashMap + } + if (includeConstructors) { + entry.allDeclaredConstructors = true + } + if (includeFields) { + entry.allDeclaredFields = true + } + if (includeMethods) { + entry.allDeclaredMethods = true + entry.allPublicMethods = true + } + } + def addMethodEntry = { String className, String methodName, List parameterTypes -> + Map entry = classEntries.computeIfAbsent(className) { + [type: className] as LinkedHashMap + } + List> methods = entry.methods as List> + if (methods == null) { + methods = [] + entry.methods = methods + } + List orderedParameterTypes = parameterTypes == null ? [] : parameterTypes + boolean exists = methods.any { method -> + method.name == methodName && ((method.parameterTypes ?: []) == orderedParameterTypes) + } + if (!exists) { + methods.add([ + name : methodName, + parameterTypes: orderedParameterTypes + ] as LinkedHashMap) + } + } + + discoveredClassNames.each { className -> + Class clazz = loadClassSafely(scanLoader, className) + if (clazz == null) { + return + } + + boolean regularMatch = + targetTypeClasses.any { safeAssignableFrom(it, clazz) } || + targetAnnotationClasses.any { safeAnnotationPresent(clazz, it) } + if (regularMatch) { + Class current = clazz + while (current != null && current != Object.class) { + if (discoveredClassNames.contains(current.name)) { + updateClassEntry(current.name, isConstructible(current), true, true) + } + current = safeSuperclass(current) + } + } + + if (targetTypeClasses.any { it == Cloneable.class || it.name == 'java.lang.Cloneable' } + && safeAssignableFrom(Cloneable.class, clazz) + && hasPublicNoArgCloneMethod(clazz)) { + addMethodEntry(className, 'clone', []) + } + } + + (defaultCloneMethodTypes + asStringList(project.ext.jmeNativeMetadataAdditionalCloneMethodTypes)) + .findAll { !it.isEmpty() } + .unique() + .sort() + .each { className -> + Class clazz = loadClassSafely(scanLoader, className) + if (clazz != null + && safeAssignableFrom(Cloneable.class, clazz) + && hasPublicNoArgCloneMethod(clazz)) { + addMethodEntry(className, 'clone', []) + } + } + + List> reflectionEntries = classEntries.values().collect { entry -> + Map orderedEntry = new LinkedHashMap<>() + orderedEntry.type = entry.type + if (entry.allDeclaredConstructors == true) { + orderedEntry.allDeclaredConstructors = true + } + if (entry.allDeclaredFields == true) { + orderedEntry.allDeclaredFields = true + } + if (entry.allDeclaredMethods == true) { + orderedEntry.allDeclaredMethods = true + } + if (entry.allPublicMethods == true) { + orderedEntry.allPublicMethods = true + } + if (entry.methods instanceof List && !entry.methods.isEmpty()) { + orderedEntry.methods = entry.methods.collect { method -> + [ + name : method.name, + parameterTypes: method.parameterTypes ?: [] + ] as LinkedHashMap + } + } + return orderedEntry + } + + proxyInterfaceSets.collect { interfaceSet -> + def proxyClasses = interfaceSet.collect { loadClassSafely(scanLoader, it) } + if (proxyClasses.every { it != null && it.isInterface() }) { + return [proxy: interfaceSet] + } + return null + }.findAll { it != null }.sort { a, b -> + a.proxy.join(',') <=> b.proxy.join(',') + }.each { proxyDefinition -> + reflectionEntries.add([type: [proxy: proxyDefinition.proxy]]) + } + + SortedSet resourceGlobs = new TreeSet<>() + originalResourceDirs.findAll { it.exists() }.sort { it.absolutePath }.each { resourceDir -> + project.fileTree(resourceDir).files.findAll { it.isFile() }.sort { it.absolutePath }.each { resourceFile -> + if (resourceFile.toPath().startsWith(generatedResourcesRoot.toPath())) { + return + } + String relativePath = resourceDir.toPath().relativize(resourceFile.toPath()).toString().replace(File.separatorChar, (char) '/') + if (!relativePath.isEmpty()) { + resourceGlobs.add(relativePath) + } + } + } + resourceGlobs.addAll(asStringList(project.ext.jmeNativeMetadataAdditionalResourceGlobs).findAll { !it.isEmpty() }) + + SortedSet bundleNames = new TreeSet<>() + if (resourceGlobs.contains('com/jme3/app/SettingsDialog.properties')) { + bundleNames.add('com.jme3.app.SettingsDialog') + } + + List> resourceEntries = [] + resourceGlobs.each { resourceGlob -> + resourceEntries.add([glob: resourceGlob]) + } + bundleNames.each { bundleName -> + resourceEntries.add([bundle: bundleName]) + } + + Map metadata = new LinkedHashMap<>() + metadata.reflection = reflectionEntries + metadata.resources = resourceEntries + metadataFile.text = JsonOutput.prettyPrint(JsonOutput.toJson(metadata)) + System.lineSeparator() + + List> reflectConfigEntries = classEntries.values().collect { entry -> + Map reflectEntry = new LinkedHashMap<>() + reflectEntry.name = entry.type + if (entry.allDeclaredConstructors == true) { + reflectEntry.allDeclaredConstructors = true + } + if (entry.allDeclaredFields == true) { + reflectEntry.allDeclaredFields = true + } + if (entry.allDeclaredMethods == true) { + reflectEntry.allDeclaredMethods = true + } + if (entry.allPublicMethods == true) { + reflectEntry.allPublicMethods = true + } + if (entry.methods instanceof List && !entry.methods.isEmpty()) { + reflectEntry.methods = entry.methods.collect { method -> + [ + name : method.name, + parameterTypes: method.parameterTypes ?: [] + ] as LinkedHashMap + } + } + return reflectEntry + } + reflectConfigFile.text = JsonOutput.prettyPrint(JsonOutput.toJson(reflectConfigEntries)) + System.lineSeparator() + } finally { + scanLoader.close() + } + } + } + + def validateTaskProvider = project.tasks.register('validateNativeImageMetadata') { + group = 'verification' + description = 'Validates generated GraalVM Native Image reachability metadata.' + dependsOn(generateTaskProvider) + inputs.file(metadataFile) + + doLast { + def parsed = new JsonSlurper().parse(metadataFile) + if (!(parsed instanceof Map) || !(parsed.reflection instanceof List) || !(parsed.resources instanceof List)) { + throw new GradleException("Malformed reachability metadata in ${metadataFile}") + } + } + } + + def prepareNativeRuntimeLibrariesProvider = project.tasks.register('prepareNativeRuntimeLibraries') { + group = 'build' + description = 'Extracts platform native libraries needed by native-image executables.' + outputs.dir(nativeRuntimeLibsDir) + + doLast { + project.delete(nativeRuntimeLibsDir) + nativeRuntimeLibsDir.mkdirs() + + String osName = System.getProperty('os.name', '').toLowerCase() + String arch = System.getProperty('os.arch', '').toLowerCase() + String matcher + if (osName.contains('mac')) { + matcher = arch.contains('aarch64') || arch.contains('arm64') + ? '(?i)natives-macos-arm64|natives-macos-aarch64' + : '(?i)natives-macos(?!-arm64|-aarch64)' + } else if (osName.contains('linux')) { + matcher = arch.contains('aarch64') || arch.contains('arm64') + ? '(?i)natives-linux-arm64|natives-linux-aarch64' + : '(?i)natives-linux(?!-arm64|-aarch64)' + } else if (osName.contains('win')) { + matcher = '(?i)natives-windows' + } else { + matcher = '(?i)natives' + } + + Set runtimeFiles = new LinkedHashSet<>() + try { + runtimeFiles.addAll(mainSourceSet.runtimeClasspath.files.findAll { it.exists() }) + } catch (Throwable ignored) { + } + + Set nativeJars = runtimeFiles.findAll { + it.name.endsWith('.jar') && (it.name =~ matcher) + } as Set + + nativeJars.each { File nativeJar -> + project.copy { + from(project.zipTree(nativeJar)) + include '**/*.dylib', '**/*.so', '**/*.dll' + into nativeRuntimeLibsDir + includeEmptyDirs = false + eachFile { details -> + details.path = details.name + } + } + } + + logger.lifecycle("${project.path}: extracted ${nativeJars.size()} native jars to ${nativeRuntimeLibsDir}") + } + } + + def resolveGraalVmToolchainForNativeMetadataProvider = project.tasks.register('resolveGraalVmToolchainForNativeMetadata', Exec) { + group = 'build' + description = 'Resolves GraalVM toolchain via Foojay for native-image metadata generation.' + executable = graalVmLauncherProvider.get().executablePath.asFile.absolutePath + args '-version' + doFirst { + logger.lifecycle("${project.path}: using GraalVM toolchain ${graalVmLauncherProvider.get().metadata.installationPath.asFile}") + } + } + + def generateWithGraalVmToolchainTaskProvider = project.tasks.register('generateNativeImageMetadataWithGraalVmToolchain') { + group = 'build' + description = 'Generates reachability metadata after resolving GraalVM toolchain via Foojay.' + dependsOn(resolveGraalVmToolchainForNativeMetadataProvider) + dependsOn(generateTaskProvider) + } + + def processResourcesTask = project.tasks.findByName('processResources') + if (processResourcesTask != null) { + processResourcesTask.dependsOn(generateWithGraalVmToolchainTaskProvider) + } + + def nativeCompileTask = project.tasks.findByName('nativeCompile') + if (nativeCompileTask != null) { + nativeCompileTask.finalizedBy(prepareNativeRuntimeLibrariesProvider) + } + + def checkTask = project.tasks.findByName('check') + if (checkTask != null) { + checkTask.dependsOn(validateTaskProvider) + } +} diff --git a/jme3-core/src/main/java/com/jme3/system/Platform.java b/jme3-core/src/main/java/com/jme3/system/Platform.java index e392e23eec..a02d8fce31 100644 --- a/jme3-core/src/main/java/com/jme3/system/Platform.java +++ b/jme3-core/src/main/java/com/jme3/system/Platform.java @@ -1,39 +1,43 @@ -/* - * Copyright (c) 2009-2022 jMonkeyEngine - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * * Neither the name of 'jMonkeyEngine' nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR - * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.jme3.system; - -/** - * Enumerate known operating system/architecture pairs. - */ +/* + * Copyright (c) 2009-2022 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.system; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Enumerate known operating system/architecture pairs. + */ public enum Platform { /** @@ -100,8 +104,8 @@ public enum Platform { * Apple Mac OS X 64-bit Intel */ MacOSX64(Os.MacOS, true), - - /** + + /** * Apple Mac OS X 64-bit ARM */ MacOSX_ARM64(Os.MacOS, true), @@ -187,65 +191,163 @@ public enum Platform { */ Web(Os.Web, true) // assume always 64-bit, it shouldn't matter for web ; - - - /** - * Enumerate generic names of operating systems - */ - public enum Os { - /** - * Linux operating systems - */ - Linux, - /** - * Microsoft Windows operating systems - */ - Windows, - /** - * iOS operating systems - */ - iOS, - /** - * macOS operating systems - */ - MacOS, - /** - * Android operating systems - */ - Android, - /** - * Generic web platform - */ - Web - } - - private final boolean is64bit; - private final Os os; - - /** - * Test for a 64-bit address space. - * - * @return true if 64 bits, otherwise false - */ - public boolean is64Bit() { - return is64bit; - } - - /** - * Returns the operating system of this platform. - * - * @return the generic name of the operating system of this platform - */ - public Os getOs() { - return os; - } - - private Platform(Os os, boolean is64bit) { - this.os = os; - this.is64bit = is64bit; - } - - private Platform(Os os) { - this(os, false); - } -} + + + /** + * Enumerate generic names of operating systems + */ + public enum Os { + /** + * Linux operating systems + */ + Linux, + /** + * Microsoft Windows operating systems + */ + Windows, + /** + * iOS operating systems + */ + iOS, + /** + * macOS operating systems + */ + MacOS, + /** + * Android operating systems + */ + Android, + /** + * Generic web platform + */ + Web + } + + private final boolean is64bit; + private final Os os; + private static final boolean NATIVE_IMAGE_RUNTIME = detectNativeImageRuntime(); + private static final boolean GRAAL_VM_RUNTIME = detectGraalVmRuntime(); + + /** + * Test for a 64-bit address space. + * + * @return true if 64 bits, otherwise false + */ + public boolean is64Bit() { + return is64bit; + } + + /** + * Returns the operating system of this platform. + * + * @return the generic name of the operating system of this platform + */ + public Os getOs() { + return os; + } + + /** + * Test whether this process runs on GraalVM or inside a native-image runtime. + * + * @return true if GraalVM/native-image is detected, otherwise false + */ + public boolean isGraalVM() { + return GRAAL_VM_RUNTIME; + } + + /** + * Test whether this process is running as a GraalVM native-image executable. + * + * @return true if running inside a native-image runtime, otherwise false + */ + public boolean isNativeImage() { + return NATIVE_IMAGE_RUNTIME; + } + + /** + * Test whether this process is running on GraalVM JRE/JDK (not native-image). + * + * @return true if GraalVM is detected and this is not native-image + */ + public boolean isGraalVmJvm() { + return GRAAL_VM_RUNTIME && !NATIVE_IMAGE_RUNTIME; + } + + /** + * Resolve a sibling native library directory next to the current executable. + * + * @param directoryName the sibling directory name, for example {@code libs} + * @return absolute path to the directory, or null if unavailable + */ + public static String resolveNativeImageSiblingDirectory(String directoryName) { + if (!NATIVE_IMAGE_RUNTIME || directoryName == null || directoryName.isEmpty()) { + return null; + } + + String executableName; + try { + Class processPropertiesClass = Class.forName("org.graalvm.nativeimage.ProcessProperties", false, + Platform.class.getClassLoader()); + Object result = processPropertiesClass.getMethod("getExecutableName").invoke(null); + executableName = result instanceof String ? (String) result : null; + } catch (Throwable ignored) { + return null; + } + + if (executableName == null || executableName.isEmpty()) { + return null; + } + + try { + Path executablePath = Paths.get(executableName).toAbsolutePath().normalize(); + Path parent = executablePath.getParent(); + if (parent == null) { + return null; + } + Path sibling = parent.resolve(directoryName).normalize(); + if (Files.isDirectory(sibling)) { + return sibling.toString(); + } + } catch (Throwable ignored) { + return null; + } + + return null; + } + + private static boolean detectNativeImageRuntime() { + return System.getProperty("org.graalvm.nativeimage.imagecode") != null; + } + + private static boolean detectGraalVmRuntime() { + if (NATIVE_IMAGE_RUNTIME) { + return true; + } + + String vmName = System.getProperty("java.vm.name", "").toLowerCase(); + if (vmName.contains("graal")) { + return true; + } + + String vendor = System.getProperty("java.vendor", "").toLowerCase(); + if (vendor.contains("graal")) { + return true; + } + + try { + Class.forName("org.graalvm.polyglot.Context", false, Platform.class.getClassLoader()); + return true; + } catch (ClassNotFoundException ignored) { + return false; + } + } + + private Platform(Os os, boolean is64bit) { + this.os = os; + this.is64bit = is64bit; + } + + private Platform(Os os) { + this(os, false); + } +} diff --git a/jme3-examples/build.gradle b/jme3-examples/build.gradle index 4d5d11a460..ee32df76d7 100644 --- a/jme3-examples/build.gradle +++ b/jme3-examples/build.gradle @@ -1,4 +1,96 @@ +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.jvm.toolchain.JvmVendorSpec + +import groovy.json.JsonOutput +import java.nio.file.Files + +plugins { + id 'org.graalvm.buildtools.native' version '0.10.6' +} + ext.mainClassName = 'jme3test.TestChooser' +ext.nativeMainClassName = 'jme3test.TestChooserCli' +ext.jmeNativeMetadataAdditionalResourceGlobs = [] + +def generatedTestChooserResourcesDir = layout.buildDirectory.dir('generated/testchooser/resources') +def testChooserClassListFile = generatedTestChooserResourcesDir.map { it.file('jme3test/test-classes.txt') } +def testChooserReflectionConfigFile = layout.buildDirectory.file('generated/testchooser/reflect-config.json') +def nativeImageBuildOutputJsonFile = layout.buildDirectory.file('reports/native-image/jme3-testchooser-build-output.json') +def testChooserReachabilityMetadataFile = generatedTestChooserResourcesDir.map { + it.file('META-INF/native-image/org.jmonkeyengine/jme3-examples-testchooser/reachability-metadata.json') +} + +tasks.register('generateTestChooserClassList') { + group = 'build' + description = 'Generates a resource list of jme3test classes for TestChooserCli fallback discovery.' + dependsOn 'compileJava' + outputs.files(testChooserClassListFile, testChooserReachabilityMetadataFile, testChooserReflectionConfigFile) + + doLast { + Set allJme3TestClassNames = new TreeSet() + Set launcherClassNames = new TreeSet() + sourceSets.main.output.classesDirs.files.findAll { it.exists() }.each { classesDir -> + fileTree(classesDir).matching { + include 'jme3test/**/*.class' + exclude '**/*$*' + exclude '**/module-info.class' + exclude '**/package-info.class' + exclude '**/TestChooser.class' + exclude '**/TestChooserCli.class' + }.files.each { classFile -> + String relativePath = classesDir.toPath().relativize(classFile.toPath()).toString().replace(File.separatorChar, (char) '/') + if (relativePath.endsWith('.class')) { + String className = relativePath.substring(0, relativePath.length() - '.class'.length()).replace('/', '.') + allJme3TestClassNames.add(className) + if (relativePath.contains('Test')) { + launcherClassNames.add(className) + } + } + } + } + + File classListFile = testChooserClassListFile.get().asFile + classListFile.parentFile.mkdirs() + classListFile.text = launcherClassNames.isEmpty() + ? '' + : launcherClassNames.join(System.lineSeparator()) + System.lineSeparator() + + List> reflectionEntries = allJme3TestClassNames.collect { className -> + [ + type : className, + allDeclaredConstructors: true, + allDeclaredMethods : true, + allPublicMethods : true + ] + } + Map metadata = [ + reflection: reflectionEntries, + resources : [] + ] + + File reachabilityFile = testChooserReachabilityMetadataFile.get().asFile + reachabilityFile.parentFile.mkdirs() + reachabilityFile.text = JsonOutput.prettyPrint(JsonOutput.toJson(metadata)) + System.lineSeparator() + + List> reflectionConfigEntries = allJme3TestClassNames.collect { className -> + [ + name : className, + allDeclaredConstructors: true, + allDeclaredMethods : true, + allPublicMethods : true + ] + } + File reflectionConfigFile = testChooserReflectionConfigFile.get().asFile + reflectionConfigFile.parentFile.mkdirs() + reflectionConfigFile.text = JsonOutput.prettyPrint(JsonOutput.toJson(reflectionConfigEntries)) + System.lineSeparator() + } +} + +sourceSets.main.resources.srcDir(generatedTestChooserResourcesDir) + +tasks.named('processResources') { + dependsOn 'generateTestChooserClassList' +} def androidProject = project(':jme3-android') def androidNativeProject = project(':jme3-android-native') @@ -181,6 +273,83 @@ task runExamplesWithProton(dependsOn: ['build', 'downloadProtonRuntime', 'downlo } } +def graalLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.GRAAL_VM + nativeImageCapable = true +} + +graalvmNative { + binaries { + named('main') { + imageName = 'jme3-testchooser' + mainClass = nativeMainClassName + sharedLibrary = false + javaLauncher = graalLauncher + project.ext.jmeApplyDefaultNativeImageResourceSettings(delegate) + buildArgs.add("-H:ReflectionConfigurationFiles=${testChooserReflectionConfigFile.get().asFile.absolutePath}") + buildArgs.add('-H:+BuildOutputBreakdowns') + buildArgs.add("-H:BuildOutputJSONFile=${nativeImageBuildOutputJsonFile.get().asFile.absolutePath}") + } + } +} + +tasks.named('nativeCompile') { + dependsOn 'processResources' + + doFirst { + // Gradle's Copy task cannot preserve symlinks in some provisioned toolchains. + // Recreate likely broken links in the GraalVM toolchain before native-image runs. + if (!FileSystems.getDefault().supportedFileAttributeViews().contains('posix')) { + logger.info("Skipping GraalVM toolchain symlink fixup on non-POSIX file system.") + return + } + + File graalvmHomeDir = System.getenv('GRAALVM_HOME') ? file(System.getenv('GRAALVM_HOME')) : null + def nativeImageLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.GRAAL_VM + nativeImageCapable = true + } + + if (delegate.hasProperty('options') && delegate.options.hasProperty('javaLauncher')) { + delegate.options.javaLauncher.set(nativeImageLauncher) + } + + File toolchainDir = graalvmHomeDir + if (toolchainDir == null) { + File executableParent = nativeImageLauncher.get().executablePath.asFile.parentFile + toolchainDir = executableParent.name == 'bin' ? executableParent.parentFile : executableParent + } + + def toolchainFiles = project.fileTree(toolchainDir).files.findAll { it.isFile() } + def emptyFiles = toolchainFiles.findAll { it.length() == 0L } + Set emptyFileSet = emptyFiles as Set + + toolchainFiles.groupBy { it.name }.each { ignoredName, sameNamedFiles -> + List nonEmptyCandidates = sameNamedFiles.findAll { it.length() > 0L } + List emptyCandidates = sameNamedFiles.findAll { emptyFileSet.contains(it) } + if (!nonEmptyCandidates.isEmpty() && !emptyCandidates.isEmpty()) { + File target = nonEmptyCandidates.first() + emptyCandidates.each { File link -> + if (link != target) { + logger.quiet("Fixing up '${link}' to link to '${target}'.") + if (link.delete()) { + try { + Files.createSymbolicLink(link.toPath(), target.toPath()) + } catch (Exception ex) { + logger.warn("Unable to create symlink '${link}' to '${target}'.", ex) + } + } else { + logger.warn("Unable to delete '${link}'.") + } + } + } + } + } + } +} + dependencies { implementation project(':jme3-core') implementation project(':jme3-desktop') diff --git a/jme3-examples/src/main/java/jme3test/TestChooser.java b/jme3-examples/src/main/java/jme3test/TestChooser.java index 3799bd5832..edcb245e39 100644 --- a/jme3-examples/src/main/java/jme3test/TestChooser.java +++ b/jme3-examples/src/main/java/jme3test/TestChooser.java @@ -35,6 +35,7 @@ import com.jme3.app.LegacyApplication; import com.jme3.app.SimpleApplication; import com.jme3.system.JmeContext; +import com.jme3.system.JmeSystem; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.FlowLayout; @@ -497,6 +498,11 @@ private void center() { * command line parameters */ public static void main(final String[] args) { + if (JmeSystem.getPlatform().isGraalVM()) { + TestChooserCli.main(args); + return; + } + try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); } catch (Exception e) {} diff --git a/jme3-examples/src/main/java/jme3test/TestChooserCli.java b/jme3-examples/src/main/java/jme3test/TestChooserCli.java new file mode 100644 index 0000000000..ca453ebdb7 --- /dev/null +++ b/jme3-examples/src/main/java/jme3test/TestChooserCli.java @@ -0,0 +1,447 @@ +/* + * Copyright (c) 2009-2025 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package jme3test; + +import com.jme3.app.LegacyApplication; +import com.jme3.app.SimpleApplication; +import com.jme3.system.JmeContext; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.DirectoryStream; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Command-line test chooser for running example classes without any AWT/Swing usage. + */ +public class TestChooserCli { + + private static final Logger logger = Logger.getLogger(TestChooserCli.class.getName()); + private static final String CLASS_LIST_RESOURCE = "/jme3test/test-classes.txt"; + private static final long WAIT_INTERVAL_MILLIS = Math.max(10L, + Long.getLong("jme3test.cli.waitIntervalMillis", 100L)); + private static final long START_TIMEOUT_MILLIS = Math.max(WAIT_INTERVAL_MILLIS, + Long.getLong("jme3test.cli.startTimeoutMillis", 30000L)); + private static final long RUN_TIMEOUT_MILLIS = Long.getLong("jme3test.cli.runTimeoutMillis", + 30L * 60L * 1000L); + + public static void main(String[] args) { + new TestChooserCli().start(args); + } + + private void start(String[] args) { + if (args.length > 0) { + launchFromArgument(args); + return; + } + + List fallbackClassNames = loadClassNamesFromResource(); + Set> discovered = new LinkedHashSet>(); + addDisplayedClasses(discovered); + + List> sorted = new ArrayList>(discovered); + Collections.sort(sorted, (a, b) -> a.getName().compareTo(b.getName())); + + if (sorted.isEmpty()) { + if (!fallbackClassNames.isEmpty()) { + printClassNameMenu(fallbackClassNames); + String selectedClassName = chooseClassName(fallbackClassNames); + if (selectedClassName != null) { + launchFromArgument(new String[]{selectedClassName}); + } + return; + } + logger.warning("No test classes discovered. Pass a class name explicitly, for example: jme3test.light.TestManyLights"); + return; + } + + printMenu(sorted); + Class target = chooseClass(sorted); + if (target != null) { + launchClass(target, new String[0]); + } + } + + private void launchFromArgument(String[] args) { + String requested = args[0]; + String[] appArgs = Arrays.copyOfRange(args, 1, args.length); + + for (String listedClassName : loadClassNamesFromResource()) { + if (listedClassName.equals(requested) || simpleClassName(listedClassName).equals(requested)) { + Class listedClass = loadFromClassName(listedClassName); + if (listedClass != null) { + launchClass(listedClass, appArgs); + return; + } + } + } + + Class target; + try { + target = Class.forName(requested); + } catch (ClassNotFoundException e) { + logger.log(Level.SEVERE, "Cannot find test class: " + requested, e); + return; + } + + launchClass(target, appArgs); + } + + private Class chooseClass(List> classes) { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + System.out.print("Choose test index or class name (empty to exit): "); + try { + String input = reader.readLine(); + if (input == null || input.trim().isEmpty()) { + return null; + } + input = input.trim(); + try { + int index = Integer.parseInt(input); + if (index >= 1 && index <= classes.size()) { + return classes.get(index - 1); + } + System.err.println("Invalid index: " + index); + return null; + } catch (NumberFormatException ignored) { + for (Class c : classes) { + if (c.getName().equals(input) || c.getSimpleName().equals(input)) { + return c; + } + } + try { + return Class.forName(input); + } catch (ClassNotFoundException e) { + logger.log(Level.SEVERE, "Cannot find class: " + input, e); + return null; + } + } + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to read input", e); + return null; + } + } + + private void printMenu(List> classes) { + System.out.println("Available jME tests:"); + for (int i = 0; i < classes.size(); i++) { + System.out.println(String.format("%3d. %s", i + 1, classes.get(i).getName())); + } + } + + private void printClassNameMenu(List classNames) { + System.out.println("Available jME tests (from resource list):"); + for (int i = 0; i < classNames.size(); i++) { + System.out.println(String.format("%3d. %s", i + 1, classNames.get(i))); + } + } + + private String chooseClassName(List classNames) { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + System.out.print("Choose test index or class name (empty to exit): "); + try { + String input = reader.readLine(); + if (input == null || input.trim().isEmpty()) { + return null; + } + input = input.trim(); + try { + int index = Integer.parseInt(input); + if (index >= 1 && index <= classNames.size()) { + return classNames.get(index - 1); + } + System.err.println("Invalid index: " + index); + return null; + } catch (NumberFormatException ignored) { + for (String className : classNames) { + if (className.equals(input) || simpleClassName(className).equals(input)) { + return className; + } + } + return input; + } + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to read input", e); + return null; + } + } + + private void launchClass(Class clazz, String[] appArgs) { + try { + if (LegacyApplication.class.isAssignableFrom(clazz)) { + LegacyApplication app = (LegacyApplication) clazz.getDeclaredConstructor().newInstance(); + if (app instanceof SimpleApplication) { + ((SimpleApplication) app).setShowSettings(false); + } + app.start(); + + JmeContext context = waitForContext(app, clazz.getName()); + waitForContextCreated(app, context, clazz.getName()); + waitForContextDestroyed(app, context, clazz.getName()); + } else { + Method mainMethod = clazz.getMethod("main", String[].class); + mainMethod.invoke(null, new Object[]{appArgs}); + } + } catch (IllegalAccessException e) { + logger.log(Level.SEVERE, "Cannot access constructor: " + clazz.getName(), e); + } catch (IllegalArgumentException e) { + logger.log(Level.SEVERE, "main() had illegal argument: " + clazz.getName(), e); + } catch (InvocationTargetException e) { + logger.log(Level.SEVERE, "main() method had exception: " + clazz.getName(), e); + } catch (InstantiationException e) { + logger.log(Level.SEVERE, "Failed to create app: " + clazz.getName(), e); + } catch (NoSuchMethodException e) { + logger.log(Level.SEVERE, "Test class does not have required method: " + clazz.getName(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.log(Level.SEVERE, "Interrupted while waiting for app context", e); + } catch (Exception e) { + logger.log(Level.SEVERE, "Cannot start test: " + clazz.getName(), e); + } + } + + private JmeContext waitForContext(LegacyApplication app, String className) throws InterruptedException { + long deadline = System.currentTimeMillis() + START_TIMEOUT_MILLIS; + JmeContext context = app.getContext(); + while (context == null) { + if (System.currentTimeMillis() >= deadline) { + requestStop(app); + throw new IllegalStateException("Timed out waiting for application context: " + className); + } + Thread.sleep(WAIT_INTERVAL_MILLIS); + context = app.getContext(); + } + return context; + } + + private void waitForContextCreated(LegacyApplication app, JmeContext context, String className) + throws InterruptedException { + long deadline = System.currentTimeMillis() + START_TIMEOUT_MILLIS; + while (!context.isCreated()) { + if (System.currentTimeMillis() >= deadline) { + requestStop(app); + throw new IllegalStateException("Timed out waiting for context creation: " + className); + } + Thread.sleep(WAIT_INTERVAL_MILLIS); + } + } + + private void waitForContextDestroyed(LegacyApplication app, JmeContext context, String className) + throws InterruptedException { + long deadline = RUN_TIMEOUT_MILLIS > 0L ? System.currentTimeMillis() + RUN_TIMEOUT_MILLIS : Long.MAX_VALUE; + while (context.isCreated()) { + if (System.currentTimeMillis() >= deadline) { + requestStop(app); + throw new IllegalStateException("Timed out waiting for application to exit: " + className); + } + Thread.sleep(WAIT_INTERVAL_MILLIS); + } + } + + private void requestStop(LegacyApplication app) { + JmeContext context = app.getContext(); + if (context == null) { + return; + } + try { + app.stop(false); + } catch (RuntimeException e) { + logger.log(Level.WARNING, "Failed to stop timed-out application", e); + } + } + + private void find(String packageName, boolean recursive, Set> classes) { + String name = packageName; + if (!name.startsWith("/")) { + name = "/" + name; + } + name = name.replace('.', '/'); + + packageName = packageName + "."; + URI uri; + FileSystem fileSystem = null; + try { + URL packageUrl = this.getClass().getResource(name); + if (packageUrl == null) { + return; + } + uri = packageUrl.toURI(); + } catch (URISyntaxException e) { + throw new RuntimeException("Failed to load demo classes.", e); + } + + if ("jar".equalsIgnoreCase(uri.getScheme())) { + try { + fileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap()); + } catch (IOException e) { + throw new RuntimeException("Failed to load demo classes from JAR.", e); + } + } + + try { + Path directory = Paths.get(uri); + addAllFilesInDirectory(directory, classes, packageName, recursive); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to find classes", e); + } finally { + if (fileSystem != null) { + try { + fileSystem.close(); + } catch (IOException e) { + logger.log(Level.WARNING, "Failed to close JAR.", e); + } + } + } + } + + private void addAllFilesInDirectory( + final Path directory, + final Set> allClasses, + final String packageName, + final boolean recursive) { + + try (DirectoryStream stream = Files.newDirectoryStream(directory, getFileFilter())) { + for (Path file : stream) { + if (Files.isDirectory(file)) { + if (recursive) { + String dirName = String.valueOf(file.getFileName()); + if (dirName.endsWith("/")) { + dirName = dirName.substring(0, dirName.length() - 1); + } + addAllFilesInDirectory(file, allClasses, packageName + dirName + ".", true); + } + } else { + Class result = load(packageName + file.getFileName()); + if (result != null && !allClasses.contains(result)) { + allClasses.add(result); + } + } + } + } catch (IOException ex) { + logger.log(Level.SEVERE, "Could not search the folder", ex); + } + } + + private static DirectoryStream.Filter getFileFilter() { + return new DirectoryStream.Filter() { + @Override + public boolean accept(Path entry) throws IOException { + String fileName = entry.getFileName().toString(); + return ((fileName.endsWith(".class") && fileName.contains("Test") && !fileName.contains("$")) + || (!fileName.startsWith(".") && Files.isDirectory(entry))); + } + }; + } + + private Class load(String name) { + String classname = name.substring(0, name.length() - ".class".length()); + if (classname.startsWith("/")) { + classname = classname.substring(1); + } + classname = classname.replace('/', '.'); + + return loadFromClassName(classname); + } + + private Class loadFromClassName(String classname) { + + if (classname.equals(TestChooser.class.getName()) || classname.equals(TestChooserCli.class.getName())) { + return null; + } + + try { + final Class cls = Class.forName(classname, false, TestChooserCli.class.getClassLoader()); + cls.getMethod("main", String[].class); + return cls; + } catch (NoClassDefFoundError e) { + return null; + } catch (ClassNotFoundException e) { + return null; + } catch (NoSuchMethodException e) { + return null; + } catch (UnsupportedClassVersionError e) { + return null; + } + } + + private List loadClassNamesFromResource() { + InputStream stream = TestChooserCli.class.getResourceAsStream(CLASS_LIST_RESOURCE); + if (stream == null) { + return Collections.emptyList(); + } + + List classNames = new ArrayList(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + String line; + while ((line = reader.readLine()) != null) { + String className = line.trim(); + if (!className.isEmpty()) { + classNames.add(className); + } + } + } catch (IOException e) { + logger.log(Level.WARNING, "Failed reading test class list resource: " + CLASS_LIST_RESOURCE, e); + } + return classNames; + } + + private String simpleClassName(String className) { + int lastDot = className.lastIndexOf('.'); + if (lastDot >= 0 && lastDot + 1 < className.length()) { + return className.substring(lastDot + 1); + } + return className; + } + + protected void addDisplayedClasses(Set> classes) { + find("jme3test", true, classes); + } +} diff --git a/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java b/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java index 0f02d0fa5e..2ff1f0ece1 100644 --- a/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java +++ b/jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglContext.java @@ -47,6 +47,7 @@ import com.jme3.renderer.opengl.*; import com.jme3.system.AppSettings; import com.jme3.system.JmeContext; +import com.jme3.system.JmeSystem; import com.jme3.system.SystemListener; import com.jme3.system.Timer; import com.jme3.util.BufferAllocatorFactory; @@ -87,6 +88,8 @@ public abstract class LwjglContext implements JmeContext { private static final Logger logger = Logger.getLogger(LwjglContext.class.getName()); static { + autoConfigureNativeLibraryPathForNativeImage(); + final String implementation = BufferAllocatorFactory.PROPERTY_BUFFER_ALLOCATOR_IMPLEMENTATION; final String configuredImplementation = System.getProperty(implementation); @@ -105,6 +108,20 @@ public abstract class LwjglContext implements JmeContext { } } + private static void autoConfigureNativeLibraryPathForNativeImage() { + if (!JmeSystem.getPlatform().isNativeImage()) { + return; + } + if (System.getProperty("org.lwjgl.librarypath") != null) { + return; + } + + String nativeLibsDir = com.jme3.system.Platform.resolveNativeImageSiblingDirectory("libs"); + if (nativeLibsDir != null) { + System.setProperty("org.lwjgl.librarypath", nativeLibsDir); + } + } + private static final Set SUPPORTED_RENDERS = new HashSet<>(Arrays.asList( AppSettings.LWJGL_OPENGL32, AppSettings.LWJGL_OPENGL33, diff --git a/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java index 24aad54c5e..4cafa8014a 100644 --- a/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java +++ b/jme3-saferallocator/src/main/java/com/jme3/util/SaferBufferAllocator.java @@ -1,5 +1,7 @@ package com.jme3.util; +import com.jme3.system.JmeSystem; + import java.lang.ref.PhantomReference; import java.lang.ref.ReferenceQueue; import java.nio.Buffer; @@ -21,11 +23,26 @@ public final class SaferBufferAllocator implements BufferAllocator { "Safer Deallocator"); static { + autoConfigureSaferAllocOverrideForNativeImage(); reaperThread.setDaemon(true); reaperThread.start(); SaferAlloc.ensureLoaded(); } + private static void autoConfigureSaferAllocOverrideForNativeImage() { + if (!JmeSystem.getPlatform().isNativeImage()) { + return; + } + if (System.getProperty("saferalloc.native.override") != null) { + return; + } + + String nativeLibsDir = com.jme3.system.Platform.resolveNativeImageSiblingDirectory("libs"); + if (nativeLibsDir != null) { + System.setProperty("saferalloc.native.override", nativeLibsDir); + } + } + public SaferBufferAllocator() { logger.info(getClass().getSimpleName() + " enabled!"); }