fix(stealth): support Purpur 26.3 #1

Merged
dmg merged 1 commits from ticket-415-support-purpur-26-3 into main 2026-10-17 03:53:15 +00:00
11 changed files with 57 additions and 37 deletions

No files matched your search

+2 -2
View File
@@ -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
+8 -8
View File
@@ -27,10 +27,10 @@ tasks.withType<JavaCompile>().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<JavaExec>("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)
@@ -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();
@@ -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);
@@ -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);
@@ -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();
@@ -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);
@@ -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<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>, MobEffectInstance>();
var field = LivingEntity.class.getDeclaredField("activeEffects");
@@ -41,12 +41,12 @@ final class NativeRuntime {
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> 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<HolderLookup.RegistryLookup<?>> 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());
@@ -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();
@@ -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));