From 448e9369de911a47134f13bc465ea3b7e82f35b6 Mon Sep 17 00:00:00 2001 From: Lukas Alt Date: Sat, 29 Apr 2023 21:45:47 +0200 Subject: [PATCH] JSON Parsing for WrappedServerPing and fixed modifying favicon (#2265) * Fix WrappedServerPing access and ensure legacy compatability for JSON parsing * added wrappers for mojang codecs and allow serializing server pings --- build.gradle | 2 + pom.xml | 5 ++ .../protocol/utility/MinecraftReflection.java | 16 +++++ .../protocol/wrappers/WrappedServerPing.java | 26 +++++--- .../wrappers/codecs/WrappedCodec.java | 31 ++++++++++ .../wrappers/codecs/WrappedDataResult.java | 50 +++++++++++++++ .../wrappers/codecs/WrappedDynamicOps.java | 26 ++++++++ .../wrappers/ping/LegacyServerPing.java | 5 ++ .../wrappers/ping/ServerPingImpl.java | 4 +- .../wrappers/ping/ServerPingRecord.java | 62 ++++++++++++++++--- .../wrappers/WrappedServerPingTest.java | 13 ++++ 11 files changed, 221 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedCodec.java create mode 100644 src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDataResult.java create mode 100644 src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDynamicOps.java diff --git a/build.gradle b/build.gradle index 52d611a0..9f7d3b5e 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,8 @@ dependencies { testImplementation 'io.netty:netty-common:4.1.77.Final' testImplementation 'io.netty:netty-transport:4.1.77.Final' testImplementation 'org.spigotmc:spigot:1.19.4-R0.1-SNAPSHOT' + testImplementation 'net.kyori:adventure-text-serializer-gson:4.13.0' + testImplementation 'net.kyori:adventure-text-serializer-plain:4.13.1' } java { diff --git a/pom.xml b/pom.xml index 60c41852..eda8f205 100644 --- a/pom.xml +++ b/pom.xml @@ -332,6 +332,11 @@ 4.13.0 provided + + net.kyori + adventure-text-serializer-plain + 4.13.0 + net.bytebuddy diff --git a/src/main/java/com/comphenix/protocol/utility/MinecraftReflection.java b/src/main/java/com/comphenix/protocol/utility/MinecraftReflection.java index 9a7cccd6..8e675d1a 100644 --- a/src/main/java/com/comphenix/protocol/utility/MinecraftReflection.java +++ b/src/main/java/com/comphenix/protocol/utility/MinecraftReflection.java @@ -1685,6 +1685,22 @@ public final class MinecraftReflection { } } + public static Class getDynamicOpsClass() { + return getLibraryClass("com.mojang.serialization.DynamicOps"); + } + + public static Class getJsonOpsClass() { + return getLibraryClass("com.mojang.serialization.JsonOps"); + } + + public static Class getNbtOpsClass() { + return getMinecraftClass("nbt.DynamicOpsNBT" /* Spigot Mappings */, "nbt.NbtOps" /* Mojang Mappings */); + } + + public static Class getCodecClass() { + return getLibraryClass("com.mojang.serialization.Codec"); + } + public static Class getHolderClass() { return getMinecraftClass("core.Holder"); } diff --git a/src/main/java/com/comphenix/protocol/wrappers/WrappedServerPing.java b/src/main/java/com/comphenix/protocol/wrappers/WrappedServerPing.java index fa058b2d..8c948f8a 100644 --- a/src/main/java/com/comphenix/protocol/wrappers/WrappedServerPing.java +++ b/src/main/java/com/comphenix/protocol/wrappers/WrappedServerPing.java @@ -1,11 +1,8 @@ package com.comphenix.protocol.wrappers; -import com.comphenix.protocol.PacketType; import com.comphenix.protocol.injector.BukkitUnwrapper; -import com.comphenix.protocol.reflect.EquivalentConverter; import com.comphenix.protocol.reflect.accessors.Accessors; import com.comphenix.protocol.reflect.accessors.FieldAccessor; -import com.comphenix.protocol.reflect.accessors.MethodAccessor; import com.comphenix.protocol.utility.MinecraftReflection; import com.comphenix.protocol.utility.MinecraftVersion; import com.comphenix.protocol.wrappers.ping.LegacyServerPing; @@ -15,7 +12,6 @@ import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.io.ByteStreams; - import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.handler.codec.base64.Base64; @@ -103,8 +99,10 @@ public class WrappedServerPing implements ClonableWrapper { * @return The wrapped server ping. */ public static WrappedServerPing fromJson(String json) { - // return fromHandle(GSON_FROM_JSON.invoke(PING_GSON.get(null), json, SERVER_PING)); - return null; + if(MinecraftVersion.FEATURE_PREVIEW_2.atOrAbove()) { + return new WrappedServerPing(ServerPingRecord.fromJson(json).getHandle()); + } + return new WrappedServerPing(LegacyServerPing.fromJson(json)); } /** @@ -350,8 +348,7 @@ public class WrappedServerPing implements ClonableWrapper { * @return The JSON representation. */ public String toJson() { - return null; - // return (String) GSON_TO_JSON.invoke(PING_GSON.get(null), getHandle()); + return impl.getJson(); } @Override @@ -546,4 +543,17 @@ public class WrappedServerPing implements ClonableWrapper { return encoded; } } + + @Override + public boolean equals(Object obj) { + if(!(obj instanceof WrappedServerPing)) { + return false; + } + return getHandle().equals(((WrappedServerPing) obj).getHandle()); + } + + @Override + public int hashCode() { + return getHandle().hashCode(); + } } diff --git a/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedCodec.java b/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedCodec.java new file mode 100644 index 00000000..391c8565 --- /dev/null +++ b/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedCodec.java @@ -0,0 +1,31 @@ +package com.comphenix.protocol.wrappers.codecs; + +import com.comphenix.protocol.reflect.accessors.Accessors; +import com.comphenix.protocol.reflect.accessors.MethodAccessor; +import com.comphenix.protocol.utility.MinecraftReflection; +import com.comphenix.protocol.wrappers.AbstractWrapper; + +public class WrappedCodec extends AbstractWrapper { + private static final Class HANDLE_TYPE = MinecraftReflection.getCodecClass(); + private static final Class ENCODER_CLASS = MinecraftReflection.getLibraryClass("com.mojang.serialization.Encoder"); + private static final Class DECODER_CLASS = MinecraftReflection.getLibraryClass("com.mojang.serialization.Decoder"); + private static final MethodAccessor ENCODE_START_ACCESSOR = Accessors.getMethodAccessor(ENCODER_CLASS, "encodeStart", MinecraftReflection.getDynamicOpsClass(), Object.class); + private static final MethodAccessor PARSE_ACCESSOR = Accessors.getMethodAccessor(DECODER_CLASS, "parse", MinecraftReflection.getDynamicOpsClass(), Object.class); + + private WrappedCodec(Object handle) { + super(HANDLE_TYPE); + this.setHandle(handle); + } + + public static WrappedCodec fromHandle(Object handle) { + return new WrappedCodec(handle); + } + + public WrappedDataResult encode(Object object, WrappedDynamicOps ops) { + return WrappedDataResult.fromHandle(ENCODE_START_ACCESSOR.invoke(handle, ops.getHandle(), object)); + } + + public WrappedDataResult parse(Object value, WrappedDynamicOps ops) { + return WrappedDataResult.fromHandle(PARSE_ACCESSOR.invoke(handle, ops.getHandle(), value)); + } +} diff --git a/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDataResult.java b/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDataResult.java new file mode 100644 index 00000000..e430df61 --- /dev/null +++ b/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDataResult.java @@ -0,0 +1,50 @@ +package com.comphenix.protocol.wrappers.codecs; + +import com.comphenix.protocol.reflect.accessors.Accessors; +import com.comphenix.protocol.reflect.accessors.MethodAccessor; +import com.comphenix.protocol.utility.MinecraftReflection; +import com.comphenix.protocol.wrappers.AbstractWrapper; + +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.Function; + +public class WrappedDataResult extends AbstractWrapper { + private final static Class HANDLE_TYPE = MinecraftReflection.getLibraryClass("com.mojang.serialization.DataResult"); + private final static Class PARTIAL_DATA_RESULT_CLASS = MinecraftReflection.getLibraryClass("com.mojang.serialization.DataResult$PartialResult"); + private final static MethodAccessor ERROR_ACCESSOR = Accessors.getMethodAccessor(HANDLE_TYPE, "error"); + private final static MethodAccessor RESULT_ACCESSOR = Accessors.getMethodAccessor(HANDLE_TYPE, "result"); + private final static MethodAccessor PARTIAL_RESULT_MESSAGE_ACCESSOR = Accessors.getMethodAccessor(PARTIAL_DATA_RESULT_CLASS, "message"); + + /** + * Construct a new NMS wrapper. + **/ + public WrappedDataResult(Object handle) { + super(HANDLE_TYPE); + this.setHandle(handle); + } + + public static WrappedDataResult fromHandle(Object handle) { + return new WrappedDataResult(handle); + } + + public Optional getResult() { + return (Optional) RESULT_ACCESSOR.invoke(this.handle); + } + + public Optional getErrorMessage() { + return (Optional) ERROR_ACCESSOR.invoke(this.handle); + } + + public Object getOrThrow(Function errorHandler) { + Optional err = getErrorMessage(); + if(err.isPresent()) { + return errorHandler.apply((String) PARTIAL_RESULT_MESSAGE_ACCESSOR.invoke(err.get())); + } + Optional result = getResult(); + if(result.isPresent()) { + return result.get(); + } + throw new NoSuchElementException(); + } +} diff --git a/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDynamicOps.java b/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDynamicOps.java new file mode 100644 index 00000000..2bc49f8d --- /dev/null +++ b/src/main/java/com/comphenix/protocol/wrappers/codecs/WrappedDynamicOps.java @@ -0,0 +1,26 @@ +package com.comphenix.protocol.wrappers.codecs; + +import com.comphenix.protocol.reflect.accessors.Accessors; +import com.comphenix.protocol.reflect.accessors.FieldAccessor; +import com.comphenix.protocol.utility.MinecraftReflection; +import com.comphenix.protocol.wrappers.AbstractWrapper; + +public class WrappedDynamicOps extends AbstractWrapper { + private static final Class HANDLE_TYPE = MinecraftReflection.getDynamicOpsClass(); + public static final FieldAccessor NBT_ACCESSOR = Accessors.getFieldAccessor(MinecraftReflection.getNbtOpsClass(), MinecraftReflection.getNbtOpsClass(), false); + public static final FieldAccessor[] JSON_ACCESSORS = Accessors.getFieldAccessorArray(MinecraftReflection.getJsonOpsClass(), MinecraftReflection.getJsonOpsClass(), false); + private WrappedDynamicOps(Object handle) { + super(HANDLE_TYPE); + this.setHandle(handle); + } + public static WrappedDynamicOps fromHandle(Object handle) { + return new WrappedDynamicOps(handle); + } + public static WrappedDynamicOps json(boolean compressed) { + return fromHandle(JSON_ACCESSORS[compressed ? 1 : 0].get(null)); + } + + public static WrappedDynamicOps nbt() { + return fromHandle(NBT_ACCESSOR); + } +} diff --git a/src/main/java/com/comphenix/protocol/wrappers/ping/LegacyServerPing.java b/src/main/java/com/comphenix/protocol/wrappers/ping/LegacyServerPing.java index 08cca1b2..4d99e466 100644 --- a/src/main/java/com/comphenix/protocol/wrappers/ping/LegacyServerPing.java +++ b/src/main/java/com/comphenix/protocol/wrappers/ping/LegacyServerPing.java @@ -286,6 +286,11 @@ public final class LegacyServerPing extends AbstractWrapper implements ServerPin } } + @Override + public String getJson() { + return (String) GSON_TO_JSON.invoke(PING_GSON.get(null), getHandle()); + } + /** * Determine if the player count and maximum is visible. *

diff --git a/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingImpl.java b/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingImpl.java index d8744d04..26608125 100644 --- a/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingImpl.java +++ b/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingImpl.java @@ -1,10 +1,7 @@ package com.comphenix.protocol.wrappers.ping; -import java.util.Optional; - import com.comphenix.protocol.wrappers.WrappedChatComponent; import com.comphenix.protocol.wrappers.WrappedGameProfile; - import com.google.common.collect.ImmutableList; public interface ServerPingImpl extends Cloneable { @@ -38,6 +35,7 @@ public interface ServerPingImpl extends Cloneable { boolean arePlayersVisible(); void setPlayersVisible(boolean visible); + String getJson(); Object getHandle(); } \ No newline at end of file diff --git a/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingRecord.java b/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingRecord.java index cd96c7ba..f395ec32 100644 --- a/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingRecord.java +++ b/src/main/java/com/comphenix/protocol/wrappers/ping/ServerPingRecord.java @@ -1,31 +1,39 @@ package com.comphenix.protocol.wrappers.ping; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.Semaphore; - import com.comphenix.protocol.events.InternalStructure; import com.comphenix.protocol.reflect.EquivalentConverter; import com.comphenix.protocol.reflect.StructureModifier; import com.comphenix.protocol.reflect.accessors.Accessors; import com.comphenix.protocol.reflect.accessors.ConstructorAccessor; +import com.comphenix.protocol.reflect.accessors.FieldAccessor; +import com.comphenix.protocol.reflect.accessors.MethodAccessor; import com.comphenix.protocol.utility.MinecraftProtocolVersion; import com.comphenix.protocol.utility.MinecraftReflection; import com.comphenix.protocol.utility.MinecraftVersion; import com.comphenix.protocol.wrappers.*; - +import com.comphenix.protocol.wrappers.codecs.WrappedCodec; +import com.comphenix.protocol.wrappers.codecs.WrappedDynamicOps; import com.google.common.collect.ImmutableList; import org.bukkit.Bukkit; +import java.nio.charset.StandardCharsets; +import java.util.*; + public final class ServerPingRecord implements ServerPingImpl { private static Class SERVER_PING; private static Class PLAYER_SAMPLE_CLASS; private static Class SERVER_DATA_CLASS; + private static Class GSON_CLASS; + private static MethodAccessor GSON_TO_JSON; + private static MethodAccessor GSON_FROM_JSON; + private static FieldAccessor DATA_SERIALIZER_GSON; + private static Class JSON_ELEMENT_CLASS; + private static WrappedChatComponent DEFAULT_DESCRIPTION; private static ConstructorAccessor PING_CTOR; + private static WrappedCodec CODEC; private static EquivalentConverter> PROFILE_LIST_CONVERTER; @@ -57,6 +65,13 @@ public final class ServerPingRecord implements ServerPingImpl { PROFILE_LIST_CONVERTER = BukkitConverters.getListConverter(BukkitConverters.getWrappedGameProfileConverter()); DEFAULT_DESCRIPTION = WrappedChatComponent.fromLegacyText("A Minecraft Server"); + + GSON_CLASS = MinecraftReflection.getMinecraftGsonClass(); + GSON_TO_JSON = Accessors.getMethodAccessor(GSON_CLASS, "toJson", Object.class); + GSON_FROM_JSON = Accessors.getMethodAccessor(GSON_CLASS, "fromJson", String.class, Class.class); + DATA_SERIALIZER_GSON = Accessors.getFieldAccessor(MinecraftReflection.getPacketDataSerializerClass(), GSON_CLASS, true); + JSON_ELEMENT_CLASS = MinecraftReflection.getLibraryClass("com.google.gson.JsonElement"); + CODEC = WrappedCodec.fromHandle(Accessors.getFieldAccessor(SERVER_PING, MinecraftReflection.getCodecClass(), false).get(null)); } catch (Exception ex) { throw new RuntimeException("Failed to initialize Server Ping", ex); } finally { @@ -133,15 +148,26 @@ public final class ServerPingRecord implements ServerPingImpl { int max = Bukkit.getMaxPlayers(); int online = Bukkit.getOnlinePlayers().size(); - return new PlayerSample(max, online, null); + return new PlayerSample(max, online, new ArrayList<>()); } private static Favicon defaultFavicon() { return new Favicon(); } + public static ServerPingRecord fromJson(String json) { + + Object jsonElement = GSON_FROM_JSON.invoke(DATA_SERIALIZER_GSON.get(null), json, JSON_ELEMENT_CLASS); + + Object decoded = CODEC.parse(jsonElement, WrappedDynamicOps.json(false)).getOrThrow(e -> new IllegalStateException("Failed to decode: " + e)); + return new ServerPingRecord(decoded); + } + public ServerPingRecord(Object handle) { initialize(); + if(handle.getClass() != SERVER_PING) { + throw new IllegalArgumentException("Expected handle of type " + SERVER_PING.getName() + " but got " + handle.getClass().getName()); + } StructureModifier modifier = new StructureModifier<>(handle.getClass()).withTarget(handle); InternalStructure structure = new InternalStructure(handle, modifier); @@ -286,15 +312,35 @@ public final class ServerPingRecord implements ServerPingImpl { this.playersVisible = visible; } + @Override + public String getJson() { + Object encoded = CODEC.encode(getHandle(), WrappedDynamicOps.json(false)).getOrThrow(e -> new IllegalStateException("Failed to encode: " + e)); + return (String) GSON_TO_JSON.invoke(DATA_SERIALIZER_GSON.get(null), encoded); + } + @Override public Object getHandle() { WrappedChatComponent wrappedDescription = description != null ? description : DEFAULT_DESCRIPTION; Object descHandle = wrappedDescription.getHandle(); - Optional playersHandle = Optional.ofNullable(playerSample != null ? SAMPLE_WRAPPER.unwrap(playerSample) : null); + Optional playersHandle = Optional.ofNullable(SAMPLE_WRAPPER.unwrap(playerSample != null ? playerSample : new ArrayList<>())); // sample has to be non-null in handle Optional versionHandle = Optional.ofNullable(serverData != null ? DATA_WRAPPER.unwrap(serverData) : null); Optional favHandle = Optional.ofNullable(favicon != null ? FAVICON_WRAPPER.unwrap(favicon) : null); return PING_CTOR.invoke(descHandle, playersHandle, versionHandle, favHandle, enforceSafeChat); } + + @Override + public boolean equals(Object obj) { + if(!(obj instanceof ServerPingRecord)) { + return false; + } + ServerPingRecord other = (ServerPingRecord) obj; + + return Objects.equals(description, other.description) + && Objects.equals(playerSample, other.playerSample) + && Objects.equals(serverData, other.serverData) + && ((favicon == null && other.favicon.iconBytes == null) + || ((favicon != null) == (other.favicon != null) && Arrays.equals(favicon.iconBytes, other.favicon.iconBytes))); + } } diff --git a/src/test/java/com/comphenix/protocol/wrappers/WrappedServerPingTest.java b/src/test/java/com/comphenix/protocol/wrappers/WrappedServerPingTest.java index 16be896a..4bfa0733 100644 --- a/src/test/java/com/comphenix/protocol/wrappers/WrappedServerPingTest.java +++ b/src/test/java/com/comphenix/protocol/wrappers/WrappedServerPingTest.java @@ -9,6 +9,7 @@ import com.comphenix.protocol.events.PacketContainer; import com.comphenix.protocol.utility.MinecraftProtocolVersion; import com.comphenix.protocol.wrappers.WrappedServerPing.CompressedImage; import com.google.common.io.Resources; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder; @@ -49,6 +50,18 @@ public class WrappedServerPingTest { WrappedServerPing roundTrip = packet.getServerPings().read(0); + String asJson = serverPing.toJson(); + WrappedServerPing deserialized = WrappedServerPing.fromJson(asJson); + deserialized.setMotD(serverPing.getMotD()); + + // Deserializing to JSON and parsing the JSON again can lead to a different object as the Mojang Datafixer optimizes server icons or reorders components in the server description + assertEquals(serverPing.getVersionName(), deserialized.getVersionName(), "Failed to serialize as JSON and deserialize afterwards (version name mismatch)"); + assertEquals(serverPing.getVersionProtocol(), deserialized.getVersionProtocol(), "Failed to serialize as JSON and deserialize afterwards (version protocol mismatch)"); + assertEquals(serverPing.getPlayersOnline(), deserialized.getPlayersOnline(), "Failed to serialize as JSON and deserialize afterwards (players online mismatch)"); + assertEquals(serverPing.getPlayersMaximum(), deserialized.getPlayersMaximum(), "Failed to serialize as JSON and deserialize afterwards (players maximum mismatch)"); + assertEquals(serverPing.getPlayers(), deserialized.getPlayers(), "Failed to serialize as JSON and deserialize afterwards (player sample mismatch)"); + assertEquals(PlainTextComponentSerializer.plainText().serialize(AdventureComponentConverter.fromWrapper(serverPing.getMotD())), PlainTextComponentSerializer.plainText().serialize(AdventureComponentConverter.fromWrapper(deserialized.getMotD()))); // Check if plain text is equivalent + assertEquals(5, roundTrip.getPlayersOnline()); assertEquals(10, roundTrip.getPlayersMaximum()); assertEquals("Minecraft 123", roundTrip.getVersionName());