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

228 lines
6.8 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.
// 世界存档管理器save.golevel.dat 元数据 + 脏区块写 region + 载入重建。
//
// 流程(存档与持久化.md §2§5
// - 保存:遍历脏区块 → 序列化 → 写 region → 清除 SaveDirty → 写 level.dat
// - 载入:读 level.dat 取 seed → 生成器重建 → 用已保存区块覆盖;
// - 全部写盘走临时文件 + 原子替换(崩溃恢复)。
package save
import (
"encoding/binary"
"encoding/json"
"fmt"
"os"
"path/filepath"
"mc/internal/block"
"mc/internal/blockentity"
"mc/internal/chunk"
"mc/internal/world"
)
// InvSlot 背包槽快照(存档格式.md §2 player.slots
type InvSlot struct {
Name string `json:"name"`
Count uint8 `json:"count"`
Durability int32 `json:"durability,omitempty"`
}
// PlayerData 玩家快照(存档格式.md §2
type PlayerData struct {
X, Y, Z float64 `json:"x"`
Yaw, Pitch float64 `json:"yaw"`
Health float64 `json:"health"`
Hunger int `json:"hunger"`
Saturation float64 `json:"saturation,omitempty"`
Selected int `json:"selected"`
Slots []InvSlot `json:"slots,omitempty"`
UnlockedRecipes []string `json:"unlocked_recipes,omitempty"`
}
// LevelData 世界元数据(存档格式.md §2
type LevelData struct {
Version int `json:"version"`
Seed int64 `json:"seed"`
Time int64 `json:"time"`
GameMode string `json:"game_mode"`
Spawn [3]float64 `json:"spawn,omitempty"`
Player *PlayerData `json:"player,omitempty"`
BlockEntities []blockentity.Snapshot `json:"block_entities,omitempty"`
}
// WriteLevel 写 level.dat临时文件 + 原子替换)。
func WriteLevel(dir, name string, lv LevelData) error {
b, err := json.MarshalIndent(lv, "", " ")
if err != nil {
return fmt.Errorf("save.WriteLevel: %w", err)
}
path := filepath.Join(dir, name, "level.dat")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("save.WriteLevel: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "level.tmp.*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return atomicReplace(tmpPath, path)
}
// ReadLevel 读 level.dat不存在返回零值。
func ReadLevel(dir, name string) (LevelData, error) {
path := filepath.Join(dir, name, "level.dat")
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return LevelData{}, nil
}
return LevelData{}, fmt.Errorf("save.ReadLevel: %w", err)
}
var lv LevelData
if err := json.Unmarshal(b, &lv); err != nil {
return LevelData{}, fmt.Errorf("save.ReadLevel: %w", err)
}
return lv, nil
}
// EncodeChunk 序列化区块:[魔数 4][版本 1][cx 4][cz 4][方块 65536×4][天空光 32768][方块光 32768]。
func EncodeChunk(c *chunk.Chunk) []byte {
blocks := c.Snapshot()
sky, bl := c.LightBytes()
out := make([]byte, 0, 9+len(blocks)*4+len(sky)+len(bl))
out = append(out, magic...)
out = append(out, chunkVer)
out = binary.BigEndian.AppendUint32(out, uint32(c.CX))
out = binary.BigEndian.AppendUint32(out, uint32(c.CZ))
for _, s := range blocks {
out = binary.BigEndian.AppendUint32(out, uint32(s))
}
out = append(out, sky...)
out = append(out, bl...)
return out
}
// DecodeChunk 反序列化区块。
func DecodeChunk(data []byte) (*chunk.Chunk, error) {
if len(data) < 9 || string(data[0:4]) != magic {
return nil, fmt.Errorf("save.DecodeChunk: 魔数错误")
}
if data[4] != chunkVer {
return nil, fmt.Errorf("save.DecodeChunk: 版本 %d 不支持", data[4])
}
cx := int32(binary.BigEndian.Uint32(data[5:9]))
cz := int32(binary.BigEndian.Uint32(data[9:13]))
off := 13
n := chunk.SizeX * chunk.SizeZ * chunk.Height
blocks := make([]block.State, n)
for i := 0; i < n; i++ {
blocks[i] = block.State(binary.BigEndian.Uint32(data[off:]))
off += 4
}
sky := make([]byte, n/2)
bl := make([]byte, n/2)
copy(sky, data[off:off+n/2])
off += n / 2
copy(bl, data[off:off+n/2])
c := chunk.New(cx, cz)
c.SetAll(blocks, sky, bl)
return c, nil
}
// SaveWorld 保存全部脏区块 + level.dat存档与持久化.md §2 保存时机由调用方触发)。
func SaveWorld(w *world.World, dir, name string, seed int64, tick int64) error {
return SaveWorldLevel(w, dir, name, LevelData{Version: 1, Seed: seed, Time: tick, GameMode: "survival"})
}
// SaveWorldLevel 保存脏区块并写入完整 level.dat可带玩家快照
func SaveWorldLevel(w *world.World, dir, name string, lv LevelData) error {
regionDir := filepath.Join(dir, name, "region")
if err := os.MkdirAll(regionDir, 0o755); err != nil {
return fmt.Errorf("save.SaveWorld mkdir: %w", err)
}
if err := EnqueueDirty(w, nil, regionDir); err != nil {
return err
}
if lv.Version == 0 {
lv.Version = 1
}
return WriteLevel(dir, name, lv)
}
// EnqueueDirty 编码脏区块pool != nil 则异步入队,否则同步写 region。
func EnqueueDirty(w *world.World, pool *SavePool, regionDir string) error {
if err := os.MkdirAll(regionDir, 0o755); err != nil {
return fmt.Errorf("save.EnqueueDirty mkdir: %w", err)
}
for _, c := range w.ActiveChunks() {
if !c.SaveDirty.Load() {
continue
}
data := EncodeChunk(c)
c.SaveDirty.Store(false)
if pool != nil {
pool.Enqueue(ChunkJob{RegionDir: regionDir, CX: c.CX, CZ: c.CZ, Data: data})
continue
}
if err := writeChunkBytes(regionDir, c.CX, c.CZ, data); err != nil {
return err
}
}
return nil
}
// LoadWorld 载入世界level.dat 取 seed 重建,再用 region 中已保存区块覆盖。
func LoadWorld(w *world.World, dir, name string) (int64, error) {
lv, err := ReadLevel(dir, name)
if err != nil {
return 0, err
}
regionDir := filepath.Join(dir, name, "region")
entries, err := os.ReadDir(regionDir)
if err != nil {
if os.IsNotExist(err) {
return lv.Seed, nil // 新世界
}
return 0, fmt.Errorf("save.LoadWorld: %w", err)
}
for _, e := range entries {
if e.IsDir() {
continue
}
var rx, rz int32
if _, err := fmt.Sscanf(e.Name(), "r.%d.%d.mcr", &rx, &rz); err != nil {
continue
}
region, err := OpenRegion(regionDir, rx, rz)
if err != nil {
continue // 单个 region 打不开:跳过,不中止整世界
}
for lz := int32(0); lz < regionSize; lz++ {
for lx := int32(0); lx < regionSize; lx++ {
cx, cz := rx*regionSize+lx, rz*regionSize+lz
data, ok, err := region.ReadChunk(cx, cz)
if err != nil || !ok {
continue // 损坏记录视为未保存(存档格式.md §5
}
c, err := DecodeChunk(data)
if err != nil {
continue
}
w.InstallChunk(c)
}
}
}
return lv.Seed, nil
}