diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeCompressionDisabler.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeCompressionDisabler.java new file mode 100644 index 000000000..084e1d2dc --- /dev/null +++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeCompressionDisabler.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.platform.bungeecord; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOutboundHandlerAdapter; +import io.netty.channel.ChannelPromise; +import net.md_5.bungee.protocol.packet.LoginSuccess; +import net.md_5.bungee.protocol.packet.SetCompression; + +public class GeyserBungeeCompressionDisabler extends ChannelOutboundHandlerAdapter { + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (!(msg instanceof SetCompression)) { + if (msg instanceof LoginSuccess) { + // We're past the point that compression can be enabled + if (ctx.pipeline().get("compress") != null) { + ctx.pipeline().remove("compress"); + } + if (ctx.pipeline().get("decompress") != null) { + ctx.pipeline().remove("decompress"); + } + ctx.pipeline().remove(this); + } + super.write(ctx, msg, promise); + } + } +} diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java index cef430bd6..e10b3ce6f 100644 --- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java +++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeInjector.java @@ -140,6 +140,11 @@ public class GeyserBungeeInjector extends GeyserInjector implements Listener { channelInitializer = PipelineUtils.SERVER_CHILD; } initChannel.invoke(channelInitializer, ch); + + if (bootstrap.getGeyserConfig().isDisableCompression()) { + ch.pipeline().addAfter(PipelineUtils.PACKET_ENCODER, "geyser-compression-disabler", + new GeyserBungeeCompressionDisabler()); + } } }) .childAttr(listener, listenerInfo) @@ -163,7 +168,7 @@ public class GeyserBungeeInjector extends GeyserInjector implements Listener { // If native compression is enabled, then this line is tripped up if a heap buffer is sent over in such a situation // as a new direct buffer is not created with that patch (HeapByteBufs throw an UnsupportedOperationException here): // https://github.com/SpigotMC/BungeeCord/blob/a283aaf724d4c9a815540cd32f3aafaa72df9e05/native/src/main/java/net/md_5/bungee/jni/zlib/NativeZlib.java#L43 - // This issue could be mitigated down the line by preventing Bungee from setting compression + // If disable compression is enabled, this can probably be disabled now, but BungeeCord (not Waterfall) complains LocalSession.createDirectByteBufAllocator(); } diff --git a/bootstrap/fabric/src/main/java/org/geysermc/geyser/platform/fabric/world/GeyserFabricWorldManager.java b/bootstrap/fabric/src/main/java/org/geysermc/geyser/platform/fabric/world/GeyserFabricWorldManager.java index eb4f61c67..b003a76ba 100644 --- a/bootstrap/fabric/src/main/java/org/geysermc/geyser/platform/fabric/world/GeyserFabricWorldManager.java +++ b/bootstrap/fabric/src/main/java/org/geysermc/geyser/platform/fabric/world/GeyserFabricWorldManager.java @@ -31,12 +31,13 @@ import com.nukkitx.nbt.NbtMapBuilder; import com.nukkitx.nbt.NbtType; import me.lucko.fabric.api.permissions.v0.Permissions; import net.minecraft.core.BlockPos; -import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.*; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.WritableBookItem; import net.minecraft.world.item.WrittenBookItem; +import net.minecraft.world.level.block.entity.BannerBlockEntity; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.LecternBlockEntity; import org.geysermc.geyser.level.GeyserWorldManager; @@ -44,8 +45,10 @@ import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.translator.inventory.LecternInventoryTranslator; import org.geysermc.geyser.util.BlockEntityUtils; +import javax.annotation.Nonnull; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; public class GeyserFabricWorldManager extends GeyserWorldManager { @@ -127,7 +130,127 @@ public class GeyserFabricWorldManager extends GeyserWorldManager { return Permissions.check(player, permission); } + @Nonnull + @Override + public CompletableFuture getPickItemNbt(GeyserSession session, int x, int y, int z, boolean addNbtData) { + CompletableFuture future = new CompletableFuture<>(); + server.execute(() -> { + ServerPlayer player = getPlayer(session); + if (player == null) { + future.complete(null); + return; + } + + BlockPos pos = new BlockPos(x, y, z); + // Don't create a new block entity if invalid + BlockEntity blockEntity = player.level.getChunkAt(pos).getBlockEntity(pos); + if (blockEntity instanceof BannerBlockEntity banner) { + // Potentially exposes other NBT data? But we need to get the NBT data for the banner patterns *and* + // the banner might have a custom name, both of which a Java client knows and caches + ItemStack itemStack = banner.getItem(); + var tag = OpenNbtTagVisitor.convert("", itemStack.getOrCreateTag()); + + future.complete(tag); + return; + } + future.complete(null); + }); + return future; + } + private ServerPlayer getPlayer(GeyserSession session) { return server.getPlayerList().getPlayer(session.getPlayerEntity().getUuid()); } + + // Future considerations: option to clone; would affect arrays + private static class OpenNbtTagVisitor implements TagVisitor { + private String currentKey; + private final com.github.steveice10.opennbt.tag.builtin.CompoundTag root; + private com.github.steveice10.opennbt.tag.builtin.Tag currentTag; + + OpenNbtTagVisitor(String key) { + root = new com.github.steveice10.opennbt.tag.builtin.CompoundTag(key); + } + + @Override + public void visitString(StringTag stringTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.StringTag(currentKey, stringTag.getAsString()); + } + + @Override + public void visitByte(ByteTag byteTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.ByteTag(currentKey, byteTag.getAsByte()); + } + + @Override + public void visitShort(ShortTag shortTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.ShortTag(currentKey, shortTag.getAsShort()); + } + + @Override + public void visitInt(IntTag intTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.IntTag(currentKey, intTag.getAsInt()); + } + + @Override + public void visitLong(LongTag longTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.LongTag(currentKey, longTag.getAsLong()); + } + + @Override + public void visitFloat(FloatTag floatTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.FloatTag(currentKey, floatTag.getAsFloat()); + } + + @Override + public void visitDouble(DoubleTag doubleTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.DoubleTag(currentKey, doubleTag.getAsDouble()); + } + + @Override + public void visitByteArray(ByteArrayTag byteArrayTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.ByteArrayTag(currentKey, byteArrayTag.getAsByteArray()); + } + + @Override + public void visitIntArray(IntArrayTag intArrayTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.IntArrayTag(currentKey, intArrayTag.getAsIntArray()); + } + + @Override + public void visitLongArray(LongArrayTag longArrayTag) { + currentTag = new com.github.steveice10.opennbt.tag.builtin.LongArrayTag(currentKey, longArrayTag.getAsLongArray()); + } + + @Override + public void visitList(ListTag listTag) { + var newList = new com.github.steveice10.opennbt.tag.builtin.ListTag(currentKey); + for (Tag tag : listTag) { + currentKey = ""; + tag.accept(this); + newList.add(currentTag); + } + currentTag = newList; + } + + @Override + public void visitCompound(CompoundTag compoundTag) { + currentTag = convert(currentKey, compoundTag); + } + + private static com.github.steveice10.opennbt.tag.builtin.CompoundTag convert(String name, CompoundTag compoundTag) { + OpenNbtTagVisitor visitor = new OpenNbtTagVisitor(name); + for (String key : compoundTag.getAllKeys()) { + visitor.currentKey = key; + Tag tag = compoundTag.get(key); + tag.accept(visitor); + visitor.root.put(visitor.currentTag); + } + return visitor.root; + } + + @Override + public void visitEnd(EndTag endTag) { + } + } } diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotCompressionDisabler.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotCompressionDisabler.java new file mode 100644 index 000000000..9b112f62f --- /dev/null +++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotCompressionDisabler.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.platform.spigot; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOutboundHandlerAdapter; +import io.netty.channel.ChannelPromise; +import org.bukkit.Bukkit; +import org.geysermc.geyser.GeyserImpl; + +/** + * Disables the compression packet (and the compression handlers from being added to the pipeline) for Geyser clients + * that won't be receiving the data over the network. + * + * As of 1.8 - 1.17.1, compression is enabled in the Netty pipeline by adding a listener after a packet is written. + * If we simply "cancel" or don't forward the packet, then the listener is never called. + */ +public class GeyserSpigotCompressionDisabler extends ChannelOutboundHandlerAdapter { + static final boolean ENABLED; + + private static final Class COMPRESSION_PACKET_CLASS; + private static final Class LOGIN_SUCCESS_PACKET_CLASS; + private static final boolean PROTOCOL_SUPPORT_INSTALLED; + + static { + PROTOCOL_SUPPORT_INSTALLED = Bukkit.getPluginManager().getPlugin("ProtocolSupport") != null; + + Class compressionPacketClass = null; + Class loginSuccessPacketClass = null; + boolean enabled = false; + try { + compressionPacketClass = findCompressionPacket(); + loginSuccessPacketClass = findLoginSuccessPacket(); + enabled = true; + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error("Could not initialize compression disabler!", e); + } + COMPRESSION_PACKET_CLASS = compressionPacketClass; + LOGIN_SUCCESS_PACKET_CLASS = loginSuccessPacketClass; + ENABLED = enabled; + } + + public GeyserSpigotCompressionDisabler() { + if (!ENABLED) { + throw new RuntimeException("Geyser compression disabler cannot be initialized in its current state!"); + } + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + Class msgClass = msg.getClass(); + // Don't let any compression packet get through + if (!COMPRESSION_PACKET_CLASS.isAssignableFrom(msgClass)) { + if (LOGIN_SUCCESS_PACKET_CLASS.isAssignableFrom(msgClass)) { + if (PROTOCOL_SUPPORT_INSTALLED) { + // ProtocolSupport must send the compression packet, so let's remove what it did before it does damage + if (ctx.pipeline().get("compress") != null) { + ctx.pipeline().remove("compress"); + } + if (ctx.pipeline().get("decompress") != null) { + ctx.pipeline().remove("decompress"); + } + } + // We're past the point that a compression packet can be sent, so we can safely yeet ourselves away + ctx.channel().pipeline().remove(this); + } + super.write(ctx, msg, promise); + } else if (PROTOCOL_SUPPORT_INSTALLED) { + // We must indicate it "succeeded" or ProtocolSupport will time us out + promise.setSuccess(); + } + } + + private static Class findCompressionPacket() throws ClassNotFoundException { + try { + return Class.forName("net.minecraft.network.protocol.login.PacketLoginOutSetCompression"); + } catch (ClassNotFoundException e) { + String prefix = Bukkit.getServer().getClass().getPackage().getName().replace("org.bukkit.craftbukkit", "net.minecraft.server"); + return Class.forName(prefix + ".PacketLoginOutSetCompression"); + } + } + + private static Class findLoginSuccessPacket() throws ClassNotFoundException { + try { + return Class.forName("net.minecraft.network.protocol.login.PacketLoginOutSuccess"); + } catch (ClassNotFoundException e) { + String prefix = Bukkit.getServer().getClass().getPackage().getName().replace("org.bukkit.craftbukkit", "net.minecraft.server"); + return Class.forName(prefix + ".PacketLoginOutSuccess"); + } + } +} diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java index c1d3b6871..e3d73fb19 100644 --- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java +++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotInjector.java @@ -115,10 +115,14 @@ public class GeyserSpigotInjector extends GeyserInjector { ChannelFuture channelFuture = (new ServerBootstrap() .channel(LocalServerChannelWrapper.class) - .childHandler(new ChannelInitializer() { + .childHandler(new ChannelInitializer<>() { @Override protected void initChannel(Channel ch) throws Exception { initChannel.invoke(childHandler, ch); + + if (bootstrap.getGeyserConfig().isDisableCompression() && GeyserSpigotCompressionDisabler.ENABLED) { + ch.pipeline().addAfter("encoder", "geyser-compression-disabler", new GeyserSpigotCompressionDisabler()); + } } }) // Set to MAX_PRIORITY as MultithreadEventLoopGroup#newDefaultThreadFactory which DefaultEventLoopGroup implements does by default diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java index 52f29dcfe..cca982cbb 100644 --- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java +++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java @@ -25,14 +25,17 @@ package org.geysermc.geyser.platform.spigot.world.manager; +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; +import com.github.steveice10.opennbt.tag.builtin.ListTag; +import com.github.steveice10.opennbt.tag.builtin.Tag; import com.nukkitx.math.vector.Vector3i; import com.nukkitx.nbt.NbtMap; import com.nukkitx.nbt.NbtMapBuilder; import com.nukkitx.nbt.NbtType; import org.bukkit.Bukkit; import org.bukkit.World; -import org.bukkit.block.Block; -import org.bukkit.block.Lectern; +import org.bukkit.block.*; +import org.bukkit.block.banner.Pattern; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BookMeta; @@ -43,10 +46,14 @@ import org.geysermc.geyser.level.block.BlockStateValues; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.translator.inventory.LecternInventoryTranslator; +import org.geysermc.geyser.translator.inventory.item.nbt.BannerTranslator; import org.geysermc.geyser.util.BlockEntityUtils; +import org.jetbrains.annotations.Nullable; +import javax.annotation.Nonnull; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; /** * The base world manager to use when there is no supported NMS revision @@ -173,6 +180,46 @@ public class GeyserSpigotWorldManager extends WorldManager { return Bukkit.getPlayer(session.getPlayerEntity().getUsername()).hasPermission(permission); } + @Nonnull + @Override + public CompletableFuture<@Nullable CompoundTag> getPickItemNbt(GeyserSession session, int x, int y, int z, boolean addNbtData) { + CompletableFuture<@Nullable CompoundTag> future = new CompletableFuture<>(); + // Paper 1.19.3 complains about async access otherwise. + // java.lang.IllegalStateException: Tile is null, asynchronous access? + Bukkit.getScheduler().runTask(this.plugin, () -> { + Player bukkitPlayer; + if ((bukkitPlayer = Bukkit.getPlayer(session.getPlayerEntity().getUuid())) == null) { + future.complete(null); + return; + } + + Block block = bukkitPlayer.getWorld().getBlockAt(x, y, z); + BlockState state = block.getState(); + if (state instanceof Banner banner) { + ListTag list = new ListTag("Patterns"); + for (int i = 0; i < banner.numberOfPatterns(); i++) { + Pattern pattern = banner.getPattern(i); + list.add(BannerTranslator.getJavaPatternTag(pattern.getPattern().getIdentifier(), pattern.getColor().ordinal())); + } + + CompoundTag root = addToBlockEntityTag(list); + + future.complete(root); + return; + } + future.complete(null); + }); + return future; + } + + private CompoundTag addToBlockEntityTag(Tag tag) { + CompoundTag compoundTag = new CompoundTag(""); + CompoundTag blockEntityTag = new CompoundTag("BlockEntityTag"); + blockEntityTag.put(tag); + compoundTag.put(blockEntityTag); + return compoundTag; + } + /** * This should be set to true if we are post-1.13 but before the latest version, and we should convert the old block state id * to the current one. diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityCompressionDisabler.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityCompressionDisabler.java new file mode 100644 index 000000000..e787e7355 --- /dev/null +++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityCompressionDisabler.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.platform.velocity; + +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import org.geysermc.geyser.GeyserImpl; + +import java.lang.reflect.Method; + +public class GeyserVelocityCompressionDisabler extends ChannelDuplexHandler { + static final boolean ENABLED; + private static final Class COMPRESSION_PACKET_CLASS; + private static final Class LOGIN_SUCCESS_PACKET_CLASS; + private static final Object COMPRESSION_ENABLED_EVENT; + private static final Method SET_COMPRESSION_METHOD; + + static { + boolean enabled = false; + Class compressionPacketClass = null; + Class loginSuccessPacketClass = null; + Object compressionEnabledEvent = null; + Method setCompressionMethod = null; + + try { + compressionPacketClass = Class.forName("com.velocitypowered.proxy.protocol.packet.SetCompression"); + loginSuccessPacketClass = Class.forName("com.velocitypowered.proxy.protocol.packet.ServerLoginSuccess"); + compressionEnabledEvent = Class.forName("com.velocitypowered.proxy.protocol.VelocityConnectionEvent") + .getDeclaredField("COMPRESSION_ENABLED").get(null); + setCompressionMethod = Class.forName("com.velocitypowered.proxy.connection.MinecraftConnection") + .getMethod("setCompressionThreshold", int.class); + enabled = true; + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error("Could not initialize compression disabler!", e); + } + + ENABLED = enabled; + COMPRESSION_PACKET_CLASS = compressionPacketClass; + LOGIN_SUCCESS_PACKET_CLASS = loginSuccessPacketClass; + COMPRESSION_ENABLED_EVENT = compressionEnabledEvent; + SET_COMPRESSION_METHOD = setCompressionMethod; + } + + public GeyserVelocityCompressionDisabler() { + if (!ENABLED) { + throw new RuntimeException("Geyser compression disabler cannot be initialized in its current state!"); + } + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + Class msgClass = msg.getClass(); + if (!COMPRESSION_PACKET_CLASS.isAssignableFrom(msgClass)) { + if (LOGIN_SUCCESS_PACKET_CLASS.isAssignableFrom(msgClass)) { + // We're past the point that compression can be enabled + + ctx.pipeline().remove(this); + } + super.write(ctx, msg, promise); + } + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt != COMPRESSION_ENABLED_EVENT) { + super.userEventTriggered(ctx, evt); + return; + } + + // Invoke the method as it calls a Netty event and handles removing cleaner than we could + Object minecraftConnection = ctx.pipeline().get("handler"); + SET_COMPRESSION_METHOD.invoke(minecraftConnection, -1); + // Do not call super and let the new compression enabled event continue firing + } +} diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityInjector.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityInjector.java index e1a1030e3..4ffb286b8 100644 --- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityInjector.java +++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityInjector.java @@ -34,6 +34,7 @@ import org.geysermc.geyser.network.netty.GeyserInjector; import org.geysermc.geyser.network.netty.LocalServerChannelWrapper; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.function.Supplier; public class GeyserVelocityInjector extends GeyserInjector { @@ -67,9 +68,23 @@ public class GeyserVelocityInjector extends GeyserInjector { workerGroupField.setAccessible(true); EventLoopGroup workerGroup = (EventLoopGroup) workerGroupField.get(connectionManager); + // This method is what initializes the connection in Java Edition, after Netty is all set. + Method initChannel = ChannelInitializer.class.getDeclaredMethod("initChannel", Channel.class); + initChannel.setAccessible(true); + ChannelFuture channelFuture = (new ServerBootstrap() .channel(LocalServerChannelWrapper.class) - .childHandler(channelInitializer) + .childHandler(new ChannelInitializer<>() { + @Override + protected void initChannel(Channel ch) throws Exception { + initChannel.invoke(channelInitializer, ch); + + if (bootstrap.getGeyserConfig().isDisableCompression() && GeyserVelocityCompressionDisabler.ENABLED) { + ch.pipeline().addAfter("minecraft-encoder", "geyser-compression-disabler", + new GeyserVelocityCompressionDisabler()); + } + } + }) .group(bossGroup, workerGroup) // Cannot be DefaultEventLoopGroup .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, serverWriteMark) // Required or else rare network freezes can occur .localAddress(LocalAddress.ANY)) diff --git a/core/src/main/java/org/geysermc/geyser/Constants.java b/core/src/main/java/org/geysermc/geyser/Constants.java index 6a53c37de..46a1cb2ec 100644 --- a/core/src/main/java/org/geysermc/geyser/Constants.java +++ b/core/src/main/java/org/geysermc/geyser/Constants.java @@ -30,7 +30,6 @@ import java.net.URISyntaxException; public final class Constants { public static final URI GLOBAL_API_WS_URI; - public static final String NTP_SERVER = "time.cloudflare.com"; public static final String NEWS_OVERVIEW_URL = "https://api.geysermc.org/v2/news/"; public static final String NEWS_PROJECT_NAME = "geyser"; diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index 982ce6fef..c1b21e943 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -79,6 +79,7 @@ import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.PendingMicrosoftAuthentication; import org.geysermc.geyser.session.SessionManager; import org.geysermc.geyser.skin.FloodgateSkinUploader; +import org.geysermc.geyser.skin.ProvidedSkins; import org.geysermc.geyser.skin.SkinProvider; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.text.MinecraftLocale; @@ -95,6 +96,7 @@ import java.net.UnknownHostException; import java.security.Key; import java.text.DecimalFormat; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -195,7 +197,23 @@ public class GeyserImpl implements GeyserApi { EntityDefinitions.init(); ItemTranslator.init(); MessageTranslator.init(); - MinecraftLocale.init(); + + // Download the latest asset list and cache it + AssetUtils.generateAssetCache().whenComplete((aVoid, ex) -> { + if (ex != null) { + return; + } + MinecraftLocale.ensureEN_US(); + String locale = GeyserLocale.getDefaultLocale(); + if (!"en_us".equals(locale)) { + // English will be loaded after assets are downloaded, if necessary + MinecraftLocale.downloadAndLoadLocale(locale); + } + + ProvidedSkins.init(); + + CompletableFuture.runAsync(AssetUtils::downloadAndRunClientJarTasks); + }); startInstance(); diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java index 8a366baae..4843df72b 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java @@ -182,6 +182,8 @@ public interface GeyserConfiguration { boolean isUseDirectConnection(); + boolean isDisableCompression(); + int getConfigVersion(); static void checkGeyserConfiguration(GeyserConfiguration geyserConfig, GeyserLogger geyserLogger) { diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java index 229895c3c..dc675319b 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java @@ -335,6 +335,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration @JsonProperty("use-direct-connection") private boolean useDirectConnection = true; + @JsonProperty("disable-compression") + private boolean isDisableCompression = true; + @JsonProperty("config-version") private int configVersion = 0; diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java index 663dd3c33..ed009bf8a 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/Entity.java @@ -44,8 +44,6 @@ import lombok.Setter; import net.kyori.adventure.text.Component; import org.geysermc.geyser.entity.EntityDefinition; import org.geysermc.geyser.entity.GeyserDirtyMetadata; -import org.geysermc.geyser.entity.type.player.SessionPlayerEntity; -import org.geysermc.geyser.network.GameProtocol; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.translator.text.MessageTranslator; import org.geysermc.geyser.util.EntityUtils; diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java index 8e600b1a8..3e3a298bd 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/player/PlayerEntity.java @@ -77,7 +77,6 @@ public class PlayerEntity extends LivingEntity { } private String username; - private boolean playerList = true; // Player is in the player list /** * The textures property from the GameProfile. @@ -417,4 +416,11 @@ public class PlayerEntity extends LivingEntity { session.sendUpstreamPacket(packet); } } + + /** + * @return the UUID that should be used when dealing with Bedrock's tab list. + */ + public UUID getTabListUuid() { + return getUuid(); + } } diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java index 74b95b73c..99517b208 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/player/SessionPlayerEntity.java @@ -250,4 +250,9 @@ public class SessionPlayerEntity extends PlayerEntity { dirtyMetadata.put(EntityData.PLAYER_HAS_DIED, (byte) 0); } } + + @Override + public UUID getTabListUuid() { + return session.getAuthData().uuid(); + } } diff --git a/core/src/main/java/org/geysermc/geyser/entity/type/player/SkullPlayerEntity.java b/core/src/main/java/org/geysermc/geyser/entity/type/player/SkullPlayerEntity.java index 176d171de..c2af2e36b 100644 --- a/core/src/main/java/org/geysermc/geyser/entity/type/player/SkullPlayerEntity.java +++ b/core/src/main/java/org/geysermc/geyser/entity/type/player/SkullPlayerEntity.java @@ -48,7 +48,6 @@ public class SkullPlayerEntity extends PlayerEntity { public SkullPlayerEntity(GeyserSession session, long geyserId) { super(session, 0, geyserId, UUID.randomUUID(), Vector3f.ZERO, Vector3f.ZERO, 0, 0, 0, "", null); - setPlayerList(false); } @Override diff --git a/core/src/main/java/org/geysermc/geyser/level/WorldManager.java b/core/src/main/java/org/geysermc/geyser/level/WorldManager.java index e10981f4b..1909915db 100644 --- a/core/src/main/java/org/geysermc/geyser/level/WorldManager.java +++ b/core/src/main/java/org/geysermc/geyser/level/WorldManager.java @@ -27,12 +27,15 @@ package org.geysermc.geyser.level; import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode; import com.github.steveice10.mc.protocol.data.game.setting.Difficulty; +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.nukkitx.math.vector.Vector3i; import com.nukkitx.nbt.NbtMap; import org.geysermc.geyser.session.GeyserSession; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; +import javax.annotation.Nonnull; import java.util.Locale; +import java.util.concurrent.CompletableFuture; /** * Class that manages or retrieves various information @@ -166,4 +169,14 @@ public abstract class WorldManager { public String[] getBiomeIdentifiers(boolean withTags) { return null; } + + /** + * Used for pick block, so we don't need to cache more data than necessary. + * + * @return expected NBT for this item. + */ + @Nonnull + public CompletableFuture<@Nullable CompoundTag> getPickItemNbt(GeyserSession session, int x, int y, int z, boolean addNbtData) { + return CompletableFuture.completedFuture(null); + } } diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java index 8f9bc394a..72697a85c 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -62,7 +62,6 @@ import com.github.steveice10.packetlib.event.session.*; import com.github.steveice10.packetlib.packet.Packet; import com.github.steveice10.packetlib.tcp.TcpClientSession; import com.github.steveice10.packetlib.tcp.TcpSession; -import com.nukkitx.math.GenericMath; import com.nukkitx.math.vector.*; import com.nukkitx.nbt.NbtMap; import com.nukkitx.protocol.bedrock.BedrockPacket; @@ -135,7 +134,6 @@ import org.geysermc.geyser.translator.text.MessageTranslator; import org.geysermc.geyser.util.ChunkUtils; import org.geysermc.geyser.util.DimensionUtils; import org.geysermc.geyser.util.LoginEncryptionUtils; -import org.geysermc.geyser.util.MathUtils; import java.net.ConnectException; import java.net.InetSocketAddress; @@ -539,6 +537,12 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { @Setter private ScheduledFuture lookBackScheduledFuture = null; + /** + * Used to return players back to their vehicles if the server doesn't want them unmounting. + */ + @Setter + private ScheduledFuture mountVehicleScheduledFuture = null; + private MinecraftProtocol protocol; public GeyserSession(GeyserImpl geyser, BedrockServerSession bedrockServerSession, EventLoop eventLoop) { @@ -1073,6 +1077,17 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { closed = true; } + /** + * Moves task to the session event loop if already not in it. Otherwise, the task is automatically ran. + */ + public void ensureInEventLoop(Runnable runnable) { + if (eventLoop.inEventLoop()) { + runnable.run(); + return; + } + executeInEventLoop(runnable); + } + /** * Executes a task and prints a stack trace if an error occurs. */ @@ -1378,7 +1393,6 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { } public void setServerRenderDistance(int renderDistance) { - renderDistance = GenericMath.ceil(++renderDistance * MathUtils.SQRT_OF_TWO); //square to circle this.serverRenderDistance = renderDistance; ChunkRadiusUpdatedPacket chunkRadiusUpdatedPacket = new ChunkRadiusUpdatedPacket(); diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java index 012606615..9dc89215a 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/EntityCache.java @@ -123,7 +123,8 @@ public class EntityCache { } public void addPlayerEntity(PlayerEntity entity) { - playerEntities.put(entity.getUuid(), entity); + // putIfAbsent matches the behavior of playerInfoMap in Java as of 1.19.3 + playerEntities.putIfAbsent(entity.getUuid(), entity); } public PlayerEntity getPlayerEntity(UUID uuid) { diff --git a/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java index 6794af498..b312f9811 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java @@ -29,10 +29,6 @@ import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; -import com.nukkitx.protocol.bedrock.data.skin.ImageData; -import com.nukkitx.protocol.bedrock.data.skin.SerializedSkin; -import com.nukkitx.protocol.bedrock.packet.PlayerListPacket; -import com.nukkitx.protocol.bedrock.packet.PlayerSkinPacket; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; @@ -45,7 +41,6 @@ import org.geysermc.geyser.text.GeyserLocale; import javax.annotation.Nonnull; import java.awt.*; import java.awt.image.BufferedImage; -import java.util.Collections; import java.util.Objects; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -68,7 +63,7 @@ public class FakeHeadProvider { SkinProvider.Skin skin = skinData.skin(); SkinProvider.Cape cape = skinData.cape(); - SkinProvider.SkinGeometry geometry = skinData.geometry().getGeometryName().equals("{\"geometry\" :{\"default\" :\"geometry.humanoid.customSlim\"}}") + SkinProvider.SkinGeometry geometry = skinData.geometry().geometryName().equals("{\"geometry\" :{\"default\" :\"geometry.humanoid.customSlim\"}}") ? SkinProvider.WEARING_CUSTOM_SKULL_SLIM : SkinProvider.WEARING_CUSTOM_SKULL; SkinProvider.Skin headSkin = SkinProvider.getOrDefault( @@ -111,7 +106,7 @@ public class FakeHeadProvider { try { SkinProvider.SkinData mergedSkinData = MERGED_SKINS_LOADING_CACHE.get(new FakeHeadEntry(texturesProperty, fakeHeadSkinUrl, entity)); - sendSkinPacket(session, entity, mergedSkinData); + SkinManager.sendSkinPacket(session, entity, mergedSkinData); } catch (ExecutionException e) { GeyserImpl.getInstance().getLogger().error("Couldn't merge skin of " + entity.getUsername() + " with head skin url " + fakeHeadSkinUrl, e); } @@ -133,50 +128,10 @@ public class FakeHeadProvider { return; } - sendSkinPacket(session, entity, skinData); + SkinManager.sendSkinPacket(session, entity, skinData); }); } - private static void sendSkinPacket(GeyserSession session, PlayerEntity entity, SkinProvider.SkinData skinData) { - SkinProvider.Skin skin = skinData.skin(); - SkinProvider.Cape cape = skinData.cape(); - SkinProvider.SkinGeometry geometry = skinData.geometry(); - - if (entity.getUuid().equals(session.getPlayerEntity().getUuid())) { - PlayerListPacket.Entry updatedEntry = SkinManager.buildEntryManually( - session, - entity.getUuid(), - entity.getUsername(), - entity.getGeyserId(), - skin.getTextureUrl(), - skin.getSkinData(), - cape.getCapeId(), - cape.getCapeData(), - geometry - ); - - PlayerListPacket playerAddPacket = new PlayerListPacket(); - playerAddPacket.setAction(PlayerListPacket.Action.ADD); - playerAddPacket.getEntries().add(updatedEntry); - session.sendUpstreamPacket(playerAddPacket); - } else { - PlayerSkinPacket packet = new PlayerSkinPacket(); - packet.setUuid(entity.getUuid()); - packet.setOldSkinName(""); - packet.setNewSkinName(skin.getTextureUrl()); - packet.setSkin(getSkin(skin.getTextureUrl(), skin, cape, geometry)); - packet.setTrustedSkin(true); - session.sendUpstreamPacket(packet); - } - } - - private static SerializedSkin getSkin(String skinId, SkinProvider.Skin skin, SkinProvider.Cape cape, SkinProvider.SkinGeometry geometry) { - return SerializedSkin.of(skinId, "", geometry.getGeometryName(), - ImageData.of(skin.getSkinData()), Collections.emptyList(), - ImageData.of(cape.getCapeData()), geometry.getGeometryData(), - "", true, false, false, cape.getCapeId(), skinId); - } - @AllArgsConstructor @Getter @Setter diff --git a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkin.java b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkin.java deleted file mode 100644 index bb638556d..000000000 --- a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkin.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - * @author GeyserMC - * @link https://github.com/GeyserMC/Geyser - */ - -package org.geysermc.geyser.skin; - -import lombok.Getter; -import org.geysermc.geyser.GeyserImpl; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; - -public class ProvidedSkin { - @Getter private byte[] skin; - - public ProvidedSkin(String internalUrl) { - try { - BufferedImage image; - try (InputStream stream = GeyserImpl.getInstance().getBootstrap().getResource(internalUrl)) { - image = ImageIO.read(stream); - } - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(image.getWidth() * 4 + image.getHeight() * 4); - for (int y = 0; y < image.getHeight(); y++) { - for (int x = 0; x < image.getWidth(); x++) { - int rgba = image.getRGB(x, y); - outputStream.write((rgba >> 16) & 0xFF); // Red - outputStream.write((rgba >> 8) & 0xFF); // Green - outputStream.write(rgba & 0xFF); // Blue - outputStream.write((rgba >> 24) & 0xFF); // Alpha - } - } - image.flush(); - skin = outputStream.toByteArray(); - } catch (IOException e) { - e.printStackTrace(); - } - } -} diff --git a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java new file mode 100644 index 000000000..999df0929 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.skin; + +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.util.AssetUtils; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Objects; +import java.util.UUID; + +public final class ProvidedSkins { + private static final ProvidedSkin[] PROVIDED_SKINS = { + new ProvidedSkin("textures/entity/player/slim/alex.png", true), + new ProvidedSkin("textures/entity/player/slim/ari.png", true), + new ProvidedSkin("textures/entity/player/slim/efe.png", true), + new ProvidedSkin("textures/entity/player/slim/kai.png", true), + new ProvidedSkin("textures/entity/player/slim/makena.png", true), + new ProvidedSkin("textures/entity/player/slim/noor.png", true), + new ProvidedSkin("textures/entity/player/slim/steve.png", true), + new ProvidedSkin("textures/entity/player/slim/sunny.png", true), + new ProvidedSkin("textures/entity/player/slim/zuri.png", true), + new ProvidedSkin("textures/entity/player/wide/alex.png", false), + new ProvidedSkin("textures/entity/player/wide/ari.png", false), + new ProvidedSkin("textures/entity/player/wide/efe.png", false), + new ProvidedSkin("textures/entity/player/wide/kai.png", false), + new ProvidedSkin("textures/entity/player/wide/makena.png", false), + new ProvidedSkin("textures/entity/player/wide/noor.png", false), + new ProvidedSkin("textures/entity/player/wide/steve.png", false), + new ProvidedSkin("textures/entity/player/wide/sunny.png", false), + new ProvidedSkin("textures/entity/player/wide/zuri.png", false) + }; + + public static ProvidedSkin getDefaultPlayerSkin(UUID uuid) { + return PROVIDED_SKINS[Math.floorMod(uuid.hashCode(), PROVIDED_SKINS.length)]; + } + + private ProvidedSkins() { + } + + public static final class ProvidedSkin { + private SkinProvider.Skin data; + private final boolean slim; + + ProvidedSkin(String asset, boolean slim) { + this.slim = slim; + + Path folder = GeyserImpl.getInstance().getBootstrap().getConfigFolder() + .resolve("cache") + .resolve("default_player_skins") + .resolve(slim ? "slim" : "wide"); + String assetName = asset.substring(asset.lastIndexOf('/') + 1); + + File location = folder.resolve(assetName).toFile(); + AssetUtils.addTask(!location.exists(), new AssetUtils.ClientJarTask("assets/minecraft/" + asset, + (stream) -> AssetUtils.saveFile(location, stream), + () -> { + try { + // TODO lazy initialize? + BufferedImage image; + try (InputStream stream = new FileInputStream(location)) { + image = ImageIO.read(stream); + } + + byte[] byteData = SkinProvider.bufferedImageToImageData(image); + image.flush(); + + String identifier = "geysermc:" + assetName + "_" + (slim ? "slim" : "wide"); + this.data = new SkinProvider.Skin(-1, identifier, byteData); + } catch (IOException e) { + e.printStackTrace(); + } + })); + } + + public SkinProvider.Skin getData() { + // Fall back to the default skin if we can't load our skins, or it's not loaded yet. + return Objects.requireNonNullElse(data, SkinProvider.EMPTY_SKIN); + } + + public boolean isSlim() { + return slim; + } + } + + public static void init() { + // no-op + } + + static { + Path folder = GeyserImpl.getInstance().getBootstrap().getConfigFolder() + .resolve("cache") + .resolve("default_player_skins"); + folder.toFile().mkdirs(); + // Two directories since there are two skins for each model: one slim, one wide + folder.resolve("slim").toFile().mkdir(); + folder.resolve("wide").toFile().mkdir(); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java index 730d46908..800b71c96 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java @@ -32,8 +32,8 @@ import com.github.steveice10.opennbt.tag.builtin.StringTag; import com.nukkitx.protocol.bedrock.data.skin.ImageData; import com.nukkitx.protocol.bedrock.data.skin.SerializedSkin; import com.nukkitx.protocol.bedrock.packet.PlayerListPacket; +import com.nukkitx.protocol.bedrock.packet.PlayerSkinPacket; import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.api.network.AuthType; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.auth.BedrockClientData; @@ -53,13 +53,30 @@ public class SkinManager { * Builds a Bedrock player list entry from our existing, cached Bedrock skin information */ public static PlayerListPacket.Entry buildCachedEntry(GeyserSession session, PlayerEntity playerEntity) { + // First: see if we have the cached skin texture ID. GameProfileData data = GameProfileData.from(playerEntity); - SkinProvider.Cape cape = SkinProvider.getCachedCape(data.capeUrl()); - SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.getLegacy(data.isAlex()); + SkinProvider.Skin skin = null; + SkinProvider.Cape cape = null; + SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.WIDE; + if (data != null) { + // GameProfileData is not null = server provided us with textures data to work with. + skin = SkinProvider.getCachedSkin(data.skinUrl()); + cape = SkinProvider.getCachedCape(data.capeUrl()); + geometry = data.isAlex() ? SkinProvider.SkinGeometry.SLIM : SkinProvider.SkinGeometry.WIDE; + } - SkinProvider.Skin skin = SkinProvider.getCachedSkin(data.skinUrl()); - if (skin == null) { - skin = SkinProvider.EMPTY_SKIN; + if (skin == null || cape == null) { + // The server either didn't have a texture to send, or we didn't have the texture ID cached. + // Let's see if this player is a Bedrock player, and if so, let's pull their skin. + // Otherwise, grab the default player skin + SkinProvider.SkinData fallbackSkinData = SkinProvider.determineFallbackSkinData(playerEntity); + if (skin == null) { + skin = fallbackSkinData.skin(); + geometry = fallbackSkinData.geometry(); + } + if (cape == null) { + cape = fallbackSkinData.cape(); + } } return buildEntryManually( @@ -67,10 +84,8 @@ public class SkinManager { playerEntity.getUuid(), playerEntity.getUsername(), playerEntity.getGeyserId(), - skin.getTextureUrl(), - skin.getSkinData(), - cape.getCapeId(), - cape.getCapeData(), + skin, + cape, geometry ); } @@ -79,14 +94,10 @@ public class SkinManager { * With all the information needed, build a Bedrock player entry with translated skin information. */ public static PlayerListPacket.Entry buildEntryManually(GeyserSession session, UUID uuid, String username, long geyserId, - String skinId, byte[] skinData, - String capeId, byte[] capeData, + SkinProvider.Skin skin, + SkinProvider.Cape cape, SkinProvider.SkinGeometry geometry) { - SerializedSkin serializedSkin = SerializedSkin.of( - skinId, "", geometry.getGeometryName(), ImageData.of(skinData), Collections.emptyList(), - ImageData.of(capeData), geometry.getGeometryData(), "", true, false, - !capeId.equals(SkinProvider.EMPTY_CAPE.getCapeId()), capeId, skinId - ); + SerializedSkin serializedSkin = getSkin(skin.getTextureUrl(), skin, cape, geometry); // This attempts to find the XUID of the player so profile images show up for Xbox accounts String xuid = ""; @@ -116,6 +127,45 @@ public class SkinManager { return entry; } + public static void sendSkinPacket(GeyserSession session, PlayerEntity entity, SkinProvider.SkinData skinData) { + SkinProvider.Skin skin = skinData.skin(); + SkinProvider.Cape cape = skinData.cape(); + SkinProvider.SkinGeometry geometry = skinData.geometry(); + + if (entity.getUuid().equals(session.getPlayerEntity().getUuid())) { + // TODO is this special behavior needed? + PlayerListPacket.Entry updatedEntry = buildEntryManually( + session, + entity.getUuid(), + entity.getUsername(), + entity.getGeyserId(), + skin, + cape, + geometry + ); + + PlayerListPacket playerAddPacket = new PlayerListPacket(); + playerAddPacket.setAction(PlayerListPacket.Action.ADD); + playerAddPacket.getEntries().add(updatedEntry); + session.sendUpstreamPacket(playerAddPacket); + } else { + PlayerSkinPacket packet = new PlayerSkinPacket(); + packet.setUuid(entity.getUuid()); + packet.setOldSkinName(""); + packet.setNewSkinName(skin.getTextureUrl()); + packet.setSkin(getSkin(skin.getTextureUrl(), skin, cape, geometry)); + packet.setTrustedSkin(true); + session.sendUpstreamPacket(packet); + } + } + + private static SerializedSkin getSkin(String skinId, SkinProvider.Skin skin, SkinProvider.Cape cape, SkinProvider.SkinGeometry geometry) { + return SerializedSkin.of(skinId, "", geometry.geometryName(), + ImageData.of(skin.getSkinData()), Collections.emptyList(), + ImageData.of(cape.capeData()), geometry.geometryData(), + "", true, false, false, cape.capeId(), skinId); + } + public static void requestAndHandleSkinAndCape(PlayerEntity entity, GeyserSession session, Consumer skinAndCapeConsumer) { SkinProvider.requestSkinData(entity).whenCompleteAsync((skinData, throwable) -> { @@ -128,34 +178,7 @@ public class SkinManager { } if (skinData.geometry() != null) { - SkinProvider.Skin skin = skinData.skin(); - SkinProvider.Cape cape = skinData.cape(); - SkinProvider.SkinGeometry geometry = skinData.geometry(); - - PlayerListPacket.Entry updatedEntry = buildEntryManually( - session, - entity.getUuid(), - entity.getUsername(), - entity.getGeyserId(), - skin.getTextureUrl(), - skin.getSkinData(), - cape.getCapeId(), - cape.getCapeData(), - geometry - ); - - - PlayerListPacket playerAddPacket = new PlayerListPacket(); - playerAddPacket.setAction(PlayerListPacket.Action.ADD); - playerAddPacket.getEntries().add(updatedEntry); - session.sendUpstreamPacket(playerAddPacket); - - if (!entity.isPlayerList()) { - PlayerListPacket playerRemovePacket = new PlayerListPacket(); - playerRemovePacket.setAction(PlayerListPacket.Action.REMOVE); - playerRemovePacket.getEntries().add(updatedEntry); - session.sendUpstreamPacket(playerRemovePacket); - } + sendSkinPacket(session, entity, skinData); } if (skinAndCapeConsumer != null) { @@ -186,7 +209,7 @@ public class SkinManager { } if (!clientData.getCapeId().equals("")) { - SkinProvider.storeBedrockCape(playerEntity.getUuid(), capeBytes); + SkinProvider.storeBedrockCape(clientData.getCapeId(), capeBytes); } } catch (Exception e) { throw new AssertionError("Failed to cache skin for bedrock user (" + playerEntity.getUsername() + "): ", e); @@ -231,26 +254,21 @@ public class SkinManager { * @param entity entity to build the GameProfileData from * @return The built GameProfileData */ - public static GameProfileData from(PlayerEntity entity) { + public static @Nullable GameProfileData from(PlayerEntity entity) { try { String texturesProperty = entity.getTexturesProperty(); if (texturesProperty == null) { // Likely offline mode - return loadBedrockOrOfflineSkin(entity); - } - GameProfileData data = loadFromJson(texturesProperty); - if (data != null) { - return data; - } else { - return loadBedrockOrOfflineSkin(entity); + return null; } + return loadFromJson(texturesProperty); } catch (IOException exception) { GeyserImpl.getInstance().getLogger().debug("Something went wrong while processing skin for " + entity.getUsername()); if (GeyserImpl.getInstance().getConfig().isDebugMode()) { exception.printStackTrace(); } - return loadBedrockOrOfflineSkin(entity); + return null; } } @@ -279,27 +297,5 @@ public class SkinManager { return new GameProfileData(skinUrl, capeUrl, isAlex); } - - /** - * @return default skin with default cape when texture data is invalid, or the Bedrock player's skin if this - * is a Bedrock player. - */ - private static GameProfileData loadBedrockOrOfflineSkin(PlayerEntity entity) { - // Fallback to the offline mode of working it out - UUID uuid = entity.getUuid(); - boolean isAlex = (Math.abs(uuid.hashCode() % 2) == 1); - - String skinUrl = isAlex ? SkinProvider.EMPTY_SKIN_ALEX.getTextureUrl() : SkinProvider.EMPTY_SKIN.getTextureUrl(); - String capeUrl = SkinProvider.EMPTY_CAPE.getTextureUrl(); - if (("steve".equals(skinUrl) || "alex".equals(skinUrl)) && GeyserImpl.getInstance().getConfig().getRemote().authType() != AuthType.ONLINE) { - GeyserSession session = GeyserImpl.getInstance().connectionByUuid(uuid); - - if (session != null) { - skinUrl = session.getClientData().getSkinId(); - capeUrl = session.getClientData().getCapeId(); - } - } - return new GameProfileData(skinUrl, capeUrl, isAlex); - } } } diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java index 43cf30b47..61f24ac1e 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java @@ -26,22 +26,25 @@ package org.geysermc.geyser.skin; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.github.steveice10.opennbt.tag.builtin.IntArrayTag; import com.github.steveice10.opennbt.tag.builtin.Tag; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; +import it.unimi.dsi.fastutil.bytes.ByteArrays; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.network.AuthType; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.util.FileUtils; import org.geysermc.geyser.util.WebUtils; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; @@ -57,28 +60,28 @@ import java.util.concurrent.*; import java.util.function.Predicate; public class SkinProvider { - public static final boolean ALLOW_THIRD_PARTY_CAPES = GeyserImpl.getInstance().getConfig().isAllowThirdPartyCapes(); + private static final boolean ALLOW_THIRD_PARTY_CAPES = GeyserImpl.getInstance().getConfig().isAllowThirdPartyCapes(); static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(ALLOW_THIRD_PARTY_CAPES ? 21 : 14); - public static final byte[] STEVE_SKIN = new ProvidedSkin("bedrock/skin/skin_steve.png").getSkin(); - public static final Skin EMPTY_SKIN = new Skin(-1, "steve", STEVE_SKIN); - public static final byte[] ALEX_SKIN = new ProvidedSkin("bedrock/skin/skin_alex.png").getSkin(); - public static final Skin EMPTY_SKIN_ALEX = new Skin(-1, "alex", ALEX_SKIN); - private static final Map permanentSkins = new HashMap<>() {{ - put("steve", EMPTY_SKIN); - put("alex", EMPTY_SKIN_ALEX); - }}; - private static final Cache cachedSkins = CacheBuilder.newBuilder() + static final Skin EMPTY_SKIN; + static final Cape EMPTY_CAPE = new Cape("", "no-cape", ByteArrays.EMPTY_ARRAY, -1, true); + + private static final Cache CACHED_JAVA_CAPES = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build(); + private static final Cache CACHED_JAVA_SKINS = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.HOURS) .build(); - private static final Map> requestedSkins = new ConcurrentHashMap<>(); - - public static final Cape EMPTY_CAPE = new Cape("", "no-cape", new byte[0], -1, true); - private static final Cache cachedCapes = CacheBuilder.newBuilder() + private static final Cache CACHED_BEDROCK_CAPES = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.HOURS) .build(); + private static final Cache CACHED_BEDROCK_SKINS = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build(); + private static final Map> requestedCapes = new ConcurrentHashMap<>(); + private static final Map> requestedSkins = new ConcurrentHashMap<>(); private static final Map cachedGeometry = new ConcurrentHashMap<>(); @@ -86,18 +89,36 @@ public class SkinProvider { * Citizens NPCs use UUID version 2, while legitimate Minecraft players use version 4, and * offline mode players use version 3. */ - public static final Predicate IS_NPC = uuid -> uuid.version() == 2; + private static final Predicate IS_NPC = uuid -> uuid.version() == 2; - public static final boolean ALLOW_THIRD_PARTY_EARS = GeyserImpl.getInstance().getConfig().isAllowThirdPartyEars(); - public static final String EARS_GEOMETRY; - public static final String EARS_GEOMETRY_SLIM; - public static final SkinGeometry SKULL_GEOMETRY; - public static final SkinGeometry WEARING_CUSTOM_SKULL; - public static final SkinGeometry WEARING_CUSTOM_SKULL_SLIM; - - public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final boolean ALLOW_THIRD_PARTY_EARS = GeyserImpl.getInstance().getConfig().isAllowThirdPartyEars(); + private static final String EARS_GEOMETRY; + private static final String EARS_GEOMETRY_SLIM; + static final SkinGeometry SKULL_GEOMETRY; + static final SkinGeometry WEARING_CUSTOM_SKULL; + static final SkinGeometry WEARING_CUSTOM_SKULL_SLIM; static { + // Generate the empty texture to use as an emergency fallback + final int pink = -524040; + final int black = -16777216; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(64 * 4 + 64 * 4); + for (int y = 0; y < 64; y++) { + for (int x = 0; x < 64; x++) { + int rgba; + if (y > 32) { + rgba = x >= 32 ? pink : black; + } else { + rgba = x >= 32 ? black : pink; + } + outputStream.write((rgba >> 16) & 0xFF); // Red + outputStream.write((rgba >> 8) & 0xFF); // Green + outputStream.write(rgba & 0xFF); // Blue + outputStream.write((rgba >> 24) & 0xFF); // Alpha + } + } + EMPTY_SKIN = new Skin(-1, "geysermc:empty", outputStream.toByteArray()); + /* Load in the normal ears geometry */ EARS_GEOMETRY = new String(FileUtils.readAllBytes("bedrock/skin/geometry.humanoid.ears.json"), StandardCharsets.UTF_8); @@ -141,48 +162,104 @@ public class SkinProvider { } } - public static boolean hasCapeCached(String capeUrl) { - return cachedCapes.getIfPresent(capeUrl) != null; + /** + * Search our cached database for an already existing, translated skin of this Java URL. + */ + static Skin getCachedSkin(String skinUrl) { + return CACHED_JAVA_SKINS.getIfPresent(skinUrl); } - public static Skin getCachedSkin(String skinUrl) { - return permanentSkins.getOrDefault(skinUrl, cachedSkins.getIfPresent(skinUrl)); + /** + * If skin data fails to apply, or there is no skin data to apply, determine what skin we should give as a fallback. + */ + static SkinData determineFallbackSkinData(PlayerEntity entity) { + Skin skin = null; + Cape cape = null; + SkinGeometry geometry = SkinGeometry.WIDE; + + if (GeyserImpl.getInstance().getConfig().getRemote().authType() != AuthType.ONLINE) { + // Let's see if this player is a Bedrock player, and if so, let's pull their skin. + UUID uuid = entity.getUuid(); + GeyserSession session = GeyserImpl.getInstance().connectionByUuid(uuid); + if (session != null) { + String skinId = session.getClientData().getSkinId(); + skin = CACHED_BEDROCK_SKINS.getIfPresent(skinId); + String capeId = session.getClientData().getCapeId(); + cape = CACHED_BEDROCK_CAPES.getIfPresent(capeId); + geometry = cachedGeometry.getOrDefault(uuid, geometry); + } + } + + if (skin == null) { + // We don't have a skin for the player right now. Fall back to a default. + ProvidedSkins.ProvidedSkin providedSkin = ProvidedSkins.getDefaultPlayerSkin(entity.getUuid()); + skin = providedSkin.getData(); + geometry = providedSkin.isSlim() ? SkinProvider.SkinGeometry.SLIM : SkinProvider.SkinGeometry.WIDE; + } + + if (cape == null) { + cape = EMPTY_CAPE; + } + + return new SkinData(skin, cape, geometry); } - public static Cape getCachedCape(String capeUrl) { - Cape cape = capeUrl != null ? cachedCapes.getIfPresent(capeUrl) : EMPTY_CAPE; - return cape != null ? cape : EMPTY_CAPE; + /** + * Used as a fallback if an official Java cape doesn't exist for this user. + */ + @Nonnull + private static Cape getCachedBedrockCape(UUID uuid) { + GeyserSession session = GeyserImpl.getInstance().connectionByUuid(uuid); + if (session != null) { + String capeId = session.getClientData().getCapeId(); + Cape bedrockCape = CACHED_BEDROCK_CAPES.getIfPresent(capeId); + if (bedrockCape != null) { + return bedrockCape; + } + } + return EMPTY_CAPE; } - public static CompletableFuture requestSkinData(PlayerEntity entity) { + @Nullable + static Cape getCachedCape(String capeUrl) { + if (capeUrl == null) { + return null; + } + return CACHED_JAVA_CAPES.getIfPresent(capeUrl); + } + + static CompletableFuture requestSkinData(PlayerEntity entity) { SkinManager.GameProfileData data = SkinManager.GameProfileData.from(entity); + if (data == null) { + // This player likely does not have a textures property + return CompletableFuture.completedFuture(determineFallbackSkinData(entity)); + } return requestSkinAndCape(entity.getUuid(), data.skinUrl(), data.capeUrl()) .thenApplyAsync(skinAndCape -> { try { - Skin skin = skinAndCape.getSkin(); - Cape cape = skinAndCape.getCape(); - SkinGeometry geometry = SkinGeometry.getLegacy(data.isAlex()); + Skin skin = skinAndCape.skin(); + Cape cape = skinAndCape.cape(); + SkinGeometry geometry = data.isAlex() ? SkinGeometry.SLIM : SkinGeometry.WIDE; - if (cape.isFailed()) { - cape = getOrDefault(requestBedrockCape(entity.getUuid()), - EMPTY_CAPE, 3); + // Whether we should see if this player has a Bedrock skin we should check for on failure of + // any skin property + boolean checkForBedrock = entity.getUuid().version() != 4; + + if (cape.failed() && checkForBedrock) { + cape = getCachedBedrockCape(entity.getUuid()); } - if (cape.isFailed() && ALLOW_THIRD_PARTY_CAPES) { + if (cape.failed() && ALLOW_THIRD_PARTY_CAPES) { cape = getOrDefault(requestUnofficialCape( cape, entity.getUuid(), entity.getUsername(), false ), EMPTY_CAPE, CapeProvider.VALUES.length * 3); } - geometry = getOrDefault(requestBedrockGeometry( - geometry, entity.getUuid() - ), geometry, 3); - boolean isDeadmau5 = "deadmau5".equals(entity.getUsername()); // Not a bedrock player check for ears - if (geometry.isFailed() && (ALLOW_THIRD_PARTY_EARS || isDeadmau5)) { + if (geometry.failed() && (ALLOW_THIRD_PARTY_EARS || isDeadmau5)) { boolean isEars; // Its deadmau5, gotta support his skin :) @@ -213,26 +290,17 @@ public class SkinProvider { GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), e); } - return new SkinData(skinAndCape.getSkin(), skinAndCape.getCape(), null); + return new SkinData(skinAndCape.skin(), skinAndCape.cape(), null); }); } - public static CompletableFuture requestSkinAndCape(UUID playerId, String skinUrl, String capeUrl) { + private static CompletableFuture requestSkinAndCape(UUID playerId, String skinUrl, String capeUrl) { return CompletableFuture.supplyAsync(() -> { long time = System.currentTimeMillis(); - String newSkinUrl = skinUrl; - - if ("steve".equals(skinUrl) || "alex".equals(skinUrl)) { - GeyserSession session = GeyserImpl.getInstance().connectionByUuid(playerId); - - if (session != null) { - newSkinUrl = session.getClientData().getSkinId(); - } - } CapeProvider provider = capeUrl != null ? CapeProvider.MINECRAFT : null; SkinAndCape skinAndCape = new SkinAndCape( - getOrDefault(requestSkin(playerId, newSkinUrl, false), EMPTY_SKIN, 5), + getOrDefault(requestSkin(playerId, skinUrl, false), EMPTY_SKIN, 5), getOrDefault(requestCape(capeUrl, provider, false), EMPTY_CAPE, 5) ); @@ -241,7 +309,7 @@ public class SkinProvider { }, EXECUTOR_SERVICE); } - public static CompletableFuture requestSkin(UUID playerId, String textureUrl, boolean newThread) { + static CompletableFuture requestSkin(UUID playerId, String textureUrl, boolean newThread) { if (textureUrl == null || textureUrl.isEmpty()) return CompletableFuture.completedFuture(EMPTY_SKIN); CompletableFuture requestedSkin = requestedSkins.get(textureUrl); if (requestedSkin != null) { @@ -249,7 +317,7 @@ public class SkinProvider { return requestedSkin; } - Skin cachedSkin = getCachedSkin(textureUrl); + Skin cachedSkin = CACHED_JAVA_SKINS.getIfPresent(textureUrl); if (cachedSkin != null) { return CompletableFuture.completedFuture(cachedSkin); } @@ -259,23 +327,26 @@ public class SkinProvider { future = CompletableFuture.supplyAsync(() -> supplySkin(playerId, textureUrl), EXECUTOR_SERVICE) .whenCompleteAsync((skin, throwable) -> { skin.updated = true; - cachedSkins.put(textureUrl, skin); + CACHED_JAVA_SKINS.put(textureUrl, skin); requestedSkins.remove(textureUrl); }); requestedSkins.put(textureUrl, future); } else { Skin skin = supplySkin(playerId, textureUrl); future = CompletableFuture.completedFuture(skin); - cachedSkins.put(textureUrl, skin); + CACHED_JAVA_SKINS.put(textureUrl, skin); } return future; } - public static CompletableFuture requestCape(String capeUrl, CapeProvider provider, boolean newThread) { + private static CompletableFuture requestCape(String capeUrl, CapeProvider provider, boolean newThread) { if (capeUrl == null || capeUrl.isEmpty()) return CompletableFuture.completedFuture(EMPTY_CAPE); - if (requestedCapes.containsKey(capeUrl)) return requestedCapes.get(capeUrl); // already requested + CompletableFuture requestedCape = requestedCapes.get(capeUrl); + if (requestedCape != null) { + return requestedCape; + } - Cape cachedCape = cachedCapes.getIfPresent(capeUrl); + Cape cachedCape = CACHED_JAVA_CAPES.getIfPresent(capeUrl); if (cachedCape != null) { return CompletableFuture.completedFuture(cachedCape); } @@ -284,21 +355,21 @@ public class SkinProvider { if (newThread) { future = CompletableFuture.supplyAsync(() -> supplyCape(capeUrl, provider), EXECUTOR_SERVICE) .whenCompleteAsync((cape, throwable) -> { - cachedCapes.put(capeUrl, cape); + CACHED_JAVA_CAPES.put(capeUrl, cape); requestedCapes.remove(capeUrl); }); requestedCapes.put(capeUrl, future); } else { Cape cape = supplyCape(capeUrl, provider); // blocking future = CompletableFuture.completedFuture(cape); - cachedCapes.put(capeUrl, cape); + CACHED_JAVA_CAPES.put(capeUrl, cape); } return future; } - public static CompletableFuture requestUnofficialCape(Cape officialCape, UUID playerId, + private static CompletableFuture requestUnofficialCape(Cape officialCape, UUID playerId, String username, boolean newThread) { - if (officialCape.isFailed() && ALLOW_THIRD_PARTY_CAPES) { + if (officialCape.failed() && ALLOW_THIRD_PARTY_CAPES) { for (CapeProvider provider : CapeProvider.VALUES) { if (provider.type != CapeUrlType.USERNAME && IS_NPC.test(playerId)) { continue; @@ -308,7 +379,7 @@ public class SkinProvider { requestCape(provider.getUrlFor(playerId, username), provider, newThread), EMPTY_CAPE, 4 ); - if (!cape1.isFailed()) { + if (!cape1.failed()) { return CompletableFuture.completedFuture(cape1); } } @@ -316,7 +387,7 @@ public class SkinProvider { return CompletableFuture.completedFuture(officialCape); } - public static CompletableFuture requestEars(String earsUrl, boolean newThread, Skin skin) { + private static CompletableFuture requestEars(String earsUrl, boolean newThread, Skin skin) { if (earsUrl == null || earsUrl.isEmpty()) return CompletableFuture.completedFuture(skin); CompletableFuture future; @@ -339,7 +410,7 @@ public class SkinProvider { * @param newThread Should we start in a new thread * @return The updated skin with ears */ - public static CompletableFuture requestUnofficialEars(Skin officialSkin, UUID playerId, String username, boolean newThread) { + private static CompletableFuture requestUnofficialEars(Skin officialSkin, UUID playerId, String username, boolean newThread) { for (EarsProvider provider : EarsProvider.VALUES) { if (provider.type != CapeUrlType.USERNAME && IS_NPC.test(playerId)) { continue; @@ -357,30 +428,17 @@ public class SkinProvider { return CompletableFuture.completedFuture(officialSkin); } - public static CompletableFuture requestBedrockCape(UUID playerID) { - Cape bedrockCape = cachedCapes.getIfPresent(playerID.toString() + ".Bedrock"); - if (bedrockCape == null) { - bedrockCape = EMPTY_CAPE; - } - return CompletableFuture.completedFuture(bedrockCape); + static void storeBedrockSkin(UUID playerID, String skinId, byte[] skinData) { + Skin skin = new Skin(playerID, skinId, skinData, System.currentTimeMillis(), true, false); + CACHED_BEDROCK_SKINS.put(skin.getTextureUrl(), skin); } - public static CompletableFuture requestBedrockGeometry(SkinGeometry currentGeometry, UUID playerID) { - SkinGeometry bedrockGeometry = cachedGeometry.getOrDefault(playerID, currentGeometry); - return CompletableFuture.completedFuture(bedrockGeometry); + static void storeBedrockCape(String capeId, byte[] capeData) { + Cape cape = new Cape(capeId, capeId, capeData, System.currentTimeMillis(), false); + CACHED_BEDROCK_CAPES.put(capeId, cape); } - public static void storeBedrockSkin(UUID playerID, String skinID, byte[] skinData) { - Skin skin = new Skin(playerID, skinID, skinData, System.currentTimeMillis(), true, false); - cachedSkins.put(skin.getTextureUrl(), skin); - } - - public static void storeBedrockCape(UUID playerID, byte[] capeData) { - Cape cape = new Cape(playerID.toString() + ".Bedrock", playerID.toString(), capeData, System.currentTimeMillis(), false); - cachedCapes.put(playerID.toString() + ".Bedrock", cape); - } - - public static void storeBedrockGeometry(UUID playerID, byte[] geometryName, byte[] geometryData) { + static void storeBedrockGeometry(UUID playerID, byte[] geometryName, byte[] geometryData) { SkinGeometry geometry = new SkinGeometry(new String(geometryName), new String(geometryData), false); cachedGeometry.put(playerID, geometry); } @@ -391,7 +449,7 @@ public class SkinProvider { * @param skin The skin to cache */ public static void storeEarSkin(Skin skin) { - cachedSkins.put(skin.getTextureUrl(), skin); + CACHED_JAVA_SKINS.put(skin.getTextureUrl(), skin); } /** @@ -400,7 +458,7 @@ public class SkinProvider { * @param playerID The UUID to cache it against * @param isSlim If the player is using an slim base */ - public static void storeEarGeometry(UUID playerID, boolean isSlim) { + private static void storeEarGeometry(UUID playerID, boolean isSlim) { cachedGeometry.put(playerID, SkinGeometry.getEars(isSlim)); } @@ -414,7 +472,7 @@ public class SkinProvider { } private static Cape supplyCape(String capeUrl, CapeProvider provider) { - byte[] cape = EMPTY_CAPE.getCapeData(); + byte[] cape = EMPTY_CAPE.capeData(); try { cape = requestImage(capeUrl, provider); } catch (Exception ignored) { @@ -604,7 +662,7 @@ public class SkinProvider { } private static BufferedImage readFiveZigCape(String url) throws IOException { - JsonNode element = OBJECT_MAPPER.readTree(WebUtils.getBody(url)); + JsonNode element = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(url)); if (element != null && element.isObject()) { JsonNode capeElement = element.get("d"); if (capeElement == null || capeElement.isNull()) return null; @@ -683,13 +741,12 @@ public class SkinProvider { return defaultValue; } - @AllArgsConstructor - @Getter - public static class SkinAndCape { - private final Skin skin; - private final Cape cape; + public record SkinAndCape(Skin skin, Cape cape) { } + /** + * Represents a full package of skin, cape, and geometry. + */ public record SkinData(Skin skin, Cape cape, SkinGeometry geometry) { } @@ -703,29 +760,19 @@ public class SkinProvider { private boolean updated; private boolean ears; - private Skin(long requestedOn, String textureUrl, byte[] skinData) { + Skin(long requestedOn, String textureUrl, byte[] skinData) { this.requestedOn = requestedOn; this.textureUrl = textureUrl; this.skinData = skinData; } } - @AllArgsConstructor - @Getter - public static class Cape { - private final String textureUrl; - private final String capeId; - private final byte[] capeData; - private final long requestedOn; - private final boolean failed; + public record Cape(String textureUrl, String capeId, byte[] capeData, long requestedOn, boolean failed) { } - @AllArgsConstructor - @Getter - public static class SkinGeometry { - private final String geometryName; - private final String geometryData; - private final boolean failed; + public record SkinGeometry(String geometryName, String geometryData, boolean failed) { + public static SkinGeometry WIDE = getLegacy(false); + public static SkinGeometry SLIM = getLegacy(true); /** * Generate generic geometry @@ -733,7 +780,7 @@ public class SkinProvider { * @param isSlim Should it be the alex model * @return The generic geometry object */ - public static SkinGeometry getLegacy(boolean isSlim) { + private static SkinGeometry getLegacy(boolean isSlim) { return new SkinProvider.SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.custom" + (isSlim ? "Slim" : "") + "\"}}", "", true); } @@ -743,7 +790,7 @@ public class SkinProvider { * @param isSlim Should it be the alex model * @return The generated geometry for the ears model */ - public static SkinGeometry getEars(boolean isSlim) { + private static SkinGeometry getEars(boolean isSlim) { return new SkinProvider.SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.ears" + (isSlim ? "Slim" : "") + "\"}}", (isSlim ? EARS_GEOMETRY_SLIM : EARS_GEOMETRY), false); } } diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java b/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java index 58054e9c5..2759b1408 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java @@ -42,9 +42,9 @@ public class SkullSkinManager extends SkinManager { // Prevents https://cdn.discordapp.com/attachments/613194828359925800/779458146191147008/unknown.png skinId = skinId + "_skull"; return SerializedSkin.of( - skinId, "", SkinProvider.SKULL_GEOMETRY.getGeometryName(), ImageData.of(skinData), Collections.emptyList(), - ImageData.of(SkinProvider.EMPTY_CAPE.getCapeData()), SkinProvider.SKULL_GEOMETRY.getGeometryData(), - "", true, false, false, SkinProvider.EMPTY_CAPE.getCapeId(), skinId + skinId, "", SkinProvider.SKULL_GEOMETRY.geometryName(), ImageData.of(skinData), Collections.emptyList(), + ImageData.of(SkinProvider.EMPTY_CAPE.capeData()), SkinProvider.SKULL_GEOMETRY.geometryData(), + "", true, false, false, SkinProvider.EMPTY_CAPE.capeId(), skinId ); } diff --git a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java index 94ad5eead..9b0edd82f 100644 --- a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java +++ b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java @@ -25,91 +25,45 @@ package org.geysermc.geyser.text; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; -import lombok.Getter; import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.network.GameProtocol; +import org.geysermc.geyser.util.AssetUtils; import org.geysermc.geyser.util.FileUtils; import org.geysermc.geyser.util.WebUtils; import java.io.*; import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.zip.ZipFile; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; public class MinecraftLocale { public static final Map> LOCALE_MAPPINGS = new HashMap<>(); - private static final Map ASSET_MAP = new HashMap<>(); - - private static VersionDownload clientJarInfo; - static { // Create the locales folder File localesFolder = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales").toFile(); //noinspection ResultOfMethodCallIgnored localesFolder.mkdir(); - // Download the latest asset list and cache it - generateAssetCache().whenComplete((aVoid, ex) -> downloadAndLoadLocale(GeyserLocale.getDefaultLocale())); + // FIXME TEMPORARY + try { + Files.delete(localesFolder.toPath().resolve("en_us.hash")); + } catch (IOException ignored) { + } } - /** - * Fetch the latest versions asset cache from Mojang so we can grab the locale files later - */ - private static CompletableFuture generateAssetCache() { - return CompletableFuture.supplyAsync(() -> { - try { - // Get the version manifest from Mojang - VersionManifest versionManifest = GeyserImpl.JSON_MAPPER.readValue(WebUtils.getBody("https://launchermeta.mojang.com/mc/game/version_manifest.json"), VersionManifest.class); - - // Get the url for the latest version of the games manifest - String latestInfoURL = ""; - for (Version version : versionManifest.getVersions()) { - if (version.getId().equals(GameProtocol.getJavaCodec().getMinecraftVersion())) { - latestInfoURL = version.getUrl(); - break; + public static void ensureEN_US() { + File localeFile = getFile("en_us"); + AssetUtils.addTask(!localeFile.exists(), new AssetUtils.ClientJarTask("assets/minecraft/lang/en_us.json", + (stream) -> AssetUtils.saveFile(localeFile, stream), + () -> { + if ("en_us".equals(GeyserLocale.getDefaultLocale())) { + loadLocale("en_us"); } - } - - // Make sure we definitely got a version - if (latestInfoURL.isEmpty()) { - throw new Exception(GeyserLocale.getLocaleStringLog("geyser.locale.fail.latest_version")); - } - - // Get the individual version manifest - VersionInfo versionInfo = GeyserImpl.JSON_MAPPER.readValue(WebUtils.getBody(latestInfoURL), VersionInfo.class); - - // Get the client jar for use when downloading the en_us locale - GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(versionInfo.getDownloads())); - clientJarInfo = versionInfo.getDownloads().get("client"); - GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(clientJarInfo)); - - // Get the assets list - JsonNode assets = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(versionInfo.getAssetIndex().getUrl())).get("objects"); - - // Put each asset into an array for use later - Iterator> assetIterator = assets.fields(); - while (assetIterator.hasNext()) { - Map.Entry entry = assetIterator.next(); - if (!entry.getKey().startsWith("minecraft/lang/")) { - // No need to cache non-language assets as we don't use them - continue; - } - - Asset asset = GeyserImpl.JSON_MAPPER.treeToValue(entry.getValue(), Asset.class); - ASSET_MAP.put(entry.getKey(), asset); - } - } catch (Exception e) { - GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.asset_cache", (!e.getMessage().isEmpty() ? e.getMessage() : e.getStackTrace()))); - } - return null; - }); + })); } /** @@ -125,7 +79,7 @@ public class MinecraftLocale { } // Check the locale isn't already loaded - if (!ASSET_MAP.containsKey("minecraft/lang/" + locale + ".json") && !locale.equals("en_us")) { + if (!AssetUtils.isAssetKnown("minecraft/lang/" + locale + ".json") && !locale.equals("en_us")) { if (loadLocale(locale)) { GeyserImpl.getInstance().getLogger().debug("Loaded locale locally while not being in asset map: " + locale); } else { @@ -148,33 +102,15 @@ public class MinecraftLocale { * @param locale Locale to download */ private static void downloadLocale(String locale) { - File localeFile = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/" + locale + ".json").toFile(); + if (locale.equals("en_us")) { + return; + } + File localeFile = getFile(locale); // Check if we have already downloaded the locale file if (localeFile.exists()) { - String curHash = ""; - String targetHash; - - if (locale.equals("en_us")) { - try { - File hashFile = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/en_us.hash").toFile(); - if (hashFile.exists()) { - try (BufferedReader br = new BufferedReader(new FileReader(hashFile))) { - curHash = br.readLine().trim(); - } - } - } catch (IOException ignored) { } - - if (clientJarInfo == null) { - // Likely failed to download - GeyserImpl.getInstance().getLogger().debug("Skipping en_US hash check as client jar is null."); - return; - } - targetHash = clientJarInfo.getSha1(); - } else { - curHash = byteArrayToHexString(FileUtils.calculateSHA1(localeFile)); - targetHash = ASSET_MAP.get("minecraft/lang/" + locale + ".json").getHash(); - } + String curHash = byteArrayToHexString(FileUtils.calculateSHA1(localeFile)); + String targetHash = AssetUtils.getAsset("minecraft/lang/" + locale + ".json").getHash(); if (!curHash.equals(targetHash)) { GeyserImpl.getInstance().getLogger().debug("Locale out of date; re-downloading: " + locale); @@ -184,22 +120,19 @@ public class MinecraftLocale { } } - // Create the en_us locale - if (locale.equals("en_us")) { - downloadEN_US(localeFile); - - return; - } - try { // Get the hash and download the locale - String hash = ASSET_MAP.get("minecraft/lang/" + locale + ".json").getHash(); + String hash = AssetUtils.getAsset("minecraft/lang/" + locale + ".json").getHash(); WebUtils.downloadFile("https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash, localeFile.toString()); } catch (Exception e) { GeyserImpl.getInstance().getLogger().error("Unable to download locale file hash", e); } } + private static File getFile(String locale) { + return GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/" + locale + ".json").toFile(); + } + /** * Loads a locale already downloaded, if the file doesn't exist it just logs a warning * @@ -254,51 +187,6 @@ public class MinecraftLocale { } } - /** - * Download then en_us locale by downloading the server jar and extracting it from there. - * - * @param localeFile File to save the locale to - */ - private static void downloadEN_US(File localeFile) { - try { - // Let the user know we are downloading the JAR - GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us")); - GeyserImpl.getInstance().getLogger().debug("Download URL: " + clientJarInfo.getUrl()); - - // Download the smallest JAR (client or server) - Path tmpFilePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("tmp_locale.jar"); - WebUtils.downloadFile(clientJarInfo.getUrl(), tmpFilePath.toString()); - - // Load in the JAR as a zip and extract the file - try (ZipFile localeJar = new ZipFile(tmpFilePath.toString())) { - try (InputStream fileStream = localeJar.getInputStream(localeJar.getEntry("assets/minecraft/lang/en_us.json"))) { - try (FileOutputStream outStream = new FileOutputStream(localeFile)) { - - // Write the file to the locale dir - byte[] buf = new byte[fileStream.available()]; - int length; - while ((length = fileStream.read(buf)) != -1) { - outStream.write(buf, 0, length); - } - - // Flush all changes to disk and cleanup - outStream.flush(); - } - } - } - - // Store the latest jar hash - FileUtils.writeFile(GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/en_us.hash").toString(), clientJarInfo.getSha1().toCharArray()); - - // Delete the nolonger needed client/server jar - Files.delete(tmpFilePath); - - GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us.done")); - } catch (Exception e) { - GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.en_us"), e); - } - } - /** * Translate the given language string into the given locale, or falls back to the default locale * @@ -333,111 +221,4 @@ public class MinecraftLocale { } return result.toString(); } - - public static void init() { - // no-op - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class VersionManifest { - @JsonProperty("latest") - private LatestVersion latestVersion; - - @JsonProperty("versions") - private List versions; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class LatestVersion { - @JsonProperty("release") - private String release; - - @JsonProperty("snapshot") - private String snapshot; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class Version { - @JsonProperty("id") - private String id; - - @JsonProperty("type") - private String type; - - @JsonProperty("url") - private String url; - - @JsonProperty("time") - private String time; - - @JsonProperty("releaseTime") - private String releaseTime; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class VersionInfo { - @JsonProperty("id") - private String id; - - @JsonProperty("type") - private String type; - - @JsonProperty("time") - private String time; - - @JsonProperty("releaseTime") - private String releaseTime; - - @JsonProperty("assetIndex") - private AssetIndex assetIndex; - - @JsonProperty("downloads") - private Map downloads; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class VersionDownload { - @JsonProperty("sha1") - private String sha1; - - @JsonProperty("size") - private int size; - - @JsonProperty("url") - private String url; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class AssetIndex { - @JsonProperty("id") - private String id; - - @JsonProperty("sha1") - private String sha1; - - @JsonProperty("size") - private int size; - - @JsonProperty("totalSize") - private int totalSize; - - @JsonProperty("url") - private String url; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class Asset { - @JsonProperty("hash") - private String hash; - - @JsonProperty("size") - private int size; - } } \ No newline at end of file diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java index 95dd07f22..a69a2cfe9 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/item/nbt/BannerTranslator.java @@ -56,17 +56,17 @@ public class BannerTranslator extends NbtItemStackTranslator { static { OMINOUS_BANNER_PATTERN = new ListTag("Patterns"); // Construct what an ominous banner is supposed to look like - OMINOUS_BANNER_PATTERN.add(getPatternTag("mr", 9)); - OMINOUS_BANNER_PATTERN.add(getPatternTag("bs", 8)); - OMINOUS_BANNER_PATTERN.add(getPatternTag("cs", 7)); - OMINOUS_BANNER_PATTERN.add(getPatternTag("bo", 8)); - OMINOUS_BANNER_PATTERN.add(getPatternTag("ms", 15)); - OMINOUS_BANNER_PATTERN.add(getPatternTag("hh", 8)); - OMINOUS_BANNER_PATTERN.add(getPatternTag("mc", 8)); - OMINOUS_BANNER_PATTERN.add(getPatternTag("bo", 15)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("mr", 9)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("bs", 8)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("cs", 7)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("bo", 8)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("ms", 15)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("hh", 8)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("mc", 8)); + OMINOUS_BANNER_PATTERN.add(getJavaPatternTag("bo", 15)); } - private static CompoundTag getPatternTag(String pattern, int color) { + public static CompoundTag getJavaPatternTag(String pattern, int color) { StringTag patternType = new StringTag("Pattern", pattern); IntTag colorTag = new IntTag("Color", color); CompoundTag tag = new CompoundTag(""); @@ -117,11 +117,7 @@ public class BannerTranslator extends NbtItemStackTranslator { * @return The Java edition format pattern nbt */ public static CompoundTag getJavaBannerPattern(NbtMap pattern) { - Map tags = new HashMap<>(); - tags.put("Color", new IntTag("Color", 15 - pattern.getInt("Color"))); - tags.put("Pattern", new StringTag("Pattern", pattern.getString("Pattern"))); - - return new CompoundTag("", tags); + return BannerTranslator.getJavaPatternTag(pattern.getString("Pattern"), 15 - pattern.getInt("Color")); } /** diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java index 8d7cbe22b..90316a8bd 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockBlockPickRequestTranslator.java @@ -25,12 +25,18 @@ package org.geysermc.geyser.translator.protocol.bedrock; +import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack; +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; +import com.github.steveice10.opennbt.tag.builtin.ListTag; +import com.github.steveice10.opennbt.tag.builtin.StringTag; import com.nukkitx.math.vector.Vector3i; import com.nukkitx.protocol.bedrock.packet.BlockPickRequestPacket; import org.geysermc.geyser.entity.EntityDefinitions; import org.geysermc.geyser.entity.type.ItemFrameEntity; import org.geysermc.geyser.level.block.BlockStateValues; import org.geysermc.geyser.registry.BlockRegistries; +import org.geysermc.geyser.registry.type.BlockMapping; +import org.geysermc.geyser.registry.type.ItemMapping; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.translator.protocol.PacketTranslator; import org.geysermc.geyser.translator.protocol.Translator; @@ -61,6 +67,41 @@ public class BedrockBlockPickRequestTranslator extends PacketTranslator { + if (tag == null) { + pickItem(session, blockMapping); + return; + } + + session.ensureInEventLoop(() -> { + if (addNbtData) { + ListTag lore = new ListTag("Lore"); + lore.add(new StringTag("", "\"(+NBT)\"")); + CompoundTag display = tag.get("display"); + if (display == null) { + display = new CompoundTag("display"); + tag.put(display); + } + display.put(lore); + } + // I don't really like this... I'd rather get an ID from the block mapping I think + ItemMapping mapping = session.getItemMappings().getMapping(blockMapping.getPickItem()); + + ItemStack itemStack = new ItemStack(mapping.getJavaId(), 1, tag); + InventoryUtils.findOrCreateItem(session, itemStack); + }); + }); + return; + } + + pickItem(session, blockMapping); + } + + private void pickItem(GeyserSession session, BlockMapping blockToPick) { + InventoryUtils.findOrCreateItem(session, blockToPick.getPickItem()); } } diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java index a9ef65fb5..482d153bb 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/BedrockEntityPickRequestTranslator.java @@ -25,7 +25,6 @@ package org.geysermc.geyser.translator.protocol.bedrock; -import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode; import com.nukkitx.protocol.bedrock.packet.EntityPickRequestPacket; import org.geysermc.geyser.entity.type.BoatEntity; import org.geysermc.geyser.entity.type.Entity; @@ -45,7 +44,10 @@ public class BedrockEntityPickRequestTranslator extends PacketTranslator { @@ -73,6 +77,23 @@ public class BedrockInteractTranslator extends PacketTranslator case LEAVE_VEHICLE: ServerboundPlayerCommandPacket sneakPacket = new ServerboundPlayerCommandPacket(entity.getEntityId(), PlayerState.START_SNEAKING); session.sendDownstreamPacket(sneakPacket); + + Entity currentVehicle = session.getPlayerEntity().getVehicle(); + session.setMountVehicleScheduledFuture(session.scheduleInEventLoop(() -> { + if (session.getPlayerEntity().getVehicle() == null) { + return; + } + + long vehicleBedrockId = currentVehicle.getGeyserId(); + if (session.getPlayerEntity().getVehicle().getGeyserId() == vehicleBedrockId) { + // The Bedrock client, as of 1.19.51, dismounts on its end. The server may not agree with this. + // If the server doesn't agree with our dismount (sends a packet saying we dismounted), + // then remount the player. + SetEntityLinkPacket linkPacket = new SetEntityLinkPacket(); + linkPacket.setEntityLink(new EntityLinkData(vehicleBedrockId, session.getPlayerEntity().getGeyserId(), EntityLinkData.Type.PASSENGER, true, false)); + session.sendUpstreamPacket(linkPacket); + } + }, 1, TimeUnit.SECONDS)); break; case MOUSEOVER: // Handle the buttons for mobile - "Mount", etc; and the suggestions for console - "ZL: Mount", etc diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoRemoveTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoRemoveTranslator.java index 4e9f0ca42..1aa9e33d9 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoRemoveTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoRemoveTranslator.java @@ -45,17 +45,15 @@ public class JavaPlayerInfoRemoveTranslator extends PacketTranslator { @Override public void translate(GeyserSession session, ClientboundPlayerInfoUpdatePacket packet) { - if (!packet.getActions().contains(PlayerListEntryAction.ADD_PLAYER)) { - return; - } + Set actions = packet.getActions(); - PlayerListPacket translate = new PlayerListPacket(); - translate.setAction(PlayerListPacket.Action.ADD); + if (actions.contains(PlayerListEntryAction.ADD_PLAYER)) { + for (PlayerListEntry entry : packet.getEntries()) { + GameProfile profile = entry.getProfile(); + PlayerEntity playerEntity; + boolean self = profile.getId().equals(session.getPlayerEntity().getUuid()); - for (PlayerListEntry entry : packet.getEntries()) { - GameProfile profile = entry.getProfile(); - PlayerEntity playerEntity; - boolean self = profile.getId().equals(session.getPlayerEntity().getUuid()); + GameProfile.Property textures = profile.getProperty("textures"); + String texturesProperty = textures == null ? null : textures.getValue(); - if (self) { - // Entity is ourself - playerEntity = session.getPlayerEntity(); - } else { - playerEntity = session.getEntityCache().getPlayerEntity(profile.getId()); - } + if (self) { + // Entity is ourself + playerEntity = session.getPlayerEntity(); + } else { + // It's a new player + playerEntity = new PlayerEntity( + session, + -1, + session.getEntityCache().getNextEntityId().incrementAndGet(), + profile.getId(), + Vector3f.ZERO, + Vector3f.ZERO, + 0, 0, 0, + profile.getName(), + texturesProperty + ); - GameProfile.Property textures = profile.getProperty("textures"); - String texturesProperty = textures == null ? null : textures.getValue(); - - if (playerEntity == null) { - // It's a new player - playerEntity = new PlayerEntity( - session, - -1, - session.getEntityCache().getNextEntityId().incrementAndGet(), - profile.getId(), - Vector3f.ZERO, - Vector3f.ZERO, - 0, 0, 0, - profile.getName(), - texturesProperty - ); - - session.getEntityCache().addPlayerEntity(playerEntity); - } else { + session.getEntityCache().addPlayerEntity(playerEntity); + } playerEntity.setUsername(profile.getName()); playerEntity.setTexturesProperty(texturesProperty); - } - playerEntity.setPlayerList(true); - - // We'll send our own PlayerListEntry in requestAndHandleSkinAndCape - // But we need to send other player's entries so they show up in the player list - // without processing their skin information - that'll be processed when they spawn in - if (self) { - SkinManager.requestAndHandleSkinAndCape(playerEntity, session, skinAndCape -> - GeyserImpl.getInstance().getLogger().debug("Loaded Local Bedrock Java Skin Data for " + session.getClientData().getUsername())); - } else { - playerEntity.setValid(true); - PlayerListPacket.Entry playerListEntry = SkinManager.buildCachedEntry(session, playerEntity); - - translate.getEntries().add(playerListEntry); + if (self) { + SkinManager.requestAndHandleSkinAndCape(playerEntity, session, skinAndCape -> + GeyserImpl.getInstance().getLogger().debug("Loaded Local Bedrock Java Skin Data for " + session.getClientData().getUsername())); + } else { + playerEntity.setValid(true); + } } } - if (!translate.getEntries().isEmpty()) { - session.sendUpstreamPacket(translate); + if (actions.contains(PlayerListEntryAction.UPDATE_LISTED)) { + List toAdd = new ArrayList<>(); + List toRemove = new ArrayList<>(); + + for (PlayerListEntry entry : packet.getEntries()) { + PlayerEntity entity = session.getEntityCache().getPlayerEntity(entry.getProfileId()); + if (entity == null) { + session.getGeyser().getLogger().debug("Ignoring player info update for " + entry.getProfileId()); + continue; + } + + if (entry.isListed()) { + PlayerListPacket.Entry playerListEntry = SkinManager.buildCachedEntry(session, entity); + toAdd.add(playerListEntry); + } else { + toRemove.add(new PlayerListPacket.Entry(entity.getTabListUuid())); + } + } + + if (!toAdd.isEmpty()) { + PlayerListPacket tabListPacket = new PlayerListPacket(); + tabListPacket.setAction(PlayerListPacket.Action.ADD); + tabListPacket.getEntries().addAll(toAdd); + session.sendUpstreamPacket(tabListPacket); + } + if (!toRemove.isEmpty()) { + PlayerListPacket tabListPacket = new PlayerListPacket(); + tabListPacket.setAction(PlayerListPacket.Action.REMOVE); + tabListPacket.getEntries().addAll(toRemove); + session.sendUpstreamPacket(tabListPacket); + } } } } diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerPositionTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerPositionTranslator.java index a55e49f70..5f182805e 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerPositionTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerPositionTranslator.java @@ -84,7 +84,7 @@ public class JavaPlayerPositionTranslator extends PacketTranslator 47 && !session.isEmulatePost1_13Logic()) { + if (session.getServerRenderDistance() > 32 && !session.isEmulatePost1_13Logic()) { // See DimensionUtils for an explanation ChunkRadiusUpdatedPacket chunkRadiusUpdatedPacket = new ChunkRadiusUpdatedPacket(); chunkRadiusUpdatedPacket.setRadius(session.getServerRenderDistance()); @@ -113,6 +113,13 @@ public class JavaPlayerPositionTranslator extends PacketTranslator 1); entity.updateBedrockMetadata(); + + if (session.getMountVehicleScheduledFuture() != null) { + // Cancel this task as it is now unnecessary. + // Note that this isn't present in JavaSetPassengersTranslator as that code is not called for players + // as of Java 1.19.3, but the scheduled future checks for the vehicle being null anyway. + session.getMountVehicleScheduledFuture().cancel(false); + } } // If coordinates are relative, then add to the existing coordinate diff --git a/core/src/main/java/org/geysermc/geyser/util/AssetUtils.java b/core/src/main/java/org/geysermc/geyser/util/AssetUtils.java new file mode 100644 index 000000000..299e63e0e --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/util/AssetUtils.java @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.util; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.network.GameProtocol; +import org.geysermc.geyser.text.GeyserLocale; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.zip.ZipFile; + +/** + * Implementation note: try to design processes to fail softly if the client jar can't be downloaded, + * either if Mojang is down or internet access to Mojang is spotty. + */ +public final class AssetUtils { + private static final String CLIENT_JAR_HASH_FILE = "client_jar.hash"; + + private static final Map ASSET_MAP = new HashMap<>(); + + private static VersionDownload CLIENT_JAR_INFO; + + private static final Queue CLIENT_JAR_TASKS = new ArrayDeque<>(); + /** + * Download the client jar even if the hash is correct + */ + private static boolean FORCE_DOWNLOAD_JAR = false; + + public static Asset getAsset(String name) { + return ASSET_MAP.get(name); + } + + public static boolean isAssetKnown(String name) { + return ASSET_MAP.containsKey(name); + } + + /** + * Add task to be ran after the client jar is downloaded or found to be cached. + * + * @param required if set to true, the client jar will always be downloaded, even if a pre-existing hash is matched. + * This means an asset or texture is missing. + */ + public static void addTask(boolean required, ClientJarTask task) { + CLIENT_JAR_TASKS.add(task); + FORCE_DOWNLOAD_JAR |= required; + } + + /** + * Fetch the latest versions asset cache from Mojang so we can grab the locale files later + */ + public static CompletableFuture generateAssetCache() { + return CompletableFuture.supplyAsync(() -> { + try { + // Get the version manifest from Mojang + VersionManifest versionManifest = GeyserImpl.JSON_MAPPER.readValue( + WebUtils.getBody("https://launchermeta.mojang.com/mc/game/version_manifest.json"), VersionManifest.class); + + // Get the url for the latest version of the games manifest + String latestInfoURL = ""; + for (Version version : versionManifest.getVersions()) { + if (version.getId().equals(GameProtocol.getJavaCodec().getMinecraftVersion())) { + latestInfoURL = version.getUrl(); + break; + } + } + + // Make sure we definitely got a version + if (latestInfoURL.isEmpty()) { + throw new Exception(GeyserLocale.getLocaleStringLog("geyser.locale.fail.latest_version")); + } + + // Get the individual version manifest + VersionInfo versionInfo = GeyserImpl.JSON_MAPPER.readValue(WebUtils.getBody(latestInfoURL), VersionInfo.class); + + // Get the client jar for use when downloading the en_us locale + GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(versionInfo.getDownloads())); + CLIENT_JAR_INFO = versionInfo.getDownloads().get("client"); + GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(CLIENT_JAR_INFO)); + + // Get the assets list + JsonNode assets = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(versionInfo.getAssetIndex().getUrl())).get("objects"); + + // Put each asset into an array for use later + Iterator> assetIterator = assets.fields(); + while (assetIterator.hasNext()) { + Map.Entry entry = assetIterator.next(); + if (!entry.getKey().startsWith("minecraft/lang/")) { + // No need to cache non-language assets as we don't use them + continue; + } + + Asset asset = GeyserImpl.JSON_MAPPER.treeToValue(entry.getValue(), Asset.class); + ASSET_MAP.put(entry.getKey(), asset); + } + + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.asset_cache", (!e.getMessage().isEmpty() ? e.getMessage() : e.getStackTrace()))); + } + return null; + }); + } + + public static void downloadAndRunClientJarTasks() { + if (CLIENT_JAR_INFO == null) { + // Likely failed to download + GeyserImpl.getInstance().getLogger().debug("Skipping en_US hash check as client jar is null."); + return; + } + + if (!FORCE_DOWNLOAD_JAR) { // Don't bother checking the hash if we need to download new files anyway. + String curHash = null; + try { + File hashFile = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve(CLIENT_JAR_HASH_FILE).toFile(); + if (hashFile.exists()) { + try (BufferedReader br = new BufferedReader(new FileReader(hashFile))) { + curHash = br.readLine().trim(); + } + } + } catch (IOException ignored) { } + String targetHash = CLIENT_JAR_INFO.getSha1(); + if (targetHash.equals(curHash)) { + // Just run all tasks - no new download required + ClientJarTask task; + while ((task = CLIENT_JAR_TASKS.poll()) != null) { + task.whenDone.run(); + } + return; + } + } + + try { + // Let the user know we are downloading the JAR + GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us")); + GeyserImpl.getInstance().getLogger().debug("Download URL: " + CLIENT_JAR_INFO.getUrl()); + + Path tmpFilePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("tmp_locale.jar"); + WebUtils.downloadFile(CLIENT_JAR_INFO.getUrl(), tmpFilePath.toString()); + + // Load in the JAR as a zip and extract the files + try (ZipFile localeJar = new ZipFile(tmpFilePath.toString())) { + ClientJarTask task; + while ((task = CLIENT_JAR_TASKS.poll()) != null) { + try (InputStream fileStream = localeJar.getInputStream(localeJar.getEntry(task.asset))) { + task.ifNewDownload.accept(fileStream); + task.whenDone.run(); + } + } + } + + // Store the latest jar hash + Path cache = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache"); + Files.createDirectories(cache); + FileUtils.writeFile(cache.resolve(CLIENT_JAR_HASH_FILE).toString(), CLIENT_JAR_INFO.getSha1().toCharArray()); + + // Delete the nolonger needed client/server jar + Files.delete(tmpFilePath); + + GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us.done")); + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.en_us"), e); + } + } + + public static void saveFile(File location, InputStream fileStream) throws IOException { + try (FileOutputStream outStream = new FileOutputStream(location)) { + + // Write the file to the locale dir + byte[] buf = new byte[fileStream.available()]; + int length; + while ((length = fileStream.read(buf)) != -1) { + outStream.write(buf, 0, length); + } + + // Flush all changes to disk and cleanup + outStream.flush(); + } + } + + /** + * A process that requires we download the client jar. + * Designed to accommodate Geyser updates that require more assets from the jar. + */ + public record ClientJarTask(String asset, InputStreamConsumer ifNewDownload, Runnable whenDone) { + } + + @FunctionalInterface + public interface InputStreamConsumer { + void accept(InputStream stream) throws IOException; + } + + /* Classes that map to JSON files served by Mojang */ + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class VersionManifest { + @JsonProperty("latest") + private LatestVersion latestVersion; + + @JsonProperty("versions") + private List versions; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class LatestVersion { + @JsonProperty("release") + private String release; + + @JsonProperty("snapshot") + private String snapshot; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class Version { + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("url") + private String url; + + @JsonProperty("time") + private String time; + + @JsonProperty("releaseTime") + private String releaseTime; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class VersionInfo { + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("time") + private String time; + + @JsonProperty("releaseTime") + private String releaseTime; + + @JsonProperty("assetIndex") + private AssetIndex assetIndex; + + @JsonProperty("downloads") + private Map downloads; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class VersionDownload { + @JsonProperty("sha1") + private String sha1; + + @JsonProperty("size") + private int size; + + @JsonProperty("url") + private String url; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class AssetIndex { + @JsonProperty("id") + private String id; + + @JsonProperty("sha1") + private String sha1; + + @JsonProperty("size") + private int size; + + @JsonProperty("totalSize") + private int totalSize; + + @JsonProperty("url") + private String url; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + public static class Asset { + @JsonProperty("hash") + private String hash; + + @JsonProperty("size") + private int size; + } + + private AssetUtils() { + } +} diff --git a/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java b/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java index 5963da703..6b7296c12 100644 --- a/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java +++ b/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java @@ -75,18 +75,17 @@ public class DimensionUtils { session.getPistonCache().clear(); session.getSkullCache().clear(); - if (session.getServerRenderDistance() > 47 && !session.isEmulatePost1_13Logic()) { + if (session.getServerRenderDistance() > 32 && !session.isEmulatePost1_13Logic()) { // The server-sided view distance wasn't a thing until Minecraft Java 1.14 // So ViaVersion compensates by sending a "view distance" of 64 // That's fine, except when the actual view distance sent from the server is five chunks // The client locks up when switching dimensions, expecting more chunks than it's getting // To solve this, we cap at 32 unless we know that the render distance actually exceeds 32 - // 47 is the Bedrock equivalent of 32 // Also, as of 1.19: PS4 crashes with a ChunkRadiusUpdatedPacket too large session.getGeyser().getLogger().debug("Applying dimension switching workaround for Bedrock render distance of " + session.getServerRenderDistance()); ChunkRadiusUpdatedPacket chunkRadiusUpdatedPacket = new ChunkRadiusUpdatedPacket(); - chunkRadiusUpdatedPacket.setRadius(47); + chunkRadiusUpdatedPacket.setRadius(32); session.sendUpstreamPacket(chunkRadiusUpdatedPacket); // Will be re-adjusted on spawn } diff --git a/core/src/main/resources/bedrock/skin/skin_alex.png b/core/src/main/resources/bedrock/skin/skin_alex.png deleted file mode 100644 index ffd8e0719..000000000 Binary files a/core/src/main/resources/bedrock/skin/skin_alex.png and /dev/null differ diff --git a/core/src/main/resources/bedrock/skin/skin_steve.png b/core/src/main/resources/bedrock/skin/skin_steve.png deleted file mode 100644 index 056f108f2..000000000 Binary files a/core/src/main/resources/bedrock/skin/skin_steve.png and /dev/null differ diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 502441560..f237e32ab 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -111,7 +111,7 @@ debug-mode: false # Allow third party capes to be visible. Currently allowing: # OptiFine capes, LabyMod capes, 5Zig capes and MinecraftCapes -allow-third-party-capes: true +allow-third-party-capes: false # Allow third party deadmau5 ears to be visible. Currently allowing: # MinecraftCapes @@ -219,4 +219,9 @@ mtu: 1400 # If disabled on plugin versions, expect performance decrease and latency increase use-direct-connection: true +# Whether Geyser should attempt to disable compression for Bedrock players. This should be a benefit as there is no need to compress data +# when Java packets aren't being handled over the network. +# This requires use-direct-connection to be true. +disable-compression: true + config-version: 4