diff --git a/api/src/main/java/xyz/jonesdev/sonar/api/SonarPlatform.java b/api/src/main/java/xyz/jonesdev/sonar/api/SonarPlatform.java
index 02b3f2aa..fefffb69 100644
--- a/api/src/main/java/xyz/jonesdev/sonar/api/SonarPlatform.java
+++ b/api/src/main/java/xyz/jonesdev/sonar/api/SonarPlatform.java
@@ -30,6 +30,8 @@ public enum SonarPlatform {
pipeline -> pipeline.context("outbound_config") != null ? "outbound_config" : "encoder"),
BUNGEE("BungeeCord", 19109, "inbound-boss",
pipeline -> "packet-encoder"),
+ PAPER("Paper", 19110, "packet_handler",
+ pipeline -> pipeline.context("outbound_config") != null ? "outbound_config" : "encoder"),
VELOCITY("Velocity", 19107, "handler",
pipeline -> "minecraft-encoder");
diff --git a/build.gradle.kts b/build.gradle.kts
index 52cf78e6..889ff48a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -102,7 +102,7 @@ allprojects {
tasks {
// This is a small wrapper tasks to simplify the building process
register("build-sonar") {
- val subprojects = listOf("api", "captcha", "common", "bukkit", "bungeecord", "velocity")
+ val subprojects = listOf("api", "captcha", "common", "bukkit", "bungeecord", "paper", "velocity")
val buildTasks = subprojects.flatMap { listOf("$it:clean", "$it:spotlessApply", "$it:shadowJar") }
dependsOn(buildTasks)
}
diff --git a/common/src/main/java/xyz/jonesdev/sonar/common/boot/LibraryLoader.java b/common/src/main/java/xyz/jonesdev/sonar/common/boot/LibraryLoader.java
index cda3773a..3fca756f 100644
--- a/common/src/main/java/xyz/jonesdev/sonar/common/boot/LibraryLoader.java
+++ b/common/src/main/java/xyz/jonesdev/sonar/common/boot/LibraryLoader.java
@@ -68,26 +68,38 @@ void loadLibraries(final @NotNull LibraryManager libraryManager, final @NotNull
// Only load adventure if not on Velocity
if (platform != SonarPlatform.VELOCITY) {
- libraryManager.loadLibraries(
- Library.builder()
- .groupId("net{}kyori")
- .artifactId("adventure-text-minimessage")
- .version("4.21.0")
- .relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
- .build(),
- Library.builder()
- .groupId("net{}kyori")
- .artifactId("adventure-text-serializer-gson")
- .version("4.21.0")
- .relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
- .build(),
- Library.builder()
- .groupId("net{}kyori")
- .artifactId("adventure-nbt")
- .version("4.21.0")
- .relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
- .build()
- );
+ if (platform != SonarPlatform.PAPER) {
+ libraryManager.loadLibraries(
+ Library.builder()
+ .groupId("net{}kyori")
+ .artifactId("adventure-text-minimessage")
+ .version("4.21.0")
+ .relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
+ .build(),
+ Library.builder()
+ .groupId("net{}kyori")
+ .artifactId("adventure-text-serializer-gson")
+ .version("4.21.0")
+ .relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
+ .build(),
+ Library.builder()
+ .groupId("net{}kyori")
+ .artifactId("adventure-nbt")
+ .version("4.21.0")
+ .relocate("net{}kyori", "xyz{}jonesdev{}sonar{}libs{}kyori")
+ .build()
+ );
+ } else {
+ // On Paper, we only relocate the nbt package since the rest of adventure is bundled with the server.
+ libraryManager.loadLibrary(
+ Library.builder()
+ .groupId("net{}kyori")
+ .artifactId("adventure-nbt")
+ .version("4.21.0")
+ .relocate("net{}kyori{}adventure{}nbt", "xyz{}jonesdev{}sonar{}libs{}kyori{}adventure{}nbt")
+ .build()
+ );
+ }
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e4f74104..8468ac05 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,6 +12,7 @@ shadow = "com.gradleup.shadow:8.3.8"
spotless = "com.diffplug.spotless:7.2.1"
pluginyml-bungee = { id = "net.minecrell.plugin-yml.bungee", version.ref = "pluginyml" }
pluginyml-bukkit = { id = "net.minecrell.plugin-yml.bukkit", version.ref = "pluginyml" }
+pluginyml-paper = { id = "net.minecrell.plugin-yml.paper", version.ref = "pluginyml" }
[libraries]
netty = { module = "io.netty:netty-all", version.ref = "netty" }
@@ -23,6 +24,7 @@ adventure-platform-bungee = { module = "net.kyori:adventure-platform-bungeecord"
libby-core = { module = "com.alessiodp.libby:libby-core", version.ref = "libby" }
libby-bungee = { module = "com.alessiodp.libby:libby-bungee", version.ref = "libby" }
libby-bukkit = { module = "com.alessiodp.libby:libby-bukkit", version.ref = "libby" }
+libby-paper = { module = "com.alessiodp.libby:libby-paper", version.ref = "libby" }
libby-velocity = { module = "com.alessiodp.libby:libby-velocity", version.ref = "libby" }
bstats-bungee = { module = "org.bstats:bstats-bungeecord", version.ref = "bstats" }
bstats-bukkit = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" }
@@ -35,5 +37,6 @@ ormlite = "com.j256.ormlite:ormlite-jdbc:6.1"
lombok = "org.projectlombok:lombok:1.18.42"
bungeecord = "net.md-5:bungeecord-proxy:1.21-SNAPSHOT"
velocity = "io.papermc.velocity:velocity-proxy:3.4.0-SNAPSHOT"
+paper = "io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT"
# We have to use 1.16.5 for backwards compatibility
spigot = "org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT"
diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts
new file mode 100644
index 00000000..6b0ff7dd
--- /dev/null
+++ b/paper/build.gradle.kts
@@ -0,0 +1,52 @@
+import net.minecrell.pluginyml.bukkit.BukkitPluginDescription
+
+plugins {
+ alias(libs.plugins.pluginyml.paper) apply true
+}
+
+paper {
+ name = rootProject.name
+ main = "xyz.jonesdev.sonar.paper.SonarPaperPlugin"
+ authors = listOf("Jones Development", "Sonar Contributors")
+ website = "https://jonesdev.xyz/discord/"
+ load = BukkitPluginDescription.PluginLoadOrder.POSTWORLD
+ apiVersion = "1.21" // ignore legacy plugin warning
+ foliaSupported = true
+
+ serverDependencies {
+ listOf("Geyser-Spigot", "floodgate", "Protocolize", "ProtocolSupport",
+ "ViaVersion", "packetevents", "ProtocolLib", "FastLogin").forEach {
+ register(it) {
+ required = false
+ }
+ }
+ }
+}
+
+java {
+ // Modern Paper requires Java 21
+ java.sourceCompatibility = JavaVersion.VERSION_21
+ java.targetCompatibility = JavaVersion.VERSION_21
+ toolchain.languageVersion.set(JavaLanguageVersion.of(21))
+}
+
+repositories {
+ maven(url = "https://repo.papermc.io/repository/maven-public/") // Paper
+}
+
+dependencies {
+ implementation(project(":api"))
+ implementation(project(":common"))
+ implementation(project(":bukkit"))
+
+ compileOnly(rootProject.libs.paper)
+
+ implementation(rootProject.libs.bstats.bukkit)
+ implementation(rootProject.libs.libby.paper)
+}
+
+tasks {
+ shadowJar {
+ relocate("net.kyori.adventure.nbt", "xyz.jonesdev.sonar.libs.kyori.adventure.nbt")
+ }
+}
diff --git a/paper/src/main/java/xyz/jonesdev/sonar/paper/SonarPaper.java b/paper/src/main/java/xyz/jonesdev/sonar/paper/SonarPaper.java
new file mode 100644
index 00000000..034cbc97
--- /dev/null
+++ b/paper/src/main/java/xyz/jonesdev/sonar/paper/SonarPaper.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2026 Sonar Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package xyz.jonesdev.sonar.paper;
+
+import com.alessiodp.libby.PaperLibraryManager;
+import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
+import lombok.Getter;
+import net.kyori.adventure.audience.Audience;
+import org.bstats.bukkit.Metrics;
+import org.bstats.charts.SimplePie;
+import org.bukkit.Bukkit;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import xyz.jonesdev.sonar.paper.command.PaperSonarCommand;
+import xyz.jonesdev.sonar.api.SonarPlatform;
+import xyz.jonesdev.sonar.api.logger.LoggerWrapper;
+import xyz.jonesdev.sonar.bukkit.SonarBukkit;
+import xyz.jonesdev.sonar.bukkit.antibot.BukkitInjector;
+import xyz.jonesdev.sonar.bukkit.listener.BukkitJoinListener;
+import xyz.jonesdev.sonar.common.boot.SonarBootstrap;
+
+import java.util.UUID;
+
+@Getter
+public final class SonarPaper extends SonarBootstrap {
+ private final LoggerWrapper logger = new LoggerWrapper() {
+
+ @Override
+ public void info(final String message, final Object... args) {
+ getPlugin().getLogger().info(buildFullMessage(message, args));
+ }
+
+ @Override
+ public void warn(final String message, final Object... args) {
+ getPlugin().getLogger().warning(buildFullMessage(message, args));
+ }
+
+ @Override
+ public void error(final String message, final Object... args) {
+ getPlugin().getLogger().severe(buildFullMessage(message, args));
+ }
+ };
+ private Metrics metrics;
+
+ public SonarPaper(final @NotNull SonarPaperPlugin plugin) {
+ super(plugin, SonarPlatform.PAPER, plugin.getDataFolder(), new PaperLibraryManager(plugin));
+ }
+
+ @Override
+ public @Nullable Audience audience(@Nullable final UUID uniqueId) {
+ if (uniqueId == null) return null;
+ return Bukkit.getPlayer(uniqueId);
+ }
+
+ @Override
+ public @NotNull Audience sender(@NotNull final Object object) {
+ return (Audience) object;
+ }
+
+ @Override
+ public @NotNull LoggerWrapper getLogger() {
+ return logger;
+ }
+
+ @Override
+ public void enable() {
+ // Initialize bStats.org metrics
+ metrics = new Metrics(getPlugin(), getPlatform().getMetricsId());
+
+ // Add charts for some configuration options
+ metrics.addCustomChart(new SimplePie("verification",
+ () -> getConfig().getVerification().getTiming().getDisplayName()));
+ metrics.addCustomChart(new SimplePie("captcha",
+ () -> getConfig().getVerification().getMap().getTiming().getDisplayName()));
+ metrics.addCustomChart(new SimplePie("language",
+ () -> getConfig().getLanguage().getName()));
+ metrics.addCustomChart(new SimplePie("database_type",
+ () -> getConfig().getDatabase().getType().getDisplayName()));
+
+ // Register Sonar command
+ getPlugin().getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event ->
+ event.registrar().register("sonar", new PaperSonarCommand()));
+
+ // Try to inject into the server
+ if (BukkitInjector.isLateBindEnabled()) {
+ getPlugin().getServer().getScheduler().runTask(getPlugin(), BukkitInjector::inject);
+ } else {
+ getPlugin().getServer().getPluginManager().registerEvents(new BukkitJoinListener(), getPlugin());
+ }
+
+ // Let the injector know that the plugin has been enabled
+ SonarBukkit.INITIALIZE_LISTENER.complete(null);
+ }
+
+ @Override
+ public void disable() {
+ // Make sure to properly stop the metrics
+ if (metrics != null) {
+ metrics.shutdown();
+ }
+ }
+}
diff --git a/paper/src/main/java/xyz/jonesdev/sonar/paper/SonarPaperPlugin.java b/paper/src/main/java/xyz/jonesdev/sonar/paper/SonarPaperPlugin.java
new file mode 100644
index 00000000..82fc01f7
--- /dev/null
+++ b/paper/src/main/java/xyz/jonesdev/sonar/paper/SonarPaperPlugin.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2026 Sonar Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package xyz.jonesdev.sonar.paper;
+
+import org.bukkit.plugin.java.JavaPlugin;
+import xyz.jonesdev.sonar.bukkit.antibot.BukkitInjector;
+
+public final class SonarPaperPlugin extends JavaPlugin {
+ private SonarPaper bootstrap;
+
+ @Override
+ public void onLoad() {
+ // Inject early if late-bind is disabled
+ if (!BukkitInjector.isLateBindEnabled()) {
+ BukkitInjector.inject();
+ }
+ }
+
+ @Override
+ public void onEnable() {
+ bootstrap = new SonarPaper(this);
+ bootstrap.initialize();
+ }
+
+ @Override
+ public void onDisable() {
+ bootstrap.shutdown();
+ }
+}
diff --git a/paper/src/main/java/xyz/jonesdev/sonar/paper/command/PaperSonarCommand.java b/paper/src/main/java/xyz/jonesdev/sonar/paper/command/PaperSonarCommand.java
new file mode 100644
index 00000000..ed01f722
--- /dev/null
+++ b/paper/src/main/java/xyz/jonesdev/sonar/paper/command/PaperSonarCommand.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2026 Sonar Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package xyz.jonesdev.sonar.paper.command;
+
+import io.papermc.paper.command.brigadier.BasicCommand;
+import io.papermc.paper.command.brigadier.CommandSourceStack;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import xyz.jonesdev.sonar.api.command.InvocationSource;
+import xyz.jonesdev.sonar.api.command.SonarCommand;
+
+import java.util.Collection;
+import java.util.Collections;
+
+public final class PaperSonarCommand implements BasicCommand, SonarCommand {
+ @Override
+ public void execute(final CommandSourceStack commandSourceStack, final String @NonNull [] args) {
+ final CommandSender sender = commandSourceStack.getSender();
+ final InvocationSource invocationSource = new InvocationSource(
+ sender instanceof Player ? ((Player) sender).getUniqueId() : null,
+ sender,
+ sender::hasPermission
+ );
+ handle(invocationSource, args);
+ }
+
+ @Override
+ public @NonNull Collection suggest(final CommandSourceStack commandSourceStack, final String @NonNull [] args) {
+ // Do not allow tab completion if the player does not have the required permission
+ return commandSourceStack.getSender().hasPermission("sonar.command") ?
+ getCachedTabSuggestions(args) : Collections.emptyList();
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 3306b5d0..87a09aad 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -6,6 +6,7 @@ sequenceOf(
"common",
"bukkit",
"bungeecord",
+ "paper",
"velocity"
).forEach {
include(":$it")