RegionIO/internal/world/cache.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

238 lines
6.2 KiB
Go

package world
import (
"context"
"log/slog"
"sync"
"time"
"regionio/internal/protocol"
)
// Generator produces the chunk at the given coordinate.
type Generator func(cx, cz int32) *Chunk
// Cache is the live world: it owns the mutable chunk data and memoizes the
// framed, compression-ready level_chunk packet for each chunk. A block edit
// mutates the chunk and invalidates its cached frame so the next request
// re-encodes it.
//
// When a Store is attached (NewCacheWithStore), the cache is read-through — a
// chunk miss first tries disk, then generation — and edits mark chunks dirty
// for the background autosave. Frames are built for a fixed compression
// threshold shared by all play connections, so one frame is valid for every
// client.
//
// Generation can be expensive; it runs outside the lock to avoid blocking other
// chunk requests. An eviction policy belongs here once worlds stream far.
type Cache struct {
threshold int32
gen Generator
store *Store // nil = in-memory only (tests, flat worlds)
mu sync.Mutex
chunks map[[2]int32]*Chunk
frames map[[2]int32][]byte
dirty map[[2]int32]struct{}
}
// NewCache returns a world cache that frames packets at the given compression
// threshold using gen to produce missing chunks. It has no persistence.
func NewCache(threshold int32, gen Generator) *Cache {
return &Cache{
threshold: threshold,
gen: gen,
chunks: make(map[[2]int32]*Chunk),
frames: make(map[[2]int32][]byte),
dirty: make(map[[2]int32]struct{}),
}
}
// NewCacheWithStore returns a cache backed by store: chunk misses load from disk
// first (then fall back to gen), and edits are persisted by the autosave loop.
func NewCacheWithStore(threshold int32, gen Generator, store *Store) *Cache {
c := NewCache(threshold, gen)
c.store = store
return c
}
// chunkAt returns the chunk at (cx, cz). Resolution order: in-memory cache →
// disk (if a store is attached) → generation. Generation and disk reads run
// outside the lock.
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
key := [2]int32{cx, cz}
c.mu.Lock()
if ch, ok := c.chunks[key]; ok {
c.mu.Unlock()
return ch
}
c.mu.Unlock()
// Try disk before generation so saved edits survive restarts.
var ch *Chunk
if c.store != nil {
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
ch = loaded
}
}
if ch == nil {
ch = c.gen(cx, cz) // generate outside the lock
}
c.mu.Lock()
defer c.mu.Unlock()
if existing, ok := c.chunks[key]; ok {
return existing // another goroutine won the race
}
c.chunks[key] = ch
return ch
}
// Frame returns the prebuilt level_chunk packet for (cx, cz), building it on
// first request and caching until the chunk is edited. The slice must not be
// mutated.
func (c *Cache) Frame(cx, cz int32) []byte {
key := [2]int32{cx, cz}
c.mu.Lock()
if f, ok := c.frames[key]; ok {
c.mu.Unlock()
return f
}
c.mu.Unlock()
ch := c.chunkAt(cx, cz)
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, ch.Encode())
c.mu.Lock()
defer c.mu.Unlock()
if existing, ok := c.frames[key]; ok {
return existing
}
c.frames[key] = frame
return frame
}
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
// affected chunk's cached frame and marking it dirty for autosave. It reports
// whether a chunk was actually touched (false if y is out of range).
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
if y < MinY || y >= MinY+WorldHeight {
return false
}
cx := int32(x >> 4)
cz := int32(z >> 4)
ch := c.chunkAt(cx, cz)
ch.SetBlock(x, y, z, state)
c.mu.Lock()
key := [2]int32{cx, cz}
delete(c.frames, key)
if c.store != nil {
c.dirty[key] = struct{}{}
}
c.mu.Unlock()
return true
}
// markDirty flags the chunk at (cx, cz) for the next autosave. Public so tests
// can simulate edits that the generator made worth persisting.
func (c *Cache) markDirty(cx, cz int32) {
if c.store == nil {
return
}
c.mu.Lock()
c.dirty[[2]int32{cx, cz}] = struct{}{}
c.mu.Unlock()
}
// StartAutosave launches a goroutine that periodically persists dirty chunks
// until ctx is cancelled, then performs a final flush. It returns a done channel
// that is closed once the goroutine has fully exited (including the final
// SaveAll) — callers must wait on it before closing the underlying Store to
// avoid racing the saver against Close. Call this once per server lifetime.
func (c *Cache) StartAutosave(ctx context.Context, log *slog.Logger, interval time.Duration) <-chan struct{} {
done := make(chan struct{})
if c.store == nil {
close(done)
return done // in-memory cache: nothing to save
}
go func() {
defer close(done)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
if err := c.SaveAll(); err != nil && log != nil {
log.Error("world: final autosave failed", "err", err)
}
return
case <-t.C:
if err := c.flushDirty(); err != nil && log != nil {
log.Error("world: autosave failed", "err", err)
}
}
}
}()
return done
}
// flushDirty saves every chunk currently marked dirty and clears the set.
func (c *Cache) flushDirty() error {
c.mu.Lock()
keys := make([][2]int32, 0, len(c.dirty))
for k := range c.dirty {
keys = append(keys, k)
}
chunks := make(map[[2]int32]*Chunk, len(keys))
for _, k := range keys {
chunks[k] = c.chunks[k]
}
c.dirty = make(map[[2]int32]struct{})
c.mu.Unlock()
for _, k := range keys {
ch := chunks[k]
if ch == nil {
continue
}
if err := c.store.SaveChunk(ch); err != nil {
c.mu.Lock()
c.dirty[k] = struct{}{} // re-mark; retry next cycle
c.mu.Unlock()
return err
}
}
return nil
}
// SaveAll synchronously persists every chunk currently in memory. Used at
// shutdown to guarantee no edit is lost.
func (c *Cache) SaveAll() error {
if c.store == nil {
return nil
}
c.mu.Lock()
keys := make([][2]int32, 0, len(c.chunks))
for k := range c.chunks {
keys = append(keys, k)
}
chunks := make(map[[2]int32]*Chunk, len(keys))
for _, k := range keys {
chunks[k] = c.chunks[k]
}
c.mu.Unlock()
var firstErr error
for _, k := range keys {
if ch := chunks[k]; ch != nil {
if err := c.store.SaveChunk(ch); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}