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:
Master290 2026-06-25 00:40:14 +03:00
parent 4dcf938a85
commit d1cc29bb60
10 changed files with 305112 additions and 20 deletions

View 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