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:
parent
4dcf938a85
commit
d1cc29bb60
10 changed files with 305112 additions and 20 deletions
|
|
@ -1,7 +1,10 @@
|
|||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
)
|
||||
|
|
@ -14,32 +17,48 @@ type Generator func(cx, cz int32) *Chunk
|
|||
// mutates the chunk and invalidates its cached frame so the next request
|
||||
// re-encodes it.
|
||||
//
|
||||
// Frames are built for a fixed compression threshold shared by all play
|
||||
// connections, so one frame is valid for every client.
|
||||
// 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.
|
||||
// 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{}),
|
||||
}
|
||||
}
|
||||
|
||||
// chunkAt returns the chunk at (cx, cz), generating it on first access.
|
||||
// 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}
|
||||
|
||||
|
|
@ -50,7 +69,16 @@ func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
|||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
ch := c.gen(cx, cz) // generate outside the lock
|
||||
// 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()
|
||||
|
|
@ -87,8 +115,8 @@ func (c *Cache) Frame(cx, cz int32) []byte {
|
|||
}
|
||||
|
||||
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
|
||||
// affected chunk's cached frame. It reports whether a chunk was actually
|
||||
// touched (false if y is out of range).
|
||||
// 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
|
||||
|
|
@ -100,7 +128,111 @@ func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
|||
ch.SetBlock(x, y, z, state)
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.frames, [2]int32{cx, cz})
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue