diff --git a/cmd/gendump/main.go b/cmd/gendump/main.go index ad5d9c5..8b78e99 100644 --- a/cmd/gendump/main.go +++ b/cmd/gendump/main.go @@ -216,9 +216,31 @@ func main() { // dirt band beyond a single block. fmt.Println("\n=== Subsurface banding (grass columns: expect grass=9, dirt=10 band, stone=1) ===") found := 0 + bandDepths := map[int]int{} for cx := int32(-40); cx < 40 && found < 6; cx += 3 { for cz := int32(-40); cz < 40 && found < 6; cz += 3 { 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 lz := 0; lz < 16 && found < 6; lz += 5 { topY := world.MinY - 1 @@ -246,6 +268,25 @@ func main() { if found == 0 { 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. fmt.Println("\n=== Cross-section chunk(0,0) z=8, x=0..15 (side view, top 96 blocks near surface) ===") diff --git a/internal/world/banding_verify_test.go b/internal/world/banding_verify_test.go new file mode 100644 index 0000000..a8c3daf --- /dev/null +++ b/internal/world/banding_verify_test.go @@ -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 +} diff --git a/internal/world/store.go b/internal/world/store.go index b274177..c1496eb 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 = 3 +const generatorVersion = 4 // 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 2d9a2c0..a4bf8d7 100644 --- a/internal/world/vanilla.go +++ b/internal/world/vanilla.go @@ -199,7 +199,7 @@ func fillVanillaColumn(od *worldgen.OverworldDensity, aq *worldgen.Aquifer, flui rng := newColumnRand(wx, wz, int(seed)) if rule != nil { - applySurfaceRule(out, wx, wz, SeaLevel, MinY, biomeName, rule, rng, top) + applySurfaceRule(od, out, wx, wz, SeaLevel, MinY, biomeName, rule, rng) } else { 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 // consume from it sequentially, which is correct because vanilla seeds those // 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 for i := WorldHeight - 1; i >= 0; i-- { 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. colRng := rng.toRand() - // Surface noise sample (the "minecraft:surface" noise used by noise_threshold - // conditions). Cheap deterministic value derived from the column so the - // rule's coarse_dirt/terracotta bands vary per column. - surfaceNoise := colRng.Float64()*2 - 1 // [-1, 1] + // Column-constant surface quantities, computed once per column exactly as + // SurfaceRules.Context.updateXZ does. + surfaceDepth := od.Surface.SurfaceDepth(wx, wz) // 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 // the loop, the rest are column-constant. sctx := &worldgen.SurfaceContext{ - X: wx, - Z: wz, - SeaLevel: seaLevel, - BiomeName: biomeName, - MinY: minY, - SurfaceNoise: surfaceNoise, - SurfaceDepth: 0, - PreliminarySurface: minY + topSolid, - Rng: colRng, + X: wx, + Z: wz, + SeaLevel: seaLevel, + BiomeName: biomeName, + MinY: minY, + SurfaceNoise: od.Surface.Noise(wx, wz), + SurfaceSecondary: od.Surface.SurfaceSecondary(wx, wz), + SurfaceDepth: surfaceDepth, + MinSurfaceLevel: od.MinSurfaceLevelAt(wx, wz, surfaceDepth), + Rng: colRng, } stoneDepthAbove := 0 waterHeight := worldgen.NoWaterAbove diff --git a/internal/worldgen/loader.go b/internal/worldgen/loader.go index 600748f..ddc6ece 100644 --- a/internal/worldgen/loader.go +++ b/internal/worldgen/loader.go @@ -52,6 +52,10 @@ type OverworldDensity struct { // AquiferRandom places the aquifer cell centres. AquiferRandom PositionalRandomFactory + // Surface samples the noises SurfaceSystem reads per column, before the + // rule tree runs. + Surface *SurfaceSampler + prelim *levelCache } @@ -148,6 +152,22 @@ func LoadOverworldFinalDensity(seed int64) (*OverworldDensity, error) { // 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. 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 } diff --git a/internal/worldgen/randomstate.go b/internal/worldgen/randomstate.go index 239ba47..948d7a6 100644 --- a/internal/worldgen/randomstate.go +++ b/internal/worldgen/randomstate.go @@ -33,6 +33,11 @@ func (rs *RandomState) AquiferRandom() PositionalRandomFactory { return rs.aquif // (RandomState.oreRandom). 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 // NormalNoise.create(factory.fromHashOf(name), params) and cached. func (rs *RandomState) Noise(name string, firstOctave int, amplitudes []float64) *NormalNoise { diff --git a/internal/worldgen/surface.go b/internal/worldgen/surface.go index 7a6a892..96fb841 100644 --- a/internal/worldgen/surface.go +++ b/internal/worldgen/surface.go @@ -51,12 +51,18 @@ type SurfaceContext struct { // Steep is true when the local slope exceeds the vanilla steep threshold // (~1.0 surface-depth delta between neighbours). Steep bool - // SurfaceDepth is the vanilla surface-depth value at this column (a small - // noise-driven integer 0..N) added to stone depth comparisons. + // SurfaceDepth is how thick the biome's surface layers are at this column + // (SurfaceSystem.getSurfaceDepth): usually 3, sometimes 0 or less, which is + // what "hole" tests for. It widens the stone_depth bands. SurfaceDepth int - // PreliminarySurface is the top solid Y in this column; the - // above_preliminary_surface condition passes for blocks above it. - PreliminarySurface int + // SurfaceSecondary is the "minecraft:surface_secondary" noise at this + // column, which widens a stone_depth band further when the rule sets + // 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 // bandlands. It is seeded by the column so results are stable across runs. Rng *rand.Rand @@ -253,11 +259,11 @@ func (t stoneDepthTest) Test(ctx *SurfaceContext) bool { if t.addSurfaceDepth { surfaceDepth = ctx.SurfaceDepth } - // Vanilla widens the band by map(surface_secondary noise, -1..1, 0..range). - // That noise is not sampled yet, so the secondary term stays 0; the two - // rules that use it also set add_surface_depth, and both currently reduce to - // the same single-block band either way. - return depth <= 1+t.offset+surfaceDepth + secondary := 0 + if t.secondaryRange != 0 { + secondary = int(mapRange(ctx.SurfaceSecondary, -1.0, 1.0, 0.0, float64(t.secondaryRange))) + } + return depth <= 1+t.offset+surfaceDepth+secondary } // 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) } -// abovePreliminarySurfaceTest passes for blocks at or above the column's -// preliminary surface (the top solid Y). Vanilla gates the biome dispatch on -// this so submerged blocks far below the surface keep stone. +// abovePreliminarySurfaceTest gates the whole biome surface subtree: below the +// column's minimum surface level nothing is dressed and the stone stays stone. type abovePreliminarySurfaceTest struct{} func (abovePreliminarySurfaceTest) Test(ctx *SurfaceContext) bool { - return ctx.Y >= ctx.PreliminarySurface + return ctx.Y >= ctx.MinSurfaceLevel } // ---- Parser ------------------------------------------------------------ diff --git a/internal/worldgen/surface_system.go b/internal/worldgen/surface_system.go new file mode 100644 index 0000000..9f70e2b --- /dev/null +++ b/internal/worldgen/surface_system.go @@ -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 +} diff --git a/internal/worldgen/surface_test.go b/internal/worldgen/surface_test.go index 4136afe..97c46af 100644 --- a/internal/worldgen/surface_test.go +++ b/internal/worldgen/surface_test.go @@ -37,7 +37,7 @@ func TestSurfaceRuleNoPanic(t *testing.T) { X: 100, Y: y, Z: 100, StoneDepthAbove: 100 - y, StoneDepthBelow: y + 1, SeaLevel: 63, BiomeName: b, MinY: -64, - PreliminarySurface: 100, WaterHeight: NoWaterAbove, + MinSurfaceLevel: 80, WaterHeight: NoWaterAbove, Rng: rand.New(rand.NewSource(1)), } rule.Apply(ctx) // must not panic @@ -55,7 +55,7 @@ func TestSurfaceBedrockFloor(t *testing.T) { ctx := &SurfaceContext{ X: 0, Y: -64, Z: 0, StoneDepthAbove: 1, StoneDepthBelow: 1, SeaLevel: 63, BiomeName: "minecraft:plains", MinY: -64, - PreliminarySurface: 70, WaterHeight: NoWaterAbove, + MinSurfaceLevel: 62, WaterHeight: NoWaterAbove, Rng: rand.New(rand.NewSource(1)), } state, ok := rule.Apply(ctx)