Files
ngzz-mc/internal/save/region.go

226 lines
6.2 KiB
Go
Raw Normal View History

// 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, fmt.Errorf("save.ReadChunk 读取扇区: %w", err)
}
length := binary.BigEndian.Uint32(raw[0:4])
compType := raw[4]
if 5+int(length) > len(raw) {
return nil, false, fmt.Errorf("save.ReadChunk: 记录长度越界")
}
data := raw[5 : 5+int(length)]
wantCRC := binary.BigEndian.Uint32(raw[5+int(length):])
if crc32.Checksum(data, crcTable) != wantCRC {
return nil, false, fmt.Errorf("save.ReadChunk: CRC 校验失败(区块 %d,%d)", cx, cz)
}
if compType == 0 {
return data, true, nil
}
if compType != 2 {
return nil, false, fmt.Errorf("save.ReadChunk: 未知压缩类型 %d", compType)
}
out, err := zstdDecode(data)
if err != nil {
return nil, false, fmt.Errorf("save.ReadChunk 解压: %w", err)
}
return out, true, nil
}
// WriteChunk 写入区块压缩数据(写完整区域文件到临时文件再原子替换,存档格式.md §6)。
func (r *Region) WriteChunk(cx, cz int32, data []byte) error {
idx := sectorIndex(cx-r.rx*regionSize, cz-r.rz*regionSize)
comp, err := zstdEncode(data)
if err != nil {
return fmt.Errorf("save.WriteChunk 压缩: %w", err)
}
rec := make([]byte, 5+len(comp)+4)
binary.BigEndian.PutUint32(rec[0:4], uint32(len(comp)))
rec[4] = 2 // zstd
copy(rec[5:], comp)
binary.BigEndian.PutUint32(rec[5+len(comp):], crc32.Checksum(comp, crcTable))
sectorCount := (len(rec) + sectorSize - 1) / sectorSize
if sectorCount == 0 {
sectorCount = 1
}
// 找空闲扇区(简单策略:追加到文件尾;头部 8KB 占 2 扇区,数据从扇区 2 起)
fileSize := int64(0)
if st, err := os.Stat(r.path); err == nil {
fileSize = st.Size()
}
startSector := int(fileSize+sectorSize-1) / sectorSize
if startSector < 2 {
startSector = 2
}
// 构建完整新文件:头部 + 既有数据 + 新记录
tmp, err := os.CreateTemp(filepath.Dir(r.path), "region.tmp.*")
if err != nil {
return fmt.Errorf("save.WriteChunk 临时文件: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
var head [8192]byte
r.offsets[idx] = uint32(startSector<<8) | uint32(sectorCount)
r.timestamps[idx] = uint32(nowUnix())
for i := 0; i < 1024; i++ {
binary.BigEndian.PutUint32(head[i*4:], r.offsets[i])
binary.BigEndian.PutUint32(head[4096+i*4:], r.timestamps[i])
}
if _, err := tmp.Write(head[:]); err != nil {
tmp.Close()
return err
}
// 复制旧数据区
if old, err := os.Open(r.path); err == nil {
if _, err := io.Copy(tmp, old); err != nil {
old.Close()
tmp.Close()
return err
}
old.Close()
}
// 追加新记录(对齐扇区)
if _, err := tmp.Write(rec); err != nil {
tmp.Close()
return err
}
pad := sectorCount*sectorSize - len(rec)
if pad > 0 {
if _, err := tmp.Write(make([]byte, pad)); err != nil {
tmp.Close()
return err
}
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
// 原子替换(存档格式.md §6:临时文件 → rename)
if err := os.Rename(tmpPath, r.path); err != nil {
return fmt.Errorf("save.WriteChunk 原子替换: %w", err)
}
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() }