diff --git a/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java index c2f1cd42770..19c16f811dd 100644 --- a/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java +++ b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java @@ -26,10 +26,13 @@ package org.geysermc.geyser.api.event.bedrock; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.api.connection.GeyserConnection; import org.geysermc.geyser.api.event.connection.ConnectionEvent; import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.option.ResourcePackOption; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -42,7 +45,7 @@ public SessionLoadResourcePacksEvent(@NonNull GeyserConnection connection) { } /** - * Gets an unmodifiable list of {@link ResourcePack}s that will be sent to the client. + * Gets an unmodifiable list of {@link ResourcePack}'s that will be sent to the client. * * @return an unmodifiable list of resource packs that will be sent to the client. */ @@ -51,11 +54,49 @@ public SessionLoadResourcePacksEvent(@NonNull GeyserConnection connection) { /** * Registers a {@link ResourcePack} to be sent to the client. * + * @param pack a resource pack that will be sent to the client + * @return true if the resource pack was added successfully, + * or false if already present + */ + public abstract boolean register(@NonNull ResourcePack pack); + + /** + * Registers a {@link ResourcePack} to be sent to the client, alongside + * specific options. + * * @param resourcePack a resource pack that will be sent to the client. + * @param options {@link ResourcePackOption}'s that specify how clients load the pack * @return true if the resource pack was added successfully, * or false if already present */ - public abstract boolean register(@NonNull ResourcePack resourcePack); + public abstract boolean register(@NonNull ResourcePack resourcePack, @Nullable ResourcePackOption... options); + + /** + * Sets {@link ResourcePackOption}'s for a resource pack + * + * @param uuid the resource pack uuid to register the options for + * @param options the options to register for the pack + * @throws IllegalArgumentException if the pack is not registered. + */ + public abstract void registerOptions(@NonNull UUID uuid, @NonNull ResourcePackOption... options); + + /** + * Returns the subpack options set for a specific resource pack uuid. + * These are not modifiable. + * + * @param uuid the resource pack for which the options are set + * @return a list of {@link ResourcePackOption} + */ + public abstract Collection> options(@NonNull UUID uuid); + + /** + * Returns the current option, or null, for a given ResourcePackOption type. + * + * @param uuid the resource pack for which the option type is set + * @param type the {@link ResourcePackOption.Type} of the option to query + * @throws IllegalArgumentException if the pack is not registered. + */ + public abstract @Nullable ResourcePackOption option(@NonNull UUID uuid, ResourcePackOption.@NonNull Type type); /** * Unregisters a resource pack from being sent to the client. diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java new file mode 100644 index 00000000000..44b158fba25 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.event.lifecycle; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.event.Event; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.option.ResourcePackOption; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * Called when {@link ResourcePack}'s are loaded within Geyser. + */ +public abstract class GeyserDefineResourcePacksEvent implements Event { + + /** + * Gets an unmodifiable list of {@link ResourcePack}'s that will be sent to clients. + * + * @return an unmodifiable list of resource packs that will be sent to clients. + */ + public abstract @NonNull List resourcePacks(); + + /** + * Registers a {@link ResourcePack} to be sent to the client, optionally alongside + * {@link ResourcePackOption} options specifying how it will be applied on clients. + * + * @param pack a resource pack that will be sent to the client. + * @param options {@link ResourcePackOption}'s that specify how clients load the pack + * @return true if the resource pack was added successfully, + * or false if already present + */ + public abstract boolean register(@NonNull ResourcePack pack, @Nullable ResourcePackOption... options); + + /** + * Sets {@link ResourcePackOption}'s for a resource pack + * + * @param uuid the resource pack uuid to register the options for + * @param options the options to register for the pack + * @throws IllegalArgumentException if the pack is not registered. + */ + public abstract void registerOptions(@NonNull UUID uuid, @NonNull ResourcePackOption... options); + + /** + * Returns the subpack options set for a specific resource pack uuid. + * These are not modifiable. + * + * @param uuid the resource pack uuid for which the options are set + * @return a list of {@link ResourcePackOption} + */ + public abstract Collection> options(@NonNull UUID uuid); + + /** + * Returns the current option, or null, for a given ResourcePackOption type. + * + * @param uuid the resource pack for which the option type is set + * @param type the {@link ResourcePackOption.Type} of the option to query + * @throws IllegalArgumentException if the pack is not registered. + */ + public abstract @Nullable ResourcePackOption option(@NonNull UUID uuid, ResourcePackOption.@NonNull Type type); + + /** + * Unregisters a {@link ResourcePack} from being sent to clients. + * + * @param uuid the uuid of the resource pack to remove. + * @return true whether the resource pack was removed successfully. + */ + public abstract boolean unregister(@NonNull UUID uuid); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java index e9b283ecbbd..d3e494c1418 100644 --- a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java @@ -32,9 +32,8 @@ import java.util.List; /** - * Called when resource packs are loaded within Geyser. - * - * @param resourcePacks a mutable list of the currently listed resource packs + * @deprecated Use {@link GeyserDefineResourcePacksEvent} instead. */ +@Deprecated(forRemoval = true) public record GeyserLoadResourcePacksEvent(@NonNull List resourcePacks) implements Event { } diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java b/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java index 884129fa3f5..37d66177e50 100644 --- a/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java +++ b/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java @@ -53,13 +53,21 @@ public abstract class PackCodec { public abstract long size(); /** - * Serializes the given resource pack into a byte buffer. + * Use {@link #serialize()} instead. + */ + @Deprecated + @NonNull + public SeekableByteChannel serialize(@NonNull ResourcePack resourcePack) throws IOException { + return serialize(); + }; + + /** + * Serializes the given codec into a byte buffer. * - * @param resourcePack the resource pack to serialize * @return the serialized resource pack */ @NonNull - public abstract SeekableByteChannel serialize(@NonNull ResourcePack resourcePack) throws IOException; + public abstract SeekableByteChannel serialize() throws IOException; /** * Creates a new resource pack from this codec. @@ -69,6 +77,13 @@ public abstract class PackCodec { @NonNull protected abstract ResourcePack create(); + /** + * Creates a new resource pack builder from this codec. + * + * @return the new resource pack builder + */ + protected abstract ResourcePack.@NonNull Builder createBuilder(); + /** * Creates a new pack provider from the given path. * @@ -76,7 +91,18 @@ public abstract class PackCodec { * @return the new pack provider */ @NonNull - public static PackCodec path(@NonNull Path path) { + public static PathPackCodec path(@NonNull Path path) { return GeyserApi.api().provider(PathPackCodec.class, path); } + + /** + * Creates a new pack provider from the given url. + * + * @param url the url to create the pack provider from + * @return the new pack provider + */ + @NonNull + public static UrlPackCodec url(@NonNull String url) { + return GeyserApi.api().provider(UrlPackCodec.class, url); + } } diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePack.java b/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePack.java index de1beaf65e2..75e0ba7c8cd 100644 --- a/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePack.java +++ b/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePack.java @@ -26,6 +26,9 @@ package org.geysermc.geyser.api.pack; import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.GeyserApi; + +import java.util.UUID; /** * Represents a resource pack sent to Bedrock clients @@ -59,6 +62,14 @@ public interface ResourcePack { @NonNull String contentKey(); + /** + * @return the resource pack uuid. Shortcut for getting the UUID from the header. + */ + @NonNull + default UUID uuid() { + return manifest().header().uuid(); + } + /** * Creates a resource pack with the given {@link PackCodec}. * @@ -69,4 +80,49 @@ public interface ResourcePack { static ResourcePack create(@NonNull PackCodec codec) { return codec.create(); } + + /** + * Returns a {@link Builder} for a resource pack. + * It can be used to set a content key. + * + * @param codec the {@link PackCodec} to base the builder on + * @return a {@link Builder} to build a resource pack. + */ + static Builder builder(@NonNull PackCodec codec) { + return GeyserApi.api().provider(Builder.class, codec); + } + + /** + * A builder for a resource pack. It allows providing a content key manually. + */ + interface Builder { + + /** + * @return the {@link ResourcePackManifest} of this resource pack + */ + ResourcePackManifest manifest(); + + /** + * @return the {@link PackCodec} of this resource pack + */ + PackCodec codec(); + + /** + * @return the current content key, or an empty string if not set + */ + String contentKey(); + + /** + * Sets a content key for this resource pack. + * + * @param contentKey the content key + * @return this builder + */ + Builder contentKey(@NonNull String contentKey); + + /** + * @return the resource pack + */ + ResourcePack build(); + } } diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePackManifest.java b/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePackManifest.java index c9ccdd6c54a..9cec9a5976c 100644 --- a/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePackManifest.java +++ b/api/src/main/java/org/geysermc/geyser/api/pack/ResourcePackManifest.java @@ -26,6 +26,7 @@ package org.geysermc.geyser.api.pack; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; import java.util.Collection; import java.util.UUID; @@ -66,6 +67,22 @@ public interface ResourcePackManifest { @NonNull Collection dependencies(); + /** + * Gets the subpacks of the resource pack. + * See Microsoft's docs for more information. + * + * @return the subpacks + */ + @NonNull + Collection subpacks(); + + /** + * Gets the settings of the resource pack. + * This is the text shown in the settings menu of a resource pack. + */ + @NonNull + Collection settings(); + /** * Represents the header of a resource pack. */ @@ -172,6 +189,64 @@ interface Dependency { Version version(); } + /** + * Represents a subpack of a resource pack. + * See Micoroft's docs for more information. + */ + interface Subpack { + + /** + * Gets the folder name of this subpack. + * + * @return the folder name + */ + @NonNull + String folderName(); + + /** + * Gets the name of this subpack. Required for each sub pack to be valid. + * It can be sent to the Bedrock client alongside the pack + * to load a particular subpack within a resource pack. + * + * @return the subpack name + */ + @NonNull + String name(); + + /** + * Gets the memory tier of this Subpack, representing how much RAM a device must have to run it. + * Each memory tier requires 0.25 GB of RAM. For example, a memory tier of 0 is no requirement, + * and a memory tier of 4 requires 1GB of RAM. + * + * @return the memory tier + */ + @Nullable + Float memoryTier(); + } + + /** + * Represents a setting that is shown client-side that describe what a pack does. + * Multiple setting entries are shown in separate paragraphs. + */ + interface Setting { + + /** + * The type of the setting. Usually just "label". + * + * @return the type + */ + @NonNull + String type(); + + /** + * The text shown for the setting. + * + * @return the text content + */ + @NonNull + String text(); + } + /** * Represents a version of a resource pack. */ diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java b/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java new file mode 100644 index 00000000000..1f0cc02412e --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.pack; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Represents a pack codec that creates a resource + * pack from a URL. + *

+ * Due to Bedrock limitations, the URL must: + *

    + *
  • be a direct download link to a .zip or .mcpack resource pack
  • + *
  • use the application type `application/zip` and set a correct content length
  • + *
+ */ +public abstract class UrlPackCodec extends PackCodec { + + /** + * Gets the URL to the resource pack location. + * + * @return the URL of the resource pack + */ + @NonNull + public abstract String url(); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/option/PriorityOption.java b/api/src/main/java/org/geysermc/geyser/api/pack/option/PriorityOption.java new file mode 100644 index 00000000000..506d62b8f4b --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/pack/option/PriorityOption.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.pack.option; + +import org.geysermc.geyser.api.GeyserApi; + +/** + * Allows specifying a pack priority that decides the order on how packs are sent to the client. + * Multiple resource packs can override each other. The higher the priority, the "higher" in the stack + * a pack is, and the more a pack can override other packs. + */ +public interface PriorityOption extends ResourcePackOption { + + PriorityOption HIGHEST = PriorityOption.priority(10); + PriorityOption HIGH = PriorityOption.priority(8); + PriorityOption NORMAL = PriorityOption.priority(5); + PriorityOption LOW = PriorityOption.priority(3); + PriorityOption LOWEST = PriorityOption.priority(0); + + /** + * Constructs a priority option based on a value between 0 and 10 + * + * @param priority an integer that is above 0, but smaller than 10 + * @return the priority option + */ + static PriorityOption priority(double priority) { + if (priority < 0 || priority > 10) { + throw new IllegalArgumentException("Priority must be between 0 and 10 inclusive!"); + } + return GeyserApi.api().provider(PriorityOption.class, priority); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/option/ResourcePackOption.java b/api/src/main/java/org/geysermc/geyser/api/pack/option/ResourcePackOption.java new file mode 100644 index 00000000000..c2c0ad19855 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/pack/option/ResourcePackOption.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.pack.option; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.pack.ResourcePack; + +/** + * Represents a resource pack option that can be used to specify how a resource + * pack is sent to Bedrock clients. + */ +public interface ResourcePackOption { + + /** + * @return the option type + */ + @NonNull Type type(); + + /** + * @return the value of the option + */ + @NonNull T value(); + + /** + * Used to validate a specific options for a pack. + * Some options are not applicable to some packs. + * + * @param pack the resource pack to validate the option for + */ + void validate(@NonNull ResourcePack pack); + + enum Type { + SUBPACK, + PRIORITY, + FALLBACK + } + +} diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/option/SubpackOption.java b/api/src/main/java/org/geysermc/geyser/api/pack/option/SubpackOption.java new file mode 100644 index 00000000000..5ebbc5290aa --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/pack/option/SubpackOption.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.pack.option; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.GeyserApi; +import org.geysermc.geyser.api.pack.ResourcePackManifest; + +/** + * Can be used to specify which subpack from a resource pack a player should load. + * Available subpacks can be seen in a resource pack manifest {@link ResourcePackManifest#subpacks()} + */ +public interface SubpackOption extends ResourcePackOption { + + /** + * Creates a subpack option based on a {@link ResourcePackManifest.Subpack} + * + * @param subpack the chosen subpack + * @return a subpack option specifying that subpack + */ + static SubpackOption subpack(ResourcePackManifest.@NonNull Subpack subpack) { + return named(subpack.name()); + } + + /** + * Creates a subpack option based on a subpack name. + * + * @param subpackName the name of the subpack + * @return a subpack option specifying a subpack with that name + */ + static SubpackOption named(@NonNull String subpackName) { + return GeyserApi.api().provider(SubpackOption.class, subpackName); + } + + /** + * Creates a subpack option with no subpack specified + * + * @return a subpack option specifying no subpack + */ + static SubpackOption empty() { + return GeyserApi.api().provider(SubpackOption.class, ""); + } + +} diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/option/UrlFallbackOption.java b/api/src/main/java/org/geysermc/geyser/api/pack/option/UrlFallbackOption.java new file mode 100644 index 00000000000..37268a59b04 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/pack/option/UrlFallbackOption.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.pack.option; + +import org.geysermc.geyser.api.GeyserApi; + +/** + * Can be used for resource packs using the {@link org.geysermc.geyser.api.pack.UrlPackCodec}. + * When a Bedrock client is unable to download a resource pack from a URL, Geyser will, by default, + * serve the resource pack over raknet (as packs are served with the {@link org.geysermc.geyser.api.pack.PathPackCodec}). + * This option can be used to disable that behavior, and disconnect the player instead. + * By default, {@link UrlFallbackOption#TRUE} is set. + */ +public interface UrlFallbackOption extends ResourcePackOption { + + UrlFallbackOption TRUE = fallback(true); + UrlFallbackOption FALSE = fallback(false); + + /** + * Whether to fall back to serving packs over the raknet connection + * @param fallback whether to fall back + * @return a UrlFallbackOption with the specified behavior + */ + static UrlFallbackOption fallback(boolean fallback) { + return GeyserApi.api().provider(UrlFallbackOption.class, fallback); + } + +} diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeLogger.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeLogger.java index e8cf7ee39b0..e9b18acca59 100644 --- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeLogger.java +++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeLogger.java @@ -75,4 +75,11 @@ public void debug(String message) { info(message); } } + + @Override + public void debug(String message, Object... arguments) { + if (debug) { + info(String.format(message, arguments)); + } + } } diff --git a/bootstrap/mod/src/main/java/org/geysermc/geyser/platform/mod/GeyserModLogger.java b/bootstrap/mod/src/main/java/org/geysermc/geyser/platform/mod/GeyserModLogger.java index 9260288d7ab..9903d0d2e43 100644 --- a/bootstrap/mod/src/main/java/org/geysermc/geyser/platform/mod/GeyserModLogger.java +++ b/bootstrap/mod/src/main/java/org/geysermc/geyser/platform/mod/GeyserModLogger.java @@ -84,6 +84,13 @@ public void debug(String message) { } } + @Override + public void debug(String message, Object... arguments) { + if (debug) { + logger.info(message, arguments); + } + } + @Override public void setDebug(boolean debug) { this.debug = debug; diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotLogger.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotLogger.java index 5c6101eaefe..231255fec94 100644 --- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotLogger.java +++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotLogger.java @@ -75,4 +75,11 @@ public void debug(String message) { info(message); } } + + @Override + public void debug(String message, Object... arguments) { + if (debug) { + info(String.format(message, arguments)); + } + } } diff --git a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java index b614a7b2375..03fe7bee5c9 100644 --- a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java +++ b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java @@ -103,6 +103,11 @@ public void debug(String message) { log.debug(ChatColor.GRAY + message); } + @Override + public void debug(String message, Object... arguments) { + log.debug(ChatColor.GRAY + message, arguments); + } + @Override public void setDebug(boolean debug) { Configurator.setLevel(log.getName(), debug ? Level.DEBUG : Level.INFO); diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityLogger.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityLogger.java index 4d10e4daf0d..5155d89589e 100644 --- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityLogger.java +++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityLogger.java @@ -73,4 +73,11 @@ public void debug(String message) { info(message); } } -} \ No newline at end of file + + @Override + public void debug(String message, Object... arguments) { + if (debug) { + logger.info(message, arguments); + } + } +} diff --git a/bootstrap/viaproxy/src/main/java/org/geysermc/geyser/platform/viaproxy/GeyserViaProxyLogger.java b/bootstrap/viaproxy/src/main/java/org/geysermc/geyser/platform/viaproxy/GeyserViaProxyLogger.java index 10f414b51dc..fdcfe227986 100644 --- a/bootstrap/viaproxy/src/main/java/org/geysermc/geyser/platform/viaproxy/GeyserViaProxyLogger.java +++ b/bootstrap/viaproxy/src/main/java/org/geysermc/geyser/platform/viaproxy/GeyserViaProxyLogger.java @@ -75,6 +75,13 @@ public void debug(String message) { } } + @Override + public void debug(String message, Object... arguments) { + if (this.debug) { + this.debug(String.format(message, arguments)); + } + } + @Override public void setDebug(boolean debug) { this.debug = debug; diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index 5171c0633a7..c0edff3aba3 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -83,6 +83,7 @@ import org.geysermc.geyser.network.netty.GeyserServer; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; import org.geysermc.geyser.registry.provider.ProviderSupplier; import org.geysermc.geyser.scoreboard.ScoreboardUpdater; import org.geysermc.geyser.session.GeyserSession; @@ -728,9 +729,7 @@ public void disable() { runIfNonNull(newsHandler, NewsHandler::shutdown); runIfNonNull(erosionUnixListener, UnixSocketClientListener::close); - if (Registries.RESOURCE_PACKS.loaded()) { - Registries.RESOURCE_PACKS.get().clear(); - } + ResourcePackLoader.clear(); this.setEnabled(false); } diff --git a/core/src/main/java/org/geysermc/geyser/GeyserLogger.java b/core/src/main/java/org/geysermc/geyser/GeyserLogger.java index f408de29c02..34a2d6e938c 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserLogger.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserLogger.java @@ -103,6 +103,15 @@ default void debug(@Nullable Object object) { debug(String.valueOf(object)); } + /** + * Logs an object to console if debug mode is enabled, + * and formats it with the provided arguments + * + * @param message the message to log + * @param arguments the arguments to replace in the message + */ + void debug(String message, Object... arguments); + /** * Sets if the logger should print debug messages * diff --git a/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java new file mode 100644 index 00000000000..a2369f12405 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2019-2023 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.event.type; + +import lombok.Getter; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineResourcePacksEvent; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.option.ResourcePackOption; +import org.geysermc.geyser.pack.GeyserResourcePack; +import org.geysermc.geyser.pack.ResourcePackHolder; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +@Getter +public class GeyserDefineResourcePacksEventImpl extends GeyserDefineResourcePacksEvent { + private final Map packs; + + public GeyserDefineResourcePacksEventImpl(Map packMap) { + this.packs = packMap; + } + + @Override + public @NonNull List resourcePacks() { + return packs.values().stream().map(ResourcePackHolder::resourcePack).toList(); + } + + @Override + public boolean register(@NonNull ResourcePack resourcePack, @Nullable ResourcePackOption... options) { + Objects.requireNonNull(resourcePack, "resource pack must not be null!"); + if (!(resourcePack instanceof GeyserResourcePack pack)) { + throw new IllegalArgumentException("unknown resource pack implementation: %s". + formatted(resourcePack.getClass().getSuperclass().getName())); + } + + UUID uuid = resourcePack.uuid(); + if (packs.containsKey(uuid)) { + return false; + } + + ResourcePackHolder holder = ResourcePackHolder.of(pack); + attemptRegisterOptions(holder, options); + packs.put(uuid, holder); + return true; + } + + @Override + public void registerOptions(@NonNull UUID uuid, @NonNull ResourcePackOption... options) { + Objects.requireNonNull(uuid); + Objects.requireNonNull(options); + + ResourcePackHolder holder = packs.get(uuid); + if (holder == null) { + throw new IllegalArgumentException("resource pack with uuid " + uuid + " not found, unable to register options"); + } + + attemptRegisterOptions(holder, options); + } + + @Override + public Collection> options(@NonNull UUID uuid) { + Objects.requireNonNull(uuid); + ResourcePackHolder packHolder = packs.get(uuid); + if (packHolder == null) { + throw new IllegalArgumentException("resource pack with uuid " + uuid + " not found, unable to provide options"); + } + + return packHolder.optionHolder().immutableValues(); + } + + @Override + public @Nullable ResourcePackOption option(@NonNull UUID uuid, ResourcePackOption.@NonNull Type type) { + Objects.requireNonNull(uuid); + Objects.requireNonNull(type); + + ResourcePackHolder packHolder = packs.get(uuid); + if (packHolder == null) { + throw new IllegalArgumentException("resource pack with uuid " + uuid + " not found, unable to provide option"); + } + + return packHolder.optionHolder().get(type); + } + + @Override + public boolean unregister(@NonNull UUID uuid) { + return packs.remove(uuid) != null; + } + + private void attemptRegisterOptions(@NonNull ResourcePackHolder holder, @Nullable ResourcePackOption... options) { + if (options == null) { + return; + } + + holder.optionHolder().validateAndAdd(holder.pack(), options); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java index 5bc0dd0bdda..a13e7f3b88f 100644 --- a/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java +++ b/core/src/main/java/org/geysermc/geyser/event/type/SessionLoadResourcePacksEventImpl.java @@ -25,45 +25,194 @@ package org.geysermc.geyser.event.type; +import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import lombok.Getter; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.cloudburstmc.protocol.bedrock.packet.ResourcePackStackPacket; +import org.cloudburstmc.protocol.bedrock.packet.ResourcePacksInfoPacket; import org.geysermc.geyser.api.event.bedrock.SessionLoadResourcePacksEvent; import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.pack.option.PriorityOption; +import org.geysermc.geyser.api.pack.option.ResourcePackOption; +import org.geysermc.geyser.pack.GeyserResourcePack; +import org.geysermc.geyser.pack.ResourcePackHolder; +import org.geysermc.geyser.pack.option.OptionHolder; +import org.geysermc.geyser.registry.Registries; import org.geysermc.geyser.session.GeyserSession; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; public class SessionLoadResourcePacksEventImpl extends SessionLoadResourcePacksEvent { - private final Map packs; + /** + * The packs for this Session. A {@link ResourcePackHolder} may contain resource pack options registered + * during the {@link org.geysermc.geyser.api.event.lifecycle.GeyserDefineResourcePacksEvent}. + */ + @Getter + private final Map packs; - public SessionLoadResourcePacksEventImpl(GeyserSession session, Map packMap) { - super(session); - this.packs = packMap; - } + /** + * The additional, per-session options for the resource packs of this session. + * These options are prioritized over the "default" options registered + * in the {@link org.geysermc.geyser.api.event.lifecycle.GeyserDefineResourcePacksEvent} + */ + private final Map sessionPackOptionOverrides; - public @NonNull Map getPacks() { - return packs; + public SessionLoadResourcePacksEventImpl(GeyserSession session) { + super(session); + this.packs = new Object2ObjectLinkedOpenHashMap<>(Registries.RESOURCE_PACKS.get()); + this.sessionPackOptionOverrides = new Object2ObjectOpenHashMap<>(); } @Override public @NonNull List resourcePacks() { - return List.copyOf(packs.values()); + return packs.values().stream().map(ResourcePackHolder::resourcePack).toList(); } @Override public boolean register(@NonNull ResourcePack resourcePack) { - UUID packID = resourcePack.manifest().header().uuid(); - if (packs.containsValue(resourcePack) || packs.containsKey(packID)) { + return register(resourcePack, PriorityOption.NORMAL); + } + + @Override + public boolean register(@NonNull ResourcePack resourcePack, @Nullable ResourcePackOption... options) { + Objects.requireNonNull(resourcePack); + if (!(resourcePack instanceof GeyserResourcePack pack)) { + throw new IllegalArgumentException("Unknown resource pack implementation: %s". + formatted(resourcePack.getClass().getSuperclass().getName())); + } + + UUID uuid = resourcePack.uuid(); + if (packs.containsKey(uuid)) { return false; } - packs.put(resourcePack.manifest().header().uuid(), resourcePack); + + attemptRegisterOptions(pack, options); + packs.put(uuid, ResourcePackHolder.of(pack)); return true; } + @Override + public void registerOptions(@NonNull UUID uuid, @NonNull ResourcePackOption... options) { + Objects.requireNonNull(uuid, "uuid cannot be null"); + Objects.requireNonNull(options, "options cannot be null"); + ResourcePackHolder holder = packs.get(uuid); + if (holder == null) { + throw new IllegalArgumentException("resource pack with uuid " + uuid + " not found, unable to register options"); + } + + attemptRegisterOptions(holder.pack(), options); + } + + @Override + public Collection> options(@NonNull UUID uuid) { + Objects.requireNonNull(uuid); + ResourcePackHolder packHolder = packs.get(uuid); + if (packHolder == null) { + throw new IllegalArgumentException("resource pack with uuid " + uuid + " not found, unable to provide options"); + } + + OptionHolder optionHolder = sessionPackOptionOverrides.get(uuid); + if (optionHolder == null) { + // No need to create a new session option holder + return packHolder.optionHolder().immutableValues(); + } + + return optionHolder.immutableValues(packHolder.optionHolder()); + } + + @Override + public @Nullable ResourcePackOption option(@NonNull UUID uuid, ResourcePackOption.@NonNull Type type) { + Objects.requireNonNull(uuid); + Objects.requireNonNull(type); + + ResourcePackHolder packHolder = packs.get(uuid); + if (packHolder == null) { + throw new IllegalArgumentException("resource pack with uuid " + uuid + " not found, unable to provide option"); + } + + @Nullable OptionHolder additionalOptions = sessionPackOptionOverrides.get(uuid); + OptionHolder defaultHolder = packHolder.optionHolder(); + Objects.requireNonNull(defaultHolder); // should never be null + + return OptionHolder.optionByType(type, additionalOptions, defaultHolder); + } + @Override public boolean unregister(@NonNull UUID uuid) { + sessionPackOptionOverrides.remove(uuid); return packs.remove(uuid) != null; } + + private void attemptRegisterOptions(@NonNull GeyserResourcePack pack, @Nullable ResourcePackOption... options) { + if (options == null) { + return; + } + + OptionHolder holder = this.sessionPackOptionOverrides.computeIfAbsent(pack.uuid(), $ -> new OptionHolder()); + holder.validateAndAdd(pack, options); + } + + // Methods used internally for e.g. ordered packs, or resource pack entries + + public List orderedPacks() { + return packs.values().stream() + // Map each ResourcePack to a pair of (GeyserResourcePack, Priority) + .map(holder -> new AbstractMap.SimpleEntry<>(holder.pack(), priority(holder.pack()))) + // Sort by priority in ascending order + .sorted(Map.Entry.comparingByValue(Comparator.naturalOrder())) + // Map the sorted entries to ResourcePackStackPacket.Entry + .map(entry -> { + ResourcePackManifest.Header header = entry.getKey().manifest().header(); + return new ResourcePackStackPacket.Entry( + header.uuid().toString(), + header.version().toString(), + subpackName(entry.getKey()) + ); + }) + .toList(); + } + + public List infoPacketEntries() { + List entries = new ArrayList<>(); + + for (ResourcePackHolder holder : packs.values()) { + GeyserResourcePack pack = holder.pack(); + ResourcePackManifest.Header header = pack.manifest().header(); + entries.add(new ResourcePacksInfoPacket.Entry( + header.uuid(), header.version().toString(), pack.codec().size(), pack.contentKey(), + subpackName(pack), header.uuid().toString(), false, false, false, subpackName(pack)) + ); + } + + return entries; + } + + // Helper methods to get the options for a ResourcePack + + public T value(UUID uuid, ResourcePackOption.Type type, T defaultValue) { + OptionHolder holder = sessionPackOptionOverrides.get(uuid); + OptionHolder defaultHolder = packs.get(uuid).optionHolder(); + Objects.requireNonNull(defaultHolder); // should never be null + + return OptionHolder.valueOrFallback(type, holder, defaultHolder, defaultValue); + } + + private double priority(GeyserResourcePack pack) { + return value(pack.uuid(), ResourcePackOption.Type.PRIORITY, PriorityOption.NORMAL.value()); + } + + private String subpackName(GeyserResourcePack pack) { + return value(pack.uuid(), ResourcePackOption.Type.SUBPACK, ""); + } } diff --git a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java index c67ea6545d9..f2bd7a5c5ae 100644 --- a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java +++ b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java @@ -58,10 +58,14 @@ import org.geysermc.geyser.api.pack.PackCodec; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.api.pack.option.ResourcePackOption; import org.geysermc.geyser.event.type.SessionLoadResourcePacksEventImpl; import org.geysermc.geyser.pack.GeyserResourcePack; +import org.geysermc.geyser.pack.ResourcePackHolder; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.PendingMicrosoftAuthentication; import org.geysermc.geyser.text.GeyserLocale; @@ -74,7 +78,6 @@ import java.nio.channels.SeekableByteChannel; import java.util.ArrayDeque; import java.util.Deque; -import java.util.HashMap; import java.util.OptionalInt; import java.util.UUID; @@ -199,17 +202,12 @@ public PacketSignal handle(LoginPacket loginPacket) { geyser.getSessionManager().addPendingSession(session); - this.resourcePackLoadEvent = new SessionLoadResourcePacksEventImpl(session, new HashMap<>(Registries.RESOURCE_PACKS.get())); + this.resourcePackLoadEvent = new SessionLoadResourcePacksEventImpl(session); this.geyser.eventBus().fire(this.resourcePackLoadEvent); ResourcePacksInfoPacket resourcePacksInfo = new ResourcePacksInfoPacket(); - for (ResourcePack pack : this.resourcePackLoadEvent.resourcePacks()) { - PackCodec codec = pack.codec(); - ResourcePackManifest.Header header = pack.manifest().header(); - resourcePacksInfo.getResourcePackInfos().add(new ResourcePacksInfoPacket.Entry( - header.uuid(), header.version().toString(), codec.size(), pack.contentKey(), - "", header.uuid().toString(), false, false, false, "")); - } + resourcePacksInfo.getResourcePackInfos().addAll(this.resourcePackLoadEvent.infoPacketEntries()); + resourcePacksInfo.setForcedToAccept(GeyserImpl.getInstance().getConfig().isForceResourcePacks()); resourcePacksInfo.setWorldTemplateId(UUID.randomUUID()); resourcePacksInfo.setWorldTemplateVersion("*"); @@ -222,7 +220,7 @@ public PacketSignal handle(LoginPacket loginPacket) { @Override public PacketSignal handle(ResourcePackClientResponsePacket packet) { switch (packet.getStatus()) { - case COMPLETED: + case COMPLETED -> { if (geyser.getConfig().getRemote().authType() != AuthType.ONLINE) { session.authenticate(session.getAuthData().name()); } else if (!couldLoginUserByName(session.getAuthData().name())) { @@ -230,30 +228,21 @@ public PacketSignal handle(ResourcePackClientResponsePacket packet) { session.connect(); } geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.connect", session.getAuthData().name())); - break; - - case SEND_PACKS: + } + case SEND_PACKS -> { packsToSend.addAll(packet.getPackIds()); sendPackDataInfo(packsToSend.pop()); - break; - - case HAVE_ALL_PACKS: + } + case HAVE_ALL_PACKS -> { ResourcePackStackPacket stackPacket = new ResourcePackStackPacket(); stackPacket.setExperimentsPreviouslyToggled(false); stackPacket.setForcedToAccept(false); // Leaving this as false allows the player to choose to download or not stackPacket.setGameVersion(session.getClientData().getGameVersion()); - - for (ResourcePack pack : this.resourcePackLoadEvent.resourcePacks()) { - ResourcePackManifest.Header header = pack.manifest().header(); - stackPacket.getResourcePacks().add(new ResourcePackStackPacket.Entry(header.uuid().toString(), header.version().toString(), "")); - } + stackPacket.getResourcePacks().addAll(this.resourcePackLoadEvent.orderedPacks()); session.sendUpstreamPacket(stackPacket); - break; - - default: - session.disconnect("disconnectionScreen.resourcePack"); - break; + } + default -> session.disconnect("disconnectionScreen.resourcePack"); } return PacketSignal.HANDLED; @@ -302,10 +291,29 @@ public PacketSignal handle(PlayerAuthInputPacket packet) { @Override public PacketSignal handle(ResourcePackChunkRequestPacket packet) { + ResourcePackHolder holder = this.resourcePackLoadEvent.getPacks().get(packet.getPackId()); + + if (holder == null) { + GeyserImpl.getInstance().getLogger().debug("Client {0} tried to request pack id {1} not sent to it!", + session.bedrockUsername(), packet.getPackId()); + session.disconnect("disconnectionScreen.resourcePack"); + return PacketSignal.HANDLED; + } + + ResourcePack pack = holder.pack(); ResourcePackChunkDataPacket data = new ResourcePackChunkDataPacket(); - ResourcePack pack = this.resourcePackLoadEvent.getPacks().get(packet.getPackId()); PackCodec codec = pack.codec(); + // If a remote pack ends up here, that usually implies that a client was not able to download the pack + if (codec instanceof UrlPackCodec urlPackCodec) { + ResourcePackLoader.testRemotePack(session, urlPackCodec, packet.getPackId(), packet.getPackVersion()); + + if (!resourcePackLoadEvent.value(pack.uuid(), ResourcePackOption.Type.FALLBACK, true)) { + session.disconnect("Unable to provide downloaded resource pack. Contact an administrator!"); + return PacketSignal.HANDLED; + } + } + data.setChunkIndex(packet.getChunkIndex()); data.setProgress((long) packet.getChunkIndex() * GeyserResourcePack.CHUNK_SIZE); data.setPackVersion(packet.getPackVersion()); @@ -315,10 +323,11 @@ public PacketSignal handle(ResourcePackChunkRequestPacket packet) { long remainingSize = codec.size() - offset; byte[] packData = new byte[(int) MathUtils.constrain(remainingSize, 0, GeyserResourcePack.CHUNK_SIZE)]; - try (SeekableByteChannel channel = codec.serialize(pack)) { + try (SeekableByteChannel channel = codec.serialize()) { channel.position(offset); channel.read(ByteBuffer.wrap(packData, 0, packData.length)); } catch (IOException e) { + session.disconnect("disconnectionScreen.resourcePack"); e.printStackTrace(); } @@ -337,8 +346,33 @@ public PacketSignal handle(ResourcePackChunkRequestPacket packet) { private void sendPackDataInfo(String id) { ResourcePackDataInfoPacket data = new ResourcePackDataInfoPacket(); String[] packID = id.split("_"); - UUID uuid = UUID.fromString(packID[0]); - ResourcePack pack = this.resourcePackLoadEvent.getPacks().get(uuid); + + if (packID.length < 2) { + GeyserImpl.getInstance().getLogger().debug("Client {0} tried to request invalid pack id {1}!", + session.bedrockUsername(), packID); + session.disconnect("disconnectionScreen.resourcePack"); + return; + } + + UUID packId; + try { + packId = UUID.fromString(packID[0]); + } catch (IllegalArgumentException e) { + GeyserImpl.getInstance().getLogger().debug("Client {0} tried to request pack with an invalid id {1})", + session.bedrockUsername(), id); + session.disconnect("disconnectionScreen.resourcePack"); + return; + } + + ResourcePackHolder holder = this.resourcePackLoadEvent.getPacks().get(packId); + if (holder == null) { + GeyserImpl.getInstance().getLogger().debug("Client {0} tried to request pack id {1} not sent to it!", + session.bedrockUsername(), id); + session.disconnect("disconnectionScreen.resourcePack"); + return; + } + + ResourcePack pack = holder.pack(); PackCodec codec = pack.codec(); ResourcePackManifest.Header header = pack.manifest().header(); diff --git a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java index 82408b6e731..306bb9aa8d9 100644 --- a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java +++ b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java @@ -25,14 +25,64 @@ package org.geysermc.geyser.pack; +import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.geyser.api.pack.PackCodec; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.api.pack.ResourcePackManifest; -public record GeyserResourcePack(PackCodec codec, ResourcePackManifest manifest, String contentKey) implements ResourcePack { +import java.util.Objects; + +public record GeyserResourcePack( + @NonNull PackCodec codec, + @NonNull ResourcePackManifest manifest, + @NonNull String contentKey +) implements ResourcePack { /** * The size of each chunk to use when sending the resource packs to clients in bytes */ public static final int CHUNK_SIZE = 102400; + + public static class Builder implements ResourcePack.Builder { + + public Builder(PackCodec codec, ResourcePackManifest manifest) { + this.codec = codec; + this.manifest = manifest; + } + + public Builder(PackCodec codec, ResourcePackManifest manifest, String contentKey) { + this.codec = codec; + this.manifest = manifest; + this.contentKey = contentKey; + } + + private final PackCodec codec; + private final ResourcePackManifest manifest; + private String contentKey = ""; + + @Override + public ResourcePackManifest manifest() { + return manifest; + } + + @Override + public PackCodec codec() { + return codec; + } + + @Override + public String contentKey() { + return contentKey; + } + + public Builder contentKey(@NonNull String contentKey) { + Objects.requireNonNull(contentKey); + this.contentKey = contentKey; + return this; + } + + public GeyserResourcePack build() { + return new GeyserResourcePack(codec, manifest, contentKey); + } + } } diff --git a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePackManifest.java b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePackManifest.java index 25a0f0ee044..4120bebbef8 100644 --- a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePackManifest.java +++ b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePackManifest.java @@ -37,7 +37,14 @@ import java.util.Collection; import java.util.UUID; -public record GeyserResourcePackManifest(@JsonProperty("format_version") int formatVersion, Header header, Collection modules, Collection dependencies) implements ResourcePackManifest { +public record GeyserResourcePackManifest( + @JsonProperty("format_version") int formatVersion, + Header header, + Collection modules, + Collection dependencies, + Collection subpacks, + Collection settings +) implements ResourcePackManifest { public record Header(UUID uuid, Version version, String name, String description, @JsonProperty("min_engine_version") Version minimumSupportedMinecraftVersion) implements ResourcePackManifest.Header { } @@ -45,6 +52,10 @@ public record Module(UUID uuid, Version version, String type, String description public record Dependency(UUID uuid, Version version) implements ResourcePackManifest.Dependency { } + public record Subpack(@JsonProperty("folder_name") String folderName, String name, @JsonProperty("memory_tier") Float memoryTier) implements ResourcePackManifest.Subpack { } + + public record Setting(String type, String text) implements ResourcePackManifest.Setting { } + @JsonDeserialize(using = Version.VersionDeserializer.class) public record Version(int major, int minor, int patch) implements ResourcePackManifest.Version { diff --git a/core/src/main/java/org/geysermc/geyser/pack/ResourcePackHolder.java b/core/src/main/java/org/geysermc/geyser/pack/ResourcePackHolder.java new file mode 100644 index 00000000000..f475127e77a --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/ResourcePackHolder.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.pack; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.option.PriorityOption; +import org.geysermc.geyser.pack.option.OptionHolder; + +public record ResourcePackHolder( + @NonNull GeyserResourcePack pack, + @NonNull OptionHolder optionHolder +) { + + public static ResourcePackHolder of(GeyserResourcePack pack) { + return new ResourcePackHolder(pack, new OptionHolder(PriorityOption.NORMAL)); + } + + public ResourcePack resourcePack() { + return this.pack; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/pack/option/GeyserPriorityOption.java b/core/src/main/java/org/geysermc/geyser/pack/option/GeyserPriorityOption.java new file mode 100644 index 00000000000..f8a31fecec1 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/option/GeyserPriorityOption.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.pack.option; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.option.PriorityOption; + +import java.util.Objects; + +public record GeyserPriorityOption(double priority) implements PriorityOption { + + public GeyserPriorityOption { + if (priority < 0 || priority > 10) { + throw new IllegalArgumentException("Priority must be between 0 and 10 inclusive!"); + } + } + + @Override + public @NonNull Type type() { + return Type.PRIORITY; + } + + @Override + public @NonNull Double value() { + return priority; + } + + @Override + public void validate(@NonNull ResourcePack pack) { + Objects.requireNonNull(pack); + if (priority <= 10 && priority > 0) { + throw new IllegalArgumentException("Priority must be between 0 and 10 inclusive!"); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/pack/option/GeyserSubpackOption.java b/core/src/main/java/org/geysermc/geyser/pack/option/GeyserSubpackOption.java new file mode 100644 index 00000000000..fe8d75e2e49 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/option/GeyserSubpackOption.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.pack.option; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.pack.option.SubpackOption; + +import java.util.Objects; + +/** + * Can be used to specify which subpack from a resource pack a player should load. + * Available subpacks can be seen in a resource pack manifest {@link ResourcePackManifest#subpacks()} + */ +public record GeyserSubpackOption(String subpackName) implements SubpackOption { + + @Override + public @NonNull Type type() { + return Type.SUBPACK; + } + + @Override + public @NonNull String value() { + return subpackName; + } + + @Override + public void validate(@NonNull ResourcePack pack) { + Objects.requireNonNull(pack); + + // Allow empty subpack names - they're the same as "none" + if (subpackName.isEmpty()) { + return; + } + + if (pack.manifest().subpacks().stream().noneMatch(subpack -> subpack.name().equals(subpackName))) { + throw new IllegalArgumentException("No subpack with the name %s found!".formatted(subpackName)); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/pack/option/GeyserUrlFallbackOption.java b/core/src/main/java/org/geysermc/geyser/pack/option/GeyserUrlFallbackOption.java new file mode 100644 index 00000000000..405d311f098 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/option/GeyserUrlFallbackOption.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.pack.option; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.api.pack.option.UrlFallbackOption; + +public record GeyserUrlFallbackOption(Boolean enabled) implements UrlFallbackOption { + + @Override + public @NonNull Type type() { + return Type.FALLBACK; + } + + @Override + public @NonNull Boolean value() { + return enabled; + } + + @Override + public void validate(@NonNull ResourcePack pack) { + if (!(pack.codec() instanceof UrlPackCodec)) { + throw new IllegalArgumentException("UrlFallbackOption cannot be set on resource packs that " + + "are not created using the url pack codec!"); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/pack/option/OptionHolder.java b/core/src/main/java/org/geysermc/geyser/pack/option/OptionHolder.java new file mode 100644 index 00000000000..f6ec741df6b --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/option/OptionHolder.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.pack.option; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.option.PriorityOption; +import org.geysermc.geyser.api.pack.option.ResourcePackOption; +import org.geysermc.geyser.pack.GeyserResourcePack; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class OptionHolder extends HashMap> { + + public OptionHolder() { + super(); + } + + // Used when adding resource packs initially to ensure that a priority option is always set + // It is however NOT used for session-options, as then the "normal" prio might override + // the resource pack option + public OptionHolder(PriorityOption option) { + super(); + put(option.type(), option); + } + + public void validateAndAdd(ResourcePack pack, ResourcePackOption... options) { + for (ResourcePackOption option : options) { + // Validate before adding + option.validate(pack); + + // Ensure that we do not have duplicate types. + if (super.containsKey(option.type())) { + super.replace(option.type(), option); + } else { + super.put(option.type(), option); + } + } + } + + @SuppressWarnings("unchecked") + public static T valueOrFallback(ResourcePackOption.@NonNull Type type, + @Nullable OptionHolder sessionPackOptions, + @NonNull OptionHolder resourcePackOptions, + @NonNull T defaultValue) { + ResourcePackOption option; + + // First: the session's options, if they exist + if (sessionPackOptions != null) { + option = sessionPackOptions.get(type); + if (option != null) { + return (T) option.value(); + } + } + + // Second: check the resource pack options + option = resourcePackOptions.get(type); + if (option != null) { + return (T) option.value(); + } + + // Finally: return default + return defaultValue; + } + + public static @Nullable ResourcePackOption optionByType(ResourcePackOption.@NonNull Type type, + @Nullable OptionHolder sessionPackOptions, + @NonNull OptionHolder resourcePackOptions) { + + // First: the session-specific options, if these exist + if (sessionPackOptions != null) { + ResourcePackOption option = sessionPackOptions.get(type); + if (option != null) { + return option; + } + } + + // Second: check the default holder for the option, if it exists; + // Or return null if the option isn't set. + return resourcePackOptions.get(type); + } + + public void remove(ResourcePackOption option) { + super.remove(option.type()); + } + + /** + * @return the options of this holder in an immutable collection + */ + public Collection> immutableValues() { + return Collections.unmodifiableCollection(values()); + } + + /** + * @return the options of this option holder, with fallbacks to options of a {@link GeyserResourcePack} + * if they're not already overridden here + */ + public Collection> immutableValues(OptionHolder defaultValues) { + // Create a map to hold the combined values + Map> combinedOptions = new HashMap<>(this); + + // Add options from the pack if not already overridden by this OptionHolder + defaultValues.forEach(combinedOptions::putIfAbsent); + + // Return an immutable collection of the combined options + return Collections.unmodifiableCollection(combinedOptions.values()); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/pack/path/GeyserPathPackCodec.java b/core/src/main/java/org/geysermc/geyser/pack/path/GeyserPathPackCodec.java index 84067600fce..ea6f319f930 100644 --- a/core/src/main/java/org/geysermc/geyser/pack/path/GeyserPathPackCodec.java +++ b/core/src/main/java/org/geysermc/geyser/pack/path/GeyserPathPackCodec.java @@ -79,15 +79,20 @@ public long size() { } @Override - public @NonNull SeekableByteChannel serialize(@NonNull ResourcePack resourcePack) throws IOException { + public @NonNull SeekableByteChannel serialize() throws IOException { return FileChannel.open(this.path); } @Override - protected @NonNull ResourcePack create() { + protected ResourcePack.@NonNull Builder createBuilder() { return ResourcePackLoader.readPack(this.path); } + @Override + protected @NonNull ResourcePack create() { + return createBuilder().build(); + } + private void checkLastModified() { try { FileTime lastModified = Files.getLastModifiedTime(this.path); diff --git a/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java b/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java new file mode 100644 index 00000000000..ee7f48c90cd --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2019-2023 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.url; + +import lombok.Getter; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.pack.PathPackCodec; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.pack.GeyserResourcePack; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; + +import java.io.IOException; +import java.nio.channels.SeekableByteChannel; +import java.util.Objects; + +public class GeyserUrlPackCodec extends UrlPackCodec { + private final @NonNull String url; + @Getter + private PathPackCodec fallback; + + public GeyserUrlPackCodec(@NonNull String url) throws IllegalArgumentException { + Objects.requireNonNull(url); + this.url = url; + } + + @Override + public byte @NonNull [] sha256() { + Objects.requireNonNull(fallback, "must call #create() before attempting to get the sha256!"); + return fallback.sha256(); + } + + @Override + public long size() { + Objects.requireNonNull(fallback, "must call #create() before attempting to get the size!"); + return fallback.size(); + } + + @Override + public @NonNull SeekableByteChannel serialize() throws IOException { + Objects.requireNonNull(fallback, "must call #create() before attempting to serialize!!"); + return fallback.serialize(); + } + + @Override + @NonNull + public GeyserResourcePack create() { + return createBuilder().build(); + } + + @Override + protected GeyserResourcePack.@NonNull Builder createBuilder() { + if (this.fallback == null) { + try { + ResourcePackLoader.downloadPack(url, false).whenComplete((pack, throwable) -> { + if (throwable != null) { + throw new IllegalArgumentException(throwable); + } else if (pack != null) { + this.fallback = pack; + } + }).join(); // Needed to ensure that we don't attempt to read a pack before downloading/checking it + } catch (Exception e) { + throw new IllegalArgumentException("Failed to download pack from the url %s (%s)!".formatted(url, e.getMessage())); + } + } + + return ResourcePackLoader.readPack(this); + } + + @Override + public @NonNull String url() { + return this.url; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/Registries.java b/core/src/main/java/org/geysermc/geyser/registry/Registries.java index fc41275aecf..af0c9dbc0c1 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/Registries.java +++ b/core/src/main/java/org/geysermc/geyser/registry/Registries.java @@ -34,10 +34,10 @@ import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.PotionMixData; import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket; import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.entity.EntityDefinition; import org.geysermc.geyser.inventory.recipe.GeyserRecipe; import org.geysermc.geyser.item.type.Item; +import org.geysermc.geyser.pack.ResourcePackHolder; import org.geysermc.geyser.registry.loader.BiomeIdentifierRegistryLoader; import org.geysermc.geyser.registry.loader.BlockEntityRegistryLoader; import org.geysermc.geyser.registry.loader.ParticleTypesRegistryLoader; @@ -166,9 +166,9 @@ public final class Registries { //public static final SimpleMappedDeferredRegistry> RECIPES = SimpleMappedDeferredRegistry.create("mappings/recipes.nbt", RecipeRegistryLoader::new); /** - * A mapped registry holding {@link ResourcePack}'s with the pack uuid as keys. + * A mapped registry holding {@link ResourcePackHolder}'s with the pack uuid as keys. */ - public static final SimpleMappedDeferredRegistry RESOURCE_PACKS = SimpleMappedDeferredRegistry.create(GeyserImpl.getInstance().packDirectory(), RegistryLoaders.RESOURCE_PACKS); + public static final SimpleMappedDeferredRegistry RESOURCE_PACKS = SimpleMappedDeferredRegistry.create(GeyserImpl.getInstance().packDirectory(), RegistryLoaders.RESOURCE_PACKS); /** * A versioned registry holding most Bedrock tags, with the Java item list (sorted) being the key, and the tag name as the value. diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java index 94de0c29858..242b396c1de 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java @@ -40,10 +40,14 @@ import org.geysermc.geyser.api.item.custom.CustomItemOptions; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; import org.geysermc.geyser.api.pack.PathPackCodec; -import org.geysermc.geyser.impl.camera.GeyserCameraFade; -import org.geysermc.geyser.impl.camera.GeyserCameraPosition; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.api.pack.option.PriorityOption; +import org.geysermc.geyser.api.pack.option.SubpackOption; +import org.geysermc.geyser.api.pack.option.UrlFallbackOption; import org.geysermc.geyser.event.GeyserEventRegistrar; import org.geysermc.geyser.extension.command.GeyserExtensionCommand; +import org.geysermc.geyser.impl.camera.GeyserCameraFade; +import org.geysermc.geyser.impl.camera.GeyserCameraPosition; import org.geysermc.geyser.item.GeyserCustomItemData; import org.geysermc.geyser.item.GeyserCustomItemOptions; import org.geysermc.geyser.item.GeyserNonVanillaCustomItemData; @@ -53,7 +57,11 @@ import org.geysermc.geyser.level.block.GeyserJavaBlockState; import org.geysermc.geyser.level.block.GeyserMaterialInstance; import org.geysermc.geyser.level.block.GeyserNonVanillaCustomBlockData; +import org.geysermc.geyser.pack.option.GeyserPriorityOption; +import org.geysermc.geyser.pack.option.GeyserSubpackOption; +import org.geysermc.geyser.pack.option.GeyserUrlFallbackOption; import org.geysermc.geyser.pack.path.GeyserPathPackCodec; +import org.geysermc.geyser.pack.url.GeyserUrlPackCodec; import org.geysermc.geyser.registry.provider.ProviderSupplier; import java.nio.file.Path; @@ -66,9 +74,10 @@ public class ProviderRegistryLoader implements RegistryLoader, Prov @Override public Map, ProviderSupplier> load(Map, ProviderSupplier> providers) { - // misc + // commands providers.put(Command.Builder.class, args -> new GeyserExtensionCommand.Builder<>((Extension) args[0])); + // custom blocks providers.put(CustomBlockComponents.Builder.class, args -> new GeyserCustomBlockComponents.Builder()); providers.put(CustomBlockData.Builder.class, args -> new GeyserCustomBlockData.Builder()); providers.put(JavaBlockState.Builder.class, args -> new GeyserJavaBlockState.Builder()); @@ -76,8 +85,15 @@ public Map, ProviderSupplier> load(Map, ProviderSupplier> prov providers.put(MaterialInstance.Builder.class, args -> new GeyserMaterialInstance.Builder()); providers.put(GeometryComponent.Builder.class, args -> new GeyserGeometryComponent.Builder()); + // misc providers.put(EventRegistrar.class, args -> new GeyserEventRegistrar(args[0])); + + // packs providers.put(PathPackCodec.class, args -> new GeyserPathPackCodec((Path) args[0])); + providers.put(UrlPackCodec.class, args -> new GeyserUrlPackCodec((String) args[0])); + providers.put(PriorityOption.class, args -> new GeyserPriorityOption((double) args[0])); + providers.put(SubpackOption.class, args -> new GeyserSubpackOption((String) args[0])); + providers.put(UrlFallbackOption.class, args -> new GeyserUrlFallbackOption((Boolean) args[0])); // items providers.put(CustomItemData.Builder.class, args -> new GeyserCustomItemData.Builder()); diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java index adb64b8afdf..1ce296e02c6 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java @@ -25,16 +25,30 @@ package org.geysermc.geyser.registry.loader; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.api.event.lifecycle.GeyserLoadResourcePacksEvent; +import org.geysermc.geyser.api.pack.PathPackCodec; import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.event.type.GeyserDefineResourcePacksEventImpl; import org.geysermc.geyser.pack.GeyserResourcePack; import org.geysermc.geyser.pack.GeyserResourcePackManifest; +import org.geysermc.geyser.pack.ResourcePackHolder; import org.geysermc.geyser.pack.SkullResourcePackManager; import org.geysermc.geyser.pack.path.GeyserPathPackCodec; +import org.geysermc.geyser.pack.url.GeyserUrlPackCodec; +import org.geysermc.geyser.registry.Registries; +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 java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; @@ -42,10 +56,13 @@ import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -53,9 +70,17 @@ import java.util.zip.ZipFile; /** - * Loads {@link ResourcePack}s within a {@link Path} directory, firing the {@link GeyserLoadResourcePacksEvent}. + * Loads {@link ResourcePack}s within a {@link Path} directory, firing the {@link GeyserDefineResourcePacksEventImpl}. */ -public class ResourcePackLoader implements RegistryLoader> { +public class ResourcePackLoader implements RegistryLoader> { + + /** + * Used to keep track of remote resource packs that the client rejected. + * If a client rejects such a pack, it falls back to the old method, and Geyser serves a cached variant. + */ + private static final Cache CACHED_FAILED_PACKS = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); static final PathMatcher PACK_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**.{zip,mcpack}"); @@ -65,8 +90,8 @@ public class ResourcePackLoader implements RegistryLoader load(Path directory) { - Map packMap = new HashMap<>(); + public Map load(Path directory) { + Map packMap = new Object2ObjectOpenHashMap<>(); if (!Files.exists(directory)) { try { @@ -95,33 +120,77 @@ public Map load(Path directory) { resourcePacks.add(skullResourcePack); } + //noinspection removal GeyserLoadResourcePacksEvent event = new GeyserLoadResourcePacksEvent(resourcePacks); GeyserImpl.getInstance().eventBus().fire(event); + //noinspection removal for (Path path : event.resourcePacks()) { try { - GeyserResourcePack pack = readPack(path); - packMap.put(pack.manifest().header().uuid(), pack); + GeyserResourcePack pack = readPack(path).build(); + packMap.put(pack.uuid(), ResourcePackHolder.of(pack)); } catch (Exception e) { e.printStackTrace(); } } - return packMap; + + // Load all remote resource packs from the config before firing the new event + // TODO configurate + //packMap.putAll(loadRemotePacks()); + + GeyserDefineResourcePacksEventImpl defineEvent = new GeyserDefineResourcePacksEventImpl(packMap); + GeyserImpl.getInstance().eventBus().fire(defineEvent); + + // After loading the new resource packs: let's clean up the old url packs + cleanupRemotePacks(); + + return defineEvent.getPacks(); } /** - * Reads a resource pack at the given file. Also searches for a file in the same directory, with the same name + * Reads a resource pack builder at the given file. Also searches for a file in the same directory, with the same name * but suffixed by ".key", containing the content key. If such file does not exist, no content key is stored. * * @param path the file to read from, in ZIP format - * @return a {@link ResourcePack} representation + * @return a {@link ResourcePack.Builder} representation * @throws IllegalArgumentException if the pack manifest was invalid or there was any processing exception */ - public static GeyserResourcePack readPack(Path path) throws IllegalArgumentException { - if (!path.getFileName().toString().endsWith(".mcpack") && !path.getFileName().toString().endsWith(".zip")) { + public static GeyserResourcePack.Builder readPack(Path path) throws IllegalArgumentException { + if (!PACK_MATCHER.matches(path)) { throw new IllegalArgumentException("Resource pack " + path.getFileName() + " must be a .zip or .mcpack file!"); } + ResourcePackManifest manifest = readManifest(path, path.getFileName().toString()); + String contentKey; + + try { + // Check if a file exists with the same name as the resource pack suffixed by .key, + // and set this as content key. (e.g. test.zip, key file would be test.zip.key) + Path keyFile = path.resolveSibling(path.getFileName().toString() + ".key"); + contentKey = Files.exists(keyFile) ? Files.readString(keyFile, StandardCharsets.UTF_8) : ""; + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to read content key for resource pack " + path.getFileName(), e); + contentKey = ""; + } + + return new GeyserResourcePack.Builder(new GeyserPathPackCodec(path), manifest, contentKey); + } + + /** + * Reads a Resource pack from a URL codec, and returns a resource pack. Unlike {@link ResourcePackLoader#readPack(Path)} + * this method reads content keys differently. + * + * @param codec the URL pack codec with the url to download the pack from + * @return a {@link GeyserResourcePack} representation + * @throws IllegalArgumentException if there was an error reading the pack. + */ + public static GeyserResourcePack.Builder readPack(GeyserUrlPackCodec codec) throws IllegalArgumentException { + Path path = codec.getFallback().path(); + ResourcePackManifest manifest = readManifest(path, codec.url()); + return new GeyserResourcePack.Builder(codec, manifest); + } + + private static ResourcePackManifest readManifest(Path path, String packLocation) throws IllegalArgumentException { AtomicReference manifestReference = new AtomicReference<>(); try (ZipFile zip = new ZipFile(path.toFile()); @@ -129,7 +198,7 @@ public static GeyserResourcePack readPack(Path path) throws IllegalArgumentExcep stream.forEach(x -> { String name = x.getName(); if (SHOW_RESOURCE_PACK_LENGTH_WARNING && name.length() >= 80) { - GeyserImpl.getInstance().getLogger().warning("The resource pack " + path.getFileName() + GeyserImpl.getInstance().getLogger().warning("The resource pack " + packLocation + " has a file in it that meets or exceeds 80 characters in its path (" + name + ", " + name.length() + " characters long). This will cause problems on some Bedrock platforms." + " Please rename it to be shorter, or reduce the amount of folders needed to get to the file."); @@ -148,17 +217,190 @@ public static GeyserResourcePack readPack(Path path) throws IllegalArgumentExcep GeyserResourcePackManifest manifest = manifestReference.get(); if (manifest == null) { - throw new IllegalArgumentException(path.getFileName() + " does not contain a valid pack_manifest.json or manifest.json"); + throw new IllegalArgumentException(packLocation + " does not contain a valid pack_manifest.json or manifest.json"); } - // Check if a file exists with the same name as the resource pack suffixed by .key, - // and set this as content key. (e.g. test.zip, key file would be test.zip.key) - Path keyFile = path.resolveSibling(path.getFileName().toString() + ".key"); - String contentKey = Files.exists(keyFile) ? Files.readString(keyFile, StandardCharsets.UTF_8) : ""; - - return new GeyserResourcePack(new GeyserPathPackCodec(path), manifest, contentKey); + return manifest; } catch (Exception e) { - throw new IllegalArgumentException(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", path.getFileName()), e); + throw new IllegalArgumentException(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", packLocation), e); + } + } + + private Map loadRemotePacks() { + GeyserImpl instance = GeyserImpl.getInstance(); + // Unable to make this a static variable, as the test would fail + final Path cachedDirectory = instance.getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs"); + + if (!Files.exists(cachedDirectory)) { + try { + Files.createDirectories(cachedDirectory); + } catch (IOException e) { + instance.getLogger().error("Could not create remote pack cache directory", e); + return new Object2ObjectOpenHashMap<>(); + } + } + + //List remotePackUrls = instance.getConfig().getResourcePackUrls(); + List remotePackUrls = List.of(); + Map packMap = new Object2ObjectOpenHashMap<>(); + + for (String url : remotePackUrls) { + try { + GeyserUrlPackCodec codec = new GeyserUrlPackCodec(url); + GeyserResourcePack pack = codec.create(); + packMap.put(pack.uuid(), ResourcePackHolder.of(pack)); + } catch (Throwable e) { + instance.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url)); + instance.getLogger().error(e.getMessage()); + if (instance.getLogger().isDebug()) { + e.printStackTrace(); + } + } + } + + return packMap; + } + + /** + * Used when a Bedrock client requests a Bedrock resource pack from the server when it should be downloading it + * from a remote provider. Since this would be called each time a Bedrock client requests a piece of the Bedrock pack, + * this uses a cache to ensure we aren't re-checking a dozen times. + * + * @param codec the codec of the resource pack that wasn't successfully downloaded by a Bedrock client. + */ + public static void testRemotePack(GeyserSession session, UrlPackCodec codec, UUID packId, String packVersion) { + if (CACHED_FAILED_PACKS.getIfPresent(codec.url()) == null) { + String url = codec.url(); + CACHED_FAILED_PACKS.put(url, codec); + GeyserImpl.getInstance().getLogger().warning( + "A Bedrock client (%s, playing on %s / %s) was not able to download the resource pack at %s. Checking for changes now:" + .formatted(session.bedrockUsername(), session.getClientData().getDeviceOs().name(), session.getClientData().getDeviceId(), codec.url()) + ); + + downloadPack(codec.url(), true).whenComplete((pathPackCodec, e) -> { + if (e != null) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url), e); + if (GeyserImpl.getInstance().getLogger().isDebug()) { + e.printStackTrace(); + if (pathPackCodec != null) { + deleteFile(pathPackCodec.path()); + } + return; + } + } + + if (pathPackCodec == null) { + return; // Already warned about + } + + GeyserResourcePack newPack = readPack(pathPackCodec.path()).build(); + if (newPack.uuid().equals(packId)) { + if (packVersion.equals(newPack.manifest().header().version().toString())) { + GeyserImpl.getInstance().getLogger().info("No version or pack change detected: Was the resource pack server down?"); + } else { + GeyserImpl.getInstance().getLogger().info("Detected a new resource pack version (%s, old version %s) for pack at %s!" + .formatted(packVersion, newPack.manifest().header().version().toString(), url)); + } + } else { + GeyserImpl.getInstance().getLogger().info("Detected a new resource pack at the url %s!".formatted(url)); + } + + // This should be safe to do as we're not directly using registries to read packs. + // Instead, they're cached per-session in the SessionLoadResourcePacks event + Registries.RESOURCE_PACKS.get().remove(packId); + Registries.RESOURCE_PACKS.get().put(newPack.uuid(), ResourcePackHolder.of(newPack)); + + if (codec instanceof GeyserUrlPackCodec geyserUrlPackCodec + && geyserUrlPackCodec.getFallback() != null) { + Path path = geyserUrlPackCodec.getFallback().path(); + try { + GeyserImpl.getInstance().getScheduledThread().schedule(() -> { + CACHED_FAILED_PACKS.invalidate(codec.url()); + deleteFile(path); + }, 5, TimeUnit.MINUTES); + } catch (RejectedExecutionException exception) { + // No scheduling here, probably because we're shutting down? + deleteFile(path); + } + } + }); + } + } + + private static void deleteFile(Path path) { + if (path.toFile().exists()) { + try { + Files.delete(path); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Unable to delete old pack! " + e.getMessage()); + e.printStackTrace(); + } + } + } + + public static CompletableFuture<@Nullable PathPackCodec> downloadPack(String url, boolean testing) throws IllegalArgumentException { + return CompletableFuture.supplyAsync(() -> { + Path path = WebUtils.downloadRemotePack(url, testing); + + // Already warned about these above + if (path == null) { + return null; + } + + // Check if the pack is a .zip or .mcpack file + if (!PACK_MATCHER.matches(path)) { + throw new IllegalArgumentException("Invalid pack format from url %s! Not a .zip or .mcpack file.".formatted(url)); + } + + try { + try (ZipFile zip = new ZipFile(path.toFile())) { + if (zip.stream().noneMatch(x -> x.getName().contains("manifest.json"))) { + throw new IllegalArgumentException("The pack at the url " + url + " does not contain a manifest file!"); + } + + // Check if a "manifest.json" or "pack_manifest.json" file is located directly in the zip... does not work otherwise. + // (something like MyZip.zip/manifest.json) will not, but will if it's a subfolder (MyPack.zip/MyPack/manifest.json) + if (zip.getEntry("manifest.json") != null || zip.getEntry("pack_manifest.json") != null) { + if (GeyserImpl.getInstance().getLogger().isDebug()) { + GeyserImpl.getInstance().getLogger().info("The remote resource pack from " + url + " contains a manifest.json file at the root of the zip file. " + + "This may not work for remote packs, and could cause Bedrock clients to fall back to request the pack from the server. " + + "Please put the pack file in a subfolder, and provide that zip in the URL."); + } + } + } + } catch (IOException e) { + throw new IllegalArgumentException(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url), e); + } + + return new GeyserPathPackCodec(path); + }); + } + + public static void clear() { + if (Registries.RESOURCE_PACKS.loaded()) { + Registries.RESOURCE_PACKS.get().clear(); + } + CACHED_FAILED_PACKS.invalidateAll(); + } + + public static void cleanupRemotePacks() { + File cacheFolder = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs").toFile(); + if (!cacheFolder.exists()) { + return; + } + + int count = 0; + final long expireTime = (((long) 1000 * 60 * 60)); // one hour + for (File cachedPack : Objects.requireNonNull(cacheFolder.listFiles())) { + if (cachedPack.lastModified() < System.currentTimeMillis() - expireTime) { + //noinspection ResultOfMethodCallIgnored + cachedPack.delete(); + count++; + } + } + + if (count > 0) { + GeyserImpl.getInstance().getLogger().debug(String.format("Removed %d cached resource pack files as they are no longer in use!", count)); } } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java index 3d3bfb48d95..d2bc251e55f 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java @@ -79,7 +79,7 @@ * This fact is the biggest contributor for the class being structured like it is. */ public final class Scoreboard { - private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "true")); + private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "false")); private static final boolean ADD_TEAM_SUGGESTIONS = Boolean.parseBoolean(System.getProperty("Geyser.AddTeamSuggestions", "true")); private final GeyserSession session; diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java index 18a4bce3947..dc1bc57bb77 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java @@ -48,7 +48,7 @@ public final class ScoreboardUpdater extends Thread { static { GeyserConfiguration config = GeyserImpl.getInstance().getConfig(); FIRST_SCORE_PACKETS_PER_SECOND_THRESHOLD = Math.min(config.getScoreboardPacketThreshold(), SECOND_SCORE_PACKETS_PER_SECOND_THRESHOLD); - DEBUG_ENABLED = config.isDebugMode(); + DEBUG_ENABLED = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "false")) && config.isDebugMode(); } private final GeyserImpl geyser = GeyserImpl.getInstance(); 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 aec1fa4de4c..f3ad0be2ffa 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java @@ -167,7 +167,7 @@ public static void registerCacheImageTask(GeyserImpl geyser) { if (count > 0) { GeyserImpl.getInstance().getLogger().debug(String.format("Removed %d cached image files as they have expired", count)); } - }, 10, 1440, TimeUnit.MINUTES); + }, 10, 1, TimeUnit.DAYS); } } diff --git a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java index 1b7f2d9d966..1db75631473 100644 --- a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java +++ b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java @@ -28,22 +28,26 @@ import com.fasterxml.jackson.databind.JsonNode; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.GeyserLogger; import javax.naming.directory.Attribute; import javax.naming.directory.InitialDirContext; import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; +import java.net.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.stream.Stream; public class WebUtils { + private static final Path REMOTE_PACK_CACHE = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs"); + /** * Makes a web request to the given URL and returns the body as a string * @@ -96,6 +100,115 @@ public static void downloadFile(String reqURL, String fileLocation) { } } + /** + * Checks a remote pack URL to see if it is valid + * If it is, it will download the pack file and return a path to it + * + * @param url The URL to check + * @param force If true, the pack will be downloaded even if it is cached to a separate location. + * @return Path to the downloaded pack file, or null if it was unable to be loaded + */ + @SuppressWarnings("ResultOfMethodCallIgnored") + public static @Nullable Path downloadRemotePack(String url, boolean force) { + GeyserLogger logger = GeyserImpl.getInstance().getLogger(); + try { + HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); + + con.setConnectTimeout(10000); + con.setReadTimeout(10000); + con.setRequestProperty("User-Agent", "Geyser-" + GeyserImpl.getInstance().getPlatformType().platformName() + "/" + GeyserImpl.VERSION); + con.setInstanceFollowRedirects(true); + + int responseCode = con.getResponseCode(); + if (responseCode >= 400) { + throw new IllegalStateException(String.format("Invalid response code from remote pack at URL: %s (code: %d)", url, responseCode)); + } + + int size = con.getContentLength(); + String type = con.getContentType(); + + if (size <= 0) { + throw new IllegalArgumentException(String.format("Invalid content length received from remote pack at URL: %s (size: %d)", url, size)); + } + + if (type == null || !type.equals("application/zip")) { + throw new IllegalArgumentException(String.format("Url %s tries to provide a resource pack using the %s content type, which is not supported by Bedrock edition! " + + "Bedrock Edition only supports the application/zip content type.", url, type)); + } + + Path packMetadata = REMOTE_PACK_CACHE.resolve(url.hashCode() + ".metadata"); + Path downloadLocation; + + // If we downloaded this pack before, reuse it if the ETag matches. + if (Files.exists(packMetadata) && !force) { + try { + List metadata = Files.readAllLines(packMetadata, StandardCharsets.UTF_8); + int cachedSize = Integer.parseInt(metadata.get(0)); + String cachedEtag = metadata.get(1); + long cachedLastModified = Long.parseLong(metadata.get(2)); + downloadLocation = REMOTE_PACK_CACHE.resolve(metadata.get(3)); + + if (cachedSize == size && + cachedEtag.equals(con.getHeaderField("ETag")) && + cachedLastModified == con.getLastModified() && + downloadLocation.toFile().exists()) { + logger.debug("Using cached pack (%s) for %s.".formatted(downloadLocation.getFileName(), url)); + downloadLocation.toFile().setLastModified(System.currentTimeMillis()); + packMetadata.toFile().setLastModified(System.currentTimeMillis()); + return downloadLocation; + } else { + logger.debug("Deleting cached pack/metadata as it appears to have changed!"); + Files.deleteIfExists(packMetadata); + Files.deleteIfExists(downloadLocation); + } + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to read cached pack metadata! " + e); + packMetadata.toFile().deleteOnExit(); + } + } + + downloadLocation = REMOTE_PACK_CACHE.resolve(url.hashCode() + "_" + System.currentTimeMillis() + ".zip"); + Files.copy(con.getInputStream(), downloadLocation, StandardCopyOption.REPLACE_EXISTING); + + // This needs to match as the client fails to download the pack otherwise + long downloadSize = Files.size(downloadLocation); + if (downloadSize != size) { + Files.delete(downloadLocation); + throw new IllegalStateException("Size mismatch with resource pack at url: %s. Downloaded pack has %s bytes, expected %s bytes!" + .formatted(url, downloadSize, size)); + } + + try { + Files.write( + packMetadata, + Arrays.asList( + String.valueOf(size), + con.getHeaderField("ETag"), + String.valueOf(con.getLastModified()), + downloadLocation.getFileName().toString() + )); + packMetadata.toFile().setLastModified(System.currentTimeMillis()); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to write cached pack metadata: " + e.getMessage()); + Files.delete(packMetadata); + Files.delete(downloadLocation); + return null; + } + + downloadLocation.toFile().setLastModified(System.currentTimeMillis()); + return downloadLocation; + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Unable to download resource pack from malformed URL %s! ".formatted(url)); + } catch (SocketTimeoutException | ConnectException e) { + logger.error("Unable to download pack from url %s due to network error! ( %s )".formatted(url, e.getMessage())); + logger.debug(e); + } catch (IOException e) { + throw new IllegalStateException("Unable to download and save remote resource pack from: %s ( %s )!".formatted(url, e.getMessage())); + } + return null; + } + + /** * Post a string to the given URL * diff --git a/core/src/test/java/org/geysermc/geyser/registry/loader/ResourcePackLoaderTest.java b/core/src/test/java/org/geysermc/geyser/registry/loader/ResourcePackLoaderTest.java index ce2fd2a6f97..510680848dc 100644 --- a/core/src/test/java/org/geysermc/geyser/registry/loader/ResourcePackLoaderTest.java +++ b/core/src/test/java/org/geysermc/geyser/registry/loader/ResourcePackLoaderTest.java @@ -62,7 +62,7 @@ public void testPathMatcher() { public void testPack() throws Exception { // this mcpack only contains a folder, which the manifest is in Path path = getResource("empty_pack.mcpack"); - ResourcePack pack = ResourcePackLoader.readPack(path); + ResourcePack pack = ResourcePackLoader.readPack(path).build(); assertEquals("", pack.contentKey()); // should probably add some more tests here related to the manifest } @@ -71,7 +71,7 @@ public void testPack() throws Exception { public void testEncryptedPack() throws Exception { // this zip only contains a contents.json and manifest.json at the root Path path = getResource("encrypted_pack.zip"); - ResourcePack pack = ResourcePackLoader.readPack(path); + ResourcePack pack = ResourcePackLoader.readPack(path).build(); assertEquals("JAGcSXcXwcODc1YS70GzeWAUKEO172UA", pack.contentKey()); } diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java index f147e766d08..e033b7288cb 100644 --- a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/EmptyGeyserLogger.java @@ -63,6 +63,11 @@ public void debug(String message) { } + @Override + public void debug(String message, Object... arguments) { + + } + @Override public void setDebug(boolean debug) {