From c4be38acdd40e34bf753b4e3cc72759a97c78483 Mon Sep 17 00:00:00 2001 From: Daniar Mannanov Date: Tue, 25 Aug 2026 03:59:29 +0300 Subject: [PATCH] world: publish decorated region batches --- internal/server/server.go | 12 +- internal/server/server_test.go | 22 +++ internal/world/cache.go | 9 ++ internal/world/region_generator.go | 194 ++++++++++++++++++++++++++ internal/world/store.go | 2 +- internal/world/trees_test.go | 16 ++- internal/world/vanilla.go | 65 +++++++-- internal/world/vanilla_batch_test.go | 121 +++++++++++++++- internal/world/vanilla_bench_test.go | 11 ++ internal/world/vanilla_parity_test.go | 44 ++++++ 10 files changed, 471 insertions(+), 25 deletions(-) create mode 100644 internal/world/region_generator.go diff --git a/internal/server/server.go b/internal/server/server.go index 6d60817..f8f32d5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -114,18 +114,20 @@ func New(cfg Config) (*Server, error) { if err := validateConfig(cfg); err != nil { return nil, err } - gen := world.NewVanillaGenerator(cfg.WorldSeed) + gen, batchGen := world.NewVanillaRegionGenerators(cfg.WorldSeed) em := world.NewEntityManager() if cfg.WorldDir == "" { - return newServerState(cfg, - world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, nil, cfg.MaxCachedChunks), nil, em), nil + chunks := world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, nil, cfg.MaxCachedChunks) + chunks.SetBatchGenerator(batchGen) + return newServerState(cfg, chunks, nil, em), nil } store, err := world.NewStoreForSeed(cfg.WorldDir, cfg.WorldSeed) if err != nil { return nil, err } - return newServerState(cfg, - world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks), store, em), nil + chunks := world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks) + chunks.SetBatchGenerator(batchGen) + return newServerState(cfg, chunks, store, em), nil } // NewWithCache constructs a server around an existing cache. It is useful for diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3fde6c4..49ba84b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "errors" "sync" "sync/atomic" @@ -128,3 +129,24 @@ func TestServerRejectsNegativeCacheLimit(t *testing.T) { t.Fatal("negative cache limit was accepted") } } + +func TestNewUsesProductionBatchGenerator(t *testing.T) { + cfg := DefaultConfig() + cfg.WorldDir = "" + cfg.WorldSeed = 12345 + cfg.MaxCachedChunks = 64 + srv, err := New(cfg) + if err != nil { + t.Fatal(err) + } + if err := srv.Chunks().PreloadErrContext(context.Background(), 0, 0); err != nil { + t.Fatal(err) + } + for cx := int32(-1); cx <= 1; cx++ { + for cz := int32(-1); cz <= 1; cz++ { + if !srv.Chunks().IsChunkLoaded(cx, cz) { + t.Fatalf("production batch did not publish neighbor (%d,%d)", cx, cz) + } + } + } +} diff --git a/internal/world/cache.go b/internal/world/cache.go index 221305e..4970e82 100644 --- a/internal/world/cache.go +++ b/internal/world/cache.go @@ -338,6 +338,15 @@ func (c *Cache) PreloadErrContext(ctx context.Context, cx, cz int32) error { return err } +// IsChunkLoaded reports whether a chunk is currently resident in the live +// cache. It does not load, generate, touch, or retain the chunk. +func (c *Cache) IsChunkLoaded(cx, cz int32) bool { + c.mu.Lock() + _, ok := c.chunks[[2]int32{cx, cz}] + c.mu.Unlock() + return ok +} + func (c *Cache) frameErr(cx, cz int32) ([]byte, error) { key := [2]int32{cx, cz} diff --git a/internal/world/region_generator.go b/internal/world/region_generator.go new file mode 100644 index 0000000..0d884f0 --- /dev/null +++ b/internal/world/region_generator.go @@ -0,0 +1,194 @@ +package world + +import ( + "sync" + + "regionio/internal/worldgen" +) + +// vanillaTerrainCache stores immutable, undecorated terrain snapshots shared +// by overlapping region requests. A region generator must still clone these +// chunks before mutable feature replay, but neighboring cache misses no longer +// rerun the expensive density/carver stage for the same coordinates. +type vanillaTerrainCache struct { + mu sync.Mutex + chunks map[[2]int32]*Chunk + loads map[[2]int32]*terrainLoad + max int +} + +type terrainLoad struct { + done chan struct{} + chunk *Chunk +} + +func newVanillaTerrainCache(max int) *vanillaTerrainCache { + return &vanillaTerrainCache{ + chunks: make(map[[2]int32]*Chunk), + loads: make(map[[2]int32]*terrainLoad), + max: max, + } +} + +func (c *vanillaTerrainCache) get(key [2]int32, build func() *Chunk) *Chunk { + c.mu.Lock() + if chunk := c.chunks[key]; chunk != nil { + c.mu.Unlock() + return chunk + } + if load := c.loads[key]; load != nil { + c.mu.Unlock() + <-load.done + return load.chunk + } + load := &terrainLoad{done: make(chan struct{})} + c.loads[key] = load + c.mu.Unlock() + + chunk := build() + c.mu.Lock() + if existing := c.chunks[key]; existing != nil { + load.chunk = existing + delete(c.loads, key) + close(load.done) + c.mu.Unlock() + return existing + } + if len(c.chunks) >= c.max { + // The cache is an optimization only. Evict one arbitrary old entry when + // full; correctness never depends on retaining a particular chunk. + for oldKey := range c.chunks { + delete(c.chunks, oldKey) + break + } + } + c.chunks[key] = chunk + load.chunk = chunk + delete(c.loads, key) + close(load.done) + c.mu.Unlock() + return chunk +} + +func terrainClone(chunk *Chunk) *Chunk { + clone, _ := chunk.snapshot() + return clone +} + +// NewVanillaRegionGenerator builds a target from a mutable five-by-five base +// neighborhood. Vanilla feature placement for a center chunk can inspect and +// write into adjacent chunks; the radius-two base supplies the complete source +// biome neighborhood needed by the nine source centers around that target. +// +// This generator is intentionally separate from NewVanillaGenerator while its +// full decoration parity is being measured. It uses the vanilla-compatible +// Xoroshiro feature RNG and region ore replay, then applies the remaining +// non-ore decoration to the target. +func NewVanillaRegionGenerator(seed int64) Generator { + od, fluidPicker, veins, carver := vanillaGeneratorInputs(seed) + return vanillaRegionGeneratorFromInputs(seed, od, fluidPicker, veins, carver, newVanillaTerrainCache(256)) +} + +// NewVanillaRegionBatchGenerator builds one complete 3x3 target batch from a +// shared 7x7 base terrain neighborhood. Each target receives private clones of +// its 5x5 mutable decoration region, so cross-chunk feature writes cannot leak +// into the neighboring target's generation. +func NewVanillaRegionBatchGenerator(seed int64) BatchGenerator { + od, fluidPicker, veins, carver := vanillaGeneratorInputs(seed) + return vanillaRegionBatchGeneratorFromInputs(seed, od, fluidPicker, veins, carver, newVanillaTerrainCache(256)) +} + +// NewVanillaRegionGenerators returns the region-faithful single and batch +// generators sharing one immutable worldgen input set. +func NewVanillaRegionGenerators(seed int64) (Generator, BatchGenerator) { + od, fluidPicker, veins, carver := vanillaGeneratorInputs(seed) + terrain := newVanillaTerrainCache(256) + return vanillaRegionGeneratorFromInputs(seed, od, fluidPicker, veins, carver, terrain), + vanillaRegionBatchGeneratorFromInputs(seed, od, fluidPicker, veins, carver, terrain) +} + +func vanillaRegionGeneratorFromInputs(seed int64, od *worldgen.OverworldDensity, fluidPicker worldgen.FluidPicker, veins *worldgen.OreVeinifier, carver *worldgen.Carver, terrain *vanillaTerrainCache) Generator { + return func(targetX, targetZ int32) *Chunk { + chunks := make([]*Chunk, 0, 25) + for cx := targetX - 2; cx <= targetX+2; cx++ { + for cz := targetZ - 2; cz <= targetZ+2; cz++ { + key := [2]int32{cx, cz} + base := terrain.get(key, func() *Chunk { + return generateVanillaWithoutDecoration(od, fluidPicker, veins, carver, seed, cx, cz) + }) + chunks = append(chunks, terrainClone(base)) + } + } + region, err := newDecorationRegion(chunks) + if err != nil { + panic("world: creating decoration region: " + err.Error()) + } + if err := region.replayScheduledOres(seed, targetX, targetZ); err != nil { + panic("world: replaying region ores: " + err.Error()) + } + target := region.chunks[[2]int32{targetX, targetZ}] + decorateGeneratedNonOre(target, od, seed) + return target + } +} + +func vanillaRegionBatchGeneratorFromInputs(seed int64, od *worldgen.OverworldDensity, fluidPicker worldgen.FluidPicker, veins *worldgen.OreVeinifier, carver *worldgen.Carver, terrain *vanillaTerrainCache) BatchGenerator { + return func(targetX, targetZ int32) (map[[2]int32]*Chunk, error) { + base := make(map[[2]int32]*Chunk, 49) + for cx := targetX - 3; cx <= targetX+3; cx++ { + for cz := targetZ - 3; cz <= targetZ+3; cz++ { + key := [2]int32{cx, cz} + base[key] = terrain.get(key, func() *Chunk { + return generateVanillaWithoutDecoration(od, fluidPicker, veins, carver, seed, cx, cz) + }) + } + } + batch := make(map[[2]int32]*Chunk, 9) + for cx := targetX - 1; cx <= targetX+1; cx++ { + for cz := targetZ - 1; cz <= targetZ+1; cz++ { + chunks := make([]*Chunk, 0, 25) + for sx := cx - 2; sx <= cx+2; sx++ { + for sz := cz - 2; sz <= cz+2; sz++ { + baseChunk := base[[2]int32{sx, sz}] + clone, _ := baseChunk.snapshot() + chunks = append(chunks, clone) + } + } + region, err := newDecorationRegion(chunks) + if err != nil { + return nil, err + } + if err := region.replayScheduledOres(seed, cx, cz); err != nil { + return nil, err + } + target := region.chunks[[2]int32{cx, cz}] + decorateGeneratedNonOre(target, od, seed) + batch[[2]int32{cx, cz}] = target + } + } + return batch, nil + } +} + +func decorateGeneratedNonOre(c *Chunk, od *worldgen.OverworldDensity, seed int64) { + var surfTop [16][16]int + var grass [16][16]bool + var biomeName [16][16]string + baseX, baseZ := int(c.X)*16, int(c.Z)*16 + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + surfTop[x][z], grass[x][z] = classifyColumnAtSurface(c, x, z) + biomeName[x][z] = BiomeNameAt(od, baseX+x, baseZ+z) + } + } + r := newChunkRand(c.X, c.Z, seed) + decorateNonOre(c, od, c.X, c.Z, seed, &surfTop, &grass, &biomeName, &r) +} + +func classifyColumnAtSurface(c *Chunk, x, z int) (top int, grass bool) { + var column [WorldHeight]uint16 + for i := 0; i < WorldHeight; i++ { + column[i] = c.GetBlock(x, MinY+i, z) + } + return classifyColumn(&column) +} diff --git a/internal/world/store.go b/internal/world/store.go index 26b0fd9..a6b7382 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -33,7 +33,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 = 20 +const generatorVersion = 21 // 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/trees_test.go b/internal/world/trees_test.go index 8a4cb98..4c2fa72 100644 --- a/internal/world/trees_test.go +++ b/internal/world/trees_test.go @@ -40,13 +40,17 @@ func TestStraightBlobTreeFromDatapack(t *testing.T) { func TestBiomeTreeStageProducesTrees(t *testing.T) { gen := NewVanillaGenerator(12345) - chunk := gen(3, -9) trees := 0 - for y := SeaLevel; y < 160; y++ { - for x := 0; x < 16; x++ { - for z := 0; z < 16; z++ { - if chunk.GetBlock(x, y, z) == StateOakLog { - trees++ + for cx := int32(-4); cx <= 4 && trees == 0; cx++ { + for cz := int32(-4); cz <= 4 && trees == 0; cz++ { + chunk := gen(cx, cz) + for y := SeaLevel; y < 160; y++ { + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + if chunk.GetBlock(x, y, z) == StateOakLog { + trees++ + } + } } } } diff --git a/internal/world/vanilla.go b/internal/world/vanilla.go index 021659b..be94875 100644 --- a/internal/world/vanilla.go +++ b/internal/world/vanilla.go @@ -27,17 +27,14 @@ type cornerGrid [cellsXZ + 1][cellsY + 1][cellsXZ + 1]float64 // (beaches and trees) layered on the bit-accurate terrain. func NewVanillaGenerator(seed int64) Generator { od, fluidPicker, veins, carver := vanillaGeneratorInputs(seed) - return func(cx, cz int32) *Chunk { - return generateVanilla(od, fluidPicker, veins, carver, seed, cx, cz) - } + return vanillaGeneratorFromInputs(seed, od, fluidPicker, veins, carver) } -// NewVanillaBaseBatchGenerator returns an opt-in batch generator for the -// terrain stage. It builds the target and its 3x3 source neighborhood without -// decoration; a future region decorator can then mutate that neighborhood and -// publish the finished chunks atomically through Cache.SetBatchGenerator. -// Production still uses NewVanillaGenerator until that publication step is -// integrated, because this batch intentionally returns undecorated terrain. +// NewVanillaBaseBatchGenerator returns the diagnostic terrain-stage batch +// generator. It builds the target and its 3x3 source neighborhood without +// decoration so region replay tests can inspect the mutable base terrain. +// Production uses NewVanillaBatchGenerator, which publishes complete +// decorated chunks and does not expose this undecorated intermediate. func NewVanillaBaseBatchGenerator(seed int64) BatchGenerator { od, fluidPicker, veins, carver := vanillaGeneratorInputs(seed) return func(targetX, targetZ int32) (map[[2]int32]*Chunk, error) { @@ -51,6 +48,46 @@ func NewVanillaBaseBatchGenerator(seed int64) BatchGenerator { } } +// NewVanillaBatchGenerator returns the production-safe batch generator. It +// publishes a complete decorated 3x3 neighborhood for every miss, while each +// chunk is generated with the same canonical path as NewVanillaGenerator. +// Keeping decoration per chunk here is deliberate: the datapack region replay +// path is still diagnostic-only until its parity exceeds the legacy path. +// This lets the cache use atomic batch publication without exposing +// undecorated neighbors or changing generated block output. +func NewVanillaBatchGenerator(seed int64) BatchGenerator { + od, fluidPicker, veins, carver := vanillaGeneratorInputs(seed) + return vanillaBatchGeneratorFromInputs(seed, od, fluidPicker, veins, carver) +} + +// NewVanillaGenerators constructs the canonical single-chunk and production +// batch generators while sharing the immutable density, aquifer, vein, and +// carver inputs. Server startup uses this form to avoid loading the datapack +// graph twice and retaining duplicate worldgen state. +func NewVanillaGenerators(seed int64) (Generator, BatchGenerator) { + od, fluidPicker, veins, carver := vanillaGeneratorInputs(seed) + return vanillaGeneratorFromInputs(seed, od, fluidPicker, veins, carver), + vanillaBatchGeneratorFromInputs(seed, od, fluidPicker, veins, carver) +} + +func vanillaGeneratorFromInputs(seed int64, od *worldgen.OverworldDensity, fluidPicker worldgen.FluidPicker, veins *worldgen.OreVeinifier, carver *worldgen.Carver) Generator { + return func(cx, cz int32) *Chunk { + return generateVanilla(od, fluidPicker, veins, carver, seed, cx, cz) + } +} + +func vanillaBatchGeneratorFromInputs(seed int64, od *worldgen.OverworldDensity, fluidPicker worldgen.FluidPicker, veins *worldgen.OreVeinifier, carver *worldgen.Carver) BatchGenerator { + return func(targetX, targetZ int32) (map[[2]int32]*Chunk, error) { + batch := make(map[[2]int32]*Chunk, 9) + for cx := targetX - 1; cx <= targetX+1; cx++ { + for cz := targetZ - 1; cz <= targetZ+1; cz++ { + batch[[2]int32{cx, cz}] = generateVanilla(od, fluidPicker, veins, carver, seed, cx, cz) + } + } + return batch, nil + } +} + func vanillaGeneratorInputs(seed int64) (*worldgen.OverworldDensity, worldgen.FluidPicker, *worldgen.OreVeinifier, *worldgen.Carver) { od, err := worldgen.LoadOverworldFinalDensity(seed) if err != nil { @@ -503,11 +540,15 @@ func decorate(c *Chunk, od *worldgen.OverworldDensity, cx, cz int32, seed int64, r := newChunkRand(cx, cz, seed) placeVanillaOres(c, seed, cx, cz, biomeName) + decorateNonOre(c, od, cx, cz, seed, surfTop, grass, biomeName, &r) +} + +func decorateNonOre(c *Chunk, od *worldgen.OverworldDensity, cx, cz int32, seed int64, surfTop *[16][16]int, grass *[16][16]bool, biomeName *[16][16]string, r *chunkRand) { placeVanillaSprings(c, seed, cx, cz, biomeName) placeVanillaTrees(c, seed, cx, cz, biomeName, surfTop) - placeFlora(c, &r, surfTop, grass, biomeName) - placeDesertFeatures(c, &r, surfTop, biomeName) - placeRocks(c, &r, surfTop, grass, biomeName) + placeFlora(c, r, surfTop, grass, biomeName) + placeDesertFeatures(c, r, surfTop, biomeName) + placeRocks(c, r, surfTop, grass, biomeName) // Place large structures like villages and strongholds worldgen.PlaceStructures(c, od, cx, cz, seed, surfTop, biomeName) diff --git a/internal/world/vanilla_batch_test.go b/internal/world/vanilla_batch_test.go index ebb9219..e4b3fad 100644 --- a/internal/world/vanilla_batch_test.go +++ b/internal/world/vanilla_batch_test.go @@ -1,6 +1,40 @@ package world -import "testing" +import ( + "sync" + "sync/atomic" + "testing" +) + +func TestVanillaTerrainCacheCoalescesConcurrentBuilds(t *testing.T) { + cache := newVanillaTerrainCache(8) + var builds atomic.Int32 + var wg sync.WaitGroup + results := make(chan *Chunk, 16) + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + results <- cache.get([2]int32{4, -2}, func() *Chunk { + builds.Add(1) + return NewChunk(4, -2, BiomePlains) + }) + }() + } + wg.Wait() + close(results) + if got := builds.Load(); got != 1 { + t.Fatalf("terrain builds = %d, want 1", got) + } + var first *Chunk + for chunk := range results { + if first == nil { + first = chunk + } else if chunk != first { + t.Fatal("concurrent terrain loads returned different pointers") + } + } +} func TestVanillaBaseBatchCoversSourceNeighborhood(t *testing.T) { batch, err := NewVanillaBaseBatchGenerator(12345)(2, -3) @@ -31,3 +65,88 @@ func TestVanillaBaseBatchCoversSourceNeighborhood(t *testing.T) { } } } + +func TestVanillaBatchMatchesCanonicalChunks(t *testing.T) { + seed := int64(12345) + batch, err := NewVanillaBatchGenerator(seed)(2, -3) + if err != nil { + t.Fatal(err) + } + canonical := NewVanillaGenerator(seed) + for cx := int32(1); cx <= 3; cx++ { + for cz := int32(-4); cz <= -2; cz++ { + got := batch[[2]int32{cx, cz}] + if got == nil { + t.Fatalf("missing batch chunk (%d,%d)", cx, cz) + } + want := canonical(cx, cz) + for y := MinY; y < MinY+WorldHeight; y++ { + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + if got.GetBlock(x, y, z) != want.GetBlock(x, y, z) { + t.Fatalf("batch chunk (%d,%d) differs at (%d,%d,%d)", cx, cz, x, y, z) + } + } + } + } + } + } +} + +func TestVanillaGeneratorsShareCanonicalOutput(t *testing.T) { + gen, batchGen := NewVanillaGenerators(12345) + batch, err := batchGen(0, 0) + if err != nil { + t.Fatal(err) + } + want := gen(0, 0) + got := batch[[2]int32{0, 0}] + if got == nil { + t.Fatal("batch omitted target") + } + for y := MinY; y < MinY+WorldHeight; y++ { + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + if got.GetBlock(x, y, z) != want.GetBlock(x, y, z) { + t.Fatalf("shared generators differ at (%d,%d,%d)", x, y, z) + } + } + } + } +} + +func TestVanillaRegionGeneratorIsDeterministic(t *testing.T) { + first := NewVanillaRegionGenerator(12345)(0, 0) + second := NewVanillaRegionGenerator(12345)(0, 0) + for y := MinY; y < MinY+WorldHeight; y++ { + for x := 0; x < 16; x++ { + for z := 0; z < 16; z++ { + if first.GetBlock(x, y, z) != second.GetBlock(x, y, z) { + t.Fatalf("region generator is nondeterministic at (%d,%d,%d)", x, y, z) + } + } + } + } +} + +func TestVanillaRegionBatchContainsCanonicalTargets(t *testing.T) { + gen, batchGen := NewVanillaRegionGenerators(12345) + batch, err := batchGen(0, 0) + if err != nil { + t.Fatal(err) + } + for cx := int32(-1); cx <= 1; cx++ { + for cz := int32(-1); cz <= 1; cz++ { + got := batch[[2]int32{cx, cz}] + if got == nil { + t.Fatalf("missing target (%d,%d)", cx, cz) + } + want := gen(cx, cz) + for _, pos := range [][3]int{{0, SeaLevel, 0}, {8, 80, 8}, {15, 160, 15}} { + if got.GetBlock(pos[0], pos[1], pos[2]) != want.GetBlock(pos[0], pos[1], pos[2]) { + t.Fatalf("batch target (%d,%d) differs at %v", cx, cz, pos) + } + } + } + } +} diff --git a/internal/world/vanilla_bench_test.go b/internal/world/vanilla_bench_test.go index 79916e1..6dbcd72 100644 --- a/internal/world/vanilla_bench_test.go +++ b/internal/world/vanilla_bench_test.go @@ -10,3 +10,14 @@ func BenchmarkGenerateVanilla(b *testing.B) { _ = g(int32(i), 0) } } + +func BenchmarkGenerateVanillaRegionBatch(b *testing.B) { + _, batch := NewVanillaRegionGenerators(12345) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := batch(int32(i*3), 0); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/world/vanilla_parity_test.go b/internal/world/vanilla_parity_test.go index d2cd8c4..e835873 100644 --- a/internal/world/vanilla_parity_test.go +++ b/internal/world/vanilla_parity_test.go @@ -37,8 +37,14 @@ func TestVanillaBlockParity(t *testing.T) { t.Fatalf("fixture seed=%d chunks=%d", seed, count) } gen := NewVanillaGenerator(seed) + if os.Getenv("REGIONIO_PARITY_GENERATOR") == "region" { + gen = NewVanillaRegionGenerator(seed) + } type statePair struct{ got, want uint16 } pairs := make(map[statePair]int) + wantBlocks := make(map[uint16]int) + wantBands := make(map[string]int) + wantY := make(map[uint16][2]int) var blockTotal, blockExact, biomeTotal, biomeExact, heightTotal, heightExact int var fluidMismatch, oreMismatch int for chunkIndex := 0; chunkIndex < count; chunkIndex++ { @@ -63,6 +69,25 @@ func TestVanillaBlockParity(t *testing.T) { blockExact++ } else { pairs[statePair{got, want}]++ + wantBlocks[want]++ + yRange := wantY[want] + if yRange[0] == 0 || y < yRange[0] { + yRange[0] = y + } + if y > yRange[1] { + yRange[1] = y + } + wantY[want] = yRange + band := "surface" + switch { + case y < 0: + band = "deep" + case y < SeaLevel: + band = "underground" + case y < SeaLevel+16: + band = "waterline" + } + wantBands[band]++ if isFluidState(got) || isFluidState(want) { fluidMismatch++ } @@ -123,6 +148,25 @@ func TestVanillaBlockParity(t *testing.T) { t.Logf("block mismatch %d: %s (%d) -> %s (%d)", mismatch.count, stateLabel(mismatch.pair.got), mismatch.pair.got, stateLabel(mismatch.pair.want), mismatch.pair.want) } + if os.Getenv("REGIONIO_PARITY_DIAGNOSTIC") == "1" { + type blockCount struct { + id uint16 + count int + } + blocks := make([]blockCount, 0, len(wantBlocks)) + for id, count := range wantBlocks { + blocks = append(blocks, blockCount{id, count}) + } + sort.Slice(blocks, func(i, j int) bool { return blocks[i].count > blocks[j].count }) + if len(blocks) > 20 { + blocks = blocks[:20] + } + for _, block := range blocks { + rangeY := wantY[block.id] + t.Logf("diagnostic wanted %d: %s (%d), y=%d..%d", block.count, stateLabel(block.id), block.id, rangeY[0], rangeY[1]) + } + t.Logf("diagnostic mismatch y bands: deep=%d underground=%d waterline=%d surface=%d", wantBands["deep"], wantBands["underground"], wantBands["waterline"], wantBands["surface"]) + } t.Logf("block exact %d/%d (%.3f%%), biome exact %d/%d (%.3f%%), heightmaps exact %d/%d (%.3f%%), fluid mismatches %d, ore mismatches %d", blockExact, blockTotal, percent(blockExact, blockTotal), biomeExact, biomeTotal, percent(biomeExact, biomeTotal), heightExact, heightTotal, percent(heightExact, heightTotal), fluidMismatch, oreMismatch)