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
*
- * @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);
}