Restore the subsurface layers: real above_preliminary_surface and surface depth

Every land column was one block of grass sitting straight on stone. No dirt
under grass, no sandstone under sand, nothing. Two stubs did it together:

above_preliminary_surface compared blockY against the column's actual top block,
so of every position in the column exactly one passed -- and the entire
biome-specific half of the surface rule tree hangs under that condition.
Vanilla compares against a minimum surface level: the preliminary surface level
sampled at the four corners of the 16-block cell, bilinearly interpolated, plus
the surface depth less 8. That is about twenty blocks of reach on ordinary
terrain, which is what the biome subtree is written against.

Surface depth was hardcoded to 0. Vanilla is surfaceNoise*2.75 + 3 with a
per-column jitter, so it comes out around three; it sets how thick the band is
and feeds every add_surface_depth term in the tree. Zero collapsed them all.

Also samples surface_secondary, so stone_depth's secondary_depth_range widens
its band instead of being parsed and dropped.

Grass columns now read grass, two to four dirt, stone -- the histogram over 256
columns is {2: 223, 3: 33}, against vanilla's 2..4. gendump prints it and fails
if the band collapses again; TestGrassColumnsHaveDirt guards it in the suite.
This commit is contained in:
Master290 2026-07-27 02:15:14 +03:00
parent e77fe3dc07
commit 1083e47211
9 changed files with 231 additions and 32 deletions

View file

