From fab7cb9f43ebc95cc98465025bb821b6eaba99a0 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 16 Oct 2026 23:50:53 -0400 Subject: [PATCH] fix(stealth): support Purpur 26.3 --- README.md | 4 ++-- build.gradle.kts | 16 ++++++------- .../spigotstealth/EyePacketListenerTest.java | 6 ++--- .../spigotstealth/EyePluginLifecycleTest.java | 6 ++--- .../spigotstealth/EyeRevealRuntimeTest.java | 6 ++--- .../dmg/spigotstealth/NativeChatWire.java | 9 +++---- .../NativeCombatTabRestoreTest.java | 6 ++--- .../spigotstealth/NativePotionDamageTest.java | 1 - .../dmg/spigotstealth/NativeRuntime.java | 10 ++++---- .../spigotstealth/NativeTabMaskingTest.java | 6 ++--- .../spigotstealth/PurpurDistributionTest.java | 24 +++++++++++++++++-- 11 files changed, 57 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index fe53583..04bec38 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 @@ -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. -`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 diff --git a/build.gradle.kts b/build.gradle.kts index e1f24ae..04b3978 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,10 +27,10 @@ tasks.withType().configureEach { } 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") - 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(platform("org.junit:junit-bom:5.13.4")) 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. -val stealthRuntime = layout.buildDirectory.dir("stealth-runtime") +val stealthRuntime = layout.buildDirectory.dir("stealth-runtime-26.3-2651") 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) doLast { val file = launcher.get().asFile 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.readTimeout = 120_000 val bytes = connection.getInputStream().use { it.readBytes() } val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } - check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { - "Purpur 2618 checksum mismatch" + check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") { + "Purpur 2651 checksum mismatch" } file.writeBytes(bytes) } @@ -68,7 +68,7 @@ val downloadStealthRuntime = tasks.register("downloadStealthRuntime") { val prepareStealthRuntime = tasks.register("prepareStealthRuntime") { dependsOn(downloadStealthRuntime) 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" jvmArgs("-Dpaperclip.patchonly=true") workingDir(stealthRuntime) diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java index 070957d..de6b6f9 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePacketListenerTest.java @@ -31,9 +31,9 @@ class EyePacketListenerTest { try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) { case "getServer" -> server; case "isPrimaryThread" -> true; - case "getVersion" -> "Purpur 2618 (MC: 26.2)"; - case "getMinecraftVersion" -> "26.2"; - case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT"; default -> call.callRealMethod(); })) { UUID viewerId = UUID.randomUUID(); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java index fe3e928..e920593 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyePluginLifecycleTest.java @@ -94,9 +94,9 @@ class EyePluginLifecycleTest { 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()) { case "getServer" -> server; - case "getVersion" -> "Purpur 2618 (MC: 26.2)"; - case "getMinecraftVersion" -> "26.2"; - case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT"; default -> call.callRealMethod(); }); var protocol = mockStatic(ProtocolLibrary.class)) { var protocolManager = mock(ProtocolManager.class); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java b/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java index 454db71..45f862e 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/EyeRevealRuntimeTest.java @@ -51,9 +51,9 @@ class EyeRevealRuntimeTest { try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) { case "getServer" -> server; case "isPrimaryThread" -> true; - case "getVersion" -> "Purpur 2618 (MC: 26.2)"; - case "getMinecraftVersion" -> "26.2"; - case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT"; default -> call.callRealMethod(); })) { var world = mock(World.class); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java index f3d9510..91e7aeb 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeChatWire.java @@ -52,12 +52,13 @@ final class NativeChatWire { var decoded = ClientboundPlayerChatPacket.STREAM_CODEC.decode(buffer); assertEquals(nextChatIndex++, decoded.globalIndex(), "system messages must not create signed packet-index gaps"); var body = decoded.body().unpack(clientCache).orElseThrow(); - var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), decoded.signature(), body, - decoded.unsignedContent(), decoded.filterMask()); + var signature = decoded.signature().orElseThrow(); + var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), signature, body, + decoded.unsignedContent().orElse(null), decoded.filterMask()); assertEquals(expected, body.content()); assertNotNull(validator.updateAndValidate(message), "the retained native client signature chain must accept normal chat again"); - assertTrue(clientSeen.addPending(decoded.signature(), true)); - clientCache.push(body, decoded.signature()); + assertTrue(clientSeen.addPending(signature, true)); + clientCache.push(body, signature); } } finally { buffer.release(); } var acknowledgement = clientSeen.generateAndApplyUpdate(); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java index a56cc34..76519a3 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeCombatTabRestoreTest.java @@ -24,9 +24,9 @@ class NativeCombatTabRestoreTest { try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) { case "getServer" -> server; case "isPrimaryThread" -> true; - case "getVersion" -> "Purpur 2618 (MC: 26.2)"; - case "getMinecraftVersion" -> "26.2"; - case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT"; default -> call.callRealMethod(); })) { var target = mock(CraftPlayer.class); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativePotionDamageTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativePotionDamageTest.java index 0f49a0c..accbba6 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/NativePotionDamageTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativePotionDamageTest.java @@ -46,7 +46,6 @@ class NativePotionDamageTest { var nativeAttacker = mock(ServerPlayer.class); when(victim.getHandle()).thenReturn(nativeVictim); when(nativeVictim.getBukkitEntity()).thenReturn(victim); - when(nativeVictim.getBukkitLivingEntity()).thenReturn(victim); when(nativeAttacker.getBukkitEntity()).thenReturn(attacker); var active = new HashMap, MobEffectInstance>(); var field = LivingEntity.class.getDeclaredField("activeEffects"); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java index 5e66104..5edb48a 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeRuntime.java @@ -41,12 +41,12 @@ final class NativeRuntime { try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) { LayeredRegistryAccess layers = RegistryLayer.createRegistryAccess(); List> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); - List> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags); - RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups, - RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join(); - layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen); + List> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags); + RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups, + RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join(); + layers = layers.replaceFrom(RegistryLayer.WORLD, world); 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(); layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions); Class.forName(org.bukkit.Registry.class.getName()); diff --git a/src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java b/src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java index 6af6086..e44c154 100644 --- a/src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java +++ b/src/nativeTest/java/games/dmg/spigotstealth/NativeTabMaskingTest.java @@ -30,9 +30,9 @@ class NativeTabMaskingTest { try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) { case "getServer" -> server; case "isPrimaryThread" -> true; - case "getVersion" -> "Purpur 2618 (MC: 26.2)"; - case "getMinecraftVersion" -> "26.2"; - case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT"; + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT"; default -> call.callRealMethod(); })) { UUID hiddenId = UUID.randomUUID(), otherId = UUID.randomUUID(), viewerId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotstealth/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotstealth/PurpurDistributionTest.java index 0ffff7a..77bb5f1 100644 --- a/src/test/java/games/dmg/spigotstealth/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotstealth/PurpurDistributionTest.java @@ -21,9 +21,22 @@ class PurpurDistributionTest { assertEquals(version, metadata.get("version")); assertEquals("SpigotStealth", metadata.get("name"), "Retain the data directory and persistent namespace"); 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")); 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 void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception { 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")); for (String workflow : new String[] {"ci.yml", "release.yml"}) { String content = Files.readString(Path.of(".gitea/workflows", workflow));