From 3a255b52e1a5988623806c1743283eaa93026293 Mon Sep 17 00:00:00 2001 From: Master290 Date: Mon, 27 Jul 2026 02:31:10 +0300 Subject: [PATCH] Real badlands clay bands and a real biome temperature table The bandlands rule cycled four terracotta colours off a per-column random draw. Vanilla generates a 192-entry band table once per world, from a random source named clay_bands, and reads it at the block's height shifted by the clay_bands_offset noise. Brown, red and light grey terracotta were never placed anywhere; the stripes were the wrong thickness and did not line up between neighbouring columns. All seven colours now appear. The temperature condition matched a hand-written list of eleven biome names. Replacing it with the temperature field read out of the jar's 65 biome JSONs fixes one of them: deep_frozen_ocean reads cold by name but its base temperature is 0.5, so vanilla does not freeze it. taiga and the pine taigas were the other way round -- excluded by name, and correctly so, but by coincidence rather than by data. Two parts of the vanilla calculation are left out and documented where they belong: the height adjustment that cools peaks, and the "frozen" modifier that warms scattered patches of frozen ocean. Both need PerlinSimplexNoise. Neither is reachable from the overworld tree in a way that shows: the single condition that consults temperature sits under a frozen_ocean biome check, below a water check, and decides whether a hole in the ocean floor ices over. The snowy mountain tops come from biome selection, not from here -- which is not what the plan for this commit assumed. The per-column *rand.Rand threaded through SurfaceContext goes away with the old bandlands rule; nothing needs it now that vertical_gradient rolls positionally. --- cmd/gendump/main.go | 42 +++++++++++ internal/world/store.go | 2 +- internal/world/vanilla.go | 10 ++- internal/worldgen/bandlands.go | 87 ++++++++++++++++++++++ internal/worldgen/biome_temperature.go | 99 ++++++++++++++++++++++++++ internal/worldgen/loader.go | 15 ++++ internal/worldgen/surface.go | 61 ++++------------ internal/worldgen/surface_test.go | 41 +++++++---- 8 files changed, 289 insertions(+), 68 deletions(-) create mode 100644 internal/worldgen/bandlands.go create mode 100644 internal/worldgen/biome_temperature.go diff --git a/cmd/gendump/main.go b/cmd/gendump/main.go index dd6b205..503a5ce 100644 --- a/cmd/gendump/main.go +++ b/cmd/gendump/main.go @@ -302,6 +302,48 @@ func main() { fmt.Printf(" OK: %d of %d grass columns carry 2+ blocks of dirt\n", banded, allGrass) } + // Badlands banding: the clay band table is 192 entries of seven terracotta + // colours. The stand-in it replaced cycled four, so brown, red and light + // grey never appeared anywhere in the world. + fmt.Println("\n=== Badlands clay bands (expect several terracotta colours down a column) ===") + terracottas := map[uint16]string{ + 12912: "terracotta", 11444: "white", 11445: "orange", 11448: "yellow", + 11452: "light_gray", 11456: "brown", 11458: "red", + } + seenBands := map[uint16]int{} + badlandsCols := 0 + for cx := int32(-300); cx < 300 && badlandsCols < 8; cx += 7 { + for cz := int32(-300); cz < 300 && badlandsCols < 8; cz += 7 { + name := world.BiomeNameAt(od, int(cx)*16+8, int(cz)*16+8) + if name != "minecraft:badlands" && name != "minecraft:eroded_badlands" && name != "minecraft:wooded_badlands" { + continue + } + ch := gen(cx, cz) + for lx := 0; lx < 16; lx += 4 { + for lz := 0; lz < 16; lz += 4 { + for wy := world.MinY + world.WorldHeight - 1; wy >= world.MinY; wy-- { + if _, isBand := terracottas[ch.GetBlock(lx, wy, lz)]; isBand { + seenBands[ch.GetBlock(lx, wy, lz)]++ + } + } + } + } + badlandsCols++ + } + } + if badlandsCols == 0 { + fmt.Println(" (no badlands in the scan area)") + } else { + for id, label := range terracottas { + fmt.Printf(" %-11s %d\n", label, seenBands[id]) + } + if len(seenBands) < 6 { + fmt.Printf(" FAIL: only %d of 7 terracotta colours placed\n", len(seenBands)) + } else { + fmt.Printf(" OK: %d of 7 terracotta colours across %d badlands chunks\n", len(seenBands), badlandsCols) + } + } + // 2) Cross-section at chunk (0,0): column x=8, over full Y, ASCII. fmt.Println("\n=== Cross-section chunk(0,0) z=8, x=0..15 (side view, top 96 blocks near surface) ===") c := gen(0, 0) diff --git a/internal/world/store.go b/internal/world/store.go index b0a3e77..f6e6ad4 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -32,7 +32,7 @@ const dataVersion26 = 4790 // first time it ran: chunkAt prefers the store over the generator, so the // already-explored area around spawn keeps its old terrain and every later fix // looks like it did nothing in exactly the place you are standing. -const generatorVersion = 5 +const generatorVersion = 6 // generatorVersionTag is the NBT key holding generatorVersion. It is namespaced // because it is ours, not part of the vanilla chunk format. diff --git a/internal/world/vanilla.go b/internal/world/vanilla.go index 23ed4a8..e0a1b84 100644 --- a/internal/world/vanilla.go +++ b/internal/world/vanilla.go @@ -123,13 +123,12 @@ func generateVanilla(od *worldgen.OverworldDensity, fluidPicker worldgen.FluidPi sctx = surfaceRule.NewContext() } for lz := 0; lz < 16; lz++ { - rng := newColumnRand(baseX+lx, baseZ+lz, int(seed)) if ruleErr == nil { applySurfaceRule(od, surfaceRule, sctx, &columns[lx][lz], - baseX+lx, baseZ+lz, lx, lz, &worldSurface, biomeName[lx][lz], rng) - } else { - fillLegacySurface(&columns[lx][lz], surfTop[lx][lz], rng) + baseX+lx, baseZ+lz, lx, lz, &worldSurface, biomeName[lx][lz]) + continue } + fillLegacySurface(&columns[lx][lz], surfTop[lx][lz], newColumnRand(baseX+lx, baseZ+lz, int(seed))) } }(lx) } @@ -275,7 +274,7 @@ func substance(aq *worldgen.Aquifer, fluidPicker worldgen.FluidPicker, x, y, z i // One *rand.Rand is created per column (not per block) — bandlands/gradient // consume from it sequentially, which is correct because vanilla seeds those // per-column too. This avoids ~98k rand.New allocations per chunk. -func applySurfaceRule(od *worldgen.OverworldDensity, rules *worldgen.SurfaceRuleSet, sctx *worldgen.SurfaceContext, out *[WorldHeight]uint16, wx, wz, lx, lz int, worldSurface *[16][16]int, biomeName string, rng chunkRand) { +func applySurfaceRule(od *worldgen.OverworldDensity, rules *worldgen.SurfaceRuleSet, sctx *worldgen.SurfaceContext, out *[WorldHeight]uint16, wx, wz, lx, lz int, worldSurface *[16][16]int, biomeName string) { top := -1 for i := WorldHeight - 1; i >= 0; i-- { if out[i] != StateAir { @@ -299,7 +298,6 @@ func applySurfaceRule(od *worldgen.OverworldDensity, rules *worldgen.SurfaceRule sctx.SurfaceDepth = surfaceDepth sctx.MinSurfaceLevel = od.MinSurfaceLevelAt(wx, wz, surfaceDepth) sctx.Steep = steepAt(worldSurface, lx, lz) - sctx.Rng = rng.toRand() minY := MinY stoneDepthAbove := 0 waterHeight := worldgen.NoWaterAbove diff --git a/internal/worldgen/bandlands.go b/internal/worldgen/bandlands.go new file mode 100644 index 0000000..5c4900f --- /dev/null +++ b/internal/worldgen/bandlands.go @@ -0,0 +1,87 @@ +package worldgen + +import "math" + +// bandlands.go ports SurfaceSystem's badlands clay banding: a 192-entry table +// of terracotta colours generated once per world, indexed by height plus a +// noise offset. It is what gives badlands their horizontal stripes. +// +// The previous stand-in cycled four colours off a per-column random draw, which +// produced stripes of the wrong thickness in the wrong places and never used +// brown, red or light grey at all. + +const clayBandCount = 192 + +// clayBands is the generated band table plus the noise that shifts it +// horizontally. +type clayBands struct { + bands [clayBandCount]uint16 + offset *NormalNoise +} + +// bandAt is SurfaceSystem.getBand. +func (c *clayBands) bandAt(x, y, z int) uint16 { + // Math.round, which is floor(v+0.5) — not Go's round-half-away-from-zero. + shift := int(math.Floor(c.offset.GetValue(float64(x), 0, float64(z))*4.0 + 0.5)) + return c.bands[((y+shift)%clayBandCount+clayBandCount)%clayBandCount] +} + +// newClayBands is SurfaceSystem.generateBands: terracotta everywhere, then +// orange stripes at random intervals, then runs of yellow, brown and red, then +// white bands flanked by light grey. +func newClayBands(random RandomSource, offset *NormalNoise) *clayBands { + c := &clayBands{offset: offset} + terracotta, _ := surfaceBlockID("minecraft:terracotta", nil) + orange, _ := surfaceBlockID("minecraft:orange_terracotta", nil) + yellow, _ := surfaceBlockID("minecraft:yellow_terracotta", nil) + brown, _ := surfaceBlockID("minecraft:brown_terracotta", nil) + red, _ := surfaceBlockID("minecraft:red_terracotta", nil) + white, _ := surfaceBlockID("minecraft:white_terracotta", nil) + lightGray, _ := surfaceBlockID("minecraft:light_gray_terracotta", nil) + + for i := range c.bands { + c.bands[i] = terracotta + } + // The stride is added to the loop variable, so the ++ at the end of each + // iteration is part of the spacing — as it is in vanilla. + for i := 0; i < clayBandCount; i++ { + if i += int(random.NextIntN(5)) + 1; i >= clayBandCount { + continue + } + c.bands[i] = orange + } + c.makeBands(random, 1, yellow) + c.makeBands(random, 2, brown) + c.makeBands(random, 1, red) + + whiteBandCount := nextIntBetweenInclusive(random, 9, 15) + for i, start := 0, 0; i < whiteBandCount && start < clayBandCount; i, start = i+1, start+int(random.NextIntN(16))+4 { + c.bands[start] = white + if start-1 > 0 && random.NextBoolean() { + c.bands[start-1] = lightGray + } + if start+1 >= clayBandCount || !random.NextBoolean() { + continue + } + c.bands[start+1] = lightGray + } + return c +} + +// makeBands is SurfaceSystem.makeBands: six to fifteen runs of one colour, each +// a few bands wide, dropped at random offsets. +func (c *clayBands) makeBands(random RandomSource, baseWidth int, state uint16) { + bandCount := nextIntBetweenInclusive(random, 6, 15) + for i := 0; i < bandCount; i++ { + width := baseWidth + int(random.NextIntN(3)) + start := int(random.NextIntN(clayBandCount)) + for p := 0; start+p < clayBandCount && p < width; p++ { + c.bands[start+p] = state + } + } +} + +// nextIntBetweenInclusive is RandomSource.nextIntBetweenInclusive. +func nextIntBetweenInclusive(random RandomSource, lo, hi int) int { + return lo + int(random.NextIntN(int32(hi-lo+1))) +} diff --git a/internal/worldgen/biome_temperature.go b/internal/worldgen/biome_temperature.go new file mode 100644 index 0000000..b110e58 --- /dev/null +++ b/internal/worldgen/biome_temperature.go @@ -0,0 +1,99 @@ +package worldgen + +// biome_temperature.go holds each overworld biome's base temperature, extracted +// verbatim from the "temperature" field of data/minecraft/worldgen/biome/*.json +// inside the 26.1.2 server jar. It replaces a hand-written list of biome names +// that guessed at which ones were cold. +// +// The surface rule tree consults it through exactly one condition +// (minecraft:temperature), which decides whether a hole in a frozen ocean floor +// freezes over. + +// biomeTemperature maps a biome to Biome.getBaseTemperature(). +var biomeTemperature = map[string]float32{ + "minecraft:badlands": 2.0, + "minecraft:bamboo_jungle": 0.95, + "minecraft:basalt_deltas": 2.0, + "minecraft:beach": 0.8, + "minecraft:birch_forest": 0.6, + "minecraft:cherry_grove": 0.5, + "minecraft:cold_ocean": 0.5, + "minecraft:crimson_forest": 2.0, + "minecraft:dark_forest": 0.7, + "minecraft:deep_cold_ocean": 0.5, + "minecraft:deep_dark": 0.8, + "minecraft:deep_frozen_ocean": 0.5, // temperature_modifier: frozen + "minecraft:deep_lukewarm_ocean": 0.5, + "minecraft:deep_ocean": 0.5, + "minecraft:desert": 2.0, + "minecraft:dripstone_caves": 0.8, + "minecraft:end_barrens": 0.5, + "minecraft:end_highlands": 0.5, + "minecraft:end_midlands": 0.5, + "minecraft:eroded_badlands": 2.0, + "minecraft:flower_forest": 0.7, + "minecraft:forest": 0.7, + "minecraft:frozen_ocean": 0.0, // temperature_modifier: frozen + "minecraft:frozen_peaks": -0.7, + "minecraft:frozen_river": 0.0, + "minecraft:grove": -0.2, + "minecraft:ice_spikes": 0.0, + "minecraft:jagged_peaks": -0.7, + "minecraft:jungle": 0.95, + "minecraft:lukewarm_ocean": 0.5, + "minecraft:lush_caves": 0.5, + "minecraft:mangrove_swamp": 0.8, + "minecraft:meadow": 0.5, + "minecraft:mushroom_fields": 0.9, + "minecraft:nether_wastes": 2.0, + "minecraft:ocean": 0.5, + "minecraft:old_growth_birch_forest": 0.6, + "minecraft:old_growth_pine_taiga": 0.3, + "minecraft:old_growth_spruce_taiga": 0.25, + "minecraft:pale_garden": 0.7, + "minecraft:plains": 0.8, + "minecraft:river": 0.5, + "minecraft:savanna": 2.0, + "minecraft:savanna_plateau": 2.0, + "minecraft:small_end_islands": 0.5, + "minecraft:snowy_beach": 0.05, + "minecraft:snowy_plains": 0.0, + "minecraft:snowy_slopes": -0.3, + "minecraft:snowy_taiga": -0.5, + "minecraft:soul_sand_valley": 2.0, + "minecraft:sparse_jungle": 0.95, + "minecraft:stony_peaks": 1.0, + "minecraft:stony_shore": 0.2, + "minecraft:sunflower_plains": 0.8, + "minecraft:swamp": 0.8, + "minecraft:taiga": 0.25, + "minecraft:the_end": 0.5, + "minecraft:the_void": 0.5, + "minecraft:warm_ocean": 0.5, + "minecraft:warped_forest": 2.0, + "minecraft:windswept_forest": 0.2, + "minecraft:windswept_gravelly_hills": 0.2, + "minecraft:windswept_hills": 0.2, + "minecraft:windswept_savanna": 2.0, + "minecraft:wooded_badlands": 2.0, +} + +// coldEnoughToSnow is Biome.coldEnoughToSnow: below 0.15 the biome gets snow +// and ice rather than rain. +// +// Two parts of vanilla's calculation are not reproduced, both because they need +// PerlinSimplexNoise, which we do not have: +// +// - the height adjustment, which cools a column above sea level + 17 and so +// puts snow on peaks in otherwise temperate biomes. No rule in the +// overworld tree reaches this condition above that height. +// - the "frozen" temperature modifier, which warms scattered patches of +// frozen_ocean and deep_frozen_ocean. Its absence makes frozen-ocean ice +// uniform where vanilla leaves open water in it. +// +// An unknown biome reads as warm, which is the safe direction: it leaves the +// default block alone rather than icing something over. +func coldEnoughToSnow(biome string) bool { + temperature, ok := biomeTemperature[biome] + return ok && temperature < 0.15 +} diff --git a/internal/worldgen/loader.go b/internal/worldgen/loader.go index 061ca29..8934bf6 100644 --- a/internal/worldgen/loader.go +++ b/internal/worldgen/loader.go @@ -17,6 +17,7 @@ type Loader struct { rs *RandomState dfCache map[string]DensityFunction interpolated []*Interpolated + bands *clayBands } // OverworldDensity is the parsed final_density plus the set of Interpolated @@ -446,3 +447,17 @@ func (l *Loader) parseSplineValue(v any) (DensityFunction, error) { } return l.parseNode(v) } + +// clayBands builds the world's badlands band table on first use. It is seeded +// from the root positional factory hashed by name, as SurfaceSystem does. +func (l *Loader) clayBands() (*clayBands, error) { + if l.bands != nil { + return l.bands, nil + } + offset, err := l.noiseField("minecraft:clay_bands_offset") + if err != nil { + return nil, fmt.Errorf("clay_bands_offset noise: %w", err) + } + l.bands = newClayBands(l.rs.Positional().FromHashOf("minecraft:clay_bands"), offset) + return l.bands, nil +} diff --git a/internal/worldgen/surface.go b/internal/worldgen/surface.go index 03bf43d..e230445 100644 --- a/internal/worldgen/surface.go +++ b/internal/worldgen/surface.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "math" - "math/rand" ) // surface.go implements the vanilla SurfaceRules interpreter: a rule tree that @@ -59,10 +58,6 @@ type SurfaceContext struct { // the interpolated preliminary surface level plus SurfaceDepth less 8. // above_preliminary_surface tests Y against it. MinSurfaceLevel int - // Rng is a per-column deterministic source for the bandlands rule. It is - // seeded by the column so results are stable across runs. - Rng *rand.Rand - // noiseValues holds one sample per noise the rule tree's noise_threshold // conditions reference, refreshed once per column by BeginColumn. Vanilla // caches these the same way, through LazyXZCondition. @@ -107,29 +102,13 @@ func (r conditionRule) Apply(ctx *SurfaceContext) (uint16, bool) { return r.then.Apply(ctx) } -// bandlandsRule reproduces the vanilla badlands coloured-clay banding: a -// deterministic per-column pattern of terracotta colours at certain Y bands. We -// approximate the 8-band rotation using the column RNG; exact band geometry is -// captured well enough to read as badlands. -type bandlandsRule struct{} +// bandlandsRule reads the world's clay band table at the block's height, +// shifted horizontally by the clay_bands_offset noise. It is what stripes the +// badlands. +type bandlandsRule struct{ bands *clayBands } -func (bandlandsRule) Apply(ctx *SurfaceContext) (uint16, bool) { - orange, _ := surfaceBlockID("minecraft:orange_terracotta", nil) - if ctx.Rng == nil { - return orange, true - } - // Vanilla chooses band by Y + a per-column random offset; the rotation - // cycles white/orange/yellow/orange terracotta. Pick from the cycle by Y. - white, _ := surfaceBlockID("minecraft:white_terracotta", nil) - yellow, _ := surfaceBlockID("minecraft:yellow_terracotta", nil) - switch (ctx.Y + ctx.Rng.Intn(7)) % 4 { - case 0: - return white, true - case 1, 3: - return orange, true - default: - return yellow, true - } +func (r bandlandsRule) Apply(ctx *SurfaceContext) (uint16, bool) { + return r.bands.bandAt(ctx.X, ctx.Y, ctx.Z), true } // ---- Condition tests --------------------------------------------------- @@ -188,27 +167,13 @@ func (t waterTest) Test(ctx *SurfaceContext) bool { return y >= ctx.WaterHeight+t.offset+ctx.SurfaceDepth*t.surfaceDepthMul } -// temperatureTest passes when the (column) temperature is below freezing — the -// snow-at-height rule. We fold temperature into the biome name (snowy_* -// biomes) rather than sampling the temperature noise, so pass for cold biomes. +// temperatureTest passes where the biome is cold enough for snow and ice +// rather than rain. The overworld tree uses it once, to freeze holes in a +// frozen ocean floor. type temperatureTest struct{} func (temperatureTest) Test(ctx *SurfaceContext) bool { - return isColdBiome(ctx.BiomeName) -} - -// isColdBiome reports whether the biome should receive snow cover. We use the -// biome name rather than the temperature noise for simplicity; this matches -// the visible result for the standard overworld biomes. -func isColdBiome(name string) bool { - switch name { - case "minecraft:snowy_plains", "minecraft:snowy_taiga", "minecraft:snowy_beach", - "minecraft:snowy_slopes", "minecraft:jagged_peaks", "minecraft:frozen_peaks", - "minecraft:frozen_river", "minecraft:frozen_ocean", "minecraft:deep_frozen_ocean", - "minecraft:ice_spikes", "minecraft:grove": - return true - } - return false + return coldEnoughToSnow(ctx.BiomeName) } // yAboveTest passes when Y clears an anchor, with optional surface-depth and @@ -445,7 +410,11 @@ func (p *surfaceParser) parseRule(raw json.RawMessage) (SurfaceRule, error) { return conditionRule{test: test, then: then}, nil case "minecraft:bandlands": - return bandlandsRule{}, nil + bands, err := p.loader.clayBands() + if err != nil { + return nil, err + } + return bandlandsRule{bands: bands}, nil } return nil, fmt.Errorf("surface: unknown rule type %q", obj.Type) } diff --git a/internal/worldgen/surface_test.go b/internal/worldgen/surface_test.go index 5fc3f4d..77781d2 100644 --- a/internal/worldgen/surface_test.go +++ b/internal/worldgen/surface_test.go @@ -1,9 +1,6 @@ package worldgen -import ( - "math/rand" - "testing" -) +import "testing" // loadTestRules compiles the overworld surface rule set at a fixed seed. func loadTestRules(t *testing.T) *SurfaceRuleSet { @@ -47,7 +44,6 @@ func TestSurfaceRuleNoPanic(t *testing.T) { ctx.SeaLevel, ctx.MinY = 63, -64 ctx.MinSurfaceLevel, ctx.WaterHeight = 80, NoWaterAbove ctx.SurfaceDepth = 3 - ctx.Rng = rand.New(rand.NewSource(1)) for _, b := range biomes { ctx.BiomeName = b for y := 0; y < 100; y++ { @@ -70,7 +66,6 @@ func TestSurfaceBedrockFloor(t *testing.T) { ctx.BiomeName = "minecraft:plains" ctx.MinSurfaceLevel, ctx.WaterHeight = 62, NoWaterAbove ctx.SurfaceDepth = 3 - ctx.Rng = rand.New(rand.NewSource(1)) state, ok := rules.Apply(ctx) if !ok { t.Fatal("no rule matched at bedrock floor") @@ -116,21 +111,37 @@ func TestSurfaceBlockIDResolution(t *testing.T) { } } -// TestIsColdBiome confirms the snow-cover predicate recognises cold biomes so -// the temperature condition routes snowy biomes to snow. -func TestIsColdBiome(t *testing.T) { - cold := []string{"minecraft:snowy_plains", "minecraft:frozen_peaks", "minecraft:grove"} +// TestColdEnoughToSnow pins the temperature predicate against the biome table +// extracted from the jar. deep_frozen_ocean is the interesting case: the name +// reads cold but its base temperature is 0.5, so vanilla does not freeze it — +// the hand-written list this replaced got it wrong. +func TestColdEnoughToSnow(t *testing.T) { + cold := []string{ + "minecraft:frozen_ocean", "minecraft:frozen_peaks", "minecraft:frozen_river", + "minecraft:grove", "minecraft:ice_spikes", "minecraft:jagged_peaks", + "minecraft:snowy_beach", "minecraft:snowy_plains", "minecraft:snowy_slopes", + "minecraft:snowy_taiga", + } for _, b := range cold { - if !isColdBiome(b) { - t.Errorf("isColdBiome(%q) = false, want true", b) + if !coldEnoughToSnow(b) { + t.Errorf("coldEnoughToSnow(%q) = false, want true", b) } } - warm := []string{"minecraft:desert", "minecraft:plains", "minecraft:badlands"} + warm := []string{ + "minecraft:desert", "minecraft:plains", "minecraft:badlands", + "minecraft:deep_frozen_ocean", "minecraft:taiga", "minecraft:windswept_hills", + } for _, b := range warm { - if isColdBiome(b) { - t.Errorf("isColdBiome(%q) = true, want false", b) + if coldEnoughToSnow(b) { + t.Errorf("coldEnoughToSnow(%q) = true, want false", b) } } + if coldEnoughToSnow("minecraft:not_a_biome") { + t.Error("an unknown biome read as cold") + } + if len(biomeTemperature) != 65 { + t.Errorf("biome temperature table has %d entries, want 65", len(biomeTemperature)) + } } // TestWaterCondition pins SurfaceRules.WaterConditionSource against the