// 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...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() }