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

226 lines
6.2 KiB
Go
Raw 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, 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() }