diff --git a/README.md b/README.md index ff1598e8d80..75a50591c3f 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Special thanks to the DragonProxy project for being a trailblazer in protocol tr ## Supported Versions -| Edition | Supported Versions | -|---------|----------------------------------------------------------------------------------------------------------------------------------------| -| Bedrock | 26.0, 26.1, 26.2, 26.3, 26.10, 26.20, 26.21, 26.22, 26.23, 26.30, 26.31, 26.32, 26.33, 26.34, 26.40, 26.41, 26.42, 26.43, 26.44, 26.45 | -| Java | 26.2 (For older versions, [see this guide](https://geysermc.org/wiki/geyser/supported-versions/)) | +| Edition | Supported Versions | +|---------|---------------------------------------------------------------------------------------------------| +| Bedrock | 26.30, 26.31, 26.32, 26.33, 26.34, 26.40, 26.41, 26.42, 26.43, 26.44, 26.45, 26.50, 26.51 | +| Java | 26.2 (For older versions, [see this guide](https://geysermc.org/wiki/geyser/supported-versions/)) | ## Setting Up Take a look [here](https://geysermc.org/wiki/geyser/setup/) for how to set up Geyser. diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeAppearance.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeAppearance.java new file mode 100644 index 00000000000..532ba0d12a1 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeAppearance.java @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import org.checkerframework.common.returnsreceiver.qual.This; +import org.geysermc.geyser.api.GeyserApi; +import org.geysermc.geyser.api.util.GenericBuilder; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.Nullable; + +import java.awt.Color; + +/** + * The visual appearance of a custom biome, delivered to Bedrock clients through a + * generated resource pack. All values are optional, and at least one value must be set. + * + *

