From 7fe9563314a737787e1b84fe568f29007073f448 Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Sat, 15 Aug 2026 20:08:32 +0545 Subject: [PATCH 01/16] Custom Biome API --- .../biome/custom/CustomBiomeAppearance.java | 277 ++++++++++++++++++ .../biome/custom/CustomBiomeDefinition.java | 150 ++++++++++ ...ustomBiomeDefinitionRegisterException.java | 46 +++ .../custom/CustomBiomePrecipitation.java | 96 ++++++ .../geyser/api/biome/custom/package-info.java | 29 ++ .../GeyserDefineCustomBiomesEvent.java | 74 +++++ .../custom/GeyserCustomBiomeAppearance.java | 217 ++++++++++++++ .../custom/GeyserCustomBiomeDefinition.java | 164 +++++++++++ .../GeyserCustomBiomePrecipitation.java | 40 +++ .../loader/ProviderRegistryLoader.java | 11 + .../custom/CustomBiomeDefinitionTest.java | 93 ++++++ .../network/util/GeyserMockContext.java | 5 +- 12 files changed, 1200 insertions(+), 2 deletions(-) create mode 100644 api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeAppearance.java create mode 100644 api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinition.java create mode 100644 api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinitionRegisterException.java create mode 100644 api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomePrecipitation.java create mode 100644 api/src/main/java/org/geysermc/geyser/api/biome/custom/package-info.java create mode 100644 api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomBiomesEvent.java create mode 100644 core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeAppearance.java create mode 100644 core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java create mode 100644 core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomePrecipitation.java create mode 100644 core/src/test/java/org/geysermc/geyser/biome/custom/CustomBiomeDefinitionTest.java 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..5e43829df05 --- /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.1 + */ +@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.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @Nullable Color foliageColor(); + + /** + * The dry foliage tint, or null to use the client's default. + * + * @return the dry foliage color + * @since 2.11.1 + */ + @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.1 + */ + @Nullable CustomBiomePrecipitation precipitation(); + + /** + * Creates a builder for a custom biome appearance. + * + * @return a new appearance builder + * @since 2.11.1 + */ + static Builder builder() { + return GeyserApi.api().provider(Builder.class); + } + + /** + * The builder for a custom biome appearance. + * @since 2.11.1 + */ + interface Builder extends GenericBuilder { + + /** + * Sets the sky color. + * + * @param skyColor the sky color + * @see CustomBiomeAppearance#skyColor() + * @return this builder + * @since 2.11.1 + */ + @This + Builder skyColor(Color skyColor); + + /** + * Sets the fog color. + * + * @param fogColor the fog color + * @see CustomBiomeAppearance#fogColor() + * @return this builder + * @since 2.11.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @This + Builder waterFogEndDistance(float waterFogEndDistance); + + /** + * Sets the grass tint. + * + * @param grassColor the grass color + * @see CustomBiomeAppearance#grassColor() + * @return this builder + * @since 2.11.1 + */ + @This + Builder grassColor(Color grassColor); + + /** + * Sets the foliage tint. + * + * @param foliageColor the foliage color + * @see CustomBiomeAppearance#foliageColor() + * @return this builder + * @since 2.11.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @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..de7d05eacc6 --- /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.1 + */ +@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.1 + */ + 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.1 + */ + 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.1 + */ + @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.1 + */ + static Builder builder(Identifier bedrockIdentifier) { + return GeyserApi.api().provider(Builder.class, bedrockIdentifier); + } + + /** + * The builder for a custom biome definition. + * @since 2.11.1 + */ + interface Builder extends GenericBuilder { + + /** + * Adds a Bedrock biome tag. + * + * @param tag the tag to add + * @see CustomBiomeDefinition#tags() + * @return this builder + * @since 2.11.1 + */ + @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.1 + */ + @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.1 + */ + @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.1 + */ + @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..e2dbfac8500 --- /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.1 + */ +@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..ce9d0ff20df --- /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.1 + */ +@ApiStatus.NonExtendable +public interface CustomBiomePrecipitation { + + /** + * The particle type shown in this biome. + * + * @return the ambient particle type + * @since 2.11.1 + */ + 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.1 + */ + 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.1 + */ + static CustomBiomePrecipitation of(Type type, float density) { + return GeyserApi.api().provider(CustomBiomePrecipitation.class, type, density); + } + + /** + * The available precipitation particle types. + * + * @since 2.11.1 + */ + 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..c366cdde0a8 --- /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.1 + */ +@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.1 + */ + 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.1 + */ + void register(Identifier javaIdentifier, CustomBiomeDefinition definition); +} 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..02794e2b1be --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java @@ -0,0 +1,164 @@ +/* + * 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.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; + + 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: prefix: " + 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; + } + + /** + * 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; + } + + public static class Builder implements CustomBiomeDefinition.Builder { + private final Identifier bedrockIdentifier; + private final Set tags = new HashSet<>(); + private @Nullable CustomBiomeAppearance appearance; + 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; + } + + @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/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/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/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)) { From 4cdc53efc7f2a829be7f7195b074c53ac72e9b9e Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Sat, 15 Aug 2026 20:08:33 +0545 Subject: [PATCH 02/16] Register custom biomes from mappings files and extensions --- .../geyser/configuration/GeyserConfig.java | 2 +- .../geysermc/geyser/registry/Registries.java | 13 +- .../mappings/MappingsConfigReader.java | 1 + .../registry/mappings/MappingsType.java | 4 + .../registry/mappings/util/NodeReader.java | 30 +++- .../biome/BiomeMappingsReader_v1.java | 156 ++++++++++++++++++ .../CustomBiomeRegistryPopulator.java | 101 ++++++++++++ .../mappings/CustomBiomesLoaderTest.java | 127 ++++++++++++++ .../CustomBiomeRegistryPopulatorTest.java | 108 ++++++++++++ .../configuration/custom-biomes.json | 71 ++++++++ 10 files changed, 606 insertions(+), 7 deletions(-) create mode 100644 core/src/main/java/org/geysermc/geyser/registry/mappings/versions/biome/BiomeMappingsReader_v1.java create mode 100644 core/src/main/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulator.java create mode 100644 core/src/test/java/org/geysermc/geyser/registry/mappings/CustomBiomesLoaderTest.java create mode 100644 core/src/test/java/org/geysermc/geyser/registry/populator/CustomBiomeRegistryPopulatorTest.java create mode 100644 core/src/test/resources/configuration/custom-biomes.json 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 3ebf7b8831c..20710a68b2d 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java @@ -312,7 +312,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/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/mappings/MappingsConfigReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java index c26c35e45ca..2d40aed6185 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); 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..c0dd2acef58 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,7 +41,9 @@ 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.function.Predicate; @@ -139,23 +142,42 @@ public interface NodeReader { NodeReader SKULL_TEXTURE_TYPE = ofEnum(GeyserDefineCustomSkullsEvent.SkullTextureType.class); + // Biome readers + + NodeReader PRECIPITATION_TYPE = ofEnum(CustomBiomePrecipitation.Type.class); + + // 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..c641d1c495c --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/biome/BiomeMappingsReader_v1.java @@ -0,0 +1,156 @@ +/* + * 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.function.BiConsumer; +import java.util.function.Consumer; + +public class BiomeMappingsReader_v1 implements MappingsReader { + + @Override + public void read(Path file, JsonObject mappings, BiConsumer consumer) { + // 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(), "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!"); + } + }); + } + + /** + * 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. + */ + private CustomBiomeDefinition readDefinition(Identifier javaIdentifier, JsonObject object, 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)) { + builder.appearance(appearance); + } + 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/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/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..a8cf284a90d --- /dev/null +++ b/core/src/test/java/org/geysermc/geyser/registry/mappings/CustomBiomesLoaderTest.java @@ -0,0 +1,127 @@ +/* + * 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.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 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 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:derived")); + assertNotNull(derived); + assertEquals(Identifier.of("example:derived"), 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")); + 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/resources/configuration/custom-biomes.json b/core/src/test/resources/configuration/custom-biomes.json new file mode 100644 index 00000000000..ae9ab3f9484 --- /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:derived": { + "effects": { + "grass_color": "#5f9f45" + } + }, + "my_datapack:cave/derived": { + "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" + } + } + } +} From 34d9c844fd4965e0a61f1c4922d89f5c4f40ab3b Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Sat, 15 Aug 2026 20:08:33 +0545 Subject: [PATCH 03/16] Send custom biome definitions to Bedrock clients --- .../org/geysermc/geyser/level/JavaBiome.java | 41 +++ .../loader/BiomeIdentifierRegistryLoader.java | 7 +- .../geyser/session/GeyserSession.java | 5 +- .../session/cache/CustomBiomeCache.java | 298 +++++++++++++++ .../cache/registry/JavaRegistries.java | 3 +- .../session/cache/registry/JavaRegistry.java | 3 +- .../translator/level/BiomeTranslator.java | 24 +- .../JavaFinishConfigurationTranslator.java | 2 + .../JavaStartConfigurationTranslator.java | 3 + .../session/cache/CustomBiomeCacheTest.java | 343 ++++++++++++++++++ 10 files changed, 713 insertions(+), 16 deletions(-) create mode 100644 core/src/main/java/org/geysermc/geyser/level/JavaBiome.java create mode 100644 core/src/main/java/org/geysermc/geyser/session/cache/CustomBiomeCache.java create mode 100644 core/src/test/java/org/geysermc/geyser/session/cache/CustomBiomeCacheTest.java 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/registry/loader/BiomeIdentifierRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java index ce7c9f218d3..020597e2cc0 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 @@ -41,9 +41,10 @@ public class BiomeIdentifierRegistryLoader implements RegistryLoader 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/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java index cbed63f8f77..f7cc6d0db98 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -172,6 +172,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; @@ -301,6 +302,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; @@ -862,6 +864,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); @@ -953,7 +956,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 858cbadfc63..8a48fb7807d 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/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"); + } + } +} From b36f782f423d3550d575d8e944106bb888caabde Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Sat, 15 Aug 2026 20:08:33 +0545 Subject: [PATCH 04/16] Generate resource pack visuals for custom biomes --- .../pack/CustomBiomeResourcePackManager.java | 384 ++++++++++++++++++ .../registry/loader/ResourcePackLoader.java | 13 + .../atmosphere_settings.json | 90 ++++ .../CustomBiomeResourcePackManagerTest.java | 323 +++++++++++++++ 4 files changed, 810 insertions(+) create mode 100644 core/src/main/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManager.java create mode 100644 core/src/main/resources/bedrock/custom_biome_pack/atmosphere_settings.json create mode 100644 core/src/test/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManagerTest.java 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/loader/ResourcePackLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java index 6d29db9076c..3859ee54d51 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 @@ -35,7 +35,9 @@ 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.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; @@ -126,6 +128,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()); 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/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(); + } +} From 41ab468eddbf86079794dd15cce0cd8a0fedbe9b Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Thu, 20 Aug 2026 15:46:33 +0545 Subject: [PATCH 05/16] Support pack_uuid in biome mappings files --- .../custom/GeyserCustomBiomeDefinition.java | 17 +++++++++++ .../registry/loader/ResourcePackLoader.java | 20 +++++++++++++ .../mappings/MappingsConfigReader.java | 2 +- .../registry/mappings/MappingsReader.java | 8 +++++ .../registry/mappings/util/NodeReader.java | 9 ++++++ .../biome/BiomeMappingsReader_v1.java | 28 +++++++++++++++-- .../mappings/CustomBiomesLoaderTest.java | 30 +++++++++++++++++++ .../configuration/custom-biomes-bad-pack.json | 7 +++++ .../configuration/custom-biomes-packed.json | 18 +++++++++++ 9 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 core/src/test/resources/configuration/custom-biomes-bad-pack.json create mode 100644 core/src/test/resources/configuration/custom-biomes-packed.json 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 index 02794e2b1be..91c89a4a548 100644 --- a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java +++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java @@ -41,6 +41,7 @@ import java.util.Objects; import java.util.Set; import java.util.TreeSet; +import java.util.UUID; import java.util.regex.Pattern; @EqualsAndHashCode @@ -53,6 +54,7 @@ public final class GeyserCustomBiomeDefinition implements CustomBiomeDefinition 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(); @@ -87,6 +89,7 @@ public GeyserCustomBiomeDefinition(Builder builder) { // 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; } /** @@ -134,10 +137,19 @@ public Set tags() { 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) { @@ -156,6 +168,11 @@ public Builder appearance(CustomBiomeAppearance appearance) { 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/registry/loader/ResourcePackLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java index 3859ee54d51..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,14 @@ 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; @@ -58,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; @@ -152,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/MappingsConfigReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java index 2d40aed6185..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 @@ -109,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/util/NodeReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java index c0dd2acef58..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 @@ -46,6 +46,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.Predicate; @FunctionalInterface @@ -146,6 +147,14 @@ public interface NodeReader { 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()) { 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 index c641d1c495c..d5222562018 100644 --- 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 @@ -42,6 +42,7 @@ import java.nio.file.Path; import java.util.Map; +import java.util.UUID; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -49,12 +50,25 @@ public class BiomeMappingsReader_v1 implements MappingsReader consumer) { + read(file, new JsonObject(), mappings, consumer); + } + + @Override + public void read(Path file, JsonObject root, JsonObject mappings, BiConsumer consumer) { + UUID packUuid; + try { + // A file can name a provided resource pack that styles its biomes in place of the generated one + packUuid = MappingsUtil.readOrDefault(root, "pack_uuid", NodeReader.UUID, null, "custom biome mappings"); + } catch (InvalidCustomMappingsFileException exception) { + GeyserImpl.getInstance().getLogger().error("Error reading pack_uuid 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(), "biome " + javaIdentifier)); + 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); } @@ -68,9 +82,11 @@ public void read(Path file, JsonObject mappings, BiConsumer 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"); 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..d2c9ca12042 --- /dev/null +++ b/core/src/test/resources/configuration/custom-biomes-bad-pack.json @@ -0,0 +1,7 @@ +{ + "format_version": 1, + "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..d3916c05dbc --- /dev/null +++ b/core/src/test/resources/configuration/custom-biomes-packed.json @@ -0,0 +1,18 @@ +{ + "format_version": 1, + "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" + } + } + } +} From d01e849a3398358eb70415f4a50701659441f1d2 Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Wed, 26 Aug 2026 15:12:09 +0545 Subject: [PATCH 06/16] Oops --- .../geyser/registry/loader/BiomeIdentifierRegistryLoader.java | 3 +++ 1 file changed, 3 insertions(+) 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 020597e2cc0..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 @@ -41,6 +41,9 @@ public class BiomeIdentifierRegistryLoader implements RegistryLoader 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 From 84e648cb007bd5a5078df7a8304a8bbab7093495 Mon Sep 17 00:00:00 2001 From: Metehan Gezer Date: Wed, 16 Sep 2026 02:08:41 +0300 Subject: [PATCH 07/16] Add system properties: `-DgeyserRaknetPort`, `-DgeyserSignalingPort` (#6705) --- .../java/org/geysermc/geyser/GeyserImpl.java | 75 +++++++++++-------- .../geyser/configuration/GeyserConfig.java | 33 ++++++-- .../bedrock/nethernet/NetherNetServer.java | 45 ++++++----- 3 files changed, 94 insertions(+), 59 deletions(-) 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/configuration/GeyserConfig.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java index 5b034ad2501..c8f20898af4 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() { 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); } } From ab8a67ef18bb01bf510eb7bab1e983bc694b6385 Mon Sep 17 00:00:00 2001 From: Metehan Gezer Date: Wed, 16 Sep 2026 02:46:24 +0300 Subject: [PATCH 08/16] Update supported versions (#6708) --- README.md | 8 ++++---- .../org/geysermc/geyser/network/bedrock/GameProtocol.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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..30485e2ac79 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 @@ -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_v2192.CODEC.toBuilder().protocolVersion(2193).build(), "26.50", "26.51"); MinecraftVersion latestBedrock = SUPPORTED_BEDROCK_VERSIONS.getLast(); DEFAULT_BEDROCK_VERSION = latestBedrock.versionString(); From d12a0efb63700a5961cef1a0ad024aeae25c46a0 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 16 Sep 2026 02:50:09 +0200 Subject: [PATCH 09/16] Bump Bedrock protocol library to resolve furnace recipe packet issues --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97fda7023a6..f9e0f391614 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,9 +13,9 @@ 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-20260915.235937-24" +protocol-common = "3.0.0.Beta13-20260915.235937-24" +protocol-codec = "3.0.0.Beta13-20260915.235937-24" # CloudburstMC/Network, "nethernet" branch; RakNet and NetherNet come from the same build network = "2.0.0.CR3-20260915.150851-9" # opencollab-incubator/libdatachannel-java, which builds opencollab-incubator/libdatachannel and libjuice into its natives From be6749dfbb233aaf3a428afcfc5b5905a38169a4 Mon Sep 17 00:00:00 2001 From: HPnetwork <136156956+HP-network@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:30:56 -0400 Subject: [PATCH 10/16] Apply ambient occlusion to mushroom blocks (#6697) --- .../geyser/registry/mappings/BuiltInMappings.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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() ); } } From 4c345fbeb7d415ba4c43a77d8ffcfa7adc260fbf Mon Sep 17 00:00:00 2001 From: Auri Date: Wed, 16 Sep 2026 13:59:37 +0100 Subject: [PATCH 11/16] Update Cloudburst protocol (#6711) * Update Cloudburst protocol * Fix codec processor * Fix registry populators --- .../geysermc/geyser/network/bedrock/CodecProcessor.java | 8 ++++---- .../org/geysermc/geyser/network/bedrock/GameProtocol.java | 6 +++--- .../geyser/registry/populator/BlockRegistryPopulator.java | 4 ++-- .../geyser/registry/populator/ItemRegistryPopulator.java | 4 ++-- .../geyser/registry/populator/TagRegistryPopulator.java | 4 ++-- .../java/org/geysermc/geyser/session/GeyserSession.java | 2 +- gradle/libs.versions.toml | 6 +++--- 7 files changed, 17 insertions(+), 17 deletions(-) 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 30485e2ac79..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", "26.51"); + 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/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/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..5e15dd589f9 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -926,7 +926,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); } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9e0f391614..4eebdc116d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,9 +13,9 @@ 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-20260915.235937-24" -protocol-common = "3.0.0.Beta13-20260915.235937-24" -protocol-codec = "3.0.0.Beta13-20260915.235937-24" +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" # opencollab-incubator/libdatachannel-java, which builds opencollab-incubator/libdatachannel and libjuice into its natives From 53ca3bcf6845f1dc21653bbf81f72da1114564d3 Mon Sep 17 00:00:00 2001 From: Metehan Gezer Date: Wed, 16 Sep 2026 17:27:13 +0300 Subject: [PATCH 12/16] Bump network version (#6713) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4eebdc116d0..37bba5c580d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ 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" From 8be9ddeeb5511b84dac80c627102c74c90961060 Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Thu, 17 Sep 2026 00:11:22 +0545 Subject: [PATCH 13/16] Reword the reserved tag namespace message --- .../geyser/biome/custom/GeyserCustomBiomeDefinition.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 91c89a4a548..ef4c05d7805 100644 --- a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java +++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java @@ -81,7 +81,7 @@ public GeyserCustomBiomeDefinition(Builder builder) { } if (tag.startsWith("minecraft:")) { throw new IllegalArgumentException( - "Biome tags cannot use the minecraft: prefix: " + tag); + "Biome tags cannot use the minecraft namespace: " + tag); } } From 5d3900585716360a76563ede4e2951b8b112d234 Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Thu, 17 Sep 2026 00:11:22 +0545 Subject: [PATCH 14/16] Move pack_uuid under mapping_options.biomes --- .../biome/BiomeMappingsReader_v1.java | 22 ++++++++++++++----- .../configuration/custom-biomes-bad-pack.json | 6 ++++- .../configuration/custom-biomes-packed.json | 6 ++++- 3 files changed, 27 insertions(+), 7 deletions(-) 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 index d5222562018..eb73ffa5ade 100644 --- 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 @@ -57,10 +57,9 @@ public void read(Path file, JsonObject mappings, BiConsumer consumer) { UUID packUuid; try { - // A file can name a provided resource pack that styles its biomes in place of the generated one - packUuid = MappingsUtil.readOrDefault(root, "pack_uuid", NodeReader.UUID, null, "custom biome mappings"); + packUuid = readPackUuid(root); } catch (InvalidCustomMappingsFileException exception) { - GeyserImpl.getInstance().getLogger().error("Error reading pack_uuid in custom mappings file: " + file, 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 @@ -78,13 +77,26 @@ public void read(Path file, JsonObject root, JsonObject mappings, BiConsumer Date: Thu, 17 Sep 2026 00:11:22 +0545 Subject: [PATCH 15/16] Name the identifier fixtures after what they cover --- .../geyser/registry/mappings/CustomBiomesLoaderTest.java | 6 +++--- core/src/test/resources/configuration/custom-biomes.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 index b7bb19ba5c1..54945fd6522 100644 --- a/core/src/test/java/org/geysermc/geyser/registry/mappings/CustomBiomesLoaderTest.java +++ b/core/src/test/java/org/geysermc/geyser/registry/mappings/CustomBiomesLoaderTest.java @@ -107,13 +107,13 @@ private void assertMappings(Map biomes) { 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:derived")); + CustomBiomeDefinition derived = biomes.get(Identifier.of("example:reused_identifier")); assertNotNull(derived); - assertEquals(Identifier.of("example:derived"), derived.bedrockIdentifier()); + 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")); + CustomBiomeDefinition digest = biomes.get(Identifier.of("my_datapack:cave/derived_identifier")); assertNotNull(digest); assertEquals("geyser", digest.bedrockIdentifier().namespace()); assertTrue(digest.bedrockIdentifier().path().startsWith("auto_")); diff --git a/core/src/test/resources/configuration/custom-biomes.json b/core/src/test/resources/configuration/custom-biomes.json index ae9ab3f9484..0922b2dad25 100644 --- a/core/src/test/resources/configuration/custom-biomes.json +++ b/core/src/test/resources/configuration/custom-biomes.json @@ -38,12 +38,12 @@ "bedrock_identifier": "example:swamp_recolor" } }, - "example:derived": { + "example:reused_identifier": { "effects": { "grass_color": "#5f9f45" } }, - "my_datapack:cave/derived": { + "my_datapack:cave/derived_identifier": { "attributes": { "minecraft:visual/sky_color": "#123456" }, From c609c0e517954fbb58579c24c81564724a3be3bb Mon Sep 17 00:00:00 2001 From: darkcupid412 Date: Thu, 17 Sep 2026 00:13:31 +0545 Subject: [PATCH 16/16] Bump the API version to 2.11.3 --- .../biome/custom/CustomBiomeAppearance.java | 48 +++++++++---------- .../biome/custom/CustomBiomeDefinition.java | 20 ++++---- ...ustomBiomeDefinitionRegisterException.java | 2 +- .../custom/CustomBiomePrecipitation.java | 10 ++-- .../GeyserDefineCustomBiomesEvent.java | 6 +-- 5 files changed, 43 insertions(+), 43 deletions(-) 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 index 5e43829df05..532ba0d12a1 100644 --- 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 @@ -41,7 +41,7 @@ * is set, the other is completed from the vanilla Bedrock default fog ({@code #44AFF5}, * fully opaque at 60 blocks).

* - * @since 2.11.1 + * @since 2.11.3 */ @ApiStatus.NonExtendable public interface CustomBiomeAppearance { @@ -51,7 +51,7 @@ public interface CustomBiomeAppearance { * uses it as the daytime zenith tint; the horizon and the vanilla night colors remain. * * @return the sky color - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Color skyColor(); @@ -61,7 +61,7 @@ public interface CustomBiomeAppearance { * does not apply this value as of Bedrock 1.26.44. * * @return the fog color - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Color fogColor(); @@ -71,7 +71,7 @@ public interface CustomBiomeAppearance { * exact rendered color can differ. * * @return the water surface color - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Color waterSurfaceColor(); @@ -81,7 +81,7 @@ public interface CustomBiomeAppearance { * this value as of Bedrock 1.26.44. * * @return the water surface opacity - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Float waterSurfaceOpacity(); @@ -91,7 +91,7 @@ public interface CustomBiomeAppearance { * nearby submerged surfaces still pick up the tint. * * @return the underwater fog color - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Color waterFogColor(); @@ -100,7 +100,7 @@ public interface CustomBiomeAppearance { * not set. * * @return the underwater fog end distance - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Float waterFogEndDistance(); @@ -108,7 +108,7 @@ public interface CustomBiomeAppearance { * The grass tint, or null to let the client derive one from the biome's climate. * * @return the grass color - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Color grassColor(); @@ -116,7 +116,7 @@ public interface CustomBiomeAppearance { * The foliage tint, or null to let the client derive one from the biome's climate. * * @return the foliage color - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Color foliageColor(); @@ -124,7 +124,7 @@ public interface CustomBiomeAppearance { * The dry foliage tint, or null to use the client's default. * * @return the dry foliage color - * @since 2.11.1 + * @since 2.11.3 */ @Nullable Color dryFoliageColor(); @@ -132,7 +132,7 @@ public interface CustomBiomeAppearance { * 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.1 + * @since 2.11.3 */ @Nullable CustomBiomePrecipitation precipitation(); @@ -140,7 +140,7 @@ public interface CustomBiomeAppearance { * Creates a builder for a custom biome appearance. * * @return a new appearance builder - * @since 2.11.1 + * @since 2.11.3 */ static Builder builder() { return GeyserApi.api().provider(Builder.class); @@ -148,7 +148,7 @@ static Builder builder() { /** * The builder for a custom biome appearance. - * @since 2.11.1 + * @since 2.11.3 */ interface Builder extends GenericBuilder { @@ -158,7 +158,7 @@ interface Builder extends GenericBuilder { * @param skyColor the sky color * @see CustomBiomeAppearance#skyColor() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder skyColor(Color skyColor); @@ -169,7 +169,7 @@ interface Builder extends GenericBuilder { * @param fogColor the fog color * @see CustomBiomeAppearance#fogColor() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder fogColor(Color fogColor); @@ -180,7 +180,7 @@ interface Builder extends GenericBuilder { * @param waterSurfaceColor the water surface color * @see CustomBiomeAppearance#waterSurfaceColor() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder waterSurfaceColor(Color waterSurfaceColor); @@ -192,7 +192,7 @@ interface Builder extends GenericBuilder { * @param waterSurfaceOpacity the water surface opacity * @see CustomBiomeAppearance#waterSurfaceOpacity() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder waterSurfaceOpacity(float waterSurfaceOpacity); @@ -203,7 +203,7 @@ interface Builder extends GenericBuilder { * @param waterFogColor the underwater fog color * @see CustomBiomeAppearance#waterFogColor() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder waterFogColor(Color waterFogColor); @@ -214,7 +214,7 @@ interface Builder extends GenericBuilder { * @param waterFogEndDistance the underwater fog end distance * @see CustomBiomeAppearance#waterFogEndDistance() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder waterFogEndDistance(float waterFogEndDistance); @@ -225,7 +225,7 @@ interface Builder extends GenericBuilder { * @param grassColor the grass color * @see CustomBiomeAppearance#grassColor() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder grassColor(Color grassColor); @@ -236,7 +236,7 @@ interface Builder extends GenericBuilder { * @param foliageColor the foliage color * @see CustomBiomeAppearance#foliageColor() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder foliageColor(Color foliageColor); @@ -247,7 +247,7 @@ interface Builder extends GenericBuilder { * @param dryFoliageColor the dry foliage color * @see CustomBiomeAppearance#dryFoliageColor() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder dryFoliageColor(Color dryFoliageColor); @@ -258,7 +258,7 @@ interface Builder extends GenericBuilder { * @param precipitation the ambient ash or spore particles * @see CustomBiomeAppearance#precipitation() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder precipitation(CustomBiomePrecipitation precipitation); @@ -269,7 +269,7 @@ interface Builder extends GenericBuilder { * @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.1 + * @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 index de7d05eacc6..f6854f45db6 100644 --- 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 @@ -46,7 +46,7 @@ * set in the optional {@link CustomBiomeAppearance}, which Geyser delivers to clients in * a generated resource pack.

* - * @since 2.11.1 + * @since 2.11.3 */ @ApiStatus.NonExtendable public interface CustomBiomeDefinition { @@ -58,7 +58,7 @@ public interface CustomBiomeDefinition { * {@code geyser:auto_} prefix are reserved. * * @return the Bedrock biome identifier - * @since 2.11.1 + * @since 2.11.3 */ Identifier bedrockIdentifier(); @@ -70,7 +70,7 @@ public interface CustomBiomeDefinition { * cannot start with {@code minecraft:}. * * @return an immutable set of the biome's Bedrock tags - * @since 2.11.1 + * @since 2.11.3 */ Set tags(); @@ -80,7 +80,7 @@ public interface CustomBiomeDefinition { * style the biome. * * @return the biome's appearance - * @since 2.11.1 + * @since 2.11.3 */ @Nullable CustomBiomeAppearance appearance(); @@ -89,7 +89,7 @@ public interface CustomBiomeDefinition { * * @param bedrockIdentifier the Bedrock identifier of the biome * @return a new definition builder - * @since 2.11.1 + * @since 2.11.3 */ static Builder builder(Identifier bedrockIdentifier) { return GeyserApi.api().provider(Builder.class, bedrockIdentifier); @@ -97,7 +97,7 @@ static Builder builder(Identifier bedrockIdentifier) { /** * The builder for a custom biome definition. - * @since 2.11.1 + * @since 2.11.3 */ interface Builder extends GenericBuilder { @@ -107,7 +107,7 @@ interface Builder extends GenericBuilder { * @param tag the tag to add * @see CustomBiomeDefinition#tags() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder tag(String tag); @@ -118,7 +118,7 @@ interface Builder extends GenericBuilder { * @param appearance the biome appearance * @see CustomBiomeDefinition#appearance() * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This Builder appearance(CustomBiomeAppearance appearance); @@ -129,7 +129,7 @@ interface Builder extends GenericBuilder { * @param appearance the builder of the biome appearance * @see CustomBiomeDefinition.Builder#appearance(CustomBiomeAppearance) * @return this builder - * @since 2.11.1 + * @since 2.11.3 */ @This default Builder appearance(CustomBiomeAppearance.Builder appearance) { @@ -142,7 +142,7 @@ default Builder appearance(CustomBiomeAppearance.Builder appearance) { * @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.1 + * @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 index e2dbfac8500..f04e4598d63 100644 --- 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 @@ -31,7 +31,7 @@ /** * 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.1 + * @since 2.11.3 */ @ApiStatus.NonExtendable public class CustomBiomeDefinitionRegisterException extends RuntimeException { 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 index ce9d0ff20df..7da306a3aea 100644 --- 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 @@ -34,7 +34,7 @@ * 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.1 + * @since 2.11.3 */ @ApiStatus.NonExtendable public interface CustomBiomePrecipitation { @@ -43,7 +43,7 @@ public interface CustomBiomePrecipitation { * The particle type shown in this biome. * * @return the ambient particle type - * @since 2.11.1 + * @since 2.11.3 */ Type type(); @@ -52,7 +52,7 @@ public interface CustomBiomePrecipitation { * deltas use a white ash density of {@code 2.0}. * * @return the particle density - * @since 2.11.1 + * @since 2.11.3 */ float density(); @@ -64,7 +64,7 @@ public interface CustomBiomePrecipitation { * @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.1 + * @since 2.11.3 */ static CustomBiomePrecipitation of(Type type, float density) { return GeyserApi.api().provider(CustomBiomePrecipitation.class, type, density); @@ -73,7 +73,7 @@ static CustomBiomePrecipitation of(Type type, float density) { /** * The available precipitation particle types. * - * @since 2.11.1 + * @since 2.11.3 */ enum Type { /** 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 index c366cdde0a8..994f5b58c6f 100644 --- 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 @@ -45,7 +45,7 @@ *

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

* - * @since 2.11.1 + * @since 2.11.3 */ @ApiStatus.NonExtendable public interface GeyserDefineCustomBiomesEvent extends Event { @@ -55,7 +55,7 @@ public interface GeyserDefineCustomBiomesEvent extends Event { * identifier of the Java biome they were registered for. * * @return an unmodifiable map of the registered definitions - * @since 2.11.1 + * @since 2.11.3 */ Map customBiomeDefinitions(); @@ -68,7 +68,7 @@ public interface GeyserDefineCustomBiomesEvent extends Event { * @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.1 + * @since 2.11.3 */ void register(Identifier javaIdentifier, CustomBiomeDefinition definition); }