update to Paper 26.3: load biome colors from vanilla data, fix dappled forest grass

Biome grass/foliage/dry-foliage/water colors and grass color modifiers are now
read from the server's built-in worldgen biome data at runtime, with a 26.3
snapshot as fallback. Fixes missing biomes (dappled_forest, pale_garden,
sulfur_caves) and several outdated values. Replace deprecated ExactChoice
constructors.
This commit is contained in:
MineTec committed 2026-11-04 11:19:08 +01:00
1 parent 8dd295e5d6
commit 2d84a35cc4
4 files changed
+168 -100

No files matched your search

@@ -1,100 +1,171 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Hardcoded vanilla temperature/downfall and water color per biome. Used to drive the colormap
* lookup, since Paper does not expose the client-side per-block climate reliably. Unknown biomes
* fall back to a plains-like default.
* Per-biome climate (temperature/downfall) and color effects (water, grass/foliage/dry-foliage
* overrides, grass color modifier) as defined by the vanilla biome data.
*
* <p>The definitions are read at runtime from the server's built-in data pack
* ({@code data/minecraft/worldgen/biome/<name>.json} on the server classpath), so new or changed
* vanilla biomes are picked up automatically after a server update. If that lookup fails, a
* snapshot of the vanilla 26.3 values is used; unknown biomes fall back to a plains-like default.
*/
public final class BiomeClimate {
public record Climate(double temperature, double downfall, int water) {}
/** Marker for "no explicit color" (use the colormap). */
public static final int NONE = -1;
/**
* @param water ARGB-less RGB water color
* @param grass explicit grass color override, or {@link #NONE}
* @param foliage explicit foliage color override, or {@link #NONE}
* @param dryFoliage explicit dry-foliage color override, or {@link #NONE}
* @param grassModifier vanilla {@code grass_color_modifier} ("swamp", "dark_forest") or null
*/
public record Climate(
double temperature,
double downfall,
int water,
int grass,
int foliage,
int dryFoliage,
String grassModifier) {}
public static final int DEFAULT_WATER = 0x3F76E4;
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER);
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null);
private static final Map<String, Climate> TABLE = new HashMap<>();
private static final String DATA_PATH = "data/minecraft/worldgen/biome/";
private static void put(String key, double t, double d) {
TABLE.put(key, new Climate(t, d, DEFAULT_WATER));
}
private static void put(String key, double t, double d, int water) {
TABLE.put(key, new Climate(t, d, water));
private static final Map<String, Climate> FALLBACK = new HashMap<>();
private static final Map<String, Optional<Climate>> LOADED = new ConcurrentHashMap<>();
private static void put(
String key, double t, double d, int water, int grass, int foliage, int dry, String modifier) {
FALLBACK.put(key, new Climate(t, d, water, grass, foliage, dry, modifier));
}
// Snapshot of vanilla 26.3 data/minecraft/worldgen/biome/*.json
static {
put("plains", 0.8, 0.4);
put("sunflower_plains", 0.8, 0.4);
put("snowy_plains", 0.0, 0.5);
put("ice_spikes", 0.0, 0.5);
put("desert", 2.0, 0.0);
put("swamp", 0.8, 0.9, 0x617B64);
put("mangrove_swamp", 0.8, 0.9, 0x3A7A6A);
put("forest", 0.7, 0.8);
put("flower_forest", 0.7, 0.8);
put("birch_forest", 0.6, 0.6);
put("old_growth_birch_forest", 0.6, 0.6);
put("dark_forest", 0.7, 0.8);
put("old_growth_pine_taiga", 0.3, 0.8);
put("old_growth_spruce_taiga", 0.25, 0.8);
put("taiga", 0.25, 0.8);
put("snowy_taiga", -0.5, 0.4);
put("savanna", 2.0, 0.0);
put("savanna_plateau", 2.0, 0.0);
put("windswept_hills", 0.2, 0.3);
put("windswept_gravelly_hills", 0.2, 0.3);
put("windswept_forest", 0.2, 0.3);
put("windswept_savanna", 2.0, 0.0);
put("jungle", 0.95, 0.9);
put("sparse_jungle", 0.95, 0.8);
put("bamboo_jungle", 0.95, 0.9);
put("badlands", 2.0, 0.0);
put("eroded_badlands", 2.0, 0.0);
put("wooded_badlands", 2.0, 0.0);
put("meadow", 0.5, 0.8);
put("cherry_grove", 0.5, 0.8, 0x5DB7DD);
put("grove", -0.2, 0.8);
put("snowy_slopes", -0.3, 0.9);
put("frozen_peaks", -0.7, 0.9);
put("jagged_peaks", -0.7, 0.9);
put("stony_peaks", 1.0, 0.3);
put("river", 0.5, 0.5);
put("frozen_river", 0.0, 0.5, 0x3938C9);
put("beach", 0.8, 0.4);
put("snowy_beach", 0.05, 0.3, 0x3D57D6);
put("stony_shore", 0.2, 0.3);
put("warm_ocean", 0.5, 0.5, 0x43D5EE);
put("lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
put("deep_lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
put("ocean", 0.5, 0.5);
put("deep_ocean", 0.5, 0.5);
put("cold_ocean", 0.5, 0.5, 0x3D57D6);
put("deep_cold_ocean", 0.5, 0.5, 0x3D57D6);
put("frozen_ocean", 0.0, 0.5, 0x3938C9);
put("deep_frozen_ocean", 0.5, 0.5, 0x3938C9);
put("mushroom_fields", 0.9, 1.0);
put("dripstone_caves", 0.8, 0.4);
put("lush_caves", 0.5, 0.5);
put("deep_dark", 0.8, 0.4);
put("nether_wastes", 2.0, 0.0, 0x905957);
put("soul_sand_valley", 2.0, 0.0, 0x905957);
put("crimson_forest", 2.0, 0.0, 0x905957);
put("warped_forest", 2.0, 0.0, 0x905957);
put("basalt_deltas", 2.0, 0.0, 0x3F76E4);
put("the_end", 0.5, 0.5, 0x62529E);
put("end_highlands", 0.5, 0.5, 0x62529E);
put("end_midlands", 0.5, 0.5, 0x62529E);
put("small_end_islands", 0.5, 0.5, 0x62529E);
put("end_barrens", 0.5, 0.5, 0x62529E);
put("the_void", 0.5, 0.5);
put("badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
put("bamboo_jungle", 0.95, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("basalt_deltas", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("beach", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("birch_forest", 0.6, 0.6, 0x3F76E4, NONE, NONE, NONE, null);
put("cherry_grove", 0.5, 0.8, 0x5DB7EF, 0xB6DB61, 0xB6DB61, NONE, null);
put("cold_ocean", 0.5, 0.5, 0x3D57D6, NONE, NONE, NONE, null);
put("crimson_forest", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("dappled_forest", 0.6, 0.6, 0x375154, 0xDF6827, 0xE68E30, 0x8C3A04, null);
put("dark_forest", 0.7, 0.8, 0x3F76E4, NONE, NONE, 0x7B5334, "dark_forest");
put("deep_cold_ocean", 0.5, 0.5, 0x3D57D6, NONE, NONE, NONE, null);
put("deep_dark", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("deep_frozen_ocean", 0.5, 0.5, 0x3938C9, NONE, NONE, NONE, null);
put("deep_lukewarm_ocean", 0.5, 0.5, 0x45ADF2, NONE, NONE, NONE, null);
put("deep_ocean", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("desert", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("dripstone_caves", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("end_barrens", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("end_highlands", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("end_midlands", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("eroded_badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
put("flower_forest", 0.7, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("forest", 0.7, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("frozen_ocean", 0.0, 0.5, 0x3938C9, NONE, NONE, NONE, null);
put("frozen_peaks", -0.7, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("frozen_river", 0.0, 0.5, 0x3938C9, NONE, NONE, NONE, null);
put("grove", -0.2, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("ice_spikes", 0.0, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("jagged_peaks", -0.7, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("jungle", 0.95, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("lukewarm_ocean", 0.5, 0.5, 0x45ADF2, NONE, NONE, NONE, null);
put("lush_caves", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("mangrove_swamp", 0.8, 0.9, 0x3A7A6A, NONE, 0x8DB127, 0x7B5334, "swamp");
put("meadow", 0.5, 0.8, 0x0E4ECF, NONE, NONE, NONE, null);
put("mushroom_fields", 0.9, 1.0, 0x3F76E4, NONE, NONE, NONE, null);
put("nether_wastes", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("ocean", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("old_growth_birch_forest", 0.6, 0.6, 0x3F76E4, NONE, NONE, NONE, null);
put("old_growth_pine_taiga", 0.3, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("old_growth_spruce_taiga", 0.25, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("pale_garden", 0.7, 0.8, 0x76889D, 0x778272, 0x878D76, 0xA0A69C, null);
put("plains", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("river", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("savanna", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("savanna_plateau", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("small_end_islands", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("snowy_beach", 0.05, 0.3, 0x3D57D6, NONE, NONE, NONE, null);
put("snowy_plains", 0.0, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("snowy_slopes", -0.3, 0.9, 0x3F76E4, NONE, NONE, NONE, null);
put("snowy_taiga", -0.5, 0.4, 0x3D57D6, NONE, NONE, NONE, null);
put("soul_sand_valley", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("sparse_jungle", 0.95, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("stony_peaks", 1.0, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("stony_shore", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("sulfur_caves", 0.8, 0.4, 0x34BF89, 0xABA64F, NONE, NONE, null);
put("sunflower_plains", 0.8, 0.4, 0x3F76E4, NONE, NONE, NONE, null);
put("swamp", 0.8, 0.9, 0x617B64, NONE, 0x6A7039, 0x7B5334, "swamp");
put("taiga", 0.25, 0.8, 0x3F76E4, NONE, NONE, NONE, null);
put("the_end", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("the_void", 0.5, 0.5, 0x3F76E4, NONE, NONE, NONE, null);
put("warm_ocean", 0.5, 0.5, 0x43D5EE, NONE, NONE, NONE, null);
put("warped_forest", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_forest", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_gravelly_hills", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_hills", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_savanna", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("wooded_badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
}
private BiomeClimate() {}
public static Climate forKey(String biomePath) {
return TABLE.getOrDefault(biomePath, DEFAULT);
Optional<Climate> loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData);
return loaded.orElseGet(() -> FALLBACK.getOrDefault(biomePath, DEFAULT));
}
private static Optional<Climate> loadFromServerData(String biomePath) {
if (!biomePath.matches("[a-z0-9_./-]+")) return Optional.empty();
ClassLoader loader = BiomeClimate.class.getClassLoader();
if (loader == null) return Optional.empty();
try (InputStream in = loader.getResourceAsStream(DATA_PATH + biomePath + ".json")) {
if (in == null) return Optional.empty();
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
JsonObject effects = root.has("effects") ? root.getAsJsonObject("effects") : new JsonObject();
JsonElement modifier = effects.get("grass_color_modifier");
return Optional.of(new Climate(
root.has("temperature") ? root.get("temperature").getAsDouble() : DEFAULT.temperature(),
root.has("downfall") ? root.get("downfall").getAsDouble() : DEFAULT.downfall(),
color(effects, "water_color", DEFAULT_WATER),
color(effects, "grass_color", NONE),
color(effects, "foliage_color", NONE),
color(effects, "dry_foliage_color", NONE),
modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null));
} catch (Exception e) {
return Optional.empty();
}
}
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
private static int color(JsonObject effects, String key, int fallback) {
JsonElement el = effects.get(key);
if (el == null || !el.isJsonPrimitive()) return fallback;
if (el.getAsJsonPrimitive().isNumber()) return el.getAsInt() & 0xFFFFFF;
String s = el.getAsString().trim();
if (s.startsWith("#")) s = s.substring(1);
try {
return Integer.parseInt(s, 16) & 0xFFFFFF;
} catch (NumberFormatException e) {
return fallback;
}
}
}
@@ -39,26 +39,23 @@ public final class BiomeTintProvider {
private BiomeTint compute(String key) {
BiomeClimate.Climate climate = BiomeClimate.forKey(key);
int grass = sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
int foliage = sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
int dry = sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
// Explicit biome colors (biome "effects") win over the colormap, like in vanilla.
int grass = climate.grass() != BiomeClimate.NONE
? 0xFF000000 | climate.grass()
: sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
int foliage = climate.foliage() != BiomeClimate.NONE
? 0xFF000000 | climate.foliage()
: sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
int dry = climate.dryFoliage() != BiomeClimate.NONE
? 0xFF000000 | climate.dryFoliage()
: sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
// Vanilla per-biome grass/foliage color overrides and modifiers that the colormap alone misses.
switch (key) {
case "swamp", "mangrove_swamp" -> {
grass = 0xFF6A7039;
foliage = 0xFF6A7039;
}
case "badlands", "eroded_badlands", "wooded_badlands" -> {
grass = 0xFF90814D;
foliage = 0xFF9E814D;
}
case "dark_forest" -> {
// DARK_FOREST modifier: ((color & 0xFEFEFE) + 0x28340A) >> 1
grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1);
foliage = 0xFF000000 | (((foliage & 0xFEFEFE) + 0x28340A) >> 1);
}
default -> {}
// Vanilla grass_color_modifier (applies to grass only).
if ("swamp".equals(climate.grassModifier())) {
// Vanilla picks 0x4C763C/0x6A7039 via position noise; use the dominant color.
grass = 0xFF6A7039;
} else if ("dark_forest".equals(climate.grassModifier())) {
grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1);
}
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
}
@@ -63,8 +63,8 @@ public final class SurvivalRecipes {
loadableCameras.add(CameraItems.createCamera(i));
}
ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1));
load.addIngredient(new RecipeChoice.ExactChoice(loadableCameras));
load.addIngredient(new RecipeChoice.ExactChoice(CameraItems.createFilm()));
load.addIngredient(RecipeChoice.exactChoice(loadableCameras));
load.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm()));
Bukkit.addRecipe(load, false);
// Copy photo: photo + film. The photo carries a variable map id (cannot be enumerated), so it
@@ -72,7 +72,7 @@ public final class SurvivalRecipes {
// its real texture shows in the book.
ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.ExactChoice(CameraItems.createFilm()));
copy.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm()));
Bukkit.addRecipe(copy, false);
// Cover /reload while players are online; fresh joins are handled by JoinListener.