The alpha component of colors is ignored. When only one of the two water fog values + * is set, the other is completed from the vanilla Bedrock default fog ({@code #44AFF5}, + * fully opaque at 60 blocks).

+ * + * @since 2.11.3 + */ +@ApiStatus.NonExtendable +public interface CustomBiomeAppearance { + + /** + * The sky color, or null to use the client's default. The Vibrant Visuals renderer + * uses it as the daytime zenith tint; the horizon and the vanilla night colors remain. + * + * @return the sky color + * @since 2.11.3 + */ + @Nullable Color skyColor(); + + /** + * The color of the distance fog in air, or null to use the client's default. Rain + * shows it darkened, matching Java's full-rain fog. The Vibrant Visuals renderer + * does not apply this value as of Bedrock 1.26.44. + * + * @return the fog color + * @since 2.11.3 + */ + @Nullable Color fogColor(); + + /** + * The color of the water surface, or null to use the client's default. The Vibrant + * Visuals renderer mixes it into the water rather than applying it directly, so the + * exact rendered color can differ. + * + * @return the water surface color + * @since 2.11.3 + */ + @Nullable Color waterSurfaceColor(); + + /** + * The opacity of the water surface, between {@code 0.0} and {@code 1.0} inclusive, or + * null to use the client's default. The Vibrant Visuals renderer does not apply + * this value as of Bedrock 1.26.44. + * + * @return the water surface opacity + * @since 2.11.3 + */ + @Nullable Float waterSurfaceOpacity(); + + /** + * The color of the fog seen underwater, or null when not set. The Vibrant Visuals + * renderer does not apply it to the underwater haze as of Bedrock 1.26.44, though + * nearby submerged surfaces still pick up the tint. + * + * @return the underwater fog color + * @since 2.11.3 + */ + @Nullable Color waterFogColor(); + + /** + * The distance, in blocks, at which the underwater fog is fully opaque, or null when + * not set. + * + * @return the underwater fog end distance + * @since 2.11.3 + */ + @Nullable Float waterFogEndDistance(); + + /** + * The grass tint, or null to let the client derive one from the biome's climate. + * + * @return the grass color + * @since 2.11.3 + */ + @Nullable Color grassColor(); + + /** + * The foliage tint, or null to let the client derive one from the biome's climate. + * + * @return the foliage color + * @since 2.11.3 + */ + @Nullable Color foliageColor(); + + /** + * The dry foliage tint, or null to use the client's default. + * + * @return the dry foliage color + * @since 2.11.3 + */ + @Nullable Color dryFoliageColor(); + + /** + * The ambient ash or spore particles shown in this biome, or null for none. + * + * @return the biome's ambient ash or spore particles + * @since 2.11.3 + */ + @Nullable CustomBiomePrecipitation precipitation(); + + /** + * Creates a builder for a custom biome appearance. + * + * @return a new appearance builder + * @since 2.11.3 + */ + static Builder builder() { + return GeyserApi.api().provider(Builder.class); + } + + /** + * The builder for a custom biome appearance. + * @since 2.11.3 + */ + interface Builder extends GenericBuilder { + + /** + * Sets the sky color. + * + * @param skyColor the sky color + * @see CustomBiomeAppearance#skyColor() + * @return this builder + * @since 2.11.3 + */ + @This + Builder skyColor(Color skyColor); + + /** + * Sets the fog color. + * + * @param fogColor the fog color + * @see CustomBiomeAppearance#fogColor() + * @return this builder + * @since 2.11.3 + */ + @This + Builder fogColor(Color fogColor); + + /** + * Sets the color of the water surface. + * + * @param waterSurfaceColor the water surface color + * @see CustomBiomeAppearance#waterSurfaceColor() + * @return this builder + * @since 2.11.3 + */ + @This + Builder waterSurfaceColor(Color waterSurfaceColor); + + /** + * Sets the opacity of the water surface, between {@code 0.0} and {@code 1.0} + * inclusive. + * + * @param waterSurfaceOpacity the water surface opacity + * @see CustomBiomeAppearance#waterSurfaceOpacity() + * @return this builder + * @since 2.11.3 + */ + @This + Builder waterSurfaceOpacity(float waterSurfaceOpacity); + + /** + * Sets the color of the fog seen underwater. + * + * @param waterFogColor the underwater fog color + * @see CustomBiomeAppearance#waterFogColor() + * @return this builder + * @since 2.11.3 + */ + @This + Builder waterFogColor(Color waterFogColor); + + /** + * Sets the distance, in blocks, at which the underwater fog is fully opaque. + * + * @param waterFogEndDistance the underwater fog end distance + * @see CustomBiomeAppearance#waterFogEndDistance() + * @return this builder + * @since 2.11.3 + */ + @This + Builder waterFogEndDistance(float waterFogEndDistance); + + /** + * Sets the grass tint. + * + * @param grassColor the grass color + * @see CustomBiomeAppearance#grassColor() + * @return this builder + * @since 2.11.3 + */ + @This + Builder grassColor(Color grassColor); + + /** + * Sets the foliage tint. + * + * @param foliageColor the foliage color + * @see CustomBiomeAppearance#foliageColor() + * @return this builder + * @since 2.11.3 + */ + @This + Builder foliageColor(Color foliageColor); + + /** + * Sets the dry foliage tint. + * + * @param dryFoliageColor the dry foliage color + * @see CustomBiomeAppearance#dryFoliageColor() + * @return this builder + * @since 2.11.3 + */ + @This + Builder dryFoliageColor(Color dryFoliageColor); + + /** + * Sets the ambient ash or spore particles shown in this biome. + * + * @param precipitation the ambient ash or spore particles + * @see CustomBiomeAppearance#precipitation() + * @return this builder + * @since 2.11.3 + */ + @This + Builder precipitation(CustomBiomePrecipitation precipitation); + + /** + * Creates the custom biome appearance. + * + * @return the created appearance + * @throws IllegalArgumentException when no value was set, when a numeric value is + * out of range, or when the precipitation was not created through {@link CustomBiomePrecipitation#of} + * @since 2.11.3 + */ + @Override + CustomBiomeAppearance build(); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinition.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinition.java new file mode 100644 index 00000000000..f6854f45db6 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinition.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import org.checkerframework.common.returnsreceiver.qual.This; +import org.geysermc.geyser.api.GeyserApi; +import org.geysermc.geyser.api.util.GenericBuilder; +import org.geysermc.geyser.api.util.Identifier; +import org.jetbrains.annotations.ApiStatus; +import org.jspecify.annotations.Nullable; + +import java.util.Set; + +/** + * Defines a custom Bedrock biome. Geyser registers the definition with the Bedrock client, + * and uses it when translating chunks that contain the Java biome it was registered for. + * Java biomes that have neither a definition nor a vanilla equivalent are translated to a + * vanilla biome fitting the dimension instead. + * + *

Base climate values are taken from the Java biome the definition is registered for, + * so rain and snow follow the server's climate. Java behavior that varies with the + * position inside one biome, such as mountain snow lines, is approximated. Visuals can be + * set in the optional {@link CustomBiomeAppearance}, which Geyser delivers to clients in + * a generated resource pack.

+ * + * @since 2.11.3 + */ +@ApiStatus.NonExtendable +public interface CustomBiomeDefinition { + + /** + * The Bedrock identifier of this biome. Namespace and path may only contain lowercase + * letters, digits, {@code .}, {@code _} and {@code -}; unlike Java identifiers, the + * path cannot contain {@code /}. The {@code minecraft} namespace and the + * {@code geyser:auto_} prefix are reserved. + * + * @return the Bedrock biome identifier + * @since 2.11.3 + */ + Identifier bedrockIdentifier(); + + /** + * The Bedrock biome tags of this biome, passed through to the Bedrock definition. + * Data-driven content, such as spawn rules, matches them with the + * {@code has_biome_tag} filter. A tag may only contain lowercase letters, digits, + * {@code .} and {@code _}, with at most one {@code :} separating a namespace, and + * cannot start with {@code minecraft:}. + * + * @return an immutable set of the biome's Bedrock tags + * @since 2.11.3 + */ + Set tags(); + + /** + * The visual appearance of this biome, or null when Geyser should not generate + * appearance assets for it. A resource pack supplied by the server owner can still + * style the biome. + * + * @return the biome's appearance + * @since 2.11.3 + */ + @Nullable CustomBiomeAppearance appearance(); + + /** + * Creates a builder for a custom biome definition. + * + * @param bedrockIdentifier the Bedrock identifier of the biome + * @return a new definition builder + * @since 2.11.3 + */ + static Builder builder(Identifier bedrockIdentifier) { + return GeyserApi.api().provider(Builder.class, bedrockIdentifier); + } + + /** + * The builder for a custom biome definition. + * @since 2.11.3 + */ + interface Builder extends GenericBuilder { + + /** + * Adds a Bedrock biome tag. + * + * @param tag the tag to add + * @see CustomBiomeDefinition#tags() + * @return this builder + * @since 2.11.3 + */ + @This + Builder tag(String tag); + + /** + * Sets the biome's visual appearance. + * + * @param appearance the biome appearance + * @see CustomBiomeDefinition#appearance() + * @return this builder + * @since 2.11.3 + */ + @This + Builder appearance(CustomBiomeAppearance appearance); + + /** + * Convenience method for {@link CustomBiomeDefinition.Builder#appearance(CustomBiomeAppearance)}. + * + * @param appearance the builder of the biome appearance + * @see CustomBiomeDefinition.Builder#appearance(CustomBiomeAppearance) + * @return this builder + * @since 2.11.3 + */ + @This + default Builder appearance(CustomBiomeAppearance.Builder appearance) { + return appearance(appearance.build()); + } + + /** + * Creates the custom biome definition. + * + * @return the created definition + * @throws IllegalArgumentException when the identifier or a tag is invalid, or + * when the appearance was not created through {@link CustomBiomeAppearance#builder()} + * @since 2.11.3 + */ + @Override + CustomBiomeDefinition build(); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinitionRegisterException.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinitionRegisterException.java new file mode 100644 index 00000000000..f04e4598d63 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinitionRegisterException.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import org.jetbrains.annotations.ApiStatus; + +import java.io.Serial; + +/** + * Thrown when there was an error registering the custom biome definition. The exception message will have details as to what went wrong. + * @since 2.11.3 + */ +@ApiStatus.NonExtendable +public class CustomBiomeDefinitionRegisterException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + @ApiStatus.Internal + public CustomBiomeDefinitionRegisterException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomePrecipitation.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomePrecipitation.java new file mode 100644 index 00000000000..7da306a3aea --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomePrecipitation.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import org.geysermc.geyser.api.GeyserApi; +import org.jetbrains.annotations.ApiStatus; + +/** + * Ambient ash or spore particles shown in a custom biome, like in the vanilla soul sand + * valley and warped forest. Bedrock exposes these through its precipitation component, + * but they are not rain or snow; those follow the Java biome's climate. A biome can have + * at most one precipitation type. + * + * @since 2.11.3 + */ +@ApiStatus.NonExtendable +public interface CustomBiomePrecipitation { + + /** + * The particle type shown in this biome. + * + * @return the ambient particle type + * @since 2.11.3 + */ + Type type(); + + /** + * The particle density, {@code 0.0} or greater. For reference, the vanilla basalt + * deltas use a white ash density of {@code 2.0}. + * + * @return the particle density + * @since 2.11.3 + */ + float density(); + + /** + * Creates a precipitation instance of the given type and density. + * + * @param type the ambient particle type + * @param density the particle density, {@code 0.0} or greater + * @return a new precipitation instance + * @throws NullPointerException when the type is null + * @throws IllegalArgumentException when the density is negative or not finite + * @since 2.11.3 + */ + static CustomBiomePrecipitation of(Type type, float density) { + return GeyserApi.api().provider(CustomBiomePrecipitation.class, type, density); + } + + /** + * The available precipitation particle types. + * + * @since 2.11.3 + */ + enum Type { + /** + * Ash particles, used by the vanilla soul sand valley + */ + ASH, + /** + * White ash particles, used by the vanilla basalt deltas + */ + WHITE_ASH, + /** + * Red spore particles, used by the vanilla crimson forest + */ + RED_SPORES, + /** + * Blue spore particles, used by the vanilla warped forest + */ + BLUE_SPORES + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/package-info.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/package-info.java new file mode 100644 index 00000000000..d8c385ee4e3 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/package-info.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 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 + */ + +@NullMarked +package org.geysermc.geyser.api.biome.custom; + +import org.jspecify.annotations.NullMarked; diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomBiomesEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomBiomesEvent.java new file mode 100644 index 00000000000..994f5b58c6f --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomBiomesEvent.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 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.geysermc.event.Event; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinitionRegisterException; +import org.geysermc.geyser.api.util.Identifier; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Map; + +/** + * Called on Geyser's startup when looking for custom biomes. Custom biomes must be + * registered through this event. They are most useful for Java biomes that have no + * Bedrock equivalent, such as biomes added by datapacks or mods, but vanilla Java + * biomes can be overridden as well. + * + *

A registered definition is only used on sessions where the Java server has the + * Java biome in its registry.

+ * + *

This event will not be called if the "enable-custom-content" setting is disabled + * in the Geyser config.

+ * + * @since 2.11.3 + */ +@ApiStatus.NonExtendable +public interface GeyserDefineCustomBiomesEvent extends Event { + + /** + * A map of all the already registered custom biome definitions, indexed by the + * identifier of the Java biome they were registered for. + * + * @return an unmodifiable map of the registered definitions + * @since 2.11.3 + */ + Map customBiomeDefinitions(); + + /** + * Registers a custom biome definition for a Java biome; the definition must come from + * {@link CustomBiomeDefinition#builder(Identifier)}. Registering is only possible + * while this event is being fired. Every registration needs its own Java biome and + * its own Bedrock identifier; reusing either will throw an exception. + * + * @param javaIdentifier the identifier of the Java biome to register the definition for + * @param definition the custom biome definition to register + * @throws CustomBiomeDefinitionRegisterException when an error occurred while registering the biome + * @since 2.11.3 + */ + void register(Identifier javaIdentifier, CustomBiomeDefinition definition); +} diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index 780ed5c9d48..e0a80a70dff 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -460,45 +460,34 @@ private void startInstance() { // The explicit WebRTC port property always wins. Without it, and with only NetherNet, WebRTC is the only // UDP service, so it follows the UDP port property; with "both", RakNet keeps that port. - String webrtcPort = System.getProperty("geyserWebrtcPort", ""); - boolean webrtcPortPropertyApplied = false; - if (!webrtcPort.isEmpty()) { - try { - int parsedPort = Integer.parseInt(webrtcPort); - if (parsedPort < 1 || parsedPort > 65535) { - throw new NumberFormatException("The WebRTC port must be between 1 and 65535 inclusive!"); - } - config.bedrock().webrtcPort(parsedPort); - webrtcPortPropertyApplied = true; - logger.info("NetherNet (WebRTC) port set from system property: " + parsedPort); - } catch (NumberFormatException e) { - logger.error(String.format("Invalid WebRTC port from system property: %s! Defaulting to configured port.", webrtcPort + " (" + e.getMessage() + ")")); - } - } - if (!webrtcPortPropertyApplied && udpPortPropertyApplied && config.bedrock().transport() == GeyserConfig.BedrockConfig.Transport.NETHERNET) { + int webrtcPort = portProperty("geyserWebrtcPort", "NetherNet (WebRTC)", logger); + if (webrtcPort != 0) { + config.bedrock().webrtcPort(webrtcPort); + logger.info("NetherNet (WebRTC) port set from system property: " + webrtcPort); + } else if (udpPortPropertyApplied && config.bedrock().transport() == GeyserConfig.BedrockConfig.Transport.NETHERNET) { config.bedrock().webrtcPort(0); logger.info("NetherNet (WebRTC) port set from the Bedrock port system property: " + config.bedrock().port()); } // Now that the Bedrock port may have been changed, also check the broadcast port (configurable on all platforms) - String broadcastPort = System.getProperty("geyserBroadcastPort", ""); - if (!broadcastPort.isEmpty()) { - try { - int parsedPort = Integer.parseInt(broadcastPort); - if (parsedPort < 1 || parsedPort > 65535) { - throw new NumberFormatException("The broadcast port must be between 1 and 65535 inclusive!"); - } - config.advanced().bedrock().broadcastPort(parsedPort); - logger.info("Broadcast port set from system property: " + parsedPort); - } catch (NumberFormatException e) { - logger.error(String.format("Invalid broadcast port from system property: %s! Defaulting to configured port.", broadcastPort + " (" + e.getMessage() + ")")); - } + int broadcastPort = portProperty("geyserBroadcastPort", "Broadcast", logger); + if (broadcastPort != 0) { + config.advanced().bedrock().broadcastPort(broadcastPort); + logger.info("Broadcast port set from system property: " + broadcastPort); } + config.bedrock().raknetPort(portProperty("geyserRaknetPort", "RakNet", logger)); + config.bedrock().signaling().port(portProperty("geyserSignalingPort", "Built-in signaling", logger)); - // It's set to 0 only if no system property or manual config value was set + // These are 0 only if no system property or manual config value was set if (config.advanced().bedrock().broadcastPort() == 0) { config.advanced().bedrock().broadcastPort(config.bedrock().port()); } + if (config.bedrock().raknetPort() == 0) { + config.bedrock().raknetPort(config.bedrock().port()); + } + if (config.bedrock().signaling().port() == 0) { + config.bedrock().signaling().port(config.bedrock().port()); + } if (!(config instanceof GeyserPluginConfig)) { String remoteAddress = config.java().address(); @@ -626,6 +615,30 @@ public boolean transfer(@NonNull UUID uuid, @NonNull String address, int port) { return session.transfer(address, port); } + /** + * Reads a port from a system property. Where each service ends up is already in the startup logs, + * so a valid port is applied quietly. + * + * @return the port, or 0 when the property is unset or does not hold a valid port + */ + private static int portProperty(String property, String description, GeyserLogger logger) { + String value = System.getProperty(property, ""); + if (value.isEmpty()) { + return 0; + } + try { + int port = Integer.parseInt(value); + if (port < 1 || port > 65535) { + throw new NumberFormatException("it must be between 1 and 65535 inclusive"); + } + return port; + } catch (NumberFormatException e) { + logger.error("Invalid " + description + " port from system property: " + value + + " (" + e.getMessage() + ")! Defaulting to the configured port."); + return 0; + } + } + private void startRaknet(GeyserConfig config, GeyserLogger logger) { int bedrockThreadCount = Integer.getInteger("Geyser.BedrockNetworkThreads", -1); if (bedrockThreadCount == -1) { @@ -634,10 +647,10 @@ private void startRaknet(GeyserConfig config, GeyserLogger logger) { } this.geyserServer = new RaknetServer(this, bedrockThreadCount); - this.geyserServer.bind(new InetSocketAddress(config.bedrock().address(), config.bedrock().port())) + this.geyserServer.bind(new InetSocketAddress(config.bedrock().address(), config.bedrock().raknetPort())) .whenComplete((avoid, throwable) -> { String address = config.bedrock().address(); - String port = String.valueOf(config.bedrock().port()); // otherwise we get commas + String port = String.valueOf(config.bedrock().raknetPort()); // otherwise we get commas if (throwable == null) { if ("0.0.0.0".equals(address)) { diff --git a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeAppearance.java b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeAppearance.java new file mode 100644 index 00000000000..2ef785d2ced --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeAppearance.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; + +import java.awt.Color; +import java.util.Objects; + +@EqualsAndHashCode +@ToString +public final class GeyserCustomBiomeAppearance implements CustomBiomeAppearance { + private final @Nullable Color skyColor; + private final @Nullable Color fogColor; + private final @Nullable Color waterSurfaceColor; + private final @Nullable Float waterSurfaceOpacity; + private final @Nullable Color waterFogColor; + private final @Nullable Float waterFogEndDistance; + private final @Nullable Color grassColor; + private final @Nullable Color foliageColor; + private final @Nullable Color dryFoliageColor; + private final @Nullable CustomBiomePrecipitation precipitation; + + public GeyserCustomBiomeAppearance(Builder builder) { + if (builder.skyColor == null && builder.fogColor == null && builder.waterSurfaceColor == null + && builder.waterSurfaceOpacity == null && builder.waterFogColor == null + && builder.waterFogEndDistance == null && builder.grassColor == null + && builder.foliageColor == null && builder.dryFoliageColor == null + && builder.precipitation == null) { + throw new IllegalArgumentException("A biome appearance must set at least one value"); + } + if (builder.waterFogEndDistance != null + && (!Float.isFinite(builder.waterFogEndDistance) || builder.waterFogEndDistance < 0.0F)) { + throw new IllegalArgumentException( + "Water fog end distance must be finite and non-negative, got " + builder.waterFogEndDistance); + } + if (builder.waterSurfaceOpacity != null && (!Float.isFinite(builder.waterSurfaceOpacity) + || builder.waterSurfaceOpacity < 0.0F || builder.waterSurfaceOpacity > 1.0F)) { + throw new IllegalArgumentException( + "Water surface opacity must be between 0.0 and 1.0, got " + builder.waterSurfaceOpacity); + } + if (builder.precipitation != null && !(builder.precipitation instanceof GeyserCustomBiomePrecipitation)) { + throw new IllegalArgumentException("The precipitation was not created with CustomBiomePrecipitation.of()"); + } + + this.skyColor = rgb(builder.skyColor); + this.fogColor = rgb(builder.fogColor); + this.waterSurfaceColor = rgb(builder.waterSurfaceColor); + this.waterSurfaceOpacity = builder.waterSurfaceOpacity; + this.waterFogColor = rgb(builder.waterFogColor); + this.waterFogEndDistance = builder.waterFogEndDistance; + this.grassColor = rgb(builder.grassColor); + this.foliageColor = rgb(builder.foliageColor); + this.dryFoliageColor = rgb(builder.dryFoliageColor); + this.precipitation = builder.precipitation; + } + + // Appearance colors are RGB only; snapshot them without alpha so it can't affect equality + private static @Nullable Color rgb(@Nullable Color color) { + return color == null ? null : new Color(color.getRGB() & 0xFFFFFF); + } + + @Override + public @Nullable Color skyColor() { + return skyColor; + } + + @Override + public @Nullable Color fogColor() { + return fogColor; + } + + @Override + public @Nullable Color waterSurfaceColor() { + return waterSurfaceColor; + } + + @Override + public @Nullable Float waterSurfaceOpacity() { + return waterSurfaceOpacity; + } + + @Override + public @Nullable Color waterFogColor() { + return waterFogColor; + } + + @Override + public @Nullable Float waterFogEndDistance() { + return waterFogEndDistance; + } + + @Override + public @Nullable Color grassColor() { + return grassColor; + } + + @Override + public @Nullable Color foliageColor() { + return foliageColor; + } + + @Override + public @Nullable Color dryFoliageColor() { + return dryFoliageColor; + } + + @Override + public @Nullable CustomBiomePrecipitation precipitation() { + return precipitation; + } + + public static class Builder implements CustomBiomeAppearance.Builder { + private @Nullable Color skyColor; + private @Nullable Color fogColor; + private @Nullable Color waterSurfaceColor; + private @Nullable Float waterSurfaceOpacity; + private @Nullable Color waterFogColor; + private @Nullable Float waterFogEndDistance; + private @Nullable Color grassColor; + private @Nullable Color foliageColor; + private @Nullable Color dryFoliageColor; + private @Nullable CustomBiomePrecipitation precipitation; + + @Override + public Builder skyColor(Color skyColor) { + this.skyColor = Objects.requireNonNull(skyColor, "skyColor may not be null"); + return this; + } + + @Override + public Builder fogColor(Color fogColor) { + this.fogColor = Objects.requireNonNull(fogColor, "fogColor may not be null"); + return this; + } + + @Override + public Builder waterSurfaceColor(Color waterSurfaceColor) { + this.waterSurfaceColor = Objects.requireNonNull(waterSurfaceColor, "waterSurfaceColor may not be null"); + return this; + } + + @Override + public Builder waterSurfaceOpacity(float waterSurfaceOpacity) { + this.waterSurfaceOpacity = waterSurfaceOpacity; + return this; + } + + @Override + public Builder waterFogColor(Color waterFogColor) { + this.waterFogColor = Objects.requireNonNull(waterFogColor, "waterFogColor may not be null"); + return this; + } + + @Override + public Builder waterFogEndDistance(float waterFogEndDistance) { + this.waterFogEndDistance = waterFogEndDistance; + return this; + } + + @Override + public Builder grassColor(Color grassColor) { + this.grassColor = Objects.requireNonNull(grassColor, "grassColor may not be null"); + return this; + } + + @Override + public Builder foliageColor(Color foliageColor) { + this.foliageColor = Objects.requireNonNull(foliageColor, "foliageColor may not be null"); + return this; + } + + @Override + public Builder dryFoliageColor(Color dryFoliageColor) { + this.dryFoliageColor = Objects.requireNonNull(dryFoliageColor, "dryFoliageColor may not be null"); + return this; + } + + @Override + public Builder precipitation(CustomBiomePrecipitation precipitation) { + this.precipitation = Objects.requireNonNull(precipitation, "precipitation may not be null"); + return this; + } + + @Override + public CustomBiomeAppearance build() { + return new GeyserCustomBiomeAppearance(this); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java new file mode 100644 index 00000000000..ef4c05d7805 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.util.Identifier; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.regex.Pattern; + +@EqualsAndHashCode +@ToString +public final class GeyserCustomBiomeDefinition implements CustomBiomeDefinition { + // Stricter than Java's identifier rules, which also allow '/' + private static final Pattern BEDROCK_IDENTIFIER = Pattern.compile("^[a-z0-9._-]+:[a-z0-9._-]+$"); + private static final Pattern BIOME_TAG = Pattern.compile("^[a-z0-9_.]+(:[a-z0-9_.]+)?$"); + + private final Identifier bedrockIdentifier; + private final Set tags; + private final @Nullable CustomBiomeAppearance appearance; + private final @Nullable UUID packUuid; + + public GeyserCustomBiomeDefinition(Builder builder) { + String identifier = builder.bedrockIdentifier.toString(); + if (!BEDROCK_IDENTIFIER.matcher(identifier).matches()) { + throw new IllegalArgumentException( + "Bedrock biome identifiers must match " + BEDROCK_IDENTIFIER.pattern() + ", got " + identifier); + } + if (Identifier.DEFAULT_NAMESPACE.equals(builder.bedrockIdentifier.namespace())) { + throw new IllegalArgumentException( + "Custom biomes cannot use the minecraft namespace: " + identifier); + } + if (!builder.derived && "geyser".equals(builder.bedrockIdentifier.namespace()) && builder.bedrockIdentifier.path().startsWith("auto_")) { + throw new IllegalArgumentException( + "The geyser:auto_ prefix is reserved for Geyser: " + identifier); + } + if (builder.appearance != null && !(builder.appearance instanceof GeyserCustomBiomeAppearance)) { + throw new IllegalArgumentException( + "The appearance for " + identifier + " was not created with CustomBiomeAppearance.builder()"); + } + for (String tag : builder.tags) { + if (!BIOME_TAG.matcher(tag).matches()) { + throw new IllegalArgumentException( + "Biome tags must match " + BIOME_TAG.pattern() + ", got " + tag); + } + if (tag.startsWith("minecraft:")) { + throw new IllegalArgumentException( + "Biome tags cannot use the minecraft namespace: " + tag); + } + } + + this.bedrockIdentifier = builder.bedrockIdentifier; + // Sorted so tag order doesn't change how a definition serializes + this.tags = Collections.unmodifiableSortedSet(new TreeSet<>(builder.tags)); + this.appearance = builder.appearance; + this.packUuid = builder.packUuid; + } + + /** + * Creates the builder for a Java biome mapping that doesn't name a Bedrock identifier, + * deriving one from the Java identifier instead. + */ + public static Builder derivedBuilder(Identifier javaIdentifier) { + Builder builder = new Builder(deriveBedrockIdentifier(javaIdentifier)); + builder.derived = true; + return builder; + } + + /** + * Java identifiers that are valid custom Bedrock identifiers are used as they are; for + * others, a digest of the full identifier is used, since flattening characters like + * {@code /} could make different Java identifiers collide. + */ + private static Identifier deriveBedrockIdentifier(Identifier javaIdentifier) { + String identifier = javaIdentifier.toString(); + if (BEDROCK_IDENTIFIER.matcher(identifier).matches() + && !Identifier.DEFAULT_NAMESPACE.equals(javaIdentifier.namespace()) + && !("geyser".equals(javaIdentifier.namespace()) && javaIdentifier.path().startsWith("auto_"))) { + return javaIdentifier; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(identifier.getBytes(StandardCharsets.UTF_8)); + return Identifier.of("geyser", "auto_" + HexFormat.of().formatHex(digest, 0, 16)); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + @Override + public Identifier bedrockIdentifier() { + return bedrockIdentifier; + } + + @Override + public Set tags() { + return tags; + } + + @Override + public @Nullable CustomBiomeAppearance appearance() { + return appearance; + } + + /** + * The provided resource pack that the biome's mappings file bound it to, or null when + * none was named. Only mappings files set this. + */ + public @Nullable UUID packUuid() { + return packUuid; + } + + public static class Builder implements CustomBiomeDefinition.Builder { + private final Identifier bedrockIdentifier; + private final Set tags = new HashSet<>(); + private @Nullable CustomBiomeAppearance appearance; + private @Nullable UUID packUuid; + private boolean derived; + + public Builder(Identifier bedrockIdentifier) { + this.bedrockIdentifier = Objects.requireNonNull(bedrockIdentifier, "bedrockIdentifier may not be null"); + } + + @Override + public Builder tag(String tag) { + this.tags.add(Objects.requireNonNull(tag, "tag may not be null")); + return this; + } + + @Override + public Builder appearance(CustomBiomeAppearance appearance) { + this.appearance = Objects.requireNonNull(appearance, "appearance may not be null"); + return this; + } + + public Builder packUuid(UUID packUuid) { + this.packUuid = Objects.requireNonNull(packUuid, "packUuid may not be null"); + return this; + } + + @Override + public CustomBiomeDefinition build() { + return new GeyserCustomBiomeDefinition(this); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomePrecipitation.java b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomePrecipitation.java new file mode 100644 index 00000000000..252918b3f48 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomePrecipitation.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; + +import java.util.Objects; + +public record GeyserCustomBiomePrecipitation(Type type, float density) implements CustomBiomePrecipitation { + + public GeyserCustomBiomePrecipitation { + Objects.requireNonNull(type, "type may not be null"); + if (!Float.isFinite(density) || density < 0.0F) { + throw new IllegalArgumentException("Precipitation density must be finite and non-negative, got " + density); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java index 5b034ad2501..845789a2947 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java @@ -36,6 +36,7 @@ import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.util.CooldownUtils; import org.spongepowered.configurate.interfaces.meta.Exclude; +import org.spongepowered.configurate.interfaces.meta.Field; import org.spongepowered.configurate.interfaces.meta.defaults.DefaultBoolean; import org.spongepowered.configurate.interfaces.meta.defaults.DefaultNumeric; import org.spongepowered.configurate.interfaces.meta.defaults.DefaultString; @@ -139,6 +140,15 @@ default Mode mode() { void mode(Mode mode); + /** + * The TCP port built-in signaling binds, set by {@code -DgeyserSignalingPort} when a host routes it separately. + * Resolved to the Bedrock port on startup when the property is unset. + */ + @Field + int port(); + + void port(int port); + @Comment("Settings for built-in signaling. Only used in the \"builtin\" and \"hybrid\" modes.") BuiltinConfig builtin(); @@ -223,6 +233,8 @@ interface BedrockConfig extends BedrockListener { @AsteriskSerializer.Asterisk String address(); + void address(String address); + @Comment(""" The port that Geyser will listen on for incoming Bedrock connections. Built-in signaling uses this port over TCP, and RakNet uses it over UDP.""") @@ -231,6 +243,8 @@ interface BedrockConfig extends BedrockListener { @NumericRange(from = 0, to = 65535) int port(); + void port(int port); + @Comment(""" The UDP port that NetherNet connections use. 0 means the same port as above. If raknet and nethernet transport mode are used in parallel, then this port has to be different to the port above.""") @@ -238,6 +252,18 @@ interface BedrockConfig extends BedrockListener { @NumericRange(from = 0, to = 65535) int webrtcPort(); + void webrtcPort(int port); + + /** + * The UDP port RakNet binds, set by {@code -DgeyserRaknetPort} when a host routes it separately. + * {@link #port()} stays the port players connect to, so the broadcast port keeps following it. + * Resolved to {@link #port()} on startup when the property is unset. + */ + @Field + int raknetPort(); + + void raknetPort(int port); + @Comment(""" How Bedrock players connect. Changes require a restart. "nethernet" uses NetherNet (with signaling), which is the new connection method for Bedrock Edition. @@ -247,6 +273,8 @@ default Transport transport() { return Transport.RAKNET; } + void transport(Transport transport); + @Comment(""" Some hosting services change your Java port everytime you start the server and require the same port to be used for Bedrock. This option makes the Bedrock port the same as the Java port every time you start the server.""") @@ -259,11 +287,6 @@ default Transport transport() { Only used with the "nethernet" and "both" transports. Changes require a restart.""") SignalingConfig signaling(); - void address(String address); - void port(int port); - void webrtcPort(int port); - void transport(Transport transport); - @Exclude @Override default int broadcastPort() { @@ -439,7 +462,7 @@ default CooldownUtils.CooldownType cooldownType() { Whether to add any items and blocks which normally does not exist in Bedrock Edition. This should only need to be disabled if using a proxy that does not use the "transfer packet" style of server switching. If this is disabled, furnace minecart items will be mapped to hopper minecart items. - Geyser's block, item, and skull mappings systems will also be disabled. + Geyser's biome, block, item, and skull mappings systems will also be disabled. This option requires a restart of Geyser in order to change its setting.""") @DefaultBoolean(true) boolean enableCustomContent(); diff --git a/core/src/main/java/org/geysermc/geyser/level/JavaBiome.java b/core/src/main/java/org/geysermc/geyser/level/JavaBiome.java new file mode 100644 index 00000000000..d3d91e0f846 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/level/JavaBiome.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 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.level; + +/** + * Represents the information we store from a Java biome. + * + * @param bedrockId the Bedrock biome ID used when translating chunks of this biome. + * @param temperature the biome's base temperature. + * @param downfall the biome's base downfall. + * @param hasPrecipitation whether rain or snow falls in this biome. + */ +public record JavaBiome(int bedrockId, float temperature, float downfall, boolean hasPrecipitation) { + + public JavaBiome withBedrockId(int bedrockId) { + return new JavaBiome(bedrockId, temperature, downfall, hasPrecipitation); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java b/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java index 18bd1f53fc9..d2f72646bea 100644 --- a/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java +++ b/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java @@ -35,7 +35,7 @@ import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168; import org.cloudburstmc.protocol.bedrock.codec.v2168.serializer.MovePlayerSerializer_v2168; import org.cloudburstmc.protocol.bedrock.codec.v2168.serializer.PlayerSkinSerializer_v2168; -import org.cloudburstmc.protocol.bedrock.codec.v2192.serializer.BossEventSerializer_v2192; +import org.cloudburstmc.protocol.bedrock.codec.v2193.serializer.BossEventSerializer_v2193; import org.cloudburstmc.protocol.bedrock.codec.v291.serializer.MobEquipmentSerializer_v291; import org.cloudburstmc.protocol.bedrock.codec.v291.serializer.MoveEntityAbsoluteSerializer_v291; import org.cloudburstmc.protocol.bedrock.codec.v291.serializer.PlayerHotbarSerializer_v291; @@ -200,7 +200,7 @@ public void deserialize(ByteBuf buffer, BedrockCodecHelper helper, BossEventPack } }; - private static final BedrockPacketSerializer BOSS_EVENT_SERIALIZER_V2192 = new BossEventSerializer_v2192() { + private static final BedrockPacketSerializer BOSS_EVENT_SERIALIZER_V2193 = new BossEventSerializer_v2193() { @Override public void deserialize(ByteBuf buffer, BedrockCodecHelper helper, BossEventPacket packet) { } @@ -384,10 +384,10 @@ static BedrockCodec processCodec(BedrockCodec codec) { codecBuilder.updateSerializer(PlayerSkinPacket.class, PLAYER_SKIN_SERIALIZER_V2168); } - if (codec.getProtocolVersion() < 2192) { // 26.50 + if (codec.getProtocolVersion() < 2193) { // 26.50 codecBuilder.updateSerializer(BossEventPacket.class, BOSS_EVENT_SERIALIZER_V1001); } else { - codecBuilder.updateSerializer(BossEventPacket.class, BOSS_EVENT_SERIALIZER_V2192); + codecBuilder.updateSerializer(BossEventPacket.class, BOSS_EVENT_SERIALIZER_V2193); } return codecBuilder.build(); diff --git a/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java b/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java index a094fc6fa4a..fc491c17dc7 100644 --- a/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java +++ b/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java @@ -33,7 +33,7 @@ import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168; import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168_hotfix4; import org.cloudburstmc.protocol.bedrock.codec.v2169.Bedrock_v2169; -import org.cloudburstmc.protocol.bedrock.codec.v2192.Bedrock_v2192; +import org.cloudburstmc.protocol.bedrock.codec.v2193.Bedrock_v2193; import org.cloudburstmc.protocol.bedrock.netty.codec.packet.BedrockPacketCodec; import org.geysermc.geyser.api.util.MinecraftVersion; import org.geysermc.geyser.impl.MinecraftVersionImpl; @@ -86,7 +86,7 @@ public final class GameProtocol { register(Bedrock_v1001.CODEC, "26.30", "26.31", "26.32", "26.33", "26.34"); register(Bedrock_v2168_hotfix4.CODEC, "26.40", "26.41", "26.42", "26.43", "26.44"); register(Bedrock_v2169.CODEC, "26.45"); - register(Bedrock_v2192.CODEC.toBuilder().protocolVersion(2193).build(), "26.50"); + register(Bedrock_v2193.CODEC, "26.50", "26.51"); MinecraftVersion latestBedrock = SUPPORTED_BEDROCK_VERSIONS.getLast(); DEFAULT_BEDROCK_VERSION = latestBedrock.versionString(); @@ -143,7 +143,7 @@ public static boolean is26_40orHigher(int protocolVersion) { } public static boolean is26_50orHigher(int protocolVersion) { - return protocolVersion >= Bedrock_v2192.CODEC.getProtocolVersion(); + return protocolVersion >= Bedrock_v2193.CODEC.getProtocolVersion(); } /** diff --git a/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java b/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java index a9db110efdd..61bb96682a5 100644 --- a/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java +++ b/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java @@ -90,7 +90,7 @@ /** * The NetherNet (WebRTC) transport, used instead of RakNet: inbuilt HTTP signaling, NXS provider registration, or both, - * all on the Bedrock address and port. + * all on the Bedrock address, and on the Bedrock port unless "webrtc-port" or -DgeyserSignalingPort move them. * Its state (signing identity, provider registration and DTLS identity) lives in the "nethernet" folder next to the config. */ public final class NetherNetServer implements EventRegistrar { @@ -102,6 +102,10 @@ public final class NetherNetServer implements EventRegistrar { * The UDP port for WebRTC: "webrtc-port", or the Bedrock port if that is 0. */ private final int webrtcPort; + /** + * The TCP port built-in signaling listens on. + */ + private final int signalingPort; private final Path dataFolder; private final BedrockPingHandler pingResponder; @@ -126,6 +130,7 @@ public NetherNetServer(GeyserImpl geyser) { GeyserConfig.BedrockConfig bedrock = geyser.config().bedrock(); this.config = bedrock.signaling(); this.webrtcPort = bedrock.webrtcPort() == 0 ? bedrock.port() : bedrock.webrtcPort(); + this.signalingPort = config.port(); this.dataFolder = geyser.getBootstrap().getConfigFolder().resolve("nethernet"); this.pingResponder = new BedrockPingHandler(geyser); } @@ -148,28 +153,22 @@ public void start() { "cannot work that way: every player will time out, including ones on this machine. " + "Set \"address\" in the \"bedrock\" section to 0.0.0.0, or to this machine's local network address."); } - if (listener.transport().raknet()) { - if (webrtcPort == listener.port()) { - logger().error("NetherNet will not start! With the \"both\" transport, RakNet and NetherNet each need their own UDP port, " + - "but both are set to " + listener.port() + ". Set \"webrtc-port\" in the \"bedrock\" section to a different free port."); - return; - } - if (listener.cloneRemotePort()) { - logger().warning("\"clone-remote-port\" is enabled, but the \"both\" transport also needs a separate TCP port for NetherNet. " + - "If your host only gives you one port, set \"transport\" in the \"bedrock\" section to \"raknet\"."); - } + if (listener.transport().raknet() && webrtcPort == listener.raknetPort()) { + logger().error("NetherNet will not start! With the \"both\" transport, RakNet and NetherNet each need their own UDP port, " + + "but both are set to " + webrtcPort + ". Set \"webrtc-port\" in the \"bedrock\" section to a different free port."); + return; } - if (inbuilt && listener.port() == geyser.config().java().port()) { + if (inbuilt && signalingPort == geyser.config().java().port()) { // e.g. clone-remote-port: the Java server owns that TCP port inbuilt = false; + String reason = "Built-in signaling cannot use port " + signalingPort + " because the Java server already uses it. "; if (!provider) { provider = true; - logger().warning("Built-in signaling cannot use port " + listener.port() + " because the Java server already uses it. " + - "Bedrock players can still find this server through the external signaling service at " + config.nxs().endpoint() + " instead."); + logger().warning(reason + "Bedrock players can still find this server through the external signaling service at " + + config.nxs().endpoint() + " instead."); } else { - logger().warning("Built-in signaling cannot use port " + listener.port() + " because the Java server already uses it. " + - "Only the external signaling service will be used."); + logger().warning(reason + "Only the external signaling service will be used."); } } @@ -255,9 +254,9 @@ private void startInbuilt() { } BedrockListener listener = geyser.config().bedrock(); - // The channel binds HTTP signaling over TCP to the Bedrock port, and by default pins ICE to its UDP side. - // When "webrtc-port" is another port, ICE goes there instead. - boolean separateIcePort = webrtcPort != listener.port(); + // The channel binds HTTP signaling over TCP to the signaling port, and by default pins ICE to its UDP side. + // When the WebRTC port is another port, ICE goes there instead. + boolean separateIcePort = webrtcPort != signalingPort; this.signaling = signalingBuilder.setIceOnLocalPort(!separateIcePort).build(); this.inbuiltEventLoopGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); @@ -279,19 +278,19 @@ protected void initChannel(Channel channel) { }); } - this.inbuiltChannel = b.bind(new InetSocketAddress(listener.address(), listener.port())).sync().channel(); + this.inbuiltChannel = b.bind(new InetSocketAddress(listener.address(), signalingPort)).sync().channel(); // TLS is served on the same port as plaintext, so both schemes reach it when configured String endpoint = https.certificate().isBlank() - ? "http://" + listener.address() + ":" + listener.port() - : "https:// and http:// on " + listener.address() + ":" + listener.port(); + ? "http://" + listener.address() + ":" + signalingPort + : "https:// and http:// on " + listener.address() + ":" + signalingPort; logger().info("Built-in signaling started on " + endpoint + (separateIcePort ? ", with NetherNet on UDP port " + webrtcPort : "")); } catch (Throwable e) { // Throwable: the WebRTC natives are not available on every platform closeInbuiltResources(); logger().warning("Built-in signaling could not start. Make sure no other program is using TCP port " - + geyser.config().bedrock().port() + ". Enable debug mode for more details."); + + signalingPort + ". Enable debug mode for more details."); logger().debug("Built-in signaling failure: " + e); } } diff --git a/core/src/main/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManager.java b/core/src/main/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManager.java new file mode 100644 index 00000000000..a457799a14f --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManager.java @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2026 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 com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import it.unimi.dsi.fastutil.Pair; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.util.FileUtils; + +import java.awt.Color; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Generates the resource pack for registered custom biome appearances. + */ +public class CustomBiomeResourcePackManager { + + // The pack UUIDs hash this salt and the asset bytes, but not the manifest; bump it when + // the manifest changes, so clients drop their cached copy + private static final long RESOURCE_PACK_VERSION = 1; + + private static final String PACK_ROOT = "custom_biome_pack/"; + // The newest schema the pack can contain is the 1.26.0 water format; every supported + // client has it + private static final String MIN_ENGINE_VERSION = "[1, 26, 0]"; + private static final String CLIENT_BIOME_FORMAT_VERSION = "1.21.120"; + private static final String FOG_FORMAT_VERSION = "1.16.100"; + // The water schema version that introduced biome_water_color_contribution + private static final String WATER_FORMAT_VERSION = "1.26.0"; + // One water setting serves every custom biome that colors its water surface, like + // vanilla's minecraft:default_water + private static final String WATER_IDENTIFIER = "geyser:biome_water"; + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + @SuppressWarnings("ResultOfMethodCallIgnored") + public static @Nullable Path createResourcePack() { + Path cachePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache"); + try { + Files.createDirectories(cachePath); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().severe("Unable to create directories for the custom biome resource pack!", e); + return null; + } + + Path packPath = cachePath.resolve("custom_biomes.mcpack"); + File packFile = packPath.toFile(); + + Map files = generateFiles(); + if (files.isEmpty()) { + packFile.delete(); // No appearances to deliver + return null; + } + + // The pack is small enough to always rewrite; content-derived UUIDs keep the + // client's own cache valid while the content is unchanged + Pair uuids = generatePackUUIDs(files); + GeyserImpl.getInstance().getLogger().info("Creating custom biome resource pack."); + try (ZipOutputStream zipOS = new ZipOutputStream(Files.newOutputStream(packPath))) { + writeEntry(zipOS, PACK_ROOT + "manifest.json", manifestJson(uuids).getBytes(StandardCharsets.UTF_8)); + for (Map.Entry file : files.entrySet()) { + writeEntry(zipOS, PACK_ROOT + file.getKey(), file.getValue()); + } + return packPath; + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().severe("Unable to create the custom biome resource pack!", e); + GeyserImpl.getInstance().getLogger().severe("Geyser-generated custom biome appearances will be unavailable."); + packFile.delete(); + } + return null; + } + + /** + * Generates every appearance asset, keyed by pack-relative path. The map is sorted so + * the content hash and the zip layout don't depend on catalogue iteration order. + */ + private static Map generateFiles() { + Map files = new TreeMap<>(); + String atmosphereTemplate = null; + boolean water = false; + for (CustomBiomeDefinition definition : Registries.CUSTOM_BIOMES.get().values()) { + CustomBiomeAppearance appearance = definition.appearance(); + if (appearance == null) { + continue; + } + String assetKey = assetKey(definition.bedrockIdentifier()); + + files.put("biomes/" + assetKey + ".client_biome.json", clientBiomeJson(definition, assetKey).getBytes(StandardCharsets.UTF_8)); + if (hasFog(appearance)) { + files.put("fogs/" + assetKey + ".fog.json", fogJson(appearance, assetKey).getBytes(StandardCharsets.UTF_8)); + } + if (appearance.skyColor() != null) { + if (atmosphereTemplate == null) { + atmosphereTemplate = FileUtils.readToString("bedrock/custom_biome_pack/atmosphere_settings.json"); + } + files.put("atmospherics/" + assetKey + ".json", atmosphereJson(atmosphereTemplate, appearance.skyColor(), assetKey).getBytes(StandardCharsets.UTF_8)); + } + water |= appearance.waterSurfaceColor() != null; + } + if (water) { + files.put("water/biome_water.water.json", waterJson().getBytes(StandardCharsets.UTF_8)); + } + return files; + } + + private static String clientBiomeJson(CustomBiomeDefinition definition, String assetKey) { + CustomBiomeAppearance appearance = definition.appearance(); + JsonObject components = new JsonObject(); + if (appearance.skyColor() != null) { + components.add("minecraft:sky_color", color("sky_color", appearance.skyColor())); + JsonObject atmosphere = new JsonObject(); + atmosphere.addProperty("atmosphere_identifier", "geyser:atmo_" + assetKey); + components.add("minecraft:atmosphere_identifier", atmosphere); + } + if (hasFog(appearance)) { + JsonObject fog = new JsonObject(); + fog.addProperty("fog_identifier", "geyser:fog_" + assetKey); + components.add("minecraft:fog_appearance", fog); + } + if (appearance.waterSurfaceColor() != null || appearance.waterSurfaceOpacity() != null) { + JsonObject water = new JsonObject(); + if (appearance.waterSurfaceColor() != null) { + water.addProperty("surface_color", hex(appearance.waterSurfaceColor())); + } + if (appearance.waterSurfaceOpacity() != null) { + water.addProperty("surface_opacity", appearance.waterSurfaceOpacity()); + } + components.add("minecraft:water_appearance", water); + } + if (appearance.waterSurfaceColor() != null) { + // Vibrant Visuals only mixes in the surface color through a bound water setting + JsonObject water = new JsonObject(); + water.addProperty("water_identifier", WATER_IDENTIFIER); + components.add("minecraft:water_identifier", water); + } + if (appearance.grassColor() != null) { + components.add("minecraft:grass_appearance", color("color", appearance.grassColor())); + } + if (appearance.foliageColor() != null) { + components.add("minecraft:foliage_appearance", color("color", appearance.foliageColor())); + } + if (appearance.dryFoliageColor() != null) { + components.add("minecraft:dry_foliage_color", color("color", appearance.dryFoliageColor())); + } + CustomBiomePrecipitation precipitation = appearance.precipitation(); + if (precipitation != null) { + JsonObject density = new JsonObject(); + density.addProperty(precipitation.type().name().toLowerCase(Locale.ROOT), precipitation.density()); + components.add("minecraft:precipitation", density); + } + + JsonObject description = new JsonObject(); + description.addProperty("identifier", definition.bedrockIdentifier().toString()); + JsonObject clientBiome = new JsonObject(); + clientBiome.add("description", description); + clientBiome.add("components", components); + JsonObject root = new JsonObject(); + root.addProperty("format_version", CLIENT_BIOME_FORMAT_VERSION); + root.add("minecraft:client_biome", clientBiome); + return GSON.toJson(root); + } + + private static boolean hasFog(CustomBiomeAppearance appearance) { + return appearance.fogColor() != null || appearance.waterFogColor() != null || appearance.waterFogEndDistance() != null; + } + + /** + * The client resolves fog per setting type, so only the blocks a value was supplied for + * are emitted, and each emitted block is complete: members the caller didn't set are + * filled in from the vanilla Bedrock default fog. + */ + private static String fogJson(CustomBiomeAppearance appearance, String assetKey) { + JsonObject distance = new JsonObject(); + if (appearance.fogColor() != null) { + JsonObject air = new JsonObject(); + air.addProperty("fog_start", 0.92); + air.addProperty("fog_end", 1.0); + air.addProperty("fog_color", hex(appearance.fogColor())); + air.addProperty("render_distance_type", "render"); + distance.add("air", air); + + // Weather fog replaces air fog in rain; without this block the default grey + // would take over, so the custom color is kept, darkened with Java's full-rain factors + JsonObject weather = new JsonObject(); + weather.addProperty("fog_start", 0.23); + weather.addProperty("fog_end", 0.7); + weather.addProperty("fog_color", hex(rainDarkened(appearance.fogColor()))); + weather.addProperty("render_distance_type", "render"); + distance.add("weather", weather); + } + if (appearance.waterFogColor() != null || appearance.waterFogEndDistance() != null) { + String waterColor = appearance.waterFogColor() != null ? hex(appearance.waterFogColor()) : "#44AFF5"; + JsonObject water = new JsonObject(); + water.addProperty("fog_start", 0.0); + water.addProperty("fog_end", appearance.waterFogEndDistance() != null ? appearance.waterFogEndDistance() : 60.0); + water.addProperty("fog_color", waterColor); + water.addProperty("render_distance_type", "fixed"); + water.add("transition_fog", transitionFog(waterColor)); + distance.add("water", water); + } + + JsonObject description = new JsonObject(); + description.addProperty("identifier", "geyser:fog_" + assetKey); + JsonObject settings = new JsonObject(); + settings.add("description", description); + settings.add("distance", distance); + JsonObject root = new JsonObject(); + root.addProperty("format_version", FOG_FORMAT_VERSION); + root.add("minecraft:fog_settings", settings); + return GSON.toJson(root); + } + + // The gradual fade-in the vanilla default fog uses when the camera enters water + private static JsonObject transitionFog(String waterColor) { + JsonObject initFog = new JsonObject(); + initFog.addProperty("fog_start", 0.0); + initFog.addProperty("fog_end", 0.01); + initFog.addProperty("fog_color", waterColor); + initFog.addProperty("render_distance_type", "fixed"); + JsonObject transition = new JsonObject(); + transition.add("init_fog", initFog); + transition.addProperty("min_percent", 0.25); + transition.addProperty("mid_seconds", 5); + transition.addProperty("mid_percent", 0.6); + transition.addProperty("max_seconds", 30); + return transition; + } + + // Java multiplies air fog with its full-rain color #7F7F99, dividing each channel by 255 + private static Color rainDarkened(Color color) { + return new Color(color.getRed() * 127 / 255, color.getGreen() * 127 / 255, color.getBlue() * 153 / 255); + } + + /** + * Vibrant Visuals ignores the client biome sky color; it needs an atmosphere with the + * sky color as its zenith. The template is the vanilla day cycle with the custom color + * on the daylight keyframes only; the night keyframes stay vanilla, and sunsets + * interpolate between the two. + */ + private static String atmosphereJson(String template, Color skyColor, String assetKey) { + return template + .replace("${identifier}", "geyser:atmo_" + assetKey) + .replace("${zenith_color}", "[" + skyColor.getRed() + ", " + skyColor.getGreen() + ", " + skyColor.getBlue() + "]"); + } + + /** + * Gives the client biome's surface color maximum contribution in Vibrant Visuals + * water; the renderer's other water properties still shape the final color. + */ + private static String waterJson() { + JsonObject description = new JsonObject(); + description.addProperty("identifier", WATER_IDENTIFIER); + JsonObject settings = new JsonObject(); + settings.add("description", description); + settings.addProperty("biome_water_color_contribution", 1.0); + JsonObject root = new JsonObject(); + root.addProperty("format_version", WATER_FORMAT_VERSION); + root.add("minecraft:water_settings", settings); + return GSON.toJson(root); + } + + private static String manifestJson(Pair uuids) { + // The pbr capability is required for the client to load this pack's Vibrant Visuals assets + return """ + { + "format_version": 2, + "header": { + "name": "Geyser Custom Biomes", + "description": "Client-side visuals for custom biomes registered through the Geyser API", + "uuid": "%s", + "version": [1, 0, 0], + "min_engine_version": %s + }, + "modules": [ + { + "type": "resources", + "uuid": "%s", + "version": [1, 0, 0] + } + ], + "capabilities": ["pbr"] + } + """.formatted(uuids.first(), MIN_ENGINE_VERSION, uuids.second()); + } + + /** + * File names use a digest of the Bedrock identifier: identifiers can contain characters + * that are unsafe in zip paths, and the digest gives each biome's assets a stable name + * that operator packs can override. + */ + private static String assetKey(Identifier bedrockIdentifier) { + return HexFormat.of().formatHex(sha256(bedrockIdentifier.toString().getBytes(StandardCharsets.UTF_8)), 0, 16); + } + + private static Pair generatePackUUIDs(Map files) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + for (int i = 0; i < 8; i++) { + digest.update((byte) ((RESOURCE_PACK_VERSION >> (i * 8)) & 0xFF)); + } + files.forEach((path, bytes) -> { + digest.update(path.getBytes(StandardCharsets.UTF_8)); + digest.update(bytes); + }); + + ByteBuffer hash = ByteBuffer.wrap(digest.digest()); + return Pair.of(new UUID(hash.getLong(), hash.getLong()), new UUID(hash.getLong(), hash.getLong())); + } + + private static void writeEntry(ZipOutputStream zipOS, String path, byte[] bytes) throws IOException { + ZipEntry entry = new ZipEntry(path); + entry.setTime(0); // Fixed timestamps keep equal content byte-for-byte reproducible + zipOS.putNextEntry(entry); + zipOS.write(bytes); + zipOS.closeEntry(); + } + + private static JsonObject color(String name, Color value) { + JsonObject object = new JsonObject(); + object.addProperty(name, hex(value)); + return object; + } + + private static String hex(Color color) { + return "#%06X".formatted(color.getRGB() & 0xFFFFFF); + } + + private static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } +} 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 ed1f72da26f..9741d93e990 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/Registries.java +++ b/core/src/main/java/org/geysermc/geyser/registry/Registries.java @@ -37,6 +37,7 @@ 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.biome.custom.CustomBiomeDefinition; import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.api.waypoint.CustomWaypointStyle; import org.geysermc.geyser.entity.BedrockEntityDefinition; @@ -58,6 +59,7 @@ import org.geysermc.geyser.registry.loader.SoundTranslatorRegistryLoader; import org.geysermc.geyser.registry.loader.WaypointStyleLoader; import org.geysermc.geyser.registry.mappings.MappingsType; +import org.geysermc.geyser.registry.populator.CustomBiomeRegistryPopulator; import org.geysermc.geyser.registry.populator.DataComponentRegistryPopulator; import org.geysermc.geyser.registry.populator.ItemRegistryPopulator; import org.geysermc.geyser.registry.populator.PacketRegistryPopulator; @@ -114,15 +116,21 @@ public final class Registries { public static final SimpleDeferredRegistry BIOMES_NBT = SimpleDeferredRegistry.create("bedrock/biome_definitions.dat", RegistryLoaders.NBT); /** - * A registry holding biome data for all known biomes. + * A registry holding the vanilla Bedrock biome definitions. */ public static final SimpleDeferredRegistry BIOMES = SimpleDeferredRegistry.create("bedrock/stripped_biome_definitions.json", RegistryLoaders.BIOME_LOADER); /** - * A mapped registry which stores Java biome identifiers and their Bedrock biome identifier. + * A mapped registry which stores each Java biome identifier and the numeric ID of the + * vanilla Bedrock biome it maps to. */ public static final SimpleDeferredRegistry> BIOME_IDENTIFIERS = SimpleDeferredRegistry.create("mappings/biomes.json", BiomeIdentifierRegistryLoader::new); + /** + * A mapped registry which stores Java biome identifiers to the custom biome definitions registered for them. + */ + public static final SimpleMappedRegistry CUSTOM_BIOMES = SimpleMappedRegistry.create(RegistryLoaders.empty(Object2ObjectOpenHashMap::new)); + /** * A mapped registry which stores a block entity identifier to its {@link BlockEntityTranslator}. */ @@ -265,6 +273,7 @@ public static void populate() { PacketRegistryPopulator.populate(); ItemRegistryPopulator.populate(); TagRegistryPopulator.populate(); + CustomBiomeRegistryPopulator.populate(); // potion mixes depend on other registries POTION_MIXES.load(); diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java index ce7c9f218d3..ad7affa967d 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java @@ -44,6 +44,10 @@ public Object2IntMap load(String input) { // As of Bedrock Edition 1.17.10 with the experimental toggle, any unmapped biome identifier sent to the client // crashes the client. Therefore, we need to have a list of all valid Bedrock biome IDs with which we can use from. // The server sends the corresponding Java network IDs, so we don't need to worry about that now. + // On current clients that no longer holds: an unknown id renders with default visuals instead + // (verified on 1.26.44). Every id a chunk uses should still come from this vanilla mapping, from + // a custom biome definition sent to the client, or from the vanilla fallback fitting the + // dimension, so biomes keep their intended look. // Reference variable for Gson to read off of Type biomeEntriesType = new TypeToken>() { }.getType(); 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 26e65579e38..e355322b36b 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 @@ -27,6 +27,9 @@ import org.geysermc.geyser.api.bedrock.camera.CameraFade; import org.geysermc.geyser.api.bedrock.camera.CameraPosition; +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; import org.geysermc.geyser.api.block.custom.CustomBlockData; import org.geysermc.geyser.api.block.custom.NonVanillaCustomBlockData; import org.geysermc.geyser.api.block.custom.component.CustomBlockComponents; @@ -76,6 +79,9 @@ import org.geysermc.geyser.api.util.Holders; import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.api.waypoint.CustomWaypointStyle; +import org.geysermc.geyser.biome.custom.GeyserCustomBiomeAppearance; +import org.geysermc.geyser.biome.custom.GeyserCustomBiomeDefinition; +import org.geysermc.geyser.biome.custom.GeyserCustomBiomePrecipitation; import org.geysermc.geyser.entity.BedrockEntityDefinition; import org.geysermc.geyser.entity.CustomBedrockEntityDefinition; import org.geysermc.geyser.entity.GeyserEntityType; @@ -220,6 +226,11 @@ public Map, ProviderSupplier> load(Map, ProviderSupplier> prov // waypoints providers.put(CustomWaypointStyle.VanillaBuilder.class, args -> new VanillaWaypoint.Builder((int) args[0], (int) args[1])); + // custom biomes + providers.put(CustomBiomeDefinition.Builder.class, args -> new GeyserCustomBiomeDefinition.Builder((Identifier) args[0])); + providers.put(CustomBiomeAppearance.Builder.class, args -> new GeyserCustomBiomeAppearance.Builder()); + providers.put(CustomBiomePrecipitation.class, args -> new GeyserCustomBiomePrecipitation((CustomBiomePrecipitation.Type) args[0], (float) args[1])); + return providers; } 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 6d29db9076c..24bc27f6ca7 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 @@ -30,12 +30,16 @@ import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; 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.api.pack.option.PriorityOption; +import org.geysermc.geyser.biome.custom.GeyserCustomBiomeDefinition; import org.geysermc.geyser.event.type.GeyserDefineResourcePacksEventImpl; +import org.geysermc.geyser.pack.CustomBiomeResourcePackManager; import org.geysermc.geyser.pack.GeyserResourcePack; import org.geysermc.geyser.pack.GeyserResourcePackManifest; import org.geysermc.geyser.pack.ResourcePackHolder; @@ -56,9 +60,11 @@ import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -126,6 +132,17 @@ public Map load(Path directory) { GeyserDefineResourcePacksEventImpl defineEvent = new GeyserDefineResourcePacksEventImpl(packMap); + // The generated biome pack is registered below normal priority, so packs supplied + // by the operator override its assets + try { + Path biomeResourcePack = CustomBiomeResourcePackManager.createResourcePack(); + if (biomeResourcePack != null) { + defineEvent.register(readPack(biomeResourcePack).build(), PriorityOption.LOW); + } + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error("Unable to register the custom biome resource pack!", e); + } + for (Path path : event.resourcePacks()) { try { defineEvent.register(readPack(path).build()); @@ -139,6 +156,22 @@ public Map load(Path directory) { loadRemotePacks(defineEvent); GeyserImpl.getInstance().eventBus().fire(defineEvent); + // Biome mappings can name a provided resource pack that styles their biomes; a + // pack_uuid typo would otherwise only show as unstyled biomes in game + Set expectedBiomePacks = new HashSet<>(); + for (CustomBiomeDefinition definition : Registries.CUSTOM_BIOMES.get().values()) { + if (definition instanceof GeyserCustomBiomeDefinition custom && custom.packUuid() != null) { + expectedBiomePacks.add(custom.packUuid()); + } + } + if (!expectedBiomePacks.isEmpty()) { + defineEvent.resourcePacks().forEach(pack -> expectedBiomePacks.remove(pack.manifest().header().uuid())); + for (UUID uuid : expectedBiomePacks) { + GeyserImpl.getInstance().getLogger().warning( + "Custom biome mappings expect resource pack " + uuid + ", but no pack with that UUID is registered"); + } + } + // After loading the new resource packs: let's clean up the old url packs cleanupRemotePacks(); diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/BuiltInMappings.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/BuiltInMappings.java index 896899563be..7507a350556 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/BuiltInMappings.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/BuiltInMappings.java @@ -306,7 +306,11 @@ private static Function mushroomCompone .build()) .materialInstance( "*", - MaterialInstance.builder().texture(fallbackOutside ? outsideTexture : insideTexture).build() + MaterialInstance.builder() + .texture(fallbackOutside ? outsideTexture : insideTexture) + .ambientOcclusion(true) + .faceDimming(true) + .build() ); for (Map.Entry entry : state.properties().entrySet()) { @@ -314,7 +318,11 @@ private static Function mushroomCompone if (outside != fallbackOutside) { components.materialInstance( entry.getKey(), - MaterialInstance.builder().texture(outside ? outsideTexture : insideTexture).build() + MaterialInstance.builder() + .texture(outside ? outsideTexture : insideTexture) + .ambientOcclusion(true) + .faceDimming(true) + .build() ); } } diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java index c26c35e45ca..71d1e3b2358 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java @@ -79,6 +79,7 @@ private static Path[] getCustomMappingsFiles(Path directory) { try (Stream paths = Files.walk(directory)) { return paths .filter(child -> child.toString().endsWith(".json")) + .sorted() // Keep order-dependent conflict resolution deterministic .toArray(Path[]::new); } catch (IOException exception) { GeyserImpl.getInstance().getLogger().error("Failed to gather custom mappings files in directory " + directory, exception); @@ -108,7 +109,7 @@ public static void readCustomMappings(MappingsType type, Path file, GeyserImpl.getInstance().getLogger().error("Mappings file " + file + " has an unsupported format version (" + formatVersion + ") for " + type.name() + " mappings"); return; } - reader.read(file, mappings.getAsJsonObject(), consumer); + reader.read(file, mappingsRoot, mappings.getAsJsonObject(), consumer); } private static @Nullable JsonObject getMappingsRoot(Path file) { diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsReader.java index c7540e6aa27..a7531349b7a 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsReader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsReader.java @@ -34,4 +34,12 @@ public interface MappingsReader { void read(Path file, JsonObject mappings, BiConsumer consumer); + + /** + * Reads a mappings file with access to its root object, for readers that consume + * file-level keys next to {@code format_version}. Defaults to ignoring the root. + */ + default void read(Path file, JsonObject root, JsonObject mappings, BiConsumer consumer) { + read(file, mappings, consumer); + } } diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsType.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsType.java index 4846eee2fa0..381bdfdedf8 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsType.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsType.java @@ -28,11 +28,13 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMaps; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomSkullsEvent; import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.api.waypoint.CustomWaypointStyle; import org.geysermc.geyser.registry.mappings.util.CustomBlockMapping; +import org.geysermc.geyser.registry.mappings.versions.biome.BiomeMappingsReader_v1; import org.geysermc.geyser.registry.mappings.versions.block.BlockMappingsReader_v1; import org.geysermc.geyser.registry.mappings.versions.item.ItemMappingsReader_v1; import org.geysermc.geyser.registry.mappings.versions.item.ItemMappingsReader_v2; @@ -43,6 +45,8 @@ import java.util.function.UnaryOperator; public record MappingsType(String name, Int2ObjectMap> readers) { + public static final MappingsType BIOMES = create("biomes", builder -> builder + .with(1, new BiomeMappingsReader_v1())); public static final MappingsType BLOCKS = create("blocks", builder -> builder .with(1, new BlockMappingsReader_v1())); public static final MappingsType ITEMS = create("items", builder -> builder diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java index e892c7c8304..5d64baa5aee 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java @@ -27,6 +27,7 @@ import com.google.gson.JsonPrimitive; import org.geysermc.geyser.Constants; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomSkullsEvent; import org.geysermc.geyser.api.item.custom.v2.component.java.JavaConsumable; import org.geysermc.geyser.api.item.custom.v2.component.java.JavaEquippable; @@ -40,9 +41,12 @@ import org.geysermc.geyser.registry.mappings.predicate.ItemMatchProperty; import org.geysermc.geyser.registry.mappings.predicate.ItemRangeDispatchProperty; +import java.awt.Color; import java.util.Arrays; +import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.Predicate; @FunctionalInterface @@ -139,23 +143,50 @@ public interface NodeReader { NodeReader SKULL_TEXTURE_TYPE = ofEnum(GeyserDefineCustomSkullsEvent.SkullTextureType.class); + // Biome readers + + NodeReader PRECIPITATION_TYPE = ofEnum(CustomBiomePrecipitation.Type.class); + + NodeReader UUID = NON_EMPTY_STRING.andThen(s -> { + try { + return java.util.UUID.fromString(s); + } catch (IllegalArgumentException exception) { + throw new InvalidCustomMappingsFileException("expected a UUID such as 01234567-89ab-cdef-0123-456789abcdef"); + } + }); + + // The alpha in #aarrggbb values is accepted and ignored, as biome colors are RGB only + NodeReader COLOR = node -> { + if (node.isNumber()) { + return new Color(INT.read(node)); + } + String string = node.getAsString(); + try { + if ((string.length() == 7 || string.length() == 9) && string.startsWith("#")) { + return new Color(Integer.parseUnsignedInt(string.substring(1), 16)); + } + } catch (NumberFormatException ignored) { + } + throw new InvalidCustomMappingsFileException("expected color to be an integer or a #rrggbb / #aarrggbb string"); + }; + static > NodeReader ofEnum(Class clazz) { - return NON_EMPTY_STRING.andThen(String::toUpperCase).andThen(s -> { + return NON_EMPTY_STRING.andThen(s -> s.toUpperCase(Locale.ROOT)).andThen(s -> { try { return Enum.valueOf(clazz, s); } catch (IllegalArgumentException exception) { throw new InvalidCustomMappingsFileException("unknown element in enum " + clazz.getSimpleName() + ", must be one of [" - + String.join(", ", Arrays.stream(clazz.getEnumConstants()).map(E::toString).toArray(String[]::new)).toLowerCase() + "]"); + + String.join(", ", Arrays.stream(clazz.getEnumConstants()).map(E::toString).toArray(String[]::new)).toLowerCase(Locale.ROOT) + "]"); } }); } static NodeReader ofMap(Map map) { - return NON_EMPTY_STRING.andThen(String::toLowerCase).andThen(s -> { + return NON_EMPTY_STRING.andThen(s -> s.toLowerCase(Locale.ROOT)).andThen(s -> { T value = map.get(s); if (value == null) { throw new InvalidCustomMappingsFileException("unknown element, must be one of [" - + String.join(", ", map.keySet()).toLowerCase() + "]"); + + String.join(", ", map.keySet()).toLowerCase(Locale.ROOT) + "]"); } return value; }); diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/biome/BiomeMappingsReader_v1.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/biome/BiomeMappingsReader_v1.java new file mode 100644 index 00000000000..eb73ffa5ade --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/biome/BiomeMappingsReader_v1.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 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.registry.mappings.versions.biome; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinitionRegisterException; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.biome.custom.GeyserCustomBiomeDefinition; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.MappingsReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public class BiomeMappingsReader_v1 implements MappingsReader { + + @Override + public void read(Path file, JsonObject mappings, BiConsumer consumer) { + read(file, new JsonObject(), mappings, consumer); + } + + @Override + public void read(Path file, JsonObject root, JsonObject mappings, BiConsumer consumer) { + UUID packUuid; + try { + packUuid = readPackUuid(root); + } catch (InvalidCustomMappingsFileException exception) { + GeyserImpl.getInstance().getLogger().error("Error reading mapping_options in custom mappings file: " + file, exception); + return; + } + // Sorted so registration conflicts don't depend on the file's property order + mappings.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { + if (entry.getValue().isJsonObject()) { + try { + Identifier javaIdentifier = Identifier.of(entry.getKey()); + consumer.accept(javaIdentifier, readDefinition(javaIdentifier, entry.getValue().getAsJsonObject(), packUuid, "biome " + javaIdentifier)); + } catch (InvalidCustomMappingsFileException | IllegalArgumentException | CustomBiomeDefinitionRegisterException exception) { + GeyserImpl.getInstance().getLogger().error("Error reading custom biome " + entry.getKey() + " in custom mappings file: " + file.toString(), exception); + } + } else { + GeyserImpl.getInstance().getLogger().error("Custom biome key " + entry.getKey() + " in custom mappings file " + file.toString() + " was not an object!"); + } + }); + } + + /** + * A file can name a provided resource pack that styles its biomes in place of the generated one, + * under {@code mapping_options.biomes.pack_uuid}. + */ + private @Nullable UUID readPackUuid(JsonObject root) throws InvalidCustomMappingsFileException { + JsonObject options = readObject(root, "mapping_options", "custom biome mappings"); + JsonObject biomeOptions = options == null ? null : readObject(options, "biomes", "custom biome mappings"); + if (biomeOptions == null) { + return null; + } + return MappingsUtil.readOrDefault(biomeOptions, "pack_uuid", NodeReader.UUID, null, "custom biome mappings"); + } + + /** + * Reads one biome. Colors are read from the same {@code effects} and {@code attributes} + * keys a Java biome uses, so the plain values of a Java biome definition can be copied + * in as-is; the optional {@code geyser} object holds what Bedrock needs on top of that. + * When no Bedrock identifier is named, one is derived from the Java identifier. In a + * file whose biome options name a {@code pack_uuid}, that pack provides the visuals, so + * appearance values are rejected. + */ + private CustomBiomeDefinition readDefinition(Identifier javaIdentifier, JsonObject object, @Nullable UUID packUuid, String... context) throws InvalidCustomMappingsFileException { + JsonObject geyser = readObject(object, "geyser", context); + GeyserCustomBiomeDefinition.Builder builder; + if (geyser != null && geyser.has("bedrock_identifier")) { + builder = new GeyserCustomBiomeDefinition.Builder( + MappingsUtil.readOrThrow(geyser, "bedrock_identifier", NodeReader.GEYSER_IDENTIFIER, context)); + } else { + builder = GeyserCustomBiomeDefinition.derivedBuilder(javaIdentifier); + } + if (geyser != null) { + MappingsUtil.readArrayIfPresent(geyser, "tags", tags -> tags.forEach(builder::tag), NodeReader.NON_EMPTY_STRING, context); + } + + CustomBiomeAppearance.Builder appearance = CustomBiomeAppearance.builder(); + if (readAppearance(appearance, readObject(object, "effects", context), readObject(object, "attributes", context), geyser, context)) { + if (packUuid != null) { + throw new InvalidCustomMappingsFileException("reading appearance values", "the file names a pack_uuid that provides the visuals; remove them", context); + } + builder.appearance(appearance); + } + if (packUuid != null) { + builder.packUuid(packUuid); + } + return builder.build(); + } + + /** + * Reads the appearance values, returning whether any was present, as the API rejects + * an empty appearance. + */ + private boolean readAppearance(CustomBiomeAppearance.Builder builder, @Nullable JsonObject effects, + @Nullable JsonObject attributes, @Nullable JsonObject geyser, String... context) throws InvalidCustomMappingsFileException { + boolean set = false; + if (effects != null) { + set |= readValue(effects, "water_color", builder::waterSurfaceColor, NodeReader.COLOR, context); + set |= readValue(effects, "grass_color", builder::grassColor, NodeReader.COLOR, context); + set |= readValue(effects, "foliage_color", builder::foliageColor, NodeReader.COLOR, context); + set |= readValue(effects, "dry_foliage_color", builder::dryFoliageColor, NodeReader.COLOR, context); + } + if (attributes != null) { + set |= readAttribute(attributes, "minecraft:visual/sky_color", builder::skyColor, NodeReader.COLOR, context); + set |= readAttribute(attributes, "minecraft:visual/fog_color", builder::fogColor, NodeReader.COLOR, context); + set |= readAttribute(attributes, "minecraft:visual/water_fog_color", builder::waterFogColor, NodeReader.COLOR, context); + set |= readAttribute(attributes, "minecraft:visual/water_fog_end_distance", builder::waterFogEndDistance, NodeReader.FLOAT, context); + } + + // The geyser values are read last, so they override the Java-shaped ones + if (geyser != null) { + set |= readValue(geyser, "water_surface_opacity", builder::waterSurfaceOpacity, NodeReader.FLOAT, context); + set |= readValue(geyser, "water_fog_end_distance", builder::waterFogEndDistance, NodeReader.FLOAT, context); + JsonObject precipitation = readObject(geyser, "precipitation", context); + if (precipitation != null) { + builder.precipitation(CustomBiomePrecipitation.of( + MappingsUtil.readOrThrow(precipitation, "type", NodeReader.PRECIPITATION_TYPE, context), + MappingsUtil.readOrThrow(precipitation, "density", NodeReader.FLOAT, context))); + set = true; + } + } + return set; + } + + private boolean readValue(JsonObject object, String key, Consumer consumer, NodeReader reader, String... context) throws InvalidCustomMappingsFileException { + if (!object.has(key)) { + return false; + } + consumer.accept(MappingsUtil.readOrThrow(object, key, reader, context)); + return true; + } + + /** + * Object-form attribute modifiers have no fixed result to translate, so they are + * skipped rather than failing the biome. + */ + private boolean readAttribute(JsonObject object, String key, Consumer consumer, NodeReader reader, String... context) throws InvalidCustomMappingsFileException { + if (object.get(key) instanceof JsonObject) { + return false; + } + return readValue(object, key, consumer, reader, context); + } + + private @Nullable JsonObject readObject(JsonObject object, String key, String... context) throws InvalidCustomMappingsFileException { + JsonElement element = object.get(key); + if (element == null) { + return null; + } else if (!element.isJsonObject()) { + throw new InvalidCustomMappingsFileException("reading " + key, key + " must be an object", context); + } + return element.getAsJsonObject(); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/BlockRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/BlockRegistryPopulator.java index 66733be2698..6b065bb0454 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/populator/BlockRegistryPopulator.java +++ b/core/src/main/java/org/geysermc/geyser/registry/populator/BlockRegistryPopulator.java @@ -46,7 +46,7 @@ import org.cloudburstmc.nbt.NbtUtils; import org.cloudburstmc.protocol.bedrock.codec.v1001.Bedrock_v1001; import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168; -import org.cloudburstmc.protocol.bedrock.codec.v2192.Bedrock_v2192; +import org.cloudburstmc.protocol.bedrock.codec.v2193.Bedrock_v2193; import org.cloudburstmc.protocol.bedrock.data.BlockPropertyData; import org.cloudburstmc.protocol.bedrock.data.definitions.BlockDefinition; import org.geysermc.geyser.GeyserImpl; @@ -124,7 +124,7 @@ private static void registerBedrockBlocks() { var blockMappers = ImmutableMap., Remapper>builder() .put(ObjectIntPair.of("26_30", Bedrock_v1001.CODEC.getProtocolVersion()), ICanHasStates::convertBlock) .put(ObjectIntPair.of("26_40", Bedrock_v2168.CODEC.getProtocolVersion()), ICanHasStates::convertBlock) - .put(ObjectIntPair.of("26_50", Bedrock_v2192.CODEC.getProtocolVersion()), tag -> tag) + .put(ObjectIntPair.of("26_50", Bedrock_v2193.CODEC.getProtocolVersion()), tag -> tag) .build(); // We can keep this strong as nothing should be garbage collected diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulator.java new file mode 100644 index 00000000000..54f22784849 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulator.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 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.registry.populator; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinitionRegisterException; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomBiomesEvent; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.biome.custom.GeyserCustomBiomeDefinition; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.registry.mappings.MappingsConfigReader; +import org.geysermc.geyser.registry.mappings.MappingsType; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public class CustomBiomeRegistryPopulator { + + public static void populate() { + if (!GeyserImpl.getInstance().config().gameplay().enableCustomContent()) { + Registries.CUSTOM_BIOMES.set(Map.of()); + return; + } + + DefineCustomBiomesEvent event = new DefineCustomBiomesEvent(); + try { + MappingsConfigReader.loadCustomMappingsFromJson(MappingsType.BIOMES, event::register); + GeyserImpl.getInstance().getEventBus().fire(event); + } finally { + // The catalogue must not change after sessions and pack generation start reading it + event.closed = true; + } + Registries.CUSTOM_BIOMES.set(Map.copyOf(event.definitions)); + + if (!event.definitions.isEmpty()) { + GeyserImpl.getInstance().getLogger().info("Registered " + event.definitions.size() + " custom biome mappings"); + } + } + + private static class DefineCustomBiomesEvent implements GeyserDefineCustomBiomesEvent { + private final Map definitions = new Object2ObjectOpenHashMap<>(); + private final Set bedrockIdentifiers = new ObjectOpenHashSet<>(); + private boolean closed; + + @Override + public Map customBiomeDefinitions() { + return Collections.unmodifiableMap(definitions); + } + + @Override + public void register(Identifier javaIdentifier, CustomBiomeDefinition definition) { + Objects.requireNonNull(javaIdentifier, "javaIdentifier may not be null"); + Objects.requireNonNull(definition, "definition may not be null"); + if (closed) { + throw new CustomBiomeDefinitionRegisterException( + "Custom biomes can only be registered while the event is being fired"); + } + if (!(definition instanceof GeyserCustomBiomeDefinition)) { + throw new CustomBiomeDefinitionRegisterException( + "The definition for " + javaIdentifier + " was not created with CustomBiomeDefinition.builder()"); + } + if (definitions.containsKey(javaIdentifier)) { + throw new CustomBiomeDefinitionRegisterException( + "A custom biome is already registered for " + javaIdentifier); + } + if (!bedrockIdentifiers.add(definition.bedrockIdentifier())) { + throw new CustomBiomeDefinitionRegisterException( + "A custom biome definition is already registered as " + definition.bedrockIdentifier()); + } + definitions.put(javaIdentifier, definition); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java index a319df3aa11..e35f3644abe 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java +++ b/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java @@ -47,7 +47,7 @@ import org.cloudburstmc.nbt.NbtUtils; import org.cloudburstmc.protocol.bedrock.codec.v1001.Bedrock_v1001; import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168; -import org.cloudburstmc.protocol.bedrock.codec.v2192.Bedrock_v2192; +import org.cloudburstmc.protocol.bedrock.codec.v2193.Bedrock_v2193; import org.cloudburstmc.protocol.bedrock.codec.v924.Bedrock_v924; import org.cloudburstmc.protocol.bedrock.codec.v944.Bedrock_v944; import org.cloudburstmc.protocol.bedrock.codec.v975.Bedrock_v975; @@ -157,7 +157,7 @@ public static void populate() { List paletteVersions = new ArrayList<>(3); paletteVersions.add(new PaletteVersion("26_30", Bedrock_v1001.CODEC.getProtocolVersion())); paletteVersions.add(new PaletteVersion("26_40", Bedrock_v2168.CODEC.getProtocolVersion())); - paletteVersions.add(new PaletteVersion("26_50", Bedrock_v2192.CODEC.getProtocolVersion())); + paletteVersions.add(new PaletteVersion("26_50", Bedrock_v2193.CODEC.getProtocolVersion())); GeyserBootstrap bootstrap = GeyserImpl.getInstance().getBootstrap(); diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/TagRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/TagRegistryPopulator.java index 508f16d474c..acf7fab3dfe 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/populator/TagRegistryPopulator.java +++ b/core/src/main/java/org/geysermc/geyser/registry/populator/TagRegistryPopulator.java @@ -35,7 +35,7 @@ import it.unimi.dsi.fastutil.objects.ObjectIntPair; import org.cloudburstmc.protocol.bedrock.codec.v1001.Bedrock_v1001; import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168; -import org.cloudburstmc.protocol.bedrock.codec.v2192.Bedrock_v2192; +import org.cloudburstmc.protocol.bedrock.codec.v2193.Bedrock_v2193; import org.cloudburstmc.protocol.bedrock.codec.v924.Bedrock_v924; import org.cloudburstmc.protocol.bedrock.codec.v944.Bedrock_v944; import org.cloudburstmc.protocol.bedrock.codec.v975.Bedrock_v975; @@ -73,7 +73,7 @@ public boolean equals(int[] a, int[] b) { List> paletteVersions = List.of( ObjectIntPair.of("26_30", Bedrock_v1001.CODEC.getProtocolVersion()), ObjectIntPair.of("26_30", Bedrock_v2168.CODEC.getProtocolVersion()), - ObjectIntPair.of("26_30", Bedrock_v2192.CODEC.getProtocolVersion()) + ObjectIntPair.of("26_30", Bedrock_v2193.CODEC.getProtocolVersion()) ); Type type = new TypeToken>>() {}.getType(); diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java index 14715e8b1bc..a71d9f25c7b 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -176,6 +176,7 @@ import org.geysermc.geyser.session.cache.BundleCache; import org.geysermc.geyser.session.cache.ChunkCache; import org.geysermc.geyser.session.cache.ComponentCache; +import org.geysermc.geyser.session.cache.CustomBiomeCache; import org.geysermc.geyser.session.cache.EntityCache; import org.geysermc.geyser.session.cache.EntityEffectCache; import org.geysermc.geyser.session.cache.FormCache; @@ -304,6 +305,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { private final BundleCache bundleCache; private final ChunkCache chunkCache; private final ComponentCache componentCache; + private final CustomBiomeCache customBiomeCache; private final EntityCache entityCache; private final EntityEffectCache effectCache; private final FormCache formCache; @@ -867,6 +869,7 @@ public GeyserSession(GeyserImpl geyser, BedrockServerSession bedrockServerSessio this.bundleCache = new BundleCache(this); this.chunkCache = new ChunkCache(this); this.componentCache = new ComponentCache(this); + this.customBiomeCache = new CustomBiomeCache(this); this.entityCache = new EntityCache(this); this.effectCache = new EntityEffectCache(); this.formCache = new FormCache(this); @@ -926,7 +929,7 @@ public void connect() { geyser.getLogger().debug("Extending overworld dimension to " + minY + " - " + maxY); DimensionDataPacket dimensionDataPacket = new DimensionDataPacket(); - dimensionDataPacket.getDefinitions().add(new DimensionDefinition("minecraft:overworld", maxY, minY, 5, 3, GeyserIntegratedPackUtil.INTEGRATED_PACK_UUID, "minecraft:plains")); + dimensionDataPacket.getDefinitions().add(new DimensionDefinition("minecraft:overworld", maxY, minY, 5, 3, GeyserIntegratedPackUtil.INTEGRATED_PACK_UUID, "")); upstream.sendPacket(dimensionDataPacket); } @@ -958,7 +961,7 @@ public void connect() { */ private void sendRegistryDefinitions() { BiomeDefinitionListPacket biomeDefinitionListPacket = new BiomeDefinitionListPacket(); - biomeDefinitionListPacket.setBiomes(Registries.BIOMES.get()); + biomeDefinitionListPacket.setBiomes(customBiomeCache.loginDefinitions()); upstream.sendPacket(biomeDefinitionListPacket); AvailableEntityIdentifiersPacket entityPacket = new AvailableEntityIdentifiersPacket(); diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/CustomBiomeCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/CustomBiomeCache.java new file mode 100644 index 00000000000..c159f0f9443 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/session/cache/CustomBiomeCache.java @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2026 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.session.cache; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; +import net.kyori.adventure.key.Key; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.cloudburstmc.protocol.bedrock.data.biome.BiomeDefinitionData; +import org.cloudburstmc.protocol.bedrock.data.biome.BiomeDefinitions; +import org.cloudburstmc.protocol.bedrock.packet.BiomeDefinitionListPacket; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.level.JavaBiome; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.session.cache.registry.JavaRegistries; +import org.geysermc.geyser.session.cache.registry.JavaRegistry; +import org.geysermc.geyser.session.cache.registry.RegistryEntryData; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Tracks the custom biome definitions and numeric ids this session has sent to the client. + * + *

Definitions are registered globally, but which ones apply depends on the Java registry + * the current backend sends. At the end of each configuration phase, {@link #reconcile()} + * matches the global catalogue against the session's Java biome registry. Before the client + * has spawned this only prepares a pending candidate; the candidate takes effect in + * {@link #loginDefinitions()}, when its definitions are handed over for sending. + * Once sent, a definition and its id keep their meaning for the whole session, even across + * backend switches: the client may still have chunks loaded that reference them.

+ */ +public final class CustomBiomeCache { + // Where Bedrock custom biome ids start; the string-pool cap bounds the definition + // count, keeping ids well below the wire format's signed-short maximum + private static final int FIRST_CUSTOM_ID = 30000; + // gophertunnel-based proxies reject definition lists and string pools over 1024 entries + private static final int MAX_LIST_ENTRIES = 1024; + // The vanilla definition that custom entries copy their wire-only values from + private static final String REFERENCE_BIOME = "minecraft:plains"; + + private final GeyserSession session; + + private final Map sent = new Object2ObjectOpenHashMap<>(); + private @Nullable Set pooledStrings; + private @Nullable PendingCatalogue pending; + + public CustomBiomeCache(GeyserSession session) { + this.session = session; + } + + /** + * The biome definitions to send during login. When {@link #reconcile()} prepared a + * pending candidate, returning it also commits it: the session's Java biome registry is + * rewritten to the candidate's ids in the same call, so the mapping and the definition + * send cannot be interleaved by chunk translation. + */ + public BiomeDefinitions loginDefinitions() { + if (pending == null) { + return Registries.BIOMES.get(); + } + return commitPending(); + } + + /** + * Discards the pending login candidate. Called when the Java server starts a new + * configuration phase: the candidate was built against the previous phase's registry, + * and forms can spawn the client before the new phase provides a replacement. + */ + public void discardPending() { + pending = null; + } + + /** + * Matches the registered custom biomes against the session's current Java biome registry. + * Called at the end of each configuration phase. Before the client has spawned, this + * replaces the pending login candidate; afterwards (backend switches, and login paths + * that spawn the client before the Java registry arrives) it resends the complete + * definition list when new definitions were added. + */ + public void reconcile() { + // Nothing can have been sent when the catalogue is empty; it is frozen at startup + Map catalogue = Registries.CUSTOM_BIOMES.get(); + if (catalogue.isEmpty()) { + return; + } + + JavaRegistry biomes = session.getRegistryCache().registry(JavaRegistries.BIOME); + List> entries = biomes.entries(); + + List claims = new ArrayList<>(); + for (int i = 0; i < entries.size(); i++) { + RegistryEntryData entry = entries.get(i); + CustomBiomeDefinition definition = catalogue.get(Identifier.of(entry.key().asString())); + if (definition != null) { + claims.add(new Claim(i, definition, entry.data())); + } + } + if (claims.isEmpty()) { + return; + } + // Sorted by Bedrock identifier so id allocation doesn't depend on the registry's order + claims.sort(Comparator.comparing(claim -> claim.definition().bedrockIdentifier().toString())); + + if (pooledStrings == null) { + pooledStrings = new ObjectOpenHashSet<>(); + Registries.BIOMES.get().getDefinitions().forEach((name, data) -> { + pooledStrings.add(name); + List tags = data.getTags(); + if (tags != null) { + pooledStrings.addAll(tags); + } + }); + } + + if (!session.isSentSpawnPacket()) { + prepareLoginCandidate(claims, entries); + } else { + reconcileSpawned(claims, entries); + } + } + + /** + * Builds the login candidate. Nothing is considered sent until {@link #commitPending()} + * runs; a discarded candidate leaves no trace in the id space or the string pool. The + * spawn packet precedes the commit, so {@link #sent} is always empty here and every + * claim is new. + */ + private void prepareLoginCandidate(List claims, List> entries) { + Map union = new Object2ObjectOpenHashMap<>(); + Set pool = new ObjectOpenHashSet<>(pooledStrings); + Map claimed = new Object2ObjectOpenHashMap<>(); + int skipped = 0; + for (Claim claim : claims) { + if (admit(claim, union, pool)) { + claimed.put(entries.get(claim.index()).key(), claim.definition().bedrockIdentifier()); + } else { + skipped++; + } + } + pending = new PendingCatalogue(buildDefinitions(union), union, pool, claimed); + warnSkipped(skipped); + } + + private BiomeDefinitions commitPending() { + PendingCatalogue pending = this.pending; + this.pending = null; + sent.putAll(pending.union()); + pooledStrings = pending.pool(); + + List> entries = session.getRegistryCache().registry(JavaRegistries.BIOME).entries(); + for (int i = 0; i < entries.size(); i++) { + RegistryEntryData entry = entries.get(i); + Identifier bedrockIdentifier = pending.claims().get(entry.key()); + if (bedrockIdentifier != null) { + entries.set(i, new RegistryEntryData<>(entry.id(), entry.key(), + entry.data().withBedrockId(sent.get(bedrockIdentifier).id()))); + } + } + return pending.definitions(); + } + + private void reconcileSpawned(List claims, List> entries) { + boolean unionGrew = false; + for (Claim claim : claims) { + if (!sent.containsKey(claim.definition().bedrockIdentifier())) { + unionGrew |= admit(claim, sent, pooledStrings); + } + } + + if (unionGrew) { + // Resend the complete list so definitions sent earlier keep their ids + BiomeDefinitionListPacket packet = new BiomeDefinitionListPacket(); + packet.setBiomes(buildDefinitions(sent)); + session.sendUpstreamPacket(packet); + } + + // Rewrite the registry only after the definition list is queued + int skipped = 0; + for (Claim claim : claims) { + SentBiome sentBiome = sent.get(claim.definition().bedrockIdentifier()); + if (sentBiome == null || !climateMatches(sentBiome.data(), claim.biome())) { + // Rejected at admission, or sent earlier with different climate; the Java + // biome stays on the vanilla fallback path + skipped++; + continue; + } + RegistryEntryData entry = entries.get(claim.index()); + entries.set(claim.index(), new RegistryEntryData<>(entry.id(), entry.key(), entry.data().withBedrockId(sentBiome.id()))); + } + warnSkipped(skipped); + } + + private boolean admit(Claim claim, Map union, Set pool) { + CustomBiomeDefinition definition = claim.definition(); + // A malformed backend could send non-finite climate values; keep them off the wire + if (!Float.isFinite(claim.biome().temperature()) || !Float.isFinite(claim.biome().downfall())) { + return false; + } + + Set newStrings = new ObjectOpenHashSet<>(); + newStrings.add(definition.bedrockIdentifier().toString()); + newStrings.addAll(definition.tags()); + newStrings.removeAll(pool); + + // Every definition pools its unique name, so this also caps the definition count + if (pool.size() + newStrings.size() > MAX_LIST_ENTRIES) { + return false; + } + + // Ids are never reused within a session, so the next slot after the union is always free + int id = FIRST_CUSTOM_ID + union.size(); + pool.addAll(newStrings); + union.put(definition.bedrockIdentifier(), new SentBiome(id, toData(id, claim))); + return true; + } + + /** + * Whether the sent wire data still matches the Java biome's climate. A definition + * keeps the climate it was first sent with for the whole session; when a later backend + * has the same biome with different climate, its claim is rejected and the Java biome + * falls back rather than rendering with stale values. + */ + private static boolean climateMatches(BiomeDefinitionData data, JavaBiome biome) { + return data.getTemperature() == biome.temperature() && data.getDownfall() == biome.downfall() + && data.isRain() == biome.hasPrecipitation(); + } + + /** + * Builds the wire definition from the Java biome the server sent, so climate always + * matches the backend. + */ + private static BiomeDefinitionData toData(int id, Claim claim) { + JavaBiome biome = claim.biome(); + BiomeDefinitionData reference = Registries.BIOMES.get().getDefinitions().get(REFERENCE_BIOME); + List tags = claim.definition().tags().isEmpty() ? null : List.copyOf(claim.definition().tags()); + // Depth, scale, foliage snow and the map water color are wire fields without a Java + // equivalent, so vanilla values are copied. chunkGenData describes client-side world + // generation, which never happens for proxied chunks; keeping it null also sidesteps + // the CloudburstMC v975+ nested write path, which cannot round-trip it + return new BiomeDefinitionData(id, biome.temperature(), biome.downfall(), reference.getFoliageSnow(), + reference.getDepth(), reference.getScale(), reference.getMapWaterColor(), biome.hasPrecipitation(), tags, null); + } + + private static BiomeDefinitions buildDefinitions(Map union) { + Map definitions = new LinkedHashMap<>(Registries.BIOMES.get().getDefinitions()); + union.entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.comparing(Identifier::toString))) + .forEach(entry -> definitions.put(entry.getKey().toString(), entry.getValue().data())); + return new BiomeDefinitions(definitions); + } + + private void warnSkipped(int skipped) { + if (skipped > 0) { + session.getGeyser().getLogger().warning(skipped + " custom biome mappings for " + session.bedrockUsername() + + " could not be applied; affected Java biomes will use fallbacks"); + } + } + + private record Claim(int index, CustomBiomeDefinition definition, JavaBiome biome) { + } + + private record SentBiome(int id, BiomeDefinitionData data) { + } + + private record PendingCatalogue(BiomeDefinitions definitions, Map union, + Set pool, Map claims) { + } +} diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistries.java b/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistries.java index 57a6040ccee..ffba192cb35 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistries.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistries.java @@ -38,6 +38,7 @@ import org.geysermc.geyser.inventory.item.GeyserInstrument; import org.geysermc.geyser.item.enchantment.Enchantment; import org.geysermc.geyser.item.type.Item; +import org.geysermc.geyser.level.JavaBiome; import org.geysermc.geyser.level.JavaDimension; import org.geysermc.geyser.level.JukeboxSong; import org.geysermc.geyser.level.PaintingType; @@ -72,7 +73,7 @@ public class JavaRegistries { public static final JavaRegistryKey CHAT_TYPE = create("chat_type"); public static final JavaRegistryKey DIMENSION_TYPE = create("dimension_type"); - public static final JavaRegistryKey BIOME = create("worldgen/biome"); + public static final JavaRegistryKey BIOME = create("worldgen/biome"); public static final JavaRegistryKey ENCHANTMENT = create("enchantment"); public static final JavaRegistryKey BANNER_PATTERN = create("banner_pattern"); public static final JavaRegistryKey INSTRUMENT = create("instrument"); diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java b/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java index fac44af94bb..087da5d2a9c 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java @@ -124,7 +124,8 @@ default int size() { } /** - * All entries of this registry, as a list. + * All entries of this registry, as a list. The returned list is the registry's live + * backing list: replacing entries in place is permitted and visible to all accessors. */ List> entries(); } diff --git a/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java index badbbc03c4a..27cf368a93f 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/level/BiomeTranslator.java @@ -26,7 +26,9 @@ package org.geysermc.geyser.translator.level; import org.checkerframework.checker.nullness.qual.Nullable; +import org.cloudburstmc.nbt.NbtMap; import org.geysermc.geyser.level.BedrockDimension; +import org.geysermc.geyser.level.JavaBiome; import org.geysermc.geyser.level.JavaDimension; import org.geysermc.geyser.session.cache.registry.JavaRegistries; import org.geysermc.geyser.session.cache.registry.JavaRegistry; @@ -50,14 +52,16 @@ public class BiomeTranslator { /** - * Marks a Java biome with no direct Bedrock equivalent; a dimension-appropriate fallback - * is selected in {@link #bedrockBiomeId(GeyserSession, JavaRegistry, int)} instead. + * Marks a Java biome with no vanilla Bedrock equivalent. A registered custom biome may + * replace the value before chunks use it; otherwise a dimension-appropriate fallback is + * selected in {@link #bedrockBiomeId(GeyserSession, JavaRegistry, int)}. */ private static final int UNKNOWN_BIOME = -1; - public static int loadServerBiome(RegistryEntryContext entry) { - String javaIdentifier = entry.id().asString(); - return Registries.BIOME_IDENTIFIERS.get().getOrDefault(javaIdentifier, UNKNOWN_BIOME); + public static JavaBiome loadServerBiome(RegistryEntryContext entry) { + NbtMap data = entry.data(); + return new JavaBiome(Registries.BIOME_IDENTIFIERS.get().getOrDefault(entry.id().asString(), UNKNOWN_BIOME), + data.getFloat("temperature"), data.getFloat("downfall"), data.getBoolean("has_precipitation")); } /** @@ -66,12 +70,12 @@ public static int loadServerBiome(RegistryEntryContext entry) { * vanilla biome fitting the session's current dimension so that sky, fog and weather * render sensibly instead of always defaulting to ocean. */ - private static int bedrockBiomeId(GeyserSession session, JavaRegistry biomeTranslations, int javaId) { - Integer bedrockId = javaId < 0 ? null : biomeTranslations.byId(javaId); - if (bedrockId == null || bedrockId == UNKNOWN_BIOME) { + private static int bedrockBiomeId(GeyserSession session, JavaRegistry biomeTranslations, int javaId) { + JavaBiome biome = javaId < 0 ? null : biomeTranslations.byId(javaId); + if (biome == null || biome.bedrockId() == UNKNOWN_BIOME) { return fallbackBiomeId(session.getDimensionType()); } - return bedrockId; + return biome.bedrockId(); } private static int fallbackBiomeId(@Nullable JavaDimension dimension) { @@ -91,7 +95,7 @@ private static int fallbackBiomeId(@Nullable JavaDimension dimension) { } public static BlockStorage toNewBedrockBiome(GeyserSession session, DataPalette biomeData) { - JavaRegistry biomeTranslations = session.getRegistryCache().registry(JavaRegistries.BIOME); + JavaRegistry biomeTranslations = session.getRegistryCache().registry(JavaRegistries.BIOME); // As of 1.17.10: the client expects the same format as a chunk but filled with biomes // As of 1.18 this is the same as Java Edition diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaFinishConfigurationTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaFinishConfigurationTranslator.java index da8fcbd6dca..4695e3962e2 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaFinishConfigurationTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaFinishConfigurationTranslator.java @@ -93,5 +93,7 @@ public void translate(GeyserSession session, ClientboundFinishConfigurationPacke session.getComponentCache().resolveComponents(); // This MUST be called after components are resolved. It uses both the collected data-driven registry information and the resolved components session.getTrimRecipes().initializeBedrockTrimRecipes(session); + + session.getCustomBiomeCache().reconcile(); } } diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaStartConfigurationTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaStartConfigurationTranslator.java index 93cac07a9d1..8d40925958b 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaStartConfigurationTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaStartConfigurationTranslator.java @@ -47,6 +47,9 @@ public void translate(GeyserSession session, ClientboundStartConfigurationPacket // Reset code of conduct being accepted session.hasAcceptedCodeOfConduct(false); + // The pending custom biome candidate was built against the previous phase's registry + session.getCustomBiomeCache().discardPending(); + ChunkUtils.sendEmptyChunks(session, session.getPlayerEntity().position().toInt(), session.getServerRenderDistance(), false); } } diff --git a/core/src/main/resources/bedrock/custom_biome_pack/atmosphere_settings.json b/core/src/main/resources/bedrock/custom_biome_pack/atmosphere_settings.json new file mode 100644 index 00000000000..63483360ce9 --- /dev/null +++ b/core/src/main/resources/bedrock/custom_biome_pack/atmosphere_settings.json @@ -0,0 +1,90 @@ +{ + "format_version": "1.21.40", + "minecraft:atmosphere_settings": { + "description": { + "identifier": "${identifier}" + }, + "horizon_blend_stops": { + "min": { + "0.000000": 0.0, + "1.000000": 0.0 + }, + "start": { + "0.000000": 0.800000011920929, + "0.250000": 0.5, + "0.300912": 0.25, + "0.750000": 0.25, + "0.827004": 0.5, + "1.000000": 0.800000011920929 + }, + "mie_start": { + "0.000000": 0.5, + "0.100000": 0.5, + "0.200000": 1.0, + "0.800000": 1.0, + "0.900000": 0.5, + "1.000000": 0.5 + }, + "max": { + "0.000000": 0.25, + "1.000000": 0.25 + } + }, + "rayleigh_strength": { + "0.000000": 10.0, + "0.138743": 10.0, + "0.250000": 5.0, + "0.330402": 5.0, + "0.640704": 5.0, + "0.717412": 5.0, + "0.929310": 10.0, + "1.000000": 10.0 + }, + "sun_mie_strength": { + "0.000000": 0.0, + "0.200000": 0.0, + "0.250000": 0.75, + "0.400000": 0.0, + "0.600000": 0.0, + "0.750000": 0.75, + "0.800000": 0.0, + "1.000000": 0.0 + }, + "moon_mie_strength": { + "0.0": 0.0, + "1.0": 0.0 + }, + "sun_glare_shape": { + "0.000000": 0.0, + "0.200000": 0.0, + "0.250000": 0.07377050071954727, + "0.400000": 0.0, + "0.600000": 0.0, + "0.750000": 0.05000000074505806, + "0.800000": 0.0, + "1.000000": 0.0 + }, + "sky_zenith_color": { + "0.000000": ${zenith_color}, + "0.199685": ${zenith_color}, + "0.352560": [40, 40, 40], + "0.644880": [40, 40, 40], + "0.800315": ${zenith_color} + }, + "sky_horizon_color": { + "0.000000": [183, 189, 198], + "0.167053": [183, 189, 198], + "0.217114": [243, 184, 149], + "0.239274": [255, 187, 163], + "0.276382": [136, 108, 108], + "0.361464": [168, 168, 238], + "0.401799": [83, 117, 157], + "0.616996": [83, 117, 157], + "0.654508": [144, 144, 238], + "0.706861": [223, 187, 237], + "0.748744": [255, 211, 179], + "0.786432": [226, 213, 191], + "0.830049": [183, 189, 198] + } + } +} diff --git a/core/src/test/java/org/geysermc/geyser/biome/custom/CustomBiomeDefinitionTest.java b/core/src/test/java/org/geysermc/geyser/biome/custom/CustomBiomeDefinitionTest.java new file mode 100644 index 00000000000..8b905f55c06 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/biome/custom/CustomBiomeDefinitionTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 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.biome.custom; + +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.scoreboard.network.util.GeyserMockContext; +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +public class CustomBiomeDefinitionTest { + + @Test + void validatesDefinitionInputs() { + GeyserMockContext.mockContext(() -> { + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("minecraft:plains")).build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("my_datapack:cave/deep_caves")).build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("geyser:auto_abc123")).build()); + + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("test:biome")).tag("Uppercase").build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("test:biome")).tag("minecraft:cold").build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("test:biome")).tag(":").build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("test:biome")).tag("a:b:c").build()); + assertThrows(NullPointerException.class, () -> CustomBiomeDefinition.builder(Identifier.of("test:biome")).tag(null)); + + // The catalogue is immutable after registration, which only holds for values + // created through the API builders + assertThrows(IllegalArgumentException.class, () -> CustomBiomeDefinition.builder(Identifier.of("test:biome")) + .appearance(mock(CustomBiomeAppearance.class)).build()); + + CustomBiomeDefinition definition = CustomBiomeDefinition.builder(Identifier.of("test:biome")) + .tag("cold").tag("animal").tag("monster") + .build(); + // Tag order must not depend on insertion order + assertEquals(List.of("animal", "cold", "monster"), List.copyOf(definition.tags())); + }); + } + + @Test + void validatesAppearance() { + GeyserMockContext.mockContext(() -> { + assertThrows(IllegalArgumentException.class, () -> CustomBiomeAppearance.builder().build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeAppearance.builder().waterSurfaceOpacity(1.5F).build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeAppearance.builder().waterFogEndDistance(-1.0F).build()); + assertThrows(IllegalArgumentException.class, () -> CustomBiomePrecipitation.of(CustomBiomePrecipitation.Type.ASH, -0.5F)); + assertThrows(NullPointerException.class, () -> CustomBiomeAppearance.builder().skyColor(null)); + assertThrows(IllegalArgumentException.class, () -> CustomBiomeAppearance.builder() + .precipitation(mock(CustomBiomePrecipitation.class)).build()); + + // Vanilla basalt deltas use a white ash density of 2.0; it must be accepted + assertEquals(2.0F, CustomBiomePrecipitation.of(CustomBiomePrecipitation.Type.WHITE_ASH, 2.0F).density()); + // A water fog color does not require an end distance + assertEquals(new Color(0x050533), CustomBiomeAppearance.builder().waterFogColor(new Color(0x050533)).build().waterFogColor()); + + // Appearance colors are RGB only, so alpha must not affect equality + CustomBiomeAppearance opaque = CustomBiomeAppearance.builder().skyColor(new Color(0x78a7ff)).build(); + assertEquals(opaque, CustomBiomeAppearance.builder().skyColor(new Color(0x78, 0xa7, 0xff, 0x12)).build()); + assertEquals(new Color(0x40a7ff), CustomBiomeAppearance.builder() + .skyColor(new Color(0x40, 0xa7, 0xff, 0x78)).build().skyColor()); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManagerTest.java b/core/src/test/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManagerTest.java new file mode 100644 index 00000000000..a4e94e46a35 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManagerTest.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026 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 com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import org.geysermc.geyser.GeyserBootstrap; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.scoreboard.network.util.GeyserMockContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.awt.Color; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +public class CustomBiomeResourcePackManagerTest { + + @TempDir + Path configFolder; + + @AfterEach + public void clearCatalogue() { + Registries.CUSTOM_BIOMES.set(new Object2ObjectOpenHashMap<>()); + } + + @Test + void generatesAppearanceAssets() { + Map files = new HashMap<>(); + GeyserMockContext.mockContext(context -> { + setup(context); + catalogue(Map.of( + "test:java_styled", definition("test:styled", true), + "test:java_plain", definition("test:plain", false))); + + Path pack = CustomBiomeResourcePackManager.createResourcePack(); + assertNotNull(pack); + files.putAll(readJsonEntries(pack)); + }); + + // The appearance-less biome produces no assets; the styled one produces all four + assertEquals(5, files.size()); // manifest + client_biome + fog + atmospherics + water + JsonObject clientBiome = files.values().stream() + .filter(json -> json.has("minecraft:client_biome")) + .findFirst().orElseThrow() + .getAsJsonObject("minecraft:client_biome"); + assertEquals("test:styled", clientBiome.getAsJsonObject("description").get("identifier").getAsString()); + + JsonObject components = clientBiome.getAsJsonObject("components"); + assertEquals("#78A7FF", components.getAsJsonObject("minecraft:sky_color").get("sky_color").getAsString()); + assertEquals("#00FFFF", components.getAsJsonObject("minecraft:water_appearance").get("surface_color").getAsString()); + assertEquals(0.55F, components.getAsJsonObject("minecraft:water_appearance").get("surface_opacity").getAsFloat()); + assertEquals("#5F9F45", components.getAsJsonObject("minecraft:grass_appearance").get("color").getAsString()); + assertEquals("#4F8F3F", components.getAsJsonObject("minecraft:foliage_appearance").get("color").getAsString()); + assertEquals("#9E814D", components.getAsJsonObject("minecraft:dry_foliage_color").get("color").getAsString()); + assertEquals(2.0F, components.getAsJsonObject("minecraft:precipitation").get("white_ash").getAsFloat()); + String fogIdentifier = components.getAsJsonObject("minecraft:fog_appearance").get("fog_identifier").getAsString(); + + // Vibrant Visuals only honors the surface color through a bound water setting, + // which all surface-colored custom biomes share + String waterIdentifier = components.getAsJsonObject("minecraft:water_identifier").get("water_identifier").getAsString(); + assertEquals("geyser:biome_water", waterIdentifier); + JsonObject waterRoot = files.values().stream() + .filter(json -> json.has("minecraft:water_settings")) + .findFirst().orElseThrow(); + // biome_water_color_contribution only exists from water schema 1.26.0 on + assertEquals("1.26.0", waterRoot.get("format_version").getAsString()); + JsonObject waterSettings = waterRoot.getAsJsonObject("minecraft:water_settings"); + assertEquals(waterIdentifier, waterSettings.getAsJsonObject("description").get("identifier").getAsString()); + assertEquals(1.0F, waterSettings.get("biome_water_color_contribution").getAsFloat()); + + JsonObject fog = files.values().stream() + .filter(json -> json.has("minecraft:fog_settings")) + .findFirst().orElseThrow() + .getAsJsonObject("minecraft:fog_settings"); + assertEquals(fogIdentifier, fog.getAsJsonObject("description").get("identifier").getAsString()); + JsonObject water = fog.getAsJsonObject("distance").getAsJsonObject("water"); + assertEquals("#050533", water.get("fog_color").getAsString()); + assertEquals(48.0F, water.get("fog_end").getAsFloat()); + // Entering water fades in like the vanilla default fog does + assertEquals("#050533", water.getAsJsonObject("transition_fog") + .getAsJsonObject("init_fog").get("fog_color").getAsString()); + // No air fog color was given, so neither an air nor a weather block may be emitted + assertNull(fog.getAsJsonObject("distance").get("air")); + assertNull(fog.getAsJsonObject("distance").get("weather")); + + JsonObject atmosphere = files.values().stream() + .filter(json -> json.has("minecraft:atmosphere_settings")) + .findFirst().orElseThrow() + .getAsJsonObject("minecraft:atmosphere_settings"); + // The custom color applies to the daylight keyframes only; the night keyframes stay vanilla + JsonObject zenith = atmosphere.getAsJsonObject("sky_zenith_color"); + assertEquals(5, zenith.size()); + for (String daylightKey : new String[] {"0.000000", "0.199685", "0.800315"}) { + assertEquals("[120,167,255]", zenith.get(daylightKey).getAsJsonArray().toString()); + } + for (String nightKey : new String[] {"0.352560", "0.644880"}) { + assertEquals("[40,40,40]", zenith.get(nightKey).getAsJsonArray().toString()); + } + } + + @Test + void fogBlocksAreCompletedFromVanillaDefaults() { + GeyserMockContext.mockContext(context -> { + setup(context); + catalogue(Map.of( + "test:java_end_only", definitionWithAppearance("test:end_only", + CustomBiomeAppearance.builder().waterFogEndDistance(12.0F)), + "test:java_color_only", definitionWithAppearance("test:color_only", + CustomBiomeAppearance.builder().waterFogColor(new Color(0x050533))), + "test:java_air_only", definitionWithAppearance("test:air_only", + CustomBiomeAppearance.builder().fogColor(new Color(0x808080))))); + + Map files = readJsonEntries(CustomBiomeResourcePackManager.createResourcePack()); + Map fogs = new HashMap<>(); + files.forEach((path, json) -> { + if (json.has("minecraft:fog_settings")) { + JsonObject settings = json.getAsJsonObject("minecraft:fog_settings"); + fogs.put(settings.getAsJsonObject("description").get("identifier").getAsString(), + settings.getAsJsonObject("distance")); + } + }); + assertEquals(3, fogs.size()); + assertEquals(2, fogs.values().stream().filter(distance -> distance.has("water")).count()); + + for (JsonObject distance : fogs.values()) { + JsonObject water = distance.getAsJsonObject("water"); + if (water == null) { + continue; + } + boolean endOnly = water.get("fog_end").getAsFloat() == 12.0F; + assertEquals(endOnly ? "#44AFF5" : "#050533", water.get("fog_color").getAsString()); + assertEquals(endOnly ? 12.0F : 60.0F, water.get("fog_end").getAsFloat()); + // The entry transition uses the block's own color and the vanilla timing + JsonObject transition = water.getAsJsonObject("transition_fog"); + assertEquals(water.get("fog_color").getAsString(), + transition.getAsJsonObject("init_fog").get("fog_color").getAsString()); + assertEquals(30, transition.get("max_seconds").getAsInt()); + } + + // Air fog brings a weather companion, so rain doesn't fall back to the default + // grey; the color is the air color multiplied with Java's full-rain #7F7F99 + JsonObject airOnly = fogs.values().stream() + .filter(distance -> distance.has("air")) + .findFirst().orElseThrow(); + JsonObject air = airOnly.getAsJsonObject("air"); + assertEquals("#808080", air.get("fog_color").getAsString()); + assertEquals(0.92F, air.get("fog_start").getAsFloat()); + assertEquals(1.0F, air.get("fog_end").getAsFloat()); + assertNull(airOnly.get("water")); + JsonObject weather = airOnly.getAsJsonObject("weather"); + assertEquals("#3F3F4C", weather.get("fog_color").getAsString()); + assertEquals(0.23F, weather.get("fog_start").getAsFloat()); + assertEquals(0.7F, weather.get("fog_end").getAsFloat()); + }); + } + + @Test + void manifestDeclaresPackMetadata() { + GeyserMockContext.mockContext(context -> { + setup(context); + catalogue(Map.of("test:java_styled", definition("test:styled", true))); + + JsonObject manifest = readJsonEntries(CustomBiomeResourcePackManager.createResourcePack()) + .get("custom_biome_pack/manifest.json"); + assertNotNull(manifest); + assertEquals("[\"pbr\"]", manifest.getAsJsonArray("capabilities").toString()); + assertEquals("[1,26,0]", manifest.getAsJsonObject("header").get("min_engine_version").getAsJsonArray().toString()); + assertEquals(1, manifest.getAsJsonArray("modules").size()); + assertNotEquals(manifest.getAsJsonObject("header").get("uuid").getAsString(), + manifest.getAsJsonArray("modules").get(0).getAsJsonObject().get("uuid").getAsString()); + }); + } + + @Test + void uuidsFollowThePackContent() { + GeyserMockContext.mockContext(context -> { + setup(context); + catalogue(Map.of("test:java_styled", definition("test:styled", true))); + + UUID[] first = packUuids(CustomBiomeResourcePackManager.createResourcePack()); + // Every generation rewrites the pack; equal content must keep the identity + UUID[] second = packUuids(CustomBiomeResourcePackManager.createResourcePack()); + assertEquals(first[0], second[0]); + assertEquals(first[1], second[1]); + + // Changed content must change the identity, or clients would keep stale caches + catalogue(Map.of("test:java_styled", definitionWithAppearance("test:styled", + CustomBiomeAppearance.builder().skyColor(new Color(0x123456))))); + UUID[] third = packUuids(CustomBiomeResourcePackManager.createResourcePack()); + assertNotEquals(first[0], third[0]); + assertNotEquals(first[1], third[1]); + }); + } + + @Test + void withoutAppearancesNoPackIsGenerated() { + GeyserMockContext.mockContext(context -> { + setup(context); + catalogue(Map.of("test:java_styled", definition("test:styled", true))); + assertNotNull(CustomBiomeResourcePackManager.createResourcePack()); + + // The pack from the earlier catalogue would go stale, so it is removed + catalogue(Map.of("test:java_plain", definition("test:plain", false))); + assertNull(CustomBiomeResourcePackManager.createResourcePack()); + assertFalse(Files.exists(configFolder.resolve("cache").resolve("custom_biomes.mcpack"))); + }); + } + + private void setup(GeyserMockContext context) { + GeyserBootstrap bootstrap = context.mock(GeyserBootstrap.class); + when(GeyserImpl.getInstance().getBootstrap()).thenReturn(bootstrap); + when(bootstrap.getConfigFolder()).thenReturn(configFolder); + when(bootstrap.getResourceOrThrow(any())).thenAnswer(invocation -> { + InputStream stream = getClass().getClassLoader().getResourceAsStream(invocation.getArgument(0)); + assertNotNull(stream, "Missing resource " + invocation.getArgument(0)); + return stream; + }); + } + + private static void catalogue(Map javaToDefinition) { + Map catalogue = new Object2ObjectOpenHashMap<>(); + javaToDefinition.forEach((javaIdentifier, definition) -> catalogue.put(Identifier.of(javaIdentifier), definition)); + Registries.CUSTOM_BIOMES.set(catalogue); + } + + private static CustomBiomeDefinition definition(String bedrockIdentifier, boolean styled) { + CustomBiomeDefinition.Builder builder = CustomBiomeDefinition.builder(Identifier.of(bedrockIdentifier)); + if (styled) { + builder.appearance(CustomBiomeAppearance.builder() + .skyColor(new Color(0x78A7FF)) + .waterSurfaceColor(new Color(0x00FFFF)) + .waterSurfaceOpacity(0.55F) + .waterFogColor(new Color(0x050533)) + .waterFogEndDistance(48.0F) + .grassColor(new Color(0x5F9F45)) + .foliageColor(new Color(0x4F8F3F)) + .dryFoliageColor(new Color(0x9E814D)) + .precipitation(CustomBiomePrecipitation.of(CustomBiomePrecipitation.Type.WHITE_ASH, 2.0F))); + } + return builder.build(); + } + + private static Map readJsonEntries(Path pack) { + Map files = new HashMap<>(); + try (ZipFile zipFile = new ZipFile(pack.toFile())) { + for (ZipEntry entry : zipFile.stream().toList()) { + if (entry.getName().endsWith(".json")) { + try (InputStream stream = zipFile.getInputStream(entry)) { + files.put(entry.getName(), (JsonObject) JsonParser.parseReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))); + } + } + } + } catch (IOException e) { + throw new AssertionError(e); + } + return files; + } + + private static UUID[] packUuids(Path pack) { + assertNotNull(pack); + JsonObject manifest = readJsonEntries(pack).get("custom_biome_pack/manifest.json"); + assertNotNull(manifest); + return new UUID[] { + UUID.fromString(manifest.getAsJsonObject("header").get("uuid").getAsString()), + UUID.fromString(manifest.getAsJsonArray("modules").get(0).getAsJsonObject().get("uuid").getAsString()) + }; + } + + private static CustomBiomeDefinition definitionWithAppearance(String bedrockIdentifier, CustomBiomeAppearance.Builder appearance) { + return CustomBiomeDefinition.builder(Identifier.of(bedrockIdentifier)) + .appearance(appearance) + .build(); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/registry/mappings/CustomBiomesLoaderTest.java b/core/src/test/java/org/geysermc/geyser/registry/mappings/CustomBiomesLoaderTest.java new file mode 100644 index 00000000000..54945fd6522 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/registry/mappings/CustomBiomesLoaderTest.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 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.registry.mappings; + +import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.biome.custom.GeyserCustomBiomeDefinition; +import org.geysermc.geyser.scoreboard.network.util.GeyserMockContext; +import org.junit.jupiter.api.Test; + +import java.awt.Color; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CustomBiomesLoaderTest { + + @Test + void packBoundFileAttachesThePackAndRejectsAppearances() throws URISyntaxException { + Path biomeConfigPath = getConfigResource("configuration/custom-biomes-packed.json"); + Map biomes = new HashMap<>(); + GeyserMockContext.mockContext(() -> { + MappingsConfigReader.readCustomMappings(MappingsType.BIOMES, biomeConfigPath, biomes::put); + + // The styled biome fails: the named pack provides the visuals + assertNull(biomes.get(Identifier.of("example:packed_styled"))); + assertEquals(2, biomes.size()); + UUID packUuid = UUID.fromString("8caa1b2a-0b23-4d55-9b46-8a3c2d9f0e11"); + for (CustomBiomeDefinition definition : biomes.values()) { + assertNull(definition.appearance()); + assertEquals(packUuid, ((GeyserCustomBiomeDefinition) definition).packUuid()); + } + }); + } + + @Test + void malformedPackUuidFailsTheWholeFile() throws URISyntaxException { + Path biomeConfigPath = getConfigResource("configuration/custom-biomes-bad-pack.json"); + Map biomes = new HashMap<>(); + GeyserMockContext.mockContext(() -> { + MappingsConfigReader.readCustomMappings(MappingsType.BIOMES, biomeConfigPath, biomes::put); + assertTrue(biomes.isEmpty()); + }); + } + + @Test + void readMappings() throws URISyntaxException { + Path biomeConfigPath = getConfigResource("configuration/custom-biomes.json"); + Map biomes = new HashMap<>(); + GeyserMockContext.mockContext(() -> { + MappingsConfigReader.readCustomMappings(MappingsType.BIOMES, biomeConfigPath, biomes::put); + assertMappings(biomes); + }); + } + + private void assertMappings(Map biomes) { + // The vanilla Bedrock namespace and malformed color entries are invalid and skipped + assertEquals(6, biomes.size()); + // Java also allows three-float color arrays; those fail the biome instead of loading + // without the color + assertNull(biomes.get(Identifier.of("example:bad_color"))); + assertNull(biomes.get(Identifier.of("example:not_object"))); + + // A bare bedrock_identifier lands in the geyser_custom namespace, like item mappings + CustomBiomeDefinition bare = biomes.get(Identifier.of("example:bare_identifier")); + assertNotNull(bare); + assertEquals(Identifier.of("geyser_custom:bare_biome"), bare.bedrockIdentifier()); + + // A vanilla Java biome may be overridden, as long as the Bedrock identifier is custom + CustomBiomeDefinition swamp = biomes.get(Identifier.of("minecraft:swamp")); + assertNotNull(swamp); + assertEquals(Identifier.of("example:swamp_recolor"), swamp.bedrockIdentifier()); + + // Without a geyser identifier, a Java identifier that is valid on Bedrock is reused + CustomBiomeDefinition derived = biomes.get(Identifier.of("example:reused_identifier")); + assertNotNull(derived); + assertEquals(Identifier.of("example:reused_identifier"), derived.bedrockIdentifier()); + assertEquals(new Color(0x5f9f45), Objects.requireNonNull(derived.appearance()).grassColor()); + + // A Java identifier Bedrock can't express becomes a digest in the reserved namespace + CustomBiomeDefinition digest = biomes.get(Identifier.of("my_datapack:cave/derived_identifier")); + assertNotNull(digest); + assertEquals("geyser", digest.bedrockIdentifier().namespace()); + assertTrue(digest.bedrockIdentifier().path().startsWith("auto_")); + // The geyser block still applies when it names no identifier + assertEquals(Set.of("overworld"), digest.tags()); + + CustomBiomeDefinition caves = biomes.get(Identifier.of("my_datapack:cave/crystal_caves")); + assertNotNull(caves); + assertEquals(Identifier.of("my_datapack:crystal_caves"), caves.bedrockIdentifier()); + assertEquals(Set.of("overworld", "monster"), caves.tags()); + + CustomBiomeAppearance appearance = caves.appearance(); + assertNotNull(appearance); + assertEquals(new Color(0x78a7ff), appearance.skyColor()); + assertEquals(new Color(12632256), appearance.fogColor()); + assertEquals(new Color(0x050533), appearance.waterFogColor()); + // The attribute is a modifier object, which is skipped; the geyser value applies instead + assertEquals(48.0F, appearance.waterFogEndDistance()); + // The fixture value is #803f76e4; the alpha of #aarrggbb colors is ignored + assertEquals(new Color(0x3f76e4), appearance.waterSurfaceColor()); + assertEquals(0.55F, appearance.waterSurfaceOpacity()); + assertEquals(new Color(0x5f9f45), appearance.grassColor()); + assertEquals(new Color(0x4f8f3f), appearance.foliageColor()); + assertEquals(new Color(0x9e814d), appearance.dryFoliageColor()); + + CustomBiomePrecipitation precipitation = appearance.precipitation(); + assertNotNull(precipitation); + assertEquals(CustomBiomePrecipitation.Type.BLUE_SPORES, precipitation.type()); + assertEquals(2.0F, precipitation.density()); + + CustomBiomeDefinition minimal = biomes.get(Identifier.of("example:minimal")); + assertNotNull(minimal); + assertNull(minimal.appearance()); + assertTrue(minimal.tags().isEmpty()); + } + + private Path getConfigResource(String name) throws URISyntaxException { + URL url = Objects.requireNonNull(getClass().getClassLoader().getResource(name), "No resource for name: " + name); + return Path.of(url.toURI()); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulatorTest.java b/core/src/test/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulatorTest.java new file mode 100644 index 00000000000..1c04fd17172 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulatorTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 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.registry.populator; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import org.geysermc.geyser.GeyserBootstrap; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinitionRegisterException; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomBiomesEvent; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.configuration.GeyserConfig; +import org.geysermc.geyser.event.GeyserEventBus; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.scoreboard.network.util.GeyserMockContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CustomBiomeRegistryPopulatorTest { + + @TempDir + Path configFolder; + + @AfterEach + public void clearCatalogue() { + Registries.CUSTOM_BIOMES.set(new Object2ObjectOpenHashMap<>()); + } + + @Test + void registrationClosesAfterStartup() { + GeyserMockContext.mockContext(context -> { + GeyserImpl geyser = GeyserImpl.getInstance(); + GeyserBootstrap bootstrap = context.mock(GeyserBootstrap.class); + when(geyser.getBootstrap()).thenReturn(bootstrap); + when(bootstrap.getConfigFolder()).thenReturn(configFolder); + GeyserConfig.GameplayConfig gameplay = context.mock(GeyserConfig.GameplayConfig.class); + when(context.mockOrSpy(GeyserConfig.class).gameplay()).thenReturn(gameplay); + when(gameplay.enableCustomContent()).thenReturn(true); + GeyserEventBus eventBus = (GeyserEventBus) geyser.eventBus(); + when(geyser.getEventBus()).thenReturn(eventBus); + + GeyserDefineCustomBiomesEvent[] captured = new GeyserDefineCustomBiomesEvent[1]; + + // With custom content disabled, the event never fires and the catalogue is empty + when(gameplay.enableCustomContent()).thenReturn(false); + geyser.eventBus().subscribe(geyser, GeyserDefineCustomBiomesEvent.class, event -> captured[0] = event); + CustomBiomeRegistryPopulator.populate(); + assertNull(captured[0]); + assertEquals(0, Registries.CUSTOM_BIOMES.get().size()); + when(gameplay.enableCustomContent()).thenReturn(true); + + geyser.eventBus().subscribe(geyser, GeyserDefineCustomBiomesEvent.class, event -> { + captured[0] = event; + event.register(Identifier.of("test:java_biome"), + CustomBiomeDefinition.builder(Identifier.of("test:bedrock_biome")).build()); + // Definitions must come from the API builder + assertThrows(CustomBiomeDefinitionRegisterException.class, () -> event.register( + Identifier.of("test:foreign"), mock(CustomBiomeDefinition.class))); + // Java and Bedrock identifiers may each only be registered once + assertThrows(CustomBiomeDefinitionRegisterException.class, () -> event.register( + Identifier.of("test:java_biome"), CustomBiomeDefinition.builder(Identifier.of("test:other_biome")).build())); + assertThrows(CustomBiomeDefinitionRegisterException.class, () -> event.register( + Identifier.of("test:other_java"), CustomBiomeDefinition.builder(Identifier.of("test:bedrock_biome")).build())); + }); + + CustomBiomeRegistryPopulator.populate(); + + assertEquals(1, Registries.CUSTOM_BIOMES.get().size()); + // Both the published catalogue and the retained event are frozen after startup + assertThrows(UnsupportedOperationException.class, () -> Registries.CUSTOM_BIOMES.get().put( + Identifier.of("test:late"), CustomBiomeDefinition.builder(Identifier.of("test:late_biome")).build())); + assertThrows(CustomBiomeDefinitionRegisterException.class, () -> captured[0].register( + Identifier.of("test:late"), CustomBiomeDefinition.builder(Identifier.of("test:late_biome")).build())); + }); + } +} diff --git a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java index af0f662a9e7..8acfbcf7c21 100644 --- a/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java +++ b/core/src/test/java/org/geysermc/geyser/scoreboard/network/util/GeyserMockContext.java @@ -69,8 +69,9 @@ public static void mockContext(Consumer geyserContext) { var eventBus = new GeyserEventBus(); when(geyserImpl.eventBus()).thenReturn(eventBus); - // GeyserEntityDataTypes static fields call Identifier.of(), which goes through GeyserApi.api().provider() - doAnswer(InvocationOnMock::callRealMethod).when(geyserImpl).provider(any(Class.class), any(), any()); + // API static factories (Identifier.of(), CustomBiomeDefinition.builder(), ...) go through + // GeyserApi.api().provider(), some from static initializers while a class loads + doAnswer(InvocationOnMock::callRealMethod).when(geyserImpl).provider(any(Class.class), any(Object[].class)); try (var geyserImplMock = mockStatic(GeyserImpl.class); var geyserMock = mockStatic(Geyser.class)) { diff --git a/core/src/test/java/org/geysermc/geyser/session/cache/CustomBiomeCacheTest.java b/core/src/test/java/org/geysermc/geyser/session/cache/CustomBiomeCacheTest.java new file mode 100644 index 00000000000..459263c8a85 --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/session/cache/CustomBiomeCacheTest.java @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2026 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.session.cache; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import org.cloudburstmc.nbt.NbtMap; +import org.cloudburstmc.protocol.bedrock.data.biome.BiomeDefinitionData; +import org.cloudburstmc.protocol.bedrock.data.biome.BiomeDefinitions; +import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket; +import org.cloudburstmc.protocol.bedrock.packet.BiomeDefinitionListPacket; +import org.geysermc.geyser.GeyserBootstrap; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.level.JavaBiome; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.scoreboard.network.util.GeyserMockContext; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.session.cache.registry.JavaRegistries; +import org.geysermc.geyser.session.cache.registry.RegistryEntryContext; +import org.geysermc.geyser.session.cache.registry.RegistryEntryData; +import org.geysermc.geyser.session.cache.registry.SimpleJavaRegistry; +import org.geysermc.geyser.translator.level.BiomeTranslator; +import org.geysermc.geyser.util.MinecraftKey; +import org.geysermc.mcprotocollib.protocol.data.game.RegistryEntry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CustomBiomeCacheTest { + private static final int UNKNOWN_BIOME = -1; + + @BeforeAll + public static void loadBiomeDefinitions() { + GeyserMockContext.mockContext(context -> { + GeyserBootstrap bootstrap = context.mock(GeyserBootstrap.class); + when(GeyserImpl.getInstance().getBootstrap()).thenReturn(bootstrap); + when(bootstrap.getResourceOrThrow(any())).thenAnswer(invocation -> CustomBiomeCacheTest.class + .getClassLoader().getResourceAsStream(invocation.getArgument(0))); + Registries.BIOMES.load(); + Registries.BIOME_IDENTIFIERS.load(); + }); + } + + @AfterEach + public void clearCatalogue() { + Registries.CUSTOM_BIOMES.set(new Object2ObjectOpenHashMap<>()); + } + + @Test + void readsClimateFromTheServerRegistry() { + // A modded biome has no vanilla Bedrock id; its climate comes from the registry NBT + RegistryEntry modded = new RegistryEntry(MinecraftKey.key("test:java_biome"), NbtMap.builder() + .putFloat("temperature", 1.9F) + .putFloat("downfall", 0.15F) + .putBoolean("has_precipitation", false) + .build()); + JavaBiome biome = BiomeTranslator.loadServerBiome(new RegistryEntryContext(modded, key -> -1, Optional.empty())); + assertEquals(UNKNOWN_BIOME, biome.bedrockId()); + assertEquals(1.9F, biome.temperature()); + assertEquals(0.15F, biome.downfall()); + assertFalse(biome.hasPrecipitation()); + + RegistryEntry vanilla = new RegistryEntry(MinecraftKey.key("minecraft:plains"), NbtMap.EMPTY); + assertNotEquals(UNKNOWN_BIOME, BiomeTranslator.loadServerBiome( + new RegistryEntryContext(vanilla, key -> -1, Optional.empty())).bedrockId()); + } + + @Test + void commitsPendingOnLogin() { + GeyserMockContext.mockContext(() -> { + catalogue(Map.of("test:java_biome", "test:bedrock_biome")); + TestSession session = new TestSession("minecraft:plains", "test:java_biome"); + + session.cache.reconcile(); + // Nothing is active until the login definitions are handed over for sending + assertEquals(UNKNOWN_BIOME, session.biomeValue("test:java_biome")); + + // The spawn packet precedes the commit in production + session.spawned.set(true); + BiomeDefinitions definitions = session.cache.loginDefinitions(); + BiomeDefinitionData data = definitions.getDefinitions().get("test:bedrock_biome"); + assertNotNull(data); + int id = session.biomeValue("test:java_biome"); + assertTrue(id >= 30000 && id <= 32767); + assertEquals(id, data.getId()); + // Climate comes from the Java biome the server sent; the test values match no + // vanilla definition, so a copied vanilla reference would fail here + assertEquals(1.9F, data.getTemperature()); + assertEquals(0.15F, data.getDownfall()); + assertFalse(data.isRain()); + assertTrue(session.packets.isEmpty()); + }); + } + + @Test + void committedLoginStateCarriesIntoTransfers() { + GeyserMockContext.mockContext(() -> { + catalogue(Map.of("test:java_biome", "test:bedrock_biome", "test:java_extra", "test:bedrock_extra")); + TestSession session = new TestSession("minecraft:plains", "test:java_biome"); + session.cache.reconcile(); + session.spawned.set(true); + int id = session.cache.loginDefinitions().getDefinitions().get("test:bedrock_biome").getId(); + assertTrue(session.packets.isEmpty()); + + // A transfer that adds a second custom biome resends: the committed definition + // keeps its id and the new one continues the sequence + session.reset(new JavaBiome(UNKNOWN_BIOME, 1.9F, 0.15F, false), "minecraft:plains", "test:java_biome", "test:java_extra"); + session.cache.reconcile(); + assertEquals(1, session.packets.size()); + assertEquals(id, session.biomeValue("test:java_biome")); + BiomeDefinitions resent = ((BiomeDefinitionListPacket) session.packets.getFirst()).getBiomes(); + assertEquals(id, resent.getDefinitions().get("test:bedrock_biome").getId()); + assertEquals(id + 1, resent.getDefinitions().get("test:bedrock_extra").getId()); + }); + } + + @Test + void newConfigurationPhaseDiscardsPending() { + GeyserMockContext.mockContext(() -> { + catalogue(Map.of("test:java_biome", "test:bedrock_biome")); + TestSession session = new TestSession("minecraft:plains", "test:java_biome"); + session.cache.reconcile(); + + // Starting a new configuration phase discards the candidate; a form can spawn + // the client before the phase ends, and the commit must not send the stale one + session.cache.discardPending(); + session.spawned.set(true); + assertNull(session.cache.loginDefinitions().getDefinitions().get("test:bedrock_biome")); + + // The new phase's registry applies with its own climate + session.reset(new JavaBiome(UNKNOWN_BIOME, 0.2F, 0.9F, true), "minecraft:plains", "test:java_biome"); + session.cache.reconcile(); + assertEquals(1, session.packets.size()); + assertTrue(session.biomeValue("test:java_biome") >= 30000); + BiomeDefinitions sent = ((BiomeDefinitionListPacket) session.packets.getFirst()).getBiomes(); + assertEquals(0.2F, sent.getDefinitions().get("test:bedrock_biome").getTemperature()); + }); + } + + @Test + void spawnedSessionResendsOnlyWhenDefinitionsAreAdded() { + GeyserMockContext.mockContext(() -> { + catalogue(Map.of("test:java_biome", "test:bedrock_biome", "test:java_extra", "test:bedrock_extra")); + TestSession session = new TestSession("minecraft:plains", "test:java_biome"); + session.spawned.set(true); + + session.cache.reconcile(); + assertEquals(1, session.packets.size()); + int id = session.biomeValue("test:java_biome"); + assertTrue(id >= 30000); + + // The same registry again: mapping is reinstalled, but no redundant packet is sent + session.reset("minecraft:plains", "test:java_biome"); + session.cache.reconcile(); + assertEquals(1, session.packets.size()); + assertEquals(id, session.biomeValue("test:java_biome")); + + // A backend with one more custom biome: the complete union is resent, and the + // definition sent earlier keeps its id + session.reset("minecraft:plains", "test:java_biome", "test:java_extra"); + session.cache.reconcile(); + assertEquals(2, session.packets.size()); + assertEquals(id, session.biomeValue("test:java_biome")); + assertTrue(session.biomeValue("test:java_extra") >= 30000); + BiomeDefinitions resent = ((BiomeDefinitionListPacket) session.packets.getLast()).getBiomes(); + assertEquals(id, resent.getDefinitions().get("test:bedrock_biome").getId()); + assertNotNull(resent.getDefinitions().get("test:bedrock_extra")); + }); + } + + @Test + void unusableClimateFallsBack() { + GeyserMockContext.mockContext(() -> { + catalogue(Map.of("test:java_biome", "test:bedrock_biome")); + TestSession session = new TestSession("minecraft:plains", "test:java_biome"); + session.spawned.set(true); + session.cache.reconcile(); + assertTrue(session.biomeValue("test:java_biome") >= 30000); + + // A later backend has the same biome with different climate; the sent definition + // must keep its meaning, so this backend's biome uses the vanilla fallback + session.reset(new JavaBiome(UNKNOWN_BIOME, 0.2F, 0.9F, true), "minecraft:plains", "test:java_biome"); + session.cache.reconcile(); + assertEquals(1, session.packets.size()); + assertEquals(UNKNOWN_BIOME, session.biomeValue("test:java_biome")); + + // Non-finite climate from a malformed backend is never sent + TestSession invalid = new TestSession(); + invalid.reset(new JavaBiome(UNKNOWN_BIOME, Float.NaN, 0.0F, false), "test:java_biome"); + invalid.spawned.set(true); + invalid.cache.reconcile(); + assertTrue(invalid.packets.isEmpty()); + assertEquals(UNKNOWN_BIOME, invalid.biomeValue("test:java_biome")); + }); + } + + @Test + void definitionsOverTheStringPoolCapFallBack() { + GeyserMockContext.mockContext(() -> { + Map vanilla = Registries.BIOMES.get().getDefinitions(); + Set vanillaPool = new HashSet<>(); + vanilla.forEach((name, data) -> { + vanillaPool.add(name); + if (data.getTags() != null) { + vanillaPool.addAll(data.getTags()); + } + }); + // Admission stops when the 1024-entry string pool is full; every definition + // pools its name, so this bounds the definition count too + int fits = 1024 - vanillaPool.size(); + + Map catalogue = new Object2ObjectOpenHashMap<>(); + String[] javaIdentifiers = new String[fits + 1]; + for (int i = 0; i < fits + 1; i++) { + javaIdentifiers[i] = "test:java_%04d".formatted(i); + catalogue.put(Identifier.of(javaIdentifiers[i]), definition("test:bedrock_%04d".formatted(i))); + } + Registries.CUSTOM_BIOMES.set(catalogue); + + TestSession session = new TestSession(javaIdentifiers); + session.spawned.set(true); + session.cache.reconcile(); + + int applied = 0; + for (String javaIdentifier : javaIdentifiers) { + if (session.biomeValue(javaIdentifier) != UNKNOWN_BIOME) { + applied++; + } + } + assertEquals(fits, applied); + + BiomeDefinitions sent = ((BiomeDefinitionListPacket) session.packets.getFirst()).getBiomes(); + assertEquals(vanilla.size() + fits, sent.getDefinitions().size()); + }); + } + + private static void catalogue(Map javaToBedrock) { + Map catalogue = new Object2ObjectOpenHashMap<>(); + javaToBedrock.forEach((javaIdentifier, bedrockIdentifier) -> + catalogue.put(Identifier.of(javaIdentifier), definition(bedrockIdentifier))); + Registries.CUSTOM_BIOMES.set(catalogue); + } + + private static CustomBiomeDefinition definition(String bedrockIdentifier) { + return CustomBiomeDefinition.builder(Identifier.of(bedrockIdentifier)).build(); + } + + /** + * A mocked session with a real biome registry, tracking sent packets and the spawn state + * the same way the login flow does. + */ + private static class TestSession { + private final GeyserSession session = mock(GeyserSession.class); + private final SimpleJavaRegistry registry = new SimpleJavaRegistry<>(); + private final List packets = new ArrayList<>(); + private final AtomicBoolean spawned = new AtomicBoolean(); + private final CustomBiomeCache cache; + + TestSession(String... javaIdentifiers) { + reset(javaIdentifiers); + RegistryCache registryCache = mock(RegistryCache.class); + when(session.getRegistryCache()).thenReturn(registryCache); + when(registryCache.registry(JavaRegistries.BIOME)).thenReturn(registry); + when(session.isSentSpawnPacket()).thenAnswer(invocation -> spawned.get()); + GeyserImpl geyser = GeyserImpl.getInstance(); + when(session.getGeyser()).thenReturn(geyser); + doAnswer(invocation -> { + packets.add(invocation.getArgument(0)); + return null; + }).when(session).sendUpstreamPacket(any()); + this.cache = new CustomBiomeCache(session); + } + + /** + * Loads a fresh registry epoch, like a configuration phase does. Every entry starts + * unknown, with climate no vanilla definition has, so tests can tell values derived + * from the Java registry apart from copied vanilla ones. + */ + void reset(String... javaIdentifiers) { + reset(new JavaBiome(UNKNOWN_BIOME, 1.9F, 0.15F, false), javaIdentifiers); + } + + void reset(JavaBiome data, String... javaIdentifiers) { + List> entries = new ArrayList<>(); + for (int i = 0; i < javaIdentifiers.length; i++) { + entries.add(new RegistryEntryData<>(i, MinecraftKey.key(javaIdentifiers[i]), data)); + } + registry.reset(entries); + } + + int biomeValue(String javaIdentifier) { + for (RegistryEntryData entry : registry.entries()) { + if (entry.key().asString().equals(javaIdentifier)) { + return entry.data().bedrockId(); + } + } + throw new IllegalArgumentException(javaIdentifier + " is not in the registry"); + } + } +} diff --git a/core/src/test/resources/configuration/custom-biomes-bad-pack.json b/core/src/test/resources/configuration/custom-biomes-bad-pack.json new file mode 100644 index 00000000000..b29374d4fe9 --- /dev/null +++ b/core/src/test/resources/configuration/custom-biomes-bad-pack.json @@ -0,0 +1,11 @@ +{ + "format_version": 1, + "mapping_options": { + "biomes": { + "pack_uuid": "not-a-uuid" + } + }, + "biomes": { + "example:orphan": {} + } +} diff --git a/core/src/test/resources/configuration/custom-biomes-packed.json b/core/src/test/resources/configuration/custom-biomes-packed.json new file mode 100644 index 00000000000..25326358d10 --- /dev/null +++ b/core/src/test/resources/configuration/custom-biomes-packed.json @@ -0,0 +1,22 @@ +{ + "format_version": 1, + "mapping_options": { + "biomes": { + "pack_uuid": "8caa1b2a-0b23-4d55-9b46-8a3c2d9f0e11" + } + }, + "biomes": { + "example:packed": { + "geyser": { + "bedrock_identifier": "example:packed_bedrock", + "tags": ["overworld"] + } + }, + "example:packed_derived": {}, + "example:packed_styled": { + "effects": { + "grass_color": "#5f9f45" + } + } + } +} diff --git a/core/src/test/resources/configuration/custom-biomes.json b/core/src/test/resources/configuration/custom-biomes.json new file mode 100644 index 00000000000..0922b2dad25 --- /dev/null +++ b/core/src/test/resources/configuration/custom-biomes.json @@ -0,0 +1,71 @@ +{ + "format_version": 1, + "biomes": { + "my_datapack:cave/crystal_caves": { + "attributes": { + "minecraft:visual/sky_color": "#78a7ff", + "minecraft:visual/fog_color": 12632256, + "minecraft:visual/water_fog_color": "#050533", + "minecraft:visual/water_fog_end_distance": { + "argument": 0.85, + "modifier": "multiply" + } + }, + "effects": { + "water_color": "#803f76e4", + "grass_color": "#5f9f45", + "foliage_color": "#4f8f3f", + "dry_foliage_color": "#9e814d" + }, + "geyser": { + "bedrock_identifier": "my_datapack:crystal_caves", + "tags": ["overworld", "monster"], + "water_surface_opacity": 0.55, + "water_fog_end_distance": 48.0, + "precipitation": { + "type": "blue_spores", + "density": 2.0 + } + } + }, + "example:minimal": { + "geyser": { + "bedrock_identifier": "example:minimal" + } + }, + "minecraft:swamp": { + "geyser": { + "bedrock_identifier": "example:swamp_recolor" + } + }, + "example:reused_identifier": { + "effects": { + "grass_color": "#5f9f45" + } + }, + "my_datapack:cave/derived_identifier": { + "attributes": { + "minecraft:visual/sky_color": "#123456" + }, + "geyser": { + "tags": ["overworld"] + } + }, + "example:bad_color": { + "effects": { + "grass_color": [0.1, 0.2, 0.3] + } + }, + "example:bare_identifier": { + "geyser": { + "bedrock_identifier": "bare_biome" + } + }, + "example:not_object": 42, + "example:vanilla_namespace": { + "geyser": { + "bedrock_identifier": "minecraft:oops" + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97fda7023a6..37bba5c580d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,11 +13,11 @@ guava = "29.0-jre" gson = "2.3.1" # Provided by Spigot 1.8.8 TODO bump to 2.8.1 or similar (Spigot 1.16.5 version) after Merge gson-runtime = "2.10.1" websocket = "1.5.1" -protocol-connection = "3.0.0.Beta13-20260828.182244-20" -protocol-common = "3.0.0.Beta13-20260828.182244-20" -protocol-codec = "3.0.0.Beta13-20260828.182244-20" +protocol-connection = "3.0.0.Beta13-20260916.122344-26" +protocol-common = "3.0.0.Beta13-20260916.122344-26" +protocol-codec = "3.0.0.Beta13-20260916.122344-26" # CloudburstMC/Network, "nethernet" branch; RakNet and NetherNet come from the same build -network = "2.0.0.CR3-20260915.150851-9" +network = "2.0.0.CR3-20260916.135337-10" # opencollab-incubator/libdatachannel-java, which builds opencollab-incubator/libdatachannel and libjuice into its natives libdatachannel = "0.24.5.0-SNAPSHOT" bouncycastle-pkix = "1.85"