Initial commit: RegionIO Minecraft server core (26.1.2/protocol 775)
Vanilla-faithful overworld generator (final_density + multi-noise biomes), full connection lifecycle (status/login/configuration/play), chunk streaming, creative block editing, and the protocol/nbt/registry infrastructure.
This commit is contained in:
commit
a7bb9496ae
146 changed files with 217621 additions and 0 deletions
79
internal/world/bench_test.go
Normal file
79
internal/world/bench_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkGenerateFlat(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = GenerateFlat(int32(i), 0)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncode(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = GenerateFlat(int32(i), 0).Encode()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodeAndCompress(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
var buf bytes.Buffer
|
||||
for i := 0; i < b.N; i++ {
|
||||
body := GenerateFlat(int32(i), 0).Encode()
|
||||
buf.Reset()
|
||||
zw := zlib.NewWriter(&buf)
|
||||
zw.Write(body)
|
||||
zw.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// One join currently sends a (2*radius+1)^2 grid; benchmark that batch.
|
||||
func BenchmarkJoinChunkBatch(b *testing.B) {
|
||||
const radius = 4
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for cx := int32(-radius); cx <= radius; cx++ {
|
||||
for cz := int32(-radius); cz <= radius; cz++ {
|
||||
_ = GenerateFlat(cx, cz).Encode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCacheWarmJoin(b *testing.B) {
|
||||
const radius = 4
|
||||
c := NewCache(256, GenerateFlat)
|
||||
// Warm the cache once (cold join).
|
||||
for cx := int32(-radius); cx <= radius; cx++ {
|
||||
for cz := int32(-radius); cz <= radius; cz++ {
|
||||
c.Frame(cx, cz)
|
||||
}
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for cx := int32(-radius); cx <= radius; cx++ {
|
||||
for cz := int32(-radius); cz <= radius; cz++ {
|
||||
_ = c.Frame(cx, cz) // all hits
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCacheColdJoin(b *testing.B) {
|
||||
const radius = 4
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
c := NewCache(256, GenerateFlat) // fresh cache each iter = all misses
|
||||
for cx := int32(-radius); cx <= radius; cx++ {
|
||||
for cz := int32(-radius); cz <= radius; cz++ {
|
||||
_ = c.Frame(cx, cz)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
internal/world/biome_lookup.go
Normal file
119
internal/world/biome_lookup.go
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"regionio/internal/registry"
|
||||
"regionio/internal/worldgen"
|
||||
)
|
||||
|
||||
//go:embed biome_parameters.json
|
||||
var biomeParametersJSON []byte
|
||||
|
||||
// rawParameter mirrors one entry of biome_parameters.json: a biome name plus its
|
||||
// climate ranges. Each axis value is a [min, max] array; depth is normally a
|
||||
// scalar (0.0 surface / 1.0 underground) but a few cave entries carry a [min,
|
||||
// max] array, so it is decoded loosely (see depthScalar).
|
||||
type rawParameter struct {
|
||||
Biome string `json:"biome"`
|
||||
Param struct {
|
||||
Temperature [2]float64 `json:"temperature"`
|
||||
Humidity [2]float64 `json:"humidity"`
|
||||
Continentalness [2]float64 `json:"continentalness"`
|
||||
Erosion [2]float64 `json:"erosion"`
|
||||
Weirdness [2]float64 `json:"weirdness"`
|
||||
Depth any `json:"depth"`
|
||||
Offset float64 `json:"offset"`
|
||||
} `json:"parameters"`
|
||||
}
|
||||
|
||||
// depthScalar extracts a scalar depth from a raw entry, accepting either a JSON
|
||||
// number or a single-element [v] array. Arrays with a range are cave entries
|
||||
// (non-surface) and return ok=false so the caller skips them.
|
||||
func depthScalar(v any) (float64, bool) {
|
||||
switch d := v.(type) {
|
||||
case float64:
|
||||
return d, true
|
||||
case []any:
|
||||
if len(d) == 1 {
|
||||
if f, ok := d[0].(float64); ok {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// surfaceTable is the biome parameter table filtered to depth=0 (surface layer),
|
||||
// built once at init. Cave/underground entries (depth=1, or non-zero offset for
|
||||
// lush/dripstone/deep_dark) are excluded until the per-cell milestone.
|
||||
var (
|
||||
surfaceTable *worldgen.ParameterTable
|
||||
surfaceTableOnce sync.Once
|
||||
)
|
||||
|
||||
// loadSurfaceTable parses the embedded biome parameters once and returns the
|
||||
// surface-only ParameterTable. Panics on a parse error (a corrupt embedded
|
||||
// table is a build-time bug, not a runtime condition).
|
||||
func loadSurfaceTable() *worldgen.ParameterTable {
|
||||
surfaceTableOnce.Do(func() {
|
||||
var raw struct {
|
||||
Biomes []rawParameter `json:"biomes"`
|
||||
}
|
||||
if err := json.Unmarshal(biomeParametersJSON, &raw); err != nil {
|
||||
panic(fmt.Sprintf("world: parsing embedded biome_parameters.json: %v", err))
|
||||
}
|
||||
params := make([]worldgen.BiomeParameter, 0, len(raw.Biomes)/2)
|
||||
for _, e := range raw.Biomes {
|
||||
// Surface layer only: depth resolves to the scalar 0.0, and no cave
|
||||
// offset. Range/array depths and non-zero offsets belong to cave
|
||||
// biomes (lush/dripstone/deep_dark), deferred to the per-cell stage.
|
||||
dp, ok := depthScalar(e.Param.Depth)
|
||||
if !ok || dp != 0.0 || e.Param.Offset != 0.0 {
|
||||
continue
|
||||
}
|
||||
params = append(params, makeBiomeParameter(e, dp))
|
||||
}
|
||||
surfaceTable = worldgen.NewParameterTable(params)
|
||||
})
|
||||
return surfaceTable
|
||||
}
|
||||
|
||||
// makeBiomeParameter converts a raw JSON entry into a BiomeParameter, mapping
|
||||
// the [min,max] ranges to quantized ClimateRanges. depth is a scalar in the
|
||||
// source but a [depth, depth] band in the table (a single value).
|
||||
func makeBiomeParameter(e rawParameter, depth float64) worldgen.BiomeParameter {
|
||||
qr := func(a [2]float64) worldgen.ClimateRange {
|
||||
return worldgen.ClimateRange{Min: worldgen.Quantize(a[0]), Max: worldgen.Quantize(a[1])}
|
||||
}
|
||||
dpQ := worldgen.Quantize(depth)
|
||||
return worldgen.BiomeParameter{
|
||||
Name: e.Biome,
|
||||
Ranges: [worldgen.AxisCount]worldgen.ClimateRange{
|
||||
qr(e.Param.Temperature),
|
||||
qr(e.Param.Humidity),
|
||||
qr(e.Param.Continentalness),
|
||||
qr(e.Param.Erosion),
|
||||
qr(e.Param.Weirdness),
|
||||
{Min: dpQ, Max: dpQ + 1}, // half-open band covering exactly depth
|
||||
},
|
||||
Offset: worldgen.Quantize(e.Param.Offset),
|
||||
}
|
||||
}
|
||||
|
||||
// BiomeAt returns the network biome ID for the surface biome at block (wx, wz)
|
||||
// given the loaded overworld density. It samples the climate axes at sea level,
|
||||
// finds the matching biome in the parameter table, and resolves its name to a
|
||||
// numeric ID via the synchronized biome registry. Unknown biomes fall back to
|
||||
// plains so chunk encoding always gets a valid ID.
|
||||
func BiomeAt(od *worldgen.OverworldDensity, wx, wz int) uint16 {
|
||||
point := worldgen.SampleColumn(od, SeaLevel, wx, wz)
|
||||
name := loadSurfaceTable().FindBiome(point)
|
||||
if id := registry.Index("minecraft:worldgen/biome", name); id >= 0 {
|
||||
return uint16(id)
|
||||
}
|
||||
return BiomePlains
|
||||
}
|
||||
73
internal/world/biome_lookup_test.go
Normal file
73
internal/world/biome_lookup_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"regionio/internal/registry"
|
||||
"regionio/internal/worldgen"
|
||||
)
|
||||
|
||||
// TestBiomeAtDeterministic checks BiomeAt is stable for fixed seed/coords and
|
||||
// resolves to a registry-known biome (not a fallback placeholder).
|
||||
func TestBiomeAtDeterministic(t *testing.T) {
|
||||
od, err := worldgen.LoadOverworldFinalDensity(12345)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
id1 := BiomeAt(od, 100, 200)
|
||||
id2 := BiomeAt(od, 100, 200)
|
||||
if id1 != id2 {
|
||||
t.Fatalf("BiomeAt not deterministic: %d vs %d", id1, id2)
|
||||
}
|
||||
// The returned ID must be a valid registry biome, not the plains fallback by
|
||||
// accident — resolve it back and confirm plains only when genuinely plains.
|
||||
if int(id1) != registry.Index("minecraft:worldgen/biome", biomeName(od, 100, 200)) {
|
||||
t.Errorf("BiomeAt id %d does not round-trip through registry", id1)
|
||||
}
|
||||
}
|
||||
|
||||
// biomeName is a test helper exposing the resolved biome name at (wx, wz).
|
||||
func biomeName(od *worldgen.OverworldDensity, wx, wz int) string {
|
||||
point := worldgen.SampleColumn(od, SeaLevel, wx, wz)
|
||||
return loadSurfaceTable().FindBiome(point)
|
||||
}
|
||||
|
||||
// TestBiomeAtVaryingAcrossWorld confirms different regions of the world map to
|
||||
// different biomes — the whole point of multi-noise. If every sampled chunk
|
||||
// resolved to the same biome, climate sampling or the finder would be broken.
|
||||
func TestBiomeAtVaryingAcrossWorld(t *testing.T) {
|
||||
od, err := worldgen.LoadOverworldFinalDensity(12345)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
seen := make(map[uint16]bool)
|
||||
for cx := int32(0); cx < 16; cx++ {
|
||||
for cz := int32(0); cz < 16; cz++ {
|
||||
seen[BiomeAt(od, int(cx)*16+8, int(cz)*16+8)] = true
|
||||
}
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Fatalf("expected >=2 biomes across 16x16 chunks, got %d (%v)", len(seen), seen)
|
||||
}
|
||||
t.Logf("found %d distinct biomes across 16x16 chunks", len(seen))
|
||||
}
|
||||
|
||||
// TestVanillaChunkHasBiome confirms generateVanilla threads the per-column biome
|
||||
// into the chunk (regression guard for the NewChunk call site in vanilla.go).
|
||||
func TestVanillaChunkHasBiome(t *testing.T) {
|
||||
gen := NewVanillaGenerator(12345)
|
||||
ch := gen(10, -3)
|
||||
if ch == nil {
|
||||
t.Fatal("nil chunk")
|
||||
}
|
||||
// biome is unexported; verify via the registry by re-deriving it. The chunk's
|
||||
// biome must match what BiomeAt returns at the chunk centre.
|
||||
od, err := worldgen.LoadOverworldFinalDensity(12345)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
want := BiomeAt(od, 10*16+8, -3*16+8)
|
||||
if uint16(ch.biome) != want {
|
||||
t.Errorf("chunk biome = %d, want %d", ch.biome, want)
|
||||
}
|
||||
}
|
||||
205021
internal/world/biome_parameters.json
Normal file
205021
internal/world/biome_parameters.json
Normal file
File diff suppressed because it is too large
Load diff
106
internal/world/cache.go
Normal file
106
internal/world/cache.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
||||
// Generator produces the chunk at the given coordinate.
|
||||
type Generator func(cx, cz int32) *Chunk
|
||||
|
||||
// Cache is the live world: it owns the mutable chunk data and memoizes the
|
||||
// framed, compression-ready level_chunk packet for each chunk. A block edit
|
||||
// mutates the chunk and invalidates its cached frame so the next request
|
||||
// re-encodes it.
|
||||
//
|
||||
// Frames are built for a fixed compression threshold shared by all play
|
||||
// connections, so one frame is valid for every client.
|
||||
//
|
||||
// Generation can be expensive; it runs outside the lock to avoid blocking other
|
||||
// chunk requests. An eviction policy belongs here once worlds stream far.
|
||||
type Cache struct {
|
||||
threshold int32
|
||||
gen Generator
|
||||
|
||||
mu sync.Mutex
|
||||
chunks map[[2]int32]*Chunk
|
||||
frames map[[2]int32][]byte
|
||||
}
|
||||
|
||||
// NewCache returns a world cache that frames packets at the given compression
|
||||
// threshold using gen to produce missing chunks.
|
||||
func NewCache(threshold int32, gen Generator) *Cache {
|
||||
return &Cache{
|
||||
threshold: threshold,
|
||||
gen: gen,
|
||||
chunks: make(map[[2]int32]*Chunk),
|
||||
frames: make(map[[2]int32][]byte),
|
||||
}
|
||||
}
|
||||
|
||||
// chunkAt returns the chunk at (cx, cz), generating it on first access.
|
||||
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
||||
key := [2]int32{cx, cz}
|
||||
|
||||
c.mu.Lock()
|
||||
if ch, ok := c.chunks[key]; ok {
|
||||
c.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
ch := c.gen(cx, cz) // generate outside the lock
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if existing, ok := c.chunks[key]; ok {
|
||||
return existing // another goroutine won the race
|
||||
}
|
||||
c.chunks[key] = ch
|
||||
return ch
|
||||
}
|
||||
|
||||
// Frame returns the prebuilt level_chunk packet for (cx, cz), building it on
|
||||
// first request and caching until the chunk is edited. The slice must not be
|
||||
// mutated.
|
||||
func (c *Cache) Frame(cx, cz int32) []byte {
|
||||
key := [2]int32{cx, cz}
|
||||
|
||||
c.mu.Lock()
|
||||
if f, ok := c.frames[key]; ok {
|
||||
c.mu.Unlock()
|
||||
return f
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
ch := c.chunkAt(cx, cz)
|
||||
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, ch.Encode())
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if existing, ok := c.frames[key]; ok {
|
||||
return existing
|
||||
}
|
||||
c.frames[key] = frame
|
||||
return frame
|
||||
}
|
||||
|
||||
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
|
||||
// affected chunk's cached frame. It reports whether a chunk was actually
|
||||
// touched (false if y is out of range).
|
||||
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
||||
if y < MinY || y >= MinY+WorldHeight {
|
||||
return false
|
||||
}
|
||||
cx := int32(x >> 4)
|
||||
cz := int32(z >> 4)
|
||||
ch := c.chunkAt(cx, cz)
|
||||
|
||||
ch.SetBlock(x, y, z, state)
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.frames, [2]int32{cx, cz})
|
||||
c.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
23
internal/world/chunk.go
Normal file
23
internal/world/chunk.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Package world provides chunk data for the play phase: an in-memory chunk
|
||||
// representation, the level_chunk_with_light encoder, and (for now) a flat
|
||||
// world generator. Real noise-based generation arrives in a later sub-milestone.
|
||||
package world
|
||||
|
||||
// FlatSurfaceY is the Y of the topmost solid block (grass) in the flat world.
|
||||
// A player spawns one block above it.
|
||||
const FlatSurfaceY = -61
|
||||
|
||||
// GenerateFlat builds a Classic-Flat-style chunk at (cx, cz): bedrock at the
|
||||
// world floor, two dirt layers, and a grass surface, all under a plains biome.
|
||||
func GenerateFlat(cx, cz int32) *Chunk {
|
||||
c := NewChunk(cx, cz, BiomePlains)
|
||||
for lx := 0; lx < 16; lx++ {
|
||||
for lz := 0; lz < 16; lz++ {
|
||||
c.SetBlock(lx, MinY+0, lz, StateBedrock)
|
||||
c.SetBlock(lx, MinY+1, lz, StateDirt)
|
||||
c.SetBlock(lx, MinY+2, lz, StateDirt)
|
||||
c.SetBlock(lx, FlatSurfaceY, lz, StateGrass)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
269
internal/world/encode.go
Normal file
269
internal/world/encode.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"math/bits"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
||||
// World vertical geometry for the overworld dimension type.
|
||||
const (
|
||||
MinY = -64
|
||||
WorldHeight = 384
|
||||
SectionCount = WorldHeight / 16 // 24 sections
|
||||
sectionVol = 16 * 16 * 16 // 4096 blocks
|
||||
)
|
||||
|
||||
// Common block-state network IDs (from the generated block report).
|
||||
const (
|
||||
StateAir uint16 = 0
|
||||
StateStone uint16 = 1
|
||||
StateGrass uint16 = 9
|
||||
StateDirt uint16 = 10
|
||||
StateBedrock uint16 = 85
|
||||
StateWater uint16 = 86
|
||||
StateSand uint16 = 118
|
||||
StateGravel uint16 = 124
|
||||
StateOakLog uint16 = 137
|
||||
StateOakLeaf uint16 = 279
|
||||
)
|
||||
|
||||
// BiomePlains is the network ID (registry index) of minecraft:plains.
|
||||
const BiomePlains uint16 = 40
|
||||
|
||||
// totalBlockStates is one past the largest block-state ID; it sets the
|
||||
// direct-palette bit width.
|
||||
const totalBlockStates = 29873
|
||||
|
||||
// Chunk is a 16xWorldHeightx16 column of block states with a single biome.
|
||||
// A nil section is entirely air.
|
||||
type Chunk struct {
|
||||
X, Z int32
|
||||
sections [SectionCount]*[sectionVol]uint16
|
||||
biome uint16
|
||||
}
|
||||
|
||||
// NewChunk returns an empty (all-air) chunk at (x, z) with the given biome.
|
||||
func NewChunk(x, z int32, biome uint16) *Chunk {
|
||||
return &Chunk{X: x, Z: z, biome: biome}
|
||||
}
|
||||
|
||||
// blockIndex maps local coordinates to the YZX-ordered section array index.
|
||||
func blockIndex(lx, ly, lz int) int { return (ly&15)<<8 | (lz&15)<<4 | (lx & 15) }
|
||||
|
||||
// section returns section i, allocating it on first write.
|
||||
func (c *Chunk) section(i int) *[sectionVol]uint16 {
|
||||
if c.sections[i] == nil {
|
||||
c.sections[i] = new([sectionVol]uint16)
|
||||
}
|
||||
return c.sections[i]
|
||||
}
|
||||
|
||||
// GetBlock returns the block state at local (lx, lz) and world height y, or
|
||||
// StateAir if the section is empty or y is out of range.
|
||||
func (c *Chunk) GetBlock(lx, y, lz int) uint16 {
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return StateAir
|
||||
}
|
||||
s := c.sections[si]
|
||||
if s == nil {
|
||||
return StateAir
|
||||
}
|
||||
return s[blockIndex(lx, y, lz)]
|
||||
}
|
||||
|
||||
// SetBlock sets the block at local (lx, lz) and absolute world height y.
|
||||
func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return
|
||||
}
|
||||
c.section(si)[blockIndex(lx, y, lz)] = state
|
||||
}
|
||||
|
||||
// Encode serializes the level_chunk_with_light body for this chunk.
|
||||
func (c *Chunk) Encode() []byte {
|
||||
w := protocol.NewWriter(8192)
|
||||
w.Int32(c.X).Int32(c.Z)
|
||||
c.writeHeightmaps(w)
|
||||
|
||||
// Section data is length-prefixed.
|
||||
sec := protocol.NewWriter(4096)
|
||||
for i := 0; i < SectionCount; i++ {
|
||||
c.writeSection(sec, i)
|
||||
}
|
||||
w.VarInt(int32(sec.Len()))
|
||||
w.Raw(sec.Bytes())
|
||||
|
||||
w.VarInt(0) // block entity count
|
||||
c.writeLight(w)
|
||||
return w.Bytes()
|
||||
}
|
||||
|
||||
// Heightmap.Types ordinals sent to the client.
|
||||
const (
|
||||
hmWorldSurface = 1
|
||||
hmMotionBlocking = 4
|
||||
hmMotionBlockingNoLeaves = 5
|
||||
)
|
||||
|
||||
// writeHeightmaps emits the three client-relevant heightmaps. For our blocky
|
||||
// terrain (no leaves/transparency) they share the same column heights.
|
||||
func (c *Chunk) writeHeightmaps(w *protocol.Writer) {
|
||||
heights := c.columnHeights()
|
||||
packed := packHeightmap(heights)
|
||||
|
||||
w.VarInt(3)
|
||||
for _, t := range []int32{hmMotionBlockingNoLeaves, hmMotionBlocking, hmWorldSurface} {
|
||||
w.VarInt(t)
|
||||
w.VarInt(int32(len(packed)))
|
||||
for _, v := range packed {
|
||||
w.Int64(int64(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// columnHeights returns, per column, (highestNonAirY + 1) - MinY, clamped to 0.
|
||||
func (c *Chunk) columnHeights() [256]uint16 {
|
||||
var h [256]uint16
|
||||
for lx := 0; lx < 16; lx++ {
|
||||
for lz := 0; lz < 16; lz++ {
|
||||
height := 0
|
||||
for y := MinY + WorldHeight - 1; y >= MinY; y-- {
|
||||
si := (y - MinY) >> 4
|
||||
s := c.sections[si]
|
||||
if s != nil && s[blockIndex(lx, y, lz)] != StateAir {
|
||||
height = y + 1 - MinY
|
||||
break
|
||||
}
|
||||
}
|
||||
h[lz*16+lx] = uint16(height)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// packHeightmap packs 256 column heights at 9 bits each, 7 values per long,
|
||||
// without spanning longs (37 longs).
|
||||
func packHeightmap(h [256]uint16) []uint64 {
|
||||
const bpe = 9
|
||||
const perLong = 64 / bpe // 7
|
||||
out := make([]uint64, (256+perLong-1)/perLong)
|
||||
for i, v := range h {
|
||||
out[i/perLong] |= uint64(v&0x1FF) << uint((i%perLong)*bpe)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSection emits one chunk section: block count, block paletted container,
|
||||
// then the (single-value) biome paletted container.
|
||||
func (c *Chunk) writeSection(w *protocol.Writer, i int) {
|
||||
s := c.sections[i]
|
||||
if s == nil {
|
||||
w.Uint16(0) // non-air block count
|
||||
w.Uint16(0) // reserved 2-byte field (always 0 in vanilla)
|
||||
writeSingleValued(w, uint32(StateAir))
|
||||
} else {
|
||||
w.Uint16(uint16(nonAirCount(s)))
|
||||
w.Uint16(0) // reserved 2-byte field
|
||||
writeBlockPalette(w, s)
|
||||
}
|
||||
// Biomes: a single value covers the whole section for now.
|
||||
writeSingleValued(w, uint32(c.biome))
|
||||
}
|
||||
|
||||
func nonAirCount(s *[sectionVol]uint16) int {
|
||||
n := 0
|
||||
for _, v := range s {
|
||||
if v != StateAir {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// writeSingleValued writes a bits-per-entry-0 paletted container (no data).
|
||||
func writeSingleValued(w *protocol.Writer, value uint32) {
|
||||
w.Byte(0)
|
||||
w.VarInt(int32(value))
|
||||
}
|
||||
|
||||
// writeBlockPalette writes a block-state paletted container, choosing the
|
||||
// single-valued, indirect, or direct encoding as appropriate.
|
||||
func writeBlockPalette(w *protocol.Writer, s *[sectionVol]uint16) {
|
||||
palette, indexOf := buildPalette(s)
|
||||
if len(palette) == 1 {
|
||||
writeSingleValued(w, uint32(palette[0]))
|
||||
return
|
||||
}
|
||||
|
||||
bpe := bitsFor(len(palette))
|
||||
if bpe < 4 {
|
||||
bpe = 4 // minimum for the indirect block format
|
||||
}
|
||||
if bpe > 8 {
|
||||
writeDirect(w, s)
|
||||
return
|
||||
}
|
||||
|
||||
w.Byte(byte(bpe))
|
||||
w.VarInt(int32(len(palette)))
|
||||
for _, st := range palette {
|
||||
w.VarInt(int32(st))
|
||||
}
|
||||
writePackedIndices(w, bpe, sectionVol, func(i int) uint32 {
|
||||
return uint32(indexOf[s[i]])
|
||||
})
|
||||
}
|
||||
|
||||
// writeDirect writes a direct (palette-less) container of global state IDs.
|
||||
func writeDirect(w *protocol.Writer, s *[sectionVol]uint16) {
|
||||
bpe := bitsFor(totalBlockStates)
|
||||
w.Byte(byte(bpe))
|
||||
writePackedIndices(w, bpe, sectionVol, func(i int) uint32 {
|
||||
return uint32(s[i])
|
||||
})
|
||||
}
|
||||
|
||||
// writePackedIndices emits the long-array data: count entries of bpe bits each,
|
||||
// packed perLong=64/bpe values per long, never spanning a long boundary. The
|
||||
// long count is NOT length-prefixed; the client derives it from bpe.
|
||||
func writePackedIndices(w *protocol.Writer, bpe, count int, value func(i int) uint32) {
|
||||
perLong := 64 / bpe
|
||||
numLongs := (count + perLong - 1) / perLong
|
||||
|
||||
mask := uint64(1)<<uint(bpe) - 1
|
||||
for l := 0; l < numLongs; l++ {
|
||||
var packed uint64
|
||||
for j := 0; j < perLong; j++ {
|
||||
idx := l*perLong + j
|
||||
if idx >= count {
|
||||
break
|
||||
}
|
||||
packed |= (uint64(value(idx)) & mask) << uint(j*bpe)
|
||||
}
|
||||
w.Int64(int64(packed))
|
||||
}
|
||||
}
|
||||
|
||||
// buildPalette returns the distinct block states in s and a value->index map.
|
||||
func buildPalette(s *[sectionVol]uint16) ([]uint16, map[uint16]int) {
|
||||
indexOf := make(map[uint16]int)
|
||||
var palette []uint16
|
||||
for _, v := range s {
|
||||
if _, ok := indexOf[v]; !ok {
|
||||
indexOf[v] = len(palette)
|
||||
palette = append(palette, v)
|
||||
}
|
||||
}
|
||||
return palette, indexOf
|
||||
}
|
||||
|
||||
// bitsFor returns the bits needed to index n distinct values (min 1).
|
||||
func bitsFor(n int) int {
|
||||
if n <= 1 {
|
||||
return 0
|
||||
}
|
||||
return bits.Len(uint(n - 1))
|
||||
}
|
||||
154
internal/world/encode_test.go
Normal file
154
internal/world/encode_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
||||
// parsePalettedContainer consumes one paletted container of entryCount entries.
|
||||
// The long-array length is derived from bits-per-entry, not length-prefixed.
|
||||
func parsePalettedContainer(t *testing.T, r *protocol.Reader, maxBits, entryCount int) {
|
||||
t.Helper()
|
||||
bpe, err := r.ReadByte()
|
||||
if err != nil {
|
||||
t.Fatalf("bpe: %v", err)
|
||||
}
|
||||
if bpe == 0 {
|
||||
if _, err := r.VarInt(); err != nil { // single value
|
||||
t.Fatalf("single value: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if int(bpe) <= maxBits { // indirect: palette precedes data
|
||||
n, err := r.VarInt()
|
||||
if err != nil || n < 0 {
|
||||
t.Fatalf("palette len: %v", err)
|
||||
}
|
||||
for i := int32(0); i < n; i++ {
|
||||
if _, err := r.VarInt(); err != nil {
|
||||
t.Fatalf("palette entry: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
perLong := 64 / int(bpe)
|
||||
longs := (entryCount + perLong - 1) / perLong
|
||||
for i := 0; i < longs; i++ {
|
||||
if _, err := r.Int64(); err != nil {
|
||||
t.Fatalf("data long: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skipBitSet(t *testing.T, r *protocol.Reader) {
|
||||
t.Helper()
|
||||
n, err := r.VarInt()
|
||||
if err != nil || n < 0 {
|
||||
t.Fatalf("bitset len: %v", err)
|
||||
}
|
||||
for i := int32(0); i < n; i++ {
|
||||
if _, err := r.Int64(); err != nil {
|
||||
t.Fatalf("bitset long: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlatChunkEncodesCleanly fully parses an encoded flat chunk and asserts
|
||||
// the byte stream is consumed exactly, with the expected high-level structure.
|
||||
func TestFlatChunkEncodesCleanly(t *testing.T) {
|
||||
body := GenerateFlat(2, -3).Encode()
|
||||
|
||||
// X and Z are plain big-endian ints.
|
||||
if got := readInt32(t, body[0:4]); got != 2 {
|
||||
t.Fatalf("chunkX = %d, want 2", got)
|
||||
}
|
||||
if got := readInt32(t, body[4:8]); got != -3 {
|
||||
t.Fatalf("chunkZ = %d, want -3", got)
|
||||
}
|
||||
r := protocol.NewReader(body[8:])
|
||||
|
||||
// Heightmaps: 3 entries, each 37 longs of packed 9-bit heights.
|
||||
hmCount, err := r.VarInt()
|
||||
if err != nil || hmCount != 3 {
|
||||
t.Fatalf("heightmap count = %d (err %v), want 3", hmCount, err)
|
||||
}
|
||||
for i := int32(0); i < hmCount; i++ {
|
||||
if _, err := r.VarInt(); err != nil { // type
|
||||
t.Fatalf("hm type: %v", err)
|
||||
}
|
||||
longs, err := r.VarInt()
|
||||
if err != nil || longs != 37 {
|
||||
t.Fatalf("hm longs = %d (err %v), want 37", longs, err)
|
||||
}
|
||||
for j := int32(0); j < longs; j++ {
|
||||
if _, err := r.Int64(); err != nil {
|
||||
t.Fatalf("hm long: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section data block.
|
||||
dataLen, err := r.VarInt()
|
||||
if err != nil || dataLen <= 0 {
|
||||
t.Fatalf("data len = %d (err %v)", dataLen, err)
|
||||
}
|
||||
nonAirSections := 0
|
||||
for s := 0; s < SectionCount; s++ {
|
||||
count, err := r.Uint16()
|
||||
if err != nil {
|
||||
t.Fatalf("section %d count: %v", s, err)
|
||||
}
|
||||
if _, err := r.Uint16(); err != nil { // reserved 2-byte field
|
||||
t.Fatalf("section %d reserved: %v", s, err)
|
||||
}
|
||||
if count > 0 {
|
||||
nonAirSections++
|
||||
}
|
||||
parsePalettedContainer(t, r, 8, 4096) // blocks
|
||||
parsePalettedContainer(t, r, 3, 64) // biomes
|
||||
}
|
||||
if nonAirSections != 1 {
|
||||
t.Fatalf("non-air sections = %d, want 1 (flat layers live in section 0)", nonAirSections)
|
||||
}
|
||||
|
||||
// Block entities.
|
||||
if be, err := r.VarInt(); err != nil || be != 0 {
|
||||
t.Fatalf("block entities = %d (err %v), want 0", be, err)
|
||||
}
|
||||
|
||||
// Light: four bitsets, then sky arrays, then block arrays.
|
||||
skipBitSet(t, r) // sky mask
|
||||
skipBitSet(t, r) // block mask
|
||||
skipBitSet(t, r) // empty sky mask
|
||||
skipBitSet(t, r) // empty block mask
|
||||
skyArrays, err := r.VarInt()
|
||||
if err != nil || skyArrays != lightSections {
|
||||
t.Fatalf("sky arrays = %d (err %v), want %d", skyArrays, err, lightSections)
|
||||
}
|
||||
for i := int32(0); i < skyArrays; i++ {
|
||||
n, err := r.VarInt()
|
||||
if err != nil || n != 2048 {
|
||||
t.Fatalf("sky array len = %d (err %v), want 2048", n, err)
|
||||
}
|
||||
for j := int32(0); j < n; j++ {
|
||||
if _, err := r.ReadByte(); err != nil {
|
||||
t.Fatalf("sky byte: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if blockArrays, err := r.VarInt(); err != nil || blockArrays != 0 {
|
||||
t.Fatalf("block arrays = %d (err %v), want 0", blockArrays, err)
|
||||
}
|
||||
|
||||
if rem := r.Remaining(); rem != 0 {
|
||||
t.Fatalf("trailing bytes after parse: %d", rem)
|
||||
}
|
||||
}
|
||||
|
||||
func readInt32(t *testing.T, b []byte) int32 {
|
||||
t.Helper()
|
||||
if len(b) < 4 {
|
||||
t.Fatal("short int32")
|
||||
}
|
||||
return int32(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]))
|
||||
}
|
||||
46
internal/world/golden_test.go
Normal file
46
internal/world/golden_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
||||
// extractSectionData returns the section-data byte slice of a level_chunk body
|
||||
// (the bytes covered by the VarInt size that follows the heightmaps).
|
||||
func extractSectionData(t *testing.T, body []byte) []byte {
|
||||
t.Helper()
|
||||
r := protocol.NewReader(body[8:]) // skip chunk X,Z
|
||||
count, _ := r.VarInt()
|
||||
for i := int32(0); i < count; i++ {
|
||||
r.VarInt() // heightmap type
|
||||
n, _ := r.VarInt() // long count
|
||||
for j := int32(0); j < n; j++ {
|
||||
r.Int64()
|
||||
}
|
||||
}
|
||||
size, _ := r.VarInt()
|
||||
consumed := len(body[8:]) - r.Remaining()
|
||||
return body[8+consumed : 8+consumed+int(size)]
|
||||
}
|
||||
|
||||
// TestGoldenAgainstVanilla asserts our flat-chunk section data is byte-for-byte
|
||||
// identical to a chunk captured from the official 26.1.2 server (same world
|
||||
// coordinate). This guards the paletted-container and heightmap encoding.
|
||||
// Light is intentionally not compared (we send full-bright, which differs).
|
||||
func TestGoldenAgainstVanilla(t *testing.T) {
|
||||
vanilla, err := os.ReadFile("testdata/vanilla_flat_chunk.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
ours := GenerateFlat(0, -1).Encode() // fixture was captured at chunk (0, -1)
|
||||
want := extractSectionData(t, vanilla)
|
||||
got := extractSectionData(t, ours)
|
||||
|
||||
if !bytes.Equal(want, got) {
|
||||
t.Fatalf("section data differs: vanilla=%d bytes, ours=%d bytes", len(want), len(got))
|
||||
}
|
||||
}
|
||||
1
internal/world/item_blocks.json
Normal file
1
internal/world/item_blocks.json
Normal file
File diff suppressed because one or more lines are too long
38
internal/world/items.go
Normal file
38
internal/world/items.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
//go:embed item_blocks.json
|
||||
var itemBlocksJSON []byte
|
||||
|
||||
// itemToBlock maps an item's network ID to the default block state placed when
|
||||
// that item is used. Built from the item registry crossed with block defaults
|
||||
// (items whose name is a block). Items with no block (tools, food) are absent.
|
||||
var itemToBlock map[int32]uint16
|
||||
|
||||
func init() {
|
||||
raw := make(map[string]int)
|
||||
if err := json.Unmarshal(itemBlocksJSON, &raw); err != nil {
|
||||
panic(fmt.Sprintf("world: parsing item_blocks.json: %v", err))
|
||||
}
|
||||
itemToBlock = make(map[int32]uint16, len(raw))
|
||||
for k, v := range raw {
|
||||
id, err := strconv.Atoi(k)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("world: bad item id %q: %v", k, err))
|
||||
}
|
||||
itemToBlock[int32(id)] = uint16(v)
|
||||
}
|
||||
}
|
||||
|
||||
// ItemToBlock returns the block state placed by an item, and whether the item
|
||||
// is a placeable block.
|
||||
func ItemToBlock(itemID int32) (uint16, bool) {
|
||||
s, ok := itemToBlock[itemID]
|
||||
return s, ok
|
||||
}
|
||||
45
internal/world/light.go
Normal file
45
internal/world/light.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package world
|
||||
|
||||
import "regionio/internal/protocol"
|
||||
|
||||
// lightSections is the number of light subchunks: one below the world and one
|
||||
// above, plus one per block section.
|
||||
const lightSections = SectionCount + 2
|
||||
|
||||
// writeLight emits a fully-lit sky: every light section carries sky light 15,
|
||||
// and block light is reported as uniformly empty. This avoids a black world
|
||||
// without implementing real light propagation (deferred).
|
||||
func (c *Chunk) writeLight(w *protocol.Writer) {
|
||||
full := allSectionsMask()
|
||||
|
||||
writeBitSet(w, full) // sky light mask: all sections present
|
||||
writeBitSet(w, nil) // block light mask: none present
|
||||
writeBitSet(w, nil) // empty sky light mask: none empty
|
||||
writeBitSet(w, full) // empty block light mask: all empty
|
||||
|
||||
// Sky light arrays: one 2048-byte (4096 nibbles) array of 0x0F per section.
|
||||
bright := make([]byte, 2048)
|
||||
for i := range bright {
|
||||
bright[i] = 0xFF // two nibbles of 15
|
||||
}
|
||||
w.VarInt(lightSections)
|
||||
for i := 0; i < lightSections; i++ {
|
||||
w.VarInt(2048)
|
||||
w.Raw(bright)
|
||||
}
|
||||
|
||||
w.VarInt(0) // no block light arrays
|
||||
}
|
||||
|
||||
// allSectionsMask returns a bitset (as longs) with the low lightSections bits set.
|
||||
func allSectionsMask() []uint64 {
|
||||
return []uint64{(uint64(1) << lightSections) - 1}
|
||||
}
|
||||
|
||||
// writeBitSet emits a length-prefixed array of longs.
|
||||
func writeBitSet(w *protocol.Writer, longs []uint64) {
|
||||
w.VarInt(int32(len(longs)))
|
||||
for _, v := range longs {
|
||||
w.Int64(int64(v))
|
||||
}
|
||||
}
|
||||
88
internal/world/terrain.go
Normal file
88
internal/world/terrain.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"regionio/internal/worldgen"
|
||||
)
|
||||
|
||||
// SeaLevel is the water surface height for generated terrain.
|
||||
const SeaLevel = 63
|
||||
|
||||
// NewTerrainGenerator returns a chunk generator backed by a density function.
|
||||
// The density tree is built once (shared, read-only noise state) and sampled
|
||||
// per block.
|
||||
func NewTerrainGenerator(seed int64) Generator {
|
||||
density := worldgen.SimpleTerrain(seed)
|
||||
return func(cx, cz int32) *Chunk {
|
||||
return generateFromDensity(density, cx, cz)
|
||||
}
|
||||
}
|
||||
|
||||
// generateFromDensity fills a chunk by sampling the density function (>0 is
|
||||
// solid). The per-column sampling is the expensive part and is run in parallel
|
||||
// across the 16 x-rows; chunk assembly is sequential to avoid racing on lazy
|
||||
// section allocation. Sampling the density only reads shared noise state, so it
|
||||
// is safe to run concurrently.
|
||||
func generateFromDensity(d worldgen.DensityFunction, cx, cz int32) *Chunk {
|
||||
c := NewChunk(cx, cz, BiomePlains)
|
||||
|
||||
var columns [16][16][WorldHeight]uint16
|
||||
var wg sync.WaitGroup
|
||||
for lx := 0; lx < 16; lx++ {
|
||||
wg.Add(1)
|
||||
go func(lx int) {
|
||||
defer wg.Done()
|
||||
for lz := 0; lz < 16; lz++ {
|
||||
wx := float64(int(cx)*16 + lx)
|
||||
wz := float64(int(cz)*16 + lz)
|
||||
computeColumn(d, wx, wz, &columns[lx][lz])
|
||||
}
|
||||
}(lx)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for lx := 0; lx < 16; lx++ {
|
||||
for lz := 0; lz < 16; lz++ {
|
||||
col := &columns[lx][lz]
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
if s := col[i]; s != StateAir {
|
||||
c.SetBlock(lx, MinY+i, lz, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// computeColumn fills out with the block state for each Y in one column.
|
||||
func computeColumn(d worldgen.DensityFunction, wx, wz float64, out *[WorldHeight]uint16) {
|
||||
var solid [WorldHeight]bool
|
||||
top := -1
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
y := MinY + i
|
||||
if d.Compute(worldgen.FunctionContext{X: wx, Y: float64(y), Z: wz}) > 0 {
|
||||
solid[i] = true
|
||||
top = i
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
y := MinY + i
|
||||
switch {
|
||||
case y == MinY:
|
||||
out[i] = StateBedrock
|
||||
case solid[i]:
|
||||
switch {
|
||||
case i == top && y >= SeaLevel:
|
||||
out[i] = StateGrass
|
||||
case i > top-4:
|
||||
out[i] = StateDirt
|
||||
default:
|
||||
out[i] = StateStone
|
||||
}
|
||||
case y < SeaLevel:
|
||||
out[i] = StateWater
|
||||
}
|
||||
}
|
||||
}
|
||||
9
internal/world/terrain_bench_test.go
Normal file
9
internal/world/terrain_bench_test.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package world
|
||||
|
||||
import "testing"
|
||||
|
||||
func BenchmarkGenerateTerrain(b *testing.B) {
|
||||
gen := NewTerrainGenerator(0)
|
||||
b.ResetTimer(); b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ { _ = gen(int32(i), 0) }
|
||||
}
|
||||
30
internal/world/terrain_debug_test.go
Normal file
30
internal/world/terrain_debug_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"regionio/internal/worldgen"
|
||||
)
|
||||
|
||||
func TestTerrainHeightProfile(t *testing.T) {
|
||||
d := worldgen.SimpleTerrain(0)
|
||||
minH, maxH := 1000, -1000
|
||||
// surface height along z=8 for x in [-32,32]
|
||||
line := ""
|
||||
for x := -32; x <= 32; x += 4 {
|
||||
top := MinY - 1
|
||||
for y := MinY; y < MinY+WorldHeight; y++ {
|
||||
if d.Compute(worldgen.FunctionContext{X: float64(x), Y: float64(y), Z: 8}) > 0 {
|
||||
top = y
|
||||
}
|
||||
}
|
||||
line += fmt.Sprintf("%d ", top)
|
||||
if top < minH { minH = top }
|
||||
if top > maxH { maxH = top }
|
||||
}
|
||||
t.Logf("surface heights (z=8): %s", line)
|
||||
t.Logf("min=%d max=%d range=%d", minH, maxH, maxH-minH)
|
||||
if minH < MinY || maxH > 120 {
|
||||
t.Fatalf("implausible terrain heights")
|
||||
}
|
||||
}
|
||||
BIN
internal/world/testdata/vanilla_flat_chunk.bin
vendored
Normal file
BIN
internal/world/testdata/vanilla_flat_chunk.bin
vendored
Normal file
Binary file not shown.
266
internal/world/vanilla.go
Normal file
266
internal/world/vanilla.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"regionio/internal/worldgen"
|
||||
)
|
||||
|
||||
// Noise cell dimensions for the overworld (size_horizontal=1 → 4 wide,
|
||||
// size_vertical=2 → 8 tall). Only the Interpolated terrain noise is sampled on
|
||||
// the cell-corner grid and trilinearly interpolated (as vanilla's NoiseChunk
|
||||
// does); the rest of final_density — squeeze/min and the caves — is evaluated
|
||||
// per block with those interpolated values substituted in.
|
||||
const (
|
||||
cellWidth = 4
|
||||
cellHeight = 8
|
||||
cellsXZ = 16 / cellWidth // 4
|
||||
cellsY = WorldHeight / cellHeight // 48
|
||||
)
|
||||
|
||||
type cornerGrid [cellsXZ + 1][cellsY + 1][cellsXZ + 1]float64
|
||||
|
||||
// NewVanillaGenerator returns a generator backed by the real overworld
|
||||
// final_density tree for the given seed, plus a simplified cosmetic pass
|
||||
// (beaches and trees) layered on the bit-accurate terrain.
|
||||
func NewVanillaGenerator(seed int64) Generator {
|
||||
od, err := worldgen.LoadOverworldFinalDensity(seed)
|
||||
if err != nil {
|
||||
panic("world: loading overworld density: " + err.Error())
|
||||
}
|
||||
return func(cx, cz int32) *Chunk {
|
||||
return generateVanilla(od, seed, cx, cz)
|
||||
}
|
||||
}
|
||||
|
||||
func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *Chunk {
|
||||
// Surface biome is sampled at the chunk centre column. Climate noises are
|
||||
// 2D at this stage (depth fixed to surface), so one sample per chunk is
|
||||
// representative; the per-cell milestone will sample the 4×4×4 grid.
|
||||
biome := BiomeAt(od, int(cx)*16+8, int(cz)*16+8)
|
||||
c := NewChunk(cx, cz, biome)
|
||||
baseX, baseZ := int(cx)*16, int(cz)*16
|
||||
|
||||
grids := make([]cornerGrid, len(od.Interpolated))
|
||||
var wg sync.WaitGroup
|
||||
for ix := 0; ix <= cellsXZ; ix++ {
|
||||
wg.Add(1)
|
||||
go func(ix int) {
|
||||
defer wg.Done()
|
||||
wx := float64(baseX + ix*cellWidth)
|
||||
for iy := 0; iy <= cellsY; iy++ {
|
||||
wy := float64(MinY + iy*cellHeight)
|
||||
for iz := 0; iz <= cellsXZ; iz++ {
|
||||
ctx := worldgen.FunctionContext{X: wx, Y: wy, Z: float64(baseZ + iz*cellWidth)}
|
||||
for n, node := range od.Interpolated {
|
||||
grids[n][ix][iy][iz] = node.Inner.Compute(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(ix)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var columns [16][16][WorldHeight]uint16
|
||||
var surfTop [16][16]int // top solid index, -1 if none
|
||||
var grass [16][16]bool // grassy land surface (tree-plantable)
|
||||
for lx := 0; lx < 16; lx++ {
|
||||
wg.Add(1)
|
||||
go func(lx int) {
|
||||
defer wg.Done()
|
||||
interp := make([]float64, len(od.Interpolated))
|
||||
for lz := 0; lz < 16; lz++ {
|
||||
surfTop[lx][lz], grass[lx][lz] = fillVanillaColumn(od, grids, interp, &columns[lx][lz], baseX+lx, baseZ+lz, lx, lz, seed)
|
||||
}
|
||||
}(lx)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for lx := 0; lx < 16; lx++ {
|
||||
for lz := 0; lz < 16; lz++ {
|
||||
col := &columns[lx][lz]
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
if s := col[i]; s != StateAir {
|
||||
c.SetBlock(lx, MinY+i, lz, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
decorate(c, cx, cz, seed, &surfTop, &grass)
|
||||
return c
|
||||
}
|
||||
|
||||
// fillVanillaColumn lays the blocks for one column and returns the top solid
|
||||
// index and whether the surface is grassy land (suitable for trees). Beaches
|
||||
// (sand) form a narrow ring around the waterline; deep water floors use gravel;
|
||||
// the bottom is a vanilla-style randomised bedrock layer.
|
||||
func fillVanillaColumn(od *worldgen.OverworldDensity, grids []cornerGrid, interp []float64, out *[WorldHeight]uint16, wx, wz, lx, lz int, seed int64) (int, bool) {
|
||||
cx0 := lx / cellWidth
|
||||
cz0 := lz / cellWidth
|
||||
fx := float64(lx%cellWidth) / cellWidth
|
||||
fz := float64(lz%cellWidth) / cellWidth
|
||||
|
||||
var solid [WorldHeight]bool
|
||||
top := -1
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
cy0 := i / cellHeight
|
||||
fy := float64(i%cellHeight) / cellHeight
|
||||
for n := range grids {
|
||||
interp[n] = trilerp(&grids[n], cx0, cy0, cz0, fx, fy, fz)
|
||||
}
|
||||
ctx := worldgen.FunctionContext{X: float64(wx), Y: float64(MinY + i), Z: float64(wz)}.WithInterp(interp)
|
||||
if od.Final.Compute(ctx) > 0 {
|
||||
solid[i] = true
|
||||
top = i
|
||||
}
|
||||
}
|
||||
|
||||
topY := MinY + top
|
||||
// Beach: a narrow band straddling the waterline. Dry columns well above sea
|
||||
// level stay grass; deep water floors become gravel, not sand.
|
||||
const beachBand = 3
|
||||
beach := top >= 0 && topY >= SeaLevel-beachBand && topY <= SeaLevel+1
|
||||
deepWater := top >= 0 && topY < SeaLevel-beachBand
|
||||
|
||||
// Randomised bedrock floor: solid at MinY, decaying chance up to MinY+4, like
|
||||
// the vanilla overworld floor (each layer drops the probability by ~1/4).
|
||||
rng := newColumnRand(wx, wz, int(seed))
|
||||
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
y := MinY + i
|
||||
switch {
|
||||
case y <= MinY:
|
||||
out[i] = StateBedrock
|
||||
case y <= MinY+4 && solid[i] && bedrockAt(rng, y-MinY):
|
||||
out[i] = StateBedrock
|
||||
case solid[i]:
|
||||
switch {
|
||||
case beach && i > top-4:
|
||||
out[i] = StateSand
|
||||
case deepWater && i == top:
|
||||
out[i] = StateGravel
|
||||
case i == top && y >= SeaLevel:
|
||||
out[i] = StateGrass
|
||||
case i > top-4:
|
||||
out[i] = StateDirt
|
||||
default:
|
||||
out[i] = StateStone
|
||||
}
|
||||
case y < SeaLevel:
|
||||
out[i] = StateWater
|
||||
}
|
||||
}
|
||||
return top, top >= 0 && !beach && !deepWater && topY >= SeaLevel
|
||||
}
|
||||
|
||||
// bedrockAt reports whether a block at layer d (1..4 above the floor) should be
|
||||
// bedrock, consuming randomness from rng. Vanilla's floor has probability ~1 at
|
||||
// the bottom layer dropping to 0 a few blocks up; we approximate the decay with
|
||||
// a 1/4 chance per step up from the solid floor.
|
||||
func bedrockAt(rng chunkRand, d int) bool {
|
||||
// Probability per layer: d=1 → 50%, d=2 → 25%, d=3 → 12.5%, d=4 → 6.25%.
|
||||
// Need (5-d) high bits from a 32-bit draw; compare against a per-step mask.
|
||||
keep := 5 - d // 4..1
|
||||
if keep <= 0 {
|
||||
return false
|
||||
}
|
||||
// Each surviving bit roughly halves the chance; draw once and check `keep`
|
||||
// of its low bits.
|
||||
r := rng.next()
|
||||
for b := 0; b < keep; b++ {
|
||||
if (r>>uint(b))&1 == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// decorate places simple oak trees on grassy columns. Trunks are kept two
|
||||
// blocks inside the chunk so the radius-2 canopy never crosses into a neighbour
|
||||
// (avoiding cross-chunk coordination); placement is deterministic per chunk.
|
||||
func decorate(c *Chunk, cx, cz int32, seed int64, surfTop *[16][16]int, grass *[16][16]bool) {
|
||||
r := newChunkRand(cx, cz, seed)
|
||||
const attempts = 8
|
||||
for a := 0; a < attempts; a++ {
|
||||
lx := 2 + int(r.next()%12)
|
||||
lz := 2 + int(r.next()%12)
|
||||
if !grass[lx][lz] {
|
||||
continue
|
||||
}
|
||||
baseY := MinY + surfTop[lx][lz] + 1
|
||||
placeOak(c, lx, baseY, lz, &r)
|
||||
}
|
||||
}
|
||||
|
||||
func placeOak(c *Chunk, lx, baseY, lz int, r *chunkRand) {
|
||||
h := 4 + int(r.next()%3) // trunk height 4..6
|
||||
for i := 0; i < h; i++ {
|
||||
c.SetBlock(lx, baseY+i, lz, StateOakLog)
|
||||
}
|
||||
topY := baseY + h - 1
|
||||
// Canopy: two wide layers around the top, then two narrow layers above.
|
||||
layers := []struct {
|
||||
dy, radius int
|
||||
}{{-1, 2}, {0, 2}, {1, 1}, {2, 1}}
|
||||
for _, ly := range layers {
|
||||
y := topY + ly.dy
|
||||
for dx := -ly.radius; dx <= ly.radius; dx++ {
|
||||
for dz := -ly.radius; dz <= ly.radius; dz++ {
|
||||
if ly.radius == 2 && abs(dx) == 2 && abs(dz) == 2 {
|
||||
continue // trim the far corners for a rounder shape
|
||||
}
|
||||
if c.GetBlock(lx+dx, y, lz+dz) == StateAir {
|
||||
c.SetBlock(lx+dx, y, lz+dz, StateOakLeaf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// chunkRand is a tiny deterministic PRNG (SplitMix64) seeded per chunk.
|
||||
type chunkRand struct{ s uint64 }
|
||||
|
||||
func newChunkRand(cx, cz int32, seed int64) chunkRand {
|
||||
h := uint64(seed)
|
||||
h ^= uint64(uint32(cx)) * 0x9E3779B97F4A7C15
|
||||
h ^= uint64(uint32(cz)) * 0xC2B2AE3D27D4EB4F
|
||||
return chunkRand{s: h | 1}
|
||||
}
|
||||
|
||||
// newColumnRand seeds a deterministic PRNG from a column's world coordinates so
|
||||
// each (x,z) gets a stable but independent stream (used for the random bedrock
|
||||
// layer). Mixing in the world seed keeps worlds with the same terrain shape but
|
||||
// different seeds distinct at the floor.
|
||||
func newColumnRand(wx, wz, seed int) chunkRand {
|
||||
h := uint64(seed)
|
||||
h ^= uint64(uint32(wx)) * 0x9E3779B97F4A7C15
|
||||
h ^= uint64(uint32(wz)) * 0xC2B2AE3D27D4EB4F
|
||||
return chunkRand{s: h | 1}
|
||||
}
|
||||
|
||||
func (r *chunkRand) next() uint32 {
|
||||
r.s += 0x9E3779B97F4A7C15
|
||||
z := r.s
|
||||
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
|
||||
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
|
||||
z = z ^ (z >> 31)
|
||||
return uint32(z >> 32)
|
||||
}
|
||||
|
||||
func trilerp(c *cornerGrid, x0, y0, z0 int, fx, fy, fz float64) float64 {
|
||||
x1, y1, z1 := x0+1, y0+1, z0+1
|
||||
c00 := lerpf(fx, c[x0][y0][z0], c[x1][y0][z0])
|
||||
c10 := lerpf(fx, c[x0][y1][z0], c[x1][y1][z0])
|
||||
c01 := lerpf(fx, c[x0][y0][z1], c[x1][y0][z1])
|
||||
c11 := lerpf(fx, c[x0][y1][z1], c[x1][y1][z1])
|
||||
return lerpf(fz, lerpf(fy, c00, c10), lerpf(fy, c01, c11))
|
||||
}
|
||||
|
||||
func lerpf(t, a, b float64) float64 { return a + t*(b-a) }
|
||||
7
internal/world/vanilla_bench_test.go
Normal file
7
internal/world/vanilla_bench_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package world
|
||||
import "testing"
|
||||
func BenchmarkGenerateVanilla(b *testing.B){
|
||||
g:=NewVanillaGenerator(12345)
|
||||
b.ResetTimer(); b.ReportAllocs()
|
||||
for i:=0;i<b.N;i++{ _=g(int32(i),0) }
|
||||
}
|
||||
45
internal/world/vanilla_parity_test.go
Normal file
45
internal/world/vanilla_parity_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestVanillaParity compares our generated surface heights against heights
|
||||
// captured from the official server (seed 12345, normal terrain). Requires
|
||||
// /tmp/vanilla_ground.json from the capture step.
|
||||
func TestVanillaParity(t *testing.T) {
|
||||
raw, err := os.ReadFile("/tmp/vanilla_ground.json")
|
||||
if err != nil {
|
||||
t.Skip("no vanilla capture")
|
||||
}
|
||||
var van map[string][]int
|
||||
json.Unmarshal(raw, &van)
|
||||
|
||||
gen := NewVanillaGenerator(12345)
|
||||
var total, exact, within1, within3 int
|
||||
var maxDiff int
|
||||
for key, vh := range van {
|
||||
parts := strings.Split(key, ",")
|
||||
cx, _ := strconv.Atoi(parts[0])
|
||||
cz, _ := strconv.Atoi(parts[1])
|
||||
ch := gen(int32(cx), int32(cz))
|
||||
oh := ch.columnHeights()
|
||||
for idx := 0; idx < 256; idx++ {
|
||||
ourY := int(oh[idx]) - 65
|
||||
d := int(math.Abs(float64(ourY - vh[idx])))
|
||||
total++
|
||||
if d == 0 { exact++ }
|
||||
if d <= 1 { within1++ }
|
||||
if d <= 3 { within3++ }
|
||||
if d > maxDiff { maxDiff = d }
|
||||
}
|
||||
}
|
||||
pct := func(n int) float64 { return 100 * float64(n) / float64(total) }
|
||||
t.Logf("columns=%d exact=%.1f%% within1=%.1f%% within3=%.1f%% maxDiff=%d",
|
||||
total, pct(exact), pct(within1), pct(within3), maxDiff)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue