263 lines
6.8 KiB
Go
263 lines
6.8 KiB
Go
// 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() }
|