From 9f4c90f9c875bd430f861e02ab51a5c99424f137 Mon Sep 17 00:00:00 2001 From: Dominik Suliga Date: Wed, 26 Aug 2026 10:32:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(build):=20bump=20to=20Java=2025=20and=20ve?= =?UTF-8?q?rify=20Spigot=201.21=E2=80=9326.2=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise the toolchain and bytecode target to Java 25, and apply the compiler options to every JavaCompile task instead of only compileJava, so test compilation no longer targets a different release than production code. The plugin is built against the newest Spigot API while advertising api-version 1.21, which had silently drifted out of compatibility: * Sound#getKeyOrThrow() and Enchantment#getKeyOrThrow() come from org.bukkit.registry.RegistryAware, a type that does not exist on 1.21. * Falling back to getKey() alone is not enough either — org.bukkit.Sound was an enum back then and is an interface today, so compiling against the new API emits invokeinterface and 1.21 throws IncompatibleClassChangeError. Read the key through org.bukkit.Keyed, an interface in every supported version, so the emitted call site resolves on both ends of the range. Two verification tasks now run as part of check, in every module: * compileJavaSpigotMin recompiles the sources against the oldest supported API and catches newer-only symbols. * checkSpigotBinaryCompatibility scans the emitted org/bukkit call sites with ASM and reports unresolved members as well as invokeinterface / invokevirtual mismatches, which the source compile cannot see. Co-Authored-By: Claude Opus 5 --- .github/workflows/gradle.yml | 4 +- README.md | 8 +- buildSrc/build.gradle.kts | 1 + buildSrc/src/main/kotlin/Versions.kt | 10 + .../src/main/kotlin/playtime-java.gradle.kts | 8 +- .../kotlin/playtime-spigot-compat.gradle.kts | 328 ++++++++++++++++++ playtime-api/build.gradle.kts | 1 + playtime-core/build.gradle.kts | 1 + .../playtime/BukkitPlayTimeAdapter.java | 1 - .../serdes/EnchantmentSerializer.java | 2 +- .../core/platform/serdes/RegistryKeys.java | 29 ++ .../core/platform/serdes/SoundSerializer.java | 2 +- playtime-plugin/build.gradle.kts | 5 +- .../imdmk/playtime/plugin/PlayTimePlugin.java | 2 - 14 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 buildSrc/src/main/kotlin/playtime-spigot-compat.gradle.kts create mode 100644 playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/RegistryKeys.java diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 6ef6214..bcd23eb 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v3 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Setup Gradle diff --git a/README.md b/README.md index 91519d3..054570d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ⏳ Advanced PlayTime Plugin [![Build Status](https://github.com/imDMK/AdvancedPlayTime/actions/workflows/gradle.yml/badge.svg)](https://github.com/imDMK/AdvancedPlayTime/actions/workflows/gradle.yml) -![JDK](https://img.shields.io/badge/JDK-1.21-blue.svg) -![Supported versions](https://img.shields.io/badge/Minecraft-1.21--1.21.11-green.svg) +![JDK](https://img.shields.io/badge/JDK-25-blue.svg) +![Supported versions](https://img.shields.io/badge/Minecraft-1.21--26.2-green.svg) [![SpigotMC](https://img.shields.io/badge/SpigotMC-yellow.svg)](https://www.spigotmc.org/resources/%E2%8F%B0%EF%B8%8F-advancedplaytime-1-21-1-21-10.130458/) [![Modrinth](https://img.shields.io/badge/Modrinth-1bd96a.svg)](https://modrinth.com/plugin/advancedplaytime) [![bStats](https://img.shields.io/badge/bStats-00695c)](https://bstats.org/plugin/bukkit/PlayTime/19362) @@ -13,6 +13,10 @@ --- +### 📦 Requirements +- **Java 25** or newer on the server. +- **Spigot / Paper 1.21 – 26.2**. + ### ✨ Key Features - 🧠 **Highly optimized** – Zero-lag performance, even on large servers. - 🎨 **Fully customizable GUIs** – Design the look and feel to fit your server's style. diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 2cd8bba..e32b6af 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,6 +9,7 @@ repositories { dependencies { implementation("com.gradleup.shadow:shadow-gradle-plugin:9.6.1") implementation("net.minecrell:plugin-yml:0.6.0") + implementation("org.ow2.asm:asm:9.10.1") } sourceSets { diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 04c9a4c..e445c89 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,7 +1,17 @@ object Versions { + /** Java release the plugin is compiled for and requires at runtime. */ + const val JAVA = 25 + + /** Spigot API the plugin is compiled against (newest supported server). */ const val SPIGOT_API = "26.2-R0.1-SNAPSHOT" + /** Oldest supported Spigot API, verified by the `compileJavaSpigotMin` task. */ + const val SPIGOT_API_MIN = "1.21-R0.1-SNAPSHOT" + + /** Value of `api-version` in plugin.yml. */ + const val SPIGOT_API_VERSION = "1.21" + const val PLACEHOLDER_API = "2.12.3" const val PANDA_DI = "1.8.0" diff --git a/buildSrc/src/main/kotlin/playtime-java.gradle.kts b/buildSrc/src/main/kotlin/playtime-java.gradle.kts index d85f432..3336939 100644 --- a/buildSrc/src/main/kotlin/playtime-java.gradle.kts +++ b/buildSrc/src/main/kotlin/playtime-java.gradle.kts @@ -6,11 +6,11 @@ group = "com.github.imdmk" version = "3.0.2" java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + toolchain.languageVersion.set(JavaLanguageVersion.of(Versions.JAVA)) } -tasks.compileJava { +tasks.withType().configureEach { options.compilerArgs = listOf("-Xlint:deprecation", "-parameters") options.encoding = "UTF-8" - options.release = 21 -} \ No newline at end of file + options.release.set(Versions.JAVA) +} diff --git a/buildSrc/src/main/kotlin/playtime-spigot-compat.gradle.kts b/buildSrc/src/main/kotlin/playtime-spigot-compat.gradle.kts new file mode 100644 index 0000000..06e9145 --- /dev/null +++ b/buildSrc/src/main/kotlin/playtime-spigot-compat.gradle.kts @@ -0,0 +1,328 @@ +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import java.util.zip.ZipFile + +plugins { + `java-library` +} + +/** + * Keeps the plugin usable on the whole advertised server range. + * + * The shipped artifact is built against the newest Spigot API, so two things can + * silently break on the oldest supported server: sources may reference API that did + * not exist yet, and call sites may be emitted against a type whose shape changed + * (`org.bukkit.Sound`, for instance, was an enum in 1.21 and is an interface today). + * + * `compileJavaSpigotMin` catches the first case, `checkSpigotBinaryCompatibility` the + * second. Both run as part of `check`. + */ +val spigotMinApi: Configuration = configurations.create("spigotMinApi") { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + spigotMinApi("org.spigotmc:spigot-api:${Versions.SPIGOT_API_MIN}") +} + +/** A single method/field reference emitted into one of our own classes. */ +data class MemberReference( + val owner: String, + val name: String, + val descriptor: String, + val field: Boolean, + val viaInterface: Boolean +) + +/** Everything relevant we know about one class of the reference API. */ +data class ApiClass( + val name: String, + val isInterface: Boolean, + val superName: String?, + val interfaces: List, + val methods: Set, + val fields: Set +) + +abstract class CheckSpigotBinaryCompatibilityTask : DefaultTask() { + + private val bukkitPackage = "org/bukkit/" + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val classDirectories: ConfigurableFileCollection + + @get:InputFiles + @get:Classpath + abstract val minimumApi: ConfigurableFileCollection + + @get:Input + abstract val minimumApiVersion: Property + + @get:OutputFile + abstract val report: RegularFileProperty + + @TaskAction + fun check() { + + val api = readApi() + val classReferences = sortedSetOf() + val memberReferences = mutableSetOf() + + readOwnClasses(classReferences, memberReferences) + + val problems = mutableListOf() + + for (reference in classReferences) { + if (!api.containsKey(reference)) { + problems += "unknown type $reference" + } + } + + for (reference in memberReferences.sortedBy { "${it.owner}#${it.name}${it.descriptor}" }) { + + val owner = api[reference.owner] + + if (owner == null) { + problems += "unknown type ${reference.owner}" + continue + } + + if (!hasMember(api, reference.owner, reference)) { + problems += "unknown ${if (reference.field) "field" else "method"} " + + "${reference.owner}#${reference.name}${if (reference.field) "" else reference.descriptor}" + continue + } + + if (!reference.field && reference.viaInterface != owner.isInterface) { + problems += "${reference.owner} is a ${if (owner.isInterface) "interface" else "class"} on " + + "${minimumApiVersion.get()}, but ${reference.name}${reference.descriptor} is compiled as " + + "${if (reference.viaInterface) "invokeinterface" else "invokevirtual/invokestatic"} — " + + "this throws IncompatibleClassChangeError at runtime" + } + } + + val distinct = problems.distinct() + + val file = report.get().asFile + file.parentFile.mkdirs() + file.writeText( + buildString { + appendLine("Spigot API baseline: ${minimumApiVersion.get()}") + appendLine("types referenced: ${classReferences.size}, members referenced: ${memberReferences.size}") + appendLine() + if (distinct.isEmpty()) appendLine("OK") else distinct.forEach { appendLine(it) } + } + ) + + if (distinct.isNotEmpty()) { + throw GradleException( + distinct.joinToString( + prefix = "Not binary compatible with Spigot API ${minimumApiVersion.get()}:\n - ", + separator = "\n - " + ) + ) + } + } + + private fun readApi(): Map { + + val api = mutableMapOf() + + minimumApi.filter { it.isFile }.forEach { jar -> + ZipFile(jar).use { zip -> + zip.entries().asSequence() + .filter { it.name.endsWith(".class") } + .forEach { entry -> + val reader = zip.getInputStream(entry).use { ClassReader(it.readBytes()) } + val methods = mutableSetOf() + val fields = mutableSetOf() + + reader.accept(object : ClassVisitor(Opcodes.ASM9) { + + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor? { + methods += "$name$descriptor" + return null + } + + override fun visitField( + access: Int, + name: String, + descriptor: String, + signature: String?, + value: Any? + ): FieldVisitor? { + fields += name + return null + } + }, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + api[reader.className] = ApiClass( + name = reader.className, + isInterface = reader.access and Opcodes.ACC_INTERFACE != 0, + superName = reader.superName, + interfaces = reader.interfaces.toList(), + methods = methods, + fields = fields + ) + } + } + } + + return api + } + + private fun readOwnClasses( + classReferences: MutableSet, + memberReferences: MutableSet + ) { + + fun collectTypes(descriptor: String) { + Regex("L(org/bukkit/[^;<]+);").findAll(descriptor).forEach { classReferences += it.groupValues[1] } + } + + classDirectories.asFileTree.matching { include("**/*.class") }.forEach { file -> + + ClassReader(file.readBytes()).accept(object : ClassVisitor(Opcodes.ASM9) { + + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor { + + collectTypes(descriptor) + + return object : MethodVisitor(Opcodes.ASM9) { + + override fun visitTypeInsn(opcode: Int, type: String) { + if (type.startsWith(bukkitPackage)) classReferences += type + } + + override fun visitLdcInsn(value: Any?) { + if (value is Type && value.sort == Type.OBJECT && value.internalName.startsWith(bukkitPackage)) { + classReferences += value.internalName + } + } + + override fun visitFieldInsn(opcode: Int, owner: String, name: String, descriptor: String) { + collectTypes(descriptor) + if (owner.startsWith(bukkitPackage)) { + memberReferences += MemberReference(owner, name, descriptor, field = true, viaInterface = false) + } + } + + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + collectTypes(descriptor) + if (owner.startsWith(bukkitPackage)) { + memberReferences += MemberReference(owner, name, descriptor, field = false, viaInterface = isInterface) + } + } + } + } + + override fun visitField( + access: Int, + name: String, + descriptor: String, + signature: String?, + value: Any? + ): FieldVisitor? { + collectTypes(descriptor) + return null + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + } + + private fun hasMember(api: Map, owner: String, reference: MemberReference): Boolean { + + val current = api[owner] ?: return hasJdkMember(owner, reference) + + if (reference.field && reference.name in current.fields) return true + if (!reference.field && "${reference.name}${reference.descriptor}" in current.methods) return true + + val parents = buildList { + current.superName?.let { add(it) } + addAll(current.interfaces) + } + + return parents.any { hasMember(api, it, reference) } + } + + /** + * Bukkit types inherit from the JDK — `java.lang.Object` and, for the enums the + * older API still used, `java.lang.Enum`. Those supertypes are not in the Spigot + * jar, so resolve them against the running JVM instead of reporting them missing. + */ + private fun hasJdkMember(owner: String, reference: MemberReference): Boolean { + + if (!owner.startsWith("java/")) return false + + val type = runCatching { Class.forName(owner.replace('/', '.')) }.getOrNull() ?: return false + + if (reference.field) { + return generateSequence(type) { it.superclass } + .any { candidate -> candidate.declaredFields.any { it.name == reference.name } } + } + + return generateSequence(type) { it.superclass } + .any { candidate -> + candidate.declaredMethods.any { + it.name == reference.name && Type.getMethodDescriptor(it) == reference.descriptor + } + } + } +} + +val compileJavaSpigotMin = tasks.register("compileJavaSpigotMin") { + + description = "Compiles the main sources against Spigot API ${Versions.SPIGOT_API_MIN}." + group = LifecycleBasePlugin.VERIFICATION_GROUP + + val main = project.the()["main"] + + source(main.java) + + classpath = spigotMinApi + main.compileClasspath.filter { !it.name.startsWith("spigot-api-") } + + destinationDirectory.set(layout.buildDirectory.dir("classes/java/spigotMin")) +} + +val checkSpigotBinaryCompatibility = + tasks.register("checkSpigotBinaryCompatibility") { + + description = "Verifies that the Bukkit call sites emitted into this module resolve on " + + "Spigot API ${Versions.SPIGOT_API_MIN}." + group = LifecycleBasePlugin.VERIFICATION_GROUP + + classDirectories.from(project.the()["main"].output.classesDirs) + minimumApi.from(spigotMinApi) + minimumApiVersion.set(Versions.SPIGOT_API_MIN) + report.set(layout.buildDirectory.file("reports/spigot-compat/${project.name}.txt")) + + dependsOn(tasks.named("classes")) + } + +tasks.named("check") { + dependsOn(compileJavaSpigotMin, checkSpigotBinaryCompatibility) +} diff --git a/playtime-api/build.gradle.kts b/playtime-api/build.gradle.kts index 4ea3d08..723761b 100644 --- a/playtime-api/build.gradle.kts +++ b/playtime-api/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `playtime-java` `playtime-java-test` `playtime-repositories` + `playtime-spigot-compat` } dependencies { diff --git a/playtime-core/build.gradle.kts b/playtime-core/build.gradle.kts index b8f77c2..f7e9da0 100644 --- a/playtime-core/build.gradle.kts +++ b/playtime-core/build.gradle.kts @@ -3,6 +3,7 @@ plugins { `playtime-java-test` `playtime-repositories` `playtime-runtime-libraries` + `playtime-spigot-compat` } dependencies { diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java index cd91df1..0ff34ee 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java @@ -8,7 +8,6 @@ import org.bukkit.Server; import org.bukkit.Statistic; import org.bukkit.entity.Player; -import org.bukkit.event.player.PlayerTakeLecternBookEvent; import org.jetbrains.annotations.Nullable; import org.panda_lang.utilities.inject.annotations.Inject; diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java index fb6ab1f..81ed1cb 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java @@ -18,7 +18,7 @@ public boolean supports(@NotNull Class type) { @Override public void serialize(Enchantment enchantment, SerializationData data, @NotNull GenericsDeclaration generics) { - data.setValue(enchantment.getKeyOrThrow().toString(), String.class); + data.setValue(RegistryKeys.keyOf(enchantment).toString(), String.class); } @Override diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/RegistryKeys.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/RegistryKeys.java new file mode 100644 index 0000000..8766428 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/RegistryKeys.java @@ -0,0 +1,29 @@ +package com.github.imdmk.playtime.core.platform.serdes; + +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; + +/** + * Reads the {@link NamespacedKey} of a registry value in a way that stays binary + * compatible across the whole supported Spigot range. + * + *

