Files
ngzz-mc/internal/save/region.go
2026-09-19 14:21:08 +08:00

263 lines
6.8 KiB
Go
Raw Permalink 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 save 世界存档:level.dat + region 文件 + zstd + CRC32(存档格式.md)。
//
// 策略(存档与持久化.md):diff-only(只存玩家改动的区块)、写临时文件原子替换、
// 卸载/定时写盘、先备份后迁移。
package save
import (
"bytes"
"encoding/binary"
"fmt"
"hash/crc32"
"io"
"os"
"path/filepath"
"time"
"github.com/klauspost/compress/zstd"
)
// 区块序列化格式常量(存档格式.md §4–§5)。
const (
magic = "NYGC" // 年糕历险记 区块数据魔数
chunkVer = 1 // 区块序列化版本
sectorSize = 4096 // 扇区 4KB
regionSize = 32 // 每 region 32×32 区块(存档格式.md §3)
)
// crcTable CRC32 表(IEEE)。
var crcTable = crc32.MakeTable(crc32.IEEE)
// ---- Region 文件(存档格式.md §3)----
// Region 一个区域文件(r.<rx>.<rz>.mcr)。
type Region struct {
rx, rz int32
path string
// 头部:偏移表(1024×4B)与时间戳表(1024×4B),启动时读入内存
offsets [1024]uint32
timestamps [1024]uint32
}
// sectorIndex 区块局部坐标 → 头部索引。
func sectorIndex(lx, lz int32) int {
lx &= regionSize - 1
lz &= regionSize - 1
return int(lz)*regionSize + int(lx)
}
// OpenRegion 打开区域文件(不存在则视为空)。
func OpenRegion(dir string, rx, rz int32) (*Region, error) {
r := &Region{rx: rx, rz: rz, path: filepath.Join(dir, fmt.Sprintf("r.%d.%d.mcr", rx, rz))}
f, err := os.Open(r.path)
if err != nil {
if os.IsNotExist(err) {
return r, nil // 空区域
}
return nil, fmt.Errorf("save.OpenRegion: %w", err)
}
defer f.Close()
// 读头部:偏移表(4KB)+ 时间戳表(4KB)
var buf [8192]byte
if _, err := f.ReadAt(buf[:], 0); err != nil && err != io.EOF {
return nil, fmt.Errorf("save.OpenRegion 头部: %w", err)
}
for i := 0; i < 1024; i++ {
r.offsets[i] = binary.BigEndian.Uint32(buf[i*4:])
r.timestamps[i] = binary.BigEndian.Uint32(buf[4096+i*4:])
}
return r, nil
}
// ReadChunk 读取区块压缩数据(含 CRC 校验,存档格式.md §5)。
func (r *Region) ReadChunk(cx, cz int32) ([]byte, bool, error) {
idx := sectorIndex(cx-r.rx*regionSize, cz-r.rz*regionSize)
off := r.offsets[idx]
if off == 0 {
return nil, false, nil // 未保存过
}
sector := off >> 8
count := off & 0xFF
if sector == 0 || count == 0 {
return nil, false, nil
}
f, err := os.Open(r.path)
if err != nil {
return nil, false, fmt.Errorf("save.ReadChunk: %w", err)
}
defer f.Close()
raw := make([]byte, count*sectorSize)
if _, err := f.ReadAt(raw, int64(sector)*sectorSize); err != nil {
return nil, false, nil // 扇区指针损坏或文件截断:丢弃该区块
}
length := binary.BigEndian.Uint32(raw[0:4])
compType := raw[4]
// 载荷 + 尾部 CRC32;越界视为损坏而非整档失败(存档格式.md §5)。
if 5+int(length)+4 > len(raw) {
return nil, false, nil
}
data := raw[5 : 5+int(length)]
wantCRC := binary.BigEndian.Uint32(raw[5+int(length) : 5+int(length)+4])
if crc32.Checksum(data, crcTable) != wantCRC {
return nil, false, nil
}
if compType == 0 {
return data, true, nil
}
if compType != 2 {
return nil, false, nil
}
out, err := zstdDecode(data)
if err != nil {
return nil, false, nil
}
return out, true, nil
}
// WriteChunk 写入区块:重写整个 region(8KB 头 + 紧密扇区),再原子替换。
func (r *Region) WriteChunk(cx, cz int32, data []byte) error {
chunks := make(map[[2]int32][]byte, 8)
if _, err := os.Stat(r.path); err == nil {
for lz := int32(0); lz < regionSize; lz++ {
for lx := int32(0); lx < regionSize; lx++ {
ccx, ccz := r.rx*regionSize+lx, r.rz*regionSize+lz
raw, ok, err := r.ReadChunk(ccx, ccz)
if err != nil || !ok {
continue
}
chunks[[2]int32{ccx, ccz}] = raw
}
}
}
chunks[[2]int32{cx, cz}] = data
return r.rewriteAll(chunks)
}
func (r *Region) rewriteAll(chunks map[[2]int32][]byte) error {
type rec struct {
cx, cz int32
raw []byte
}
list := make([]rec, 0, len(chunks))
for k, v := range chunks {
list = append(list, rec{k[0], k[1], v})
}
var offsets, timestamps [1024]uint32
sector := 2
var body []byte
now := uint32(nowUnix())
for _, it := range list {
comp, err := zstdEncode(it.raw)
if err != nil {
return fmt.Errorf("save.WriteChunk 压缩: %w", err)
}
recb := make([]byte, 5+len(comp)+4)
binary.BigEndian.PutUint32(recb[0:4], uint32(len(comp)))
recb[4] = 2
copy(recb[5:], comp)
binary.BigEndian.PutUint32(recb[5+len(comp):], crc32.Checksum(comp, crcTable))
sc := (len(recb) + sectorSize - 1) / sectorSize
if sc < 1 {
sc = 1
}
pad := sc*sectorSize - len(recb)
idx := sectorIndex(it.cx-r.rx*regionSize, it.cz-r.rz*regionSize)
offsets[idx] = uint32(sector<<8) | uint32(sc)
timestamps[idx] = now
body = append(body, recb...)
if pad > 0 {
body = append(body, make([]byte, pad)...)
}
sector += sc
}
dir := filepath.Dir(r.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "region.tmp.*")
if err != nil {
return fmt.Errorf("save.WriteChunk 临时文件: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
var head [8192]byte
for i := 0; i < 1024; i++ {
binary.BigEndian.PutUint32(head[i*4:], offsets[i])
binary.BigEndian.PutUint32(head[4096+i*4:], timestamps[i])
}
if _, err := tmp.Write(head[:]); err != nil {
tmp.Close()
return err
}
if len(body) > 0 {
if _, err := tmp.Write(body); err != nil {
tmp.Close()
return err
}
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := atomicReplace(tmpPath, r.path); err != nil {
return fmt.Errorf("save.WriteChunk 原子替换: %w", err)
}
r.offsets = offsets
r.timestamps = timestamps
return nil
}
// atomicReplace 临时文件 → 目标。Windows 不能 rename 覆盖已存在文件。
func atomicReplace(tmp, dest string) error {
if err := os.Rename(tmp, dest); err == nil {
return nil
}
bak := dest + ".old"
_ = os.Remove(bak)
if err := os.Rename(dest, bak); err != nil && !os.IsNotExist(err) {
if rmErr := os.Remove(dest); rmErr != nil {
return err
}
}
if err := os.Rename(tmp, dest); err != nil {
return err
}
_ = os.Remove(bak)
return nil
}
// ---- 压缩工具 ----
func zstdEncode(b []byte) ([]byte, error) {
var buf bytes.Buffer
enc, err := zstd.NewWriter(&buf)
if err != nil {
return nil, err
}
if _, err := enc.Write(b); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func zstdDecode(b []byte) ([]byte, error) {
dec, err := zstd.NewReader(bytes.NewReader(b))
if err != nil {
return nil, err
}
defer dec.Close()
return io.ReadAll(dec)
}
// nowUnix 当前 Unix 秒(时间戳表)。
func nowUnix() int64 { return time.Now().Unix() }