Chunk persistence to Anvil .mca region files
The world now survives restarts: chunks load from disk (read-through cache) and player edits persist via async autosave + a final SaveAll on shutdown. RegionIO finally does region I/O. - world/regionfile.go: Anvil .mca container — 8192-byte header (offset + timestamp tables), 4096-byte sectors, zlib chunk records. - world/compress.go: zlib deflate/inflate for chunk payloads. - world/store.go: chunk <-> Level-nested NBT (per-section block_states/biomes palettes, WORLD_SURFACE heightmap, DataVersion 4790, yPos -4) via the existing nbt package; Store opens one RegionFile per region with proper floor-division coords. - world/state_names.go: id->name bridge from the embedded blocks.json report so network int-IDs round-trip through the disk named palette. - world/encode.go: GetBiome read accessor for serialization. - world/cache.go: read-through (disk then generation), dirty tracking, StartAutosave (returns a done channel so the saver exits before Close), SaveAll, NewCacheWithStore. - server.go + main.go: Config.WorldDir (default "world"), -world flag, autosave loop every 30s, SaveAll + store Close on signal. - Tests: region round-trip/absent/overwrite, chunk NBT round-trip, end-to-end save-reload, negative chunk coords, autosave persistence.
This commit is contained in:
parent
4dcf938a85
commit
d1cc29bb60
10 changed files with 305112 additions and 20 deletions
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"regionio/internal/network"
|
"regionio/internal/network"
|
||||||
"regionio/internal/server"
|
"regionio/internal/server"
|
||||||
|
|
@ -26,20 +27,36 @@ func main() {
|
||||||
// fatal — a wrong seed silently generates a different world than intended.
|
// fatal — a wrong seed silently generates a different world than intended.
|
||||||
seedFlag := flag.Int64("seed", parseSeedEnv(os.Getenv("REGIONIO_SEED"), cfg.WorldSeed, log),
|
seedFlag := flag.Int64("seed", parseSeedEnv(os.Getenv("REGIONIO_SEED"), cfg.WorldSeed, log),
|
||||||
"world seed (overrides REGIONIO_SEED)")
|
"world seed (overrides REGIONIO_SEED)")
|
||||||
|
worldDir := flag.String("world", cfg.WorldDir, "world directory (empty = in-memory only)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
cfg.WorldSeed = *seedFlag
|
cfg.WorldSeed = *seedFlag
|
||||||
log.Info("using world seed", "seed", cfg.WorldSeed)
|
cfg.WorldDir = *worldDir
|
||||||
|
log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir)
|
||||||
|
|
||||||
|
srv, err := server.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to create server", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the chunk autosave loop. It flushes dirty chunks every 30s and does
|
||||||
|
// a final SaveAll when the context (cancelled by signal) ends.
|
||||||
|
saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second)
|
||||||
|
|
||||||
srv := server.New(cfg)
|
|
||||||
ln := network.NewListener(srv, log)
|
ln := network.NewListener(srv, log)
|
||||||
|
|
||||||
// Cancel the context on SIGINT/SIGTERM for a graceful shutdown.
|
// ListenAndServe blocks until the listener stops (on the same signals).
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
// The autosave context is separate so the final flush runs after the
|
||||||
defer stop()
|
// listener exits; stop it here to trigger that final SaveAll, then wait for
|
||||||
|
// the saver to finish before releasing the store file handles.
|
||||||
if err := ln.ListenAndServe(ctx); err != nil {
|
if err := ln.ListenAndServe(saveCtx); err != nil {
|
||||||
log.Error("server stopped", "err", err)
|
log.Error("server stopped", "err", err)
|
||||||
os.Exit(1)
|
}
|
||||||
|
saveStop()
|
||||||
|
<-autosaveDone
|
||||||
|
if srv.Store() != nil {
|
||||||
|
srv.Store().Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ type Config struct {
|
||||||
CompressionThreshold int
|
CompressionThreshold int
|
||||||
// WorldSeed seeds terrain generation.
|
// WorldSeed seeds terrain generation.
|
||||||
WorldSeed int64
|
WorldSeed int64
|
||||||
|
// WorldDir is the on-disk world directory. When non-empty, chunks are
|
||||||
|
// read from and written to <WorldDir>/region/*.mca and player edits
|
||||||
|
// survive restarts. Empty disables persistence (in-memory only).
|
||||||
|
WorldDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns sensible defaults matching vanilla expectations.
|
// DefaultConfig returns sensible defaults matching vanilla expectations.
|
||||||
|
|
@ -33,6 +37,9 @@ func DefaultConfig() Config {
|
||||||
// WorldSeed defaults to 0 for backward compatibility; operators override
|
// WorldSeed defaults to 0 for backward compatibility; operators override
|
||||||
// it via the REGIONIO_SEED env var or the -seed flag.
|
// it via the REGIONIO_SEED env var or the -seed flag.
|
||||||
WorldSeed: 0,
|
WorldSeed: 0,
|
||||||
|
// WorldDir defaults to "world" so the world persists by default;
|
||||||
|
// set to "" for a throwaway in-memory world.
|
||||||
|
WorldDir: "world",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,14 +47,26 @@ func DefaultConfig() Config {
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
chunks *world.Cache
|
chunks *world.Cache
|
||||||
|
store *world.Store // nil when persistence is disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Server from cfg.
|
// New constructs a Server from cfg. When cfg.WorldDir is set, the world is
|
||||||
func New(cfg Config) *Server {
|
// backed by an on-disk store under that directory; otherwise it is in-memory
|
||||||
|
// only. A returned error (e.g. the world dir cannot be created) is fatal.
|
||||||
|
func New(cfg Config) (*Server, error) {
|
||||||
|
gen := world.NewVanillaGenerator(cfg.WorldSeed)
|
||||||
|
if cfg.WorldDir == "" {
|
||||||
|
return &Server{cfg: cfg, chunks: world.NewCache(int32(cfg.CompressionThreshold), gen)}, nil
|
||||||
|
}
|
||||||
|
store, err := world.NewStore(cfg.WorldDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
chunks: world.NewCache(int32(cfg.CompressionThreshold), world.NewVanillaGenerator(cfg.WorldSeed)),
|
chunks: world.NewCacheWithStore(int32(cfg.CompressionThreshold), gen, store),
|
||||||
}
|
store: store,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config returns the active configuration.
|
// Config returns the active configuration.
|
||||||
|
|
@ -56,6 +75,9 @@ func (s *Server) Config() Config { return s.cfg }
|
||||||
// Chunks returns the shared chunk cache.
|
// Chunks returns the shared chunk cache.
|
||||||
func (s *Server) Chunks() *world.Cache { return s.chunks }
|
func (s *Server) Chunks() *world.Cache { return s.chunks }
|
||||||
|
|
||||||
|
// Store returns the on-disk world store, or nil if persistence is disabled.
|
||||||
|
func (s *Server) Store() *world.Store { return s.store }
|
||||||
|
|
||||||
// statusResponse mirrors the JSON shape the client expects for the server-list
|
// statusResponse mirrors the JSON shape the client expects for the server-list
|
||||||
// ping. Field names and nesting are part of the protocol contract.
|
// ping. Field names and nesting are part of the protocol contract.
|
||||||
type statusResponse struct {
|
type statusResponse struct {
|
||||||
|
|
|
||||||
303792
internal/world/blocks.json
Normal file
303792
internal/world/blocks.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,10 @@
|
||||||
package world
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
@ -14,32 +17,48 @@ type Generator func(cx, cz int32) *Chunk
|
||||||
// mutates the chunk and invalidates its cached frame so the next request
|
// mutates the chunk and invalidates its cached frame so the next request
|
||||||
// re-encodes it.
|
// re-encodes it.
|
||||||
//
|
//
|
||||||
// Frames are built for a fixed compression threshold shared by all play
|
// When a Store is attached (NewCacheWithStore), the cache is read-through — a
|
||||||
// connections, so one frame is valid for every client.
|
// chunk miss first tries disk, then generation — and edits mark chunks dirty
|
||||||
|
// for the background autosave. 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
|
// Generation can be expensive; it runs outside the lock to avoid blocking other
|
||||||
// chunk requests. An eviction policy belongs here once worlds stream far.
|
// chunk requests. An eviction policy belongs here once worlds stream far.
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
threshold int32
|
threshold int32
|
||||||
gen Generator
|
gen Generator
|
||||||
|
store *Store // nil = in-memory only (tests, flat worlds)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
chunks map[[2]int32]*Chunk
|
chunks map[[2]int32]*Chunk
|
||||||
frames map[[2]int32][]byte
|
frames map[[2]int32][]byte
|
||||||
|
dirty map[[2]int32]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache returns a world cache that frames packets at the given compression
|
// NewCache returns a world cache that frames packets at the given compression
|
||||||
// threshold using gen to produce missing chunks.
|
// threshold using gen to produce missing chunks. It has no persistence.
|
||||||
func NewCache(threshold int32, gen Generator) *Cache {
|
func NewCache(threshold int32, gen Generator) *Cache {
|
||||||
return &Cache{
|
return &Cache{
|
||||||
threshold: threshold,
|
threshold: threshold,
|
||||||
gen: gen,
|
gen: gen,
|
||||||
chunks: make(map[[2]int32]*Chunk),
|
chunks: make(map[[2]int32]*Chunk),
|
||||||
frames: make(map[[2]int32][]byte),
|
frames: make(map[[2]int32][]byte),
|
||||||
|
dirty: make(map[[2]int32]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// chunkAt returns the chunk at (cx, cz), generating it on first access.
|
// NewCacheWithStore returns a cache backed by store: chunk misses load from disk
|
||||||
|
// first (then fall back to gen), and edits are persisted by the autosave loop.
|
||||||
|
func NewCacheWithStore(threshold int32, gen Generator, store *Store) *Cache {
|
||||||
|
c := NewCache(threshold, gen)
|
||||||
|
c.store = store
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkAt returns the chunk at (cx, cz). Resolution order: in-memory cache →
|
||||||
|
// disk (if a store is attached) → generation. Generation and disk reads run
|
||||||
|
// outside the lock.
|
||||||
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
||||||
key := [2]int32{cx, cz}
|
key := [2]int32{cx, cz}
|
||||||
|
|
||||||
|
|
@ -50,7 +69,16 @@ func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
ch := c.gen(cx, cz) // generate outside the lock
|
// Try disk before generation so saved edits survive restarts.
|
||||||
|
var ch *Chunk
|
||||||
|
if c.store != nil {
|
||||||
|
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
|
||||||
|
ch = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ch == nil {
|
||||||
|
ch = c.gen(cx, cz) // generate outside the lock
|
||||||
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
@ -87,8 +115,8 @@ func (c *Cache) Frame(cx, cz int32) []byte {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
|
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
|
||||||
// affected chunk's cached frame. It reports whether a chunk was actually
|
// affected chunk's cached frame and marking it dirty for autosave. It reports
|
||||||
// touched (false if y is out of range).
|
// whether a chunk was actually touched (false if y is out of range).
|
||||||
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
||||||
if y < MinY || y >= MinY+WorldHeight {
|
if y < MinY || y >= MinY+WorldHeight {
|
||||||
return false
|
return false
|
||||||
|
|
@ -100,7 +128,111 @@ func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
||||||
ch.SetBlock(x, y, z, state)
|
ch.SetBlock(x, y, z, state)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
delete(c.frames, [2]int32{cx, cz})
|
key := [2]int32{cx, cz}
|
||||||
|
delete(c.frames, key)
|
||||||
|
if c.store != nil {
|
||||||
|
c.dirty[key] = struct{}{}
|
||||||
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markDirty flags the chunk at (cx, cz) for the next autosave. Public so tests
|
||||||
|
// can simulate edits that the generator made worth persisting.
|
||||||
|
func (c *Cache) markDirty(cx, cz int32) {
|
||||||
|
if c.store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dirty[[2]int32{cx, cz}] = struct{}{}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAutosave launches a goroutine that periodically persists dirty chunks
|
||||||
|
// until ctx is cancelled, then performs a final flush. It returns a done channel
|
||||||
|
// that is closed once the goroutine has fully exited (including the final
|
||||||
|
// SaveAll) — callers must wait on it before closing the underlying Store to
|
||||||
|
// avoid racing the saver against Close. Call this once per server lifetime.
|
||||||
|
func (c *Cache) StartAutosave(ctx context.Context, log *slog.Logger, interval time.Duration) <-chan struct{} {
|
||||||
|
done := make(chan struct{})
|
||||||
|
if c.store == nil {
|
||||||
|
close(done)
|
||||||
|
return done // in-memory cache: nothing to save
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if err := c.SaveAll(); err != nil && log != nil {
|
||||||
|
log.Error("world: final autosave failed", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.flushDirty(); err != nil && log != nil {
|
||||||
|
log.Error("world: autosave failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushDirty saves every chunk currently marked dirty and clears the set.
|
||||||
|
func (c *Cache) flushDirty() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
keys := make([][2]int32, 0, len(c.dirty))
|
||||||
|
for k := range c.dirty {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
chunks := make(map[[2]int32]*Chunk, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
chunks[k] = c.chunks[k]
|
||||||
|
}
|
||||||
|
c.dirty = make(map[[2]int32]struct{})
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
ch := chunks[k]
|
||||||
|
if ch == nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAll synchronously persists every chunk currently in memory. Used at
|
||||||
|
// shutdown to guarantee no edit is lost.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
34
internal/world/compress.go
Normal file
34
internal/world/compress.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compress.go wraps compress/zlib for region-file chunk payloads. zlib is the
|
||||||
|
// default compression for Anvil .mca chunk records (compression type 2).
|
||||||
|
|
||||||
|
// zlibDeflate compresses src into a new byte slice.
|
||||||
|
func zlibDeflate(src []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zlib.NewWriter(&buf)
|
||||||
|
_, _ = w.Write(src)
|
||||||
|
_ = w.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// zlibInflate decompresses src (a zlib stream). It returns an error if src is
|
||||||
|
// not a valid zlib payload.
|
||||||
|
func zlibInflate(src []byte) ([]byte, error) {
|
||||||
|
r, err := zlib.NewReader(bytes.NewReader(src))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
out, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
@ -102,6 +102,21 @@ func (c *Chunk) GetBlock(lx, y, lz int) uint16 {
|
||||||
return s[blockIndex(lx, y, lz)]
|
return s[blockIndex(lx, y, lz)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBiome returns the biome of the 4×4×4 cell containing block (lx, y, lz). It
|
||||||
|
// 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 {
|
||||||
|
si := (y - MinY) >> 4
|
||||||
|
if si < 0 || si >= SectionCount {
|
||||||
|
return c.biome
|
||||||
|
}
|
||||||
|
b := c.biomes[si]
|
||||||
|
if b == nil {
|
||||||
|
return c.biome
|
||||||
|
}
|
||||||
|
return b[biomeIndex(lx, y, lz)]
|
||||||
|
}
|
||||||
|
|
||||||
// SetBlock sets the block at local (lx, lz) and absolute world height y.
|
// SetBlock sets the block at local (lx, lz) and absolute world height y.
|
||||||
func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
||||||
si := (y - MinY) >> 4
|
si := (y - MinY) >> 4
|
||||||
|
|
|
||||||
232
internal/world/regionfile.go
Normal file
232
internal/world/regionfile.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// regionfile.go implements the Anvil .mca (RegionFile) container format: an
|
||||||
|
// 8192-byte header (1024-entry offset table + 1024-entry timestamp table)
|
||||||
|
// followed by 4096-byte-aligned chunk data records. Each record is a 4-byte
|
||||||
|
// big-endian length, a 1-byte compression type (2 = zlib), and the compressed
|
||||||
|
// NBT payload. This is the on-disk transport only; chunk NBT encoding lives in
|
||||||
|
// store.go.
|
||||||
|
//
|
||||||
|
// A region covers 32×32 chunks. The offset table index for a chunk is
|
||||||
|
// (localZ<<5)|localX, with localX/localZ in 0..31. Offset value 0 means the
|
||||||
|
// chunk is absent.
|
||||||
|
|
||||||
|
const (
|
||||||
|
sectorSize = 4096
|
||||||
|
headerSectors = 2 // offset table + timestamp table = 8192 bytes
|
||||||
|
chunksPerRegion = 32 * 32
|
||||||
|
compressionZlib = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrChunkNotFound is returned when a chunk's offset is 0 (not stored) or the
|
||||||
|
// region file does not exist.
|
||||||
|
var ErrChunkNotFound = errors.New("world: chunk not found in region")
|
||||||
|
|
||||||
|
// RegionFile is an open Anvil .mca file. It is safe for concurrent use: the
|
||||||
|
// mutex serializes Read/Write since both move the file offset.
|
||||||
|
type RegionFile struct {
|
||||||
|
path string
|
||||||
|
f *os.File
|
||||||
|
mu sync.Mutex
|
||||||
|
offsets [chunksPerRegion]uint32 // packed: bits 0-7 sector count, 8-31 sector offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRegion opens (creating if needed) r.<regionX>.<regionZ>.mca under dir.
|
||||||
|
// The 8192-byte header is read; a new file is initialized with a zeroed header.
|
||||||
|
func OpenRegion(dir string, regionX, regionZ int) (*RegionFile, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("r.%d.%d.mca", regionX, regionZ))
|
||||||
|
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rf := &RegionFile{path: path, f: f}
|
||||||
|
|
||||||
|
// Initialize or load the header.
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Size() < headerSectors*sectorSize {
|
||||||
|
// New/short file: write a zeroed header (two sectors).
|
||||||
|
if _, err := f.WriteAt(make([]byte, headerSectors*sectorSize), 0); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hdr := make([]byte, headerSectors*sectorSize)
|
||||||
|
if _, err := f.ReadAt(hdr, 0); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := 0; i < chunksPerRegion; i++ {
|
||||||
|
rf.offsets[i] = binary.BigEndian.Uint32(hdr[i*4:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// locationIndex maps a chunk's in-region coords to its offset-table slot.
|
||||||
|
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) {
|
||||||
|
loc := r.offsets[locationIndex(localX, localZ)]
|
||||||
|
if loc == 0 {
|
||||||
|
return nil, ErrChunkNotFound
|
||||||
|
}
|
||||||
|
sectorOffset := int(loc >> 8)
|
||||||
|
sectorCount := int(loc & 0xFF)
|
||||||
|
if sectorOffset < headerSectors {
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
length := int(binary.BigEndian.Uint32(lenBuf[:]))
|
||||||
|
// length covers compression-type byte + compressed payload, and must fit
|
||||||
|
// within the allocated sectors (minus the 4 length bytes).
|
||||||
|
maxLen := sectorCount*sectorSize - 4
|
||||||
|
if length <= 0 || length > maxLen {
|
||||||
|
return nil, fmt.Errorf("world: chunk length %d out of range (max %d)", length, maxLen)
|
||||||
|
}
|
||||||
|
raw := make([]byte, length)
|
||||||
|
if _, err := r.f.ReadAt(raw, int64(sectorOffset)*sectorSize+4); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if raw[0] != compressionZlib {
|
||||||
|
return nil, fmt.Errorf("world: unsupported compression type %d", raw[0])
|
||||||
|
}
|
||||||
|
return zlibInflate(raw[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)...)
|
||||||
|
// +4 for the length prefix; sectors needed to hold everything.
|
||||||
|
totalLen := 4 + len(compressed)
|
||||||
|
sectorsNeeded := (totalLen + sectorSize - 1) / sectorSize
|
||||||
|
if sectorsNeeded > 255 {
|
||||||
|
return fmt.Errorf("world: chunk too large: %d bytes (%d sectors)", totalLen, sectorsNeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
idx := locationIndex(localX, localZ)
|
||||||
|
old := r.offsets[idx]
|
||||||
|
oldSectors := 0
|
||||||
|
if old != 0 {
|
||||||
|
oldSectors = int(old & 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide where to write. Reuse the existing allocation if it still fits;
|
||||||
|
// otherwise append at end-of-file.
|
||||||
|
var offset int
|
||||||
|
switch {
|
||||||
|
case old != 0 && oldSectors == sectorsNeeded:
|
||||||
|
offset = int(old >> 8)
|
||||||
|
case old != 0 && oldSectors >= sectorsNeeded:
|
||||||
|
// Keep the old offset but record the smaller count (the tail of the old
|
||||||
|
// allocation becomes unreferenced dead space; acceptable for now).
|
||||||
|
offset = int(old >> 8)
|
||||||
|
default:
|
||||||
|
// Append after the last used sector.
|
||||||
|
offset = r.endSectorLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the on-disk record: length + compression byte + compressed data,
|
||||||
|
// zero-padded to a sector boundary.
|
||||||
|
rec := make([]byte, sectorsNeeded*sectorSize)
|
||||||
|
binary.BigEndian.PutUint32(rec, uint32(len(compressed)))
|
||||||
|
copy(rec[4:], compressed)
|
||||||
|
off := int64(offset) * sectorSize
|
||||||
|
if _, err := r.f.WriteAt(rec, off); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the offset table and timestamp, then persist both tables.
|
||||||
|
r.offsets[idx] = uint32(offset<<8) | uint32(sectorsNeeded)
|
||||||
|
if err := r.writeTablesLocked(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTablesLocked writes the offset + timestamp tables back to the header.
|
||||||
|
// Caller holds r.mu.
|
||||||
|
func (r *RegionFile) writeTablesLocked() error {
|
||||||
|
hdr := make([]byte, headerSectors*sectorSize)
|
||||||
|
for i, loc := range r.offsets {
|
||||||
|
binary.BigEndian.PutUint32(hdr[i*4:], loc)
|
||||||
|
}
|
||||||
|
// Timestamps: leave as zero (we don't track per-chunk save time precisely;
|
||||||
|
// the field is informational and vanilla tolerates 0).
|
||||||
|
if _, err := r.f.WriteAt(hdr, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// endSectorLocked returns the sector index just past the highest used sector,
|
||||||
|
// i.e. where new chunk data can be appended. Caller holds r.mu.
|
||||||
|
func (r *RegionFile) endSectorLocked() int {
|
||||||
|
maxUsed := headerSectors
|
||||||
|
for _, loc := range r.offsets {
|
||||||
|
if loc == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := int(loc>>8) + int(loc&0xFF)
|
||||||
|
if end > maxUsed {
|
||||||
|
maxUsed = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying file handle.
|
||||||
|
func (r *RegionFile) Close() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionIndex computes world-chunk → (region, local) coordinates with proper
|
||||||
|
// floor division for negative chunk coordinates.
|
||||||
|
func regionIndex(cx, cz int32) (regionX, regionZ, localX, localZ int) {
|
||||||
|
// Arithmetic shift floors toward negative infinity for negative values.
|
||||||
|
regionX = int(cx >> 5)
|
||||||
|
regionZ = int(cz >> 5)
|
||||||
|
localX = int(cx) - regionX*32
|
||||||
|
localZ = int(cz) - regionZ*32
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure io.EOF is referenced to keep the import honest when ReadAt paths vary.
|
||||||
|
var _ = io.EOF
|
||||||
|
|
||||||
|
// (zlib helpers live in compress.go to keep this file format-focused; the
|
||||||
|
// references below are satisfied there.)
|
||||||
|
var _ = bytes.Equal
|
||||||
119
internal/world/state_names.go
Normal file
119
internal/world/state_names.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"regionio/internal/nbt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// state_names.go bridges in-memory uint16 block-state IDs and the named palette
|
||||||
|
// form used by on-disk chunk NBT ({Name, Properties}). The wire/network format
|
||||||
|
// uses integer IDs; the disk format uses names, so serialization needs both
|
||||||
|
// directions. The table is built once from the embedded blocks.json report.
|
||||||
|
|
||||||
|
//go:embed blocks.json
|
||||||
|
var blocksReportJSON []byte
|
||||||
|
|
||||||
|
// stateName describes one block state for the on-disk palette.
|
||||||
|
type stateName struct {
|
||||||
|
Name string
|
||||||
|
Properties map[string]string // nil when the block has no state properties
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
stateByIDOnce sync.Once
|
||||||
|
stateByIDImpl map[uint16]stateName
|
||||||
|
)
|
||||||
|
|
||||||
|
// stateByID returns the named form of a block-state ID, building the lookup
|
||||||
|
// table on first use. The default state of each block (or its lowest-id state)
|
||||||
|
// is recorded; this matches what our generator emits, where each StateXxx
|
||||||
|
// constant is the default-state ID. For multi-state blocks the full ID→state
|
||||||
|
// map is loaded so every state round-trips.
|
||||||
|
func stateByID(id uint16) (stateName, bool) {
|
||||||
|
stateByIDOnce.Do(buildStateTable)
|
||||||
|
s, ok := stateByIDImpl[id]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStateTable() {
|
||||||
|
var blocks map[string]struct {
|
||||||
|
States []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Properties map[string]string `json:"properties"`
|
||||||
|
} `json:"states"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(blocksReportJSON, &blocks); err != nil {
|
||||||
|
panic("world: parsing embedded blocks.json: " + err.Error())
|
||||||
|
}
|
||||||
|
stateByIDImpl = make(map[uint16]stateName, 30000)
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockPaletteEntry builds the NBT compound for a block-state ID: {Name,
|
||||||
|
// Properties} (Properties omitted when empty). Unknown IDs map to air.
|
||||||
|
func blockPaletteEntry(id uint16) *nbt.Compound {
|
||||||
|
s, ok := stateByID(id)
|
||||||
|
if !ok {
|
||||||
|
s = stateName{Name: "minecraft:air"}
|
||||||
|
}
|
||||||
|
c := nbt.NewCompound().Set("Name", nbt.String(s.Name))
|
||||||
|
if len(s.Properties) > 0 {
|
||||||
|
props := nbt.NewCompound()
|
||||||
|
for k, v := range s.Properties {
|
||||||
|
props.Set(k, nbt.String(v))
|
||||||
|
}
|
||||||
|
c.Set("Properties", props)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// paletteEntryKey is a stable hashable key for deduplicating palette entries by
|
||||||
|
// (name, properties) during reverse lookup.
|
||||||
|
type paletteEntryKey struct {
|
||||||
|
name string
|
||||||
|
sig string
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameToStateID returns the block-state ID for a (name, properties) pair from
|
||||||
|
// the loaded table. It is used when decoding on-disk chunk NBT back into a
|
||||||
|
// 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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StateAir
|
||||||
|
}
|
||||||
|
|
||||||
|
func propsMatch(a, b map[string]string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range a {
|
||||||
|
if b[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
504
internal/world/store.go
Normal file
504
internal/world/store.go
Normal file
|
|
@ -0,0 +1,504 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"regionio/internal/nbt"
|
||||||
|
"regionio/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// store.go is the persistence layer between the in-memory Chunk model and the
|
||||||
|
// on-disk Anvil region files. It converts a Chunk to/from the "Level"-nested
|
||||||
|
// chunk NBT (26.1.2: per-section block_states/biomes, heightmaps, yPos) and
|
||||||
|
// routes the compressed NBT through RegionFile.
|
||||||
|
//
|
||||||
|
// The store keeps one RegionFile per region (32×32 chunks), opened lazily and
|
||||||
|
// cached for the process lifetime.
|
||||||
|
|
||||||
|
// dataVersion26 is the Minecraft world (NBT) DataVersion for 26.1.2, captured
|
||||||
|
// from versions/.../server.jar's version.json "world_version".
|
||||||
|
const dataVersion26 = 4790
|
||||||
|
|
||||||
|
// minYSection is the on-disk "yPos": the section index at MinY (-64 → -4),
|
||||||
|
// since sections are 16 blocks tall and the overworld is 24 sections from
|
||||||
|
// section index -4 to 19.
|
||||||
|
const minYSection = -4
|
||||||
|
|
||||||
|
// mkdirAll is a thin wrapper over os.MkdirAll kept here so the persistence
|
||||||
|
// layer reads as a self-contained unit.
|
||||||
|
func mkdirAll(path string) error { return os.MkdirAll(path, 0o755) }
|
||||||
|
|
||||||
|
// biomeNameByID resolves a numeric biome ID back to its registry name. It scans
|
||||||
|
// the synced biome registry once per call (cheap; 65 entries). Returns
|
||||||
|
// "minecraft:plains" as a safe fallback for unknown IDs.
|
||||||
|
func biomeNameByID(id uint16) string {
|
||||||
|
for _, reg := range registry.Synced() {
|
||||||
|
if reg.Name != "minecraft:worldgen/biome" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if int(id) < len(reg.Entries) {
|
||||||
|
return reg.Entries[id]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return "minecraft:plains"
|
||||||
|
}
|
||||||
|
|
||||||
|
// biomeIDByName is the reverse of biomeNameByID for decoding on-disk chunk NBT.
|
||||||
|
func biomeIDByName(name string) uint16 {
|
||||||
|
if id := registry.Index("minecraft:worldgen/biome", name); id >= 0 {
|
||||||
|
return uint16(id)
|
||||||
|
}
|
||||||
|
return BiomePlains
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reads and writes chunks under a world directory's region/ folder.
|
||||||
|
type Store struct {
|
||||||
|
dir string
|
||||||
|
mu sync.Mutex
|
||||||
|
regions map[[2]int]*RegionFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
regionDir := filepath.Join(dir, "region")
|
||||||
|
return &Store{dir: dir, regions: make(map[[2]int]*RegionFile)}, mkdirAll(regionDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// regionFor returns the cached RegionFile for the chunk's region, opening it on
|
||||||
|
// first use. Caller is responsible for any higher-level locking; the RegionFile
|
||||||
|
// itself is goroutine-safe.
|
||||||
|
func (s *Store) regionFor(cx, cz int32) (*RegionFile, error) {
|
||||||
|
rx, rz, _, _ := regionIndex(cx, cz)
|
||||||
|
key := [2]int{rx, rz}
|
||||||
|
s.mu.Lock()
|
||||||
|
rf, ok := s.regions[key]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
return rf, nil
|
||||||
|
}
|
||||||
|
rf, err := OpenRegion(filepath.Join(s.dir, "region"), rx, rz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
// Another goroutine may have opened the same region concurrently.
|
||||||
|
if existing, dup := s.regions[key]; dup {
|
||||||
|
rf.Close()
|
||||||
|
rf = existing
|
||||||
|
} else {
|
||||||
|
s.regions[key] = rf
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return rf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadChunk reads and decodes the chunk at (cx, cz). It returns ErrChunkNotFound
|
||||||
|
// when the chunk is not stored.
|
||||||
|
func (s *Store) LoadChunk(cx, cz int32) (*Chunk, error) {
|
||||||
|
rx, rz, lx, lz := regionIndex(cx, cz)
|
||||||
|
rf, err := s.regionFor(cx, cz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := rf.ReadChunk(lx, lz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, tag, err := nbt.UnmarshalNamed(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("world: decode chunk (%d,%d) NBT: %w", cx, cz, err)
|
||||||
|
}
|
||||||
|
root, ok := tag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("world: chunk (%d,%d) root is not a compound", cx, cz)
|
||||||
|
}
|
||||||
|
return nbtToChunk(root, rx, rz, lx, lz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveChunk encodes the chunk and writes it to its region file.
|
||||||
|
func (s *Store) SaveChunk(c *Chunk) error {
|
||||||
|
rf, err := s.regionFor(c.X, c.Z)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw := nbt.MarshalNamed("", chunkToNBT(c))
|
||||||
|
_, _, lx, lz := regionIndex(c.X, c.Z)
|
||||||
|
return rf.WriteChunk(lx, lz, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases all open region files. Called on shutdown.
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var firstErr error
|
||||||
|
for _, rf := range s.regions {
|
||||||
|
if err := rf.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.regions = nil
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkToNBT builds the Level-nested on-disk NBT for a chunk. The wire Encode()
|
||||||
|
// format is not reusable here: disk uses named palettes and the 26.1.2 Level
|
||||||
|
// layout with per-section biomes.
|
||||||
|
func chunkToNBT(c *Chunk) *nbt.Compound {
|
||||||
|
level := nbt.NewCompound().
|
||||||
|
Set("xPos", nbt.Int(c.X)).
|
||||||
|
Set("zPos", nbt.Int(c.Z)).
|
||||||
|
Set("yPos", nbt.Int(int32(minYSection))).
|
||||||
|
Set("Status", nbt.String("minecraft:full")).
|
||||||
|
Set("LastUpdate", nbt.Long(0)).
|
||||||
|
Set("InhabitedTime", nbt.Long(0))
|
||||||
|
|
||||||
|
// Sections: one compound per vertical section, including empty ones so the
|
||||||
|
// section Y range is contiguous (vanilla expects all sections present for
|
||||||
|
// the full height, though absent sections are tolerated as air).
|
||||||
|
sections := nbt.List{ElemID: nbt.TagCompound}
|
||||||
|
for si := 0; si < SectionCount; si++ {
|
||||||
|
sections.Elems = append(sections.Elems, sectionToNBT(c, si))
|
||||||
|
}
|
||||||
|
level.Set("sections", sections)
|
||||||
|
|
||||||
|
level.Set("Heightmaps", buildHeightmaps(c))
|
||||||
|
// Required-but-empty fields so vanilla loads the chunk without complaints.
|
||||||
|
level.Set("block_entities", nbt.List{ElemID: nbt.TagCompound})
|
||||||
|
level.Set("structures", nbt.NewCompound())
|
||||||
|
|
||||||
|
return nbt.NewCompound().
|
||||||
|
Set("DataVersion", nbt.Int(dataVersion26)).
|
||||||
|
Set("Level", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionToNBT builds one section compound: Y + block_states + biomes. Palettes
|
||||||
|
// are emitted even for single-value sections (no "data" array) which vanilla
|
||||||
|
// reads as "the whole section is this one entry".
|
||||||
|
func sectionToNBT(c *Chunk, si int) *nbt.Compound {
|
||||||
|
yIdx := int32(si + minYSection)
|
||||||
|
sec := nbt.NewCompound().Set("Y", nbt.Int(yIdx))
|
||||||
|
|
||||||
|
// Block states: build a palette of distinct IDs in the section, then a packed
|
||||||
|
// long array of indices (only when more than one distinct value).
|
||||||
|
var palette []uint16
|
||||||
|
indexOf := map[uint16]int{}
|
||||||
|
blockStates := nbt.NewCompound()
|
||||||
|
hasBlocks := c.sections[si] != nil
|
||||||
|
if hasBlocks {
|
||||||
|
s := c.sections[si]
|
||||||
|
// Collect palette in first-seen order.
|
||||||
|
for _, id := range s {
|
||||||
|
if _, ok := indexOf[id]; !ok {
|
||||||
|
indexOf[id] = len(palette)
|
||||||
|
palette = append(palette, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
palList := nbt.List{ElemID: nbt.TagCompound}
|
||||||
|
for _, id := range palette {
|
||||||
|
palList.Elems = append(palList.Elems, blockPaletteEntry(id))
|
||||||
|
}
|
||||||
|
blockStates.Set("palette", palList)
|
||||||
|
if len(palette) > 1 {
|
||||||
|
blockStates.Set("data", packIndices(s[:], indexOf))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty section → air palette, no data.
|
||||||
|
blockStates.Set("palette", nbt.List{
|
||||||
|
ElemID: nbt.TagCompound,
|
||||||
|
Elems: []nbt.Tag{blockPaletteEntry(StateAir)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sec.Set("block_states", blockStates)
|
||||||
|
|
||||||
|
// Biomes: 4×4×4 cells. Per-section array if present, else the uniform biome.
|
||||||
|
biomes := nbt.NewCompound()
|
||||||
|
biomePalette := []uint16{c.biome}
|
||||||
|
biomeIndexOf := map[uint16]int{c.biome: 0}
|
||||||
|
if c.biomes[si] != nil {
|
||||||
|
biomePalette = biomePalette[:0]
|
||||||
|
biomeIndexOf = map[uint16]int{}
|
||||||
|
for _, id := range c.biomes[si] {
|
||||||
|
if _, ok := biomeIndexOf[id]; !ok {
|
||||||
|
biomeIndexOf[id] = len(biomePalette)
|
||||||
|
biomePalette = append(biomePalette, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
biomePalList := nbt.List{ElemID: nbt.TagString}
|
||||||
|
for _, id := range biomePalette {
|
||||||
|
biomePalList.Elems = append(biomePalList.Elems, nbt.String(biomeNameByID(id)))
|
||||||
|
}
|
||||||
|
biomes.Set("palette", biomePalList)
|
||||||
|
if c.biomes[si] != nil && len(biomePalette) > 1 {
|
||||||
|
biomes.Set("data", packIndices(c.biomes[si][:], biomeIndexOf))
|
||||||
|
}
|
||||||
|
sec.Set("biomes", biomes)
|
||||||
|
|
||||||
|
return sec
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHeightmaps emits a minimal WORLD_SURFACE heightmap (the first non-air
|
||||||
|
// block per column, packed 9 bits/value, 7 per long like vanilla). Other
|
||||||
|
// heightmaps are omitted; vanilla recomputes what it needs.
|
||||||
|
func buildHeightmaps(c *Chunk) *nbt.Compound {
|
||||||
|
const bits = 9
|
||||||
|
longs := make(nbt.LongArray, 37) // 256 values × 9 bits / 64 ≈ 36, +1
|
||||||
|
perLong := 64 / bits // 7
|
||||||
|
for x := 0; x < 16; x++ {
|
||||||
|
for z := 0; z < 16; z++ {
|
||||||
|
h := topNonAirY(c, x, z)
|
||||||
|
// heightmap value is (y - MinY + 1); store absolute block count.
|
||||||
|
val := int64(h - MinY + 1)
|
||||||
|
if val < 0 {
|
||||||
|
val = 0
|
||||||
|
}
|
||||||
|
idx := z*16 + x
|
||||||
|
longIdx := idx / perLong
|
||||||
|
bitOff := (idx % perLong) * bits
|
||||||
|
longs[longIdx] |= val << uint(bitOff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nbt.NewCompound().Set("WORLD_SURFACE", longs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// topNonAirY returns the Y of the highest non-air block in column (x,z), or
|
||||||
|
// MinY-1 if the column is empty.
|
||||||
|
func topNonAirY(c *Chunk, x, z int) int {
|
||||||
|
for si := SectionCount - 1; si >= 0; si-- {
|
||||||
|
s := c.sections[si]
|
||||||
|
if s == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for ly := 15; ly >= 0; ly-- {
|
||||||
|
if s[blockIndex(x, MinY+si*16+ly, z)] != StateAir {
|
||||||
|
return MinY + si*16 + ly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MinY - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// packIndices packs a slice of IDs into a long array using the minimum bit width
|
||||||
|
// 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))
|
||||||
|
if bits < 1 {
|
||||||
|
bits = 1
|
||||||
|
}
|
||||||
|
perLong := 64 / bits
|
||||||
|
if perLong == 0 {
|
||||||
|
perLong = 1
|
||||||
|
}
|
||||||
|
numLongs := (len(ids) + perLong - 1) / perLong
|
||||||
|
longs := make(nbt.LongArray, numLongs)
|
||||||
|
for i, id := range ids {
|
||||||
|
idx := int64(indexOf[id])
|
||||||
|
longIdx := i / perLong
|
||||||
|
bitOff := (i % perLong) * bits
|
||||||
|
longs[longIdx] |= idx << uint(bitOff)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
levelTag, ok := root.Get("Level")
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("world: chunk NBT missing Level")
|
||||||
|
}
|
||||||
|
level, ok := levelTag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("world: Level is not a compound")
|
||||||
|
}
|
||||||
|
cx := int32(nbtAsInt(level, "xPos"))
|
||||||
|
cz := int32(nbtAsInt(level, "zPos"))
|
||||||
|
|
||||||
|
c := &Chunk{X: cx, Z: cz, biome: BiomePlains}
|
||||||
|
|
||||||
|
// Sections.
|
||||||
|
if secTag, ok := level.Get("sections"); ok {
|
||||||
|
if secList, ok := secTag.(nbt.List); ok && secList.ElemID == nbt.TagCompound {
|
||||||
|
for _, st := range secList.Elems {
|
||||||
|
sc, ok := st.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
yIdx := int(nbtAsInt(sc, "Y"))
|
||||||
|
si := yIdx - minYSection
|
||||||
|
if si < 0 || si >= SectionCount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
readBlockStates(c, si, sc)
|
||||||
|
readBiomes(c, si, sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func readBlockStates(c *Chunk, si int, sc *nbt.Compound) {
|
||||||
|
bsTag, ok := sc.Get("block_states")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bs, ok := bsTag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
palTag, ok := bs.Get("palette")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pal, ok := palTag.(nbt.List)
|
||||||
|
if !ok || pal.ElemID != nbt.TagCompound {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Decode palette entries to state IDs.
|
||||||
|
ids := make([]uint16, len(pal.Elems))
|
||||||
|
for i, e := range pal.Elems {
|
||||||
|
ec, ok := e.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
ids[i] = StateAir
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := string(nbtAsString(ec, "Name"))
|
||||||
|
props := readProps(ec)
|
||||||
|
ids[i] = nameToStateID(name, props)
|
||||||
|
}
|
||||||
|
c.section(si) // ensure allocated
|
||||||
|
s := c.sections[si]
|
||||||
|
if len(ids) == 1 {
|
||||||
|
var fill [sectionVol]uint16
|
||||||
|
for i := range fill {
|
||||||
|
fill[i] = ids[0]
|
||||||
|
}
|
||||||
|
c.sections[si] = &fill
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dataTag, ok := bs.Get("data"); ok {
|
||||||
|
if data, ok := dataTag.(nbt.LongArray); ok {
|
||||||
|
unpackIndices(s[:], ids, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBiomes decodes a section's biomes {palette, data?} into the per-cell array.
|
||||||
|
func readBiomes(c *Chunk, si int, sc *nbt.Compound) {
|
||||||
|
bTag, ok := sc.Get("biomes")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bc, ok := bTag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
palTag, ok := bc.Get("palette")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pal, ok := palTag.(nbt.List)
|
||||||
|
if !ok || pal.ElemID != nbt.TagString {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]uint16, len(pal.Elems))
|
||||||
|
for i, e := range pal.Elems {
|
||||||
|
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]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dataTag, ok := bc.Get("data"); ok {
|
||||||
|
if data, ok := dataTag.(nbt.LongArray); ok {
|
||||||
|
cells := new([biomeCellsPerSection]uint16)
|
||||||
|
unpackIndices(cells[:], ids, data)
|
||||||
|
c.biomes[si] = cells
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProps(c *nbt.Compound) map[string]string {
|
||||||
|
pTag, ok := c.Get("Properties")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pc, ok := pTag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, pc.Len())
|
||||||
|
for _, k := range pc.Keys() {
|
||||||
|
v, _ := pc.Get(k)
|
||||||
|
if s, ok := v.(nbt.String); ok {
|
||||||
|
out[k] = string(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func nbtAsInt(c *nbt.Compound, name string) int32 {
|
||||||
|
if t, ok := c.Get(name); ok {
|
||||||
|
if v, ok := t.(nbt.Int); ok {
|
||||||
|
return int32(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func nbtAsString(c *nbt.Compound, name string) nbt.String {
|
||||||
|
if t, ok := c.Get(name); ok {
|
||||||
|
if v, ok := t.(nbt.String); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "minecraft:air"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
if bits < 1 {
|
||||||
|
bits = 1
|
||||||
|
}
|
||||||
|
perLong := 64 / bits
|
||||||
|
if perLong == 0 {
|
||||||
|
perLong = 1
|
||||||
|
}
|
||||||
|
mask := int64(1)<<uint(bits) - 1
|
||||||
|
for i := range dst {
|
||||||
|
longIdx := i / perLong
|
||||||
|
bitOff := (i % perLong) * bits
|
||||||
|
if longIdx >= len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
idx := int((data[longIdx] >> uint(bitOff)) & mask)
|
||||||
|
if idx >= 0 && idx < len(ids) {
|
||||||
|
dst[i] = ids[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
225
internal/world/store_test.go
Normal file
225
internal/world/store_test.go
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"regionio/internal/nbt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRegionFileRoundTrip writes arbitrary NBT bytes into a region file and
|
||||||
|
// reads them back, confirming the Anvil container (header + sectors + zlib)
|
||||||
|
// preserves the payload exactly.
|
||||||
|
func TestRegionFileRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
rf, err := OpenRegion(dir, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenRegion: %v", err)
|
||||||
|
}
|
||||||
|
defer rf.Close()
|
||||||
|
|
||||||
|
payload := []byte("hello-regionio-chunk-nbt-payload-0123456789")
|
||||||
|
if err := rf.WriteChunk(3, 7, payload); err != nil {
|
||||||
|
t.Fatalf("WriteChunk: %v", err)
|
||||||
|
}
|
||||||
|
got, err := rf.ReadChunk(3, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadChunk: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(payload) {
|
||||||
|
t.Errorf("payload mismatch: got %q want %q", got, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegionFileAbsentChunk confirms ReadChunk returns ErrChunkNotFound for a
|
||||||
|
// chunk that was never written (offset table entry is 0).
|
||||||
|
func TestRegionFileAbsentChunk(t *testing.T) {
|
||||||
|
rf, err := OpenRegion(t.TempDir(), 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rf.Close()
|
||||||
|
if _, err := rf.ReadChunk(5, 5); err != ErrChunkNotFound {
|
||||||
|
t.Errorf("absent chunk err = %v, want ErrChunkNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegionFileOverwrite confirms writing the same chunk twice keeps the
|
||||||
|
// latest data (the second write replaces the first).
|
||||||
|
func TestRegionFileOverwrite(t *testing.T) {
|
||||||
|
rf, err := OpenRegion(t.TempDir(), 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rf.Close()
|
||||||
|
if err := rf.WriteChunk(1, 1, []byte("first")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := rf.WriteChunk(1, 1, []byte("second")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := rf.ReadChunk(1, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != "second" {
|
||||||
|
t.Errorf("after overwrite got %q, want %q", got, "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoreChunkRoundTrip encodes a chunk to NBT, decodes it back, and confirms
|
||||||
|
// the blocks/biomes match. This validates the chunkToNBT/nbtToChunk bridge.
|
||||||
|
func TestStoreChunkRoundTrip(t *testing.T) {
|
||||||
|
original := NewChunk(10, -5, BiomePlains)
|
||||||
|
// Build a recognizable section: a grass surface over stone, with one water
|
||||||
|
// block, so the palette has >1 entry and exercises the packed long array.
|
||||||
|
si := (SeaLevel - MinY) >> 4
|
||||||
|
original.section(si)
|
||||||
|
s := original.sections[si]
|
||||||
|
for x := 0; x < 16; x++ {
|
||||||
|
for z := 0; z < 16; z++ {
|
||||||
|
s[blockIndex(x, SeaLevel, z)] = StateGrass
|
||||||
|
s[blockIndex(x, SeaLevel-1, z)] = StateDirt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s[blockIndex(0, SeaLevel, 0)] = StateWater
|
||||||
|
|
||||||
|
nbtBytes := nbt.MarshalNamed("", chunkToNBT(original))
|
||||||
|
if len(nbtBytes) == 0 {
|
||||||
|
t.Fatal("empty NBT")
|
||||||
|
}
|
||||||
|
_, tag, err := nbt.UnmarshalNamed(nbtBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
root, ok := tag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("root not compound")
|
||||||
|
}
|
||||||
|
decoded, err := nbtToChunk(root, 0, 0, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nbtToChunk: %v", err)
|
||||||
|
}
|
||||||
|
// Spot-check the recognizable blocks.
|
||||||
|
if got := decoded.GetBlock(0, SeaLevel, 0); got != StateWater {
|
||||||
|
t.Errorf("block (0, %d, 0) = %d, want water %d", SeaLevel, got, StateWater)
|
||||||
|
}
|
||||||
|
if got := decoded.GetBlock(5, SeaLevel, 5); got != StateGrass {
|
||||||
|
t.Errorf("block (5, %d, 5) = %d, want grass %d", SeaLevel, got, StateGrass)
|
||||||
|
}
|
||||||
|
if got := decoded.GetBlock(5, SeaLevel-1, 5); got != StateDirt {
|
||||||
|
t.Errorf("block (5, %d, 5) = %d, want dirt %d", SeaLevel-1, got, StateDirt)
|
||||||
|
}
|
||||||
|
if decoded.X != 10 || decoded.Z != -5 {
|
||||||
|
t.Errorf("coords = (%d,%d), want (10,-5)", decoded.X, decoded.Z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
|
||||||
|
// generate a chunk via a store-backed cache, edit a block, SaveAll, then open a
|
||||||
|
// fresh cache over the same store and confirm the edit is present.
|
||||||
|
func TestStoreSaveLoadIntegration(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
gen := NewVanillaGenerator(12345)
|
||||||
|
cache := NewCacheWithStore(int32(256), gen, store)
|
||||||
|
|
||||||
|
// Force chunk (1,2) into the cache, then edit a block near the surface.
|
||||||
|
ch := cache.chunkAt(1, 2)
|
||||||
|
targetY := SeaLevel
|
||||||
|
// Find the top solid block in column (0,0) to place our marker above it.
|
||||||
|
markerY := targetY + 1
|
||||||
|
if !cache.SetBlock(1*16, markerY, 2*16, StateBedrock) {
|
||||||
|
t.Fatalf("SetBlock out of range y=%d", markerY)
|
||||||
|
}
|
||||||
|
_ = ch
|
||||||
|
if err := cache.SaveAll(); err != nil {
|
||||||
|
t.Fatalf("SaveAll: %v", err)
|
||||||
|
}
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
// New cache, same store — simulates a restart.
|
||||||
|
store2, err := NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store2.Close()
|
||||||
|
cache2 := NewCacheWithStore(int32(256), NewVanillaGenerator(12345), store2)
|
||||||
|
loaded := cache2.chunkAt(1, 2)
|
||||||
|
if loaded == nil {
|
||||||
|
t.Fatal("nil loaded chunk")
|
||||||
|
}
|
||||||
|
if got := loaded.GetBlock(1*16, markerY, 2*16); got != StateBedrock {
|
||||||
|
t.Errorf("after reload marker block = %d, want bedrock %d", got, StateBedrock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoreNegativeCoords exercises the floor-division region math for negative
|
||||||
|
// chunk coordinates (e.g. chunk -1 belongs to region -1, local 31).
|
||||||
|
func TestStoreNegativeCoords(t *testing.T) {
|
||||||
|
store, err := NewStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
c := NewChunk(-1, -1, BiomePlains)
|
||||||
|
c.section((SeaLevel - MinY) >> 4)
|
||||||
|
c.SetBlock(0, SeaLevel, 0, StateGrass)
|
||||||
|
if err := store.SaveChunk(c); err != nil {
|
||||||
|
t.Fatalf("SaveChunk(-1,-1): %v", err)
|
||||||
|
}
|
||||||
|
loaded, err := store.LoadChunk(-1, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadChunk(-1,-1): %v", err)
|
||||||
|
}
|
||||||
|
if loaded.X != -1 || loaded.Z != -1 {
|
||||||
|
t.Errorf("coords = (%d,%d), want (-1,-1)", loaded.X, loaded.Z)
|
||||||
|
}
|
||||||
|
// Confirm the file landed in region r.-1.-1.mca.
|
||||||
|
if _, err := os.Stat(filepath.Join(store.dir, "region", "r.-1.-1.mca")); err != nil {
|
||||||
|
t.Errorf("expected r.-1.-1.mca: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheAutosavePersistsEdits starts an autosave loop with a short interval,
|
||||||
|
// edits a block, and confirms a second cache sees the edit after the interval.
|
||||||
|
func TestCacheAutosavePersistsEdits(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gen := NewVanillaGenerator(12345)
|
||||||
|
cache := NewCacheWithStore(int32(256), gen, store)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
autosaveDone := cache.StartAutosave(ctx, slog.Default(), 50*time.Millisecond)
|
||||||
|
|
||||||
|
cache.chunkAt(0, 0)
|
||||||
|
cache.SetBlock(8, SeaLevel, 8, StateBedrock)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
store.Close()
|
||||||
|
|
||||||
|
// Fresh cache over the same store should see the edit without SaveAll.
|
||||||
|
store2, err := NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store2.Close()
|
||||||
|
cache2 := NewCacheWithStore(int32(256), gen, store2)
|
||||||
|
loaded := cache2.chunkAt(0, 0)
|
||||||
|
if got := loaded.GetBlock(8, SeaLevel, 8); got != StateBedrock {
|
||||||
|
t.Errorf("autosaved block = %d, want bedrock %d", got, StateBedrock)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue