world: publish decorated region batches

This commit is contained in:
Daniar Mannanov 2026-08-25 03:59:29 +03:00
parent d8b5d0f79b
commit c4be38acdd
10 changed files with 471 additions and 25 deletions

View file

@ -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

View file

@ -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)
}
}
}
}

View file

@ -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}

View file

@ -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)
}

View file

@ -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.

View file

@ -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++
}
}
}
}
}

View file

@ -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)

View file

@ -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)
}
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)