fix(stealth): support Purpur 26.3
CI / build (pull_request) Successful in 59s
CI / build (push) Successful in 1m21s

This commit is contained in:
dmg committed 2026-10-16 23:50:53 -04:00
1 parent 8b412141da
commit fab7cb9f43
11 files changed
+57 -37

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
# Purpur Stealth # Purpur Stealth
Progression-gated identity concealment for Purpur 26.2 build 2618, built and tested with Java 25. ProtocolLib 5.4.0 is required. Progression-gated identity concealment for Purpur 26.3 build 2651, built and tested with Java 25. ProtocolLib 5.4.0 is required.
## Build ## Build
@@ -34,7 +34,7 @@ Revelation uses private client-only teams and recipient-specific metadata; it do
Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed. Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed.
`check` includes `nativeStealthTest`, which downloads SHA-256-pinned Purpur 2618, patches it without starting a listening server, and exercises real potion events, items, recipes, event dispatch and plugin lifecycle with external platform doubles. Live-client appearance and gameplay checks remain separate follow-ups. `check` includes `nativeStealthTest`, which downloads SHA-256-pinned Purpur 26.3 build 2651, patches it without starting a listening server, and exercises real potion events, items, recipes, event dispatch and plugin lifecycle with external platform doubles. Live-client appearance and gameplay checks remain separate follow-ups.
## Migration compatibility ## Migration compatibility
+8 -8
View File
@@ -27,10 +27,10 @@ tasks.withType<JavaCompile>().configureEach {
} }
dependencies { dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
compileOnly("net.dmulloy2:ProtocolLib:5.4.0") compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation("net.dmulloy2:ProtocolLib:5.4.0") testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -47,20 +47,20 @@ tasks.test {
} }
// Exercise real potion events, item components and recipes without starting a listening server. // Exercise real potion events, item components and recipes without starting a listening server.
val stealthRuntime = layout.buildDirectory.dir("stealth-runtime") val stealthRuntime = layout.buildDirectory.dir("stealth-runtime-26.3-2651")
val downloadStealthRuntime = tasks.register("downloadStealthRuntime") { val downloadStealthRuntime = tasks.register("downloadStealthRuntime") {
val launcher = stealthRuntime.map { it.file("purpur-26.2-2618.jar") } val launcher = stealthRuntime.map { it.file("purpur-26.3-2651.jar") }
outputs.file(launcher) outputs.file(launcher)
doLast { doLast {
val file = launcher.get().asFile val file = launcher.get().asFile
file.parentFile.mkdirs() file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection() val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download").toURL().openConnection()
connection.connectTimeout = 30_000 connection.connectTimeout = 30_000
connection.readTimeout = 120_000 connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() } val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2618 checksum mismatch" "Purpur 2651 checksum mismatch"
} }
file.writeBytes(bytes) file.writeBytes(bytes)
} }
@@ -68,7 +68,7 @@ val downloadStealthRuntime = tasks.register("downloadStealthRuntime") {
val prepareStealthRuntime = tasks.register<JavaExec>("prepareStealthRuntime") { val prepareStealthRuntime = tasks.register<JavaExec>("prepareStealthRuntime") {
dependsOn(downloadStealthRuntime) dependsOn(downloadStealthRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(stealthRuntime.map { it.file("purpur-26.2-2618.jar") }) classpath = files(stealthRuntime.map { it.file("purpur-26.3-2651.jar") })
mainClass = "io.papermc.paperclip.Main" mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true") jvmArgs("-Dpaperclip.patchonly=true")
workingDir(stealthRuntime) workingDir(stealthRuntime)
@@ -31,9 +31,9 @@ class EyePacketListenerTest {
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) { try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server; case "getServer" -> server;
case "isPrimaryThread" -> true; case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)"; case "getVersion" -> "Purpur 2651 (MC: 26.3)";
case "getMinecraftVersion" -> "26.2"; case "getMinecraftVersion" -> "26.3";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
default -> call.callRealMethod(); default -> call.callRealMethod();
})) { })) {
UUID viewerId = UUID.randomUUID(); UUID viewerId = UUID.randomUUID();
@@ -94,9 +94,9 @@ class EyePluginLifecycleTest {
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(plugins).registerEvents(any(), eq(plugin)); doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(plugins).registerEvents(any(), eq(plugin));
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) { try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server; case "getServer" -> server;
case "getVersion" -> "Purpur 2618 (MC: 26.2)"; case "getVersion" -> "Purpur 2651 (MC: 26.3)";
case "getMinecraftVersion" -> "26.2"; case "getMinecraftVersion" -> "26.3";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
default -> call.callRealMethod(); default -> call.callRealMethod();
}); var protocol = mockStatic(ProtocolLibrary.class)) { }); var protocol = mockStatic(ProtocolLibrary.class)) {
var protocolManager = mock(ProtocolManager.class); var protocolManager = mock(ProtocolManager.class);
@@ -51,9 +51,9 @@ class EyeRevealRuntimeTest {
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) { try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server; case "getServer" -> server;
case "isPrimaryThread" -> true; case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)"; case "getVersion" -> "Purpur 2651 (MC: 26.3)";
case "getMinecraftVersion" -> "26.2"; case "getMinecraftVersion" -> "26.3";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
default -> call.callRealMethod(); default -> call.callRealMethod();
})) { })) {
var world = mock(World.class); var world = mock(World.class);
@@ -52,12 +52,13 @@ final class NativeChatWire {
var decoded = ClientboundPlayerChatPacket.STREAM_CODEC.decode(buffer); var decoded = ClientboundPlayerChatPacket.STREAM_CODEC.decode(buffer);
assertEquals(nextChatIndex++, decoded.globalIndex(), "system messages must not create signed packet-index gaps"); assertEquals(nextChatIndex++, decoded.globalIndex(), "system messages must not create signed packet-index gaps");
var body = decoded.body().unpack(clientCache).orElseThrow(); var body = decoded.body().unpack(clientCache).orElseThrow();
var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), decoded.signature(), body, var signature = decoded.signature().orElseThrow();
decoded.unsignedContent(), decoded.filterMask()); var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), signature, body,
decoded.unsignedContent().orElse(null), decoded.filterMask());
assertEquals(expected, body.content()); assertEquals(expected, body.content());
assertNotNull(validator.updateAndValidate(message), "the retained native client signature chain must accept normal chat again"); assertNotNull(validator.updateAndValidate(message), "the retained native client signature chain must accept normal chat again");
assertTrue(clientSeen.addPending(decoded.signature(), true)); assertTrue(clientSeen.addPending(signature, true));
clientCache.push(body, decoded.signature()); clientCache.push(body, signature);
} }
} finally { buffer.release(); } } finally { buffer.release(); }
var acknowledgement = clientSeen.generateAndApplyUpdate(); var acknowledgement = clientSeen.generateAndApplyUpdate();
@@ -24,9 +24,9 @@ class NativeCombatTabRestoreTest {
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) { try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server; case "getServer" -> server;
case "isPrimaryThread" -> true; case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)"; case "getVersion" -> "Purpur 2651 (MC: 26.3)";
case "getMinecraftVersion" -> "26.2"; case "getMinecraftVersion" -> "26.3";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
default -> call.callRealMethod(); default -> call.callRealMethod();
})) { })) {
var target = mock(CraftPlayer.class); var target = mock(CraftPlayer.class);
@@ -46,7 +46,6 @@ class NativePotionDamageTest {
var nativeAttacker = mock(ServerPlayer.class); var nativeAttacker = mock(ServerPlayer.class);
when(victim.getHandle()).thenReturn(nativeVictim); when(victim.getHandle()).thenReturn(nativeVictim);
when(nativeVictim.getBukkitEntity()).thenReturn(victim); when(nativeVictim.getBukkitEntity()).thenReturn(victim);
when(nativeVictim.getBukkitLivingEntity()).thenReturn(victim);
when(nativeAttacker.getBukkitEntity()).thenReturn(attacker); when(nativeAttacker.getBukkitEntity()).thenReturn(attacker);
var active = new HashMap<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>, MobEffectInstance>(); var active = new HashMap<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>, MobEffectInstance>();
var field = LivingEntity.class.getDeclaredField("activeEffects"); var field = LivingEntity.class.getDeclaredField("activeEffects");
@@ -41,12 +41,12 @@ final class NativeRuntime {
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) { try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess(); LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags); List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups, RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join(); RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen); layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources, RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(), Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join(); RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions); layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName()); Class.forName(org.bukkit.Registry.class.getName());
@@ -30,9 +30,9 @@ class NativeTabMaskingTest {
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) { try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server; case "getServer" -> server;
case "isPrimaryThread" -> true; case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)"; case "getVersion" -> "Purpur 2651 (MC: 26.3)";
case "getMinecraftVersion" -> "26.2"; case "getMinecraftVersion" -> "26.3";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
default -> call.callRealMethod(); default -> call.callRealMethod();
})) { })) {
UUID hiddenId = UUID.randomUUID(), otherId = UUID.randomUUID(), viewerId = UUID.randomUUID(); UUID hiddenId = UUID.randomUUID(), otherId = UUID.randomUUID(), viewerId = UUID.randomUUID();
@@ -21,9 +21,22 @@ class PurpurDistributionTest {
assertEquals(version, metadata.get("version")); assertEquals(version, metadata.get("version"));
assertEquals("SpigotStealth", metadata.get("name"), "Retain the data directory and persistent namespace"); assertEquals("SpigotStealth", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.spigotstealth.SpigotStealthPlugin", metadata.get("main")); assertEquals("games.dmg.spigotstealth.SpigotStealthPlugin", metadata.get("main"));
assertEquals("1.20", metadata.get("api-version"), "Retain the deliberate compatibility floor");
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
assertTrue(((Map<?, ?>) metadata.get("commands")).containsKey("stealth"));
assertTrue(((Map<?, ?>) metadata.get("commands")).containsKey("stealthadmin"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotstealth.use"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotstealth.admin")); assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotstealth.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/") assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class"))); || entry.getName().startsWith("org/purpurmc/")
|| entry.getName().startsWith("net/minecraft/")
|| entry.getName().startsWith("com/comphenix/protocol/")
|| entry.getName().startsWith("org/junit/")
|| entry.getName().startsWith("org/mockito/")
|| entry.getName().startsWith("org/yaml/snakeyaml/")
|| entry.getName().endsWith("Test.class")
|| entry.getName().endsWith("NativeRuntime.class")
|| entry.getName().endsWith("NativeChatWire.class")));
} }
} }
@@ -41,7 +54,14 @@ class PurpurDistributionTest {
@Test @Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception { void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts")); String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable")); assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
assertFalse(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("26.2"));
assertFalse(build.contains("2618"));
assertTrue(build.contains("stealth-runtime-26.3-2651"));
assertTrue(build.contains("purpur-26.3-2651.jar"));
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(build.contains("org.spigotmc:spigot-api")); assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) { for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow)); String content = Files.readString(Path.of(".gitea/workflows", workflow));