{@code getKeyOrThrow()} only exists on {@code org.bukkit.registry.RegistryAware}, + * introduced long after 1.21, and types such as {@link org.bukkit.Sound} were plain + * enums back then instead of interfaces. Going through {@link Keyed} — an interface in + * every supported version — keeps the emitted call site valid on 1.21 and on 26.2. + */ +final class RegistryKeys { + + private RegistryKeys() { + } + + static NamespacedKey keyOf(Keyed keyed) { + NamespacedKey key = keyed.getKey(); + + if (key == null) { + throw new IllegalArgumentException("Registry value is not registered: " + keyed); + } + + return key; + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java index 581729c..ae5da07 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java @@ -18,7 +18,7 @@ public boolean supports(@NotNull Class type) { @Override public void serialize(Sound sound, SerializationData data, @NotNull GenericsDeclaration generics) { - data.setValue(sound.getKeyOrThrow().toString(), String.class); + data.setValue(RegistryKeys.keyOf(sound).toString(), String.class); } @Override diff --git a/playtime-plugin/build.gradle.kts b/playtime-plugin/build.gradle.kts index 0f47309..4eb2380 100644 --- a/playtime-plugin/build.gradle.kts +++ b/playtime-plugin/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `playtime-java` `playtime-repositories` `playtime-shadow` + `playtime-spigot-compat` id("xyz.jpenilla.run-paper") version "3.1.0" } @@ -20,7 +21,7 @@ playTimeShadow { pluginYml { name = "AdvancedPlayTime" version = project.version.toString() - apiVersion = "1.21" + apiVersion = Versions.SPIGOT_API_VERSION softDepend = listOf("PlaceholderAPI") main = "com.github.imdmk.playtime.plugin.PlayTimePlugin" author = "imDMK (dominiks8318@gmail.com)" @@ -29,7 +30,7 @@ playTimeShadow { } shadowJar { - archiveFileName.set("AdvancedPlayTime v${project.version} (MC 1.21.x).jar") + archiveFileName.set("AdvancedPlayTime v${project.version} (MC 1.21-26.2, Java ${Versions.JAVA}).jar") mergeServiceFiles() diff --git a/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java b/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java index a2f877c..50b3df0 100644 --- a/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java +++ b/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java @@ -2,8 +2,6 @@ import org.bukkit.plugin.java.JavaPlugin; -import java.util.logging.Logger; - public final class PlayTimePlugin extends JavaPlugin { private PlayTimeCoreWrapper wrapper;