From 798f8da573d7356898f217f3bd96bc27c22836c9 Mon Sep 17 00:00:00 2001 From: qlow <20094418+qlow@users.noreply.github.com> Date: Mon, 22 Nov 2021 20:49:55 +0100 Subject: [PATCH] Enabling player heads to be seen on players (#2634) Custom player heads will now show correctly on players thanks to skin editing and custom geometry. Co-authored-by: qlow Co-authored-by: Camotoy <20743703+Camotoy@users.noreply.github.com> --- .../network/session/GeyserSession.java | 6 + .../network/session/cache/EntityCache.java | 5 + .../PlayerInventoryTranslator.java | 20 +- .../translators/item/StoredItemMappings.java | 2 + .../entity/JavaEntityEquipmentTranslator.java | 15 ++ .../entity/SkullBlockEntityTranslator.java | 5 +- .../connector/skin/FakeHeadProvider.java | 219 +++++++++++++++++ .../geysermc/connector/skin/SkinManager.java | 195 ++++++++------- .../geysermc/connector/skin/SkinProvider.java | 84 ++++++- .../geometry.humanoid.wearingCustomSkull.json | 222 ++++++++++++++++++ ...metry.humanoid.wearingCustomSkullSlim.json | 222 ++++++++++++++++++ 11 files changed, 889 insertions(+), 106 deletions(-) create mode 100644 connector/src/main/java/org/geysermc/connector/skin/FakeHeadProvider.java create mode 100644 connector/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkull.json create mode 100644 connector/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json diff --git a/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java b/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java index d9e95e9a9..3937fa648 100644 --- a/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java +++ b/connector/src/main/java/org/geysermc/connector/network/session/GeyserSession.java @@ -219,6 +219,12 @@ public class GeyserSession implements 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/connector/src/main/java/org/geysermc/connector/network/session/cache/EntityCache.java b/connector/src/main/java/org/geysermc/connector/network/session/cache/EntityCache.java index 3ff547c95..7d0c3c7bc 100644 --- a/connector/src/main/java/org/geysermc/connector/network/session/cache/EntityCache.java +++ b/connector/src/main/java/org/geysermc/connector/network/session/cache/EntityCache.java @@ -89,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(session))) { long geyserId = entityIdTranslations.remove(entity.getEntityId()); entities.remove(geyserId); @@ -107,6 +111,7 @@ public class EntityCache { session.getEntityCache().removeEntity(entity, false); } + session.getPlayerWithCustomHeads().clear(); // As a precaution cachedPlayerEntityLinks.clear(); } diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/PlayerInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/PlayerInventoryTranslator.java index f52a3053d..1de69c814 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/PlayerInventoryTranslator.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/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.window.WindowType; import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientCreativeInventoryActionPacket; +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; @@ -44,6 +45,7 @@ import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot import org.geysermc.connector.network.translators.inventory.InventoryTranslator; import org.geysermc.connector.network.translators.inventory.SlotType; import org.geysermc.connector.network.translators.item.ItemTranslator; +import org.geysermc.connector.skin.FakeHeadProvider; import org.geysermc.connector.utils.InventoryUtils; import org.geysermc.connector.utils.LanguageUtils; @@ -117,6 +119,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) { @@ -133,12 +149,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/connector/src/main/java/org/geysermc/connector/network/translators/item/StoredItemMappings.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/StoredItemMappings.java index 6bbdb7421..fae36c2ea 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/item/StoredItemMappings.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/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/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/JavaEntityEquipmentTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/JavaEntityEquipmentTranslator.java index f297f1a38..affcdfe87 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/JavaEntityEquipmentTranslator.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/java/entity/JavaEntityEquipmentTranslator.java @@ -26,14 +26,18 @@ package org.geysermc.connector.network.translators.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.server.entity.ServerEntityEquipmentPacket; +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.nukkitx.protocol.bedrock.data.inventory.ItemData; import org.geysermc.connector.entity.Entity; import org.geysermc.connector.entity.LivingEntity; +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.network.translators.item.ItemTranslator; +import org.geysermc.connector.skin.FakeHeadProvider; @Translator(packet = ServerEntityEquipmentPacket.class) public class JavaEntityEquipmentTranslator extends PacketTranslator { @@ -63,6 +67,17 @@ public class JavaEntityEquipmentTranslator 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/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java index 90458ca71..5bf75f059 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/world/block/entity/SkullBlockEntityTranslator.java @@ -48,7 +48,6 @@ import java.util.concurrent.TimeUnit; @BlockEntity(name = "Skull") public class SkullBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState { - public static boolean ALLOW_CUSTOM_SKULLS; @Override public void translateTag(NbtMapBuilder builder, CompoundTag tag, int blockState) { @@ -63,8 +62,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/connector/src/main/java/org/geysermc/connector/skin/FakeHeadProvider.java b/connector/src/main/java/org/geysermc/connector/skin/FakeHeadProvider.java new file mode 100644 index 000000000..b848c0c35 --- /dev/null +++ b/connector/src/main/java/org/geysermc/connector/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.connector.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.connector.GeyserConnector; +import org.geysermc.connector.entity.LivingEntity; +import org.geysermc.connector.entity.player.PlayerEntity; +import org.geysermc.connector.network.session.GeyserSession; +import org.geysermc.connector.utils.LanguageUtils; + +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) { + GeyserConnector.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) { + GeyserConnector.getInstance().getLogger().error(LanguageUtils.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/connector/src/main/java/org/geysermc/connector/skin/SkinManager.java b/connector/src/main/java/org/geysermc/connector/skin/SkinManager.java index 70f9f8ff5..5aaabcffb 100644 --- a/connector/src/main/java/org/geysermc/connector/skin/SkinManager.java +++ b/connector/src/main/java/org/geysermc/connector/skin/SkinManager.java @@ -27,6 +27,9 @@ package org.geysermc.connector.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.connector.network.session.GeyserSession; import org.geysermc.connector.network.session.auth.BedrockClientData; import org.geysermc.connector.utils.LanguageUtils; +import javax.annotation.Nullable; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Collections; @@ -75,9 +80,9 @@ 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.SkinGeometry geometry) { + String skinId, byte[] skinData, + String capeId, byte[] capeData, + SkinProvider.SkinGeometry geometry) { SerializedSkin serializedSkin = SerializedSkin.of( skinId, "", geometry.getGeometryName(), ImageData.of(skinData), Collections.emptyList(), ImageData.of(capeData), geometry.getGeometryData(), "", true, false, @@ -114,93 +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()); - } - } - - if (session.getUpstream().isInitialized()) { - 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) { - GeyserConnector.getInstance().getLogger().error(LanguageUtils.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) { @@ -233,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) { + GeyserConnector.getInstance().getLogger().debug("Something went wrong while processing skin for tag " + tag); + if (GeyserConnector.getInstance().getConfig().isDebugMode()) { + e.printStackTrace(); + } + return null; + } + } + /** * Generate the GameProfileData from the given GameProfile * @@ -247,22 +242,8 @@ public class SkinManager { // Likely offline mode return loadBedrockOrOfflineSkin(profile); } - 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"); - 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) { GeyserConnector.getInstance().getLogger().debug("Something went wrong while processing skin for " + profile.getName()); if (GeyserConnector.getInstance().getConfig().isDebugMode()) { exception.printStackTrace(); @@ -271,6 +252,24 @@ public class SkinManager { } } + private static GameProfileData loadFromJson(String encodedJson) throws IOException { + JsonNode skinObject = GeyserConnector.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/connector/src/main/java/org/geysermc/connector/skin/SkinProvider.java b/connector/src/main/java/org/geysermc/connector/skin/SkinProvider.java index 567e52ace..7b1ea9550 100644 --- a/connector/src/main/java/org/geysermc/connector/skin/SkinProvider.java +++ b/connector/src/main/java/org/geysermc/connector/skin/SkinProvider.java @@ -37,8 +37,10 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.geysermc.connector.GeyserConnector; +import org.geysermc.connector.entity.player.PlayerEntity; import org.geysermc.connector.network.session.GeyserSession; import org.geysermc.connector.utils.FileUtils; +import org.geysermc.connector.utils.LanguageUtils; import org.geysermc.connector.utils.WebUtils; import javax.imageio.ImageIO; @@ -57,7 +59,7 @@ import java.util.concurrent.*; public class SkinProvider { public static final boolean ALLOW_THIRD_PARTY_CAPES = GeyserConnector.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 (GeyserConnector.getInstance().getConfig().getCacheImages() > 0) { GeyserConnector.getInstance().getGeneralThreadPool().scheduleAtFixedRate(() -> { @@ -108,7 +118,7 @@ public class SkinProvider { } int count = 0; - final long expireTime = ((long)GeyserConnector.getInstance().getConfig().getCacheImages()) * ((long)1000 * 60 * 60 * 24); + final long expireTime = ((long) GeyserConnector.getInstance().getConfig().getCacheImages()) * ((long) 1000 * 60 * 60 * 24); for (File imageFile : Objects.requireNonNull(cacheFolder.listFiles())) { if (imageFile.lastModified() < System.currentTimeMillis() - expireTime) { //noinspection ResultOfMethodCallIgnored @@ -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) { + GeyserConnector.getInstance().getLogger().error(LanguageUtils.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/connector/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkull.json b/connector/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkull.json new file mode 100644 index 000000000..b18d1205b --- /dev/null +++ b/connector/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/connector/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json b/connector/src/main/resources/bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json new file mode 100644 index 000000000..3855c92ec --- /dev/null +++ b/connector/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