Implement multiplayer persistence and vanilla lighting
This commit is contained in:
parent
8f7cacf9d9
commit
cae06eb97e
47 changed files with 3784 additions and 465 deletions
|
|
@ -3,6 +3,8 @@ package world
|
|||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -36,13 +38,23 @@ type Cache struct {
|
|||
store *Store // nil = in-memory only (tests, flat worlds)
|
||||
maxChunks int // LRU capacity; 0 = unbounded
|
||||
|
||||
mu sync.Mutex
|
||||
chunks map[[2]int32]*Chunk
|
||||
frames map[[2]int32][]byte
|
||||
dirty map[[2]int32]struct{}
|
||||
mu sync.Mutex
|
||||
lightMu sync.Mutex
|
||||
chunks map[[2]int32]*Chunk
|
||||
frames map[[2]int32][]byte
|
||||
dirty map[[2]int32]uint64
|
||||
// LRU bookkeeping: order is MRU(front)→LRU(back); index gives O(1) lookup.
|
||||
order *list.List // elements are *[2]int32; nil when maxChunks==0
|
||||
order *list.List // elements are *[2]int32; nil when maxChunks==0
|
||||
index map[[2]int32]*list.Element
|
||||
loads map[[2]int32]*chunkLoad
|
||||
}
|
||||
|
||||
// chunkLoad coordinates concurrent misses for the same coordinate. The first
|
||||
// caller performs disk I/O or generation; all others wait for that exact result.
|
||||
type chunkLoad struct {
|
||||
done chan struct{}
|
||||
ch *Chunk
|
||||
err error
|
||||
}
|
||||
|
||||
// NewCache returns a world cache that frames packets at the given compression
|
||||
|
|
@ -54,7 +66,8 @@ func NewCache(threshold int32, gen Generator) *Cache {
|
|||
gen: gen,
|
||||
chunks: make(map[[2]int32]*Chunk),
|
||||
frames: make(map[[2]int32][]byte),
|
||||
dirty: make(map[[2]int32]struct{}),
|
||||
dirty: make(map[[2]int32]uint64),
|
||||
loads: make(map[[2]int32]*chunkLoad),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
|
@ -74,6 +87,9 @@ func NewCacheWithStore(threshold int32, gen Generator, store *Store) *Cache {
|
|||
// ≈ maxChunks×200KiB.
|
||||
func NewCacheWithLimit(threshold int32, gen Generator, store *Store, maxChunks int) *Cache {
|
||||
c := NewCacheWithStore(threshold, gen, store)
|
||||
if maxChunks < 0 {
|
||||
maxChunks = 0
|
||||
}
|
||||
c.maxChunks = maxChunks
|
||||
if maxChunks > 0 {
|
||||
c.order = list.New()
|
||||
|
|
@ -84,7 +100,7 @@ func NewCacheWithLimit(threshold int32, gen Generator, store *Store, maxChunks i
|
|||
|
||||
// touch marks key as most-recently-used. Must be called under c.mu.
|
||||
func (c *Cache) touch(key [2]int32) {
|
||||
if c.maxChunks == 0 {
|
||||
if c.maxChunks <= 0 {
|
||||
return
|
||||
}
|
||||
if e, ok := c.index[key]; ok {
|
||||
|
|
@ -98,10 +114,11 @@ func (c *Cache) touch(key [2]int32) {
|
|||
// Dirty chunks are skipped (moved back to MRU and the eviction halts) so the
|
||||
// autosave can persist them first. Must be called under c.mu.
|
||||
func (c *Cache) evictIfNeeded() {
|
||||
if c.maxChunks == 0 {
|
||||
if c.maxChunks <= 0 {
|
||||
return
|
||||
}
|
||||
for len(c.chunks) > c.maxChunks {
|
||||
checked := 0
|
||||
for len(c.chunks) > c.maxChunks && checked < len(c.chunks) {
|
||||
back := c.order.Back()
|
||||
if back == nil {
|
||||
return
|
||||
|
|
@ -112,12 +129,14 @@ func (c *Cache) evictIfNeeded() {
|
|||
// next eviction pass can reclaim it.
|
||||
if _, dirty := c.dirty[key]; dirty && c.store != nil {
|
||||
c.order.MoveToFront(back)
|
||||
break
|
||||
checked++
|
||||
continue
|
||||
}
|
||||
delete(c.chunks, key)
|
||||
delete(c.frames, key)
|
||||
c.order.Remove(back)
|
||||
delete(c.index, key)
|
||||
checked = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,66 +144,110 @@ func (c *Cache) evictIfNeeded() {
|
|||
// disk (if a store is attached) → generation. Generation and disk reads run
|
||||
// outside the lock.
|
||||
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
||||
ch, _ := c.chunkAtErr(cx, cz)
|
||||
return ch
|
||||
}
|
||||
|
||||
// chunkAtErr is the error-preserving form used by network and mutation paths.
|
||||
// A corrupt or unreadable stored chunk is never replaced by generated terrain.
|
||||
func (c *Cache) chunkAtErr(cx, cz int32) (*Chunk, error) {
|
||||
key := [2]int32{cx, cz}
|
||||
|
||||
c.mu.Lock()
|
||||
if ch, ok := c.chunks[key]; ok {
|
||||
c.touch(key)
|
||||
c.mu.Unlock()
|
||||
return ch
|
||||
return ch, nil
|
||||
}
|
||||
if pending, ok := c.loads[key]; ok {
|
||||
c.mu.Unlock()
|
||||
<-pending.done
|
||||
return pending.ch, pending.err
|
||||
}
|
||||
pending := &chunkLoad{done: make(chan struct{})}
|
||||
c.loads[key] = pending
|
||||
c.mu.Unlock()
|
||||
|
||||
// Try disk before generation so saved edits survive restarts.
|
||||
var ch *Chunk
|
||||
var loadErr error
|
||||
if c.store != nil {
|
||||
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
|
||||
ch = loaded
|
||||
} else if !errors.Is(err, ErrChunkNotFound) {
|
||||
loadErr = fmt.Errorf("world: load chunk (%d,%d): %w", cx, cz, err)
|
||||
}
|
||||
}
|
||||
if ch == nil {
|
||||
if ch == nil && loadErr == nil {
|
||||
ch = c.gen(cx, cz) // generate outside the lock
|
||||
if ch == nil {
|
||||
loadErr = fmt.Errorf("world: generator returned nil chunk (%d,%d)", cx, cz)
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if existing, ok := c.chunks[key]; ok {
|
||||
if loadErr == nil {
|
||||
c.chunks[key] = ch
|
||||
c.touch(key)
|
||||
return existing // another goroutine won the race
|
||||
c.evictIfNeeded()
|
||||
}
|
||||
c.chunks[key] = ch
|
||||
c.touch(key)
|
||||
c.evictIfNeeded()
|
||||
return ch
|
||||
pending.ch, pending.err = ch, loadErr
|
||||
delete(c.loads, key)
|
||||
close(pending.done)
|
||||
c.mu.Unlock()
|
||||
return ch, loadErr
|
||||
}
|
||||
|
||||
// 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 {
|
||||
frame, _ := c.FrameErr(cx, cz)
|
||||
return frame
|
||||
}
|
||||
|
||||
// FrameErr returns a framed chunk packet while preserving storage failures.
|
||||
// Callers serving clients should prefer it to Frame so corruption is observable.
|
||||
func (c *Cache) FrameErr(cx, cz int32) ([]byte, error) {
|
||||
key := [2]int32{cx, cz}
|
||||
|
||||
c.mu.Lock()
|
||||
if f, ok := c.frames[key]; ok {
|
||||
c.touch(key)
|
||||
for {
|
||||
c.mu.Lock()
|
||||
if f, ok := c.frames[key]; ok {
|
||||
c.touch(key)
|
||||
c.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return f
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
ch := c.chunkAt(cx, cz)
|
||||
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, ch.Encode())
|
||||
ch, err := c.chunkAtErr(cx, cz)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.ensureLight(ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, revision := ch.snapshot()
|
||||
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, snapshot.encode())
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if existing, ok := c.frames[key]; ok {
|
||||
c.mu.Lock()
|
||||
if existing, ok := c.frames[key]; ok {
|
||||
c.touch(key)
|
||||
c.mu.Unlock()
|
||||
return existing, nil
|
||||
}
|
||||
// An edit or eviction while the frame was being built makes it stale.
|
||||
// Retry from a fresh snapshot instead of publishing old bytes forever.
|
||||
if c.chunks[key] != ch || ch.currentRevision() != revision {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
c.frames[key] = frame
|
||||
c.touch(key)
|
||||
return existing
|
||||
c.evictIfNeeded()
|
||||
c.mu.Unlock()
|
||||
return frame, nil
|
||||
}
|
||||
c.frames[key] = frame
|
||||
c.touch(key)
|
||||
c.evictIfNeeded()
|
||||
return frame
|
||||
}
|
||||
|
||||
// GetBlock returns the block state at world coordinates (x, y, z).
|
||||
|
|
@ -195,32 +258,82 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
|
|||
}
|
||||
cx := int32(x >> 4)
|
||||
cz := int32(z >> 4)
|
||||
ch := c.chunkAt(cx, cz)
|
||||
ch, err := c.chunkAtErr(cx, cz)
|
||||
if err != nil {
|
||||
return StateAir
|
||||
}
|
||||
return ch.GetBlock(x, y, z)
|
||||
}
|
||||
|
||||
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
|
||||
// affected chunk's cached frame and marking it dirty for autosave. It reports
|
||||
// whether a chunk was actually touched (false if y is out of range).
|
||||
// LightUpdate returns the standalone light_update body for a loaded chunk.
|
||||
func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) {
|
||||
ch, err := c.chunkAtErr(cx, cz)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.ensureLight(ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ch.EncodeLightUpdate(), nil
|
||||
}
|
||||
|
||||
// SetBlock changes a block and incrementally updates lighting. Callers that need
|
||||
// to broadcast every affected light chunk should use SetBlockWithLight.
|
||||
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
||||
valid, _ := c.SetBlockWithLight(x, y, z, state)
|
||||
return valid
|
||||
}
|
||||
|
||||
// ChunkPos identifies a chunk changed by a lighting update.
|
||||
type ChunkPos struct {
|
||||
X, Z int32
|
||||
}
|
||||
|
||||
// SetBlockWithLight changes a block and returns the loaded chunks whose stored
|
||||
// light changed. Lighting operations are serialized so concurrent edits cannot
|
||||
// publish mutually stale propagation results.
|
||||
func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos) {
|
||||
if y < MinY || y >= MinY+WorldHeight {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
cx := int32(x >> 4)
|
||||
cz := int32(z >> 4)
|
||||
ch := c.chunkAt(cx, cz)
|
||||
ch, err := c.chunkAtErr(cx, cz)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ch.SetBlock(x, y, z, state)
|
||||
c.lightMu.Lock()
|
||||
defer c.lightMu.Unlock()
|
||||
live := c.cachedLightNeighborhood(cx, cz)
|
||||
for _, neighbor := range live {
|
||||
if err := c.ensureLightLocked(neighbor); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
_, changed := ch.setBlock(x, y, z, state)
|
||||
if !changed {
|
||||
return true, nil
|
||||
}
|
||||
lightChanged, err := c.updateLightAfterBlockLocked(x, y, z, live)
|
||||
if err != nil {
|
||||
// The block edit is still valid and dirty; a later Frame call will rebuild
|
||||
// its light from the authoritative blocks.
|
||||
ch.mu.Lock()
|
||||
ch.lightReady = false
|
||||
ch.mu.Unlock()
|
||||
lightChanged = []ChunkPos{{X: cx, Z: cz}}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
key := [2]int32{cx, cz}
|
||||
delete(c.frames, key)
|
||||
if c.store != nil {
|
||||
c.dirty[key] = struct{}{}
|
||||
c.dirty[key] = ch.currentRevision()
|
||||
}
|
||||
c.touch(key) // edited chunk is most-recently-used
|
||||
c.mu.Unlock()
|
||||
return true
|
||||
return true, lightChanged
|
||||
}
|
||||
|
||||
// markDirty flags the chunk at (cx, cz) for the next autosave. Public so tests
|
||||
|
|
@ -230,7 +343,10 @@ func (c *Cache) markDirty(cx, cz int32) {
|
|||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.dirty[[2]int32{cx, cz}] = struct{}{}
|
||||
key := [2]int32{cx, cz}
|
||||
if ch := c.chunks[key]; ch != nil {
|
||||
c.dirty[key] = ch.currentRevision()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
@ -277,22 +393,32 @@ func (c *Cache) flushDirty() error {
|
|||
for _, k := range keys {
|
||||
chunks[k] = c.chunks[k]
|
||||
}
|
||||
c.dirty = make(map[[2]int32]struct{})
|
||||
c.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
for _, k := range keys {
|
||||
ch := chunks[k]
|
||||
if ch == nil {
|
||||
c.mu.Lock()
|
||||
delete(c.dirty, k)
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if err := c.store.SaveChunk(ch); err != nil {
|
||||
c.mu.Lock()
|
||||
c.dirty[k] = struct{}{} // re-mark; retry next cycle
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
snapshot, savedRevision := ch.snapshot()
|
||||
if err := c.store.saveSnapshot(snapshot); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
c.mu.Lock()
|
||||
if dirtyRevision, ok := c.dirty[k]; ok && dirtyRevision <= savedRevision {
|
||||
delete(c.dirty, k)
|
||||
}
|
||||
c.evictIfNeeded()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// SaveAll synchronously persists every chunk currently in memory. Used at
|
||||
|
|
@ -301,24 +427,5 @@ func (c *Cache) SaveAll() error {
|
|||
if c.store == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
keys := make([][2]int32, 0, len(c.chunks))
|
||||
for k := range c.chunks {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
chunks := make(map[[2]int32]*Chunk, len(keys))
|
||||
for _, k := range keys {
|
||||
chunks[k] = c.chunks[k]
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
for _, k := range keys {
|
||||
if ch := chunks[k]; ch != nil {
|
||||
if err := c.store.SaveChunk(ch); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return c.flushDirty()
|
||||
}
|
||||
|
|
|
|||
174
internal/world/cache_light.go
Normal file
174
internal/world/cache_light.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ensureLight calculates an exact center-chunk solution from a 3x3 block
|
||||
// neighborhood. Light attenuates to zero within 15 blocks, so chunks outside
|
||||
// that neighborhood cannot affect the center chunk.
|
||||
func (c *Cache) ensureLight(chunk *Chunk) error {
|
||||
c.lightMu.Lock()
|
||||
defer c.lightMu.Unlock()
|
||||
return c.ensureLightLocked(chunk)
|
||||
}
|
||||
|
||||
func (c *Cache) ensureLightLocked(chunk *Chunk) error {
|
||||
for {
|
||||
center, revision := chunk.snapshot()
|
||||
if center.lightReady {
|
||||
return nil
|
||||
}
|
||||
|
||||
chunks := make(map[[2]int32]*Chunk, 9)
|
||||
for dz := int32(-1); dz <= 1; dz++ {
|
||||
for dx := int32(-1); dx <= 1; dx++ {
|
||||
key := [2]int32{chunk.X + dx, chunk.Z + dz}
|
||||
if dx == 0 && dz == 0 {
|
||||
chunks[key] = center
|
||||
continue
|
||||
}
|
||||
snapshot, err := c.lightInputSnapshot(key[0], key[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chunks[key] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
volume := newLightVolume(int(chunk.X-1), int(chunk.Z-1), 3, 3, chunks)
|
||||
clear(volume.sky)
|
||||
clear(volume.block)
|
||||
volume.calculate()
|
||||
|
||||
chunk.mu.Lock()
|
||||
if chunk.revision.Load() != revision {
|
||||
chunk.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
chunk.installLight(volume)
|
||||
chunk.mu.Unlock()
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.frames, [2]int32{chunk.X, chunk.Z})
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// lightInputSnapshot returns blocks for a neighboring chunk without inserting
|
||||
// a cache miss into the LRU. This keeps lighting correct even for very small
|
||||
// cache limits and avoids an eight-chunk eviction cascade per frame.
|
||||
func (c *Cache) lightInputSnapshot(cx, cz int32) (*Chunk, error) {
|
||||
key := [2]int32{cx, cz}
|
||||
c.mu.Lock()
|
||||
if chunk := c.chunks[key]; chunk != nil {
|
||||
c.touch(key)
|
||||
c.mu.Unlock()
|
||||
snapshot, _ := chunk.snapshot()
|
||||
return snapshot, nil
|
||||
}
|
||||
if pending := c.loads[key]; pending != nil {
|
||||
c.mu.Unlock()
|
||||
<-pending.done
|
||||
if pending.err != nil {
|
||||
return nil, pending.err
|
||||
}
|
||||
snapshot, _ := pending.ch.snapshot()
|
||||
return snapshot, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.store != nil {
|
||||
loaded, err := c.store.LoadChunk(cx, cz)
|
||||
if err == nil {
|
||||
snapshot, _ := loaded.snapshot()
|
||||
return snapshot, nil
|
||||
}
|
||||
if !errors.Is(err, ErrChunkNotFound) {
|
||||
return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err)
|
||||
}
|
||||
}
|
||||
generated := c.gen(cx, cz)
|
||||
if generated == nil {
|
||||
return nil, fmt.Errorf("world: generator returned nil light neighbor (%d,%d)", cx, cz)
|
||||
}
|
||||
snapshot, _ := generated.snapshot()
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (c *Cache) cachedLightNeighborhood(cx, cz int32) map[[2]int32]*Chunk {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
chunks := make(map[[2]int32]*Chunk, 9)
|
||||
for dz := int32(-1); dz <= 1; dz++ {
|
||||
for dx := int32(-1); dx <= 1; dx++ {
|
||||
key := [2]int32{cx + dx, cz + dz}
|
||||
if chunk := c.chunks[key]; chunk != nil {
|
||||
chunks[key] = chunk
|
||||
c.touch(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (c *Cache) updateLightAfterBlockLocked(x, y, z int, live map[[2]int32]*Chunk) ([]ChunkPos, error) {
|
||||
cx, cz := int32(x>>4), int32(z>>4)
|
||||
inputs := make(map[[2]int32]*Chunk, 9)
|
||||
for dz := int32(-1); dz <= 1; dz++ {
|
||||
for dx := int32(-1); dx <= 1; dx++ {
|
||||
key := [2]int32{cx + dx, cz + dz}
|
||||
if chunk := live[key]; chunk != nil {
|
||||
snapshot, _ := chunk.snapshot()
|
||||
inputs[key] = snapshot
|
||||
continue
|
||||
}
|
||||
snapshot, err := c.lightInputSnapshot(key[0], key[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputs[key] = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
volume := newLightVolume(int(cx-1), int(cz-1), 3, 3, inputs)
|
||||
volume.relaxBlockChange(x-volume.minX, y, z-volume.minZ)
|
||||
|
||||
keys := make([][2]int32, 0, len(live))
|
||||
for key := range live {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i][0] != keys[j][0] {
|
||||
return keys[i][0] < keys[j][0]
|
||||
}
|
||||
return keys[i][1] < keys[j][1]
|
||||
})
|
||||
|
||||
changed := make([]ChunkPos, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
chunk := live[key]
|
||||
chunk.mu.Lock()
|
||||
didChange := chunk.installLight(volume)
|
||||
if didChange {
|
||||
chunk.revision.Add(1)
|
||||
changed = append(changed, ChunkPos{X: key[0], Z: key[1]})
|
||||
}
|
||||
revision := chunk.revision.Load()
|
||||
chunk.mu.Unlock()
|
||||
|
||||
if didChange {
|
||||
c.mu.Lock()
|
||||
delete(c.frames, key)
|
||||
if c.store != nil {
|
||||
c.dirty[key] = revision
|
||||
}
|
||||
c.touch(key)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
||||
// flatGen returns a generator that produces distinct chunks keyed by coordinate,
|
||||
|
|
@ -173,3 +179,72 @@ func TestEvictionReloadPreservesEdits(t *testing.T) {
|
|||
t.Errorf("after eviction+reload, edited block = %d, want bedrock %d", got, StateBedrock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentMissGeneratesChunkOnce(t *testing.T) {
|
||||
var targetCalls atomic.Int32
|
||||
gen := func(cx, cz int32) *Chunk {
|
||||
if cx == 4 && cz == -7 {
|
||||
targetCalls.Add(1)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return NewChunk(cx, cz, BiomePlains)
|
||||
}
|
||||
c := NewCache(256, gen)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if frame, err := c.FrameErr(4, -7); err != nil || len(frame) == 0 {
|
||||
t.Errorf("FrameErr = %d bytes, %v", len(frame), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := targetCalls.Load(); got != 1 {
|
||||
t.Fatalf("target generator calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentFrameAndBlockEdits(t *testing.T) {
|
||||
c := NewCache(256, flatGen())
|
||||
if len(c.Frame(0, 0)) == 0 {
|
||||
t.Fatal("initial frame is empty")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 250; i++ {
|
||||
state := StateStone
|
||||
if i%2 == 0 {
|
||||
state = StateBedrock
|
||||
}
|
||||
c.SetBlock(3, SeaLevel, 3, state)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
if len(c.Frame(0, 0)) == 0 {
|
||||
t.Error("frame became empty")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
c.SetBlock(3, SeaLevel, 3, StateBedrock)
|
||||
if got := c.GetBlock(3, SeaLevel, 3); got != StateBedrock {
|
||||
t.Fatalf("final block = %d, want %d", got, StateBedrock)
|
||||
}
|
||||
finalFrame := c.Frame(0, 0)
|
||||
if len(finalFrame) == 0 {
|
||||
t.Fatal("final frame is empty")
|
||||
}
|
||||
snapshot, _ := c.chunkAt(0, 0).snapshot()
|
||||
wantFrame := protocol.AppendPacket(nil, 256, protocol.PlayLevelChunk, snapshot.encode())
|
||||
if !bytes.Equal(finalFrame, wantFrame) {
|
||||
t.Fatal("cached frame does not represent the final chunk revision")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ 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)
|
||||
c.setBlockRaw(lx, MinY+0, lz, StateBedrock)
|
||||
c.setBlockRaw(lx, MinY+1, lz, StateDirt)
|
||||
c.setBlockRaw(lx, MinY+2, lz, StateDirt)
|
||||
c.setBlockRaw(lx, FlatSurfaceY, lz, StateGrass)
|
||||
}
|
||||
}
|
||||
return c
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ import (
|
|||
// default compression for Anvil .mca chunk records (compression type 2).
|
||||
|
||||
// zlibDeflate compresses src into a new byte slice.
|
||||
func zlibDeflate(src []byte) []byte {
|
||||
func zlibDeflate(src []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := zlib.NewWriter(&buf)
|
||||
_, _ = w.Write(src)
|
||||
_ = w.Close()
|
||||
return buf.Bytes()
|
||||
if _, err := w.Write(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// zlibInflate decompresses src (a zlib stream). It returns an error if src is
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package world
|
|||
|
||||
import (
|
||||
"math/bits"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
|
@ -37,6 +39,7 @@ const (
|
|||
StateSnow uint16 = 6919 // snow, layers=1
|
||||
StateSnowBlock uint16 = 6928
|
||||
StateIce uint16 = 6927
|
||||
StateGlowstone uint16 = 7016
|
||||
StateMycelium uint16 = 8919
|
||||
StateTerracotta uint16 = 12912
|
||||
StateRedSandstone uint16 = 13247
|
||||
|
|
@ -57,19 +60,24 @@ const totalBlockStates = 29873
|
|||
// the biome direct-palette bit width.
|
||||
const (
|
||||
biomeCellSize = 4
|
||||
biomeCellsXZ = 16 / biomeCellSize // 4
|
||||
biomeCellsXZ = 16 / biomeCellSize // 4
|
||||
biomeCellsPerSection = biomeCellsXZ * biomeCellsXZ * biomeCellsXZ // 64
|
||||
totalBiomes = 65 // synced minecraft:worldgen/biome registry size
|
||||
totalBiomes = 65 // synced minecraft:worldgen/biome registry size
|
||||
)
|
||||
|
||||
// Chunk is a 16xWorldHeightx16 column of block states. Each section may carry a
|
||||
// per-cell biome array (4×4×4); when biomes[si] is nil the section falls back to
|
||||
// the column-wide biome field (used by flat/simple generators).
|
||||
type Chunk struct {
|
||||
X, Z int32
|
||||
sections [SectionCount]*[sectionVol]uint16
|
||||
biomes [SectionCount]*[biomeCellsPerSection]uint16
|
||||
biome uint16 // fallback uniform biome when biomes[si] is nil
|
||||
mu sync.RWMutex
|
||||
revision atomic.Uint64
|
||||
X, Z int32
|
||||
sections [SectionCount]*[sectionVol]uint16
|
||||
biomes [SectionCount]*[biomeCellsPerSection]uint16
|
||||
skyLight [SectionCount]*[2048]byte
|
||||
blockLight [SectionCount]*[2048]byte
|
||||
lightReady bool
|
||||
biome uint16 // fallback uniform biome when biomes[si] is nil
|
||||
}
|
||||
|
||||
// NewChunk returns an empty (all-air) chunk at (x, z) with the given biome.
|
||||
|
|
@ -91,6 +99,13 @@ func (c *Chunk) section(i int) *[sectionVol]uint16 {
|
|||
// 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 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.getBlock(lx, y, lz)
|
||||
}
|
||||
|
||||
// getBlock is the lock-free form used while operating on a private snapshot.
|
||||
func (c *Chunk) getBlock(lx, y, lz int) uint16 {
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return StateAir
|
||||
|
|
@ -106,6 +121,8 @@ func (c *Chunk) GetBlock(lx, y, lz int) uint16 {
|
|||
// mirrors GetBlock: the per-section biome array if present, else the column's
|
||||
// uniform fallback biome. Needed for on-disk chunk serialization.
|
||||
func (c *Chunk) GetBiome(lx, y, lz int) uint16 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return c.biome
|
||||
|
|
@ -119,6 +136,18 @@ func (c *Chunk) GetBiome(lx, y, lz int) uint16 {
|
|||
|
||||
// SetBlock sets the block at local (lx, lz) and absolute world height y.
|
||||
func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
||||
_, changed := c.setBlock(lx, y, lz, state)
|
||||
if changed {
|
||||
c.mu.Lock()
|
||||
c.lightReady = false
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// setBlockRaw writes to an unpublished chunk during generation. Generators call
|
||||
// it only after their parallel sampling phases have joined and before the chunk
|
||||
// enters Cache, avoiding a mutex operation for every solid terrain block.
|
||||
func (c *Chunk) setBlockRaw(lx, y, lz int, state uint16) {
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return
|
||||
|
|
@ -126,6 +155,24 @@ func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
|||
c.section(si)[blockIndex(lx, y, lz)] = state
|
||||
}
|
||||
|
||||
// setBlock changes a block and returns the resulting revision. The changed flag
|
||||
// lets the cache avoid dirtying a chunk for a no-op client prediction.
|
||||
func (c *Chunk) setBlock(lx, y, lz int, state uint16) (revision uint64, changed bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return c.revision.Load(), false
|
||||
}
|
||||
idx := blockIndex(lx, y, lz)
|
||||
s := c.section(si)
|
||||
if s[idx] == state {
|
||||
return c.revision.Load(), false
|
||||
}
|
||||
s[idx] = state
|
||||
return c.revision.Add(1), true
|
||||
}
|
||||
|
||||
// biomeIndex maps a block within a section to its YZX-ordered 4×4×4 biome cell.
|
||||
// Coordinates are folded into 0..15 (block coords) then divided to cell coords.
|
||||
func biomeIndex(lx, ly, lz int) int {
|
||||
|
|
@ -142,18 +189,70 @@ const biomeCellsXZBits = 2 // biomeCellsXZ=4 → 2 bits
|
|||
// section's per-cell biome array is allocated lazily on first write. Any block
|
||||
// in the cell shares its biome, matching the 4-block resolution vanilla uses.
|
||||
func (c *Chunk) SetBiome(lx, y, lz int, biome uint16) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return
|
||||
}
|
||||
if c.biomes[si] == nil {
|
||||
c.biomes[si] = new([biomeCellsPerSection]uint16)
|
||||
cells := new([biomeCellsPerSection]uint16)
|
||||
for i := range cells {
|
||||
cells[i] = c.biome
|
||||
}
|
||||
c.biomes[si] = cells
|
||||
}
|
||||
idx := biomeIndex(lx, y, lz)
|
||||
if c.biomes[si][idx] != biome {
|
||||
c.biomes[si][idx] = biome
|
||||
c.revision.Add(1)
|
||||
}
|
||||
c.biomes[si][biomeIndex(lx, y, lz)] = biome
|
||||
}
|
||||
|
||||
// Encode serializes the level_chunk_with_light body for this chunk.
|
||||
func (c *Chunk) Encode() []byte {
|
||||
snapshot, _ := c.snapshot()
|
||||
return snapshot.encode()
|
||||
}
|
||||
|
||||
// snapshot returns a detached, immutable copy and the revision it represents.
|
||||
// Section arrays are copied so encoding and persistence never race block edits.
|
||||
func (c *Chunk) snapshot() (*Chunk, uint64) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
revision := c.revision.Load()
|
||||
clone := &Chunk{X: c.X, Z: c.Z, biome: c.biome}
|
||||
clone.revision.Store(revision)
|
||||
for i := 0; i < SectionCount; i++ {
|
||||
if c.sections[i] != nil {
|
||||
section := *c.sections[i]
|
||||
clone.sections[i] = §ion
|
||||
}
|
||||
if c.biomes[i] != nil {
|
||||
biomes := *c.biomes[i]
|
||||
clone.biomes[i] = &biomes
|
||||
}
|
||||
if c.skyLight[i] != nil {
|
||||
light := *c.skyLight[i]
|
||||
clone.skyLight[i] = &light
|
||||
}
|
||||
if c.blockLight[i] != nil {
|
||||
light := *c.blockLight[i]
|
||||
clone.blockLight[i] = &light
|
||||
}
|
||||
}
|
||||
clone.lightReady = c.lightReady
|
||||
return clone, revision
|
||||
}
|
||||
|
||||
// currentRevision returns the latest mutation revision.
|
||||
func (c *Chunk) currentRevision() uint64 {
|
||||
return c.revision.Load()
|
||||
}
|
||||
|
||||
// encode serializes a detached snapshot.
|
||||
func (c *Chunk) encode() []byte {
|
||||
w := protocol.NewWriter(8192)
|
||||
w.Int32(c.X).Int32(c.Z)
|
||||
c.writeHeightmaps(w)
|
||||
|
|
@ -173,8 +272,8 @@ func (c *Chunk) Encode() []byte {
|
|||
|
||||
// Heightmap.Types ordinals sent to the client.
|
||||
const (
|
||||
hmWorldSurface = 1
|
||||
hmMotionBlocking = 4
|
||||
hmWorldSurface = 1
|
||||
hmMotionBlocking = 4
|
||||
hmMotionBlockingNoLeaves = 5
|
||||
)
|
||||
|
||||
|
|
@ -232,8 +331,8 @@ func packHeightmap(h [256]uint16) []uint64 {
|
|||
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)
|
||||
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)))
|
||||
|
|
|
|||
|
|
@ -3,22 +3,21 @@ package world
|
|||
import (
|
||||
"crypto/rand"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Entity represents an in-game movable entity (mob, animal, etc).
|
||||
type Entity struct {
|
||||
ID int32
|
||||
UUID [16]byte
|
||||
TypeID int // Network ID from the minecraft:entity_type registry
|
||||
TypeID int // Network ID from the built-in minecraft:entity_type registry
|
||||
TypeName string
|
||||
|
||||
X, Y, Z float64
|
||||
Pitch, Yaw float32
|
||||
HeadYaw float32
|
||||
VelocityX int16
|
||||
VelocityY int16
|
||||
VelocityZ int16
|
||||
X, Y, Z float64
|
||||
Pitch, Yaw float32
|
||||
HeadYaw float32
|
||||
VelocityX int16
|
||||
VelocityY int16
|
||||
VelocityZ int16
|
||||
}
|
||||
|
||||
// EntityManager tracks active entities in the server and manages thread-safe access.
|
||||
|
|
@ -40,7 +39,8 @@ func NewEntityManager() *EntityManager {
|
|||
func (em *EntityManager) Add(e *Entity) int32 {
|
||||
em.mu.Lock()
|
||||
defer em.mu.Unlock()
|
||||
e.ID = atomic.AddInt32(&em.nextID, 1)
|
||||
em.nextID++
|
||||
e.ID = em.nextID
|
||||
if e.UUID == [16]byte{} {
|
||||
rand.Read(e.UUID[:])
|
||||
// Version 4 UUID
|
||||
|
|
@ -58,20 +58,41 @@ func (em *EntityManager) Remove(id int32) {
|
|||
delete(em.entities, id)
|
||||
}
|
||||
|
||||
// Get retrieves an entity by ID, or nil if not found.
|
||||
// Get retrieves a snapshot of an entity by ID, or nil if not found.
|
||||
func (em *EntityManager) Get(id int32) *Entity {
|
||||
em.mu.RLock()
|
||||
defer em.mu.RUnlock()
|
||||
return em.entities[id]
|
||||
e := em.entities[id]
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *e
|
||||
return ©
|
||||
}
|
||||
|
||||
// Update executes a function on an entity under a write lock.
|
||||
func (em *EntityManager) Update(id int32, fn func(*Entity)) {
|
||||
em.mu.Lock()
|
||||
defer em.mu.Unlock()
|
||||
if e, ok := em.entities[id]; ok {
|
||||
fn(e)
|
||||
}
|
||||
}
|
||||
|
||||
// All returns a snapshot slice of all active entities.
|
||||
func (em *EntityManager) All() []*Entity {
|
||||
func (em *EntityManager) All() []Entity {
|
||||
em.mu.RLock()
|
||||
defer em.mu.RUnlock()
|
||||
list := make([]*Entity, 0, len(em.entities))
|
||||
list := make([]Entity, 0, len(em.entities))
|
||||
for _, e := range em.entities {
|
||||
list = append(list, e)
|
||||
list = append(list, *e)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Count returns the number of active entities.
|
||||
func (em *EntityManager) Count() int {
|
||||
em.mu.RLock()
|
||||
defer em.mu.RUnlock()
|
||||
return len(em.entities)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ var oreSpecs = []oreSpec{
|
|||
{"minecraft:gold_ore", MinY, MinY + 32, 4, 4, 0.25},
|
||||
{"minecraft:redstone_ore", MinY, MinY + 16, 4, 4, 0.3},
|
||||
{"minecraft:lapis_ore", MinY, MinY + 32, 3, 4, 0.25},
|
||||
{"minecraft:diamond_ore", MinY, MinY - 16 + 16, 3, 3, 0.2}, // -64..-16-ish
|
||||
{"minecraft:diamond_ore", MinY, MinY + 16, 3, 3, 0.2}, // -64..-48
|
||||
{"minecraft:emerald_ore", MinY + 16, MinY + 48, 1, 1, 0.15},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,50 @@ func extractSectionData(t *testing.T, body []byte) []byte {
|
|||
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).
|
||||
// extractLightData returns the complete LightData tail of level_chunk_with_light.
|
||||
func extractLightData(t *testing.T, body []byte) []byte {
|
||||
t.Helper()
|
||||
r := protocol.NewReader(body[8:])
|
||||
heightmaps, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := int32(0); i < heightmaps; i++ {
|
||||
if _, err := r.VarInt(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
longs, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for j := int32(0); j < longs; j++ {
|
||||
if _, err := r.Int64(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
sectionBytes, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := int32(0); i < sectionBytes; i++ {
|
||||
if _, err := r.ReadByte(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
blockEntities, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blockEntities != 0 {
|
||||
t.Fatalf("fixture has %d block entities; extractor only supports zero", blockEntities)
|
||||
}
|
||||
offset := len(body) - r.Remaining()
|
||||
return body[offset:]
|
||||
}
|
||||
|
||||
// TestGoldenAgainstVanilla asserts our flat chunk's section and light data are
|
||||
// byte-for-byte identical to a capture from the official 26.1.2 server.
|
||||
func TestGoldenAgainstVanilla(t *testing.T) {
|
||||
vanilla, err := os.ReadFile("testdata/vanilla_flat_chunk.bin")
|
||||
if err != nil {
|
||||
|
|
@ -43,4 +83,54 @@ func TestGoldenAgainstVanilla(t *testing.T) {
|
|||
if !bytes.Equal(want, got) {
|
||||
t.Fatalf("section data differs: vanilla=%d bytes, ours=%d bytes", len(want), len(got))
|
||||
}
|
||||
|
||||
wantLight := extractLightData(t, vanilla)
|
||||
gotLight := extractLightData(t, ours)
|
||||
if !bytes.Equal(wantLight, gotLight) {
|
||||
first := 0
|
||||
for first < len(wantLight) && first < len(gotLight) && wantLight[first] == gotLight[first] {
|
||||
first++
|
||||
}
|
||||
t.Fatalf("light data differs at byte %d: vanilla=%d bytes %v %x, ours=%d bytes %v %x", first, len(wantLight), lightSummary(t, wantLight), wantLight[:24], len(gotLight), lightSummary(t, gotLight), gotLight[:24])
|
||||
}
|
||||
}
|
||||
|
||||
func lightSummary(t *testing.T, data []byte) [6]uint64 {
|
||||
t.Helper()
|
||||
r := protocol.NewReader(data)
|
||||
var summary [6]uint64
|
||||
for i := 0; i < 4; i++ {
|
||||
longs, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for j := int32(0); j < longs; j++ {
|
||||
value, err := r.Int64()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if j == 0 {
|
||||
summary[i] = uint64(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
count, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary[4+i] = uint64(count)
|
||||
for j := int32(0); j < count; j++ {
|
||||
length, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k := int32(0); k < length; k++ {
|
||||
if _, err := r.ReadByte(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,119 +2,484 @@ 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.
|
||||
// Light is stored as vanilla nibble arrays: one 2048-byte array per 16^3
|
||||
// section. The two protocol-only sections below and above the world are added
|
||||
// while encoding.
|
||||
const lightSections = SectionCount + 2
|
||||
|
||||
// writeLight computes and emits simple lighting data. It does a vertical pass
|
||||
// for sky light (sunlight propagating downward) and a single-block pass for
|
||||
// block light (emissive blocks), without horizontal flood-fill.
|
||||
func (c *Chunk) writeLight(w *protocol.Writer) {
|
||||
skyLight := make([]*[2048]byte, lightSections)
|
||||
blockLight := make([]*[2048]byte, lightSections)
|
||||
type lightVolume struct {
|
||||
minX, minZ int
|
||||
width int
|
||||
depth int
|
||||
blocks []uint16
|
||||
sky []byte
|
||||
block []byte
|
||||
}
|
||||
|
||||
// Section lightSections-1 is above the world, fully lit by the sky.
|
||||
skyLight[lightSections-1] = new([2048]byte)
|
||||
for i := range skyLight[lightSections-1] {
|
||||
skyLight[lightSections-1][i] = 0xFF
|
||||
type lightNode struct {
|
||||
x, y, z int
|
||||
}
|
||||
|
||||
var lightDirections = [...]lightNode{
|
||||
{0, -1, 0},
|
||||
{0, 1, 0},
|
||||
{0, 0, -1},
|
||||
{0, 0, 1},
|
||||
{-1, 0, 0},
|
||||
{1, 0, 0},
|
||||
}
|
||||
|
||||
func newLightVolume(minCX, minCZ, chunksWide, chunksDeep int, chunks map[[2]int32]*Chunk) *lightVolume {
|
||||
v := &lightVolume{
|
||||
minX: minCX * 16,
|
||||
minZ: minCZ * 16,
|
||||
width: chunksWide * 16,
|
||||
depth: chunksDeep * 16,
|
||||
}
|
||||
|
||||
for lx := 0; lx < 16; lx++ {
|
||||
for lz := 0; lz < 16; lz++ {
|
||||
// Sky light pass
|
||||
currentSky := byte(15)
|
||||
for y := MinY + WorldHeight - 1; y >= MinY; y-- {
|
||||
block := c.GetBlock(lx, y, lz)
|
||||
op := blockOpacity[block]
|
||||
if op >= currentSky {
|
||||
currentSky = 0
|
||||
} else {
|
||||
currentSky -= op
|
||||
}
|
||||
|
||||
if currentSky > 0 {
|
||||
si := (y - MinY) >> 4
|
||||
lsi := si + 1
|
||||
if skyLight[lsi] == nil {
|
||||
skyLight[lsi] = new([2048]byte)
|
||||
}
|
||||
idx := blockIndex(lx, y, lz)
|
||||
if idx%2 == 0 {
|
||||
skyLight[lsi][idx/2] |= currentSky
|
||||
} else {
|
||||
skyLight[lsi][idx/2] |= currentSky << 4
|
||||
}
|
||||
}
|
||||
|
||||
// Block light pass
|
||||
em := blockEmission[block]
|
||||
if em > 0 {
|
||||
si := (y - MinY) >> 4
|
||||
lsi := si + 1
|
||||
if blockLight[lsi] == nil {
|
||||
blockLight[lsi] = new([2048]byte)
|
||||
}
|
||||
idx := blockIndex(lx, y, lz)
|
||||
if idx%2 == 0 {
|
||||
blockLight[lsi][idx/2] |= em
|
||||
} else {
|
||||
blockLight[lsi][idx/2] |= em << 4
|
||||
count := v.width * WorldHeight * v.depth
|
||||
v.blocks = make([]uint16, count)
|
||||
v.sky = make([]byte, count)
|
||||
v.block = make([]byte, count)
|
||||
for key, chunk := range chunks {
|
||||
baseX := int(key[0])*16 - v.minX
|
||||
baseZ := int(key[1])*16 - v.minZ
|
||||
for y := MinY; y < MinY+WorldHeight; y++ {
|
||||
for z := 0; z < 16; z++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
idx := v.indexLocal(baseX+x, y, baseZ+z)
|
||||
v.blocks[idx] = chunk.getBlock(x, y, z)
|
||||
if chunk.lightReady {
|
||||
v.sky[idx] = chunk.getLight(false, x, y, z)
|
||||
v.block[idx] = chunk.getLight(true, x, y, z)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
var skyMask, blockMask, emptySkyMask, emptyBlockMask uint64
|
||||
var skyCount, blockCount int
|
||||
func (v *lightVolume) indexLocal(x, y, z int) int {
|
||||
return ((y-MinY)*v.depth+z)*v.width + x
|
||||
}
|
||||
|
||||
for i := 0; i < lightSections; i++ {
|
||||
if skyLight[i] != nil {
|
||||
skyMask |= 1 << i
|
||||
skyCount++
|
||||
} else {
|
||||
emptySkyMask |= 1 << i
|
||||
func (v *lightVolume) inside(x, y, z int) bool {
|
||||
return x >= 0 && x < v.width && z >= 0 && z < v.depth && y >= MinY && y < MinY+WorldHeight
|
||||
}
|
||||
|
||||
func (v *lightVolume) calculate() {
|
||||
v.calculateSky()
|
||||
v.calculateBlock()
|
||||
}
|
||||
|
||||
func (v *lightVolume) calculateSky() {
|
||||
queue := make([]int, 0, len(v.sky)/2)
|
||||
for z := 0; z < v.depth; z++ {
|
||||
for x := 0; x < v.width; x++ {
|
||||
from := StateAir
|
||||
for y := MinY + WorldHeight - 1; y >= MinY; y-- {
|
||||
idx := v.indexLocal(x, y, z)
|
||||
state := v.blocks[idx]
|
||||
if lightOpacity(state) != 0 || lightShapeOccludes(from, state, 0) {
|
||||
break
|
||||
}
|
||||
v.sky[idx] = 15
|
||||
queue = append(queue, idx)
|
||||
from = state
|
||||
}
|
||||
}
|
||||
}
|
||||
v.propagateIncreases(v.sky, queue)
|
||||
}
|
||||
|
||||
if blockLight[i] != nil {
|
||||
blockMask |= 1 << i
|
||||
blockCount++
|
||||
} else {
|
||||
emptyBlockMask |= 1 << i
|
||||
func (v *lightVolume) calculateBlock() {
|
||||
queue := make([]int, 0, 256)
|
||||
for idx, state := range v.blocks {
|
||||
if emission := lightEmission(state); emission > 0 {
|
||||
v.block[idx] = emission
|
||||
queue = append(queue, idx)
|
||||
}
|
||||
}
|
||||
v.propagateIncreases(v.block, queue)
|
||||
}
|
||||
|
||||
func (v *lightVolume) propagateIncreases(levels []byte, queue []int) {
|
||||
for head := 0; head < len(queue); head++ {
|
||||
idx := queue[head]
|
||||
level := levels[idx]
|
||||
if level <= 1 {
|
||||
continue
|
||||
}
|
||||
x, y, z := v.coordinates(idx)
|
||||
from := v.blocks[idx]
|
||||
for direction, delta := range lightDirections {
|
||||
nx, ny, nz := x+delta.x, y+delta.y, z+delta.z
|
||||
if !v.inside(nx, ny, nz) {
|
||||
continue
|
||||
}
|
||||
nidx := v.indexLocal(nx, ny, nz)
|
||||
into := v.blocks[nidx]
|
||||
attenuation := lightOpacity(into)
|
||||
if attenuation < 1 {
|
||||
attenuation = 1
|
||||
}
|
||||
if attenuation >= level || lightShapeOccludes(from, into, direction) {
|
||||
continue
|
||||
}
|
||||
candidate := level - attenuation
|
||||
if candidate > levels[nidx] {
|
||||
levels[nidx] = candidate
|
||||
queue = append(queue, nidx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *lightVolume) coordinates(idx int) (x, y, z int) {
|
||||
x = idx % v.width
|
||||
row := idx / v.width
|
||||
z = row % v.depth
|
||||
y = row/v.depth + MinY
|
||||
return
|
||||
}
|
||||
|
||||
func (v *lightVolume) relaxBlockChange(x, y, z int) {
|
||||
skySources := v.skySources()
|
||||
blockSeeds := make([]int, 0, 7)
|
||||
if v.inside(x, y, z) {
|
||||
idx := v.indexLocal(x, y, z)
|
||||
blockSeeds = append(blockSeeds, idx)
|
||||
for _, delta := range lightDirections {
|
||||
if v.inside(x+delta.x, y+delta.y, z+delta.z) {
|
||||
blockSeeds = append(blockSeeds, v.indexLocal(x+delta.x, y+delta.y, z+delta.z))
|
||||
}
|
||||
}
|
||||
}
|
||||
v.relax(v.block, nil, blockSeeds)
|
||||
|
||||
skySeeds := make([]int, 0, WorldHeight*2)
|
||||
for sy := MinY; sy < MinY+WorldHeight; sy++ {
|
||||
idx := v.indexLocal(x, sy, z)
|
||||
skySeeds = append(skySeeds, idx)
|
||||
for _, direction := range []int{4, 5, 2, 3} {
|
||||
delta := lightDirections[direction]
|
||||
if v.inside(x+delta.x, sy, z+delta.z) {
|
||||
skySeeds = append(skySeeds, v.indexLocal(x+delta.x, sy, z+delta.z))
|
||||
}
|
||||
}
|
||||
}
|
||||
v.relax(v.sky, skySources, skySeeds)
|
||||
}
|
||||
|
||||
func (v *lightVolume) skySources() []bool {
|
||||
sources := make([]bool, len(v.sky))
|
||||
for z := 0; z < v.depth; z++ {
|
||||
for x := 0; x < v.width; x++ {
|
||||
from := StateAir
|
||||
for y := MinY + WorldHeight - 1; y >= MinY; y-- {
|
||||
idx := v.indexLocal(x, y, z)
|
||||
state := v.blocks[idx]
|
||||
if lightOpacity(state) != 0 || lightShapeOccludes(from, state, 0) {
|
||||
break
|
||||
}
|
||||
sources[idx] = true
|
||||
from = state
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func (v *lightVolume) relax(levels []byte, sources []bool, seeds []int) {
|
||||
queued := make([]bool, len(levels))
|
||||
queue := make([]int, 0, len(seeds)*2)
|
||||
for _, idx := range seeds {
|
||||
if idx >= 0 && idx < len(levels) && !queued[idx] {
|
||||
queued[idx] = true
|
||||
queue = append(queue, idx)
|
||||
}
|
||||
}
|
||||
for head := 0; head < len(queue); head++ {
|
||||
idx := queue[head]
|
||||
queued[idx] = false
|
||||
desired := v.desiredLight(levels, sources, idx)
|
||||
if desired == levels[idx] {
|
||||
continue
|
||||
}
|
||||
levels[idx] = desired
|
||||
x, y, z := v.coordinates(idx)
|
||||
for _, delta := range lightDirections {
|
||||
nx, ny, nz := x+delta.x, y+delta.y, z+delta.z
|
||||
if !v.inside(nx, ny, nz) {
|
||||
continue
|
||||
}
|
||||
nidx := v.indexLocal(nx, ny, nz)
|
||||
if !queued[nidx] {
|
||||
queued[nidx] = true
|
||||
queue = append(queue, nidx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *lightVolume) desiredLight(levels []byte, sources []bool, idx int) byte {
|
||||
state := v.blocks[idx]
|
||||
desired := lightEmission(state)
|
||||
if sources != nil {
|
||||
desired = 0
|
||||
if sources[idx] {
|
||||
desired = 15
|
||||
}
|
||||
}
|
||||
attenuation := lightOpacity(state)
|
||||
if attenuation < 1 {
|
||||
attenuation = 1
|
||||
}
|
||||
x, y, z := v.coordinates(idx)
|
||||
opposite := [...]int{1, 0, 3, 2, 5, 4}
|
||||
for direction, delta := range lightDirections {
|
||||
nx, ny, nz := x+delta.x, y+delta.y, z+delta.z
|
||||
if !v.inside(nx, ny, nz) {
|
||||
continue
|
||||
}
|
||||
nidx := v.indexLocal(nx, ny, nz)
|
||||
neighbor := levels[nidx]
|
||||
if neighbor <= attenuation || lightShapeOccludes(v.blocks[nidx], state, opposite[direction]) {
|
||||
continue
|
||||
}
|
||||
candidate := neighbor - attenuation
|
||||
if candidate > desired {
|
||||
desired = candidate
|
||||
}
|
||||
}
|
||||
return desired
|
||||
}
|
||||
|
||||
func (c *Chunk) getLight(block bool, x, y, z int) byte {
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return 0
|
||||
}
|
||||
layers := &c.skyLight
|
||||
if block {
|
||||
layers = &c.blockLight
|
||||
}
|
||||
section := layers[si]
|
||||
if section == nil {
|
||||
return 0
|
||||
}
|
||||
idx := blockIndex(x, y, z)
|
||||
b := section[idx>>1]
|
||||
if idx&1 == 0 {
|
||||
return b & 0x0f
|
||||
}
|
||||
return b >> 4
|
||||
}
|
||||
|
||||
func (c *Chunk) setLight(block bool, x, y, z int, value byte) bool {
|
||||
si := (y - MinY) >> 4
|
||||
if si < 0 || si >= SectionCount {
|
||||
return false
|
||||
}
|
||||
layers := &c.skyLight
|
||||
if block {
|
||||
layers = &c.blockLight
|
||||
}
|
||||
section := layers[si]
|
||||
if section == nil {
|
||||
if value == 0 {
|
||||
return false
|
||||
}
|
||||
section = new([2048]byte)
|
||||
layers[si] = section
|
||||
}
|
||||
idx := blockIndex(x, y, z)
|
||||
old := section[idx>>1]
|
||||
if idx&1 == 0 {
|
||||
section[idx>>1] = old&0xf0 | value&0x0f
|
||||
} else {
|
||||
section[idx>>1] = old&0x0f | value<<4
|
||||
}
|
||||
return old != section[idx>>1]
|
||||
}
|
||||
|
||||
// LightAt returns the stored sky and block light at a local block coordinate.
|
||||
func (c *Chunk) LightAt(x, y, z int) (sky, block byte, ready bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.getLight(false, x, y, z), c.getLight(true, x, y, z), c.lightReady
|
||||
}
|
||||
|
||||
func (c *Chunk) installLight(v *lightVolume) bool {
|
||||
changed := !c.lightReady
|
||||
var sky [SectionCount]*[2048]byte
|
||||
var block [SectionCount]*[2048]byte
|
||||
baseX := int(c.X)*16 - v.minX
|
||||
baseZ := int(c.Z)*16 - v.minZ
|
||||
for y := MinY; y < MinY+WorldHeight; y++ {
|
||||
for z := 0; z < 16; z++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
idx := v.indexLocal(baseX+x, y, baseZ+z)
|
||||
installNibble(&sky, x, y, z, v.sky[idx])
|
||||
installNibble(&block, x, y, z, v.block[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !lightLayersEqual(c.skyLight, sky) || !lightLayersEqual(c.blockLight, block) {
|
||||
changed = true
|
||||
}
|
||||
c.skyLight = sky
|
||||
c.blockLight = block
|
||||
c.lightReady = true
|
||||
return changed
|
||||
}
|
||||
|
||||
func installNibble(layers *[SectionCount]*[2048]byte, x, y, z int, value byte) {
|
||||
if value == 0 {
|
||||
return
|
||||
}
|
||||
si := (y - MinY) >> 4
|
||||
if layers[si] == nil {
|
||||
layers[si] = new([2048]byte)
|
||||
}
|
||||
idx := blockIndex(x, y, z)
|
||||
if idx&1 == 0 {
|
||||
layers[si][idx>>1] |= value
|
||||
} else {
|
||||
layers[si][idx>>1] |= value << 4
|
||||
}
|
||||
}
|
||||
|
||||
func lightLayersEqual(a, b [SectionCount]*[2048]byte) bool {
|
||||
for i := 0; i < SectionCount; i++ {
|
||||
if a[i] == nil || b[i] == nil {
|
||||
if a[i] != nil || b[i] != nil {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if *a[i] != *b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Chunk) writeLight(w *protocol.Writer) {
|
||||
sky, block, highest := c.skyLight, c.blockLight, c.highestFilledSection()
|
||||
if !c.lightReady {
|
||||
chunks := map[[2]int32]*Chunk{{c.X, c.Z}: c}
|
||||
v := newLightVolume(int(c.X), int(c.Z), 1, 1, chunks)
|
||||
v.calculate()
|
||||
standalone := &Chunk{X: c.X, Z: c.Z}
|
||||
standalone.installLight(v)
|
||||
sky, block = standalone.skyLight, standalone.blockLight
|
||||
}
|
||||
maxLightSection := highest + 2
|
||||
if maxLightSection < 0 {
|
||||
maxLightSection = 0
|
||||
}
|
||||
writeLightLayers(w, sky, block, maxLightSection)
|
||||
}
|
||||
|
||||
func (c *Chunk) highestFilledSection() int {
|
||||
for si := SectionCount - 1; si >= 0; si-- {
|
||||
if c.sections[si] == nil {
|
||||
continue
|
||||
}
|
||||
for _, state := range c.sections[si] {
|
||||
if state != StateAir {
|
||||
return si
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func writeLightLayers(w *protocol.Writer, sky, block [SectionCount]*[2048]byte, maxLightSection int) {
|
||||
if maxLightSection >= lightSections {
|
||||
maxLightSection = lightSections - 1
|
||||
}
|
||||
var skySections [lightSections]*[2048]byte
|
||||
var blockSections [lightSections]*[2048]byte
|
||||
for i := 0; i < SectionCount && i+1 <= maxLightSection; i++ {
|
||||
skySections[i+1] = sky[i]
|
||||
blockSections[i+1] = block[i]
|
||||
}
|
||||
if maxLightSection == lightSections-1 {
|
||||
skySections[maxLightSection] = new([2048]byte)
|
||||
for i := range skySections[maxLightSection] {
|
||||
skySections[maxLightSection][i] = 0xff
|
||||
}
|
||||
}
|
||||
|
||||
var skyMask, blockMask, emptySkyMask, emptyBlockMask uint64
|
||||
for i := 0; i <= maxLightSection; i++ {
|
||||
if skySections[i] == nil {
|
||||
emptySkyMask |= 1 << i
|
||||
} else {
|
||||
skyMask |= 1 << i
|
||||
}
|
||||
if blockSections[i] == nil {
|
||||
emptyBlockMask |= 1 << i
|
||||
} else {
|
||||
blockMask |= 1 << i
|
||||
}
|
||||
}
|
||||
writeBitSet(w, []uint64{skyMask})
|
||||
writeBitSet(w, []uint64{blockMask})
|
||||
writeBitSet(w, []uint64{emptySkyMask})
|
||||
writeBitSet(w, []uint64{emptyBlockMask})
|
||||
writeLightArrays(w, skySections[:])
|
||||
writeLightArrays(w, blockSections[:])
|
||||
}
|
||||
|
||||
w.VarInt(int32(skyCount))
|
||||
for i := 0; i < lightSections; i++ {
|
||||
if skyLight[i] != nil {
|
||||
w.VarInt(2048)
|
||||
w.Raw(skyLight[i][:])
|
||||
func writeLightArrays(w *protocol.Writer, sections []*[2048]byte) {
|
||||
count := 0
|
||||
for _, section := range sections {
|
||||
if section != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
w.VarInt(int32(blockCount))
|
||||
for i := 0; i < lightSections; i++ {
|
||||
if blockLight[i] != nil {
|
||||
w.VarInt(int32(count))
|
||||
for _, section := range sections {
|
||||
if section != nil {
|
||||
w.VarInt(2048)
|
||||
w.Raw(blockLight[i][:])
|
||||
w.Raw(section[:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// allSectionsMask returns a bitset (as longs) with the low lightSections bits set.
|
||||
// EncodeLightUpdate serializes a standalone light_update packet body from a
|
||||
// consistent chunk snapshot.
|
||||
func (c *Chunk) EncodeLightUpdate() []byte {
|
||||
snapshot, _ := c.snapshot()
|
||||
w := protocol.NewWriter(8192)
|
||||
w.VarInt(snapshot.X)
|
||||
w.VarInt(snapshot.Z)
|
||||
sky, block := snapshot.skyLight, snapshot.blockLight
|
||||
if !snapshot.lightReady {
|
||||
chunks := map[[2]int32]*Chunk{{snapshot.X, snapshot.Z}: snapshot}
|
||||
volume := newLightVolume(int(snapshot.X), int(snapshot.Z), 1, 1, chunks)
|
||||
volume.calculate()
|
||||
standalone := &Chunk{X: snapshot.X, Z: snapshot.Z}
|
||||
standalone.installLight(volume)
|
||||
sky, block = standalone.skyLight, standalone.blockLight
|
||||
}
|
||||
writeLightLayers(w, sky, block, lightSections-1)
|
||||
return w.Bytes()
|
||||
}
|
||||
|
||||
func allSectionsMask() []uint64 {
|
||||
return []uint64{(uint64(1) << lightSections) - 1}
|
||||
}
|
||||
|
||||
// writeBitSet emits a length-prefixed array of longs.
|
||||
func writeBitSet(w *protocol.Writer, longs []uint64) {
|
||||
for len(longs) > 0 && longs[len(longs)-1] == 0 {
|
||||
longs = longs[:len(longs)-1]
|
||||
}
|
||||
w.VarInt(int32(len(longs)))
|
||||
for _, v := range longs {
|
||||
w.Int64(int64(v))
|
||||
for _, value := range longs {
|
||||
w.Int64(int64(value))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
BIN
internal/world/light_properties.bin
Normal file
BIN
internal/world/light_properties.bin
Normal file
Binary file not shown.
113
internal/world/light_properties.go
Normal file
113
internal/world/light_properties.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// lightPropertiesBinary is generated by tools/VanillaLightDump.java directly
|
||||
// from the 26.1.2 runtime block-state registry.
|
||||
//
|
||||
//go:embed light_properties.bin
|
||||
var lightPropertiesBinary []byte
|
||||
|
||||
var (
|
||||
blockOpacity [totalBlockStates]byte
|
||||
blockEmission [totalBlockStates]byte
|
||||
blockLightFlags [totalBlockStates]byte
|
||||
blockLightShape [totalBlockStates]uint16
|
||||
lightFaceShapes [][lightShapeBytes]byte
|
||||
)
|
||||
|
||||
const (
|
||||
lightPropertiesMagic = 0x52494f4c // RIOL
|
||||
lightPropertiesVersion = 1
|
||||
lightShapeBytes = 6 * 32 // six 16x16 face masks
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := decodeLightProperties(lightPropertiesBinary); err != nil {
|
||||
panic(fmt.Sprintf("world: decode vanilla light properties: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func decodeLightProperties(data []byte) error {
|
||||
if len(data) < 16 {
|
||||
return fmt.Errorf("header is truncated")
|
||||
}
|
||||
if binary.BigEndian.Uint32(data[0:4]) != lightPropertiesMagic {
|
||||
return fmt.Errorf("invalid magic")
|
||||
}
|
||||
if version := binary.BigEndian.Uint32(data[4:8]); version != lightPropertiesVersion {
|
||||
return fmt.Errorf("unsupported version %d", version)
|
||||
}
|
||||
states := int(binary.BigEndian.Uint32(data[8:12]))
|
||||
shapes := int(binary.BigEndian.Uint32(data[12:16]))
|
||||
if states != totalBlockStates {
|
||||
return fmt.Errorf("state count %d, want %d", states, totalBlockStates)
|
||||
}
|
||||
want := 16 + states*5 + shapes*lightShapeBytes
|
||||
if len(data) != want {
|
||||
return fmt.Errorf("length %d, want %d", len(data), want)
|
||||
}
|
||||
|
||||
offset := 16
|
||||
for id := 0; id < states; id++ {
|
||||
blockOpacity[id] = data[offset]
|
||||
blockEmission[id] = data[offset+1]
|
||||
blockLightFlags[id] = data[offset+2]
|
||||
blockLightShape[id] = binary.BigEndian.Uint16(data[offset+3 : offset+5])
|
||||
if int(blockLightShape[id]) >= shapes {
|
||||
return fmt.Errorf("state %d references shape %d of %d", id, blockLightShape[id], shapes)
|
||||
}
|
||||
offset += 5
|
||||
}
|
||||
lightFaceShapes = make([][lightShapeBytes]byte, shapes)
|
||||
for i := range lightFaceShapes {
|
||||
copy(lightFaceShapes[i][:], data[offset:offset+lightShapeBytes])
|
||||
offset += lightShapeBytes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lightOpacity(state uint16) byte {
|
||||
if int(state) >= len(blockOpacity) {
|
||||
return 15
|
||||
}
|
||||
return blockOpacity[state]
|
||||
}
|
||||
|
||||
func lightEmission(state uint16) byte {
|
||||
if int(state) >= len(blockEmission) {
|
||||
return 0
|
||||
}
|
||||
return blockEmission[state]
|
||||
}
|
||||
|
||||
// lightShapeOccludes mirrors Shapes.faceShapeOccludes for the 1/16-resolution
|
||||
// face masks emitted from vanilla's VoxelShape data.
|
||||
func lightShapeOccludes(from, into uint16, direction int) bool {
|
||||
if direction < 0 || direction >= 6 {
|
||||
return false
|
||||
}
|
||||
var fromShape, intoShape [lightShapeBytes]byte
|
||||
// Vanilla substitutes an empty shape unless both flags are true. Ordinary
|
||||
// full cubes are handled by dampening; only shape-aware blocks (slabs,
|
||||
// stairs, etc.) participate in face occlusion.
|
||||
if int(from) < len(blockLightFlags) && blockLightFlags[from]&6 == 6 {
|
||||
fromShape = lightFaceShapes[blockLightShape[from]]
|
||||
}
|
||||
if int(into) < len(blockLightFlags) && blockLightFlags[into]&6 == 6 {
|
||||
intoShape = lightFaceShapes[blockLightShape[into]]
|
||||
}
|
||||
opposite := [...]int{1, 0, 3, 2, 5, 4}
|
||||
fromOffset := direction * 32
|
||||
intoOffset := opposite[direction] * 32
|
||||
for i := 0; i < 32; i++ {
|
||||
if fromShape[fromOffset+i]|intoShape[intoOffset+i] != 0xff {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
207
internal/world/light_test.go
Normal file
207
internal/world/light_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"testing"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
||||
// Captured from vanilla 26.1.2 after placing minecraft:glowstone at
|
||||
// (15,100,8). Layout is YZX over [0..30]x[85..115]x[-7..23].
|
||||
//
|
||||
//go:embed testdata/vanilla_glowstone_block_light.bin
|
||||
var vanillaGlowstoneBlockLight []byte
|
||||
|
||||
func TestIncrementalBlockLightAgainstVanillaFixture(t *testing.T) {
|
||||
const minX, minY, minZ, size = 0, 85, -7, 31
|
||||
if len(vanillaGlowstoneBlockLight) != size*size*size {
|
||||
t.Fatalf("vanilla fixture length = %d, want %d", len(vanillaGlowstoneBlockLight), size*size*size)
|
||||
}
|
||||
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||
return NewChunk(cx, cz, BiomePlains)
|
||||
})
|
||||
for cz := int32(-1); cz <= 1; cz++ {
|
||||
for cx := int32(-1); cx <= 1; cx++ {
|
||||
cache.chunkAt(cx, cz)
|
||||
}
|
||||
}
|
||||
glowstone := nameToStateID("minecraft:glowstone", nil)
|
||||
if valid, _ := cache.SetBlockWithLight(15, 100, 8, glowstone); !valid {
|
||||
t.Fatal("glowstone edit rejected")
|
||||
}
|
||||
|
||||
fixtureIndex := 0
|
||||
mismatches := 0
|
||||
for y := minY; y < minY+size; y++ {
|
||||
for z := minZ; z < minZ+size; z++ {
|
||||
for x := minX; x < minX+size; x++ {
|
||||
chunk := cache.chunkAt(int32(x>>4), int32(z>>4))
|
||||
_, got, ready := chunk.LightAt(x, y, z)
|
||||
want := vanillaGlowstoneBlockLight[fixtureIndex]
|
||||
fixtureIndex++
|
||||
if !ready || got != want {
|
||||
if mismatches < 10 {
|
||||
t.Errorf("block light (%d,%d,%d) = %d, ready=%v; vanilla=%d", x, y, z, got, ready, want)
|
||||
}
|
||||
mismatches++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mismatches > 10 {
|
||||
t.Errorf("... and %d additional light mismatches", mismatches-10)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementalBlockLightCrossesChunkBoundaryAndClears(t *testing.T) {
|
||||
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||
return NewChunk(cx, cz, BiomePlains)
|
||||
})
|
||||
left := cache.chunkAt(0, 0)
|
||||
right := cache.chunkAt(1, 0)
|
||||
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
glowstone := nameToStateID("minecraft:glowstone", nil)
|
||||
if emission := lightEmission(glowstone); emission != 15 {
|
||||
t.Fatalf("glowstone emission = %d, want 15", emission)
|
||||
}
|
||||
valid, changed := cache.SetBlockWithLight(15, 0, 8, glowstone)
|
||||
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||
t.Fatalf("place changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||
}
|
||||
assertLight(t, left, 15, 0, 8, 15)
|
||||
assertLight(t, right, 0, 0, 8, 14)
|
||||
assertLight(t, right, 1, 0, 8, 13)
|
||||
|
||||
valid, changed = cache.SetBlockWithLight(15, 0, 8, StateAir)
|
||||
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||
t.Fatalf("remove changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||
}
|
||||
assertLight(t, left, 15, 0, 8, 0)
|
||||
assertLight(t, right, 0, 0, 8, 0)
|
||||
}
|
||||
|
||||
func TestIncrementalSkyLightSpreadsUnderRoofAcrossChunkBoundary(t *testing.T) {
|
||||
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||
chunk := NewChunk(cx, cz, BiomePlains)
|
||||
for z := 0; z < 16; z++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
chunk.setBlockRaw(x, 1, z, StateStone)
|
||||
}
|
||||
}
|
||||
return chunk
|
||||
})
|
||||
left := cache.chunkAt(0, 0)
|
||||
right := cache.chunkAt(1, 0)
|
||||
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertSky(t, right, 0, 0, 8, 0)
|
||||
valid, changed := cache.SetBlockWithLight(15, 1, 8, StateAir)
|
||||
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||
t.Fatalf("open changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||
}
|
||||
assertSky(t, left, 15, 0, 8, 15)
|
||||
assertSky(t, right, 0, 0, 8, 14)
|
||||
assertSky(t, right, 1, 0, 8, 13)
|
||||
|
||||
valid, changed = cache.SetBlockWithLight(15, 1, 8, StateStone)
|
||||
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||
t.Fatalf("close changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||
}
|
||||
assertSky(t, left, 15, 0, 8, 0)
|
||||
assertSky(t, right, 0, 0, 8, 0)
|
||||
}
|
||||
|
||||
func assertLight(t *testing.T, chunk *Chunk, x, y, z int, want byte) {
|
||||
t.Helper()
|
||||
_, got, ready := chunk.LightAt(x, y, z)
|
||||
if !ready || got != want {
|
||||
t.Fatalf("block light (%d,%d,%d) = %d, ready=%v; want %d", x, y, z, got, ready, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSky(t *testing.T, chunk *Chunk, x, y, z int, want byte) {
|
||||
t.Helper()
|
||||
got, _, ready := chunk.LightAt(x, y, z)
|
||||
if !ready || got != want {
|
||||
t.Fatalf("sky light (%d,%d,%d) = %d, ready=%v; want %d", x, y, z, got, ready, want)
|
||||
}
|
||||
}
|
||||
|
||||
func containsChunkPos(chunks []ChunkPos, x, z int32) bool {
|
||||
for _, chunk := range chunks {
|
||||
if chunk.X == x && chunk.Z == z {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestEncodeLightUpdateLayout(t *testing.T) {
|
||||
chunk := NewChunk(-2, 3, BiomePlains)
|
||||
r := protocol.NewReader(chunk.EncodeLightUpdate())
|
||||
|
||||
if x, err := r.VarInt(); err != nil || x != -2 {
|
||||
t.Fatalf("chunk x = %d, %v; want -2", x, err)
|
||||
}
|
||||
if z, err := r.VarInt(); err != nil || z != 3 {
|
||||
t.Fatalf("chunk z = %d, %v; want 3", z, err)
|
||||
}
|
||||
|
||||
masks := make([]uint64, 4)
|
||||
for i := range masks {
|
||||
length, err := r.VarInt()
|
||||
if err != nil || length < 0 || length > 1 {
|
||||
t.Fatalf("mask[%d] length = %d, %v; want 0 or 1", i, length, err)
|
||||
}
|
||||
if length == 1 {
|
||||
value, err := r.Int64()
|
||||
if err != nil {
|
||||
t.Fatalf("mask[%d]: %v", i, err)
|
||||
}
|
||||
masks[i] = uint64(value)
|
||||
}
|
||||
}
|
||||
if masks[0] == 0 || masks[2] == 0 {
|
||||
t.Fatalf("sky masks were not populated: data=%#x empty=%#x", masks[0], masks[2])
|
||||
}
|
||||
if masks[1] != 0 || masks[3] != (uint64(1)<<lightSections)-1 {
|
||||
t.Fatalf("block masks = data %#x, empty %#x", masks[1], masks[3])
|
||||
}
|
||||
|
||||
consumeLightArrays(t, r)
|
||||
consumeLightArrays(t, r)
|
||||
if r.Remaining() != 0 {
|
||||
t.Fatalf("light update trailing bytes = %d", r.Remaining())
|
||||
}
|
||||
}
|
||||
|
||||
func consumeLightArrays(t *testing.T, r *protocol.Reader) {
|
||||
t.Helper()
|
||||
count, err := r.VarInt()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := int32(0); i < count; i++ {
|
||||
length, err := r.VarInt()
|
||||
if err != nil || length != 2048 {
|
||||
t.Fatalf("light array[%d] length = %d, %v; want 2048", i, length, err)
|
||||
}
|
||||
for j := int32(0); j < length; j++ {
|
||||
if _, err := r.ReadByte(); err != nil {
|
||||
t.Fatalf("light array[%d] byte %d: %v", i, j, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,6 +86,13 @@ func locationIndex(localX, localZ int) int { return (localZ << 5) | localX }
|
|||
// ReadChunk returns the decompressed NBT payload for the chunk, or
|
||||
// ErrChunkNotFound when the chunk is absent.
|
||||
func (r *RegionFile) ReadChunk(localX, localZ int) ([]byte, error) {
|
||||
if localX < 0 || localX > 31 || localZ < 0 || localZ > 31 {
|
||||
return nil, fmt.Errorf("world: local coordinates out of bounds")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
loc := r.offsets[locationIndex(localX, localZ)]
|
||||
if loc == 0 {
|
||||
return nil, ErrChunkNotFound
|
||||
|
|
@ -96,9 +103,6 @@ func (r *RegionFile) ReadChunk(localX, localZ int) ([]byte, error) {
|
|||
return nil, fmt.Errorf("world: invalid sector offset %d", sectorOffset)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// 4-byte length then payload (compression byte + compressed data).
|
||||
var lenBuf [4]byte
|
||||
if _, err := r.f.ReadAt(lenBuf[:], int64(sectorOffset)*sectorSize); err != nil {
|
||||
|
|
@ -124,7 +128,14 @@ func (r *RegionFile) ReadChunk(localX, localZ int) ([]byte, error) {
|
|||
// WriteChunk stores the NBT payload for the chunk, allocating (or reusing)
|
||||
// sectors and updating the offset + timestamp tables.
|
||||
func (r *RegionFile) WriteChunk(localX, localZ int, nbt []byte) error {
|
||||
compressed := append([]byte{compressionZlib}, zlibDeflate(nbt)...)
|
||||
if localX < 0 || localX > 31 || localZ < 0 || localZ > 31 {
|
||||
return fmt.Errorf("world: local coordinates out of bounds")
|
||||
}
|
||||
deflated, err := zlibDeflate(nbt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
compressed := append([]byte{compressionZlib}, deflated...)
|
||||
// +4 for the length prefix; sectors needed to hold everything.
|
||||
totalLen := 4 + len(compressed)
|
||||
sectorsNeeded := (totalLen + sectorSize - 1) / sectorSize
|
||||
|
|
@ -229,4 +240,4 @@ var _ = io.EOF
|
|||
|
||||
// (zlib helpers live in compress.go to keep this file format-focused; the
|
||||
// references below are satisfied there.)
|
||||
var _ = bytes.Equal
|
||||
var _ = bytes.Equal
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ type stateName struct {
|
|||
}
|
||||
|
||||
var (
|
||||
stateByIDOnce sync.Once
|
||||
stateByIDImpl map[uint16]stateName
|
||||
stateByIDOnce sync.Once
|
||||
stateByIDImpl map[uint16]stateName
|
||||
idsByName map[string][]uint16
|
||||
)
|
||||
|
||||
// stateByID returns the named form of a block-state ID, building the lookup
|
||||
|
|
@ -49,12 +50,15 @@ func buildStateTable() {
|
|||
panic("world: parsing embedded blocks.json: " + err.Error())
|
||||
}
|
||||
stateByIDImpl = make(map[uint16]stateName, 30000)
|
||||
idsByName = make(map[string][]uint16, len(blocks))
|
||||
for name, b := range blocks {
|
||||
for _, s := range b.States {
|
||||
if s.ID < 0 || s.ID > 65535 {
|
||||
continue
|
||||
}
|
||||
stateByIDImpl[uint16(s.ID)] = stateName{Name: name, Properties: s.Properties}
|
||||
id := uint16(s.ID)
|
||||
stateByIDImpl[id] = stateName{Name: name, Properties: s.Properties}
|
||||
idsByName[name] = append(idsByName[name], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,19 +93,14 @@ type paletteEntryKey struct {
|
|||
// Chunk. Unknown names/properties map to air (0).
|
||||
func nameToStateID(name string, props map[string]string) uint16 {
|
||||
stateByIDOnce.Do(buildStateTable)
|
||||
for id, s := range stateByIDImpl {
|
||||
if s.Name != name {
|
||||
continue
|
||||
}
|
||||
if propsMatch(s.Properties, props) {
|
||||
ids := idsByName[name]
|
||||
for _, id := range ids {
|
||||
if propsMatch(stateByIDImpl[id].Properties, props) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
// Fall back to any state of that block if properties don't match exactly.
|
||||
for id, s := range stateByIDImpl {
|
||||
if s.Name == name {
|
||||
return id
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
return ids[0]
|
||||
}
|
||||
return StateAir
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -62,11 +63,82 @@ type Store struct {
|
|||
regions map[[2]int]*RegionFile
|
||||
}
|
||||
|
||||
const worldMetadataFile = "regionio-world.json"
|
||||
|
||||
type worldMetadata struct {
|
||||
Format int `json:"format"`
|
||||
Seed int64 `json:"seed"`
|
||||
}
|
||||
|
||||
// NewStore opens (or creates) the world directory at dir, ensuring region/
|
||||
// exists. Chunks are loaded/saved relative to dir/region.
|
||||
func NewStore(dir string) (*Store, error) {
|
||||
return newStore(dir, nil)
|
||||
}
|
||||
|
||||
// NewStoreForSeed opens a persistent world and records its generation seed.
|
||||
// Reopening the same directory with another seed is rejected to prevent seams
|
||||
// between previously stored chunks and newly generated terrain.
|
||||
func NewStoreForSeed(dir string, seed int64) (*Store, error) {
|
||||
return newStore(dir, &seed)
|
||||
}
|
||||
|
||||
func newStore(dir string, seed *int64) (*Store, error) {
|
||||
regionDir := filepath.Join(dir, "region")
|
||||
return &Store{dir: dir, regions: make(map[[2]int]*RegionFile)}, mkdirAll(regionDir)
|
||||
if err := mkdirAll(regionDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seed != nil {
|
||||
if err := validateWorldMetadata(dir, *seed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Store{dir: dir, regions: make(map[[2]int]*RegionFile)}, nil
|
||||
}
|
||||
|
||||
func validateWorldMetadata(dir string, seed int64) error {
|
||||
path := filepath.Join(dir, worldMetadataFile)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var meta worldMetadata
|
||||
if err := json.Unmarshal(raw, &meta); err != nil {
|
||||
return fmt.Errorf("world: decode %s: %w", path, err)
|
||||
}
|
||||
if meta.Format != 1 {
|
||||
return fmt.Errorf("world: unsupported metadata format %d", meta.Format)
|
||||
}
|
||||
if meta.Seed != seed {
|
||||
return fmt.Errorf("world: seed mismatch for %s: stored %d, configured %d", dir, meta.Seed, seed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err = json.MarshalIndent(worldMetadata{Format: 1, Seed: seed}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
tmp, err := os.CreateTemp(dir, ".regionio-world-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
// regionFor returns the cached RegionFile for the chunk's region, opening it on
|
||||
|
|
@ -122,6 +194,12 @@ func (s *Store) LoadChunk(cx, cz int32) (*Chunk, error) {
|
|||
|
||||
// SaveChunk encodes the chunk and writes it to its region file.
|
||||
func (s *Store) SaveChunk(c *Chunk) error {
|
||||
snapshot, _ := c.snapshot()
|
||||
return s.saveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
// saveSnapshot writes a detached chunk snapshot without copying it again.
|
||||
func (s *Store) saveSnapshot(c *Chunk) error {
|
||||
rf, err := s.regionFor(c.X, c.Z)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -156,6 +234,9 @@ func chunkToNBT(c *Chunk) *nbt.Compound {
|
|||
Set("Status", nbt.String("minecraft:full")).
|
||||
Set("LastUpdate", nbt.Long(0)).
|
||||
Set("InhabitedTime", nbt.Long(0))
|
||||
if c.lightReady {
|
||||
level.Set("isLightOn", nbt.Byte(1))
|
||||
}
|
||||
|
||||
// Sections: one compound per vertical section, including empty ones so the
|
||||
// section Y range is contiguous (vanilla expects all sections present for
|
||||
|
|
@ -238,6 +319,14 @@ func sectionToNBT(c *Chunk, si int) *nbt.Compound {
|
|||
biomes.Set("data", packIndices(c.biomes[si][:], biomeIndexOf))
|
||||
}
|
||||
sec.Set("biomes", biomes)
|
||||
if c.lightReady {
|
||||
if sky := c.skyLight[si]; sky != nil {
|
||||
sec.Set("SkyLight", nbt.ByteArray(append([]byte(nil), sky[:]...)))
|
||||
}
|
||||
if block := c.blockLight[si]; block != nil {
|
||||
sec.Set("BlockLight", nbt.ByteArray(append([]byte(nil), block[:]...)))
|
||||
}
|
||||
}
|
||||
|
||||
return sec
|
||||
}
|
||||
|
|
@ -287,7 +376,7 @@ func topNonAirY(c *Chunk, x, z int) int {
|
|||
// for the palette size, mirroring the network paletted-container packing (no
|
||||
// value spans a long boundary in vanilla's chunk NBT).
|
||||
func packIndices(ids []uint16, indexOf map[uint16]int) nbt.LongArray {
|
||||
bits := bitsNeeded(len(indexOf))
|
||||
bits := bitsFor(len(indexOf))
|
||||
if bits < 1 {
|
||||
bits = 1
|
||||
}
|
||||
|
|
@ -306,21 +395,10 @@ func packIndices(ids []uint16, indexOf map[uint16]int) nbt.LongArray {
|
|||
return longs
|
||||
}
|
||||
|
||||
// bitsNeeded returns ceil(log2(n)) for n>1, or 0 for n<=1.
|
||||
func bitsNeeded(n int) int {
|
||||
bits := 0
|
||||
v := n - 1
|
||||
for v > 0 {
|
||||
v >>= 1
|
||||
bits++
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
// nbtToChunk decodes the Level-nested chunk NBT back into a Chunk. The chunk's
|
||||
// absolute coordinates are derived from the on-disk xPos/zPos (authoritative);
|
||||
// the region/local coords passed in are used only to validate.
|
||||
func nbtToChunk(root *nbt.Compound, regionX, regionZ, _, _ int) (*Chunk, error) {
|
||||
func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chunk, error) {
|
||||
levelTag, ok := root.Get("Level")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("world: chunk NBT missing Level")
|
||||
|
|
@ -331,8 +409,18 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, _, _ int) (*Chunk, error)
|
|||
}
|
||||
cx := int32(nbtAsInt(level, "xPos"))
|
||||
cz := int32(nbtAsInt(level, "zPos"))
|
||||
wantX := int32(regionX*32 + localX)
|
||||
wantZ := int32(regionZ*32 + localZ)
|
||||
if cx != wantX || cz != wantZ {
|
||||
return nil, fmt.Errorf("world: chunk coordinates (%d,%d) do not match region slot (%d,%d)", cx, cz, wantX, wantZ)
|
||||
}
|
||||
|
||||
c := &Chunk{X: cx, Z: cz, biome: BiomePlains}
|
||||
if lightTag, ok := level.Get("isLightOn"); ok {
|
||||
if enabled, ok := lightTag.(nbt.Byte); ok && enabled != 0 {
|
||||
c.lightReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// Sections.
|
||||
if secTag, ok := level.Get("sections"); ok {
|
||||
|
|
@ -349,12 +437,32 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, _, _ int) (*Chunk, error)
|
|||
}
|
||||
readBlockStates(c, si, sc)
|
||||
readBiomes(c, si, sc)
|
||||
readLightSection(c, si, sc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func readLightSection(c *Chunk, si int, sc *nbt.Compound) {
|
||||
read := func(name string) *[2048]byte {
|
||||
tag, ok := sc.Get(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
data, ok := tag.(nbt.ByteArray)
|
||||
if !ok || len(data) != 2048 {
|
||||
c.lightReady = false
|
||||
return nil
|
||||
}
|
||||
out := new([2048]byte)
|
||||
copy(out[:], data)
|
||||
return out
|
||||
}
|
||||
c.skyLight[si] = read("SkyLight")
|
||||
c.blockLight[si] = read("BlockLight")
|
||||
}
|
||||
|
||||
// readBlockStates decodes a section's block_states {palette, data?} into the
|
||||
// chunk's section array. A palette of size 1 fills the whole section; otherwise
|
||||
// the packed data array is unpacked.
|
||||
|
|
@ -427,9 +535,11 @@ func readBiomes(c *Chunk, si int, sc *nbt.Compound) {
|
|||
ids[i] = biomeIDByName(string(e.(nbt.String)))
|
||||
}
|
||||
if len(ids) == 1 {
|
||||
// Uniform biome for the section: keep the per-cell array nil and set the
|
||||
// column fallback when this is the only biome source.
|
||||
c.biome = ids[0]
|
||||
cells := new([biomeCellsPerSection]uint16)
|
||||
for i := range cells {
|
||||
cells[i] = ids[0]
|
||||
}
|
||||
c.biomes[si] = cells
|
||||
return
|
||||
}
|
||||
if dataTag, ok := bc.Get("data"); ok {
|
||||
|
|
@ -481,7 +591,7 @@ func nbtAsString(c *nbt.Compound, name string) nbt.String {
|
|||
// unpackIndices reverses packIndices: fills dst with palette IDs using the
|
||||
// packed long array.
|
||||
func unpackIndices(dst []uint16, ids []uint16, data nbt.LongArray) {
|
||||
bits := bitsNeeded(len(ids))
|
||||
bits := bitsFor(len(ids))
|
||||
if bits < 1 {
|
||||
bits = 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -100,7 +103,8 @@ func TestStoreChunkRoundTrip(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatal("root not compound")
|
||||
}
|
||||
decoded, err := nbtToChunk(root, 0, 0, 0, 0)
|
||||
rx, rz, lx, lz := regionIndex(original.X, original.Z)
|
||||
decoded, err := nbtToChunk(root, rx, rz, lx, lz)
|
||||
if err != nil {
|
||||
t.Fatalf("nbtToChunk: %v", err)
|
||||
}
|
||||
|
|
@ -117,6 +121,68 @@ func TestStoreChunkRoundTrip(t *testing.T) {
|
|||
if decoded.X != 10 || decoded.Z != -5 {
|
||||
t.Errorf("coords = (%d,%d), want (10,-5)", decoded.X, decoded.Z)
|
||||
}
|
||||
if decoded.lightReady {
|
||||
t.Error("legacy chunk without isLightOn loaded as light-ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLightRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cache := NewCacheWithStore(-1, func(cx, cz int32) *Chunk {
|
||||
return NewChunk(cx, cz, BiomePlains)
|
||||
}, store)
|
||||
left := cache.chunkAt(0, 0)
|
||||
right := cache.chunkAt(1, 0)
|
||||
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
glowstone := nameToStateID("minecraft:glowstone", nil)
|
||||
if valid, _ := cache.SetBlockWithLight(15, 0, 8, glowstone); !valid {
|
||||
t.Fatal("glowstone edit rejected")
|
||||
}
|
||||
wantLeftSky, wantLeftBlock, ready := left.LightAt(15, 0, 8)
|
||||
if !ready || wantLeftBlock != 15 {
|
||||
t.Fatalf("pre-save source light = sky %d block %d ready %v", wantLeftSky, wantLeftBlock, ready)
|
||||
}
|
||||
wantRightSky, wantRightBlock, ready := right.LightAt(0, 0, 8)
|
||||
if !ready || wantRightBlock != 14 {
|
||||
t.Fatalf("pre-save neighbor light = sky %d block %d ready %v", wantRightSky, wantRightBlock, ready)
|
||||
}
|
||||
if err := cache.SaveAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err = NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
loadedLeft, err := store.LoadChunk(0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotSky, gotBlock, gotReady := loadedLeft.LightAt(15, 0, 8)
|
||||
if !gotReady || gotSky != wantLeftSky || gotBlock != wantLeftBlock {
|
||||
t.Fatalf("loaded source light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantLeftSky, wantLeftBlock)
|
||||
}
|
||||
loadedRight, err := store.LoadChunk(1, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotSky, gotBlock, gotReady = loadedRight.LightAt(0, 0, 8)
|
||||
if !gotReady || gotSky != wantRightSky || gotBlock != wantRightBlock {
|
||||
t.Fatalf("loaded neighbor light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantRightSky, wantRightBlock)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
|
||||
|
|
@ -207,8 +273,8 @@ func TestCacheAutosavePersistsEdits(t *testing.T) {
|
|||
|
||||
// Wait for at least one autosave cycle.
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
cancel() // stop the autosave loop so it releases the store
|
||||
<-autosaveDone // wait for the goroutine to fully exit
|
||||
cancel() // stop the autosave loop so it releases the store
|
||||
<-autosaveDone // wait for the goroutine to fully exit
|
||||
store.Close()
|
||||
|
||||
// Fresh cache over the same store should see the edit without SaveAll.
|
||||
|
|
@ -223,3 +289,130 @@ func TestCacheAutosavePersistsEdits(t *testing.T) {
|
|||
t.Errorf("autosaved block = %d, want bedrock %d", got, StateBedrock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsSeedMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewStoreForSeed(dir, 12345)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err = NewStoreForSeed(dir, 12345)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen with matching seed: %v", err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewStoreForSeed(dir, 54321); err == nil {
|
||||
t.Fatal("opening a world with a different seed succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheDoesNotRegenerateCorruptStoredChunk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
regionDir := filepath.Join(dir, "region")
|
||||
rf, err := OpenRegion(regionDir, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corrupt := []byte("not an nbt document")
|
||||
if err := rf.WriteChunk(0, 0, corrupt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rf.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
var generated atomic.Bool
|
||||
cache := NewCacheWithStore(256, func(cx, cz int32) *Chunk {
|
||||
generated.Store(true)
|
||||
return NewChunk(cx, cz, BiomePlains)
|
||||
}, store)
|
||||
if _, err := cache.FrameErr(0, 0); err == nil {
|
||||
t.Fatal("FrameErr succeeded for corrupt stored chunk")
|
||||
}
|
||||
if generated.Load() {
|
||||
t.Fatal("generator ran after a stored chunk read error")
|
||||
}
|
||||
if cache.SetBlock(0, SeaLevel, 0, StateBedrock) {
|
||||
t.Fatal("SetBlock accepted an edit over a corrupt stored chunk")
|
||||
}
|
||||
if err := cache.SaveAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, lx, lz := regionIndex(0, 0)
|
||||
rf = store.regions[[2]int{0, 0}]
|
||||
raw, err := rf.ReadChunk(lx, lz)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(raw, corrupt) {
|
||||
t.Fatalf("stored corrupt payload was overwritten: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAutosavePreservesLatestEdit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cache := NewCacheWithStore(256, flatGen(), store)
|
||||
cache.chunkAt(0, 0)
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
state := StateStone
|
||||
if i%2 == 0 {
|
||||
state = StateDirt
|
||||
}
|
||||
cache.SetBlock(5, SeaLevel, 5, state)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
_ = cache.flushDirty()
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
cache.SetBlock(5, SeaLevel, 5, StateBedrock)
|
||||
if err := cache.SaveAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err = NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
loaded, err := store.LoadChunk(0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loaded.GetBlock(5, SeaLevel, 5); got != StateBedrock {
|
||||
t.Fatalf("persisted final block = %d, want %d", got, StateBedrock)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func generateFromDensity(d worldgen.DensityFunction, cx, cz int32) *Chunk {
|
|||
col := &columns[lx][lz]
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
if s := col[i]; s != StateAir {
|
||||
c.SetBlock(lx, MinY+i, lz, s)
|
||||
c.setBlockRaw(lx, MinY+i, lz, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
internal/world/testdata/vanilla_glowstone_block_light.bin
vendored
Normal file
BIN
internal/world/testdata/vanilla_glowstone_block_light.bin
vendored
Normal file
Binary file not shown.
|
|
@ -15,7 +15,7 @@ import (
|
|||
const (
|
||||
cellWidth = 4
|
||||
cellHeight = 8
|
||||
cellsXZ = 16 / cellWidth // 4
|
||||
cellsXZ = 16 / cellWidth // 4
|
||||
cellsY = WorldHeight / cellHeight // 48
|
||||
)
|
||||
|
||||
|
|
@ -82,8 +82,8 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C
|
|||
surfaceRule, ruleErr := od.SurfaceRule()
|
||||
|
||||
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)
|
||||
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) {
|
||||
|
|
@ -105,7 +105,7 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C
|
|||
col := &columns[lx][lz]
|
||||
for i := 0; i < WorldHeight; i++ {
|
||||
if s := col[i]; s != StateAir {
|
||||
c.SetBlock(lx, MinY+i, lz, s)
|
||||
c.setBlockRaw(lx, MinY+i, lz, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue