diff --git a/.idea/copyright/Geyser.xml b/.idea/copyright/Geyser.xml index c6b553aaf..758c31cbd 100644 --- a/.idea/copyright/Geyser.xml +++ b/.idea/copyright/Geyser.xml @@ -1,6 +1,7 @@ - \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml index f2d5911c9..09299b893 100644 --- a/.idea/copyright/profiles_settings.xml +++ b/.idea/copyright/profiles_settings.xml @@ -1,5 +1,5 @@ - + diff --git a/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionSkinApplyEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionSkinApplyEvent.java new file mode 100644 index 000000000..5167625ea --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionSkinApplyEvent.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2019-2024 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.api.event.bedrock; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.connection.GeyserConnection; +import org.geysermc.geyser.api.event.connection.ConnectionEvent; +import org.geysermc.geyser.api.skin.Cape; +import org.geysermc.geyser.api.skin.Skin; +import org.geysermc.geyser.api.skin.SkinData; +import org.geysermc.geyser.api.skin.SkinGeometry; + +import java.util.UUID; + +/** + * Called when a skin is applied to a player. + *

+ * Won't be called when a fake player is spawned for a player skull. + */ +public abstract class SessionSkinApplyEvent extends ConnectionEvent { + + private final String username; + private final UUID uuid; + private final boolean slim; + private final boolean bedrock; + private final SkinData originalSkinData; + + public SessionSkinApplyEvent(@NonNull GeyserConnection connection, String username, UUID uuid, boolean slim, boolean bedrock, SkinData skinData) { + super(connection); + this.username = username; + this.uuid = uuid; + this.slim = slim; + this.bedrock = bedrock; + this.originalSkinData = skinData; + } + + /** + * The username of the player. + * + * @return the username of the player + */ + public String username() { + return username; + } + + /** + * The UUID of the player. + * + * @return the UUID of the player + */ + public UUID uuid() { + return uuid; + } + + /** + * If the player is using a slim model. + * + * @return if the player is using a slim model + */ + public boolean slim() { + return slim; + } + + /** + * If the player is a Bedrock player. + * + * @return if the player is a Bedrock player + */ + public boolean bedrock() { + return bedrock; + } + + /** + * The original skin data of the player. + * + * @return the original skin data of the player + */ + public SkinData originalSkin() { + return originalSkinData; + } + + /** + * The skin data of the player. + * + * @return the skin data of the player + */ + public abstract SkinData skinData(); + + /** + * Change the skin of the player. + * + * @param newSkin the new skin + */ + public abstract void skin(@NonNull Skin newSkin); + + /** + * Change the cape of the player. + * + * @param newCape the new cape + */ + public abstract void cape(@NonNull Cape newCape); + + /** + * Change the geometry of the player. + * + * @param newGeometry the new geometry + */ + public abstract void geometry(@NonNull SkinGeometry newGeometry); + + /** + * Change the geometry of the player. + *

+ * Constructs a generic {@link SkinGeometry} object with the given data. + * + * @param geometryName the name of the geometry + * @param geometryData the data of the geometry + */ + public void geometry(@NonNull String geometryName, @NonNull String geometryData) { + geometry(new SkinGeometry("{\"geometry\" :{\"default\" :\"" + geometryName + "\"}}", geometryData)); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/skin/Cape.java b/api/src/main/java/org/geysermc/geyser/api/skin/Cape.java new file mode 100644 index 000000000..1e7341ae4 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/skin/Cape.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 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.api.skin; + +/** + * Represents a cape. + * + * @param textureUrl The URL of the cape texture + * @param capeId The ID of the cape + * @param capeData The raw cape image data in ARGB format + * @param failed If the cape failed to load, this is for things like fallback capes + */ +public record Cape(String textureUrl, String capeId, byte[] capeData, boolean failed) { + public Cape(String textureUrl, String capeId, byte[] capeData) { + this(textureUrl, capeId, capeData, false); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/skin/Skin.java b/api/src/main/java/org/geysermc/geyser/api/skin/Skin.java new file mode 100644 index 000000000..9b39ddfe8 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/skin/Skin.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 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.api.skin; + +/** + * Represents a skin. + * + * @param textureUrl The URL/ID of the skin texture + * @param skinData The raw skin image data in ARGB + * @param failed If the skin failed to load, this is for things like fallback skins + */ +public record Skin(String textureUrl, byte[] skinData, boolean failed) { + public Skin(String textureUrl, byte[] skinData) { + this(textureUrl, skinData, false); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/skin/SkinData.java b/api/src/main/java/org/geysermc/geyser/api/skin/SkinData.java new file mode 100644 index 000000000..9de4a3534 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/skin/SkinData.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 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.api.skin; + +/** + * Represents a full package of {@link Skin}, {@link Cape}, and {@link SkinGeometry}. + */ +public record SkinData(Skin skin, Cape cape, SkinGeometry geometry) { +} diff --git a/api/src/main/java/org/geysermc/geyser/api/skin/SkinGeometry.java b/api/src/main/java/org/geysermc/geyser/api/skin/SkinGeometry.java new file mode 100644 index 000000000..5b40d2022 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/skin/SkinGeometry.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 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.api.skin; + +/** + * Represents geometry of a skin. + * + * @param geometryName The name of the geometry (JSON) + * @param geometryData The geometry data (JSON) + */ +public record SkinGeometry(String geometryName, String geometryData) { + + public static SkinGeometry WIDE = getLegacy(false); + public static SkinGeometry SLIM = getLegacy(true); + + /** + * Generate generic geometry + * + * @param isSlim if true, it will be the slimmer alex model + * @return The generic geometry object + */ + private static SkinGeometry getLegacy(boolean isSlim) { + return new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.custom" + (isSlim ? "Slim" : "") + "\"}}", ""); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java index d12ab79e9..88bb98171 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * Copyright (c) 2019-2024 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 @@ -72,8 +72,10 @@ public interface GeyserConfiguration { boolean isDebugMode(); + @Deprecated boolean isAllowThirdPartyCapes(); + @Deprecated boolean isAllowThirdPartyEars(); String getShowCooldown(); diff --git a/core/src/main/java/org/geysermc/geyser/pack/SkullResourcePackManager.java b/core/src/main/java/org/geysermc/geyser/pack/SkullResourcePackManager.java index f6c5140d8..12785a4a7 100644 --- a/core/src/main/java/org/geysermc/geyser/pack/SkullResourcePackManager.java +++ b/core/src/main/java/org/geysermc/geyser/pack/SkullResourcePackManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * Copyright (c) 2019-2024 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 @@ -116,7 +116,7 @@ public class SkullResourcePackManager { return; } - BufferedImage image = SkinProvider.requestImage(skinUrl, null); + BufferedImage image = SkinProvider.requestImage(skinUrl, false); // Resize skins to 48x16 to save on space and memory BufferedImage skullTexture = new BufferedImage(48, 16, image.getType()); // Reorder skin parts to fit into the space diff --git a/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java index c7bd235b2..4c7247b10 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * Copyright (c) 2019-2024 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 @@ -36,6 +36,10 @@ import lombok.Getter; import lombok.Setter; import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.skin.Cape; +import org.geysermc.geyser.api.skin.Skin; +import org.geysermc.geyser.api.skin.SkinData; +import org.geysermc.geyser.api.skin.SkinGeometry; import org.geysermc.geyser.entity.type.LivingEntity; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; @@ -53,27 +57,27 @@ 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() + 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); + public SkinData load(@NonNull FakeHeadEntry fakeHeadEntry) throws Exception { + SkinData skinData = SkinProvider.getOrDefault(SkinProvider.requestSkinData(fakeHeadEntry.getEntity(), fakeHeadEntry.getSession()), 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().geometryName().equals("{\"geometry\" :{\"default\" :\"geometry.humanoid.customSlim\"}}") + Skin skin = skinData.skin(); + Cape cape = skinData.cape(); + SkinGeometry geometry = skinData.geometry().geometryName().equals("{\"geometry\" :{\"default\" :\"geometry.humanoid.customSlim\"}}") ? SkinProvider.WEARING_CUSTOM_SKULL_SLIM : SkinProvider.WEARING_CUSTOM_SKULL; - SkinProvider.Skin headSkin = SkinProvider.getOrDefault( + 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); + BufferedImage originalSkinImage = SkinProvider.imageDataToBufferedImage(skin.skinData(), 64, skin.skinData().length / 4 / 64); + BufferedImage headSkinImage = SkinProvider.imageDataToBufferedImage(headSkin.skinData(), 64, headSkin.skinData().length / 4 / 64); Graphics2D graphics2D = originalSkinImage.createGraphics(); graphics2D.setComposite(AlphaComposite.Clear); @@ -84,14 +88,15 @@ public class FakeHeadProvider { // 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(); + String skinKey = "customPlayerHead_" + fakeHeadEntry.getFakeHeadSkinUrl() + "_" + skin.textureUrl(); byte[] targetSkinData = SkinProvider.bufferedImageToImageData(originalSkinImage); - SkinProvider.Skin mergedSkin = new SkinProvider.Skin(fakeHeadEntry.getEntity().getUuid(), skinKey, targetSkinData, System.currentTimeMillis(), false, false); + Skin mergedSkin = new Skin(skinKey, targetSkinData); // Avoiding memory leak fakeHeadEntry.setEntity(null); + fakeHeadEntry.setSession(null); - return new SkinProvider.SkinData(mergedSkin, cape, geometry); + return new SkinData(mergedSkin, cape, geometry); } }); @@ -136,7 +141,7 @@ public class FakeHeadProvider { String texturesProperty = entity.getTexturesProperty(); SkinProvider.getExecutorService().execute(() -> { try { - SkinProvider.SkinData mergedSkinData = MERGED_SKINS_LOADING_CACHE.get(new FakeHeadEntry(texturesProperty, fakeHeadSkinUrl, entity)); + SkinData mergedSkinData = MERGED_SKINS_LOADING_CACHE.get(new FakeHeadEntry(texturesProperty, fakeHeadSkinUrl, entity, session)); SkinManager.sendSkinPacket(session, entity, mergedSkinData); } catch (ExecutionException e) { GeyserImpl.getInstance().getLogger().error("Couldn't merge skin of " + entity.getUsername() + " with head skin url " + fakeHeadSkinUrl, e); @@ -153,7 +158,7 @@ public class FakeHeadProvider { return; } - SkinProvider.requestSkinData(entity).whenCompleteAsync((skinData, throwable) -> { + SkinProvider.requestSkinData(entity, session).whenCompleteAsync((skinData, throwable) -> { if (throwable != null) { GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), throwable); return; @@ -170,6 +175,7 @@ public class FakeHeadProvider { private final String texturesProperty; private final String fakeHeadSkinUrl; private PlayerEntity entity; + private GeyserSession session; @Override public boolean equals(Object o) { diff --git a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java index 58c8f0072..ba74fbb42 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java +++ b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * Copyright (c) 2019-2024 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 @@ -26,6 +26,7 @@ package org.geysermc.geyser.skin; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.skin.Skin; import org.geysermc.geyser.util.AssetUtils; import javax.imageio.ImageIO; @@ -67,7 +68,7 @@ public final class ProvidedSkins { } public static final class ProvidedSkin { - private SkinProvider.Skin data; + private Skin data; private final boolean slim; ProvidedSkin(String asset, boolean slim) { @@ -94,14 +95,14 @@ public final class ProvidedSkins { image.flush(); String identifier = "geysermc:" + assetName + "_" + (slim ? "slim" : "wide"); - this.data = new SkinProvider.Skin(-1, identifier, byteData); + this.data = new Skin(identifier, byteData, true); } catch (IOException e) { e.printStackTrace(); } })); } - public SkinProvider.Skin getData() { + public Skin getData() { // Fall back to the default skin if we can't load our skins, or it's not loaded yet. return Objects.requireNonNullElse(data, SkinProvider.EMPTY_SKIN); } 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 33a51fe8f..49bbf9e99 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * Copyright (c) 2019-2024 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 @@ -35,6 +35,10 @@ import org.cloudburstmc.protocol.bedrock.data.skin.SerializedSkin; import org.cloudburstmc.protocol.bedrock.packet.PlayerListPacket; import org.cloudburstmc.protocol.bedrock.packet.PlayerSkinPacket; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.skin.Cape; +import org.geysermc.geyser.api.skin.Skin; +import org.geysermc.geyser.api.skin.SkinData; +import org.geysermc.geyser.api.skin.SkinGeometry; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.entity.type.player.SkullPlayerEntity; import org.geysermc.geyser.session.GeyserSession; @@ -56,21 +60,21 @@ public class SkinManager { public static PlayerListPacket.Entry buildCachedEntry(GeyserSession session, PlayerEntity playerEntity) { // First: see if we have the cached skin texture ID. GameProfileData data = GameProfileData.from(playerEntity); - SkinProvider.Skin skin = null; - SkinProvider.Cape cape = null; - SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.WIDE; + Skin skin = null; + Cape cape = null; + SkinGeometry geometry = SkinGeometry.WIDE; if (data != null) { // GameProfileData is not null = server provided us with textures data to work with. skin = SkinProvider.getCachedSkin(data.skinUrl()); cape = SkinProvider.getCachedCape(data.capeUrl()); - geometry = data.isAlex() ? SkinProvider.SkinGeometry.SLIM : SkinProvider.SkinGeometry.WIDE; + geometry = data.isAlex() ? SkinGeometry.SLIM : SkinGeometry.WIDE; } if (skin == null || cape == null) { // The server either didn't have a texture to send, or we didn't have the texture ID cached. // Let's see if this player is a Bedrock player, and if so, let's pull their skin. // Otherwise, grab the default player skin - SkinProvider.SkinData fallbackSkinData = SkinProvider.determineFallbackSkinData(playerEntity.getUuid()); + SkinData fallbackSkinData = SkinProvider.determineFallbackSkinData(playerEntity.getUuid()); if (skin == null) { skin = fallbackSkinData.skin(); geometry = fallbackSkinData.geometry(); @@ -95,10 +99,10 @@ public class SkinManager { * With all the information needed, build a Bedrock player entry with translated skin information. */ public static PlayerListPacket.Entry buildEntryManually(GeyserSession session, UUID uuid, String username, long geyserId, - SkinProvider.Skin skin, - SkinProvider.Cape cape, - SkinProvider.SkinGeometry geometry) { - SerializedSkin serializedSkin = getSkin(skin.getTextureUrl(), skin, cape, geometry); + Skin skin, + Cape cape, + SkinGeometry geometry) { + SerializedSkin serializedSkin = getSkin(skin.textureUrl(), skin, cape, geometry); // This attempts to find the XUID of the player so profile images show up for Xbox accounts String xuid = ""; @@ -128,10 +132,10 @@ public class SkinManager { return entry; } - public static void sendSkinPacket(GeyserSession session, PlayerEntity entity, SkinProvider.SkinData skinData) { - SkinProvider.Skin skin = skinData.skin(); - SkinProvider.Cape cape = skinData.cape(); - SkinProvider.SkinGeometry geometry = skinData.geometry(); + public static void sendSkinPacket(GeyserSession session, PlayerEntity entity, SkinData skinData) { + Skin skin = skinData.skin(); + Cape cape = skinData.cape(); + SkinGeometry geometry = skinData.geometry(); if (entity.getUuid().equals(session.getPlayerEntity().getUuid())) { // TODO is this special behavior needed? @@ -153,23 +157,23 @@ public class SkinManager { PlayerSkinPacket packet = new PlayerSkinPacket(); packet.setUuid(entity.getUuid()); packet.setOldSkinName(""); - packet.setNewSkinName(skin.getTextureUrl()); - packet.setSkin(getSkin(skin.getTextureUrl(), skin, cape, geometry)); + packet.setNewSkinName(skin.textureUrl()); + packet.setSkin(getSkin(skin.textureUrl(), skin, cape, geometry)); packet.setTrustedSkin(true); session.sendUpstreamPacket(packet); } } - private static SerializedSkin getSkin(String skinId, SkinProvider.Skin skin, SkinProvider.Cape cape, SkinProvider.SkinGeometry geometry) { + private static SerializedSkin getSkin(String skinId, Skin skin, Cape cape, SkinGeometry geometry) { return SerializedSkin.of(skinId, "", geometry.geometryName(), - ImageData.of(skin.getSkinData()), Collections.emptyList(), + ImageData.of(skin.skinData()), Collections.emptyList(), ImageData.of(cape.capeData()), geometry.geometryData(), "", true, false, false, cape.capeId(), skinId); } public static void requestAndHandleSkinAndCape(PlayerEntity entity, GeyserSession session, Consumer skinAndCapeConsumer) { - SkinProvider.requestSkinData(entity).whenCompleteAsync((skinData, throwable) -> { + SkinProvider.requestSkinData(entity, session).whenCompleteAsync((skinData, throwable) -> { if (skinData == null) { if (skinAndCapeConsumer != null) { skinAndCapeConsumer.accept(null); 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 683712c22..587779c43 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java @@ -35,7 +35,12 @@ import lombok.NoArgsConstructor; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.event.bedrock.SessionSkinApplyEvent; import org.geysermc.geyser.api.network.AuthType; +import org.geysermc.geyser.api.skin.Cape; +import org.geysermc.geyser.api.skin.Skin; +import org.geysermc.geyser.api.skin.SkinData; +import org.geysermc.geyser.api.skin.SkinGeometry; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.text.GeyserLocale; @@ -45,7 +50,6 @@ import org.geysermc.geyser.util.WebUtils; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; @@ -57,11 +61,10 @@ import java.util.concurrent.*; import java.util.function.Predicate; public class SkinProvider { - private static final boolean ALLOW_THIRD_PARTY_CAPES = GeyserImpl.getInstance().getConfig().isAllowThirdPartyCapes(); private static ExecutorService EXECUTOR_SERVICE; static final Skin EMPTY_SKIN; - static final Cape EMPTY_CAPE = new Cape("", "no-cape", ByteArrays.EMPTY_ARRAY, -1, true); + static final Cape EMPTY_CAPE = new Cape("", "no-cape", ByteArrays.EMPTY_ARRAY, true); private static final Cache CACHED_JAVA_CAPES = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.HOURS) @@ -88,9 +91,6 @@ public class SkinProvider { */ private static final Predicate IS_NPC = uuid -> uuid.version() == 2; - private static final boolean ALLOW_THIRD_PARTY_EARS = GeyserImpl.getInstance().getConfig().isAllowThirdPartyEars(); - private static final String EARS_GEOMETRY; - private static final String EARS_GEOMETRY_SLIM; static final SkinGeometry SKULL_GEOMETRY; static final SkinGeometry WEARING_CUSTOM_SKULL; static final SkinGeometry WEARING_CUSTOM_SKULL_SLIM; @@ -114,28 +114,27 @@ public class SkinProvider { outputStream.write((rgba >> 24) & 0xFF); // Alpha } } - EMPTY_SKIN = new Skin(-1, "geysermc:empty", outputStream.toByteArray()); - - /* Load in the normal ears geometry */ - EARS_GEOMETRY = new String(FileUtils.readAllBytes("bedrock/skin/geometry.humanoid.ears.json"), StandardCharsets.UTF_8); - - /* Load in the slim ears geometry */ - EARS_GEOMETRY_SLIM = new String(FileUtils.readAllBytes("bedrock/skin/geometry.humanoid.earsSlim.json"), StandardCharsets.UTF_8); + EMPTY_SKIN = new Skin("geysermc:empty", outputStream.toByteArray(), true); /* Load in the custom skull geometry */ String skullData = new String(FileUtils.readAllBytes("bedrock/skin/geometry.humanoid.customskull.json"), StandardCharsets.UTF_8); - SKULL_GEOMETRY = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.customskull\"}}", skullData, false); + SKULL_GEOMETRY = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.customskull\"}}", skullData); /* Load in the player head skull geometry */ String wearingCustomSkull = new String(FileUtils.readAllBytes("bedrock/skin/geometry.humanoid.wearingCustomSkull.json"), StandardCharsets.UTF_8); - WEARING_CUSTOM_SKULL = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.wearingCustomSkull\"}}", wearingCustomSkull, false); + WEARING_CUSTOM_SKULL = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.wearingCustomSkull\"}}", wearingCustomSkull); String wearingCustomSkullSlim = new String(FileUtils.readAllBytes("bedrock/skin/geometry.humanoid.wearingCustomSkullSlim.json"), StandardCharsets.UTF_8); - WEARING_CUSTOM_SKULL_SLIM = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.wearingCustomSkullSlim\"}}", wearingCustomSkullSlim, false); + WEARING_CUSTOM_SKULL_SLIM = new SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.wearingCustomSkullSlim\"}}", wearingCustomSkullSlim); + + GeyserImpl geyser = GeyserImpl.getInstance(); + if (geyser.getConfig().isAllowThirdPartyEars() || geyser.getConfig().isAllowThirdPartyCapes()) { + geyser.getLogger().warning("Third-party ears/capes have been removed from Geyser, if you still wish to have this functionality please use the extension: https://github.com/GeyserMC/ThirdPartyCosmetics"); + } } public static ExecutorService getExecutorService() { if (EXECUTOR_SERVICE == null) { - EXECUTOR_SERVICE = Executors.newFixedThreadPool(ALLOW_THIRD_PARTY_CAPES ? 21 : 14); + EXECUTOR_SERVICE = Executors.newFixedThreadPool(14); } return EXECUTOR_SERVICE; } @@ -204,7 +203,7 @@ public class SkinProvider { // We don't have a skin for the player right now. Fall back to a default. ProvidedSkins.ProvidedSkin providedSkin = ProvidedSkins.getDefaultPlayerSkin(uuid); skin = providedSkin.getData(); - geometry = providedSkin.isSlim() ? SkinProvider.SkinGeometry.SLIM : SkinProvider.SkinGeometry.WIDE; + geometry = providedSkin.isSlim() ? SkinGeometry.SLIM : SkinGeometry.WIDE; } if (cape == null) { @@ -238,7 +237,7 @@ public class SkinProvider { return CACHED_JAVA_CAPES.getIfPresent(capeUrl); } - static CompletableFuture requestSkinData(PlayerEntity entity) { + static CompletableFuture requestSkinData(PlayerEntity entity, GeyserSession session) { SkinManager.GameProfileData data = SkinManager.GameProfileData.from(entity); if (data == null) { // This player likely does not have a textures property @@ -260,42 +259,33 @@ public class SkinProvider { cape = getCachedBedrockCape(entity.getUuid()); } - if (cape.failed() && ALLOW_THIRD_PARTY_CAPES) { - cape = getOrDefault(requestUnofficialCape( - cape, entity.getUuid(), - entity.getUsername(), false - ), EMPTY_CAPE, CapeProvider.VALUES.length * 3); - } - - boolean isDeadmau5 = "deadmau5".equals(entity.getUsername()); - // Not a bedrock player check for ears - if (geometry.failed() && (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(); + // Call event to allow extensions to modify the skin, cape and geo + boolean isBedrock = GeyserImpl.getInstance().connectionByUuid(entity.getUuid()) != null; + SkinData skinData = new SkinData(skin, cape, geometry); + final EventSkinData eventSkinData = new EventSkinData(skinData); + GeyserImpl.getInstance().eventBus().fire(new SessionSkinApplyEvent(session, entity.getUsername(), entity.getUuid(), data.isAlex(), isBedrock, skinData) { + @Override + public SkinData skinData() { + return eventSkinData.skinData(); } - // 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()); + @Override + public void skin(@NonNull Skin newSkin) { + eventSkinData.skinData(new SkinData(newSkin, skinData.cape(), skinData.geometry())); } - } - return new SkinData(skin, cape, geometry); + @Override + public void cape(@NonNull Cape newCape) { + eventSkinData.skinData(new SkinData(skinData.skin(), newCape, skinData.geometry())); + } + + @Override + public void geometry(@NonNull SkinGeometry newGeometry) { + eventSkinData.skinData(new SkinData(skinData.skin(), skinData.cape(), newGeometry)); + } + }); + + return eventSkinData.skinData(); } catch (Exception e) { GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), e); } @@ -308,10 +298,9 @@ public class SkinProvider { return CompletableFuture.supplyAsync(() -> { long time = System.currentTimeMillis(); - CapeProvider provider = capeUrl != null ? CapeProvider.MINECRAFT : null; SkinAndCape skinAndCape = new SkinAndCape( getOrDefault(requestSkin(playerId, skinUrl, false), EMPTY_SKIN, 5), - getOrDefault(requestCape(capeUrl, provider, false), EMPTY_CAPE, 5) + getOrDefault(requestCape(capeUrl, false), EMPTY_CAPE, 5) ); GeyserImpl.getInstance().getLogger().debug("Took " + (System.currentTimeMillis() - time) + "ms for " + playerId); @@ -336,7 +325,6 @@ public class SkinProvider { if (newThread) { future = CompletableFuture.supplyAsync(() -> supplySkin(playerId, textureUrl), getExecutorService()) .whenCompleteAsync((skin, throwable) -> { - skin.updated = true; CACHED_JAVA_SKINS.put(textureUrl, skin); requestedSkins.remove(textureUrl); }); @@ -349,7 +337,7 @@ public class SkinProvider { return future; } - private static CompletableFuture requestCape(String capeUrl, CapeProvider provider, boolean newThread) { + private static CompletableFuture requestCape(String capeUrl, boolean newThread) { if (capeUrl == null || capeUrl.isEmpty()) return CompletableFuture.completedFuture(EMPTY_CAPE); CompletableFuture requestedCape = requestedCapes.get(capeUrl); if (requestedCape != null) { @@ -363,128 +351,48 @@ public class SkinProvider { CompletableFuture future; if (newThread) { - future = CompletableFuture.supplyAsync(() -> supplyCape(capeUrl, provider), getExecutorService()) + future = CompletableFuture.supplyAsync(() -> supplyCape(capeUrl), getExecutorService()) .whenCompleteAsync((cape, throwable) -> { CACHED_JAVA_CAPES.put(capeUrl, cape); requestedCapes.remove(capeUrl); }); requestedCapes.put(capeUrl, future); } else { - Cape cape = supplyCape(capeUrl, provider); // blocking + Cape cape = supplyCape(capeUrl); // blocking future = CompletableFuture.completedFuture(cape); CACHED_JAVA_CAPES.put(capeUrl, cape); } return future; } - private static CompletableFuture requestUnofficialCape(Cape officialCape, UUID playerId, - String username, boolean newThread) { - if (officialCape.failed() && ALLOW_THIRD_PARTY_CAPES) { - for (CapeProvider provider : CapeProvider.VALUES) { - if (provider.type != CapeUrlType.USERNAME && IS_NPC.test(playerId)) { - continue; - } - - Cape cape1 = getOrDefault( - requestCape(provider.getUrlFor(playerId, username), provider, newThread), - EMPTY_CAPE, 4 - ); - if (!cape1.failed()) { - return CompletableFuture.completedFuture(cape1); - } - } - } - return CompletableFuture.completedFuture(officialCape); - } - - private static CompletableFuture requestEars(String earsUrl, boolean newThread, Skin skin) { - if (earsUrl == null || earsUrl.isEmpty()) return CompletableFuture.completedFuture(skin); - - CompletableFuture future; - if (newThread) { - future = CompletableFuture.supplyAsync(() -> supplyEars(skin, earsUrl), getExecutorService()) - .whenCompleteAsync((outSkin, throwable) -> { }); - } else { - Skin ears = supplyEars(skin, earsUrl); // blocking - future = CompletableFuture.completedFuture(ears); - } - return future; - } - - /** - * Try and find an ear texture for a Java player - * - * @param officialSkin The current players skin - * @param playerId The players UUID - * @param username The players username - * @param newThread Should we start in a new thread - * @return The updated skin with ears - */ - private static CompletableFuture requestUnofficialEars(Skin officialSkin, UUID playerId, String username, boolean newThread) { - for (EarsProvider provider : EarsProvider.VALUES) { - if (provider.type != CapeUrlType.USERNAME && IS_NPC.test(playerId)) { - continue; - } - - Skin skin1 = getOrDefault( - requestEars(provider.getUrlFor(playerId, username), newThread, officialSkin), - officialSkin, 4 - ); - if (skin1.isEars()) { - return CompletableFuture.completedFuture(skin1); - } - } - - return CompletableFuture.completedFuture(officialSkin); - } - static void storeBedrockSkin(UUID playerID, String skinId, byte[] skinData) { - Skin skin = new Skin(playerID, skinId, skinData, System.currentTimeMillis(), true, false); - CACHED_BEDROCK_SKINS.put(skin.getTextureUrl(), skin); + Skin skin = new Skin(skinId, skinData); + CACHED_BEDROCK_SKINS.put(skin.textureUrl(), skin); } static void storeBedrockCape(String capeId, byte[] capeData) { - Cape cape = new Cape(capeId, capeId, capeData, System.currentTimeMillis(), false); + Cape cape = new Cape(capeId, capeId, capeData); CACHED_BEDROCK_CAPES.put(capeId, cape); } static void storeBedrockGeometry(UUID playerID, byte[] geometryName, byte[] geometryData) { - SkinGeometry geometry = new SkinGeometry(new String(geometryName), new String(geometryData), false); + SkinGeometry geometry = new SkinGeometry(new String(geometryName), new String(geometryData)); cachedGeometry.put(playerID, geometry); } - /** - * Stores the adjusted skin with the ear texture to the cache - * - * @param skin The skin to cache - */ - public static void storeEarSkin(Skin skin) { - CACHED_JAVA_SKINS.put(skin.getTextureUrl(), skin); - } - - /** - * Stores the geometry for a Java player with ears - * - * @param playerID The UUID to cache it against - * @param isSlim If the player is using an slim base - */ - private static void storeEarGeometry(UUID playerID, boolean isSlim) { - cachedGeometry.put(playerID, SkinGeometry.getEars(isSlim)); - } - private static Skin supplySkin(UUID uuid, String textureUrl) { try { - byte[] skin = requestImageData(textureUrl, null); - return new Skin(uuid, textureUrl, skin, System.currentTimeMillis(), false, false); + byte[] skin = requestImageData(textureUrl, false); + return new Skin(textureUrl, skin); } catch (Exception ignored) {} // just ignore I guess - return new Skin(uuid, "empty", EMPTY_SKIN.getSkinData(), System.currentTimeMillis(), false, false); + return new Skin("empty", EMPTY_SKIN.skinData(), true); } - private static Cape supplyCape(String capeUrl, CapeProvider provider) { + private static Cape supplyCape(String capeUrl) { byte[] cape = EMPTY_CAPE.capeData(); try { - cape = requestImageData(capeUrl, provider); + cape = requestImageData(capeUrl, true); } catch (Exception ignored) { } // just ignore I guess @@ -494,54 +402,12 @@ public class SkinProvider { capeUrl, urlSection[urlSection.length - 1], // get the texture id and use it as cape id cape, - System.currentTimeMillis(), cape.length == 0 ); } - /** - * Get the ears texture and place it on the skin from the given URL - * - * @param existingSkin The players current skin - * @param earsUrl The URL to get the ears texture from - * @return The updated skin with ears - */ - private static Skin supplyEars(Skin existingSkin, String earsUrl) { - try { - // Get the ears texture - BufferedImage ears = ImageIO.read(new URL(earsUrl)); - if (ears == null) throw new NullPointerException(); - - // Convert the skin data to a BufferedImage - int height = (existingSkin.getSkinData().length / 4 / 64); - BufferedImage skinImage = imageDataToBufferedImage(existingSkin.getSkinData(), 64, height); - - // Create a new image with the ears texture over it - BufferedImage newSkin = new BufferedImage(skinImage.getWidth(), skinImage.getHeight(), BufferedImage.TYPE_INT_ARGB); - Graphics2D g = (Graphics2D) newSkin.getGraphics(); - g.drawImage(skinImage, 0, 0, null); - g.drawImage(ears, 24, 0, null); - - // Turn the buffered image back into an array of bytes - byte[] data = bufferedImageToImageData(newSkin); - skinImage.flush(); - - // Create a new skin object with the new infomation - return new Skin( - existingSkin.getSkinOwner(), - existingSkin.getTextureUrl(), - data, - System.currentTimeMillis(), - true, - true - ); - } catch (Exception ignored) {} // just ignore I guess - - return existingSkin; - } - @SuppressWarnings("ResultOfMethodCallIgnored") - public static BufferedImage requestImage(String imageUrl, CapeProvider provider) throws IOException { + public static BufferedImage requestImage(String imageUrl, boolean isCape) throws IOException { BufferedImage image = null; // First see if we have a cached file. We also update the modification stamp so we know when the file was last used @@ -556,7 +422,7 @@ public class SkinProvider { // If no image we download it if (image == null) { - image = downloadImage(imageUrl, provider); + image = downloadImage(imageUrl); GeyserImpl.getInstance().getLogger().debug("Downloaded " + imageUrl); // Write to cache if we are allowed @@ -572,7 +438,7 @@ public class SkinProvider { } // if the requested image is a cape - if (provider != null) { + if (isCape) { if (image.getWidth() > 64 || image.getHeight() > 32) { // Prevent weirdly-scaled capes from being cut off BufferedImage newImage = new BufferedImage(128, 64, BufferedImage.TYPE_INT_ARGB); @@ -604,8 +470,8 @@ public class SkinProvider { return image; } - private static byte[] requestImageData(String imageUrl, CapeProvider provider) throws Exception { - BufferedImage image = requestImage(imageUrl, provider); + private static byte[] requestImageData(String imageUrl, boolean isCape) throws Exception { + BufferedImage image = requestImage(imageUrl, isCape); byte[] data = bufferedImageToImageData(image); image.flush(); return data; @@ -668,35 +534,20 @@ public class SkinProvider { }); } - private static BufferedImage downloadImage(String imageUrl, CapeProvider provider) throws IOException { - BufferedImage image; - if (provider == CapeProvider.FIVEZIG) { - image = readFiveZigCape(imageUrl); - } else { - HttpURLConnection con = (HttpURLConnection) new URL(imageUrl).openConnection(); - con.setRequestProperty("User-Agent", WebUtils.getUserAgent()); - con.setConnectTimeout(10000); - con.setReadTimeout(10000); + private static BufferedImage downloadImage(String imageUrl) throws IOException { + HttpURLConnection con = (HttpURLConnection) new URL(imageUrl).openConnection(); + con.setRequestProperty("User-Agent", WebUtils.getUserAgent()); + con.setConnectTimeout(10000); + con.setReadTimeout(10000); - image = ImageIO.read(con.getInputStream()); - } + BufferedImage image = ImageIO.read(con.getInputStream()); if (image == null) { - throw new IllegalArgumentException("Failed to read image from: %s (cape provider=%s)".formatted(imageUrl, provider)); + throw new IllegalArgumentException("Failed to read image from: %s".formatted(imageUrl)); } return image; } - private static @Nullable BufferedImage readFiveZigCape(String url) throws IOException { - JsonNode element = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(url)); - if (element != null && element.isObject()) { - JsonNode capeElement = element.get("d"); - if (capeElement == null || capeElement.isNull()) return null; - return ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(capeElement.textValue()))); - } - return null; - } - public static BufferedImage scale(BufferedImage bufferedImage, int newWidth, int newHeight) { BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D g2 = resized.createGraphics(); @@ -770,124 +621,19 @@ public class SkinProvider { public record SkinAndCape(Skin skin, Cape cape) { } - /** - * Represents a full package of skin, cape, and geometry. - */ - public record SkinData(Skin skin, Cape cape, SkinGeometry geometry) { - } + public static class EventSkinData { + private SkinData skinData; - @AllArgsConstructor - @Getter - public static class Skin { - private UUID skinOwner; - private final String textureUrl; - private final byte[] skinData; - private final long requestedOn; - private boolean updated; - private boolean ears; + public EventSkinData(SkinData skinData) { + this.skinData = skinData; + } - Skin(long requestedOn, String textureUrl, byte[] skinData) { - this.requestedOn = requestedOn; - this.textureUrl = textureUrl; + public SkinData skinData() { + return skinData; + } + + public void skinData(SkinData skinData) { this.skinData = skinData; } } - - public record Cape(String textureUrl, String capeId, byte[] capeData, long requestedOn, boolean failed) { - } - - public record SkinGeometry(String geometryName, String geometryData, boolean failed) { - public static SkinGeometry WIDE = getLegacy(false); - public static SkinGeometry SLIM = getLegacy(true); - - /** - * Generate generic geometry - * - * @param isSlim Should it be the alex model - * @return The generic geometry object - */ - private static SkinGeometry getLegacy(boolean isSlim) { - return new SkinProvider.SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.custom" + (isSlim ? "Slim" : "") + "\"}}", "", true); - } - - /** - * Generate basic geometry with ears - * - * @param isSlim Should it be the alex model - * @return The generated geometry for the ears model - */ - private static SkinGeometry getEars(boolean isSlim) { - return new SkinProvider.SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.ears" + (isSlim ? "Slim" : "") + "\"}}", (isSlim ? EARS_GEOMETRY_SLIM : EARS_GEOMETRY), false); - } - } - - /* - * Sorted by 'priority' - */ - @AllArgsConstructor - @NoArgsConstructor - @Getter - public enum CapeProvider { - MINECRAFT, - OPTIFINE("https://optifine.net/capes/%s.png", CapeUrlType.USERNAME), - LABYMOD("https://dl.labymod.net/capes/%s", CapeUrlType.UUID_DASHED), - FIVEZIG("https://textures.5zigreborn.eu/profile/%s", CapeUrlType.UUID_DASHED), - MINECRAFTCAPES("https://api.minecraftcapes.net/profile/%s/cape", CapeUrlType.UUID); - - public static final CapeProvider[] VALUES = Arrays.copyOfRange(values(), 1, 5); - private String url; - private CapeUrlType type; - - public String getUrlFor(String type) { - return String.format(url, type); - } - - public String getUrlFor(UUID uuid, String username) { - return getUrlFor(toRequestedType(type, uuid, username)); - } - - public static String toRequestedType(CapeUrlType type, UUID uuid, String username) { - return switch (type) { - case UUID -> uuid.toString().replace("-", ""); - case UUID_DASHED -> uuid.toString(); - default -> username; - }; - } - } - - public enum CapeUrlType { - USERNAME, - UUID, - UUID_DASHED - } - - /* - * Sorted by 'priority' - */ - @AllArgsConstructor - @NoArgsConstructor - @Getter - public enum EarsProvider { - MINECRAFTCAPES("https://api.minecraftcapes.net/profile/%s/ears", CapeUrlType.UUID); - - public static final EarsProvider[] VALUES = values(); - private String url; - private CapeUrlType type; - - public String getUrlFor(String type) { - return String.format(url, type); - } - - public String getUrlFor(UUID uuid, String username) { - return getUrlFor(toRequestedType(type, uuid, username)); - } - - public static String toRequestedType(CapeUrlType type, UUID uuid, String username) { - return switch (type) { - case UUID -> uuid.toString().replace("-", ""); - case UUID_DASHED -> uuid.toString(); - default -> username; - }; - } - } } diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java b/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java index 18edc8079..e3f00d3b7 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * Copyright (c) 2019-2024 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 @@ -29,6 +29,8 @@ import org.cloudburstmc.protocol.bedrock.data.skin.ImageData; import org.cloudburstmc.protocol.bedrock.data.skin.SerializedSkin; import org.cloudburstmc.protocol.bedrock.packet.PlayerSkinPacket; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.skin.Skin; +import org.geysermc.geyser.api.skin.SkinData; import org.geysermc.geyser.entity.type.player.SkullPlayerEntity; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.text.GeyserLocale; @@ -50,14 +52,14 @@ public class SkullSkinManager extends SkinManager { } public static void requestAndHandleSkin(SkullPlayerEntity entity, GeyserSession session, - Consumer skinConsumer) { - BiConsumer applySkin = (skin, throwable) -> { + Consumer skinConsumer) { + BiConsumer applySkin = (skin, throwable) -> { try { PlayerSkinPacket packet = new PlayerSkinPacket(); packet.setUuid(entity.getUuid()); packet.setOldSkinName(""); - packet.setNewSkinName(skin.getTextureUrl()); - packet.setSkin(buildSkullEntryManually(skin.getTextureUrl(), skin.getSkinData())); + packet.setNewSkinName(skin.textureUrl()); + packet.setSkin(buildSkullEntryManually(skin.textureUrl(), skin.skinData())); packet.setTrustedSkin(true); session.sendUpstreamPacket(packet); } catch (Exception e) { @@ -74,7 +76,7 @@ public class SkullSkinManager extends SkinManager { GeyserImpl.getInstance().getLogger().debug("Using fallback skin for skull at " + entity.getSkullPosition() + " with texture value: " + entity.getTexturesProperty() + " and UUID: " + entity.getSkullUUID()); // No texture available, fallback using the UUID - SkinProvider.SkinData fallback = SkinProvider.determineFallbackSkinData(entity.getSkullUUID()); + SkinData fallback = SkinProvider.determineFallbackSkinData(entity.getSkullUUID()); applySkin.accept(fallback.skin(), null); } else { SkinProvider.requestSkin(entity.getUuid(), data.skinUrl(), true) diff --git a/core/src/main/resources/bedrock/skin/geometry.humanoid.ears.json b/core/src/main/resources/bedrock/skin/geometry.humanoid.ears.json deleted file mode 100644 index 5571655b8..000000000 --- a/core/src/main/resources/bedrock/skin/geometry.humanoid.ears.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "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.0, -4.0 ], - "size": [ 8, 8, 8 ], - "uv": [ 0, 0 ] - } - ] - }, - - { - "name": "hat", - "parent" : "head", - "pivot": [ 0.0, 24.0, 0.0 ], - "rotation" : [ 0.0, 0.0, 0.0 ], - "cubes": [ - { - "origin": [ -4.0, 24.0, -4.0 ], - "size": [ 8, 8, 8 ], - "uv": [ 32, 0 ], - "inflate": 0.5 - } - ] - }, - - - { - "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 ] - } - ] - }, - - { - "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" : [] - }, - - - { - "name": "leftEar", - "parent" : "head", - "pivot": [ -1.9, 12.0, 0.0 ], - "cubes": [ - { - "origin": [ 3.0, 31.0, -0.5 ], - "size": [ 6, 6, 1 ], - "uv": [ 24, 0 ], - "inflate": 0.5 - } - ] - }, - - { - "name": "rightEar", - "parent" : "head", - "pivot": [ -1.9, 12.0, 0.0 ], - "cubes": [ - { - "origin": [ -9.0, 31.0, -0.5 ], - "size": [ 6, 6, 1 ], - "uv": [ 24, 0 ], - "inflate": 0.5 - } - ] - } - ], - "description": { - "identifier": "geometry.humanoid.ears", - "texture_height": 64, - "texture_width": 64 - } - } - ] -} \ No newline at end of file diff --git a/core/src/main/resources/bedrock/skin/geometry.humanoid.earsSlim.json b/core/src/main/resources/bedrock/skin/geometry.humanoid.earsSlim.json deleted file mode 100644 index 70c44f854..000000000 --- a/core/src/main/resources/bedrock/skin/geometry.humanoid.earsSlim.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "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.0, -4.0 ], - "size": [ 8, 8, 8 ], - "uv": [ 0, 0 ] - } - ] - }, - - { - "name": "hat", - "parent" : "head", - "pivot": [ 0.0, 24.0, 0.0 ], - "rotation" : [ 0.0, 0.0, 0.0 ], - "cubes": [ - { - "origin": [ -4.0, 24.0, -4.0 ], - "size": [ 8, 8, 8 ], - "uv": [ 32, 0 ], - "inflate": 0.5 - } - ] - }, - - - { - "name": "leftArm", - "parent" : "body", - "pivot": [ 5.0, 21.5, 0.0 ], - "rotation" : [ 0.0, 0.0, 0.0 ], - "cubes": [ - { - "origin": [ 4.0, 12, -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, 12, -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 ] - } - ] - }, - - { - "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" : [] - }, - - - { - "name": "leftEar", - "parent" : "head", - "pivot": [ -1.9, 12.0, 0.0 ], - "cubes": [ - { - "origin": [ 3.0, 31.0, -0.5 ], - "size": [ 6, 6, 1 ], - "uv": [ 24, 0 ], - "inflate": 0.5 - } - ] - }, - - { - "name": "rightEar", - "parent" : "head", - "pivot": [ -1.9, 12.0, 0.0 ], - "cubes": [ - { - "origin": [ -9.0, 31.0, -0.5 ], - "size": [ 6, 6, 1 ], - "uv": [ 24, 0 ], - "inflate": 0.5 - } - ] - } - ], - "description": { - "identifier": "geometry.humanoid.earsSlim", - "texture_height": 64, - "texture_width": 64 - } - } - ] -} \ No newline at end of file diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index c70b0d7ab..fc0cd83f1 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -113,14 +113,6 @@ max-players: 100 # If debug messages should be sent through console debug-mode: false -# Allow third party capes to be visible. Currently allowing: -# OptiFine capes, LabyMod capes, 5Zig capes and MinecraftCapes -allow-third-party-capes: false - -# Allow third party deadmau5 ears to be visible. Currently allowing: -# MinecraftCapes -allow-third-party-ears: false - # Allow a fake cooldown indicator to be sent. Bedrock players otherwise do not see a cooldown as they still use 1.8 combat. # Please note: if the cooldown is enabled, some users may see a black box during the cooldown sequence, like below: # https://cdn.discordapp.com/attachments/613170125696270357/957075682230419466/Screenshot_from_2022-03-25_20-35-08.png diff --git a/gradle.properties b/gradle.properties index a7c0bf93d..962cae5c1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,5 +7,5 @@ org.gradle.vfs.watch=false group=org.geysermc id=geyser -version=2.2.3-SNAPSHOT +version=2.2.4-SNAPSHOT description=Allows for players from Minecraft: Bedrock Edition to join Minecraft: Java Edition servers.