Custom skull block support (#683)

Custom skulls are now implemented within the world when placed as a block. This is achieved by placing a fake player entity in the same spot.

Co-authored-by: DoctorMacc <toy.fighter1@gmail.com>
Co-authored-by: bundabrg <brendan@grieve.com.au>
Co-authored-by: bundabrg <bundabrg@grieve.com.au>
Co-authored-by: Camotoy <20743703+DoctorMacc@users.noreply.github.com>
This commit is contained in:
OnlyBMan 2020-12-04 16:55:24 -05:00 committed by GitHub
parent 2067143b57
commit 2c0f3ec84d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 663 additions and 134 deletions

View file

@ -55,6 +55,7 @@ import org.geysermc.connector.network.translators.world.WorldManager;
import org.geysermc.connector.network.translators.world.block.BlockTranslator;
import org.geysermc.connector.network.translators.world.block.entity.BlockEntityTranslator;
import org.geysermc.connector.network.translators.collision.CollisionTranslator;
import org.geysermc.connector.network.translators.world.block.entity.SkullBlockEntityTranslator;
import org.geysermc.connector.utils.DimensionUtils;
import org.geysermc.connector.utils.LanguageUtils;
import org.geysermc.connector.utils.LocaleUtils;
@ -78,7 +79,11 @@ import java.util.concurrent.TimeUnit;
@Getter
public class GeyserConnector {
public static final ObjectMapper JSON_MAPPER = new ObjectMapper().enable(JsonParser.Feature.IGNORE_UNDEFINED).enable(JsonParser.Feature.ALLOW_COMMENTS).disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
public static final ObjectMapper JSON_MAPPER = new ObjectMapper()
.enable(JsonParser.Feature.IGNORE_UNDEFINED)
.enable(JsonParser.Feature.ALLOW_COMMENTS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES);
public static final String NAME = "Geyser";
public static final String VERSION = "DEV"; // A fallback for running in IDEs
@ -182,6 +187,7 @@ public class GeyserConnector {
authType = AuthType.getByName(config.getRemote().getAuthType());
DimensionUtils.changeBedrockNetherId(config.isAboveBedrockNetherBuilding()); // Apply End dimension ID workaround to Nether
SkullBlockEntityTranslator.ALLOW_CUSTOM_SKULLS = config.isAllowCustomSkulls();
// https://github.com/GeyserMC/Geyser/issues/957
RakNetConstants.MAXIMUM_MTU_SIZE = (short) config.getMtu();

View file

@ -85,6 +85,8 @@ public interface GeyserConfiguration {
int getCacheImages();
boolean isAllowCustomSkulls();
IMetricsInfo getMetrics();
interface IBedrockConfiguration {

View file

@ -101,6 +101,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
@JsonProperty("cache-images")
private int cacheImages = 0;
@JsonProperty("allow-custom-skulls")
private boolean allowCustomSkulls = true;
@JsonProperty("above-bedrock-nether-building")
private boolean aboveBedrockNetherBuilding = false;

View file

@ -65,7 +65,6 @@ public class PlayerEntity extends LivingEntity {
private GameProfile profile;
private UUID uuid;
private String username;
private long lastSkinUpdate = -1;
private boolean playerList = true; // Player is in the player list
/**

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2019-2020 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.connector.entity.player;
import com.github.steveice10.mc.auth.data.GameProfile;
import com.nukkitx.math.vector.Vector3f;
import com.nukkitx.math.vector.Vector3i;
import com.nukkitx.protocol.bedrock.data.entity.EntityData;
import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
import lombok.Getter;
import lombok.Setter;
import org.geysermc.connector.network.session.GeyserSession;
/**
* A wrapper to handle skulls more effectively - skulls have to be treated as entities since there are no
* custom player skulls in Bedrock.
*/
public class SkullPlayerEntity extends PlayerEntity {
/**
* Stores the block state that the skull is associated with. Used to determine if the block in the skull's position
* has changed
*/
@Getter
@Setter
private int blockState;
public SkullPlayerEntity(GameProfile gameProfile, long geyserId, Vector3f position, Vector3f rotation) {
super(gameProfile, 0, geyserId, position, Vector3f.ZERO, rotation);
setPlayerList(false);
//Set bounding box to almost nothing so the skull is able to be broken and not cause entity to cast a shadow
metadata.clear();
metadata.put(EntityData.SCALE, 1.08f);
metadata.put(EntityData.BOUNDING_BOX_HEIGHT, 0.001f);
metadata.put(EntityData.BOUNDING_BOX_WIDTH, 0.001f);
metadata.getOrCreateFlags().setFlag(EntityFlag.CAN_SHOW_NAME, false);
metadata.getFlags().setFlag(EntityFlag.INVISIBLE, true); // Until the skin is loaded
}
public void despawnEntity(GeyserSession session, Vector3i position) {
this.despawnEntity(session);
session.getSkullCache().remove(position, this);
}
}

View file

@ -67,6 +67,7 @@ import org.geysermc.connector.GeyserConnector;
import org.geysermc.connector.command.CommandSender;
import org.geysermc.connector.common.AuthType;
import org.geysermc.connector.entity.Entity;
import org.geysermc.connector.entity.player.SkullPlayerEntity;
import org.geysermc.connector.entity.player.SessionPlayerEntity;
import org.geysermc.connector.inventory.PlayerInventory;
import org.geysermc.connector.network.translators.chat.MessageTranslator;
@ -80,6 +81,7 @@ import org.geysermc.connector.network.translators.PacketTranslatorRegistry;
import org.geysermc.connector.network.translators.collision.CollisionManager;
import org.geysermc.connector.network.translators.inventory.EnchantmentInventoryTranslator;
import org.geysermc.connector.network.translators.item.ItemRegistry;
import org.geysermc.connector.skin.SkinManager;
import org.geysermc.connector.utils.*;
import org.geysermc.floodgate.util.BedrockData;
import org.geysermc.floodgate.util.EncryptionUtil;
@ -90,6 +92,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicInteger;
@ -121,7 +124,7 @@ public class GeyserSession implements CommandSender {
*/
private final CollisionManager collisionManager;
@Getter
private final Map<Vector3i, SkullPlayerEntity> skullCache = new ConcurrentHashMap<>();
private final Long2ObjectMap<ClientboundMapItemDataPacket> storedMaps = Long2ObjectMaps.synchronize(new Long2ObjectOpenHashMap<>());
/**
@ -531,7 +534,7 @@ public class GeyserSession implements CommandSender {
// Check if they are not using a linked account
if (connector.getAuthType() == AuthType.OFFLINE || playerEntity.getUuid().getMostSignificantBits() == 0) {
SkinUtils.handleBedrockSkin(playerEntity, clientData);
SkinManager.handleBedrockSkin(playerEntity, clientData);
}
}

View file

@ -25,13 +25,14 @@
package org.geysermc.connector.network.translators.bedrock;
import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
import com.nukkitx.protocol.bedrock.packet.SetLocalPlayerAsInitializedPacket;
import org.geysermc.connector.entity.player.PlayerEntity;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.PacketTranslator;
import org.geysermc.connector.network.translators.Translator;
import org.geysermc.connector.utils.SkinUtils;
import com.nukkitx.protocol.bedrock.packet.SetLocalPlayerAsInitializedPacket;
import org.geysermc.connector.skin.SkinManager;
import org.geysermc.connector.skin.SkullSkinManager;
@Translator(packet = SetLocalPlayerAsInitializedPacket.class)
public class BedrockSetLocalPlayerAsInitializedTranslator extends PacketTranslator<SetLocalPlayerAsInitializedPacket> {
@ -44,10 +45,20 @@ public class BedrockSetLocalPlayerAsInitializedTranslator extends PacketTranslat
for (PlayerEntity entity : session.getEntityCache().getEntitiesByType(PlayerEntity.class)) {
if (!entity.isValid()) {
SkinUtils.requestAndHandleSkinAndCape(entity, session, null);
SkinManager.requestAndHandleSkinAndCape(entity, session, null);
entity.sendPlayer(session);
}
}
// Send Skulls
for (PlayerEntity entity : session.getSkullCache().values()) {
entity.spawnEntity(session);
SkullSkinManager.requestAndHandleSkin(entity, session, (skin) -> {
entity.getMetadata().getFlags().setFlag(EntityFlag.INVISIBLE, false);
entity.updateBedrockMetadata(session);
});
}
}
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2019-2020 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.connector.network.translators.item.translators.nbt;
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
import com.github.steveice10.opennbt.tag.builtin.StringTag;
import com.github.steveice10.opennbt.tag.builtin.Tag;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.ItemRemapper;
import org.geysermc.connector.network.translators.item.ItemEntry;
import org.geysermc.connector.network.translators.item.NbtItemStackTranslator;
import org.geysermc.connector.utils.LocaleUtils;
@ItemRemapper
public class PlayerHeadTranslator extends NbtItemStackTranslator {
@Override
public void translateToBedrock(GeyserSession session, CompoundTag itemTag, ItemEntry itemEntry) {
if (!itemTag.contains("display") || !((CompoundTag) itemTag.get("display")).contains("name")) {
if (itemTag.contains("SkullOwner")) {
StringTag name;
Tag skullOwner = itemTag.get("SkullOwner");
if (skullOwner instanceof StringTag) {
name = (StringTag) skullOwner;
} else {
StringTag skullName;
if (skullOwner instanceof CompoundTag && (skullName = ((CompoundTag) skullOwner).get("Name")) != null) {
name = skullName;
} else {
session.getConnector().getLogger().debug("Not sure how to handle skull head item display. " + itemTag);
return;
}
}
// Add correct name of player skull
// TODO: It's always yellow, even with a custom name. Handle?
String displayName = "\u00a7r\u00a7e" + LocaleUtils.getLocaleString("block.minecraft.player_head.named", session.getLocale()).replace("%s", name.getValue());
if (!itemTag.contains("display")) {
itemTag.put(new CompoundTag("display"));
}
((CompoundTag) itemTag.get("display")).put(new StringTag("Name", displayName));
}
}
}
@Override
public boolean acceptItem(ItemEntry itemEntry) {
return itemEntry.getJavaIdentifier().equals("minecraft:player_head");
}
}

View file

@ -30,7 +30,7 @@ import org.geysermc.connector.entity.player.PlayerEntity;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.PacketTranslator;
import org.geysermc.connector.network.translators.Translator;
import org.geysermc.connector.utils.SkinUtils;
import org.geysermc.connector.skin.SkinManager;
import com.github.steveice10.mc.protocol.data.game.PlayerListEntry;
import com.github.steveice10.mc.protocol.data.game.PlayerListEntryAction;
@ -57,7 +57,8 @@ public class JavaPlayerListEntryTranslator extends PacketTranslator<ServerPlayer
if (self) {
// Entity is ourself
playerEntity = session.getPlayerEntity();
SkinUtils.requestAndHandleSkinAndCape(playerEntity, session, skinAndCape ->
//TODO: playerEntity.setProfile(entry.getProfile()); seems to help with online mode skins but needs more testing to ensure Floodgate skins aren't overwritten
SkinManager.requestAndHandleSkinAndCape(playerEntity, session, skinAndCape ->
GeyserConnector.getInstance().getLogger().debug("Loaded Local Bedrock Java Skin Data"));
} else {
playerEntity = session.getEntityCache().getPlayerEntity(entry.getProfile().getId());
@ -81,7 +82,7 @@ public class JavaPlayerListEntryTranslator extends PacketTranslator<ServerPlayer
playerEntity.setPlayerList(true);
playerEntity.setValid(true);
PlayerListPacket.Entry playerListEntry = SkinUtils.buildCachedEntry(session, entry.getProfile(), playerEntity.getGeyserId());
PlayerListPacket.Entry playerListEntry = SkinManager.buildCachedEntry(session, playerEntity);
translate.getEntries().add(playerListEntry);
break;

View file

@ -33,7 +33,7 @@ import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.PacketTranslator;
import org.geysermc.connector.network.translators.Translator;
import org.geysermc.connector.utils.LanguageUtils;
import org.geysermc.connector.utils.SkinUtils;
import org.geysermc.connector.skin.SkinManager;
@Translator(packet = ServerSpawnPlayerPacket.class)
public class JavaSpawnPlayerTranslator extends PacketTranslator<ServerSpawnPlayerPacket> {
@ -62,7 +62,7 @@ public class JavaSpawnPlayerTranslator extends PacketTranslator<ServerSpawnPlaye
if (session.getUpstream().isInitialized()) {
entity.sendPlayer(session);
SkinUtils.requestAndHandleSkinAndCape(entity, session, null);
SkinManager.requestAndHandleSkinAndCape(entity, session, null);
}
}
}

View file

@ -25,11 +25,13 @@
package org.geysermc.connector.network.translators.java.world;
import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerUnloadChunkPacket;
import com.nukkitx.math.vector.Vector3i;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.PacketTranslator;
import org.geysermc.connector.network.translators.Translator;
import com.github.steveice10.mc.protocol.packet.ingame.server.world.ServerUnloadChunkPacket;
import java.util.Iterator;
@Translator(packet = ServerUnloadChunkPacket.class)
public class JavaUnloadChunkTranslator extends PacketTranslator<ServerUnloadChunkPacket> {
@ -37,5 +39,15 @@ public class JavaUnloadChunkTranslator extends PacketTranslator<ServerUnloadChun
@Override
public void translate(ServerUnloadChunkPacket packet, GeyserSession session) {
session.getChunkCache().removeChunk(packet.getX(), packet.getZ());
//Checks if a skull is in an unloaded chunk then removes it
Iterator<Vector3i> iterator = session.getSkullCache().keySet().iterator();
while (iterator.hasNext()) {
Vector3i position = iterator.next();
if (Math.floor(position.getX() / 16) == packet.getX() && Math.floor(position.getZ() / 16) == packet.getZ()) {
session.getSkullCache().get(position).despawnEntity(session);
iterator.remove();
}
}
}
}

View file

@ -36,6 +36,7 @@ import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.PacketTranslator;
import org.geysermc.connector.network.translators.Translator;
import org.geysermc.connector.network.translators.world.block.entity.BlockEntityTranslator;
import org.geysermc.connector.network.translators.world.block.entity.SkullBlockEntityTranslator;
import org.geysermc.connector.utils.BlockEntityUtils;
import org.geysermc.connector.utils.ChunkUtils;
@ -64,6 +65,10 @@ public class JavaUpdateTileEntityTranslator extends PacketTranslator<ServerUpdat
// Cache chunks is not enabled; use block entity cache
ChunkUtils.CACHED_BLOCK_ENTITIES.removeInt(packet.getPosition());
BlockEntityUtils.updateBlockEntity(session, translator.getBlockEntityTag(id, packet.getNbt(), blockState), packet.getPosition());
// Check for custom skulls.
if (SkullBlockEntityTranslator.ALLOW_CUSTOM_SKULLS && packet.getNbt().contains("SkullOwner")) {
SkullBlockEntityTranslator.spawnPlayer(session, packet.getNbt(), blockState);
}
// If block entity is command block, OP permission level is appropriate, player is in creative mode and the NBT is not empty
if (packet.getType() == UpdatedTileType.COMMAND_BLOCK && session.getOpPermissionLevel() >= 2 &&

View file

@ -47,6 +47,7 @@ public class BlockStateValues {
private static final Int2BooleanMap PISTON_VALUES = new Int2BooleanOpenHashMap();
private static final Int2ByteMap SKULL_VARIANTS = new Int2ByteOpenHashMap();
private static final Int2ByteMap SKULL_ROTATIONS = new Int2ByteOpenHashMap();
private static final Int2IntMap SKULL_WALL_DIRECTIONS = new Int2IntOpenHashMap();
private static final Int2ByteMap SHULKERBOX_DIRECTIONS = new Int2ByteOpenHashMap();
/**
@ -110,6 +111,26 @@ public class BlockStateValues {
SKULL_ROTATIONS.put(javaBlockState, (byte) skullRotation.intValue());
}
if (entry.getKey().contains("wall_skull") || entry.getKey().contains("wall_head")) {
String direction = entry.getKey().substring(entry.getKey().lastIndexOf("facing=") + 7);
int rotation = 0;
switch (direction.substring(0, direction.length() - 1)) {
case "north":
rotation = 180;
break;
case "south":
rotation = 0;
break;
case "west":
rotation = 90;
break;
case "east":
rotation = 270;
break;
}
SKULL_WALL_DIRECTIONS.put(javaBlockState, rotation);
}
JsonNode shulkerDirection = entry.getValue().get("shulker_direction");
if (shulkerDirection != null) {
BlockStateValues.SHULKERBOX_DIRECTIONS.put(javaBlockState, (byte) shulkerDirection.intValue());
@ -222,6 +243,15 @@ public class BlockStateValues {
return SKULL_ROTATIONS.getOrDefault(state, (byte) -1);
}
/**
* Skull rotations are part of the namespaced ID in Java Edition, but part of the block entity tag in Bedrock.
* This gives a integer rotation that Bedrock can use.
*
* @return Skull wall rotation value with the blockstate
*/
public static Int2IntMap getSkullWallDirections() {
return SKULL_WALL_DIRECTIONS;
}
/**
* Shulker box directions are part of the namespaced ID in Java Edition, but part of the block entity tag in Bedrock.

View file

@ -25,12 +25,31 @@
package org.geysermc.connector.network.translators.world.block.entity;
import com.github.steveice10.mc.auth.data.GameProfile;
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.Vector3f;
import com.nukkitx.math.vector.Vector3i;
import com.nukkitx.nbt.NbtMapBuilder;
import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
import org.geysermc.connector.entity.player.SkullPlayerEntity;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.world.block.BlockStateValues;
import org.geysermc.connector.skin.SkinProvider;
import org.geysermc.connector.skin.SkullSkinManager;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@BlockEntity(name = "Skull", regex = "skull")
public class SkullBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState {
public static boolean ALLOW_CUSTOM_SKULLS;
@Override
public boolean isBlock(int blockState) {
return BlockStateValues.getSkullVariant(blockState) != -1;
@ -47,4 +66,98 @@ public class SkullBlockEntityTranslator extends BlockEntityTranslator implements
builder.put("Rotation", rotation);
builder.put("SkullType", skullVariant);
}
public static CompletableFuture<GameProfile> getProfile(CompoundTag tag) {
if (tag.contains("SkullOwner")) {
CompoundTag owner = tag.get("SkullOwner");
CompoundTag properties = owner.get("Properties");
if (properties == null) {
return SkinProvider.requestTexturesFromUsername(owner);
}
ListTag textures = properties.get("textures");
LinkedHashMap<?,?> tag1 = (LinkedHashMap<?,?>) textures.get(0).getValue();
StringTag texture = (StringTag) tag1.get("Value");
List<GameProfile.Property> profileProperties = new ArrayList<>();
GameProfile gameProfile = new GameProfile(UUID.randomUUID(), "");
profileProperties.add(new GameProfile.Property("textures", texture.getValue()));
gameProfile.setProperties(profileProperties);
return CompletableFuture.completedFuture(gameProfile);
}
return CompletableFuture.completedFuture(null);
}
public static void spawnPlayer(GeyserSession session, CompoundTag tag, int blockState) {
int posX = (int) tag.get("x").getValue();
int posY = (int) tag.get("y").getValue();
int posZ = (int) tag.get("z").getValue();
float x = posX + .5f;
float y = posY - .01f;
float z = posZ + .5f;
float rotation;
byte floorRotation = BlockStateValues.getSkullRotation(blockState);
if (floorRotation == -1) {
// Wall skull
y += 0.25f;
rotation = BlockStateValues.getSkullWallDirections().get(blockState);
switch ((int) rotation) {
case 180:
// North
z += 0.24f;
break;
case 0:
// South
z -= 0.24f;
break;
case 90:
// West
x += 0.24f;
break;
case 270:
// East
x -= 0.24f;
break;
}
} else {
rotation = (180f + (floorRotation * 22.5f)) % 360;
}
Vector3i blockPosition = Vector3i.from(posX, posY, posZ);
Vector3f entityPosition = Vector3f.from(x, y, z);
Vector3f entityRotation = Vector3f.from(rotation, 0, rotation);
long geyserId = session.getEntityCache().getNextEntityId().incrementAndGet();
getProfile(tag).whenComplete((gameProfile, throwable) -> {
if (gameProfile == null) {
session.getConnector().getLogger().debug("Custom skull with invalid SkullOwner tag: " + blockPosition.toString() + " " + tag.toString());
return;
}
SkullPlayerEntity existingSkull = session.getSkullCache().get(blockPosition);
if (existingSkull != null) {
// Ensure that two skulls can't spawn on the same point
existingSkull.despawnEntity(session, blockPosition);
}
SkullPlayerEntity player = new SkullPlayerEntity(gameProfile, geyserId, entityPosition, entityRotation);
player.setBlockState(blockState);
// Cache entity
session.getSkullCache().put(blockPosition, player);
// Only send to session if we are initialized, otherwise it will happen then.
if (session.getUpstream().isInitialized()) {
player.spawnEntity(session);
SkullSkinManager.requestAndHandleSkin(player, session, (skin -> session.getConnector().getGeneralThreadPool().schedule(() -> {
// Delay to minimize split-second "player" pop-in
player.getMetadata().getFlags().setFlag(EntityFlag.INVISIBLE, false);
player.updateBedrockMetadata(session);
}, 250, TimeUnit.MILLISECONDS)));
}
});
}
}

View file

@ -23,7 +23,7 @@
* @link https://github.com/GeyserMC/Geyser
*/
package org.geysermc.connector.utils;
package org.geysermc.connector.skin;
import lombok.Getter;

View file

@ -23,10 +23,9 @@
* @link https://github.com/GeyserMC/Geyser
*/
package org.geysermc.connector.utils;
package org.geysermc.connector.skin;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.steveice10.mc.auth.data.GameProfile;
import com.nukkitx.protocol.bedrock.data.skin.ImageData;
import com.nukkitx.protocol.bedrock.data.skin.SerializedSkin;
@ -38,6 +37,7 @@ import org.geysermc.connector.common.AuthType;
import org.geysermc.connector.entity.player.PlayerEntity;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.session.auth.BedrockClientData;
import org.geysermc.connector.utils.LanguageUtils;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@ -45,12 +45,11 @@ import java.util.Collections;
import java.util.UUID;
import java.util.function.Consumer;
public class SkinUtils {
public class SkinManager {
public static PlayerListPacket.Entry buildCachedEntry(GeyserSession session, GameProfile profile, long geyserId) {
GameProfileData data = GameProfileData.from(profile);
public static PlayerListPacket.Entry buildCachedEntry(GeyserSession session, PlayerEntity playerEntity) {
GameProfileData data = GameProfileData.from(playerEntity.getProfile());
SkinProvider.Cape cape = SkinProvider.getCachedCape(data.getCapeUrl());
SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.getLegacy(data.isAlex());
SkinProvider.Skin skin = SkinProvider.getCachedSkin(data.getSkinUrl());
@ -60,25 +59,24 @@ public class SkinUtils {
return buildEntryManually(
session,
profile.getId(),
profile.getName(),
geyserId,
playerEntity.getProfile().getId(),
playerEntity.getProfile().getName(),
playerEntity.getGeyserId(),
skin.getTextureUrl(),
skin.getSkinData(),
cape.getCapeId(),
cape.getCapeData(),
geometry.getGeometryName(),
geometry.getGeometryData()
geometry
);
}
public static PlayerListPacket.Entry buildEntryManually(GeyserSession session, UUID uuid, String username, long geyserId,
String skinId, byte[] skinData,
String capeId, byte[] capeData,
String geometryName, String geometryData) {
SkinProvider.SkinGeometry geometry) {
SerializedSkin serializedSkin = SerializedSkin.of(
skinId, geometryName, ImageData.of(skinData), Collections.emptyList(),
ImageData.of(capeData), geometryData, "", true, false, !capeId.equals(SkinProvider.EMPTY_CAPE.getCapeId()), capeId, skinId
skinId, geometry.getGeometryName(), ImageData.of(skinData), Collections.emptyList(),
ImageData.of(capeData), geometry.getGeometryData(), "", true, false, !capeId.equals(SkinProvider.EMPTY_CAPE.getCapeId()), capeId, skinId
);
// This attempts to find the xuid of the player so profile images show up for xbox accounts
@ -119,11 +117,11 @@ public class SkinUtils {
try {
SkinProvider.Skin skin = skinAndCape.getSkin();
SkinProvider.Cape cape = skinAndCape.getCape();
SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.getLegacy(data.isAlex());
if (cape.isFailed()) {
cape = SkinProvider.getOrDefault(SkinProvider.requestBedrockCape(
entity.getUuid(), false
), SkinProvider.EMPTY_CAPE, 3);
cape = SkinProvider.getOrDefault(SkinProvider.requestBedrockCape(entity.getUuid()),
SkinProvider.EMPTY_CAPE, 3);
}
if (cape.isFailed() && SkinProvider.ALLOW_THIRD_PARTY_CAPES) {
@ -133,9 +131,8 @@ public class SkinUtils {
), SkinProvider.EMPTY_CAPE, SkinProvider.CapeProvider.VALUES.length * 3);
}
SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.getLegacy(data.isAlex());
geometry = SkinProvider.getOrDefault(SkinProvider.requestBedrockGeometry(
geometry, entity.getUuid(), false
geometry, entity.getUuid()
), geometry, 3);
// Not a bedrock player check for ears
@ -165,8 +162,6 @@ public class SkinUtils {
}
}
entity.setLastSkinUpdate(skin.getRequestedOn());
if (session.getUpstream().isInitialized()) {
PlayerListPacket.Entry updatedEntry = buildEntryManually(
session,
@ -177,8 +172,7 @@ public class SkinUtils {
skin.getSkinData(),
cape.getCapeId(),
cape.getCapeData(),
geometry.getGeometryName(),
geometry.getGeometryData()
geometry
);
@ -252,7 +246,7 @@ public class SkinUtils {
GameProfile.Property skinProperty = profile.getProperty("textures");
// TODO: Remove try/catch here
JsonNode skinObject = new ObjectMapper().readTree(new String(Base64.getDecoder().decode(skinProperty.getValue()), StandardCharsets.UTF_8));
JsonNode skinObject = GeyserConnector.JSON_MAPPER.readTree(new String(Base64.getDecoder().decode(skinProperty.getValue()), StandardCharsets.UTF_8));
JsonNode textures = skinObject.get("textures");
JsonNode skinTexture = textures.get("SKIN");

View file

@ -23,10 +23,14 @@
* @link https://github.com/GeyserMC/Geyser
*/
package org.geysermc.connector.utils;
package org.geysermc.connector.skin;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.steveice10.mc.auth.data.GameProfile;
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 lombok.AllArgsConstructor;
@ -34,15 +38,20 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import org.geysermc.connector.GeyserConnector;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.utils.FileUtils;
import org.geysermc.connector.utils.WebUtils;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.*;
import java.util.concurrent.*;
@ -76,40 +85,20 @@ public class SkinProvider {
public static final boolean ALLOW_THIRD_PARTY_EARS = GeyserConnector.getInstance().getConfig().isAllowThirdPartyEars();
public static String EARS_GEOMETRY;
public static String EARS_GEOMETRY_SLIM;
public static SkinGeometry SKULL_GEOMETRY;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
/* Load in the normal ears geometry */
InputStream earsStream = FileUtils.getResource("bedrock/skin/geometry.humanoid.ears.json");
StringBuilder earsDataBuilder = new StringBuilder();
try (Reader reader = new BufferedReader(new InputStreamReader(earsStream, Charset.forName(StandardCharsets.UTF_8.name())))) {
int c = 0;
while ((c = reader.read()) != -1) {
earsDataBuilder.append((char) c);
}
} catch (IOException e) {
throw new AssertionError("Unable to load ears geometry", e);
}
EARS_GEOMETRY = earsDataBuilder.toString();
EARS_GEOMETRY = new String(FileUtils.readAllBytes(FileUtils.getResource("bedrock/skin/geometry.humanoid.ears.json")), StandardCharsets.UTF_8);
/* Load in the slim ears geometry */
earsStream = FileUtils.getResource("bedrock/skin/geometry.humanoid.earsSlim.json");
EARS_GEOMETRY_SLIM = new String(FileUtils.readAllBytes(FileUtils.getResource("bedrock/skin/geometry.humanoid.earsSlim.json")), StandardCharsets.UTF_8);
earsDataBuilder = new StringBuilder();
try (Reader reader = new BufferedReader(new InputStreamReader(earsStream, Charset.forName(StandardCharsets.UTF_8.name())))) {
int c = 0;
while ((c = reader.read()) != -1) {
earsDataBuilder.append((char) c);
}
} catch (IOException e) {
throw new AssertionError("Unable to load ears geometry", e);
}
EARS_GEOMETRY_SLIM = earsDataBuilder.toString();
/* Load in the custom skull geometry */
String skullData = new String(FileUtils.readAllBytes(FileUtils.getResource("bedrock/skin/geometry.humanoid.customskull.json")), StandardCharsets.UTF_8);
SKULL_GEOMETRY = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.customskull\"}}", skullData, false);
// Schedule Daily Image Expiry if we are caching them
if (GeyserConnector.getInstance().getConfig().getCacheImages() > 0) {
@ -205,7 +194,6 @@ public class SkinProvider {
if (capeUrl == null || capeUrl.isEmpty()) return CompletableFuture.completedFuture(EMPTY_CAPE);
if (requestedCapes.containsKey(capeUrl)) return requestedCapes.get(capeUrl); // already requested
boolean officialCape = provider == CapeProvider.MINECRAFT;
Cape cachedCape = cachedCapes.getIfPresent(capeUrl);
if (cachedCape != null) {
return CompletableFuture.completedFuture(cachedCape);
@ -280,7 +268,7 @@ public class SkinProvider {
return CompletableFuture.completedFuture(officialSkin);
}
public static CompletableFuture<Cape> requestBedrockCape(UUID playerID, boolean newThread) {
public static CompletableFuture<Cape> requestBedrockCape(UUID playerID) {
Cape bedrockCape = cachedCapes.getIfPresent(playerID.toString() + ".Bedrock");
if (bedrockCape == null) {
bedrockCape = EMPTY_CAPE;
@ -288,7 +276,7 @@ public class SkinProvider {
return CompletableFuture.completedFuture(bedrockCape);
}
public static CompletableFuture<SkinGeometry> requestBedrockGeometry(SkinGeometry currentGeometry, UUID playerID, boolean newThread) {
public static CompletableFuture<SkinGeometry> requestBedrockGeometry(SkinGeometry currentGeometry, UUID playerID) {
SkinGeometry bedrockGeometry = cachedGeometry.getOrDefault(playerID, currentGeometry);
return CompletableFuture.completedFuture(bedrockGeometry);
}
@ -444,6 +432,60 @@ public class SkinProvider {
return data;
}
/**
* If a skull has a username but no textures, request them.
* @param skullOwner the CompoundTag of the skull with no textures
* @return a completable GameProfile with textures included
*/
public static CompletableFuture<GameProfile> requestTexturesFromUsername(CompoundTag skullOwner) {
return CompletableFuture.supplyAsync(() -> {
Tag uuidTag = skullOwner.get("Id");
String uuidToString = "";
JsonNode node;
GameProfile gameProfile = new GameProfile(UUID.randomUUID(), "");
boolean retrieveUuidFromInternet = !(uuidTag instanceof IntArrayTag); // also covers null check
if (!retrieveUuidFromInternet) {
int[] uuidAsArray = ((IntArrayTag) uuidTag).getValue();
// thank u viaversion
UUID uuid = new UUID((long) uuidAsArray[0] << 32 | ((long) uuidAsArray[1] & 0xFFFFFFFFL),
(long) uuidAsArray[2] << 32 | ((long) uuidAsArray[3] & 0xFFFFFFFFL));
retrieveUuidFromInternet = uuid.version() != 4;
uuidToString = uuid.toString().replace("-", "");
}
try {
if (retrieveUuidFromInternet) {
// Offline skin, or no present UUID
node = WebUtils.getJson("https://api.mojang.com/users/profiles/minecraft/" + skullOwner.get("Name").getValue());
JsonNode id = node.get("id");
if (id == null) {
GeyserConnector.getInstance().getLogger().debug("No UUID found in Mojang response for " + skullOwner.get("Name").getValue());
return null;
}
uuidToString = id.asText();
}
// Get textures from UUID
node = WebUtils.getJson("https://sessionserver.mojang.com/session/minecraft/profile/" + uuidToString);
List<GameProfile.Property> profileProperties = new ArrayList<>();
JsonNode properties = node.get("properties");
if (properties == null) {
GeyserConnector.getInstance().getLogger().debug("No properties found in Mojang response for " + uuidToString);
return null;
}
profileProperties.add(new GameProfile.Property("textures", node.get("properties").get(0).get("value").asText()));
gameProfile.setProperties(profileProperties);
return gameProfile;
} catch (Exception e) {
if (GeyserConnector.getInstance().getConfig().isDebugMode()) {
e.printStackTrace();
}
return null;
}
}, EXECUTOR_SERVICE);
}
private static BufferedImage downloadImage(String imageUrl, CapeProvider provider) throws IOException {
if (provider == CapeProvider.FIVEZIG)
return readFiveZigCape(imageUrl);

View file

@ -0,0 +1,100 @@
/*
* Copyright (c) 2019-2020 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.connector.skin;
import com.nukkitx.protocol.bedrock.data.skin.ImageData;
import com.nukkitx.protocol.bedrock.data.skin.SerializedSkin;
import com.nukkitx.protocol.bedrock.packet.PlayerListPacket;
import org.geysermc.connector.GeyserConnector;
import org.geysermc.connector.entity.player.PlayerEntity;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.utils.LanguageUtils;
import java.util.Collections;
import java.util.UUID;
import java.util.function.Consumer;
public class SkullSkinManager extends SkinManager {
public static PlayerListPacket.Entry buildSkullEntryManually(UUID uuid, String username, long geyserId,
String skinId, byte[] skinData) {
// Prevents https://cdn.discordapp.com/attachments/613194828359925800/779458146191147008/unknown.png
skinId = skinId + "_skull";
SerializedSkin serializedSkin = 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
);
PlayerListPacket.Entry entry = new PlayerListPacket.Entry(uuid);
entry.setName(username);
entry.setEntityId(geyserId);
entry.setSkin(serializedSkin);
entry.setXuid("");
entry.setPlatformChatId("");
entry.setTeacher(false);
entry.setTrustedSkin(true);
return entry;
}
public static void requestAndHandleSkin(PlayerEntity entity, GeyserSession session,
Consumer<SkinProvider.Skin> skinConsumer) {
GameProfileData data = GameProfileData.from(entity.getProfile());
SkinProvider.requestSkin(entity.getUuid(), data.getSkinUrl(), false)
.whenCompleteAsync((skin, throwable) -> {
try {
if (session.getUpstream().isInitialized()) {
PlayerListPacket.Entry updatedEntry = buildSkullEntryManually(
entity.getUuid(),
entity.getUsername(),
entity.getGeyserId(),
skin.getTextureUrl(),
skin.getSkinData()
);
PlayerListPacket playerAddPacket = new PlayerListPacket();
playerAddPacket.setAction(PlayerListPacket.Action.ADD);
playerAddPacket.getEntries().add(updatedEntry);
session.sendUpstreamPacket(playerAddPacket);
// It's a skull. We don't want them in the player list.
PlayerListPacket playerRemovePacket = new PlayerListPacket();
playerRemovePacket.setAction(PlayerListPacket.Action.REMOVE);
playerRemovePacket.getEntries().add(updatedEntry);
session.sendUpstreamPacket(playerRemovePacket);
}
} catch (Exception e) {
GeyserConnector.getInstance().getLogger().error(LanguageUtils.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), e);
}
if (skinConsumer != null) {
skinConsumer.accept(skin);
}
});
}
}

View file

@ -51,12 +51,14 @@ import lombok.experimental.UtilityClass;
import org.geysermc.connector.GeyserConnector;
import org.geysermc.connector.entity.Entity;
import org.geysermc.connector.entity.ItemFrameEntity;
import org.geysermc.connector.entity.player.SkullPlayerEntity;
import org.geysermc.connector.network.session.GeyserSession;
import org.geysermc.connector.network.translators.world.block.BlockStateValues;
import org.geysermc.connector.network.translators.world.block.BlockTranslator;
import org.geysermc.connector.network.translators.world.block.entity.BedrockOnlyBlockEntity;
import org.geysermc.connector.network.translators.world.block.entity.BlockEntityTranslator;
import org.geysermc.connector.network.translators.world.block.entity.RequiresBlockState;
import org.geysermc.connector.network.translators.world.block.entity.SkullBlockEntityTranslator;
import org.geysermc.connector.network.translators.world.chunk.BlockStorage;
import org.geysermc.connector.network.translators.world.chunk.ChunkSection;
import org.geysermc.connector.network.translators.world.chunk.bitarray.BitArray;
@ -68,9 +70,7 @@ import java.util.ArrayList;
import java.util.BitSet;
import java.util.List;
import static org.geysermc.connector.network.translators.world.block.BlockTranslator.JAVA_AIR_ID;
import static org.geysermc.connector.network.translators.world.block.BlockTranslator.BEDROCK_AIR_ID;
import static org.geysermc.connector.network.translators.world.block.BlockTranslator.BEDROCK_WATER_ID;
import static org.geysermc.connector.network.translators.world.block.BlockTranslator.*;
@UtilityClass
public class ChunkUtils {
@ -293,6 +293,11 @@ public class ChunkUtils {
}
bedrockBlockEntities[i] = blockEntityTranslator.getBlockEntityTag(tagName, tag, blockState);
// Check for custom skulls
if (SkullBlockEntityTranslator.ALLOW_CUSTOM_SKULLS && tag.contains("SkullOwner")) {
SkullBlockEntityTranslator.spawnPlayer(session, tag, blockState);
}
i++;
}
@ -357,6 +362,12 @@ public class ChunkUtils {
}
}
SkullPlayerEntity skull = session.getSkullCache().get(position);
if (skull != null && blockState != skull.getBlockState()) {
// Skull is gone
skull.despawnEntity(session, position);
}
int blockId = BlockTranslator.getBedrockBlockId(blockState);
UpdateBlockPacket updateBlockPacket = new UpdateBlockPacket();

View file

@ -66,6 +66,7 @@ public class DimensionUtils {
session.getEntityCache().removeAllEntities();
session.getItemFrameCache().clear();
session.getSkullCache().clear();
if (session.getPendingDimSwitches().getAndIncrement() > 0) {
ChunkUtils.sendEmptyChunks(session, player.getPosition().toInt(), 3, true);
}

View file

@ -215,14 +215,27 @@ public class FileUtils {
* @return The byte array of the file
*/
public static byte[] readAllBytes(File file) {
int size = (int) file.length();
byte[] bytes = new byte[size];
try {
BufferedInputStream buf = new BufferedInputStream(new FileInputStream(file));
return readAllBytes(new FileInputStream(file));
} catch (IOException e) {
throw new RuntimeException("Cannot read " + file);
}
}
/**
* @param stream the InputStream to read off of
* @return the byte array of an InputStream
*/
public static byte[] readAllBytes(InputStream stream) {
try {
int size = stream.available();
byte[] bytes = new byte[size];
BufferedInputStream buf = new BufferedInputStream(stream);
buf.read(bytes, 0, bytes.length);
buf.close();
} catch (IOException ignored) { }
return bytes;
} catch (IOException e) {
throw new RuntimeException("Error while trying to read input stream!");
}
}
}

View file

@ -25,6 +25,7 @@
package org.geysermc.connector.utils;
import com.fasterxml.jackson.databind.JsonNode;
import org.geysermc.connector.GeyserConnector;
import java.io.*;
@ -57,6 +58,18 @@ public class WebUtils {
}
}
/**
* Makes a web request to the given URL and returns the body as a {@link JsonNode}.
*
* @param reqURL URL to fetch
* @return the response as JSON
*/
public static JsonNode getJson(String reqURL) throws IOException {
HttpURLConnection con = (HttpURLConnection) new URL(reqURL).openConnection();
con.setRequestProperty("User-Agent", "Geyser-" + GeyserConnector.getInstance().getPlatformType().toString() + "/" + GeyserConnector.VERSION);
return GeyserConnector.JSON_MAPPER.readTree(con.getInputStream());
}
/**
* Downloads a file from the given URL and saves it to disk
*

View file

@ -0,0 +1,27 @@
{
"format_version": "1.10.0",
"geometry.humanoid.customskull": {
"texturewidth": 64,
"textureheight": 64,
"visible_bounds_width": 2,
"visible_bounds_height": 1,
"visible_bounds_offset": [0, 0, 0],
"bones": [
{
"name": "head",
"pivot": [0, 24, 0],
"cubes": [
{"origin": [-4, 0, -4], "size": [8, 8, 8], "uv": [0, 0]}
]
},
{
"name": "hat",
"parent": "head",
"pivot": [0, 24, 0],
"cubes": [
{"origin": [-4, 0, -4], "size": [8, 8, 8], "uv": [32, 0], "inflate": 0.5}
]
}
]
}
}

View file

@ -109,6 +109,9 @@ cache-chunks: true
# A value of 0 is disabled. (Default: 0)
cache-images: 0
# Allows custom skulls to be displayed. Keeping them enabled may cause a performance decrease on older/weaker devices.
allow-custom-skulls: true
# Bedrock prevents building and displaying blocks above Y127 in the Nether -
# enabling this config option works around that by changing the Nether dimension ID
# to the End ID. The main downside to this is that the sky will resemble that of