diff --git a/README.md b/README.md
index ff1598e8d80..75a50591c3f 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,10 @@ Special thanks to the DragonProxy project for being a trailblazer in protocol tr
## Supported Versions
-| Edition | Supported Versions |
-|---------|----------------------------------------------------------------------------------------------------------------------------------------|
-| Bedrock | 26.0, 26.1, 26.2, 26.3, 26.10, 26.20, 26.21, 26.22, 26.23, 26.30, 26.31, 26.32, 26.33, 26.34, 26.40, 26.41, 26.42, 26.43, 26.44, 26.45 |
-| Java | 26.2 (For older versions, [see this guide](https://geysermc.org/wiki/geyser/supported-versions/)) |
+| Edition | Supported Versions |
+|---------|---------------------------------------------------------------------------------------------------|
+| Bedrock | 26.30, 26.31, 26.32, 26.33, 26.34, 26.40, 26.41, 26.42, 26.43, 26.44, 26.45, 26.50, 26.51 |
+| Java | 26.2 (For older versions, [see this guide](https://geysermc.org/wiki/geyser/supported-versions/)) |
## Setting Up
Take a look [here](https://geysermc.org/wiki/geyser/setup/) for how to set up Geyser.
diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeAppearance.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeAppearance.java
new file mode 100644
index 00000000000..532ba0d12a1
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeAppearance.java
@@ -0,0 +1,277 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.api.biome.custom;
+
+import org.checkerframework.common.returnsreceiver.qual.This;
+import org.geysermc.geyser.api.GeyserApi;
+import org.geysermc.geyser.api.util.GenericBuilder;
+import org.jetbrains.annotations.ApiStatus;
+import org.jspecify.annotations.Nullable;
+
+import java.awt.Color;
+
+/**
+ * The visual appearance of a custom biome, delivered to Bedrock clients through a
+ * generated resource pack. All values are optional, and at least one value must be set.
+ *
+ *
The alpha component of colors is ignored. When only one of the two water fog values
+ * is set, the other is completed from the vanilla Bedrock default fog ({@code #44AFF5},
+ * fully opaque at 60 blocks).
+ *
+ * @since 2.11.3
+ */
+@ApiStatus.NonExtendable
+public interface CustomBiomeAppearance {
+
+ /**
+ * The sky color, or null to use the client's default. The Vibrant Visuals renderer
+ * uses it as the daytime zenith tint; the horizon and the vanilla night colors remain.
+ *
+ * @return the sky color
+ * @since 2.11.3
+ */
+ @Nullable Color skyColor();
+
+ /**
+ * The color of the distance fog in air, or null to use the client's default. Rain
+ * shows it darkened, matching Java's full-rain fog. The Vibrant Visuals renderer
+ * does not apply this value as of Bedrock 1.26.44.
+ *
+ * @return the fog color
+ * @since 2.11.3
+ */
+ @Nullable Color fogColor();
+
+ /**
+ * The color of the water surface, or null to use the client's default. The Vibrant
+ * Visuals renderer mixes it into the water rather than applying it directly, so the
+ * exact rendered color can differ.
+ *
+ * @return the water surface color
+ * @since 2.11.3
+ */
+ @Nullable Color waterSurfaceColor();
+
+ /**
+ * The opacity of the water surface, between {@code 0.0} and {@code 1.0} inclusive, or
+ * null to use the client's default. The Vibrant Visuals renderer does not apply
+ * this value as of Bedrock 1.26.44.
+ *
+ * @return the water surface opacity
+ * @since 2.11.3
+ */
+ @Nullable Float waterSurfaceOpacity();
+
+ /**
+ * The color of the fog seen underwater, or null when not set. The Vibrant Visuals
+ * renderer does not apply it to the underwater haze as of Bedrock 1.26.44, though
+ * nearby submerged surfaces still pick up the tint.
+ *
+ * @return the underwater fog color
+ * @since 2.11.3
+ */
+ @Nullable Color waterFogColor();
+
+ /**
+ * The distance, in blocks, at which the underwater fog is fully opaque, or null when
+ * not set.
+ *
+ * @return the underwater fog end distance
+ * @since 2.11.3
+ */
+ @Nullable Float waterFogEndDistance();
+
+ /**
+ * The grass tint, or null to let the client derive one from the biome's climate.
+ *
+ * @return the grass color
+ * @since 2.11.3
+ */
+ @Nullable Color grassColor();
+
+ /**
+ * The foliage tint, or null to let the client derive one from the biome's climate.
+ *
+ * @return the foliage color
+ * @since 2.11.3
+ */
+ @Nullable Color foliageColor();
+
+ /**
+ * The dry foliage tint, or null to use the client's default.
+ *
+ * @return the dry foliage color
+ * @since 2.11.3
+ */
+ @Nullable Color dryFoliageColor();
+
+ /**
+ * The ambient ash or spore particles shown in this biome, or null for none.
+ *
+ * @return the biome's ambient ash or spore particles
+ * @since 2.11.3
+ */
+ @Nullable CustomBiomePrecipitation precipitation();
+
+ /**
+ * Creates a builder for a custom biome appearance.
+ *
+ * @return a new appearance builder
+ * @since 2.11.3
+ */
+ static Builder builder() {
+ return GeyserApi.api().provider(Builder.class);
+ }
+
+ /**
+ * The builder for a custom biome appearance.
+ * @since 2.11.3
+ */
+ interface Builder extends GenericBuilder {
+
+ /**
+ * Sets the sky color.
+ *
+ * @param skyColor the sky color
+ * @see CustomBiomeAppearance#skyColor()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder skyColor(Color skyColor);
+
+ /**
+ * Sets the fog color.
+ *
+ * @param fogColor the fog color
+ * @see CustomBiomeAppearance#fogColor()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder fogColor(Color fogColor);
+
+ /**
+ * Sets the color of the water surface.
+ *
+ * @param waterSurfaceColor the water surface color
+ * @see CustomBiomeAppearance#waterSurfaceColor()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder waterSurfaceColor(Color waterSurfaceColor);
+
+ /**
+ * Sets the opacity of the water surface, between {@code 0.0} and {@code 1.0}
+ * inclusive.
+ *
+ * @param waterSurfaceOpacity the water surface opacity
+ * @see CustomBiomeAppearance#waterSurfaceOpacity()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder waterSurfaceOpacity(float waterSurfaceOpacity);
+
+ /**
+ * Sets the color of the fog seen underwater.
+ *
+ * @param waterFogColor the underwater fog color
+ * @see CustomBiomeAppearance#waterFogColor()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder waterFogColor(Color waterFogColor);
+
+ /**
+ * Sets the distance, in blocks, at which the underwater fog is fully opaque.
+ *
+ * @param waterFogEndDistance the underwater fog end distance
+ * @see CustomBiomeAppearance#waterFogEndDistance()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder waterFogEndDistance(float waterFogEndDistance);
+
+ /**
+ * Sets the grass tint.
+ *
+ * @param grassColor the grass color
+ * @see CustomBiomeAppearance#grassColor()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder grassColor(Color grassColor);
+
+ /**
+ * Sets the foliage tint.
+ *
+ * @param foliageColor the foliage color
+ * @see CustomBiomeAppearance#foliageColor()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder foliageColor(Color foliageColor);
+
+ /**
+ * Sets the dry foliage tint.
+ *
+ * @param dryFoliageColor the dry foliage color
+ * @see CustomBiomeAppearance#dryFoliageColor()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder dryFoliageColor(Color dryFoliageColor);
+
+ /**
+ * Sets the ambient ash or spore particles shown in this biome.
+ *
+ * @param precipitation the ambient ash or spore particles
+ * @see CustomBiomeAppearance#precipitation()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder precipitation(CustomBiomePrecipitation precipitation);
+
+ /**
+ * Creates the custom biome appearance.
+ *
+ * @return the created appearance
+ * @throws IllegalArgumentException when no value was set, when a numeric value is
+ * out of range, or when the precipitation was not created through {@link CustomBiomePrecipitation#of}
+ * @since 2.11.3
+ */
+ @Override
+ CustomBiomeAppearance build();
+ }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinition.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinition.java
new file mode 100644
index 00000000000..f6854f45db6
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinition.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.api.biome.custom;
+
+import org.checkerframework.common.returnsreceiver.qual.This;
+import org.geysermc.geyser.api.GeyserApi;
+import org.geysermc.geyser.api.util.GenericBuilder;
+import org.geysermc.geyser.api.util.Identifier;
+import org.jetbrains.annotations.ApiStatus;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Set;
+
+/**
+ * Defines a custom Bedrock biome. Geyser registers the definition with the Bedrock client,
+ * and uses it when translating chunks that contain the Java biome it was registered for.
+ * Java biomes that have neither a definition nor a vanilla equivalent are translated to a
+ * vanilla biome fitting the dimension instead.
+ *
+ * Base climate values are taken from the Java biome the definition is registered for,
+ * so rain and snow follow the server's climate. Java behavior that varies with the
+ * position inside one biome, such as mountain snow lines, is approximated. Visuals can be
+ * set in the optional {@link CustomBiomeAppearance}, which Geyser delivers to clients in
+ * a generated resource pack.
+ *
+ * @since 2.11.3
+ */
+@ApiStatus.NonExtendable
+public interface CustomBiomeDefinition {
+
+ /**
+ * The Bedrock identifier of this biome. Namespace and path may only contain lowercase
+ * letters, digits, {@code .}, {@code _} and {@code -}; unlike Java identifiers, the
+ * path cannot contain {@code /}. The {@code minecraft} namespace and the
+ * {@code geyser:auto_} prefix are reserved.
+ *
+ * @return the Bedrock biome identifier
+ * @since 2.11.3
+ */
+ Identifier bedrockIdentifier();
+
+ /**
+ * The Bedrock biome tags of this biome, passed through to the Bedrock definition.
+ * Data-driven content, such as spawn rules, matches them with the
+ * {@code has_biome_tag} filter. A tag may only contain lowercase letters, digits,
+ * {@code .} and {@code _}, with at most one {@code :} separating a namespace, and
+ * cannot start with {@code minecraft:}.
+ *
+ * @return an immutable set of the biome's Bedrock tags
+ * @since 2.11.3
+ */
+ Set tags();
+
+ /**
+ * The visual appearance of this biome, or null when Geyser should not generate
+ * appearance assets for it. A resource pack supplied by the server owner can still
+ * style the biome.
+ *
+ * @return the biome's appearance
+ * @since 2.11.3
+ */
+ @Nullable CustomBiomeAppearance appearance();
+
+ /**
+ * Creates a builder for a custom biome definition.
+ *
+ * @param bedrockIdentifier the Bedrock identifier of the biome
+ * @return a new definition builder
+ * @since 2.11.3
+ */
+ static Builder builder(Identifier bedrockIdentifier) {
+ return GeyserApi.api().provider(Builder.class, bedrockIdentifier);
+ }
+
+ /**
+ * The builder for a custom biome definition.
+ * @since 2.11.3
+ */
+ interface Builder extends GenericBuilder {
+
+ /**
+ * Adds a Bedrock biome tag.
+ *
+ * @param tag the tag to add
+ * @see CustomBiomeDefinition#tags()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder tag(String tag);
+
+ /**
+ * Sets the biome's visual appearance.
+ *
+ * @param appearance the biome appearance
+ * @see CustomBiomeDefinition#appearance()
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ Builder appearance(CustomBiomeAppearance appearance);
+
+ /**
+ * Convenience method for {@link CustomBiomeDefinition.Builder#appearance(CustomBiomeAppearance)}.
+ *
+ * @param appearance the builder of the biome appearance
+ * @see CustomBiomeDefinition.Builder#appearance(CustomBiomeAppearance)
+ * @return this builder
+ * @since 2.11.3
+ */
+ @This
+ default Builder appearance(CustomBiomeAppearance.Builder appearance) {
+ return appearance(appearance.build());
+ }
+
+ /**
+ * Creates the custom biome definition.
+ *
+ * @return the created definition
+ * @throws IllegalArgumentException when the identifier or a tag is invalid, or
+ * when the appearance was not created through {@link CustomBiomeAppearance#builder()}
+ * @since 2.11.3
+ */
+ @Override
+ CustomBiomeDefinition build();
+ }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinitionRegisterException.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinitionRegisterException.java
new file mode 100644
index 00000000000..f04e4598d63
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomeDefinitionRegisterException.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.api.biome.custom;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import java.io.Serial;
+
+/**
+ * Thrown when there was an error registering the custom biome definition. The exception message will have details as to what went wrong.
+ * @since 2.11.3
+ */
+@ApiStatus.NonExtendable
+public class CustomBiomeDefinitionRegisterException extends RuntimeException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @ApiStatus.Internal
+ public CustomBiomeDefinitionRegisterException(String message) {
+ super(message);
+ }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomePrecipitation.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomePrecipitation.java
new file mode 100644
index 00000000000..7da306a3aea
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/CustomBiomePrecipitation.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.api.biome.custom;
+
+import org.geysermc.geyser.api.GeyserApi;
+import org.jetbrains.annotations.ApiStatus;
+
+/**
+ * Ambient ash or spore particles shown in a custom biome, like in the vanilla soul sand
+ * valley and warped forest. Bedrock exposes these through its precipitation component,
+ * but they are not rain or snow; those follow the Java biome's climate. A biome can have
+ * at most one precipitation type.
+ *
+ * @since 2.11.3
+ */
+@ApiStatus.NonExtendable
+public interface CustomBiomePrecipitation {
+
+ /**
+ * The particle type shown in this biome.
+ *
+ * @return the ambient particle type
+ * @since 2.11.3
+ */
+ Type type();
+
+ /**
+ * The particle density, {@code 0.0} or greater. For reference, the vanilla basalt
+ * deltas use a white ash density of {@code 2.0}.
+ *
+ * @return the particle density
+ * @since 2.11.3
+ */
+ float density();
+
+ /**
+ * Creates a precipitation instance of the given type and density.
+ *
+ * @param type the ambient particle type
+ * @param density the particle density, {@code 0.0} or greater
+ * @return a new precipitation instance
+ * @throws NullPointerException when the type is null
+ * @throws IllegalArgumentException when the density is negative or not finite
+ * @since 2.11.3
+ */
+ static CustomBiomePrecipitation of(Type type, float density) {
+ return GeyserApi.api().provider(CustomBiomePrecipitation.class, type, density);
+ }
+
+ /**
+ * The available precipitation particle types.
+ *
+ * @since 2.11.3
+ */
+ enum Type {
+ /**
+ * Ash particles, used by the vanilla soul sand valley
+ */
+ ASH,
+ /**
+ * White ash particles, used by the vanilla basalt deltas
+ */
+ WHITE_ASH,
+ /**
+ * Red spore particles, used by the vanilla crimson forest
+ */
+ RED_SPORES,
+ /**
+ * Blue spore particles, used by the vanilla warped forest
+ */
+ BLUE_SPORES
+ }
+}
diff --git a/api/src/main/java/org/geysermc/geyser/api/biome/custom/package-info.java b/api/src/main/java/org/geysermc/geyser/api/biome/custom/package-info.java
new file mode 100644
index 00000000000..d8c385ee4e3
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/biome/custom/package-info.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+@NullMarked
+package org.geysermc.geyser.api.biome.custom;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomBiomesEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomBiomesEvent.java
new file mode 100644
index 00000000000..994f5b58c6f
--- /dev/null
+++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomBiomesEvent.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.api.event.lifecycle;
+
+import org.geysermc.event.Event;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinitionRegisterException;
+import org.geysermc.geyser.api.util.Identifier;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.Map;
+
+/**
+ * Called on Geyser's startup when looking for custom biomes. Custom biomes must be
+ * registered through this event. They are most useful for Java biomes that have no
+ * Bedrock equivalent, such as biomes added by datapacks or mods, but vanilla Java
+ * biomes can be overridden as well.
+ *
+ * A registered definition is only used on sessions where the Java server has the
+ * Java biome in its registry.
+ *
+ * This event will not be called if the "enable-custom-content" setting is disabled
+ * in the Geyser config.
+ *
+ * @since 2.11.3
+ */
+@ApiStatus.NonExtendable
+public interface GeyserDefineCustomBiomesEvent extends Event {
+
+ /**
+ * A map of all the already registered custom biome definitions, indexed by the
+ * identifier of the Java biome they were registered for.
+ *
+ * @return an unmodifiable map of the registered definitions
+ * @since 2.11.3
+ */
+ Map customBiomeDefinitions();
+
+ /**
+ * Registers a custom biome definition for a Java biome; the definition must come from
+ * {@link CustomBiomeDefinition#builder(Identifier)}. Registering is only possible
+ * while this event is being fired. Every registration needs its own Java biome and
+ * its own Bedrock identifier; reusing either will throw an exception.
+ *
+ * @param javaIdentifier the identifier of the Java biome to register the definition for
+ * @param definition the custom biome definition to register
+ * @throws CustomBiomeDefinitionRegisterException when an error occurred while registering the biome
+ * @since 2.11.3
+ */
+ void register(Identifier javaIdentifier, CustomBiomeDefinition definition);
+}
diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
index 780ed5c9d48..e0a80a70dff 100644
--- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
+++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
@@ -460,45 +460,34 @@ private void startInstance() {
// The explicit WebRTC port property always wins. Without it, and with only NetherNet, WebRTC is the only
// UDP service, so it follows the UDP port property; with "both", RakNet keeps that port.
- String webrtcPort = System.getProperty("geyserWebrtcPort", "");
- boolean webrtcPortPropertyApplied = false;
- if (!webrtcPort.isEmpty()) {
- try {
- int parsedPort = Integer.parseInt(webrtcPort);
- if (parsedPort < 1 || parsedPort > 65535) {
- throw new NumberFormatException("The WebRTC port must be between 1 and 65535 inclusive!");
- }
- config.bedrock().webrtcPort(parsedPort);
- webrtcPortPropertyApplied = true;
- logger.info("NetherNet (WebRTC) port set from system property: " + parsedPort);
- } catch (NumberFormatException e) {
- logger.error(String.format("Invalid WebRTC port from system property: %s! Defaulting to configured port.", webrtcPort + " (" + e.getMessage() + ")"));
- }
- }
- if (!webrtcPortPropertyApplied && udpPortPropertyApplied && config.bedrock().transport() == GeyserConfig.BedrockConfig.Transport.NETHERNET) {
+ int webrtcPort = portProperty("geyserWebrtcPort", "NetherNet (WebRTC)", logger);
+ if (webrtcPort != 0) {
+ config.bedrock().webrtcPort(webrtcPort);
+ logger.info("NetherNet (WebRTC) port set from system property: " + webrtcPort);
+ } else if (udpPortPropertyApplied && config.bedrock().transport() == GeyserConfig.BedrockConfig.Transport.NETHERNET) {
config.bedrock().webrtcPort(0);
logger.info("NetherNet (WebRTC) port set from the Bedrock port system property: " + config.bedrock().port());
}
// Now that the Bedrock port may have been changed, also check the broadcast port (configurable on all platforms)
- String broadcastPort = System.getProperty("geyserBroadcastPort", "");
- if (!broadcastPort.isEmpty()) {
- try {
- int parsedPort = Integer.parseInt(broadcastPort);
- if (parsedPort < 1 || parsedPort > 65535) {
- throw new NumberFormatException("The broadcast port must be between 1 and 65535 inclusive!");
- }
- config.advanced().bedrock().broadcastPort(parsedPort);
- logger.info("Broadcast port set from system property: " + parsedPort);
- } catch (NumberFormatException e) {
- logger.error(String.format("Invalid broadcast port from system property: %s! Defaulting to configured port.", broadcastPort + " (" + e.getMessage() + ")"));
- }
+ int broadcastPort = portProperty("geyserBroadcastPort", "Broadcast", logger);
+ if (broadcastPort != 0) {
+ config.advanced().bedrock().broadcastPort(broadcastPort);
+ logger.info("Broadcast port set from system property: " + broadcastPort);
}
+ config.bedrock().raknetPort(portProperty("geyserRaknetPort", "RakNet", logger));
+ config.bedrock().signaling().port(portProperty("geyserSignalingPort", "Built-in signaling", logger));
- // It's set to 0 only if no system property or manual config value was set
+ // These are 0 only if no system property or manual config value was set
if (config.advanced().bedrock().broadcastPort() == 0) {
config.advanced().bedrock().broadcastPort(config.bedrock().port());
}
+ if (config.bedrock().raknetPort() == 0) {
+ config.bedrock().raknetPort(config.bedrock().port());
+ }
+ if (config.bedrock().signaling().port() == 0) {
+ config.bedrock().signaling().port(config.bedrock().port());
+ }
if (!(config instanceof GeyserPluginConfig)) {
String remoteAddress = config.java().address();
@@ -626,6 +615,30 @@ public boolean transfer(@NonNull UUID uuid, @NonNull String address, int port) {
return session.transfer(address, port);
}
+ /**
+ * Reads a port from a system property. Where each service ends up is already in the startup logs,
+ * so a valid port is applied quietly.
+ *
+ * @return the port, or 0 when the property is unset or does not hold a valid port
+ */
+ private static int portProperty(String property, String description, GeyserLogger logger) {
+ String value = System.getProperty(property, "");
+ if (value.isEmpty()) {
+ return 0;
+ }
+ try {
+ int port = Integer.parseInt(value);
+ if (port < 1 || port > 65535) {
+ throw new NumberFormatException("it must be between 1 and 65535 inclusive");
+ }
+ return port;
+ } catch (NumberFormatException e) {
+ logger.error("Invalid " + description + " port from system property: " + value
+ + " (" + e.getMessage() + ")! Defaulting to the configured port.");
+ return 0;
+ }
+ }
+
private void startRaknet(GeyserConfig config, GeyserLogger logger) {
int bedrockThreadCount = Integer.getInteger("Geyser.BedrockNetworkThreads", -1);
if (bedrockThreadCount == -1) {
@@ -634,10 +647,10 @@ private void startRaknet(GeyserConfig config, GeyserLogger logger) {
}
this.geyserServer = new RaknetServer(this, bedrockThreadCount);
- this.geyserServer.bind(new InetSocketAddress(config.bedrock().address(), config.bedrock().port()))
+ this.geyserServer.bind(new InetSocketAddress(config.bedrock().address(), config.bedrock().raknetPort()))
.whenComplete((avoid, throwable) -> {
String address = config.bedrock().address();
- String port = String.valueOf(config.bedrock().port()); // otherwise we get commas
+ String port = String.valueOf(config.bedrock().raknetPort()); // otherwise we get commas
if (throwable == null) {
if ("0.0.0.0".equals(address)) {
diff --git a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeAppearance.java b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeAppearance.java
new file mode 100644
index 00000000000..2ef785d2ced
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeAppearance.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.biome.custom;
+
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance;
+import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation;
+
+import java.awt.Color;
+import java.util.Objects;
+
+@EqualsAndHashCode
+@ToString
+public final class GeyserCustomBiomeAppearance implements CustomBiomeAppearance {
+ private final @Nullable Color skyColor;
+ private final @Nullable Color fogColor;
+ private final @Nullable Color waterSurfaceColor;
+ private final @Nullable Float waterSurfaceOpacity;
+ private final @Nullable Color waterFogColor;
+ private final @Nullable Float waterFogEndDistance;
+ private final @Nullable Color grassColor;
+ private final @Nullable Color foliageColor;
+ private final @Nullable Color dryFoliageColor;
+ private final @Nullable CustomBiomePrecipitation precipitation;
+
+ public GeyserCustomBiomeAppearance(Builder builder) {
+ if (builder.skyColor == null && builder.fogColor == null && builder.waterSurfaceColor == null
+ && builder.waterSurfaceOpacity == null && builder.waterFogColor == null
+ && builder.waterFogEndDistance == null && builder.grassColor == null
+ && builder.foliageColor == null && builder.dryFoliageColor == null
+ && builder.precipitation == null) {
+ throw new IllegalArgumentException("A biome appearance must set at least one value");
+ }
+ if (builder.waterFogEndDistance != null
+ && (!Float.isFinite(builder.waterFogEndDistance) || builder.waterFogEndDistance < 0.0F)) {
+ throw new IllegalArgumentException(
+ "Water fog end distance must be finite and non-negative, got " + builder.waterFogEndDistance);
+ }
+ if (builder.waterSurfaceOpacity != null && (!Float.isFinite(builder.waterSurfaceOpacity)
+ || builder.waterSurfaceOpacity < 0.0F || builder.waterSurfaceOpacity > 1.0F)) {
+ throw new IllegalArgumentException(
+ "Water surface opacity must be between 0.0 and 1.0, got " + builder.waterSurfaceOpacity);
+ }
+ if (builder.precipitation != null && !(builder.precipitation instanceof GeyserCustomBiomePrecipitation)) {
+ throw new IllegalArgumentException("The precipitation was not created with CustomBiomePrecipitation.of()");
+ }
+
+ this.skyColor = rgb(builder.skyColor);
+ this.fogColor = rgb(builder.fogColor);
+ this.waterSurfaceColor = rgb(builder.waterSurfaceColor);
+ this.waterSurfaceOpacity = builder.waterSurfaceOpacity;
+ this.waterFogColor = rgb(builder.waterFogColor);
+ this.waterFogEndDistance = builder.waterFogEndDistance;
+ this.grassColor = rgb(builder.grassColor);
+ this.foliageColor = rgb(builder.foliageColor);
+ this.dryFoliageColor = rgb(builder.dryFoliageColor);
+ this.precipitation = builder.precipitation;
+ }
+
+ // Appearance colors are RGB only; snapshot them without alpha so it can't affect equality
+ private static @Nullable Color rgb(@Nullable Color color) {
+ return color == null ? null : new Color(color.getRGB() & 0xFFFFFF);
+ }
+
+ @Override
+ public @Nullable Color skyColor() {
+ return skyColor;
+ }
+
+ @Override
+ public @Nullable Color fogColor() {
+ return fogColor;
+ }
+
+ @Override
+ public @Nullable Color waterSurfaceColor() {
+ return waterSurfaceColor;
+ }
+
+ @Override
+ public @Nullable Float waterSurfaceOpacity() {
+ return waterSurfaceOpacity;
+ }
+
+ @Override
+ public @Nullable Color waterFogColor() {
+ return waterFogColor;
+ }
+
+ @Override
+ public @Nullable Float waterFogEndDistance() {
+ return waterFogEndDistance;
+ }
+
+ @Override
+ public @Nullable Color grassColor() {
+ return grassColor;
+ }
+
+ @Override
+ public @Nullable Color foliageColor() {
+ return foliageColor;
+ }
+
+ @Override
+ public @Nullable Color dryFoliageColor() {
+ return dryFoliageColor;
+ }
+
+ @Override
+ public @Nullable CustomBiomePrecipitation precipitation() {
+ return precipitation;
+ }
+
+ public static class Builder implements CustomBiomeAppearance.Builder {
+ private @Nullable Color skyColor;
+ private @Nullable Color fogColor;
+ private @Nullable Color waterSurfaceColor;
+ private @Nullable Float waterSurfaceOpacity;
+ private @Nullable Color waterFogColor;
+ private @Nullable Float waterFogEndDistance;
+ private @Nullable Color grassColor;
+ private @Nullable Color foliageColor;
+ private @Nullable Color dryFoliageColor;
+ private @Nullable CustomBiomePrecipitation precipitation;
+
+ @Override
+ public Builder skyColor(Color skyColor) {
+ this.skyColor = Objects.requireNonNull(skyColor, "skyColor may not be null");
+ return this;
+ }
+
+ @Override
+ public Builder fogColor(Color fogColor) {
+ this.fogColor = Objects.requireNonNull(fogColor, "fogColor may not be null");
+ return this;
+ }
+
+ @Override
+ public Builder waterSurfaceColor(Color waterSurfaceColor) {
+ this.waterSurfaceColor = Objects.requireNonNull(waterSurfaceColor, "waterSurfaceColor may not be null");
+ return this;
+ }
+
+ @Override
+ public Builder waterSurfaceOpacity(float waterSurfaceOpacity) {
+ this.waterSurfaceOpacity = waterSurfaceOpacity;
+ return this;
+ }
+
+ @Override
+ public Builder waterFogColor(Color waterFogColor) {
+ this.waterFogColor = Objects.requireNonNull(waterFogColor, "waterFogColor may not be null");
+ return this;
+ }
+
+ @Override
+ public Builder waterFogEndDistance(float waterFogEndDistance) {
+ this.waterFogEndDistance = waterFogEndDistance;
+ return this;
+ }
+
+ @Override
+ public Builder grassColor(Color grassColor) {
+ this.grassColor = Objects.requireNonNull(grassColor, "grassColor may not be null");
+ return this;
+ }
+
+ @Override
+ public Builder foliageColor(Color foliageColor) {
+ this.foliageColor = Objects.requireNonNull(foliageColor, "foliageColor may not be null");
+ return this;
+ }
+
+ @Override
+ public Builder dryFoliageColor(Color dryFoliageColor) {
+ this.dryFoliageColor = Objects.requireNonNull(dryFoliageColor, "dryFoliageColor may not be null");
+ return this;
+ }
+
+ @Override
+ public Builder precipitation(CustomBiomePrecipitation precipitation) {
+ this.precipitation = Objects.requireNonNull(precipitation, "precipitation may not be null");
+ return this;
+ }
+
+ @Override
+ public CustomBiomeAppearance build() {
+ return new GeyserCustomBiomeAppearance(this);
+ }
+ }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java
new file mode 100644
index 00000000000..ef4c05d7805
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomeDefinition.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.biome.custom;
+
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition;
+import org.geysermc.geyser.api.util.Identifier;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.HexFormat;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+@EqualsAndHashCode
+@ToString
+public final class GeyserCustomBiomeDefinition implements CustomBiomeDefinition {
+ // Stricter than Java's identifier rules, which also allow '/'
+ private static final Pattern BEDROCK_IDENTIFIER = Pattern.compile("^[a-z0-9._-]+:[a-z0-9._-]+$");
+ private static final Pattern BIOME_TAG = Pattern.compile("^[a-z0-9_.]+(:[a-z0-9_.]+)?$");
+
+ private final Identifier bedrockIdentifier;
+ private final Set tags;
+ private final @Nullable CustomBiomeAppearance appearance;
+ private final @Nullable UUID packUuid;
+
+ public GeyserCustomBiomeDefinition(Builder builder) {
+ String identifier = builder.bedrockIdentifier.toString();
+ if (!BEDROCK_IDENTIFIER.matcher(identifier).matches()) {
+ throw new IllegalArgumentException(
+ "Bedrock biome identifiers must match " + BEDROCK_IDENTIFIER.pattern() + ", got " + identifier);
+ }
+ if (Identifier.DEFAULT_NAMESPACE.equals(builder.bedrockIdentifier.namespace())) {
+ throw new IllegalArgumentException(
+ "Custom biomes cannot use the minecraft namespace: " + identifier);
+ }
+ if (!builder.derived && "geyser".equals(builder.bedrockIdentifier.namespace()) && builder.bedrockIdentifier.path().startsWith("auto_")) {
+ throw new IllegalArgumentException(
+ "The geyser:auto_ prefix is reserved for Geyser: " + identifier);
+ }
+ if (builder.appearance != null && !(builder.appearance instanceof GeyserCustomBiomeAppearance)) {
+ throw new IllegalArgumentException(
+ "The appearance for " + identifier + " was not created with CustomBiomeAppearance.builder()");
+ }
+ for (String tag : builder.tags) {
+ if (!BIOME_TAG.matcher(tag).matches()) {
+ throw new IllegalArgumentException(
+ "Biome tags must match " + BIOME_TAG.pattern() + ", got " + tag);
+ }
+ if (tag.startsWith("minecraft:")) {
+ throw new IllegalArgumentException(
+ "Biome tags cannot use the minecraft namespace: " + tag);
+ }
+ }
+
+ this.bedrockIdentifier = builder.bedrockIdentifier;
+ // Sorted so tag order doesn't change how a definition serializes
+ this.tags = Collections.unmodifiableSortedSet(new TreeSet<>(builder.tags));
+ this.appearance = builder.appearance;
+ this.packUuid = builder.packUuid;
+ }
+
+ /**
+ * Creates the builder for a Java biome mapping that doesn't name a Bedrock identifier,
+ * deriving one from the Java identifier instead.
+ */
+ public static Builder derivedBuilder(Identifier javaIdentifier) {
+ Builder builder = new Builder(deriveBedrockIdentifier(javaIdentifier));
+ builder.derived = true;
+ return builder;
+ }
+
+ /**
+ * Java identifiers that are valid custom Bedrock identifiers are used as they are; for
+ * others, a digest of the full identifier is used, since flattening characters like
+ * {@code /} could make different Java identifiers collide.
+ */
+ private static Identifier deriveBedrockIdentifier(Identifier javaIdentifier) {
+ String identifier = javaIdentifier.toString();
+ if (BEDROCK_IDENTIFIER.matcher(identifier).matches()
+ && !Identifier.DEFAULT_NAMESPACE.equals(javaIdentifier.namespace())
+ && !("geyser".equals(javaIdentifier.namespace()) && javaIdentifier.path().startsWith("auto_"))) {
+ return javaIdentifier;
+ }
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(identifier.getBytes(StandardCharsets.UTF_8));
+ return Identifier.of("geyser", "auto_" + HexFormat.of().formatHex(digest, 0, 16));
+ } catch (NoSuchAlgorithmException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @Override
+ public Identifier bedrockIdentifier() {
+ return bedrockIdentifier;
+ }
+
+ @Override
+ public Set tags() {
+ return tags;
+ }
+
+ @Override
+ public @Nullable CustomBiomeAppearance appearance() {
+ return appearance;
+ }
+
+ /**
+ * The provided resource pack that the biome's mappings file bound it to, or null when
+ * none was named. Only mappings files set this.
+ */
+ public @Nullable UUID packUuid() {
+ return packUuid;
+ }
+
+ public static class Builder implements CustomBiomeDefinition.Builder {
+ private final Identifier bedrockIdentifier;
+ private final Set tags = new HashSet<>();
+ private @Nullable CustomBiomeAppearance appearance;
+ private @Nullable UUID packUuid;
+ private boolean derived;
+
+ public Builder(Identifier bedrockIdentifier) {
+ this.bedrockIdentifier = Objects.requireNonNull(bedrockIdentifier, "bedrockIdentifier may not be null");
+ }
+
+ @Override
+ public Builder tag(String tag) {
+ this.tags.add(Objects.requireNonNull(tag, "tag may not be null"));
+ return this;
+ }
+
+ @Override
+ public Builder appearance(CustomBiomeAppearance appearance) {
+ this.appearance = Objects.requireNonNull(appearance, "appearance may not be null");
+ return this;
+ }
+
+ public Builder packUuid(UUID packUuid) {
+ this.packUuid = Objects.requireNonNull(packUuid, "packUuid may not be null");
+ return this;
+ }
+
+ @Override
+ public CustomBiomeDefinition build() {
+ return new GeyserCustomBiomeDefinition(this);
+ }
+ }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomePrecipitation.java b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomePrecipitation.java
new file mode 100644
index 00000000000..252918b3f48
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/biome/custom/GeyserCustomBiomePrecipitation.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.biome.custom;
+
+import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation;
+
+import java.util.Objects;
+
+public record GeyserCustomBiomePrecipitation(Type type, float density) implements CustomBiomePrecipitation {
+
+ public GeyserCustomBiomePrecipitation {
+ Objects.requireNonNull(type, "type may not be null");
+ if (!Float.isFinite(density) || density < 0.0F) {
+ throw new IllegalArgumentException("Precipitation density must be finite and non-negative, got " + density);
+ }
+ }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java
index 5b034ad2501..845789a2947 100644
--- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java
+++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfig.java
@@ -36,6 +36,7 @@
import org.geysermc.geyser.text.GeyserLocale;
import org.geysermc.geyser.util.CooldownUtils;
import org.spongepowered.configurate.interfaces.meta.Exclude;
+import org.spongepowered.configurate.interfaces.meta.Field;
import org.spongepowered.configurate.interfaces.meta.defaults.DefaultBoolean;
import org.spongepowered.configurate.interfaces.meta.defaults.DefaultNumeric;
import org.spongepowered.configurate.interfaces.meta.defaults.DefaultString;
@@ -139,6 +140,15 @@ default Mode mode() {
void mode(Mode mode);
+ /**
+ * The TCP port built-in signaling binds, set by {@code -DgeyserSignalingPort} when a host routes it separately.
+ * Resolved to the Bedrock port on startup when the property is unset.
+ */
+ @Field
+ int port();
+
+ void port(int port);
+
@Comment("Settings for built-in signaling. Only used in the \"builtin\" and \"hybrid\" modes.")
BuiltinConfig builtin();
@@ -223,6 +233,8 @@ interface BedrockConfig extends BedrockListener {
@AsteriskSerializer.Asterisk
String address();
+ void address(String address);
+
@Comment("""
The port that Geyser will listen on for incoming Bedrock connections.
Built-in signaling uses this port over TCP, and RakNet uses it over UDP.""")
@@ -231,6 +243,8 @@ interface BedrockConfig extends BedrockListener {
@NumericRange(from = 0, to = 65535)
int port();
+ void port(int port);
+
@Comment("""
The UDP port that NetherNet connections use. 0 means the same port as above.
If raknet and nethernet transport mode are used in parallel, then this port has to be different to the port above.""")
@@ -238,6 +252,18 @@ interface BedrockConfig extends BedrockListener {
@NumericRange(from = 0, to = 65535)
int webrtcPort();
+ void webrtcPort(int port);
+
+ /**
+ * The UDP port RakNet binds, set by {@code -DgeyserRaknetPort} when a host routes it separately.
+ * {@link #port()} stays the port players connect to, so the broadcast port keeps following it.
+ * Resolved to {@link #port()} on startup when the property is unset.
+ */
+ @Field
+ int raknetPort();
+
+ void raknetPort(int port);
+
@Comment("""
How Bedrock players connect. Changes require a restart.
"nethernet" uses NetherNet (with signaling), which is the new connection method for Bedrock Edition.
@@ -247,6 +273,8 @@ default Transport transport() {
return Transport.RAKNET;
}
+ void transport(Transport transport);
+
@Comment("""
Some hosting services change your Java port everytime you start the server and require the same port to be used for Bedrock.
This option makes the Bedrock port the same as the Java port every time you start the server.""")
@@ -259,11 +287,6 @@ default Transport transport() {
Only used with the "nethernet" and "both" transports. Changes require a restart.""")
SignalingConfig signaling();
- void address(String address);
- void port(int port);
- void webrtcPort(int port);
- void transport(Transport transport);
-
@Exclude
@Override
default int broadcastPort() {
@@ -439,7 +462,7 @@ default CooldownUtils.CooldownType cooldownType() {
Whether to add any items and blocks which normally does not exist in Bedrock Edition.
This should only need to be disabled if using a proxy that does not use the "transfer packet" style of server switching.
If this is disabled, furnace minecart items will be mapped to hopper minecart items.
- Geyser's block, item, and skull mappings systems will also be disabled.
+ Geyser's biome, block, item, and skull mappings systems will also be disabled.
This option requires a restart of Geyser in order to change its setting.""")
@DefaultBoolean(true)
boolean enableCustomContent();
diff --git a/core/src/main/java/org/geysermc/geyser/level/JavaBiome.java b/core/src/main/java/org/geysermc/geyser/level/JavaBiome.java
new file mode 100644
index 00000000000..d3d91e0f846
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/level/JavaBiome.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.level;
+
+/**
+ * Represents the information we store from a Java biome.
+ *
+ * @param bedrockId the Bedrock biome ID used when translating chunks of this biome.
+ * @param temperature the biome's base temperature.
+ * @param downfall the biome's base downfall.
+ * @param hasPrecipitation whether rain or snow falls in this biome.
+ */
+public record JavaBiome(int bedrockId, float temperature, float downfall, boolean hasPrecipitation) {
+
+ public JavaBiome withBedrockId(int bedrockId) {
+ return new JavaBiome(bedrockId, temperature, downfall, hasPrecipitation);
+ }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java b/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java
index 18bd1f53fc9..d2f72646bea 100644
--- a/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java
+++ b/core/src/main/java/org/geysermc/geyser/network/bedrock/CodecProcessor.java
@@ -35,7 +35,7 @@
import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168;
import org.cloudburstmc.protocol.bedrock.codec.v2168.serializer.MovePlayerSerializer_v2168;
import org.cloudburstmc.protocol.bedrock.codec.v2168.serializer.PlayerSkinSerializer_v2168;
-import org.cloudburstmc.protocol.bedrock.codec.v2192.serializer.BossEventSerializer_v2192;
+import org.cloudburstmc.protocol.bedrock.codec.v2193.serializer.BossEventSerializer_v2193;
import org.cloudburstmc.protocol.bedrock.codec.v291.serializer.MobEquipmentSerializer_v291;
import org.cloudburstmc.protocol.bedrock.codec.v291.serializer.MoveEntityAbsoluteSerializer_v291;
import org.cloudburstmc.protocol.bedrock.codec.v291.serializer.PlayerHotbarSerializer_v291;
@@ -200,7 +200,7 @@ public void deserialize(ByteBuf buffer, BedrockCodecHelper helper, BossEventPack
}
};
- private static final BedrockPacketSerializer BOSS_EVENT_SERIALIZER_V2192 = new BossEventSerializer_v2192() {
+ private static final BedrockPacketSerializer BOSS_EVENT_SERIALIZER_V2193 = new BossEventSerializer_v2193() {
@Override
public void deserialize(ByteBuf buffer, BedrockCodecHelper helper, BossEventPacket packet) {
}
@@ -384,10 +384,10 @@ static BedrockCodec processCodec(BedrockCodec codec) {
codecBuilder.updateSerializer(PlayerSkinPacket.class, PLAYER_SKIN_SERIALIZER_V2168);
}
- if (codec.getProtocolVersion() < 2192) { // 26.50
+ if (codec.getProtocolVersion() < 2193) { // 26.50
codecBuilder.updateSerializer(BossEventPacket.class, BOSS_EVENT_SERIALIZER_V1001);
} else {
- codecBuilder.updateSerializer(BossEventPacket.class, BOSS_EVENT_SERIALIZER_V2192);
+ codecBuilder.updateSerializer(BossEventPacket.class, BOSS_EVENT_SERIALIZER_V2193);
}
return codecBuilder.build();
diff --git a/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java b/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java
index a094fc6fa4a..fc491c17dc7 100644
--- a/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java
+++ b/core/src/main/java/org/geysermc/geyser/network/bedrock/GameProtocol.java
@@ -33,7 +33,7 @@
import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168;
import org.cloudburstmc.protocol.bedrock.codec.v2168.Bedrock_v2168_hotfix4;
import org.cloudburstmc.protocol.bedrock.codec.v2169.Bedrock_v2169;
-import org.cloudburstmc.protocol.bedrock.codec.v2192.Bedrock_v2192;
+import org.cloudburstmc.protocol.bedrock.codec.v2193.Bedrock_v2193;
import org.cloudburstmc.protocol.bedrock.netty.codec.packet.BedrockPacketCodec;
import org.geysermc.geyser.api.util.MinecraftVersion;
import org.geysermc.geyser.impl.MinecraftVersionImpl;
@@ -86,7 +86,7 @@ public final class GameProtocol {
register(Bedrock_v1001.CODEC, "26.30", "26.31", "26.32", "26.33", "26.34");
register(Bedrock_v2168_hotfix4.CODEC, "26.40", "26.41", "26.42", "26.43", "26.44");
register(Bedrock_v2169.CODEC, "26.45");
- register(Bedrock_v2192.CODEC.toBuilder().protocolVersion(2193).build(), "26.50");
+ register(Bedrock_v2193.CODEC, "26.50", "26.51");
MinecraftVersion latestBedrock = SUPPORTED_BEDROCK_VERSIONS.getLast();
DEFAULT_BEDROCK_VERSION = latestBedrock.versionString();
@@ -143,7 +143,7 @@ public static boolean is26_40orHigher(int protocolVersion) {
}
public static boolean is26_50orHigher(int protocolVersion) {
- return protocolVersion >= Bedrock_v2192.CODEC.getProtocolVersion();
+ return protocolVersion >= Bedrock_v2193.CODEC.getProtocolVersion();
}
/**
diff --git a/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java b/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java
index a9db110efdd..61bb96682a5 100644
--- a/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java
+++ b/core/src/main/java/org/geysermc/geyser/network/bedrock/nethernet/NetherNetServer.java
@@ -90,7 +90,7 @@
/**
* The NetherNet (WebRTC) transport, used instead of RakNet: inbuilt HTTP signaling, NXS provider registration, or both,
- * all on the Bedrock address and port.
+ * all on the Bedrock address, and on the Bedrock port unless "webrtc-port" or -DgeyserSignalingPort move them.
* Its state (signing identity, provider registration and DTLS identity) lives in the "nethernet" folder next to the config.
*/
public final class NetherNetServer implements EventRegistrar {
@@ -102,6 +102,10 @@ public final class NetherNetServer implements EventRegistrar {
* The UDP port for WebRTC: "webrtc-port", or the Bedrock port if that is 0.
*/
private final int webrtcPort;
+ /**
+ * The TCP port built-in signaling listens on.
+ */
+ private final int signalingPort;
private final Path dataFolder;
private final BedrockPingHandler pingResponder;
@@ -126,6 +130,7 @@ public NetherNetServer(GeyserImpl geyser) {
GeyserConfig.BedrockConfig bedrock = geyser.config().bedrock();
this.config = bedrock.signaling();
this.webrtcPort = bedrock.webrtcPort() == 0 ? bedrock.port() : bedrock.webrtcPort();
+ this.signalingPort = config.port();
this.dataFolder = geyser.getBootstrap().getConfigFolder().resolve("nethernet");
this.pingResponder = new BedrockPingHandler(geyser);
}
@@ -148,28 +153,22 @@ public void start() {
"cannot work that way: every player will time out, including ones on this machine. " +
"Set \"address\" in the \"bedrock\" section to 0.0.0.0, or to this machine's local network address.");
}
- if (listener.transport().raknet()) {
- if (webrtcPort == listener.port()) {
- logger().error("NetherNet will not start! With the \"both\" transport, RakNet and NetherNet each need their own UDP port, " +
- "but both are set to " + listener.port() + ". Set \"webrtc-port\" in the \"bedrock\" section to a different free port.");
- return;
- }
- if (listener.cloneRemotePort()) {
- logger().warning("\"clone-remote-port\" is enabled, but the \"both\" transport also needs a separate TCP port for NetherNet. " +
- "If your host only gives you one port, set \"transport\" in the \"bedrock\" section to \"raknet\".");
- }
+ if (listener.transport().raknet() && webrtcPort == listener.raknetPort()) {
+ logger().error("NetherNet will not start! With the \"both\" transport, RakNet and NetherNet each need their own UDP port, " +
+ "but both are set to " + webrtcPort + ". Set \"webrtc-port\" in the \"bedrock\" section to a different free port.");
+ return;
}
- if (inbuilt && listener.port() == geyser.config().java().port()) {
+ if (inbuilt && signalingPort == geyser.config().java().port()) {
// e.g. clone-remote-port: the Java server owns that TCP port
inbuilt = false;
+ String reason = "Built-in signaling cannot use port " + signalingPort + " because the Java server already uses it. ";
if (!provider) {
provider = true;
- logger().warning("Built-in signaling cannot use port " + listener.port() + " because the Java server already uses it. " +
- "Bedrock players can still find this server through the external signaling service at " + config.nxs().endpoint() + " instead.");
+ logger().warning(reason + "Bedrock players can still find this server through the external signaling service at "
+ + config.nxs().endpoint() + " instead.");
} else {
- logger().warning("Built-in signaling cannot use port " + listener.port() + " because the Java server already uses it. " +
- "Only the external signaling service will be used.");
+ logger().warning(reason + "Only the external signaling service will be used.");
}
}
@@ -255,9 +254,9 @@ private void startInbuilt() {
}
BedrockListener listener = geyser.config().bedrock();
- // The channel binds HTTP signaling over TCP to the Bedrock port, and by default pins ICE to its UDP side.
- // When "webrtc-port" is another port, ICE goes there instead.
- boolean separateIcePort = webrtcPort != listener.port();
+ // The channel binds HTTP signaling over TCP to the signaling port, and by default pins ICE to its UDP side.
+ // When the WebRTC port is another port, ICE goes there instead.
+ boolean separateIcePort = webrtcPort != signalingPort;
this.signaling = signalingBuilder.setIceOnLocalPort(!separateIcePort).build();
this.inbuiltEventLoopGroup = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
@@ -279,19 +278,19 @@ protected void initChannel(Channel channel) {
});
}
- this.inbuiltChannel = b.bind(new InetSocketAddress(listener.address(), listener.port())).sync().channel();
+ this.inbuiltChannel = b.bind(new InetSocketAddress(listener.address(), signalingPort)).sync().channel();
// TLS is served on the same port as plaintext, so both schemes reach it when configured
String endpoint = https.certificate().isBlank()
- ? "http://" + listener.address() + ":" + listener.port()
- : "https:// and http:// on " + listener.address() + ":" + listener.port();
+ ? "http://" + listener.address() + ":" + signalingPort
+ : "https:// and http:// on " + listener.address() + ":" + signalingPort;
logger().info("Built-in signaling started on " + endpoint
+ (separateIcePort ? ", with NetherNet on UDP port " + webrtcPort : ""));
} catch (Throwable e) {
// Throwable: the WebRTC natives are not available on every platform
closeInbuiltResources();
logger().warning("Built-in signaling could not start. Make sure no other program is using TCP port "
- + geyser.config().bedrock().port() + ". Enable debug mode for more details.");
+ + signalingPort + ". Enable debug mode for more details.");
logger().debug("Built-in signaling failure: " + e);
}
}
diff --git a/core/src/main/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManager.java b/core/src/main/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManager.java
new file mode 100644
index 00000000000..a457799a14f
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/pack/CustomBiomeResourcePackManager.java
@@ -0,0 +1,384 @@
+/*
+ * Copyright (c) 2026 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.pack;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import it.unimi.dsi.fastutil.Pair;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeAppearance;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition;
+import org.geysermc.geyser.api.biome.custom.CustomBiomePrecipitation;
+import org.geysermc.geyser.api.util.Identifier;
+import org.geysermc.geyser.registry.Registries;
+import org.geysermc.geyser.util.FileUtils;
+
+import java.awt.Color;
+import java.io.File;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.UUID;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * Generates the resource pack for registered custom biome appearances.
+ */
+public class CustomBiomeResourcePackManager {
+
+ // The pack UUIDs hash this salt and the asset bytes, but not the manifest; bump it when
+ // the manifest changes, so clients drop their cached copy
+ private static final long RESOURCE_PACK_VERSION = 1;
+
+ private static final String PACK_ROOT = "custom_biome_pack/";
+ // The newest schema the pack can contain is the 1.26.0 water format; every supported
+ // client has it
+ private static final String MIN_ENGINE_VERSION = "[1, 26, 0]";
+ private static final String CLIENT_BIOME_FORMAT_VERSION = "1.21.120";
+ private static final String FOG_FORMAT_VERSION = "1.16.100";
+ // The water schema version that introduced biome_water_color_contribution
+ private static final String WATER_FORMAT_VERSION = "1.26.0";
+ // One water setting serves every custom biome that colors its water surface, like
+ // vanilla's minecraft:default_water
+ private static final String WATER_IDENTIFIER = "geyser:biome_water";
+
+ private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();
+
+ @SuppressWarnings("ResultOfMethodCallIgnored")
+ public static @Nullable Path createResourcePack() {
+ Path cachePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache");
+ try {
+ Files.createDirectories(cachePath);
+ } catch (IOException e) {
+ GeyserImpl.getInstance().getLogger().severe("Unable to create directories for the custom biome resource pack!", e);
+ return null;
+ }
+
+ Path packPath = cachePath.resolve("custom_biomes.mcpack");
+ File packFile = packPath.toFile();
+
+ Map files = generateFiles();
+ if (files.isEmpty()) {
+ packFile.delete(); // No appearances to deliver
+ return null;
+ }
+
+ // The pack is small enough to always rewrite; content-derived UUIDs keep the
+ // client's own cache valid while the content is unchanged
+ Pair uuids = generatePackUUIDs(files);
+ GeyserImpl.getInstance().getLogger().info("Creating custom biome resource pack.");
+ try (ZipOutputStream zipOS = new ZipOutputStream(Files.newOutputStream(packPath))) {
+ writeEntry(zipOS, PACK_ROOT + "manifest.json", manifestJson(uuids).getBytes(StandardCharsets.UTF_8));
+ for (Map.Entry file : files.entrySet()) {
+ writeEntry(zipOS, PACK_ROOT + file.getKey(), file.getValue());
+ }
+ return packPath;
+ } catch (IOException e) {
+ GeyserImpl.getInstance().getLogger().severe("Unable to create the custom biome resource pack!", e);
+ GeyserImpl.getInstance().getLogger().severe("Geyser-generated custom biome appearances will be unavailable.");
+ packFile.delete();
+ }
+ return null;
+ }
+
+ /**
+ * Generates every appearance asset, keyed by pack-relative path. The map is sorted so
+ * the content hash and the zip layout don't depend on catalogue iteration order.
+ */
+ private static Map generateFiles() {
+ Map files = new TreeMap<>();
+ String atmosphereTemplate = null;
+ boolean water = false;
+ for (CustomBiomeDefinition definition : Registries.CUSTOM_BIOMES.get().values()) {
+ CustomBiomeAppearance appearance = definition.appearance();
+ if (appearance == null) {
+ continue;
+ }
+ String assetKey = assetKey(definition.bedrockIdentifier());
+
+ files.put("biomes/" + assetKey + ".client_biome.json", clientBiomeJson(definition, assetKey).getBytes(StandardCharsets.UTF_8));
+ if (hasFog(appearance)) {
+ files.put("fogs/" + assetKey + ".fog.json", fogJson(appearance, assetKey).getBytes(StandardCharsets.UTF_8));
+ }
+ if (appearance.skyColor() != null) {
+ if (atmosphereTemplate == null) {
+ atmosphereTemplate = FileUtils.readToString("bedrock/custom_biome_pack/atmosphere_settings.json");
+ }
+ files.put("atmospherics/" + assetKey + ".json", atmosphereJson(atmosphereTemplate, appearance.skyColor(), assetKey).getBytes(StandardCharsets.UTF_8));
+ }
+ water |= appearance.waterSurfaceColor() != null;
+ }
+ if (water) {
+ files.put("water/biome_water.water.json", waterJson().getBytes(StandardCharsets.UTF_8));
+ }
+ return files;
+ }
+
+ private static String clientBiomeJson(CustomBiomeDefinition definition, String assetKey) {
+ CustomBiomeAppearance appearance = definition.appearance();
+ JsonObject components = new JsonObject();
+ if (appearance.skyColor() != null) {
+ components.add("minecraft:sky_color", color("sky_color", appearance.skyColor()));
+ JsonObject atmosphere = new JsonObject();
+ atmosphere.addProperty("atmosphere_identifier", "geyser:atmo_" + assetKey);
+ components.add("minecraft:atmosphere_identifier", atmosphere);
+ }
+ if (hasFog(appearance)) {
+ JsonObject fog = new JsonObject();
+ fog.addProperty("fog_identifier", "geyser:fog_" + assetKey);
+ components.add("minecraft:fog_appearance", fog);
+ }
+ if (appearance.waterSurfaceColor() != null || appearance.waterSurfaceOpacity() != null) {
+ JsonObject water = new JsonObject();
+ if (appearance.waterSurfaceColor() != null) {
+ water.addProperty("surface_color", hex(appearance.waterSurfaceColor()));
+ }
+ if (appearance.waterSurfaceOpacity() != null) {
+ water.addProperty("surface_opacity", appearance.waterSurfaceOpacity());
+ }
+ components.add("minecraft:water_appearance", water);
+ }
+ if (appearance.waterSurfaceColor() != null) {
+ // Vibrant Visuals only mixes in the surface color through a bound water setting
+ JsonObject water = new JsonObject();
+ water.addProperty("water_identifier", WATER_IDENTIFIER);
+ components.add("minecraft:water_identifier", water);
+ }
+ if (appearance.grassColor() != null) {
+ components.add("minecraft:grass_appearance", color("color", appearance.grassColor()));
+ }
+ if (appearance.foliageColor() != null) {
+ components.add("minecraft:foliage_appearance", color("color", appearance.foliageColor()));
+ }
+ if (appearance.dryFoliageColor() != null) {
+ components.add("minecraft:dry_foliage_color", color("color", appearance.dryFoliageColor()));
+ }
+ CustomBiomePrecipitation precipitation = appearance.precipitation();
+ if (precipitation != null) {
+ JsonObject density = new JsonObject();
+ density.addProperty(precipitation.type().name().toLowerCase(Locale.ROOT), precipitation.density());
+ components.add("minecraft:precipitation", density);
+ }
+
+ JsonObject description = new JsonObject();
+ description.addProperty("identifier", definition.bedrockIdentifier().toString());
+ JsonObject clientBiome = new JsonObject();
+ clientBiome.add("description", description);
+ clientBiome.add("components", components);
+ JsonObject root = new JsonObject();
+ root.addProperty("format_version", CLIENT_BIOME_FORMAT_VERSION);
+ root.add("minecraft:client_biome", clientBiome);
+ return GSON.toJson(root);
+ }
+
+ private static boolean hasFog(CustomBiomeAppearance appearance) {
+ return appearance.fogColor() != null || appearance.waterFogColor() != null || appearance.waterFogEndDistance() != null;
+ }
+
+ /**
+ * The client resolves fog per setting type, so only the blocks a value was supplied for
+ * are emitted, and each emitted block is complete: members the caller didn't set are
+ * filled in from the vanilla Bedrock default fog.
+ */
+ private static String fogJson(CustomBiomeAppearance appearance, String assetKey) {
+ JsonObject distance = new JsonObject();
+ if (appearance.fogColor() != null) {
+ JsonObject air = new JsonObject();
+ air.addProperty("fog_start", 0.92);
+ air.addProperty("fog_end", 1.0);
+ air.addProperty("fog_color", hex(appearance.fogColor()));
+ air.addProperty("render_distance_type", "render");
+ distance.add("air", air);
+
+ // Weather fog replaces air fog in rain; without this block the default grey
+ // would take over, so the custom color is kept, darkened with Java's full-rain factors
+ JsonObject weather = new JsonObject();
+ weather.addProperty("fog_start", 0.23);
+ weather.addProperty("fog_end", 0.7);
+ weather.addProperty("fog_color", hex(rainDarkened(appearance.fogColor())));
+ weather.addProperty("render_distance_type", "render");
+ distance.add("weather", weather);
+ }
+ if (appearance.waterFogColor() != null || appearance.waterFogEndDistance() != null) {
+ String waterColor = appearance.waterFogColor() != null ? hex(appearance.waterFogColor()) : "#44AFF5";
+ JsonObject water = new JsonObject();
+ water.addProperty("fog_start", 0.0);
+ water.addProperty("fog_end", appearance.waterFogEndDistance() != null ? appearance.waterFogEndDistance() : 60.0);
+ water.addProperty("fog_color", waterColor);
+ water.addProperty("render_distance_type", "fixed");
+ water.add("transition_fog", transitionFog(waterColor));
+ distance.add("water", water);
+ }
+
+ JsonObject description = new JsonObject();
+ description.addProperty("identifier", "geyser:fog_" + assetKey);
+ JsonObject settings = new JsonObject();
+ settings.add("description", description);
+ settings.add("distance", distance);
+ JsonObject root = new JsonObject();
+ root.addProperty("format_version", FOG_FORMAT_VERSION);
+ root.add("minecraft:fog_settings", settings);
+ return GSON.toJson(root);
+ }
+
+ // The gradual fade-in the vanilla default fog uses when the camera enters water
+ private static JsonObject transitionFog(String waterColor) {
+ JsonObject initFog = new JsonObject();
+ initFog.addProperty("fog_start", 0.0);
+ initFog.addProperty("fog_end", 0.01);
+ initFog.addProperty("fog_color", waterColor);
+ initFog.addProperty("render_distance_type", "fixed");
+ JsonObject transition = new JsonObject();
+ transition.add("init_fog", initFog);
+ transition.addProperty("min_percent", 0.25);
+ transition.addProperty("mid_seconds", 5);
+ transition.addProperty("mid_percent", 0.6);
+ transition.addProperty("max_seconds", 30);
+ return transition;
+ }
+
+ // Java multiplies air fog with its full-rain color #7F7F99, dividing each channel by 255
+ private static Color rainDarkened(Color color) {
+ return new Color(color.getRed() * 127 / 255, color.getGreen() * 127 / 255, color.getBlue() * 153 / 255);
+ }
+
+ /**
+ * Vibrant Visuals ignores the client biome sky color; it needs an atmosphere with the
+ * sky color as its zenith. The template is the vanilla day cycle with the custom color
+ * on the daylight keyframes only; the night keyframes stay vanilla, and sunsets
+ * interpolate between the two.
+ */
+ private static String atmosphereJson(String template, Color skyColor, String assetKey) {
+ return template
+ .replace("${identifier}", "geyser:atmo_" + assetKey)
+ .replace("${zenith_color}", "[" + skyColor.getRed() + ", " + skyColor.getGreen() + ", " + skyColor.getBlue() + "]");
+ }
+
+ /**
+ * Gives the client biome's surface color maximum contribution in Vibrant Visuals
+ * water; the renderer's other water properties still shape the final color.
+ */
+ private static String waterJson() {
+ JsonObject description = new JsonObject();
+ description.addProperty("identifier", WATER_IDENTIFIER);
+ JsonObject settings = new JsonObject();
+ settings.add("description", description);
+ settings.addProperty("biome_water_color_contribution", 1.0);
+ JsonObject root = new JsonObject();
+ root.addProperty("format_version", WATER_FORMAT_VERSION);
+ root.add("minecraft:water_settings", settings);
+ return GSON.toJson(root);
+ }
+
+ private static String manifestJson(Pair uuids) {
+ // The pbr capability is required for the client to load this pack's Vibrant Visuals assets
+ return """
+ {
+ "format_version": 2,
+ "header": {
+ "name": "Geyser Custom Biomes",
+ "description": "Client-side visuals for custom biomes registered through the Geyser API",
+ "uuid": "%s",
+ "version": [1, 0, 0],
+ "min_engine_version": %s
+ },
+ "modules": [
+ {
+ "type": "resources",
+ "uuid": "%s",
+ "version": [1, 0, 0]
+ }
+ ],
+ "capabilities": ["pbr"]
+ }
+ """.formatted(uuids.first(), MIN_ENGINE_VERSION, uuids.second());
+ }
+
+ /**
+ * File names use a digest of the Bedrock identifier: identifiers can contain characters
+ * that are unsafe in zip paths, and the digest gives each biome's assets a stable name
+ * that operator packs can override.
+ */
+ private static String assetKey(Identifier bedrockIdentifier) {
+ return HexFormat.of().formatHex(sha256(bedrockIdentifier.toString().getBytes(StandardCharsets.UTF_8)), 0, 16);
+ }
+
+ private static Pair generatePackUUIDs(Map files) {
+ MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException e) {
+ throw new AssertionError(e);
+ }
+ for (int i = 0; i < 8; i++) {
+ digest.update((byte) ((RESOURCE_PACK_VERSION >> (i * 8)) & 0xFF));
+ }
+ files.forEach((path, bytes) -> {
+ digest.update(path.getBytes(StandardCharsets.UTF_8));
+ digest.update(bytes);
+ });
+
+ ByteBuffer hash = ByteBuffer.wrap(digest.digest());
+ return Pair.of(new UUID(hash.getLong(), hash.getLong()), new UUID(hash.getLong(), hash.getLong()));
+ }
+
+ private static void writeEntry(ZipOutputStream zipOS, String path, byte[] bytes) throws IOException {
+ ZipEntry entry = new ZipEntry(path);
+ entry.setTime(0); // Fixed timestamps keep equal content byte-for-byte reproducible
+ zipOS.putNextEntry(entry);
+ zipOS.write(bytes);
+ zipOS.closeEntry();
+ }
+
+ private static JsonObject color(String name, Color value) {
+ JsonObject object = new JsonObject();
+ object.addProperty(name, hex(value));
+ return object;
+ }
+
+ private static String hex(Color color) {
+ return "#%06X".formatted(color.getRGB() & 0xFFFFFF);
+ }
+
+ private static byte[] sha256(byte[] input) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(input);
+ } catch (NoSuchAlgorithmException e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/registry/Registries.java b/core/src/main/java/org/geysermc/geyser/registry/Registries.java
index ed1f72da26f..9741d93e990 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/Registries.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/Registries.java
@@ -37,6 +37,7 @@
import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.PotionMixData;
import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket;
import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.api.biome.custom.CustomBiomeDefinition;
import org.geysermc.geyser.api.util.Identifier;
import org.geysermc.geyser.api.waypoint.CustomWaypointStyle;
import org.geysermc.geyser.entity.BedrockEntityDefinition;
@@ -58,6 +59,7 @@
import org.geysermc.geyser.registry.loader.SoundTranslatorRegistryLoader;
import org.geysermc.geyser.registry.loader.WaypointStyleLoader;
import org.geysermc.geyser.registry.mappings.MappingsType;
+import org.geysermc.geyser.registry.populator.CustomBiomeRegistryPopulator;
import org.geysermc.geyser.registry.populator.DataComponentRegistryPopulator;
import org.geysermc.geyser.registry.populator.ItemRegistryPopulator;
import org.geysermc.geyser.registry.populator.PacketRegistryPopulator;
@@ -114,15 +116,21 @@ public final class Registries {
public static final SimpleDeferredRegistry BIOMES_NBT = SimpleDeferredRegistry.create("bedrock/biome_definitions.dat", RegistryLoaders.NBT);
/**
- * A registry holding biome data for all known biomes.
+ * A registry holding the vanilla Bedrock biome definitions.
*/
public static final SimpleDeferredRegistry BIOMES = SimpleDeferredRegistry.create("bedrock/stripped_biome_definitions.json", RegistryLoaders.BIOME_LOADER);
/**
- * A mapped registry which stores Java biome identifiers and their Bedrock biome identifier.
+ * A mapped registry which stores each Java biome identifier and the numeric ID of the
+ * vanilla Bedrock biome it maps to.
*/
public static final SimpleDeferredRegistry> BIOME_IDENTIFIERS = SimpleDeferredRegistry.create("mappings/biomes.json", BiomeIdentifierRegistryLoader::new);
+ /**
+ * A mapped registry which stores Java biome identifiers to the custom biome definitions registered for them.
+ */
+ public static final SimpleMappedRegistry CUSTOM_BIOMES = SimpleMappedRegistry.create(RegistryLoaders.empty(Object2ObjectOpenHashMap::new));
+
/**
* A mapped registry which stores a block entity identifier to its {@link BlockEntityTranslator}.
*/
@@ -265,6 +273,7 @@ public static void populate() {
PacketRegistryPopulator.populate();
ItemRegistryPopulator.populate();
TagRegistryPopulator.populate();
+ CustomBiomeRegistryPopulator.populate();
// potion mixes depend on other registries
POTION_MIXES.load();
diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java
index ce7c9f218d3..ad7affa967d 100644
--- a/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java
+++ b/core/src/main/java/org/geysermc/geyser/registry/loader/BiomeIdentifierRegistryLoader.java
@@ -44,6 +44,10 @@ public Object2IntMap load(String input) {
// As of Bedrock Edition 1.17.10 with the experimental toggle, any unmapped biome identifier sent to the client
// crashes the client. Therefore, we need to have a list of all valid Bedrock biome IDs with which we can use from.
// The server sends the corresponding Java network IDs, so we don't need to worry about that now.
+ // On current clients that no longer holds: an unknown id renders with default visuals instead
+ // (verified on 1.26.44). Every id a chunk uses should still come from this vanilla mapping, from
+ // a custom biome definition sent to the client, or from the vanilla fallback fitting the
+ // dimension, so biomes keep their intended look.
// Reference variable for Gson to read off of
Type biomeEntriesType = new TypeToken