From 09f17f59ae91de2ee91c7658d8553c4ed53bb19e Mon Sep 17 00:00:00 2001 From: Camotoy <20743703+Camotoy@users.noreply.github.com> Date: Sun, 18 Dec 2022 13:18:06 -0500 Subject: [PATCH] Support new default Java skins from 1.19.3 --- .../java/org/geysermc/geyser/Constants.java | 1 - .../java/org/geysermc/geyser/GeyserImpl.java | 20 +- .../geyser/skin/FakeHeadProvider.java | 2 +- .../geysermc/geyser/skin/ProvidedSkin.java | 63 ---- .../geysermc/geyser/skin/ProvidedSkins.java | 128 +++++++ .../org/geysermc/geyser/skin/SkinManager.java | 71 ++-- .../geysermc/geyser/skin/SkinProvider.java | 279 +++++++++------ .../geyser/skin/SkullSkinManager.java | 6 +- .../geysermc/geyser/text/MinecraftLocale.java | 279 ++------------- .../JavaPlayerInfoUpdateTranslator.java | 4 - .../org/geysermc/geyser/util/AssetUtils.java | 329 ++++++++++++++++++ .../main/resources/bedrock/skin/skin_alex.png | Bin 2105 -> 0 bytes .../resources/bedrock/skin/skin_steve.png | Bin 2162 -> 0 bytes core/src/main/resources/config.yml | 2 +- 14 files changed, 704 insertions(+), 480 deletions(-) delete mode 100644 core/src/main/java/org/geysermc/geyser/skin/ProvidedSkin.java create mode 100644 core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java create mode 100644 core/src/main/java/org/geysermc/geyser/util/AssetUtils.java delete mode 100644 core/src/main/resources/bedrock/skin/skin_alex.png delete mode 100644 core/src/main/resources/bedrock/skin/skin_steve.png diff --git a/core/src/main/java/org/geysermc/geyser/Constants.java b/core/src/main/java/org/geysermc/geyser/Constants.java index 6a53c37de..46a1cb2ec 100644 --- a/core/src/main/java/org/geysermc/geyser/Constants.java +++ b/core/src/main/java/org/geysermc/geyser/Constants.java @@ -30,7 +30,6 @@ import java.net.URISyntaxException; public final class Constants { public static final URI GLOBAL_API_WS_URI; - public static final String NTP_SERVER = "time.cloudflare.com"; public static final String NEWS_OVERVIEW_URL = "https://api.geysermc.org/v2/news/"; public static final String NEWS_PROJECT_NAME = "geyser"; diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index a10e54f90..91e8e4c52 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -79,6 +79,7 @@ import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.PendingMicrosoftAuthentication; import org.geysermc.geyser.session.SessionManager; import org.geysermc.geyser.skin.FloodgateSkinUploader; +import org.geysermc.geyser.skin.ProvidedSkins; import org.geysermc.geyser.skin.SkinProvider; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.text.MinecraftLocale; @@ -95,6 +96,7 @@ import java.net.UnknownHostException; import java.security.Key; import java.text.DecimalFormat; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -195,7 +197,23 @@ public class GeyserImpl implements GeyserApi { EntityDefinitions.init(); ItemTranslator.init(); MessageTranslator.init(); - MinecraftLocale.init(); + + // Download the latest asset list and cache it + AssetUtils.generateAssetCache().whenComplete((aVoid, ex) -> { + if (ex != null) { + return; + } + MinecraftLocale.ensureEN_US(); + String locale = GeyserLocale.getDefaultLocale(); + if (!"en_us".equals(locale)) { + // English will be loaded after assets are downloaded, if necessary + MinecraftLocale.downloadAndLoadLocale(locale); + } + + ProvidedSkins.init(); + + CompletableFuture.runAsync(AssetUtils::downloadAndRunClientJarTasks); + }); startInstance(); diff --git a/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java index 464f53d48..b312f9811 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/FakeHeadProvider.java @@ -63,7 +63,7 @@ public class FakeHeadProvider { SkinProvider.Skin skin = skinData.skin(); SkinProvider.Cape cape = skinData.cape(); - SkinProvider.SkinGeometry geometry = skinData.geometry().getGeometryName().equals("{\"geometry\" :{\"default\" :\"geometry.humanoid.customSlim\"}}") + SkinProvider.SkinGeometry geometry = skinData.geometry().geometryName().equals("{\"geometry\" :{\"default\" :\"geometry.humanoid.customSlim\"}}") ? SkinProvider.WEARING_CUSTOM_SKULL_SLIM : SkinProvider.WEARING_CUSTOM_SKULL; SkinProvider.Skin headSkin = SkinProvider.getOrDefault( diff --git a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkin.java b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkin.java deleted file mode 100644 index bb638556d..000000000 --- a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkin.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - * - * @author GeyserMC - * @link https://github.com/GeyserMC/Geyser - */ - -package org.geysermc.geyser.skin; - -import lombok.Getter; -import org.geysermc.geyser.GeyserImpl; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; - -public class ProvidedSkin { - @Getter private byte[] skin; - - public ProvidedSkin(String internalUrl) { - try { - BufferedImage image; - try (InputStream stream = GeyserImpl.getInstance().getBootstrap().getResource(internalUrl)) { - image = ImageIO.read(stream); - } - - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(image.getWidth() * 4 + image.getHeight() * 4); - for (int y = 0; y < image.getHeight(); y++) { - for (int x = 0; x < image.getWidth(); x++) { - int rgba = image.getRGB(x, y); - outputStream.write((rgba >> 16) & 0xFF); // Red - outputStream.write((rgba >> 8) & 0xFF); // Green - outputStream.write(rgba & 0xFF); // Blue - outputStream.write((rgba >> 24) & 0xFF); // Alpha - } - } - image.flush(); - skin = outputStream.toByteArray(); - } catch (IOException e) { - e.printStackTrace(); - } - } -} diff --git a/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java new file mode 100644 index 000000000..999df0929 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/skin/ProvidedSkins.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.skin; + +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.util.AssetUtils; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Objects; +import java.util.UUID; + +public final class ProvidedSkins { + private static final ProvidedSkin[] PROVIDED_SKINS = { + new ProvidedSkin("textures/entity/player/slim/alex.png", true), + new ProvidedSkin("textures/entity/player/slim/ari.png", true), + new ProvidedSkin("textures/entity/player/slim/efe.png", true), + new ProvidedSkin("textures/entity/player/slim/kai.png", true), + new ProvidedSkin("textures/entity/player/slim/makena.png", true), + new ProvidedSkin("textures/entity/player/slim/noor.png", true), + new ProvidedSkin("textures/entity/player/slim/steve.png", true), + new ProvidedSkin("textures/entity/player/slim/sunny.png", true), + new ProvidedSkin("textures/entity/player/slim/zuri.png", true), + new ProvidedSkin("textures/entity/player/wide/alex.png", false), + new ProvidedSkin("textures/entity/player/wide/ari.png", false), + new ProvidedSkin("textures/entity/player/wide/efe.png", false), + new ProvidedSkin("textures/entity/player/wide/kai.png", false), + new ProvidedSkin("textures/entity/player/wide/makena.png", false), + new ProvidedSkin("textures/entity/player/wide/noor.png", false), + new ProvidedSkin("textures/entity/player/wide/steve.png", false), + new ProvidedSkin("textures/entity/player/wide/sunny.png", false), + new ProvidedSkin("textures/entity/player/wide/zuri.png", false) + }; + + public static ProvidedSkin getDefaultPlayerSkin(UUID uuid) { + return PROVIDED_SKINS[Math.floorMod(uuid.hashCode(), PROVIDED_SKINS.length)]; + } + + private ProvidedSkins() { + } + + public static final class ProvidedSkin { + private SkinProvider.Skin data; + private final boolean slim; + + ProvidedSkin(String asset, boolean slim) { + this.slim = slim; + + Path folder = GeyserImpl.getInstance().getBootstrap().getConfigFolder() + .resolve("cache") + .resolve("default_player_skins") + .resolve(slim ? "slim" : "wide"); + String assetName = asset.substring(asset.lastIndexOf('/') + 1); + + File location = folder.resolve(assetName).toFile(); + AssetUtils.addTask(!location.exists(), new AssetUtils.ClientJarTask("assets/minecraft/" + asset, + (stream) -> AssetUtils.saveFile(location, stream), + () -> { + try { + // TODO lazy initialize? + BufferedImage image; + try (InputStream stream = new FileInputStream(location)) { + image = ImageIO.read(stream); + } + + byte[] byteData = SkinProvider.bufferedImageToImageData(image); + image.flush(); + + String identifier = "geysermc:" + assetName + "_" + (slim ? "slim" : "wide"); + this.data = new SkinProvider.Skin(-1, identifier, byteData); + } catch (IOException e) { + e.printStackTrace(); + } + })); + } + + public SkinProvider.Skin getData() { + // Fall back to the default skin if we can't load our skins, or it's not loaded yet. + return Objects.requireNonNullElse(data, SkinProvider.EMPTY_SKIN); + } + + public boolean isSlim() { + return slim; + } + } + + public static void init() { + // no-op + } + + static { + Path folder = GeyserImpl.getInstance().getBootstrap().getConfigFolder() + .resolve("cache") + .resolve("default_player_skins"); + folder.toFile().mkdirs(); + // Two directories since there are two skins for each model: one slim, one wide + folder.resolve("slim").toFile().mkdir(); + folder.resolve("wide").toFile().mkdir(); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java index 85a1539bc..800b71c96 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinManager.java @@ -34,7 +34,6 @@ import com.nukkitx.protocol.bedrock.data.skin.SerializedSkin; import com.nukkitx.protocol.bedrock.packet.PlayerListPacket; import com.nukkitx.protocol.bedrock.packet.PlayerSkinPacket; import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.api.network.AuthType; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.auth.BedrockClientData; @@ -54,13 +53,30 @@ public class SkinManager { * Builds a Bedrock player list entry from our existing, cached Bedrock skin information */ public static PlayerListPacket.Entry buildCachedEntry(GeyserSession session, PlayerEntity playerEntity) { + // First: see if we have the cached skin texture ID. GameProfileData data = GameProfileData.from(playerEntity); - SkinProvider.Cape cape = SkinProvider.getCachedCape(data.capeUrl()); - SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.getLegacy(data.isAlex()); + SkinProvider.Skin skin = null; + SkinProvider.Cape cape = null; + SkinProvider.SkinGeometry geometry = SkinProvider.SkinGeometry.WIDE; + if (data != null) { + // GameProfileData is not null = server provided us with textures data to work with. + skin = SkinProvider.getCachedSkin(data.skinUrl()); + cape = SkinProvider.getCachedCape(data.capeUrl()); + geometry = data.isAlex() ? SkinProvider.SkinGeometry.SLIM : SkinProvider.SkinGeometry.WIDE; + } - SkinProvider.Skin skin = SkinProvider.getCachedSkin(data.skinUrl()); - if (skin == null) { - skin = SkinProvider.EMPTY_SKIN; + if (skin == null || cape == null) { + // The server either didn't have a texture to send, or we didn't have the texture ID cached. + // Let's see if this player is a Bedrock player, and if so, let's pull their skin. + // Otherwise, grab the default player skin + SkinProvider.SkinData fallbackSkinData = SkinProvider.determineFallbackSkinData(playerEntity); + if (skin == null) { + skin = fallbackSkinData.skin(); + geometry = fallbackSkinData.geometry(); + } + if (cape == null) { + cape = fallbackSkinData.cape(); + } } return buildEntryManually( @@ -144,10 +160,10 @@ public class SkinManager { } private static SerializedSkin getSkin(String skinId, SkinProvider.Skin skin, SkinProvider.Cape cape, SkinProvider.SkinGeometry geometry) { - return SerializedSkin.of(skinId, "", geometry.getGeometryName(), + return SerializedSkin.of(skinId, "", geometry.geometryName(), ImageData.of(skin.getSkinData()), Collections.emptyList(), - ImageData.of(cape.getCapeData()), geometry.getGeometryData(), - "", true, false, false, cape.getCapeId(), skinId); + ImageData.of(cape.capeData()), geometry.geometryData(), + "", true, false, false, cape.capeId(), skinId); } public static void requestAndHandleSkinAndCape(PlayerEntity entity, GeyserSession session, @@ -193,7 +209,7 @@ public class SkinManager { } if (!clientData.getCapeId().equals("")) { - SkinProvider.storeBedrockCape(playerEntity.getUuid(), capeBytes); + SkinProvider.storeBedrockCape(clientData.getCapeId(), capeBytes); } } catch (Exception e) { throw new AssertionError("Failed to cache skin for bedrock user (" + playerEntity.getUsername() + "): ", e); @@ -238,26 +254,21 @@ public class SkinManager { * @param entity entity to build the GameProfileData from * @return The built GameProfileData */ - public static GameProfileData from(PlayerEntity entity) { + public static @Nullable GameProfileData from(PlayerEntity entity) { try { String texturesProperty = entity.getTexturesProperty(); if (texturesProperty == null) { // Likely offline mode - return loadBedrockOrOfflineSkin(entity); - } - GameProfileData data = loadFromJson(texturesProperty); - if (data != null) { - return data; - } else { - return loadBedrockOrOfflineSkin(entity); + return null; } + return loadFromJson(texturesProperty); } catch (IOException exception) { GeyserImpl.getInstance().getLogger().debug("Something went wrong while processing skin for " + entity.getUsername()); if (GeyserImpl.getInstance().getConfig().isDebugMode()) { exception.printStackTrace(); } - return loadBedrockOrOfflineSkin(entity); + return null; } } @@ -286,27 +297,5 @@ public class SkinManager { return new GameProfileData(skinUrl, capeUrl, isAlex); } - - /** - * @return default skin with default cape when texture data is invalid, or the Bedrock player's skin if this - * is a Bedrock player. - */ - private static GameProfileData loadBedrockOrOfflineSkin(PlayerEntity entity) { - // Fallback to the offline mode of working it out - UUID uuid = entity.getUuid(); - boolean isAlex = (Math.abs(uuid.hashCode() % 2) == 1); - - String skinUrl = isAlex ? SkinProvider.EMPTY_SKIN_ALEX.getTextureUrl() : SkinProvider.EMPTY_SKIN.getTextureUrl(); - String capeUrl = SkinProvider.EMPTY_CAPE.getTextureUrl(); - if (("steve".equals(skinUrl) || "alex".equals(skinUrl)) && GeyserImpl.getInstance().getConfig().getRemote().authType() != AuthType.ONLINE) { - GeyserSession session = GeyserImpl.getInstance().connectionByUuid(uuid); - - if (session != null) { - skinUrl = session.getClientData().getSkinId(); - capeUrl = session.getClientData().getCapeId(); - } - } - return new GameProfileData(skinUrl, capeUrl, isAlex); - } } } diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java index 43cf30b47..61f24ac1e 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java @@ -26,22 +26,25 @@ package org.geysermc.geyser.skin; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.github.steveice10.opennbt.tag.builtin.CompoundTag; import com.github.steveice10.opennbt.tag.builtin.IntArrayTag; import com.github.steveice10.opennbt.tag.builtin.Tag; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; +import it.unimi.dsi.fastutil.bytes.ByteArrays; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.network.AuthType; import org.geysermc.geyser.entity.type.player.PlayerEntity; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.util.FileUtils; import org.geysermc.geyser.util.WebUtils; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; @@ -57,28 +60,28 @@ import java.util.concurrent.*; import java.util.function.Predicate; public class SkinProvider { - public static final boolean ALLOW_THIRD_PARTY_CAPES = GeyserImpl.getInstance().getConfig().isAllowThirdPartyCapes(); + private static final boolean ALLOW_THIRD_PARTY_CAPES = GeyserImpl.getInstance().getConfig().isAllowThirdPartyCapes(); static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(ALLOW_THIRD_PARTY_CAPES ? 21 : 14); - public static final byte[] STEVE_SKIN = new ProvidedSkin("bedrock/skin/skin_steve.png").getSkin(); - public static final Skin EMPTY_SKIN = new Skin(-1, "steve", STEVE_SKIN); - public static final byte[] ALEX_SKIN = new ProvidedSkin("bedrock/skin/skin_alex.png").getSkin(); - public static final Skin EMPTY_SKIN_ALEX = new Skin(-1, "alex", ALEX_SKIN); - private static final Map permanentSkins = new HashMap<>() {{ - put("steve", EMPTY_SKIN); - put("alex", EMPTY_SKIN_ALEX); - }}; - private static final Cache cachedSkins = CacheBuilder.newBuilder() + static final Skin EMPTY_SKIN; + static final Cape EMPTY_CAPE = new Cape("", "no-cape", ByteArrays.EMPTY_ARRAY, -1, true); + + private static final Cache CACHED_JAVA_CAPES = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build(); + private static final Cache CACHED_JAVA_SKINS = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.HOURS) .build(); - private static final Map> requestedSkins = new ConcurrentHashMap<>(); - - public static final Cape EMPTY_CAPE = new Cape("", "no-cape", new byte[0], -1, true); - private static final Cache cachedCapes = CacheBuilder.newBuilder() + private static final Cache CACHED_BEDROCK_CAPES = CacheBuilder.newBuilder() .expireAfterAccess(1, TimeUnit.HOURS) .build(); + private static final Cache CACHED_BEDROCK_SKINS = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build(); + private static final Map> requestedCapes = new ConcurrentHashMap<>(); + private static final Map> requestedSkins = new ConcurrentHashMap<>(); private static final Map cachedGeometry = new ConcurrentHashMap<>(); @@ -86,18 +89,36 @@ public class SkinProvider { * Citizens NPCs use UUID version 2, while legitimate Minecraft players use version 4, and * offline mode players use version 3. */ - public static final Predicate IS_NPC = uuid -> uuid.version() == 2; + private static final Predicate IS_NPC = uuid -> uuid.version() == 2; - public static final boolean ALLOW_THIRD_PARTY_EARS = GeyserImpl.getInstance().getConfig().isAllowThirdPartyEars(); - public static final String EARS_GEOMETRY; - public static final String EARS_GEOMETRY_SLIM; - public static final SkinGeometry SKULL_GEOMETRY; - public static final SkinGeometry WEARING_CUSTOM_SKULL; - public static final SkinGeometry WEARING_CUSTOM_SKULL_SLIM; - - public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final boolean ALLOW_THIRD_PARTY_EARS = GeyserImpl.getInstance().getConfig().isAllowThirdPartyEars(); + private static final String EARS_GEOMETRY; + private static final String EARS_GEOMETRY_SLIM; + static final SkinGeometry SKULL_GEOMETRY; + static final SkinGeometry WEARING_CUSTOM_SKULL; + static final SkinGeometry WEARING_CUSTOM_SKULL_SLIM; static { + // Generate the empty texture to use as an emergency fallback + final int pink = -524040; + final int black = -16777216; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(64 * 4 + 64 * 4); + for (int y = 0; y < 64; y++) { + for (int x = 0; x < 64; x++) { + int rgba; + if (y > 32) { + rgba = x >= 32 ? pink : black; + } else { + rgba = x >= 32 ? black : pink; + } + outputStream.write((rgba >> 16) & 0xFF); // Red + outputStream.write((rgba >> 8) & 0xFF); // Green + outputStream.write(rgba & 0xFF); // Blue + outputStream.write((rgba >> 24) & 0xFF); // Alpha + } + } + EMPTY_SKIN = new Skin(-1, "geysermc:empty", outputStream.toByteArray()); + /* Load in the normal ears geometry */ EARS_GEOMETRY = new String(FileUtils.readAllBytes("bedrock/skin/geometry.humanoid.ears.json"), StandardCharsets.UTF_8); @@ -141,48 +162,104 @@ public class SkinProvider { } } - public static boolean hasCapeCached(String capeUrl) { - return cachedCapes.getIfPresent(capeUrl) != null; + /** + * Search our cached database for an already existing, translated skin of this Java URL. + */ + static Skin getCachedSkin(String skinUrl) { + return CACHED_JAVA_SKINS.getIfPresent(skinUrl); } - public static Skin getCachedSkin(String skinUrl) { - return permanentSkins.getOrDefault(skinUrl, cachedSkins.getIfPresent(skinUrl)); + /** + * If skin data fails to apply, or there is no skin data to apply, determine what skin we should give as a fallback. + */ + static SkinData determineFallbackSkinData(PlayerEntity entity) { + Skin skin = null; + Cape cape = null; + SkinGeometry geometry = SkinGeometry.WIDE; + + if (GeyserImpl.getInstance().getConfig().getRemote().authType() != AuthType.ONLINE) { + // Let's see if this player is a Bedrock player, and if so, let's pull their skin. + UUID uuid = entity.getUuid(); + GeyserSession session = GeyserImpl.getInstance().connectionByUuid(uuid); + if (session != null) { + String skinId = session.getClientData().getSkinId(); + skin = CACHED_BEDROCK_SKINS.getIfPresent(skinId); + String capeId = session.getClientData().getCapeId(); + cape = CACHED_BEDROCK_CAPES.getIfPresent(capeId); + geometry = cachedGeometry.getOrDefault(uuid, geometry); + } + } + + if (skin == null) { + // We don't have a skin for the player right now. Fall back to a default. + ProvidedSkins.ProvidedSkin providedSkin = ProvidedSkins.getDefaultPlayerSkin(entity.getUuid()); + skin = providedSkin.getData(); + geometry = providedSkin.isSlim() ? SkinProvider.SkinGeometry.SLIM : SkinProvider.SkinGeometry.WIDE; + } + + if (cape == null) { + cape = EMPTY_CAPE; + } + + return new SkinData(skin, cape, geometry); } - public static Cape getCachedCape(String capeUrl) { - Cape cape = capeUrl != null ? cachedCapes.getIfPresent(capeUrl) : EMPTY_CAPE; - return cape != null ? cape : EMPTY_CAPE; + /** + * Used as a fallback if an official Java cape doesn't exist for this user. + */ + @Nonnull + private static Cape getCachedBedrockCape(UUID uuid) { + GeyserSession session = GeyserImpl.getInstance().connectionByUuid(uuid); + if (session != null) { + String capeId = session.getClientData().getCapeId(); + Cape bedrockCape = CACHED_BEDROCK_CAPES.getIfPresent(capeId); + if (bedrockCape != null) { + return bedrockCape; + } + } + return EMPTY_CAPE; } - public static CompletableFuture requestSkinData(PlayerEntity entity) { + @Nullable + static Cape getCachedCape(String capeUrl) { + if (capeUrl == null) { + return null; + } + return CACHED_JAVA_CAPES.getIfPresent(capeUrl); + } + + static CompletableFuture requestSkinData(PlayerEntity entity) { SkinManager.GameProfileData data = SkinManager.GameProfileData.from(entity); + if (data == null) { + // This player likely does not have a textures property + return CompletableFuture.completedFuture(determineFallbackSkinData(entity)); + } return requestSkinAndCape(entity.getUuid(), data.skinUrl(), data.capeUrl()) .thenApplyAsync(skinAndCape -> { try { - Skin skin = skinAndCape.getSkin(); - Cape cape = skinAndCape.getCape(); - SkinGeometry geometry = SkinGeometry.getLegacy(data.isAlex()); + Skin skin = skinAndCape.skin(); + Cape cape = skinAndCape.cape(); + SkinGeometry geometry = data.isAlex() ? SkinGeometry.SLIM : SkinGeometry.WIDE; - if (cape.isFailed()) { - cape = getOrDefault(requestBedrockCape(entity.getUuid()), - EMPTY_CAPE, 3); + // Whether we should see if this player has a Bedrock skin we should check for on failure of + // any skin property + boolean checkForBedrock = entity.getUuid().version() != 4; + + if (cape.failed() && checkForBedrock) { + cape = getCachedBedrockCape(entity.getUuid()); } - if (cape.isFailed() && ALLOW_THIRD_PARTY_CAPES) { + if (cape.failed() && ALLOW_THIRD_PARTY_CAPES) { cape = getOrDefault(requestUnofficialCape( cape, entity.getUuid(), entity.getUsername(), false ), EMPTY_CAPE, CapeProvider.VALUES.length * 3); } - geometry = getOrDefault(requestBedrockGeometry( - geometry, entity.getUuid() - ), geometry, 3); - boolean isDeadmau5 = "deadmau5".equals(entity.getUsername()); // Not a bedrock player check for ears - if (geometry.isFailed() && (ALLOW_THIRD_PARTY_EARS || isDeadmau5)) { + if (geometry.failed() && (ALLOW_THIRD_PARTY_EARS || isDeadmau5)) { boolean isEars; // Its deadmau5, gotta support his skin :) @@ -213,26 +290,17 @@ public class SkinProvider { GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.skin.fail", entity.getUuid()), e); } - return new SkinData(skinAndCape.getSkin(), skinAndCape.getCape(), null); + return new SkinData(skinAndCape.skin(), skinAndCape.cape(), null); }); } - public static CompletableFuture requestSkinAndCape(UUID playerId, String skinUrl, String capeUrl) { + private static CompletableFuture requestSkinAndCape(UUID playerId, String skinUrl, String capeUrl) { return CompletableFuture.supplyAsync(() -> { long time = System.currentTimeMillis(); - String newSkinUrl = skinUrl; - - if ("steve".equals(skinUrl) || "alex".equals(skinUrl)) { - GeyserSession session = GeyserImpl.getInstance().connectionByUuid(playerId); - - if (session != null) { - newSkinUrl = session.getClientData().getSkinId(); - } - } CapeProvider provider = capeUrl != null ? CapeProvider.MINECRAFT : null; SkinAndCape skinAndCape = new SkinAndCape( - getOrDefault(requestSkin(playerId, newSkinUrl, false), EMPTY_SKIN, 5), + getOrDefault(requestSkin(playerId, skinUrl, false), EMPTY_SKIN, 5), getOrDefault(requestCape(capeUrl, provider, false), EMPTY_CAPE, 5) ); @@ -241,7 +309,7 @@ public class SkinProvider { }, EXECUTOR_SERVICE); } - public static CompletableFuture requestSkin(UUID playerId, String textureUrl, boolean newThread) { + static CompletableFuture requestSkin(UUID playerId, String textureUrl, boolean newThread) { if (textureUrl == null || textureUrl.isEmpty()) return CompletableFuture.completedFuture(EMPTY_SKIN); CompletableFuture requestedSkin = requestedSkins.get(textureUrl); if (requestedSkin != null) { @@ -249,7 +317,7 @@ public class SkinProvider { return requestedSkin; } - Skin cachedSkin = getCachedSkin(textureUrl); + Skin cachedSkin = CACHED_JAVA_SKINS.getIfPresent(textureUrl); if (cachedSkin != null) { return CompletableFuture.completedFuture(cachedSkin); } @@ -259,23 +327,26 @@ public class SkinProvider { future = CompletableFuture.supplyAsync(() -> supplySkin(playerId, textureUrl), EXECUTOR_SERVICE) .whenCompleteAsync((skin, throwable) -> { skin.updated = true; - cachedSkins.put(textureUrl, skin); + CACHED_JAVA_SKINS.put(textureUrl, skin); requestedSkins.remove(textureUrl); }); requestedSkins.put(textureUrl, future); } else { Skin skin = supplySkin(playerId, textureUrl); future = CompletableFuture.completedFuture(skin); - cachedSkins.put(textureUrl, skin); + CACHED_JAVA_SKINS.put(textureUrl, skin); } return future; } - public static CompletableFuture requestCape(String capeUrl, CapeProvider provider, boolean newThread) { + private static CompletableFuture requestCape(String capeUrl, CapeProvider provider, boolean newThread) { if (capeUrl == null || capeUrl.isEmpty()) return CompletableFuture.completedFuture(EMPTY_CAPE); - if (requestedCapes.containsKey(capeUrl)) return requestedCapes.get(capeUrl); // already requested + CompletableFuture requestedCape = requestedCapes.get(capeUrl); + if (requestedCape != null) { + return requestedCape; + } - Cape cachedCape = cachedCapes.getIfPresent(capeUrl); + Cape cachedCape = CACHED_JAVA_CAPES.getIfPresent(capeUrl); if (cachedCape != null) { return CompletableFuture.completedFuture(cachedCape); } @@ -284,21 +355,21 @@ public class SkinProvider { if (newThread) { future = CompletableFuture.supplyAsync(() -> supplyCape(capeUrl, provider), EXECUTOR_SERVICE) .whenCompleteAsync((cape, throwable) -> { - cachedCapes.put(capeUrl, cape); + CACHED_JAVA_CAPES.put(capeUrl, cape); requestedCapes.remove(capeUrl); }); requestedCapes.put(capeUrl, future); } else { Cape cape = supplyCape(capeUrl, provider); // blocking future = CompletableFuture.completedFuture(cape); - cachedCapes.put(capeUrl, cape); + CACHED_JAVA_CAPES.put(capeUrl, cape); } return future; } - public static CompletableFuture requestUnofficialCape(Cape officialCape, UUID playerId, + private static CompletableFuture requestUnofficialCape(Cape officialCape, UUID playerId, String username, boolean newThread) { - if (officialCape.isFailed() && ALLOW_THIRD_PARTY_CAPES) { + if (officialCape.failed() && ALLOW_THIRD_PARTY_CAPES) { for (CapeProvider provider : CapeProvider.VALUES) { if (provider.type != CapeUrlType.USERNAME && IS_NPC.test(playerId)) { continue; @@ -308,7 +379,7 @@ public class SkinProvider { requestCape(provider.getUrlFor(playerId, username), provider, newThread), EMPTY_CAPE, 4 ); - if (!cape1.isFailed()) { + if (!cape1.failed()) { return CompletableFuture.completedFuture(cape1); } } @@ -316,7 +387,7 @@ public class SkinProvider { return CompletableFuture.completedFuture(officialCape); } - public static CompletableFuture requestEars(String earsUrl, boolean newThread, Skin skin) { + private static CompletableFuture requestEars(String earsUrl, boolean newThread, Skin skin) { if (earsUrl == null || earsUrl.isEmpty()) return CompletableFuture.completedFuture(skin); CompletableFuture future; @@ -339,7 +410,7 @@ public class SkinProvider { * @param newThread Should we start in a new thread * @return The updated skin with ears */ - public static CompletableFuture requestUnofficialEars(Skin officialSkin, UUID playerId, String username, boolean newThread) { + private static CompletableFuture requestUnofficialEars(Skin officialSkin, UUID playerId, String username, boolean newThread) { for (EarsProvider provider : EarsProvider.VALUES) { if (provider.type != CapeUrlType.USERNAME && IS_NPC.test(playerId)) { continue; @@ -357,30 +428,17 @@ public class SkinProvider { return CompletableFuture.completedFuture(officialSkin); } - public static CompletableFuture requestBedrockCape(UUID playerID) { - Cape bedrockCape = cachedCapes.getIfPresent(playerID.toString() + ".Bedrock"); - if (bedrockCape == null) { - bedrockCape = EMPTY_CAPE; - } - return CompletableFuture.completedFuture(bedrockCape); + static void storeBedrockSkin(UUID playerID, String skinId, byte[] skinData) { + Skin skin = new Skin(playerID, skinId, skinData, System.currentTimeMillis(), true, false); + CACHED_BEDROCK_SKINS.put(skin.getTextureUrl(), skin); } - public static CompletableFuture requestBedrockGeometry(SkinGeometry currentGeometry, UUID playerID) { - SkinGeometry bedrockGeometry = cachedGeometry.getOrDefault(playerID, currentGeometry); - return CompletableFuture.completedFuture(bedrockGeometry); + static void storeBedrockCape(String capeId, byte[] capeData) { + Cape cape = new Cape(capeId, capeId, capeData, System.currentTimeMillis(), false); + CACHED_BEDROCK_CAPES.put(capeId, cape); } - public static void storeBedrockSkin(UUID playerID, String skinID, byte[] skinData) { - Skin skin = new Skin(playerID, skinID, skinData, System.currentTimeMillis(), true, false); - cachedSkins.put(skin.getTextureUrl(), skin); - } - - public static void storeBedrockCape(UUID playerID, byte[] capeData) { - Cape cape = new Cape(playerID.toString() + ".Bedrock", playerID.toString(), capeData, System.currentTimeMillis(), false); - cachedCapes.put(playerID.toString() + ".Bedrock", cape); - } - - public static void storeBedrockGeometry(UUID playerID, byte[] geometryName, byte[] geometryData) { + static void storeBedrockGeometry(UUID playerID, byte[] geometryName, byte[] geometryData) { SkinGeometry geometry = new SkinGeometry(new String(geometryName), new String(geometryData), false); cachedGeometry.put(playerID, geometry); } @@ -391,7 +449,7 @@ public class SkinProvider { * @param skin The skin to cache */ public static void storeEarSkin(Skin skin) { - cachedSkins.put(skin.getTextureUrl(), skin); + CACHED_JAVA_SKINS.put(skin.getTextureUrl(), skin); } /** @@ -400,7 +458,7 @@ public class SkinProvider { * @param playerID The UUID to cache it against * @param isSlim If the player is using an slim base */ - public static void storeEarGeometry(UUID playerID, boolean isSlim) { + private static void storeEarGeometry(UUID playerID, boolean isSlim) { cachedGeometry.put(playerID, SkinGeometry.getEars(isSlim)); } @@ -414,7 +472,7 @@ public class SkinProvider { } private static Cape supplyCape(String capeUrl, CapeProvider provider) { - byte[] cape = EMPTY_CAPE.getCapeData(); + byte[] cape = EMPTY_CAPE.capeData(); try { cape = requestImage(capeUrl, provider); } catch (Exception ignored) { @@ -604,7 +662,7 @@ public class SkinProvider { } private static BufferedImage readFiveZigCape(String url) throws IOException { - JsonNode element = OBJECT_MAPPER.readTree(WebUtils.getBody(url)); + JsonNode element = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(url)); if (element != null && element.isObject()) { JsonNode capeElement = element.get("d"); if (capeElement == null || capeElement.isNull()) return null; @@ -683,13 +741,12 @@ public class SkinProvider { return defaultValue; } - @AllArgsConstructor - @Getter - public static class SkinAndCape { - private final Skin skin; - private final Cape cape; + public record SkinAndCape(Skin skin, Cape cape) { } + /** + * Represents a full package of skin, cape, and geometry. + */ public record SkinData(Skin skin, Cape cape, SkinGeometry geometry) { } @@ -703,29 +760,19 @@ public class SkinProvider { private boolean updated; private boolean ears; - private Skin(long requestedOn, String textureUrl, byte[] skinData) { + Skin(long requestedOn, String textureUrl, byte[] skinData) { this.requestedOn = requestedOn; this.textureUrl = textureUrl; this.skinData = skinData; } } - @AllArgsConstructor - @Getter - public static class Cape { - private final String textureUrl; - private final String capeId; - private final byte[] capeData; - private final long requestedOn; - private final boolean failed; + public record Cape(String textureUrl, String capeId, byte[] capeData, long requestedOn, boolean failed) { } - @AllArgsConstructor - @Getter - public static class SkinGeometry { - private final String geometryName; - private final String geometryData; - private final boolean failed; + public record SkinGeometry(String geometryName, String geometryData, boolean failed) { + public static SkinGeometry WIDE = getLegacy(false); + public static SkinGeometry SLIM = getLegacy(true); /** * Generate generic geometry @@ -733,7 +780,7 @@ public class SkinProvider { * @param isSlim Should it be the alex model * @return The generic geometry object */ - public static SkinGeometry getLegacy(boolean isSlim) { + private static SkinGeometry getLegacy(boolean isSlim) { return new SkinProvider.SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.custom" + (isSlim ? "Slim" : "") + "\"}}", "", true); } @@ -743,7 +790,7 @@ public class SkinProvider { * @param isSlim Should it be the alex model * @return The generated geometry for the ears model */ - public static SkinGeometry getEars(boolean isSlim) { + private static SkinGeometry getEars(boolean isSlim) { return new SkinProvider.SkinGeometry("{\"geometry\" :{\"default\" :\"geometry.humanoid.ears" + (isSlim ? "Slim" : "") + "\"}}", (isSlim ? EARS_GEOMETRY_SLIM : EARS_GEOMETRY), false); } } diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java b/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java index 58054e9c5..2759b1408 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkullSkinManager.java @@ -42,9 +42,9 @@ public class SkullSkinManager extends SkinManager { // Prevents https://cdn.discordapp.com/attachments/613194828359925800/779458146191147008/unknown.png skinId = skinId + "_skull"; return SerializedSkin.of( - skinId, "", SkinProvider.SKULL_GEOMETRY.getGeometryName(), ImageData.of(skinData), Collections.emptyList(), - ImageData.of(SkinProvider.EMPTY_CAPE.getCapeData()), SkinProvider.SKULL_GEOMETRY.getGeometryData(), - "", true, false, false, SkinProvider.EMPTY_CAPE.getCapeId(), skinId + skinId, "", SkinProvider.SKULL_GEOMETRY.geometryName(), ImageData.of(skinData), Collections.emptyList(), + ImageData.of(SkinProvider.EMPTY_CAPE.capeData()), SkinProvider.SKULL_GEOMETRY.geometryData(), + "", true, false, false, SkinProvider.EMPTY_CAPE.capeId(), skinId ); } diff --git a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java index 94ad5eead..9b0edd82f 100644 --- a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java +++ b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java @@ -25,91 +25,45 @@ package org.geysermc.geyser.text; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; -import lombok.Getter; import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.network.GameProtocol; +import org.geysermc.geyser.util.AssetUtils; import org.geysermc.geyser.util.FileUtils; import org.geysermc.geyser.util.WebUtils; import java.io.*; import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.zip.ZipFile; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; public class MinecraftLocale { public static final Map> LOCALE_MAPPINGS = new HashMap<>(); - private static final Map ASSET_MAP = new HashMap<>(); - - private static VersionDownload clientJarInfo; - static { // Create the locales folder File localesFolder = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales").toFile(); //noinspection ResultOfMethodCallIgnored localesFolder.mkdir(); - // Download the latest asset list and cache it - generateAssetCache().whenComplete((aVoid, ex) -> downloadAndLoadLocale(GeyserLocale.getDefaultLocale())); + // FIXME TEMPORARY + try { + Files.delete(localesFolder.toPath().resolve("en_us.hash")); + } catch (IOException ignored) { + } } - /** - * Fetch the latest versions asset cache from Mojang so we can grab the locale files later - */ - private static CompletableFuture generateAssetCache() { - return CompletableFuture.supplyAsync(() -> { - try { - // Get the version manifest from Mojang - VersionManifest versionManifest = GeyserImpl.JSON_MAPPER.readValue(WebUtils.getBody("https://launchermeta.mojang.com/mc/game/version_manifest.json"), VersionManifest.class); - - // Get the url for the latest version of the games manifest - String latestInfoURL = ""; - for (Version version : versionManifest.getVersions()) { - if (version.getId().equals(GameProtocol.getJavaCodec().getMinecraftVersion())) { - latestInfoURL = version.getUrl(); - break; + public static void ensureEN_US() { + File localeFile = getFile("en_us"); + AssetUtils.addTask(!localeFile.exists(), new AssetUtils.ClientJarTask("assets/minecraft/lang/en_us.json", + (stream) -> AssetUtils.saveFile(localeFile, stream), + () -> { + if ("en_us".equals(GeyserLocale.getDefaultLocale())) { + loadLocale("en_us"); } - } - - // Make sure we definitely got a version - if (latestInfoURL.isEmpty()) { - throw new Exception(GeyserLocale.getLocaleStringLog("geyser.locale.fail.latest_version")); - } - - // Get the individual version manifest - VersionInfo versionInfo = GeyserImpl.JSON_MAPPER.readValue(WebUtils.getBody(latestInfoURL), VersionInfo.class); - - // Get the client jar for use when downloading the en_us locale - GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(versionInfo.getDownloads())); - clientJarInfo = versionInfo.getDownloads().get("client"); - GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(clientJarInfo)); - - // Get the assets list - JsonNode assets = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(versionInfo.getAssetIndex().getUrl())).get("objects"); - - // Put each asset into an array for use later - Iterator> assetIterator = assets.fields(); - while (assetIterator.hasNext()) { - Map.Entry entry = assetIterator.next(); - if (!entry.getKey().startsWith("minecraft/lang/")) { - // No need to cache non-language assets as we don't use them - continue; - } - - Asset asset = GeyserImpl.JSON_MAPPER.treeToValue(entry.getValue(), Asset.class); - ASSET_MAP.put(entry.getKey(), asset); - } - } catch (Exception e) { - GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.asset_cache", (!e.getMessage().isEmpty() ? e.getMessage() : e.getStackTrace()))); - } - return null; - }); + })); } /** @@ -125,7 +79,7 @@ public class MinecraftLocale { } // Check the locale isn't already loaded - if (!ASSET_MAP.containsKey("minecraft/lang/" + locale + ".json") && !locale.equals("en_us")) { + if (!AssetUtils.isAssetKnown("minecraft/lang/" + locale + ".json") && !locale.equals("en_us")) { if (loadLocale(locale)) { GeyserImpl.getInstance().getLogger().debug("Loaded locale locally while not being in asset map: " + locale); } else { @@ -148,33 +102,15 @@ public class MinecraftLocale { * @param locale Locale to download */ private static void downloadLocale(String locale) { - File localeFile = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/" + locale + ".json").toFile(); + if (locale.equals("en_us")) { + return; + } + File localeFile = getFile(locale); // Check if we have already downloaded the locale file if (localeFile.exists()) { - String curHash = ""; - String targetHash; - - if (locale.equals("en_us")) { - try { - File hashFile = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/en_us.hash").toFile(); - if (hashFile.exists()) { - try (BufferedReader br = new BufferedReader(new FileReader(hashFile))) { - curHash = br.readLine().trim(); - } - } - } catch (IOException ignored) { } - - if (clientJarInfo == null) { - // Likely failed to download - GeyserImpl.getInstance().getLogger().debug("Skipping en_US hash check as client jar is null."); - return; - } - targetHash = clientJarInfo.getSha1(); - } else { - curHash = byteArrayToHexString(FileUtils.calculateSHA1(localeFile)); - targetHash = ASSET_MAP.get("minecraft/lang/" + locale + ".json").getHash(); - } + String curHash = byteArrayToHexString(FileUtils.calculateSHA1(localeFile)); + String targetHash = AssetUtils.getAsset("minecraft/lang/" + locale + ".json").getHash(); if (!curHash.equals(targetHash)) { GeyserImpl.getInstance().getLogger().debug("Locale out of date; re-downloading: " + locale); @@ -184,22 +120,19 @@ public class MinecraftLocale { } } - // Create the en_us locale - if (locale.equals("en_us")) { - downloadEN_US(localeFile); - - return; - } - try { // Get the hash and download the locale - String hash = ASSET_MAP.get("minecraft/lang/" + locale + ".json").getHash(); + String hash = AssetUtils.getAsset("minecraft/lang/" + locale + ".json").getHash(); WebUtils.downloadFile("https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash, localeFile.toString()); } catch (Exception e) { GeyserImpl.getInstance().getLogger().error("Unable to download locale file hash", e); } } + private static File getFile(String locale) { + return GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/" + locale + ".json").toFile(); + } + /** * Loads a locale already downloaded, if the file doesn't exist it just logs a warning * @@ -254,51 +187,6 @@ public class MinecraftLocale { } } - /** - * Download then en_us locale by downloading the server jar and extracting it from there. - * - * @param localeFile File to save the locale to - */ - private static void downloadEN_US(File localeFile) { - try { - // Let the user know we are downloading the JAR - GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us")); - GeyserImpl.getInstance().getLogger().debug("Download URL: " + clientJarInfo.getUrl()); - - // Download the smallest JAR (client or server) - Path tmpFilePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("tmp_locale.jar"); - WebUtils.downloadFile(clientJarInfo.getUrl(), tmpFilePath.toString()); - - // Load in the JAR as a zip and extract the file - try (ZipFile localeJar = new ZipFile(tmpFilePath.toString())) { - try (InputStream fileStream = localeJar.getInputStream(localeJar.getEntry("assets/minecraft/lang/en_us.json"))) { - try (FileOutputStream outStream = new FileOutputStream(localeFile)) { - - // Write the file to the locale dir - byte[] buf = new byte[fileStream.available()]; - int length; - while ((length = fileStream.read(buf)) != -1) { - outStream.write(buf, 0, length); - } - - // Flush all changes to disk and cleanup - outStream.flush(); - } - } - } - - // Store the latest jar hash - FileUtils.writeFile(GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("locales/en_us.hash").toString(), clientJarInfo.getSha1().toCharArray()); - - // Delete the nolonger needed client/server jar - Files.delete(tmpFilePath); - - GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us.done")); - } catch (Exception e) { - GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.en_us"), e); - } - } - /** * Translate the given language string into the given locale, or falls back to the default locale * @@ -333,111 +221,4 @@ public class MinecraftLocale { } return result.toString(); } - - public static void init() { - // no-op - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class VersionManifest { - @JsonProperty("latest") - private LatestVersion latestVersion; - - @JsonProperty("versions") - private List versions; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class LatestVersion { - @JsonProperty("release") - private String release; - - @JsonProperty("snapshot") - private String snapshot; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class Version { - @JsonProperty("id") - private String id; - - @JsonProperty("type") - private String type; - - @JsonProperty("url") - private String url; - - @JsonProperty("time") - private String time; - - @JsonProperty("releaseTime") - private String releaseTime; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class VersionInfo { - @JsonProperty("id") - private String id; - - @JsonProperty("type") - private String type; - - @JsonProperty("time") - private String time; - - @JsonProperty("releaseTime") - private String releaseTime; - - @JsonProperty("assetIndex") - private AssetIndex assetIndex; - - @JsonProperty("downloads") - private Map downloads; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class VersionDownload { - @JsonProperty("sha1") - private String sha1; - - @JsonProperty("size") - private int size; - - @JsonProperty("url") - private String url; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class AssetIndex { - @JsonProperty("id") - private String id; - - @JsonProperty("sha1") - private String sha1; - - @JsonProperty("size") - private int size; - - @JsonProperty("totalSize") - private int totalSize; - - @JsonProperty("url") - private String url; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - @Getter - static class Asset { - @JsonProperty("hash") - private String hash; - - @JsonProperty("size") - private int size; - } } \ No newline at end of file diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoUpdateTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoUpdateTranslator.java index 4b7b3cdf8..2784b1cc4 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoUpdateTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/entity/player/JavaPlayerInfoUpdateTranslator.java @@ -79,10 +79,6 @@ public class JavaPlayerInfoUpdateTranslator extends PacketTranslator GeyserImpl.getInstance().getLogger().debug("Loaded Local Bedrock Java Skin Data for " + session.getClientData().getUsername())); diff --git a/core/src/main/java/org/geysermc/geyser/util/AssetUtils.java b/core/src/main/java/org/geysermc/geyser/util/AssetUtils.java new file mode 100644 index 000000000..299e63e0e --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/util/AssetUtils.java @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.util; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Getter; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.network.GameProtocol; +import org.geysermc.geyser.text.GeyserLocale; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.zip.ZipFile; + +/** + * Implementation note: try to design processes to fail softly if the client jar can't be downloaded, + * either if Mojang is down or internet access to Mojang is spotty. + */ +public final class AssetUtils { + private static final String CLIENT_JAR_HASH_FILE = "client_jar.hash"; + + private static final Map ASSET_MAP = new HashMap<>(); + + private static VersionDownload CLIENT_JAR_INFO; + + private static final Queue CLIENT_JAR_TASKS = new ArrayDeque<>(); + /** + * Download the client jar even if the hash is correct + */ + private static boolean FORCE_DOWNLOAD_JAR = false; + + public static Asset getAsset(String name) { + return ASSET_MAP.get(name); + } + + public static boolean isAssetKnown(String name) { + return ASSET_MAP.containsKey(name); + } + + /** + * Add task to be ran after the client jar is downloaded or found to be cached. + * + * @param required if set to true, the client jar will always be downloaded, even if a pre-existing hash is matched. + * This means an asset or texture is missing. + */ + public static void addTask(boolean required, ClientJarTask task) { + CLIENT_JAR_TASKS.add(task); + FORCE_DOWNLOAD_JAR |= required; + } + + /** + * Fetch the latest versions asset cache from Mojang so we can grab the locale files later + */ + public static CompletableFuture generateAssetCache() { + return CompletableFuture.supplyAsync(() -> { + try { + // Get the version manifest from Mojang + VersionManifest versionManifest = GeyserImpl.JSON_MAPPER.readValue( + WebUtils.getBody("https://launchermeta.mojang.com/mc/game/version_manifest.json"), VersionManifest.class); + + // Get the url for the latest version of the games manifest + String latestInfoURL = ""; + for (Version version : versionManifest.getVersions()) { + if (version.getId().equals(GameProtocol.getJavaCodec().getMinecraftVersion())) { + latestInfoURL = version.getUrl(); + break; + } + } + + // Make sure we definitely got a version + if (latestInfoURL.isEmpty()) { + throw new Exception(GeyserLocale.getLocaleStringLog("geyser.locale.fail.latest_version")); + } + + // Get the individual version manifest + VersionInfo versionInfo = GeyserImpl.JSON_MAPPER.readValue(WebUtils.getBody(latestInfoURL), VersionInfo.class); + + // Get the client jar for use when downloading the en_us locale + GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(versionInfo.getDownloads())); + CLIENT_JAR_INFO = versionInfo.getDownloads().get("client"); + GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(CLIENT_JAR_INFO)); + + // Get the assets list + JsonNode assets = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(versionInfo.getAssetIndex().getUrl())).get("objects"); + + // Put each asset into an array for use later + Iterator> assetIterator = assets.fields(); + while (assetIterator.hasNext()) { + Map.Entry entry = assetIterator.next(); + if (!entry.getKey().startsWith("minecraft/lang/")) { + // No need to cache non-language assets as we don't use them + continue; + } + + Asset asset = GeyserImpl.JSON_MAPPER.treeToValue(entry.getValue(), Asset.class); + ASSET_MAP.put(entry.getKey(), asset); + } + + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.asset_cache", (!e.getMessage().isEmpty() ? e.getMessage() : e.getStackTrace()))); + } + return null; + }); + } + + public static void downloadAndRunClientJarTasks() { + if (CLIENT_JAR_INFO == null) { + // Likely failed to download + GeyserImpl.getInstance().getLogger().debug("Skipping en_US hash check as client jar is null."); + return; + } + + if (!FORCE_DOWNLOAD_JAR) { // Don't bother checking the hash if we need to download new files anyway. + String curHash = null; + try { + File hashFile = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve(CLIENT_JAR_HASH_FILE).toFile(); + if (hashFile.exists()) { + try (BufferedReader br = new BufferedReader(new FileReader(hashFile))) { + curHash = br.readLine().trim(); + } + } + } catch (IOException ignored) { } + String targetHash = CLIENT_JAR_INFO.getSha1(); + if (targetHash.equals(curHash)) { + // Just run all tasks - no new download required + ClientJarTask task; + while ((task = CLIENT_JAR_TASKS.poll()) != null) { + task.whenDone.run(); + } + return; + } + } + + try { + // Let the user know we are downloading the JAR + GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us")); + GeyserImpl.getInstance().getLogger().debug("Download URL: " + CLIENT_JAR_INFO.getUrl()); + + Path tmpFilePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("tmp_locale.jar"); + WebUtils.downloadFile(CLIENT_JAR_INFO.getUrl(), tmpFilePath.toString()); + + // Load in the JAR as a zip and extract the files + try (ZipFile localeJar = new ZipFile(tmpFilePath.toString())) { + ClientJarTask task; + while ((task = CLIENT_JAR_TASKS.poll()) != null) { + try (InputStream fileStream = localeJar.getInputStream(localeJar.getEntry(task.asset))) { + task.ifNewDownload.accept(fileStream); + task.whenDone.run(); + } + } + } + + // Store the latest jar hash + Path cache = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache"); + Files.createDirectories(cache); + FileUtils.writeFile(cache.resolve(CLIENT_JAR_HASH_FILE).toString(), CLIENT_JAR_INFO.getSha1().toCharArray()); + + // Delete the nolonger needed client/server jar + Files.delete(tmpFilePath); + + GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us.done")); + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.en_us"), e); + } + } + + public static void saveFile(File location, InputStream fileStream) throws IOException { + try (FileOutputStream outStream = new FileOutputStream(location)) { + + // Write the file to the locale dir + byte[] buf = new byte[fileStream.available()]; + int length; + while ((length = fileStream.read(buf)) != -1) { + outStream.write(buf, 0, length); + } + + // Flush all changes to disk and cleanup + outStream.flush(); + } + } + + /** + * A process that requires we download the client jar. + * Designed to accommodate Geyser updates that require more assets from the jar. + */ + public record ClientJarTask(String asset, InputStreamConsumer ifNewDownload, Runnable whenDone) { + } + + @FunctionalInterface + public interface InputStreamConsumer { + void accept(InputStream stream) throws IOException; + } + + /* Classes that map to JSON files served by Mojang */ + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class VersionManifest { + @JsonProperty("latest") + private LatestVersion latestVersion; + + @JsonProperty("versions") + private List versions; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class LatestVersion { + @JsonProperty("release") + private String release; + + @JsonProperty("snapshot") + private String snapshot; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class Version { + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("url") + private String url; + + @JsonProperty("time") + private String time; + + @JsonProperty("releaseTime") + private String releaseTime; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class VersionInfo { + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("time") + private String time; + + @JsonProperty("releaseTime") + private String releaseTime; + + @JsonProperty("assetIndex") + private AssetIndex assetIndex; + + @JsonProperty("downloads") + private Map downloads; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class VersionDownload { + @JsonProperty("sha1") + private String sha1; + + @JsonProperty("size") + private int size; + + @JsonProperty("url") + private String url; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + static class AssetIndex { + @JsonProperty("id") + private String id; + + @JsonProperty("sha1") + private String sha1; + + @JsonProperty("size") + private int size; + + @JsonProperty("totalSize") + private int totalSize; + + @JsonProperty("url") + private String url; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + public static class Asset { + @JsonProperty("hash") + private String hash; + + @JsonProperty("size") + private int size; + } + + private AssetUtils() { + } +} diff --git a/core/src/main/resources/bedrock/skin/skin_alex.png b/core/src/main/resources/bedrock/skin/skin_alex.png deleted file mode 100644 index ffd8e0719a1a4fac1f58895ed3a30130d3aa4b46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2105 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 diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 502441560..ac5e76cd1 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -111,7 +111,7 @@ debug-mode: false # Allow third party capes to be visible. Currently allowing: # OptiFine capes, LabyMod capes, 5Zig capes and MinecraftCapes -allow-third-party-capes: true +allow-third-party-capes: false # Allow third party deadmau5 ears to be visible. Currently allowing: # MinecraftCapes