@ -216,9 +216,31 @@ func main() {
// dirt band beyond a single block. // dirt band beyond a single block.
fmt.Println("\n=== Subsurface banding (grass columns: expect grass=9, dirt=10 band, stone=1) ===") fmt.Println("\n=== Subsurface banding (grass columns: expect grass=9, dirt=10 band, stone=1) ===")
found := 0 found := 0
bandDepths := map[int]int{}
for cx := int32(-40); cx < 40 && found < 6; cx += 3 { for cx := int32(-40); cx < 40 && found < 6; cx += 3 {
for cz := int32(-40); cz < 40 && found < 6; cz += 3 { for cz := int32(-40); cz < 40 && found < 6; cz += 3 {
ch := gen(cx, cz) ch := gen(cx, cz)
for lx := 0; lx < 16; lx++ {
for lz := 0; lz < 16; lz++ {
topY := world.MinY - 1
for wy := world.MinY + world.WorldHeight - 1; wy >= world.MinY; wy-- {
b := ch.GetBlock(lx, wy, lz)
if b != world.StateAir && b != world.StateWater && b != world.StateLava &&
b != world.StateOakLog && b != world.StateOakLeaf {
topY = wy
break
}
}
if topY < world.SeaLevel || ch.GetBlock(lx, topY, lz) != world.StateGrass {
continue
}
depth := 0
for wy := topY - 1; wy >= topY-6 && ch.GetBlock(lx, wy, lz) == world.StateDirt; wy-- {
depth++
}
bandDepths[depth]++
}
}
for lx := 0; lx < 16 && found < 6; lx += 5 { for lx := 0; lx < 16 && found < 6; lx += 5 {
for lz := 0; lz < 16 && found < 6; lz += 5 { for lz := 0; lz < 16 && found < 6; lz += 5 {
topY := world.MinY - 1 topY := world.MinY - 1
@ -246,6 +268,25 @@ func main() {
if found == 0 { if found == 0 {
fmt.Println(" (no grass columns found in scan area)") fmt.Println(" (no grass columns found in scan area)")
} }
// The band depth over every grass column scanned. Vanilla is 2..4; a
// histogram piled entirely on 0 means the biome surface subtree is gated to
// one block per column again.
banded, allGrass := 0, 0
for d, n := range bandDepths {
allGrass += n
if d >= 2 {
banded += n
}
}
fmt.Printf(" dirt-band depth over %d grass columns: %v\n", allGrass, bandDepths)
switch {
case allGrass == 0:
fmt.Println(" (no grass columns to measure)")
case banded*4 < allGrass*3:
fmt.Printf(" FAIL: only %d of %d grass columns carry 2+ blocks of dirt\n", banded, allGrass)
default:
fmt.Printf(" OK: %d of %d grass columns carry 2+ blocks of dirt\n", banded, allGrass)
}
// 2) Cross-section at chunk (0,0): column x=8, over full Y, ASCII. // 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) ===") fmt.Println("\n=== Cross-section chunk(0,0) z=8, x=0..15 (side view, top 96 blocks near surface) ===")

View file

@ -0,0 +1,67 @@
package world
import "testing"
// TestGrassColumnsHaveDirt guards the subsurface banding. Before
// above_preliminary_surface and surfaceDepth were real, every land column read
// grass-on-stone: the biome subtree was gated to a single block per column and
// the surface depth that widens the band was hardcoded to zero.
//
// Vanilla puts two to four blocks of dirt under the grass cap. The check is
// deliberately a majority rather than a universal: a column on a steep slope or
// in a surface "hole" legitimately has none.
func TestGrassColumnsHaveDirt(t *testing.T) {
gen := NewVanillaGenerator(12345)
withDirt, total := 0, 0
depths := map[int]int{}
for cx := int32(-42); cx < -36; cx++ {
for cz := int32(-40); cz < -34; cz++ {
ch := gen(cx, cz)
for lx := 0; lx < 16; lx += 4 {
for lz := 0; lz < 16; lz += 4 {
topY, ok := grassTop(ch, lx, lz)
if !ok {
continue
}
total++
depth := 0
for y := topY - 1; y >= topY-6; y-- {
if ch.GetBlock(lx, y, lz) != StateDirt {
break
}
depth++
}
depths[depth]++
if depth >= 2 {
withDirt++
}
}
}
}
}
if total < 50 {
t.Fatalf("only %d grass columns found; the scan area has no land", total)
}
t.Logf("grass columns=%d with a dirt band>=2: %d; depth histogram %v", total, withDirt, depths)
if withDirt*4 < total*3 {
t.Errorf("only %d of %d grass columns carry a dirt band of 2+; the surface subtree is gated too tightly", withDirt, total)
}
if depths[6] > total/10 {
t.Errorf("%d of %d grass columns have 6+ blocks of dirt; the surface band is running away", depths[6], total)
}
}
// grassTop returns the Y of the column's grass cap, skipping decoration.
func grassTop(c *Chunk, lx, lz int) (int, bool) {
for wy := MinY + WorldHeight - 1; wy >= MinY; wy-- {
switch b := c.GetBlock(lx, wy, lz); b {
case StateAir, StateWater, StateLava, StateOakLog, StateOakLeaf:
continue
case StateGrass:
return wy, true
default:
return 0, false
}
}
return 0, false
}

View file

@ -32,7 +32,7 @@ const dataVersion26 = 4790
// first time it ran: chunkAt prefers the store over the generator, so the // 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 // 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. // looks like it did nothing in exactly the place you are standing.
const generatorVersion = 3 const generatorVersion = 4
// generatorVersionTag is the NBT key holding generatorVersion. It is namespaced // generatorVersionTag is the NBT key holding generatorVersion. It is namespaced
// because it is ours, not part of the vanilla chunk format. // because it is ours, not part of the vanilla chunk format.

View file

@ -199,7 +199,7 @@ func fillVanillaColumn(od *worldgen.OverworldDensity, aq *worldgen.Aquifer, flui
rng := newColumnRand(wx, wz, int(seed)) rng := newColumnRand(wx, wz, int(seed))
if rule != nil { if rule != nil {
applySurfaceRule(out, wx, wz, SeaLevel, MinY, biomeName, rule, rng, top) applySurfaceRule(od, out, wx, wz, SeaLevel, MinY, biomeName, rule, rng)
} else { } else {
fillLegacySurface(out, top, beach, deepWater, rng) fillLegacySurface(out, top, beach, deepWater, rng)
} }
@ -241,7 +241,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 // One *rand.Rand is created per column (not per block) — bandlands/gradient
// consume from it sequentially, which is correct because vanilla seeds those // consume from it sequentially, which is correct because vanilla seeds those
// per-column too. This avoids ~98k rand.New allocations per chunk. // per-column too. This avoids ~98k rand.New allocations per chunk.
func applySurfaceRule(out *[WorldHeight]uint16, wx, wz, seaLevel, minY int, biomeName string, rule worldgen.SurfaceRule, rng chunkRand, topSolid int) { func applySurfaceRule(od *worldgen.OverworldDensity, out *[WorldHeight]uint16, wx, wz, seaLevel, minY int, biomeName string, rule worldgen.SurfaceRule, rng chunkRand) {
top := -1 top := -1
for i := WorldHeight - 1; i >= 0; i-- { for i := WorldHeight - 1; i >= 0; i-- {
if out[i] != StateAir { if out[i] != StateAir {
@ -254,23 +254,23 @@ func applySurfaceRule(out *[WorldHeight]uint16, wx, wz, seaLevel, minY int, biom
} }
// One per-column RNG for all surface rules in this column. // One per-column RNG for all surface rules in this column.
colRng := rng.toRand() colRng := rng.toRand()
// Surface noise sample (the "minecraft:surface" noise used by noise_threshold // Column-constant surface quantities, computed once per column exactly as
// conditions). Cheap deterministic value derived from the column so the // SurfaceRules.Context.updateXZ does.
// rule's coarse_dirt/terracotta bands vary per column. surfaceDepth := od.Surface.SurfaceDepth(wx, wz)
surfaceNoise := colRng.Float64()*2 - 1 // [-1, 1]
// Reuse one context across the column (mutated per block) to avoid ~98k // Reuse one context across the column (mutated per block) to avoid ~98k
// heap allocations per chunk; the fields that vary per block are set inside // heap allocations per chunk; the fields that vary per block are set inside
// the loop, the rest are column-constant. // the loop, the rest are column-constant.
sctx := &worldgen.SurfaceContext{ sctx := &worldgen.SurfaceContext{
X: wx, X: wx,
Z: wz, Z: wz,
SeaLevel: seaLevel, SeaLevel: seaLevel,
BiomeName: biomeName, BiomeName: biomeName,
MinY: minY, MinY: minY,
SurfaceNoise: surfaceNoise, SurfaceNoise: od.Surface.Noise(wx, wz),
SurfaceDepth: 0, SurfaceSecondary: od.Surface.SurfaceSecondary(wx, wz),
PreliminarySurface: minY + topSolid, SurfaceDepth: surfaceDepth,
Rng: colRng, MinSurfaceLevel: od.MinSurfaceLevelAt(wx, wz, surfaceDepth),
Rng: colRng,
} }
stoneDepthAbove := 0 stoneDepthAbove := 0
waterHeight := worldgen.NoWaterAbove waterHeight := worldgen.NoWaterAbove

View file

@ -52,6 +52,10 @@ type OverworldDensity struct {
// AquiferRandom places the aquifer cell centres. // AquiferRandom places the aquifer cell centres.
AquiferRandom PositionalRandomFactory AquiferRandom PositionalRandomFactory
// Surface samples the noises SurfaceSystem reads per column, before the
// rule tree runs.
Surface *SurfaceSampler
prelim *levelCache prelim *levelCache
} }
@ -148,6 +152,22 @@ func LoadOverworldFinalDensity(seed int64) (*OverworldDensity, error) {
// Interpolated nodes are collected as the whole router is parsed, so the // Interpolated nodes are collected as the whole router is parsed, so the
// list has to be taken after the loop, not just after final_density. // list has to be taken after the loop, not just after final_density.
od.Interpolated = l.interpolated od.Interpolated = l.interpolated
// SurfaceSystem's own noises. They are not router keys: vanilla pulls them
// straight out of the noise registry when it builds the SurfaceSystem.
surfaceNoise, err := l.noiseField("minecraft:surface")
if err != nil {
return nil, fmt.Errorf("surface noise: %w", err)
}
secondaryNoise, err := l.noiseField("minecraft:surface_secondary")
if err != nil {
return nil, fmt.Errorf("surface_secondary noise: %w", err)
}
od.Surface = &SurfaceSampler{
surfaceNoise: surfaceNoise,
secondaryNoise: secondaryNoise,
positionalRand: l.rs.Positional(),
}
return od, nil return od, nil
} }

View file

@ -33,6 +33,11 @@ func (rs *RandomState) AquiferRandom() PositionalRandomFactory { return rs.aquif
// (RandomState.oreRandom). // (RandomState.oreRandom).
func (rs *RandomState) OreRandom() PositionalRandomFactory { return rs.ore } func (rs *RandomState) OreRandom() PositionalRandomFactory { return rs.ore }
// Positional returns the root positional factory (RandomState.random). It is
// what SurfaceSystem jitters the surface depth with and what the surface rules'
// vertical_gradient and clay bands derive their own factories from.
func (rs *RandomState) Positional() PositionalRandomFactory { return rs.factory }
// Noise returns the NormalNoise for the named noise parameters, seeded as // Noise returns the NormalNoise for the named noise parameters, seeded as
// NormalNoise.create(factory.fromHashOf(name), params) and cached. // NormalNoise.create(factory.fromHashOf(name), params) and cached.
func (rs *RandomState) Noise(name string, firstOctave int, amplitudes []float64) *NormalNoise { func (rs *RandomState) Noise(name string, firstOctave int, amplitudes []float64) *NormalNoise {

View file

@ -51,12 +51,18 @@ type SurfaceContext struct {
// Steep is true when the local slope exceeds the vanilla steep threshold // Steep is true when the local slope exceeds the vanilla steep threshold
// (~1.0 surface-depth delta between neighbours). // (~1.0 surface-depth delta between neighbours).
Steep bool Steep bool
// SurfaceDepth is the vanilla surface-depth value at this column (a small // SurfaceDepth is how thick the biome's surface layers are at this column
// noise-driven integer 0..N) added to stone depth comparisons. // (SurfaceSystem.getSurfaceDepth): usually 3, sometimes 0 or less, which is
// what "hole" tests for. It widens the stone_depth bands.
SurfaceDepth int SurfaceDepth int
// PreliminarySurface is the top solid Y in this column; the // SurfaceSecondary is the "minecraft:surface_secondary" noise at this
// above_preliminary_surface condition passes for blocks above it. // column, which widens a stone_depth band further when the rule sets
PreliminarySurface int // secondary_depth_range.
SurfaceSecondary float64
// MinSurfaceLevel is the lowest Y the biome surface subtree may reach:
// 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 vertical_gradient and // Rng is a per-column deterministic source for vertical_gradient and
// bandlands. It is seeded by the column so results are stable across runs. // bandlands. It is seeded by the column so results are stable across runs.
Rng *rand.Rand Rng *rand.Rand
@ -253,11 +259,11 @@ func (t stoneDepthTest) Test(ctx *SurfaceContext) bool {
if t.addSurfaceDepth { if t.addSurfaceDepth {
surfaceDepth = ctx.SurfaceDepth surfaceDepth = ctx.SurfaceDepth
} }
// Vanilla widens the band by map(surface_secondary noise, -1..1, 0..range). secondary := 0
// That noise is not sampled yet, so the secondary term stays 0; the two if t.secondaryRange != 0 {
// rules that use it also set add_surface_depth, and both currently reduce to secondary = int(mapRange(ctx.SurfaceSecondary, -1.0, 1.0, 0.0, float64(t.secondaryRange)))
// the same single-block band either way. }
return depth <= 1+t.offset+surfaceDepth return depth <= 1+t.offset+surfaceDepth+secondary
} }
// noiseThresholdTest passes when the named surface noise is within [min,max]. // noiseThresholdTest passes when the named surface noise is within [min,max].
@ -309,13 +315,12 @@ func (t verticalGradientTest) Test(ctx *SurfaceContext) bool {
return ctx.Rng.Float64() > float64(pos)/float64(band) return ctx.Rng.Float64() > float64(pos)/float64(band)
} }
// abovePreliminarySurfaceTest passes for blocks at or above the column's // abovePreliminarySurfaceTest gates the whole biome surface subtree: below the
// preliminary surface (the top solid Y). Vanilla gates the biome dispatch on // column's minimum surface level nothing is dressed and the stone stays stone.
// this so submerged blocks far below the surface keep stone.
type abovePreliminarySurfaceTest struct{} type abovePreliminarySurfaceTest struct{}
func (abovePreliminarySurfaceTest) Test(ctx *SurfaceContext) bool { func (abovePreliminarySurfaceTest) Test(ctx *SurfaceContext) bool {
return ctx.Y >= ctx.PreliminarySurface return ctx.Y >= ctx.MinSurfaceLevel
} }
// ---- Parser ------------------------------------------------------------ // ---- Parser ------------------------------------------------------------

View file

@ -0,0 +1,61 @@
package worldgen
import "math"
// surface_system.go holds the per-column quantities SurfaceSystem computes
// before the rule tree runs: the surface depth (how thick the biome's surface
// layers are here) and the minimum surface level (how far down the biome
// subtree is allowed to reach at all).
//
// Both were stubbed out — surface depth at a constant 0, minimum surface level
// at the actual top block — and between them they collapsed every land column
// to a single block of grass sitting straight on stone.
// SurfaceSampler is the noise half of SurfaceSystem.
type SurfaceSampler struct {
surfaceNoise *NormalNoise
secondaryNoise *NormalNoise
positionalRand PositionalRandomFactory
}
// SurfaceDepth is SurfaceSystem.getSurfaceDepth: roughly three blocks, varied
// by the surface noise and jittered by a per-column draw. It can come out zero
// or negative, which is exactly what the "hole" condition looks for.
func (s *SurfaceSampler) SurfaceDepth(blockX, blockZ int) int {
noiseValue := s.surfaceNoise.GetValue(float64(blockX), 0, float64(blockZ))
jitter := s.positionalRand.At(blockX, 0, blockZ).NextDouble() * 0.25
return int(noiseValue*2.75 + 3.0 + jitter)
}
// SurfaceSecondary is SurfaceSystem.getSurfaceSecondary, the noise that widens
// a stone_depth band when the rule sets secondary_depth_range.
func (s *SurfaceSampler) SurfaceSecondary(blockX, blockZ int) float64 {
return s.secondaryNoise.GetValue(float64(blockX), 0, float64(blockZ))
}
// Noise returns the primary surface noise value at a column, which the
// noise_threshold conditions on "minecraft:surface" range over.
func (s *SurfaceSampler) Noise(blockX, blockZ int) float64 {
return s.surfaceNoise.GetValue(float64(blockX), 0, float64(blockZ))
}
// MinSurfaceLevelAt is SurfaceRules.Context.getMinSurfaceLevel: the preliminary
// surface level sampled at the four corners of the 16-block cell containing the
// column, bilinearly interpolated, then offset by the surface depth less 8.
//
// Every biome-specific surface rule hangs under above_preliminary_surface,
// which tests blockY against this. Comparing against the column's actual top
// block instead — what we did before — let exactly one block per column through.
func (od *OverworldDensity) MinSurfaceLevelAt(blockX, blockZ, surfaceDepth int) int {
cellX := blockX >> 4
cellZ := blockZ >> 4
c00 := float64(od.PreliminarySurfaceLevelAt(cellX<<4, cellZ<<4))
c10 := float64(od.PreliminarySurfaceLevelAt((cellX+1)<<4, cellZ<<4))
c01 := float64(od.PreliminarySurfaceLevelAt(cellX<<4, (cellZ+1)<<4))
c11 := float64(od.PreliminarySurfaceLevelAt((cellX+1)<<4, (cellZ+1)<<4))
// Vanilla forms the fractions in float before widening; both are exact here
// because the divisor is a power of two.
fx := float64(float32(blockX&15) / 16)
fz := float64(float32(blockZ&15) / 16)
return int(math.Floor(lerp2(fx, fz, c00, c10, c01, c11))) + surfaceDepth - 8
}

View file

@ -37,7 +37,7 @@ func TestSurfaceRuleNoPanic(t *testing.T) {
X: 100, Y: y, Z: 100, X: 100, Y: y, Z: 100,
StoneDepthAbove: 100 - y, StoneDepthBelow: y + 1, StoneDepthAbove: 100 - y, StoneDepthBelow: y + 1,
SeaLevel: 63, BiomeName: b, MinY: -64, SeaLevel: 63, BiomeName: b, MinY: -64,
PreliminarySurface: 100, WaterHeight: NoWaterAbove, MinSurfaceLevel: 80, WaterHeight: NoWaterAbove,
Rng: rand.New(rand.NewSource(1)), Rng: rand.New(rand.NewSource(1)),
} }
rule.Apply(ctx) // must not panic rule.Apply(ctx) // must not panic
@ -55,7 +55,7 @@ func TestSurfaceBedrockFloor(t *testing.T) {
ctx := &SurfaceContext{ ctx := &SurfaceContext{
X: 0, Y: -64, Z: 0, StoneDepthAbove: 1, StoneDepthBelow: 1, X: 0, Y: -64, Z: 0, StoneDepthAbove: 1, StoneDepthBelow: 1,
SeaLevel: 63, BiomeName: "minecraft:plains", MinY: -64, SeaLevel: 63, BiomeName: "minecraft:plains", MinY: -64,
PreliminarySurface: 70, WaterHeight: NoWaterAbove, MinSurfaceLevel: 62, WaterHeight: NoWaterAbove,
Rng: rand.New(rand.NewSource(1)), Rng: rand.New(rand.NewSource(1)),
} }
state, ok := rule.Apply(ctx) state, ok := rule.Apply(ctx)