diff --git a/core/src/main/java/org/geysermc/geyser/inventory/item/StoredItemMappings.java b/core/src/main/java/org/geysermc/geyser/inventory/item/StoredItemMappings.java index f66c34e01..940436106 100644 --- a/core/src/main/java/org/geysermc/geyser/inventory/item/StoredItemMappings.java +++ b/core/src/main/java/org/geysermc/geyser/inventory/item/StoredItemMappings.java @@ -48,6 +48,7 @@ public class StoredItemMappings { private final ItemMapping lodestoneCompass; private final ItemMapping milkBucket; private final ItemMapping powderSnowBucket; + private final ItemMapping playerHead; private final ItemMapping egg; private final ItemMapping shield; private final ItemMapping wheat; @@ -64,6 +65,7 @@ public class StoredItemMappings { this.lodestoneCompass = load(itemMappings, "lodestone_compass"); this.milkBucket = load(itemMappings, "milk_bucket"); this.powderSnowBucket = load(itemMappings, "powder_snow_bucket"); + this.playerHead = load(itemMappings, "player_head"); this.egg = load(itemMappings, "egg"); this.shield = load(itemMappings, "shield"); this.wheat = load(itemMappings, "wheat"); 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 22bb367b5..24296874a 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -235,6 +235,12 @@ public class GeyserSession implements GeyserConnection, CommandSender { */ private final Set lecternCache; + /** + * A list of all players that have a player head on with a custom texture. + * Our workaround for these players is to give them a custom skin and geometry to emulate wearing a custom skull. + */ + private final Set playerWithCustomHeads = new ObjectOpenHashSet<>(); + @Setter private boolean droppingLecternBook; 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 eb1bc6e9a..5d99ba0e3 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 @@ -45,6 +45,8 @@ import java.util.concurrent.atomic.AtomicLong; * for that player (e.g. seeing vanished players from /vanish) */ public class EntityCache { + private final GeyserSession session; + @Getter private final Long2ObjectMap entities = new Long2ObjectOpenHashMap<>(); /** @@ -60,6 +62,8 @@ public class EntityCache { private final AtomicLong nextEntityId = new AtomicLong(2L); public EntityCache(GeyserSession session) { + this.session = session; + cachedPlayerEntityLinks.defaultReturnValue(-1L); } @@ -85,6 +89,10 @@ public class EntityCache { } public boolean removeEntity(Entity entity, boolean force) { + if (entity instanceof PlayerEntity player) { + session.getPlayerWithCustomHeads().remove(player.getUuid()); + } + if (entity != null && entity.isValid() && (force || entity.despawnEntity())) { long geyserId = entityIdTranslations.remove(entity.getEntityId()); entities.remove(geyserId); @@ -103,6 +111,7 @@ public class EntityCache { removeEntity(entity, false); } + session.getPlayerWithCustomHeads().clear(); // As a precaution cachedPlayerEntityLinks.clear(); } diff --git a/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java new file mode 100644 index 000000000..e54b25b2a --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java @@ -0,0 +1,219 @@ +/* + * 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.skin; + +import com.github.steveice10.mc.auth.data.GameProfile; +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; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.entity.type.LivingEntity; +import org.geysermc.geyser.entity.type.player.PlayerEntity; +import org.geysermc.geyser.session.GeyserSession; +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; + +/** + * Responsible for modifying a player's skin when wearing a player head + */ +public class FakeHeadProvider { + private static final LoadingCache MERGED_SKINS_LOADING_CACHE = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .maximumSize(10000) + .build(new CacheLoader<>() { + @Override + public SkinProvider.SkinData load(@Nonnull FakeHeadEntry fakeHeadEntry) throws Exception { + SkinProvider.SkinData skinData = SkinProvider.getOrDefault(SkinProvider.requestSkinData(fakeHeadEntry.getEntity()), null, 5); + + if (skinData == null) { + throw new Exception("Couldn't load player's original skin"); + } + + SkinProvider.Skin skin = skinData.skin(); + SkinProvider.Cape cape = skinData.cape(); + SkinProvider.SkinGeometry geometry = skinData.geometry().getGeometryName().equals("{\"geometry\" :{\"default\" :\"geometry.humanoid.customSlim\"}}") + ? SkinProvider.WEARING_CUSTOM_SKULL_SLIM : SkinProvider.WEARING_CUSTOM_SKULL; + + SkinProvider.Skin headSkin = SkinProvider.getOrDefault( + SkinProvider.requestSkin(fakeHeadEntry.getEntity().getUuid(), fakeHeadEntry.getFakeHeadSkinUrl(), false), SkinProvider.EMPTY_SKIN, 5); + BufferedImage originalSkinImage = SkinProvider.imageDataToBufferedImage(skin.getSkinData(), 64, skin.getSkinData().length / 4 / 64); + BufferedImage headSkinImage = SkinProvider.imageDataToBufferedImage(headSkin.getSkinData(), 64, headSkin.getSkinData().length / 4 / 64); + + Graphics2D graphics2D = originalSkinImage.createGraphics(); + graphics2D.setComposite(AlphaComposite.Clear); + graphics2D.fillRect(0, 0, 64, 16); + graphics2D.setComposite(AlphaComposite.SrcOver); + graphics2D.drawImage(headSkinImage, 0, 0, 64, 16, 0, 0, 64, 16, null); + graphics2D.dispose(); + + // Make the skin key a combination of the current skin data and the new skin data + // Don't tie it to a player - that player *can* change skins in-game + String skinKey = "customPlayerHead_" + fakeHeadEntry.getFakeHeadSkinUrl() + "_" + skin.getTextureUrl(); + byte[] targetSkinData = SkinProvider.bufferedImageToImageData(originalSkinImage); + SkinProvider.Skin mergedSkin = new SkinProvider.Skin(fakeHeadEntry.getEntity().getUuid(), skinKey, targetSkinData, System.currentTimeMillis(), false, false); + + // Avoiding memory leak + fakeHeadEntry.setEntity(null); + + return new SkinProvider.SkinData(mergedSkin, cape, geometry); + } + }); + + public static void setHead(GeyserSession session, PlayerEntity entity, CompoundTag profileTag) { + SkinManager.GameProfileData gameProfileData = SkinManager.GameProfileData.from(profileTag); + if (gameProfileData == null) { + return; + } + String fakeHeadSkinUrl = gameProfileData.skinUrl(); + + session.getPlayerWithCustomHeads().add(entity.getUuid()); + + GameProfile.Property texturesProperty = entity.getProfile().getProperty("textures"); + + SkinProvider.EXECUTOR_SERVICE.execute(() -> { + try { + SkinProvider.SkinData mergedSkinData = MERGED_SKINS_LOADING_CACHE.get(new FakeHeadEntry(texturesProperty, fakeHeadSkinUrl, entity)); + + if (session.getUpstream().isInitialized()) { + sendSkinPacket(session, entity, mergedSkinData); + } + } catch (ExecutionException e) { + GeyserImpl.getInstance().getLogger().error("Couldn't merge skin of " + entity.getUsername() + " with head skin url " + fakeHeadSkinUrl, e); + } + }); + } + + public static void restoreOriginalSkin(GeyserSession session, LivingEntity livingEntity) { + if (!(livingEntity instanceof PlayerEntity entity)) { + return; + } + + if (!session.getPlayerWithCustomHeads().remove(entity.getUuid())) { + return; + } + + SkinProvider.requestSkinData(entity).whenCompleteAsync((skinData, throwable) -> { + if (throwable != null) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), throwable); + return; + } + + if (session.getUpstream().isInitialized()) { + 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 + private static class FakeHeadEntry { + private final GameProfile.Property texturesProperty; + private final String fakeHeadSkinUrl; + private PlayerEntity entity; + + @Override + public boolean equals(Object o) { + // We don't care about the equality of the entity as that is not used for caching purposes + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FakeHeadEntry that = (FakeHeadEntry) o; + return equals(texturesProperty, that.texturesProperty) && Objects.equals(fakeHeadSkinUrl, that.fakeHeadSkinUrl); + } + + private boolean equals(GameProfile.Property a, GameProfile.Property b) { + //TODO actually fix this in MCAuthLib + if (a == b) { + return true; + } + if (a == null || b == null) { + return false; + } + return Objects.equals(a.getName(), b.getName()) && Objects.equals(a.getValue(), b.getValue()) && Objects.equals(a.getSignature(), b.getSignature()); + } + + @Override + public int hashCode() { + return Objects.hash(texturesProperty, fakeHeadSkinUrl); + } + } + +} 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 05f49a664..199d69238 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java @@ -27,6 +27,9 @@ package org.geysermc.geyser.skin; import com.fasterxml.jackson.databind.JsonNode; 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.protocol.bedrock.data.skin.ImageData; import com.nukkitx.protocol.bedrock.data.skin.SerializedSkin; import com.nukkitx.protocol.bedrock.packet.PlayerListPacket; @@ -37,6 +40,8 @@ import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.auth.BedrockClientData; import org.geysermc.geyser.text.GeyserLocale; +import javax.annotation.Nullable; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Collections; @@ -114,91 +119,52 @@ public class SkinManager { public static void requestAndHandleSkinAndCape(PlayerEntity entity, GeyserSession session, Consumer skinAndCapeConsumer) { - GameProfileData data = GameProfileData.from(entity.getProfile()); + SkinProvider.requestSkinData(entity).whenCompleteAsync((skinData, throwable) -> { + if (skinData == null) { + if (skinAndCapeConsumer != null) { + skinAndCapeConsumer.accept(null); + } - SkinProvider.requestSkinAndCape(entity.getUuid(), data.skinUrl(), data.capeUrl()) - .whenCompleteAsync((skinAndCape, throwable) -> { - try { - SkinProvider.Skin skin = skinAndCape.getSkin(); - SkinProvider.Cape cape = skinAndCape.getCape(); - SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.getLegacy(data.isAlex()); + return; + } - if (cape.isFailed()) { - cape = SkinProvider.getOrDefault(SkinProvider.requestBedrockCape(entity.getUuid()), - SkinProvider.EMPTY_CAPE, 3); - } + if (skinData.geometry() != null) { + SkinProvider.Skin skin = skinData.skin(); + SkinProvider.Cape cape = skinData.cape(); + SkinProvider.SkinGeometry geometry = skinData.geometry(); - if (cape.isFailed() && SkinProvider.ALLOW_THIRD_PARTY_CAPES) { - cape = SkinProvider.getOrDefault(SkinProvider.requestUnofficialCape( - cape, entity.getUuid(), - entity.getUsername(), false - ), SkinProvider.EMPTY_CAPE, SkinProvider.CapeProvider.VALUES.length * 3); - } - - geometry = SkinProvider.getOrDefault(SkinProvider.requestBedrockGeometry( - geometry, entity.getUuid() - ), geometry, 3); - - boolean isDeadmau5 = "deadmau5".equals(entity.getUsername()); - // Not a bedrock player check for ears - if (geometry.isFailed() && (SkinProvider.ALLOW_THIRD_PARTY_EARS || isDeadmau5)) { - boolean isEars; - - // Its deadmau5, gotta support his skin :) - if (isDeadmau5) { - isEars = true; - } else { - // Get the ears texture for the player - skin = SkinProvider.getOrDefault(SkinProvider.requestUnofficialEars( - skin, entity.getUuid(), entity.getUsername(), false - ), skin, 3); - - isEars = skin.isEars(); - } - - // Does the skin have an ears texture - if (isEars) { - // Get the new geometry - geometry = SkinProvider.SkinGeometry.getEars(data.isAlex()); - - // Store the skin and geometry for the ears - SkinProvider.storeEarSkin(skin); - SkinProvider.storeEarGeometry(entity.getUuid(), data.isAlex()); - } - } - - PlayerListPacket.Entry updatedEntry = buildEntryManually( - session, - entity.getUuid(), - entity.getUsername(), - entity.getGeyserId(), - skin.getTextureUrl(), - skin.getSkinData(), - cape.getCapeId(), - cape.getCapeData(), - geometry - ); + if (session.getUpstream().isInitialized()) { + 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); + 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); - } - } catch (Exception e) { - GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), e); + if (!entity.isPlayerList()) { + PlayerListPacket playerRemovePacket = new PlayerListPacket(); + playerRemovePacket.setAction(PlayerListPacket.Action.REMOVE); + playerRemovePacket.getEntries().add(updatedEntry); + session.sendUpstreamPacket(playerRemovePacket); } + } + } - if (skinAndCapeConsumer != null) { - skinAndCapeConsumer.accept(skinAndCape); - } - }); + if (skinAndCapeConsumer != null) { + skinAndCapeConsumer.accept(new SkinProvider.SkinAndCape(skinData.skin(), skinData.cape())); + } + }); } public static void handleBedrockSkin(PlayerEntity playerEntity, BedrockClientData clientData) { @@ -231,6 +197,37 @@ public class SkinManager { } public record GameProfileData(String skinUrl, String capeUrl, boolean isAlex) { + /** + * Generate the GameProfileData from the given CompoundTag representing a GameProfile + * + * @param tag tag to build the GameProfileData from + * @return The built GameProfileData, or null if this wasn't a valid tag + */ + public static @Nullable GameProfileData from(CompoundTag tag) { + if (!(tag.get("Properties") instanceof CompoundTag propertiesTag)) { + return null; + } + if (!(propertiesTag.get("textures") instanceof ListTag texturesTag) || texturesTag.size() == 0) { + return null; + } + if (!(texturesTag.get(0) instanceof CompoundTag texturesData)) { + return null; + } + if (!(texturesData.get("Value") instanceof StringTag skinDataValue)) { + return null; + } + + try { + return loadFromJson(skinDataValue.getValue()); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().debug("Something went wrong while processing skin for tag " + tag); + if (GeyserImpl.getInstance().getConfig().isDebugMode()) { + e.printStackTrace(); + } + return null; + } + } + /** * Generate the GameProfileData from the given GameProfile * @@ -245,22 +242,8 @@ public class SkinManager { // Likely offline mode return loadBedrockOrOfflineSkin(profile); } - JsonNode skinObject = GeyserImpl.JSON_MAPPER.readTree(new String(Base64.getDecoder().decode(skinProperty.getValue()), StandardCharsets.UTF_8)); - JsonNode textures = skinObject.get("textures"); - - JsonNode skinTexture = textures.get("SKIN"); - String skinUrl = skinTexture.get("url").asText().replace("http://", "https://"); - - boolean isAlex = skinTexture.has("metadata"); - - String capeUrl = null; - if (textures.has("CAPE")) { - JsonNode capeTexture = textures.get("CAPE"); - capeUrl = capeTexture.get("url").asText().replace("http://", "https://"); - } - - return new GameProfileData(skinUrl, capeUrl, isAlex); - } catch (Exception exception) { + return loadFromJson(skinProperty.getValue()); + } catch (IOException exception) { GeyserImpl.getInstance().getLogger().debug("Something went wrong while processing skin for " + profile.getName()); if (GeyserImpl.getInstance().getConfig().isDebugMode()) { exception.printStackTrace(); @@ -269,6 +252,24 @@ public class SkinManager { } } + private static GameProfileData loadFromJson(String encodedJson) throws IOException { + JsonNode skinObject = GeyserImpl.JSON_MAPPER.readTree(new String(Base64.getDecoder().decode(encodedJson), StandardCharsets.UTF_8)); + JsonNode textures = skinObject.get("textures"); + + JsonNode skinTexture = textures.get("SKIN"); + String skinUrl = skinTexture.get("url").asText().replace("http://", "https://"); + + boolean isAlex = skinTexture.has("metadata"); + + String capeUrl = null; + JsonNode capeTexture = textures.get("CAPE"); + if (capeTexture != null) { + capeUrl = capeTexture.get("url").asText().replace("http://", "https://"); + } + + 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. 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 a79ed5b7f..e6807e3f7 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java @@ -37,7 +37,9 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.geysermc.geyser.GeyserImpl; +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; @@ -57,7 +59,7 @@ import java.util.concurrent.*; public class SkinProvider { public static final boolean ALLOW_THIRD_PARTY_CAPES = GeyserImpl.getInstance().getConfig().isAllowThirdPartyCapes(); - private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(ALLOW_THIRD_PARTY_CAPES ? 21 : 14); + 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); @@ -85,6 +87,8 @@ public class SkinProvider { 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(); @@ -99,6 +103,12 @@ public class SkinProvider { 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); + /* Load in the player head skull geometry */ + String wearingCustomSkull = new String(FileUtils.readAllBytes(FileUtils.getResource("bedrock/skin/geometry.humanoid.wearingCustomSkull.json")), StandardCharsets.UTF_8); + WEARING_CUSTOM_SKULL = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.wearingCustomSkull\"}}", wearingCustomSkull, false); + String wearingCustomSkullSlim = new String(FileUtils.readAllBytes(FileUtils.getResource("bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json")), StandardCharsets.UTF_8); + WEARING_CUSTOM_SKULL_SLIM = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.wearingCustomSkullSlim\"}}", wearingCustomSkullSlim, false); + // Schedule Daily Image Expiry if we are caching them if (GeyserImpl.getInstance().getConfig().getCacheImages() > 0) { GeyserImpl.getInstance().getScheduledThread().scheduleAtFixedRate(() -> { @@ -137,6 +147,69 @@ public class SkinProvider { return cape != null ? cape : EMPTY_CAPE; } + public static CompletableFuture requestSkinData(PlayerEntity entity) { + SkinManager.GameProfileData data = SkinManager.GameProfileData.from(entity.getProfile()); + + 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()); + + if (cape.isFailed()) { + cape = getOrDefault(requestBedrockCape(entity.getUuid()), + EMPTY_CAPE, 3); + } + + if (cape.isFailed() && 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)) { + boolean isEars; + + // Its deadmau5, gotta support his skin :) + if (isDeadmau5) { + isEars = true; + } else { + // Get the ears texture for the player + skin = getOrDefault(requestUnofficialEars( + skin, entity.getUuid(), entity.getUsername(), false + ), skin, 3); + + isEars = skin.isEars(); + } + + // Does the skin have an ears texture + if (isEars) { + // Get the new geometry + geometry = SkinGeometry.getEars(data.isAlex()); + + // Store the skin and geometry for the ears + storeEarSkin(skin); + storeEarGeometry(entity.getUuid(), data.isAlex()); + } + } + + return new SkinData(skin, cape, geometry); + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), e); + } + + return new SkinData(skinAndCape.getSkin(), skinAndCape.getCape(), null); + }); + } + public static CompletableFuture requestSkinAndCape(UUID playerId, String skinUrl, String capeUrl) { return CompletableFuture.supplyAsync(() -> { long time = System.currentTimeMillis(); @@ -329,7 +402,8 @@ public class SkinProvider { byte[] cape = EMPTY_CAPE.getCapeData(); try { cape = requestImage(capeUrl, provider); - } catch (Exception ignored) {} // just ignore I guess + } catch (Exception ignored) { + } // just ignore I guess String[] urlSection = capeUrl.split("/"); // A real url is expected at this stage @@ -451,6 +525,7 @@ public class SkinProvider { /** * 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 */ @@ -602,6 +677,9 @@ public class SkinProvider { private final Cape cape; } + public record SkinData(Skin skin, Cape cape, SkinGeometry geometry) { + } + @AllArgsConstructor @Getter public static class Skin { diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java index 19fe3f58a..0fd9f114f 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java @@ -29,6 +29,7 @@ import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack; import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode; import com.github.steveice10.mc.protocol.data.game.inventory.ContainerType; import com.github.steveice10.mc.protocol.packet.ingame.serverbound.inventory.ServerboundSetCreativeModeSlotPacket; +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.nukkitx.protocol.bedrock.data.inventory.*; import com.nukkitx.protocol.bedrock.data.inventory.stackrequestactions.*; import com.nukkitx.protocol.bedrock.packet.InventoryContentPacket; @@ -36,15 +37,12 @@ import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket; import com.nukkitx.protocol.bedrock.packet.ItemStackResponsePacket; import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.ints.IntSet; -import org.geysermc.geyser.inventory.GeyserItemStack; -import org.geysermc.geyser.inventory.Inventory; -import org.geysermc.geyser.inventory.PlayerInventory; +import org.geysermc.geyser.inventory.*; import org.geysermc.geyser.session.GeyserSession; -import org.geysermc.geyser.inventory.BedrockContainerSlot; -import org.geysermc.geyser.inventory.SlotType; +import org.geysermc.geyser.skin.FakeHeadProvider; +import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.translator.inventory.item.ItemTranslator; import org.geysermc.geyser.util.InventoryUtils; -import org.geysermc.geyser.text.GeyserLocale; import java.util.Arrays; import java.util.Collections; @@ -116,6 +114,20 @@ public class PlayerInventoryTranslator extends InventoryTranslator { @Override public void updateSlot(GeyserSession session, Inventory inventory, int slot) { + GeyserItemStack javaItem = inventory.getItem(slot); + ItemData bedrockItem = javaItem.getItemData(session); + + if (slot == 5) { + // Check for custom skull + if (javaItem.getJavaId() == session.getItemMappings().getStoredItems().playerHead().getJavaId() + && javaItem.getNbt() != null + && javaItem.getNbt().get("SkullOwner") instanceof CompoundTag profile) { + FakeHeadProvider.setHead(session, session.getPlayerEntity(), profile); + } else { + FakeHeadProvider.restoreOriginalSkin(session, session.getPlayerEntity()); + } + } + if (slot >= 1 && slot <= 44) { InventorySlotPacket slotPacket = new InventorySlotPacket(); if (slot >= 9) { @@ -132,12 +144,12 @@ public class PlayerInventoryTranslator extends InventoryTranslator { slotPacket.setContainerId(ContainerId.UI); slotPacket.setSlot(slot + 27); } - slotPacket.setItem(inventory.getItem(slot).getItemData(session)); + slotPacket.setItem(bedrockItem); session.sendUpstreamPacket(slotPacket); } else if (slot == 45) { InventoryContentPacket offhandPacket = new InventoryContentPacket(); offhandPacket.setContainerId(ContainerId.OFFHAND); - offhandPacket.setContents(Collections.singletonList(inventory.getItem(slot).getItemData(session))); + offhandPacket.setContents(Collections.singletonList(bedrockItem)); session.sendUpstreamPacket(offhandPacket); } } diff --git a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/SkullBlockEntityTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/SkullBlockEntityTranslator.java index b6b41db6d..4286b2b21 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/SkullBlockEntityTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/level/block/entity/SkullBlockEntityTranslator.java @@ -49,7 +49,6 @@ import java.util.concurrent.TimeUnit; @BlockEntity(type = BlockEntityType.SKULL) public class SkullBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState { - public static boolean ALLOW_CUSTOM_SKULLS; @Override public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) { @@ -64,8 +63,8 @@ public class SkullBlockEntityTranslator extends BlockEntityTranslator implements } public static CompletableFuture getProfile(CompoundTag tag) { - if (tag.contains("SkullOwner")) { - CompoundTag owner = tag.get("SkullOwner"); + CompoundTag owner = tag.get("SkullOwner"); + if (owner != null) { CompoundTag properties = owner.get("Properties"); if (properties == null) { return SkinProvider.requestTexturesFromUsername(owner); diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEquipmentTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEquipmentTranslator.java index 0058ed61e..ebfe40832 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEquipmentTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/JavaSetEquipmentTranslator.java @@ -26,14 +26,18 @@ package org.geysermc.geyser.translator.protocol.java.entity; import com.github.steveice10.mc.protocol.data.game.entity.metadata.Equipment; +import com.github.steveice10.mc.protocol.data.game.entity.metadata.ItemStack; import com.github.steveice10.mc.protocol.packet.ingame.clientbound.entity.ClientboundSetEquipmentPacket; +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.nukkitx.protocol.bedrock.data.inventory.ItemData; import org.geysermc.geyser.entity.type.Entity; import org.geysermc.geyser.entity.type.LivingEntity; +import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.skin.FakeHeadProvider; +import org.geysermc.geyser.translator.inventory.item.ItemTranslator; import org.geysermc.geyser.translator.protocol.PacketTranslator; import org.geysermc.geyser.translator.protocol.Translator; -import org.geysermc.geyser.translator.inventory.item.ItemTranslator; @Translator(packet = ClientboundSetEquipmentPacket.class) public class JavaSetEquipmentTranslator extends PacketTranslator { @@ -63,6 +67,17 @@ public class JavaSetEquipmentTranslator extends PacketTranslator { + ItemStack javaItem = equipment.getItem(); + if (livingEntity instanceof PlayerEntity + && javaItem != null + && javaItem.getId() == session.getItemMappings().getStoredItems().playerHead().getJavaId() + && javaItem.getNbt() != null + && javaItem.getNbt().get("SkullOwner") instanceof CompoundTag profile) { + FakeHeadProvider.setHead(session, (PlayerEntity) livingEntity, profile); + } else { + FakeHeadProvider.restoreOriginalSkin(session, livingEntity); + } + livingEntity.setHelmet(item); armorUpdated = true; } diff --git a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java index d22b47768..68d79dc4f 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/text/MessageTranslator.java @@ -149,6 +149,9 @@ public class MessageTranslator { * @return Bedrock formatted message */ public static String convertMessageLenient(String message, String locale) { + if (message == null) { + return ""; + } if (message.isBlank()) { return message; } diff --git a/core/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkull.json b/core/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkull.json new file mode 100644 index 000000000..b18d1205b --- /dev/null +++ b/core/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkull.json @@ -0,0 +1,222 @@ +{ + "format_version": "1.14.0", + "minecraft:geometry": [ + { + "bones": [ + { + "name" : "root", + "pivot" : [ 0.0, 0.0, 0.0 ] + }, + + { + "name" : "waist", + "parent" : "root", + "pivot" : [ 0.0, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes" : [] + }, + + + { + "name": "body", + "parent" : "waist", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 12.0, -2.0 ], + "size": [ 8, 12, 4 ], + "uv": [ 16, 16 ] + } + ] + }, + + { + "name": "jacket", + "parent" : "body", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 12.0, -2.0 ], + "size": [ 8, 12, 4 ], + "uv": [ 16, 32 ], + "inflate": 0.25 + } + ] + }, + + + { + "name": "head", + "parent" : "body", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 24.75, -4.0 ], + "size": [ 8, 8, 8 ], + "uv": [ 0, 0 ], + "inflate": 0.8 + } + ] + }, + + { + "name": "hat", + "parent" : "head", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 24.75, -4.0 ], + "size": [ 8, 8, 8 ], + "uv": [ 32, 0 ], + "inflate": 1.125 + } + ] + }, + + + { + "name": "leftArm", + "parent" : "body", + "pivot": [ 5.0, 22.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ 4.0, 12.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 32, 48 ] + } + ] + }, + { + "name": "rightArm", + "parent" : "body", + "pivot": [ -5.0, 22.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -8.0, 12.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 40, 16 ] + } + ] + }, + + { + "name": "leftSleeve", + "parent" : "leftArm", + "pivot": [ 5.0, 22.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ 4.0, 12.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 48, 48 ], + "inflate": 0.25 + } + ] + }, + + { + "name": "rightSleeve", + "parent" : "rightArm", + "pivot": [ -5.0, 22.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -8.0, 12.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 40, 32 ], + "inflate": 0.25 + } + ] + }, + + + { + "name": "leftLeg", + "parent" : "root", + "pivot": [ 1.9, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -0.1, 0.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 0, 16 ] + } + ], + "mirror": true + }, + + { + "name": "rightLeg", + "parent" : "root", + "pivot": [ -1.9, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -3.9, 0.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 0, 16 ] + } + ] + }, + + { + "name": "leftPants", + "parent" : "leftLeg", + "pivot": [1.9, 12.0, 0.0], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -0.1, 0.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 0, 48 ], + "inflate": 0.25 + } + ] + }, + + { + "name": "rightPants", + "parent" : "rightLeg", + "pivot": [ -1.9, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -3.9, 0.0, -2.0] , + "size": [ 4, 12, 4 ], + "uv": [ 0, 32], + "inflate": 0.25 + } + ] + }, + + + { + "name" : "rightItem", + "parent" : "rightArm", + "pivot" : [ -6.0, 15.0, 1.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes" : [] + }, + + { + "name" : "leftItem", + "parent" : "leftArm", + "pivot" : [ 6.0, 15.0, 1.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes" : [] + } + ], + "description": { + "identifier": "geometry.humanoid.wearingCustomSkull", + "texture_height": 64, + "texture_width": 64 + } + } + ] +} \ No newline at end of file diff --git a/core/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json b/core/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json new file mode 100644 index 000000000..3855c92ec --- /dev/null +++ b/core/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json @@ -0,0 +1,222 @@ +{ + "format_version": "1.14.0", + "minecraft:geometry": [ + { + "bones": [ + { + "name" : "root", + "pivot" : [ 0.0, 0.0, 0.0 ] + }, + + { + "name" : "waist", + "parent" : "root", + "pivot" : [ 0.0, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes" : [] + }, + + + { + "name": "body", + "parent" : "waist", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 12.0, -2.0 ], + "size": [ 8, 12, 4 ], + "uv": [ 16, 16 ] + } + ] + }, + + { + "name": "jacket", + "parent" : "body", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 12.0, -2.0 ], + "size": [ 8, 12, 4 ], + "uv": [ 16, 32 ], + "inflate": 0.25 + } + ] + }, + + + { + "name": "head", + "parent" : "body", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 24.75, -4.0 ], + "size": [ 8, 8, 8 ], + "uv": [ 0, 0 ], + "inflate": 0.8 + } + ] + }, + + { + "name": "hat", + "parent" : "head", + "pivot": [ 0.0, 24.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -4.0, 24.75, -4.0 ], + "size": [ 8, 8, 8 ], + "uv": [ 32, 0 ], + "inflate": 1.125 + } + ] + }, + + + { + "name": "leftArm", + "parent" : "body", + "pivot": [ 5.0, 21.5, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [4.0, 11.5, -2.0], + "size": [ 3, 12, 4 ], + "uv": [ 32, 48 ] + } + ] + }, + { + "name": "rightArm", + "parent" : "body", + "pivot": [ -5.0, 21.5, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [-7.0, 11.5, -2.0], + "size": [ 3, 12, 4 ], + "uv": [ 40, 16 ] + } + ] + }, + + { + "name": "leftSleeve", + "parent" : "leftArm", + "pivot": [ 5.0, 21.5, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ 4.0, 11.5, -2.0 ], + "size": [ 3, 12, 4 ], + "uv": [ 48, 48 ], + "inflate": 0.25 + } + ] + }, + + { + "name": "rightSleeve", + "parent" : "rightArm", + "pivot": [ -5.0, 21.5, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -7.0, 11.5, -2.0 ], + "size": [ 3, 12, 4 ], + "uv": [ 40, 32 ], + "inflate": 0.25 + } + ] + }, + + + { + "name": "leftLeg", + "parent" : "root", + "pivot": [ 1.9, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -0.1, 0.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 0, 16 ] + } + ], + "mirror": true + }, + + { + "name": "rightLeg", + "parent" : "root", + "pivot": [ -1.9, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -3.9, 0.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 0, 16 ] + } + ] + }, + + { + "name": "leftPants", + "parent" : "leftLeg", + "pivot": [1.9, 12.0, 0.0], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -0.1, 0.0, -2.0 ], + "size": [ 4, 12, 4 ], + "uv": [ 0, 48 ], + "inflate": 0.25 + } + ] + }, + + { + "name": "rightPants", + "parent" : "rightLeg", + "pivot": [ -1.9, 12.0, 0.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes": [ + { + "origin": [ -3.9, 0.0, -2.0] , + "size": [ 4, 12, 4 ], + "uv": [ 0, 32], + "inflate": 0.25 + } + ] + }, + + + { + "name" : "rightItem", + "parent" : "rightArm", + "pivot" : [ -6.0, 14.5, 1.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes" : [] + }, + + { + "name" : "leftItem", + "parent" : "leftArm", + "pivot" : [ 6.0, 14.5, 1.0 ], + "rotation" : [ 0.0, 0.0, 0.0 ], + "cubes" : [] + } + ], + "description": { + "identifier": "geometry.humanoid.wearingCustomSkullSlim", + "texture_height": 64, + "texture_width": 64 + } + } + ] +} \ No newline at end of file