diff --git a/commons/src/main/java/net/swofty/commons/MinecraftVersion.java b/commons/src/main/java/net/swofty/commons/MinecraftVersion.java new file mode 100644 index 0000000000..66a9d754fb --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/MinecraftVersion.java @@ -0,0 +1,41 @@ +package net.swofty.commons; + +import lombok.Getter; + +@Getter +public enum MinecraftVersion { + MINECRAFT_1_20_5(766, 41.0, 32), + MINECRAFT_1_21(767, 48.0, 34), + MINECRAFT_1_21_2(768, 57.0, 42), + MINECRAFT_1_21_4(769, 61.0, 46), + MINECRAFT_1_21_5(770, 71.0, 55), + MINECRAFT_1_21_6(771, 80.0, 63), + MINECRAFT_1_21_7(772, 81.0, 64), + MINECRAFT_1_21_9(773, 88.0, 69), + MINECRAFT_1_21_11(774, 94.1, 75), + MINECRAFT_26_1(775, 101.1, 84), + MINECRAFT_26_2(776, 107.1, 88); + + private final int protocolVersion; + private final double dataPackVersion; + private final int packVersion; + + MinecraftVersion(int protocolVersion, double dataPackVersion, int packVersion) { + this.protocolVersion = protocolVersion; + this.dataPackVersion = dataPackVersion; + this.packVersion = packVersion; + } + + public static MinecraftVersion byProtocol(int protocolVersion) { + for (MinecraftVersion version : values()) { + if (version.protocolVersion == protocolVersion) { + return version; + } + } + return null; + } + + public static MinecraftVersion latest() { + return MINECRAFT_26_2; + } +} diff --git a/commons/src/main/java/net/swofty/commons/config/Settings.java b/commons/src/main/java/net/swofty/commons/config/Settings.java index af30a4e011..e4352c52fd 100644 --- a/commons/src/main/java/net/swofty/commons/config/Settings.java +++ b/commons/src/main/java/net/swofty/commons/config/Settings.java @@ -57,6 +57,12 @@ public static class LimboSettings { public static class ResourcePackSettings { @Comment("Base URL of the pack server (e.g. http://0.0.0.0:7270)") private String serverUrl = "http://127.0.0.1:7270"; + + @Comment("Whether to use Hypixel's official resource pack API instead of building a local pack") + private boolean useHypixelApi = false; + + @Comment("URL of Hypixel's resource pack metadata API") + private String hypixelApiUrl = "https://api.hypixel.net/v2/resources/packs"; } @Getter diff --git a/configuration/config.docker.yml b/configuration/config.docker.yml index 73d8b1bfd4..c04da64250 100644 --- a/configuration/config.docker.yml +++ b/configuration/config.docker.yml @@ -14,5 +14,7 @@ limbo: resource-packs: skyblockpack: server-url: http://127.0.0.1:7270 + use-hypixel-api: false + hypixel-api-url: https://api.hypixel.net/v2/resources/packs ravengard: server-url: http://127.0.0.1:7270 diff --git a/configuration/config.example.yml b/configuration/config.example.yml index 22644649f2..813ffa4256 100644 --- a/configuration/config.example.yml +++ b/configuration/config.example.yml @@ -24,5 +24,10 @@ integrations: limbo: host-name: 127.0.0.1 port: 65535 -# Resource pack settings keyed by pack name (e.g. testingpack, bedwarspack) -resource-packs: { } +# Resource pack settings keyed by pack name (e.g. testingpack, skyblockpack) +resource-packs: + skyblockpack: + server-url: http://127.0.0.1:7270 + # Use Hypixel's official pack and select its format for each player's version + use-hypixel-api: false + hypixel-api-url: https://api.hypixel.net/v2/resources/packs diff --git a/packer/src/main/java/net/swofty/packer/HypixelPackBuilder.java b/packer/src/main/java/net/swofty/packer/HypixelPackBuilder.java index 971cbde512..ccbdbbe624 100644 --- a/packer/src/main/java/net/swofty/packer/HypixelPackBuilder.java +++ b/packer/src/main/java/net/swofty/packer/HypixelPackBuilder.java @@ -1,19 +1,21 @@ package net.swofty.packer; -import net.kyori.adventure.text.Component; import team.unnamed.creative.BuiltResourcePack; -import team.unnamed.creative.ResourcePack; -import team.unnamed.creative.metadata.pack.FormatVersion; -import team.unnamed.creative.metadata.pack.PackFormat; -import team.unnamed.creative.metadata.pack.PackMeta; -import team.unnamed.creative.serialize.minecraft.MinecraftResourcePackReader; -import team.unnamed.creative.serialize.minecraft.MinecraftResourcePackWriter; +import team.unnamed.creative.base.Writable; -import java.io.File; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; public class HypixelPackBuilder { - private static final FormatVersion FORMAT_VERSION = FormatVersion.of(FormatVersion.FORMAT_26_1); - private final PackDefinition definition; public HypixelPackBuilder(PackDefinition definition) { @@ -21,16 +23,55 @@ public HypixelPackBuilder(PackDefinition definition) { } public BuiltResourcePack build() { - File packDirectory = new File(definition.getPackDirectory()).getAbsoluteFile(); - if (!packDirectory.isDirectory()) { - throw new IllegalStateException("Pack directory does not exist: " + packDirectory.getPath()); + Path packDirectory = Path.of(definition.getPackDirectory()).toAbsolutePath(); + + if (!Files.isDirectory(packDirectory)) { + throw new IllegalStateException( + "Pack directory does not exist: " + packDirectory + ); } - ResourcePack pack = MinecraftResourcePackReader.minecraft() - .readFromDirectory(packDirectory); - pack.packMeta(PackMeta.of(PackFormat.format(FORMAT_VERSION, FORMAT_VERSION), Component.text("Hypixel"))); + try { + byte[] bytes = zipDirectory(packDirectory); + + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + String hash = HexFormat.of().formatHex(digest.digest(bytes)); + + return BuiltResourcePack.of( + Writable.bytes(bytes), + hash + ); + } catch (IOException e) { + throw new UncheckedIOException("Failed to build resource pack", e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-1 is unavailable", e); + } + } + + private byte[] zipDirectory(Path directory) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + try (ZipOutputStream zip = new ZipOutputStream(output); + Stream paths = Files.walk(directory)) { + + paths.filter(Files::isRegularFile).forEach(path -> { + String entryName = directory.relativize(path) + .toString() + .replace('\\', '/'); + + try { + zip.putNextEntry(new ZipEntry(entryName)); + Files.copy(path, zip); + zip.closeEntry(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); + } - return MinecraftResourcePackWriter.minecraft().build(pack); + return output.toByteArray(); } } diff --git a/proxy.api/src/main/java/net/swofty/proxyapi/ProxyPlayer.java b/proxy.api/src/main/java/net/swofty/proxyapi/ProxyPlayer.java index 9ed8cb57bf..00822c66a3 100644 --- a/proxy.api/src/main/java/net/swofty/proxyapi/ProxyPlayer.java +++ b/proxy.api/src/main/java/net/swofty/proxyapi/ProxyPlayer.java @@ -73,6 +73,34 @@ public CompletableFuture isOnline() { return future; } + public CompletableFuture getProtocolVersion() { + CompletableFuture future = new CompletableFuture<>(); + RedisClient.requestProxy(PLAYER_HANDLER, + new PlayerHandlerProtocol.Request(uuid.toString(), PlayerHandlerProtocol.Action.VERSION, Map.of())) + .thenAccept(response -> { + if (!response.success()) { + future.completeExceptionally(new IllegalStateException(response.error())); + return; + } + + Object version = response.data().get("protocolVersion"); + if (version instanceof Number number) { + future.complete(number.intValue()); + } else { + future.completeExceptionally(new IllegalStateException("Proxy returned no player version")); + } + }) + .exceptionally(error -> { + future.completeExceptionally(error); + return null; + }); + return future; + } + + public CompletableFuture getVersion() { + return getProtocolVersion(); + } + public void runEvent(ProxyUnderstandableEvent event) { RedisClient.requestProxy(PLAYER_HANDLER, new PlayerHandlerProtocol.Request(uuid.toString(), PlayerHandlerProtocol.Action.EVENT, diff --git a/setup/internal/installer/files.go b/setup/internal/installer/files.go index 1c0bab044a..f831b0e124 100644 --- a/setup/internal/installer/files.go +++ b/setup/internal/installer/files.go @@ -168,6 +168,8 @@ resource-pack: resource-packs: skyblockpack: server-url: http://127.0.0.1:7270 + use-hypixel-api: false + hypixel-api-url: https://api.hypixel.net/v2/resources/packs ravengard: server-url: http://127.0.0.1:7270 ` diff --git a/type.generic/src/main/java/net/swofty/type/generic/resourcepack/HypixelResourcePack.java b/type.generic/src/main/java/net/swofty/type/generic/resourcepack/HypixelResourcePack.java index 754e4ebf49..43cff6a5c4 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/resourcepack/HypixelResourcePack.java +++ b/type.generic/src/main/java/net/swofty/type/generic/resourcepack/HypixelResourcePack.java @@ -5,10 +5,17 @@ import net.swofty.type.generic.user.HypixelPlayer; public interface HypixelResourcePack { + record PackInfo(String url, String hash) { + } + String getPackName(); String getPackUrl(); String getPackHash(); + default PackInfo getPackFor(HypixelPlayer player) { + return new PackInfo(getPackUrl(), getPackHash()); + } + boolean isRequired(); void initialize(); diff --git a/type.generic/src/main/java/net/swofty/type/generic/resourcepack/ResourcePackManager.java b/type.generic/src/main/java/net/swofty/type/generic/resourcepack/ResourcePackManager.java index 57d44b376b..4d77448751 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/resourcepack/ResourcePackManager.java +++ b/type.generic/src/main/java/net/swofty/type/generic/resourcepack/ResourcePackManager.java @@ -5,6 +5,7 @@ import net.kyori.adventure.resource.ResourcePackRequest; import net.kyori.adventure.text.Component; import net.minestom.server.entity.Player; +import net.swofty.type.generic.user.HypixelPlayer; import org.tinylog.Logger; import java.net.URI; @@ -37,8 +38,17 @@ public void sendPack(Player player) { * already-received chunk sections permanently unrendered on the client. */ public void sendPackBlocking(Player player, int timeoutSeconds) { - String packUrl = activePack.getPackUrl(); - String packHash = activePack.getPackHash(); + HypixelResourcePack.PackInfo pack = player instanceof HypixelPlayer hypixelPlayer + ? activePack.getPackFor(hypixelPlayer) + : new HypixelResourcePack.PackInfo(activePack.getPackUrl(), activePack.getPackHash()); + + if (pack == null) { + Logger.warn("Resource pack could not be resolved for " + player.getUsername() + ", skipping pack send"); + return; + } + + String packUrl = pack.url(); + String packHash = pack.hash(); if (packUrl == null || packUrl.isEmpty() || packHash == null || packHash.isEmpty()) { Logger.warn("Resource pack URL or hash not configured, skipping pack send for " + player.getUsername()); diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/resourcepack/HypixelSkyblockPackApi.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/resourcepack/HypixelSkyblockPackApi.java new file mode 100644 index 0000000000..c29a2cce6b --- /dev/null +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/resourcepack/HypixelSkyblockPackApi.java @@ -0,0 +1,133 @@ +package net.swofty.type.skyblockgeneric.resourcepack; + +import net.swofty.commons.MinecraftVersion; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; + +final class HypixelSkyblockPackApi { + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(REQUEST_TIMEOUT) + .build(); + + private HypixelSkyblockPackApi() { + } + + static Catalog fetch(String apiUrl) throws IOException, InterruptedException { + URI uri = URI.create(apiUrl); + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Hypixel API returned HTTP " + response.statusCode()); + } + + return parse(response.body()); + } + + static Catalog parse(String responseBody) { + JSONObject response = new JSONObject(responseBody); + if (!response.optBoolean("success")) { + throw new IllegalStateException("Hypixel API returned an unsuccessful response"); + } + + JSONArray packs = response.optJSONArray("packs"); + if (packs == null) { + throw new IllegalStateException("Hypixel API response did not contain packs"); + } + + for (int i = 0; i < packs.length(); i++) { + JSONObject pack = packs.optJSONObject(i); + if (pack != null && "SkyBlock".equals(pack.optString("id"))) { + return parseCatalog(pack); + } + } + + throw new IllegalStateException("Hypixel API response did not contain the SkyBlock pack"); + } + + private static Catalog parseCatalog(JSONObject pack) { + JSONArray versions = pack.optJSONArray("versions"); + if (versions == null) { + throw new IllegalStateException("Hypixel API response did not contain SkyBlock pack versions"); + } + + NavigableMap byPackFormat = new TreeMap<>(); + for (int i = 0; i < versions.length(); i++) { + JSONObject version = versions.optJSONObject(i); + if (version == null) { + continue; + } + + String url = version.optString("url").trim(); + String hash = version.optString("hash").trim(); + if (!version.has("packFormat") || url.isEmpty() || hash.isEmpty()) { + continue; + } + + int packFormat = version.getInt("packFormat"); + URI.create(url); + byPackFormat.put(packFormat, new Version(packFormat, url, hash)); + } + + if (byPackFormat.isEmpty()) { + throw new IllegalStateException("Hypixel API response did not contain usable SkyBlock pack versions"); + } + + return new Catalog(byPackFormat); + } + + record Version(int packFormat, String url, String hash) { + Version { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(hash, "hash"); + } + } + + static final class Catalog { + private final NavigableMap versions; + + private Catalog(NavigableMap versions) { + this.versions = Collections.unmodifiableNavigableMap(new TreeMap<>(versions)); + } + + Version latest() { + return versions.lastEntry().getValue(); + } + + Version forProtocol(int protocolVersion) { + MinecraftVersion minecraftVersion = MinecraftVersion.byProtocol(protocolVersion); + if (minecraftVersion == null) { + if (protocolVersion > MinecraftVersion.latest().getProtocolVersion()) { + return latest(); + } + return null; + } + + Version version = versions.get(minecraftVersion.getPackVersion()); + if (version != null) { + return version; + } + if (minecraftVersion.getPackVersion() > versions.lastKey()) { + return latest(); + } + return null; + } + + } +} diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/resourcepack/SkyblockPack.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/resourcepack/SkyblockPack.java index 2b135c1ef9..fdac6c63a0 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/resourcepack/SkyblockPack.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/resourcepack/SkyblockPack.java @@ -8,20 +8,48 @@ import org.tinylog.Logger; import team.unnamed.creative.BuiltResourcePack; +import java.util.concurrent.TimeUnit; + public class SkyblockPack implements HypixelResourcePack { private static final SkyblockPackDefinition DEFINITION = SkyblockPackDefinition.INSTANCE; + private final PackInfo defaultPack; + private final HypixelSkyblockPackApi.Catalog officialPacks; + private final String packUrl; private final String packHash; public SkyblockPack(String serverUrl, String hash) { - this.packHash = hash; - this.packUrl = serverUrl + "/" + hash + ".zip"; + this(new PackInfo(serverUrl + "/" + hash + ".zip", hash), null); + } + + private SkyblockPack(PackInfo defaultPack, HypixelSkyblockPackApi.Catalog officialPacks) { + this.defaultPack = defaultPack; + this.officialPacks = officialPacks; + this.packUrl = defaultPack.url(); + this.packHash = defaultPack.hash(); } public static SkyblockPack fromConfig() { Settings.ResourcePackSettings settings = HypixelResourcePack.getConfigFor(DEFINITION.getPackName()); + if (settings.isUseHypixelApi()) { + try { + Logger.info("Loading the official Hypixel SkyBlock resource pack metadata..."); + HypixelSkyblockPackApi.Catalog officialPacks = HypixelSkyblockPackApi.fetch(settings.getHypixelApiUrl()); + HypixelSkyblockPackApi.Version latest = officialPacks.latest(); + PackInfo defaultPack = new PackInfo(latest.url(), latest.hash()); + Logger.info("Loaded official Hypixel SkyBlock resource pack format {} with hash {}", + latest.packFormat(), latest.hash()); + return new SkyblockPack(defaultPack, officialPacks); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while loading the official Hypixel SkyBlock resource pack", exception); + } catch (Exception exception) { + throw new IllegalStateException("Failed to load the official Hypixel SkyBlock resource pack", exception); + } + } + Logger.info("Building resource pack '{}' from {}...", DEFINITION.getPackName(), DEFINITION.getPackDirectory()); HypixelPackBuilder builder = new HypixelPackBuilder(DEFINITION); BuiltResourcePack built = builder.build(); @@ -45,6 +73,29 @@ public String getPackHash() { return packHash; } + @Override + public PackInfo getPackFor(HypixelPlayer player) { + if (officialPacks == null) { + return defaultPack; + } + + try { + int protocolVersion = player.asProxyPlayer().getProtocolVersion().get(3, TimeUnit.SECONDS); + HypixelSkyblockPackApi.Version version = officialPacks.forProtocol(protocolVersion); + if (version == null) { + Logger.warn("No official Hypixel SkyBlock resource pack is available for protocol version {}", protocolVersion); + return null; + } + return new PackInfo(version.url(), version.hash()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + Logger.warn("Interrupted while resolving the official Hypixel SkyBlock resource pack for {}", player.getUsername()); + } catch (Exception exception) { + Logger.warn("Failed to resolve the official Hypixel SkyBlock resource pack for {}, using the latest pack", player.getUsername()); + } + return defaultPack; + } + @Override public boolean isRequired() { return true; diff --git a/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java b/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java index a250e4e01c..a788400cce 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java @@ -2,11 +2,13 @@ import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ServerConnection; +import com.viaversion.viaversion.api.Via; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.json.JSONComponentSerializer; import net.swofty.commons.ServerType; import net.swofty.commons.StringUtility; import net.swofty.commons.UnderstandableProxyServer; +import net.swofty.commons.config.ConfigProvider; import net.swofty.commons.protocol.RedisProtocol; import net.swofty.commons.protocol.objects.proxy.from.RefreshCoopDataProtocol; import net.swofty.commons.protocol.objects.proxy.from.RunEventProtocol; @@ -59,6 +61,15 @@ public PlayerHandlerProtocol.Response handle(PlayerHandlerProtocol.Request messa Optional potentialServer = player.getCurrentServer(); switch (action) { + case VERSION -> { + int protocolVersion = player.getProtocolVersion().getProtocol(); + if (ConfigProvider.settings().getIntegrations().isViaVersion()) { + protocolVersion = Via.getAPI().getPlayerVersion(uuid); + } + return new PlayerHandlerProtocol.Response(Map.of( + "protocolVersion", protocolVersion, + "version", protocolVersion), true, null); + } case RESOLVE_TRANSFER -> { ServerType type = ServerType.valueOf((String) data.get("type")); if (!GameManager.hasType(type) || !GameManager.isAnyEmpty(type)) { diff --git a/website/docs/docker/setup.md b/website/docs/docker/setup.md index 46345a314d..b228393ec6 100644 --- a/website/docs/docker/setup.md +++ b/website/docs/docker/setup.md @@ -149,6 +149,8 @@ The pack URL is handed to your client rather than resolved inside the container, resource-packs: skyblockpack: server-url: http://192.0.2.10:7270 + use-hypixel-api: false + hypixel-api-url: https://api.hypixel.net/v2/resources/packs ravengard: server-url: http://192.0.2.10:7270 ``` diff --git a/website/docs/setup/resource-pack.md b/website/docs/setup/resource-pack.md index 83efcbb1b5..fd941d4498 100644 --- a/website/docs/setup/resource-pack.md +++ b/website/docs/setup/resource-pack.md @@ -51,7 +51,7 @@ On startup, the server logs: ## Configure the Game Server -Set the pack server URL in `configuration/config.yml`: +For locally built packs, set the pack server URL in `configuration/config.yml`: ```yaml resource-packs: @@ -59,10 +59,21 @@ resource-packs: server-url: "http://127.0.0.1:7270" ``` +SkyBlock can use Hypixel's official pack metadata API instead. The selected pack URL is sent directly to each player, using the pack format matching their client version: + +```yaml +resource-packs: + skyblockpack: + use-hypixel-api: true + hypixel-api-url: "https://api.hypixel.net/v2/resources/packs" +``` + Notes: -- Hash is generated automatically at runtime by the pack builder. +- Local pack hashes are generated automatically at runtime by the pack builder; official-pack hashes come from the API. - You do not manually set a `resource-pack-hash` field. +- `use-hypixel-api` uses the original client protocol reported by ViaVersion when it is enabled. +- The client does not download `skyblockpack` from the local pack server when `use-hypixel-api` is enabled. ## Verify In-Game