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.
232 lines
No EOL
7.1 KiB
Go
232 lines
No EOL
7.1 KiB
Go
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 |