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

504
internal/world/store.go Normal file
View 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]
}
}
}