存档与备份
存档是玩家唯一不能丢的东西——版本删了能重下,模组删了能重装,世界删了就没了。这页讲我们怎么把 level.dat 读出来、怎么跨版本汇总世界、以及三种粒度的备份各自解决什么问题。
level.dat 是 gzip 压的 NBT
每个世界的根目录下有一个 level.dat:先用 gzip 压缩,再包一层 NBT(NBT 是 Minecraft 自己用的二进制标签格式)。它不是纯存档——真正的世界数据在 region/ 里,level.dat 存的是元信息:世界名、最后游玩时间、游戏规则、种子。
我们只解析需要的那几个字段,所以写了一个极小的 NBT 读取器 LevelNbtReader,不用引第三方库:
using var gz = new GZipStream(File.OpenRead(levelDat), CompressionMode.Decompress);
var root = LevelNbtReader.Read(gz);
if (root?.Get("Data") is LevelNbtCompound data)
{
levelName = (data.Get("LevelName") as LevelNbtString)?.Value;
if (data.Get("LastPlayed") is LevelNbtLong lp)
lastPlayed = DateTimeOffset.FromUnixTimeMilliseconds(lp.Value).LocalDateTime;
}几个细节:NBT 是大端序(BinaryPrimitives.ReadInt32BigEndian),根标签必须是 Compound(type 10),字符串是「2 字节长度 + UTF-8」。用不到的标签类型(byte array、int array、list)直接按长度跳过。level.dat 损坏时不能把世界从列表里删掉——捕获异常后照常列出目录,名字回退成文件夹名。世界还在,只是元信息读不出来。
跨版本汇总:区分「隔离的世界」和「共享根的世界」
同一台机器上装了两个版本,隔离模式下各自有自己的 saves/,共享模式下所有版本共用 .minecraft/saves/。存档页要显示全部世界,就得同时扫两处,但不能重复。
SaveLibraryService.ListAll 先扫共享根(版本 id 记空串),再只对隔离版本逐个扫它的 versions/<id>/:
var result = saves.ListSaves(dotMc, "", false); // 共享根
foreach (var id in ListVersionIds(dotMc))
{
if (!InstanceLayout.IsIsolated(store.Get(id), settings)) continue; // 非隔离跳过
result.AddRange(saves.ListSaves(InstanceLayout.GameRoot(dotMc, id, true), id, true));
}关键在于「非隔离版本跳过」。隔离的版本目录在磁盘上是 versions/<id>/,但它的 saves/ 可能根本不存在(用户从没在这个版本里开过世界),扫了是空;而不跳过非隔离版本,同一份世界就会被列两遍。合并后还要再排一次序——每个来源内部排好了,合并起来不重排就是乱的。
三种备份,三种粒度
世界级 zip(SaveService.Backup):把一个世界目录打成 zip,落在 backupsDir/<名>-yyyy-MM-dd_HHmmss.zip。用法是「这个存档我手动备份一份」,最简单,粒度最细。
版本级快照(VersionBackupService):备份一个版本的若干内容目录(mods / config / resourcepacks / shaderpacks / 版本目录本体),索引写在 backups.json:
{
"entries": [{
"id": "a3f9c1d2",
"version_id": "1.20.1-fabric",
"name": "改光影前",
"created_at": "2026-10-14T12:00:00",
"size_bytes": 81234567,
"file_count": 412,
"zip_path": "1.20.1-fabric/改光影前-2026-10-14_120000.zip",
"includes": ["versions/1.20.1-fabric/mods", "versions/1.20.1-fabric/config"]
}]
}includes 是这份快照覆盖了哪些范围。回档时按 includes 清理 + 覆盖,所以它是权威清单,不是给人看的描述。索引写坏了还能按 BackupRoot 下实际存在的 zip 重建(RebuildIndex 会从 zip 内容反推 includes)。
分享包(SaveService.ExportForShare):格式和世界级 zip 一样(根目录直接是 level.dat),区别是落点由用户在保存对话框里选。发给任何人,对方在存档页「导入分享包」就能用,不要求对方登录任何账号——跨账号分享走文件,不经我们的云端。导入时目录名取 zip 文件名,重名就加 -2 / -3,绝不覆盖。
zip 内的路径就是落位路径
版本级快照的 zip 里,每条 entry 的路径是相对 .minecraft 根的 posix 路径(正斜杠):
versions/1.20.1-fabric/mods/sodium-fabric.jar
versions/1.20.1-fabric/config/sodium.json回档时不用再做路径映射,zip.FullName 直接拼到 .minecraft 根下就是落位点。统一用 posix 是为了跨平台——Windows 上 Path.Combine 出来的是反斜杠,而 zip 格式的规范是正斜杠。
ResolveSources 不纳入不存在的内容
ResolveSources 把勾选目录解析成范围时,如果目标在磁盘上不存在,直接跳过,不进 includes。
这不是偷懒,是回档安全的前提。回档的步骤是「清空 includes 覆盖的范围 → 再解压填回去」。如果把一个不存在的目录也写进 includes,回档时它会被「清空」(无害),但反过来——如果这个目录在备份之后被用户删掉了、或者当时是空目录没进快照——清空和覆盖的语义就对不上了。只备份真实存在过的内容,回档的范围才等于快照的范围。
回档先自动快照
RestoreAsync 的第一步不是恢复,是备份当前状态:
status?.Report("正在备份当前状态(安全网)…");
await BackupAsync(dotMc, new VersionBackupRequest(
entry.VersionId, isolated,
$"回档前自动备份-{DateTime.Now:yyyy-MM-dd_HHmmss}",
entry.Includes, false), status, ct);用户点「回档」这个动作,语义是「我不要现在这个版本了」,但他点的可能是三个月前的快照,而现在的进度是三个月里攒的。先自动快照一次,回档错了还能退回来。快照范围就是当前那份的 includes——不多不少。代价是多花一次打包时间和一份 zip 的磁盘,可以接受。
备份根必须放在 starcloud/backups/ 下面
备份 zip 落在 <dotMc>/starcloud/backups/(InstanceLayout.BackupRoot)。如果备份根放在 .minecraft 根或者 versions/<id>/ 下面,备份一个版本时会把之前生成的备份 zip 一起打进新 zip——体积指数级膨胀,几轮之后磁盘就满了。所以 IsExcluded 第一条就是排除 starcloud/backups/ 前缀,另外还排除 logs/、crash-reports/ 和版本目录下的 .log。