RegionIO/internal/world/store.go
Master290 d1cc29bb60 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.
2026-06-25 00:40:14 +03:00

504 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]
}
}
}