/* * 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.pack; import it.unimi.dsi.fastutil.Pair; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.Constants; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.api.pack.ResourcePackManifest; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.type.CustomSkull; import org.geysermc.geyser.skin.SkinProvider; import org.geysermc.geyser.util.FileUtils; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.*; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.*; import java.util.function.UnaryOperator; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; public class SkullResourcePackManager { private static final long RESOURCE_PACK_VERSION = 8; private static final Path SKULL_SKIN_CACHE_PATH = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("player_skulls"); public static final Map SKULL_SKINS = new Object2ObjectOpenHashMap<>(); @SuppressWarnings("ResultOfMethodCallIgnored") public static @Nullable Path createResourcePack() { Path cachePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache"); try { Files.createDirectories(cachePath); } catch (IOException e) { GeyserImpl.getInstance().getLogger().severe("Unable to create directories for player skull resource pack!", e); return null; } cleanSkullSkinCache(); Path packPath = cachePath.resolve("player_skulls.mcpack"); File packFile = packPath.toFile(); if (BlockRegistries.CUSTOM_SKULLS.get().isEmpty() || !GeyserImpl.getInstance().getConfig().isAddNonBedrockItems()) { packFile.delete(); // No need to keep resource pack return null; } if (packFile.exists() && canReusePack(packFile)) { GeyserImpl.getInstance().getLogger().info("Reusing cached player skull resource pack."); return packPath; } // We need to create the resource pack from scratch GeyserImpl.getInstance().getLogger().info("Creating skull resource pack."); packFile.delete(); try (ZipOutputStream zipOS = new ZipOutputStream(Files.newOutputStream(packPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE))) { addBaseResources(zipOS); addSkinTextures(zipOS); addAttachables(zipOS); GeyserImpl.getInstance().getLogger().info("Finished creating skull resource pack."); return packPath; } catch (IOException e) { GeyserImpl.getInstance().getLogger().severe("Unable to create player skull resource pack!", e); GeyserImpl.getInstance().getLogger().severe("Bedrock players will see dirt blocks instead of custom skull blocks."); packFile.delete(); } return null; } public static void cacheSkullSkin(String skinHash) throws IOException { String skinUrl = Constants.MINECRAFT_SKIN_SERVER_URL + skinHash; Path skinPath = SKULL_SKINS.get(skinHash); if (skinPath != null) { return; } Files.createDirectories(SKULL_SKIN_CACHE_PATH); skinPath = SKULL_SKIN_CACHE_PATH.resolve(skinHash + ".png"); if (Files.exists(skinPath)) { SKULL_SKINS.put(skinHash, skinPath); return; } BufferedImage image = SkinProvider.requestImage(skinUrl, null); // 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 // Right, Front, Left, Back, Top, Bottom - head // Right, Front, Left, Back, Top, Bottom - hat Graphics g = skullTexture.createGraphics(); // Right, Front, Left, Back of the head g.drawImage(image, 0, 0, 32, 8, 0, 8, 32, 16, null); // Right, Front, Left, Back of the hat g.drawImage(image, 0, 8, 32, 16, 32, 8, 64, 16, null); // Top and bottom of the head g.drawImage(image, 32, 0, 48, 8, 8, 0, 24, 8, null); // Top and bottom of the hat g.drawImage(image, 32, 8, 48, 16, 40, 0, 56, 8, null); g.dispose(); image.flush(); ImageIO.write(skullTexture, "png", skinPath.toFile()); SKULL_SKINS.put(skinHash, skinPath); GeyserImpl.getInstance().getLogger().debug("Cached player skull to " + skinPath + " for " + skinHash); } public static void cleanSkullSkinCache() { // No need to clean up if skin cache does not exist if (!Files.exists(SKULL_SKIN_CACHE_PATH)) { return; } try (Stream stream = Files.list(SKULL_SKIN_CACHE_PATH)) { int removeCount = 0; for (Path path : stream.toList()) { String skinHash = path.getFileName().toString(); skinHash = skinHash.substring(0, skinHash.length() - ".png".length()); if (!SKULL_SKINS.containsKey(skinHash) && path.toFile().delete()) { removeCount++; } } if (removeCount != 0) { GeyserImpl.getInstance().getLogger().debug("Removed " + removeCount + " unnecessary skull skins."); } } catch (IOException e) { GeyserImpl.getInstance().getLogger().debug("Unable to clean up skull skin cache."); if (GeyserImpl.getInstance().getConfig().isDebugMode()) { e.printStackTrace(); } } } private static void addBaseResources(ZipOutputStream zipOS) throws IOException { try (BufferedReader reader = new BufferedReader(new InputStreamReader(GeyserImpl.getInstance().getBootstrap().getResourceOrThrow("bedrock/skull_resource_pack_files.txt")))) { List lines = reader.lines().toList(); for (String path : lines) { ZipEntry entry = new ZipEntry(path); zipOS.putNextEntry(entry); String resourcePath = "bedrock/" + path; switch (path) { case "skull_resource_pack/manifest.json" -> fillTemplate(zipOS, resourcePath, SkullResourcePackManager::fillManifestJson); case "skull_resource_pack/textures/terrain_texture.json" -> fillTemplate(zipOS, resourcePath, SkullResourcePackManager::fillTerrainTextureJson); default -> zipOS.write(FileUtils.readAllBytes(resourcePath)); } zipOS.closeEntry(); } addFloorGeometries(zipOS); ZipEntry entry = new ZipEntry("skull_resource_pack/pack_icon.png"); zipOS.putNextEntry(entry); zipOS.write(FileUtils.readAllBytes("assets/geyser/icon.png")); zipOS.closeEntry(); } } private static void addFloorGeometries(ZipOutputStream zipOS) throws IOException { String template = FileUtils.readToString("bedrock/skull_resource_pack/models/blocks/player_skull_floor.geo.json"); String[] quadrants = {"a", "b", "c", "d"}; for (int i = 0; i < quadrants.length; i++) { String quadrant = quadrants[i]; float yRotation = i * 22.5f; String contents = template .replace("${quadrant}", quadrant) .replace("${y_rotation}", String.valueOf(yRotation)); ZipEntry entry = new ZipEntry("skull_resource_pack/models/blocks/player_skull_floor_" + quadrant + ".geo.json"); zipOS.putNextEntry(entry); zipOS.write(contents.getBytes(StandardCharsets.UTF_8)); zipOS.closeEntry(); } } private static void addAttachables(ZipOutputStream zipOS) throws IOException { String template = FileUtils.readToString("bedrock/skull_resource_pack/attachables/template_attachable.json"); for (CustomSkull skull : BlockRegistries.CUSTOM_SKULLS.get().values()) { ZipEntry entry = new ZipEntry("skull_resource_pack/attachables/" + truncateHash(skull.getSkinHash()) + ".json"); zipOS.putNextEntry(entry); zipOS.write(fillAttachableJson(template, skull).getBytes(StandardCharsets.UTF_8)); zipOS.closeEntry(); } } private static void addSkinTextures(ZipOutputStream zipOS) throws IOException { for (Path skinPath : SKULL_SKINS.values()) { ZipEntry entry = new ZipEntry("skull_resource_pack/textures/blocks/" + truncateHash(skinPath.getFileName().toString()) + ".png"); zipOS.putNextEntry(entry); try (InputStream stream = Files.newInputStream(skinPath)) { stream.transferTo(zipOS); } zipOS.closeEntry(); } } private static void fillTemplate(ZipOutputStream zipOS, String path, UnaryOperator filler) throws IOException { String template = FileUtils.readToString(path); String result = filler.apply(template); zipOS.write(result.getBytes(StandardCharsets.UTF_8)); } private static String fillAttachableJson(String template, CustomSkull skull) { return template.replace("${identifier}", skull.getCustomBlockData().identifier()) .replace("${texture}", truncateHash(skull.getSkinHash())); } private static String fillManifestJson(String template) { Pair uuids = generatePackUUIDs(); return template.replace("${uuid1}", uuids.first().toString()) .replace("${uuid2}", uuids.second().toString()); } private static String fillTerrainTextureJson(String template) { StringBuilder textures = new StringBuilder(); for (String skinHash : SKULL_SKINS.keySet()) { String texture = String.format("\"geyser.%s_player_skin\":{\"textures\":\"textures/blocks/%s\"},\n", skinHash, truncateHash(skinHash)); textures.append(texture); } if (textures.length() != 0) { // Remove trailing comma textures.delete(textures.length() - 2, textures.length()); } return template.replace("${texture_data}", textures); } private static Pair generatePackUUIDs() { UUID uuid1 = UUID.randomUUID(); UUID uuid2 = UUID.randomUUID(); try { MessageDigest md = MessageDigest.getInstance("SHA-256"); for (int i = 0; i < 8; i++) { md.update((byte) ((RESOURCE_PACK_VERSION >> (i * 8)) & 0xFF)); } SKULL_SKINS.keySet().stream() .sorted() .map(hash -> hash.getBytes(StandardCharsets.UTF_8)) .forEach(md::update); ByteBuffer skinHashes = ByteBuffer.wrap(md.digest()); uuid1 = new UUID(skinHashes.getLong(), skinHashes.getLong()); uuid2 = new UUID(skinHashes.getLong(), skinHashes.getLong()); } catch (NoSuchAlgorithmException e) { GeyserImpl.getInstance().getLogger().severe("Unable to get SHA-256 Message Digest instance! Bedrock players will have to re-downloaded the player skull resource pack after each server restart.", e); } return Pair.of(uuid1, uuid2); } private static boolean canReusePack(File packFile) { Pair uuids = generatePackUUIDs(); try (ZipFile zipFile = new ZipFile(packFile)) { Optional manifestEntry = zipFile.stream() .filter(entry -> entry.getName().contains("manifest.json")) .findFirst(); if (manifestEntry.isPresent()) { GeyserResourcePackManifest manifest = FileUtils.loadJson(zipFile.getInputStream(manifestEntry.get()), GeyserResourcePackManifest.class); if (!uuids.first().equals(manifest.header().uuid())) { return false; } Optional resourceUUID = manifest.modules().stream() .filter(module -> "resources".equals(module.type())) .findFirst() .map(ResourcePackManifest.Module::uuid); return resourceUUID.isPresent() && uuids.second().equals(resourceUUID.get()); } } catch (IOException e) { GeyserImpl.getInstance().getLogger().debug("Cached player skull resource pack was invalid! The pack will be recreated."); } return false; } private static String truncateHash(String hash) { return hash.substring(0, Math.min(hash.length(), 32)); } }