Skip to content

存档与备份 ​

存档是玩家唯一不能丢的东西——版本删了能重下,模组删了能重装,世界删了就没了。这页讲我们怎么把 level.dat 读出来、怎么跨版本汇总世界、以及三种粒度的备份各自解决什么问题。

level.dat 是 gzip 压的 NBT ​

每个世界的根目录下有一个 level.dat:先用 gzip 压缩,再包一层 NBT(NBT 是 Minecraft 自己用的二进制标签格式)。它不是纯存档——真正的世界数据在 region/ 里,level.dat 存的是元信息:世界名、最后游玩时间、游戏规则、种子。

我们只解析需要的那几个字段,所以写了一个极小的 NBT 读取器 LevelNbtReader,不用引第三方库:

csharp
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>/:

csharp
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:

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 的第一步不是恢复,是备份当前状态:

csharp
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。

相关链接:跨设备互联 · 版本隔离与实例档案 · 常见坑速查

基于 MIT 许可证(有修改)开源