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