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

@ -9,6 +9,7 @@ import (
"os/signal"
"strconv"
"syscall"
"time"
"regionio/internal/network"
"regionio/internal/server"
@ -26,20 +27,36 @@ func main() {
// fatal — a wrong seed silently generates a different world than intended.
seedFlag := flag.Int64("seed", parseSeedEnv(os.Getenv("REGIONIO_SEED"), cfg.WorldSeed, log),
"world seed (overrides REGIONIO_SEED)")
worldDir := flag.String("world", cfg.WorldDir, "world directory (empty = in-memory only)")
flag.Parse()
cfg.WorldSeed = *seedFlag
log.Info("using world seed", "seed", cfg.WorldSeed)
cfg.WorldDir = *worldDir
log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir)
srv, err := server.New(cfg)
if err != nil {
log.Error("failed to create server", "err", err)
os.Exit(1)
}
// Start the chunk autosave loop. It flushes dirty chunks every 30s and does
// a final SaveAll when the context (cancelled by signal) ends.
saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second)
srv := server.New(cfg)
ln := network.NewListener(srv, log)
// Cancel the context on SIGINT/SIGTERM for a graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := ln.ListenAndServe(ctx); err != nil {
// ListenAndServe blocks until the listener stops (on the same signals).
// The autosave context is separate so the final flush runs after the
// listener exits; stop it here to trigger that final SaveAll, then wait for
// the saver to finish before releasing the store file handles.
if err := ln.ListenAndServe(saveCtx); err != nil {
log.Error("server stopped", "err", err)
os.Exit(1)
}
saveStop()
<-autosaveDone
if srv.Store() != nil {
srv.Store().Close()
}
}