feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,20 @@
namespace PCL.Core.Minecraft.Saves;
/// <summary>
/// DataVersion 关键分界线常量。
/// 各值取自对应快照的 <c>Data.DataVersion</c>。
/// </summary>
public static class DataVersionBoundaries
{
/// <summary>15w32a1.9 快照)引入了 DataVersion 字段</summary>
public const int _15w32a = 100;
/// <summary>17w47a1.13 快照)引入了 DataPacks 字段</summary>
public const int _17w47a = 1443;
/// <summary>20w20a1.16 快照)引入了 WorldGenSettings.seed 替代 RandomSeed</summary>
public const int _20w20a = 2536;
/// <summary>26.1-snapshot-6 引入了 difficulty_settings 复合标签、spawn.pos 数组、外部种子文件</summary>
public const int _261snapshot6 = 4774;
}
@@ -0,0 +1,40 @@
using System;
namespace PCL.Core.Minecraft.Saves.Editing;
/// <summary>
/// 可编辑值的标记联合 —— 表示"不修改"或"修改为某值"。
/// 值类型实现,避免在 <see cref="Editing.SaveChanges"/> 中产生堆分配。
/// </summary>
/// <typeparam name="T">值的类型(必须为值类型)。</typeparam>
public readonly record struct Editable<T> where T : struct
{
private readonly T _value;
private readonly bool _hasValue;
/// <summary>创建一个"修改为指定值"的实例。</summary>
public Editable(T value)
{
_value = value;
_hasValue = true;
}
/// <summary>是否显式设置了新值。</summary>
public bool HasValue => _hasValue;
/// <summary>新值。如果 <see cref="HasValue"/> 为 false,则抛出异常。</summary>
public T Value => _hasValue ? _value : throw new InvalidOperationException("Editable 没有值。");
/// <summary>有值则返回新值,否则返回 <paramref name="defaultValue"/>。</summary>
public T GetValueOrDefault(T defaultValue) => _hasValue ? _value : defaultValue;
/// <summary>尝试获取新值。</summary>
public bool TryGetValue(out T value)
{
value = _value;
return _hasValue;
}
/// <summary>返回值的字符串表示,无值时返回 "&lt;unspecified&gt;"。</summary>
public override string ToString() => _hasValue ? _value!.ToString() ?? "" : "<unspecified>";
}
@@ -0,0 +1,19 @@
using fNbt;
namespace PCL.Core.Minecraft.Saves.Editing;
/// <summary>
/// 存档编辑器接口 —— 负责将修改写入 level.dat 的 Data 复合标签(内存操作)。
/// 实现类需声明自己支持的 DataVersion 范围。
/// </summary>
public interface ISaveEditor
{
/// <summary>返回此编辑器能否处理指定 DataVersion 的存档。</summary>
bool CanHandle(int? dataVersion);
/// <summary>
/// 将 <paramref name="changes"/> 中的修改写入 <paramref name="data"/> 复合标签。
/// 返回 true 表示至少有一项修改被成功写入。
/// </summary>
bool ApplyChanges(NbtCompound data, SaveChanges changes);
}
@@ -0,0 +1,53 @@
using fNbt;
namespace PCL.Core.Minecraft.Saves.Editing.Internal;
/// <summary>
/// 26.1 之前的存档编辑器(含整个 1.x 版本体系)。
/// 仅操作内存中的 NbtCompound,文件 IO 由 <see cref="SaveManager"/> 统一处理。
/// </summary>
internal sealed class Pre261SaveEditor : ISaveEditor
{
public bool CanHandle(int? dataVersion)
=> dataVersion is null || dataVersion < DataVersionBoundaries._261snapshot6;
public bool ApplyChanges(NbtCompound data, SaveChanges changes)
{
if (changes.IsEmpty)
return false;
var changed = false;
changed |= WriteAllowCommands(data, changes);
changed |= WriteDifficulty(data, changes);
changed |= WriteDifficultyLocked(data, changes);
return changed;
}
/// <summary>写入 Data.allowCommands(字节型:0/1)。仅当该字段原本存在时才写入,避免向 pre-1.3.1 存档添加新字段。</summary>
internal static bool WriteAllowCommands(NbtCompound data, SaveChanges changes)
{
if (!changes.AllowCommands.HasValue || !data.Contains("allowCommands"))
return false;
data["allowCommands"] = new NbtByte("allowCommands", (byte)(changes.AllowCommands.Value ? 1 : 0));
return true;
}
/// <summary>写入 Data.Difficulty(字节型:0=和平, 1=简单, 2=普通, 3=困难)。仅当该字段原本存在时才写入。</summary>
internal static bool WriteDifficulty(NbtCompound data, SaveChanges changes)
{
if (!changes.Difficulty.HasValue || !data.Contains("Difficulty"))
return false;
data["Difficulty"] = new NbtByte("Difficulty", (byte)changes.Difficulty.Value);
return true;
}
/// <summary>写入 Data.DifficultyLocked(字节型:0/1)。仅当该字段原本存在时才写入。</summary>
internal static bool WriteDifficultyLocked(NbtCompound data, SaveChanges changes)
{
if (!changes.LockDifficulty.HasValue || !data.Contains("DifficultyLocked"))
return false;
data["DifficultyLocked"] = new NbtByte("DifficultyLocked", (byte)(changes.LockDifficulty.Value ? 1 : 0));
return true;
}
}
@@ -0,0 +1,59 @@
using fNbt;
namespace PCL.Core.Minecraft.Saves.Editing.Internal;
/// <summary>
/// 26.1-snapshot-6 及之后的存档编辑器(2026 新版本号体系)。
/// 仅操作内存中的 NbtCompound,文件 IO 由 <see cref="SaveManager"/> 统一处理。
/// </summary>
internal sealed class Version261PlusSaveEditor : ISaveEditor
{
public bool CanHandle(int? dataVersion)
=> dataVersion >= DataVersionBoundaries._261snapshot6;
public bool ApplyChanges(NbtCompound data, SaveChanges changes)
{
if (changes.IsEmpty)
return false;
// 确保 difficulty_settings 复合标签存在
if (!data.TryGet<NbtCompound>("difficulty_settings", out var ds) || ds is null)
{
ds = new NbtCompound("difficulty_settings");
data.Add(ds);
}
var changed = false;
changed |= Pre261SaveEditor.WriteAllowCommands(data, changes);
changed |= WriteDifficulty(ds!, changes);
changed |= WriteLocked(ds!, changes);
return changed;
}
/// <summary>写入 difficulty_settings.difficulty(字符串型)。</summary>
internal static bool WriteDifficulty(NbtCompound difficultySettings, SaveChanges changes)
{
if (!changes.Difficulty.HasValue)
return false;
var val = changes.Difficulty.Value switch
{
Difficulty.Peaceful => "peaceful",
Difficulty.Easy => "easy",
Difficulty.Normal => "normal",
Difficulty.Hard => "hard",
_ => "normal",
};
difficultySettings["difficulty"] = new NbtString("difficulty", val);
return true;
}
/// <summary>写入 difficulty_settings.locked(字节型:0/1)。</summary>
internal static bool WriteLocked(NbtCompound difficultySettings, SaveChanges changes)
{
if (!changes.LockDifficulty.HasValue)
return false;
difficultySettings["locked"] = new NbtByte("locked", (byte)(changes.LockDifficulty.Value ? 1 : 0));
return true;
}
}
@@ -0,0 +1,20 @@
namespace PCL.Core.Minecraft.Saves.Editing;
/// <summary>
/// 用户希望对存档应用的修改。使用 <c>default</c> 表示"无任何修改"。
/// 仅当 <see cref="Editable{T}.HasValue"/> 为 true 的字段才会被写入。
/// </summary>
public record struct SaveChanges
{
/// <summary>是否允许作弊命令的修改。</summary>
public Editable<bool> AllowCommands { get; set; }
/// <summary>游戏难度的修改。</summary>
public Editable<Difficulty> Difficulty { get; set; }
/// <summary>是否锁定难度的修改。</summary>
public Editable<bool> LockDifficulty { get; set; }
/// <summary>此结构体是否不包含任何待写入的修改。</summary>
public bool IsEmpty => !AllowCommands.HasValue && !Difficulty.HasValue && !LockDifficulty.HasValue;
}
@@ -0,0 +1,30 @@
using System;
namespace PCL.Core.Minecraft.Saves.Exceptions;
/// <summary>
/// 存档损坏异常 —— level.dat 存在但无法解析或无法写入时抛出。
/// </summary>
public class SaveCorruptedException : Exception
{
/// <summary>存档文件夹的绝对路径。</summary>
public string FolderPath { get; }
public SaveCorruptedException(string folderPath)
: base($"存档损坏:无法解析 '{folderPath}' 中的 level.dat")
{
FolderPath = folderPath;
}
public SaveCorruptedException(string folderPath, string message)
: base(message)
{
FolderPath = folderPath;
}
public SaveCorruptedException(string folderPath, string message, Exception inner)
: base(message, inner)
{
FolderPath = folderPath;
}
}
@@ -0,0 +1,30 @@
using System;
namespace PCL.Core.Minecraft.Saves.Exceptions;
/// <summary>
/// 存档未找到异常 —— level.dat 缺失或指定文件夹非有效存档时抛出。
/// </summary>
public class SaveNotFoundException : Exception
{
/// <summary>存档文件夹的绝对路径。</summary>
public string FolderPath { get; }
public SaveNotFoundException(string folderPath)
: base($"未找到存档:'{folderPath}' 中缺少 level.dat")
{
FolderPath = folderPath;
}
public SaveNotFoundException(string folderPath, string message)
: base(message)
{
FolderPath = folderPath;
}
public SaveNotFoundException(string folderPath, string message, Exception inner)
: base(message, inner)
{
FolderPath = folderPath;
}
}
@@ -0,0 +1,58 @@
namespace PCL.Core.Minecraft.Saves;
/// <summary>
/// 游戏难度。
/// </summary>
public enum Difficulty
{
/// <summary>和平</summary>
Peaceful = 0,
/// <summary>简单</summary>
Easy = 1,
/// <summary>普通</summary>
Normal = 2,
/// <summary>困难</summary>
Hard = 3,
}
/// <summary>
/// 游戏模式。
/// </summary>
public enum GameMode
{
/// <summary>生存</summary>
Survival = 0,
/// <summary>创造</summary>
Creative = 1,
/// <summary>冒险</summary>
Adventure = 2,
/// <summary>旁观</summary>
Spectator = 3,
/// <summary>极限模式 —— 在 NBT 中并非独立的 GameType,而是 Survival + hardcore=1。</summary>
Hardcore = 4,
}
/// <summary>
/// 存档格式版本,按 Minecraft 大版本的历史演进排列,直接对应解析器类型。
/// 各解析器的匹配优先级等于版本号从高到低的顺序。
/// </summary>
public enum SaveFormatVersion
{
/// <summary>Alpha ~ 正式 1.2.5</summary>
Pre113,
/// <summary>1.3.1 ~ 1.8.9</summary>
Version131To189,
/// <summary>15w32a(1.9) ~ 1.12.2</summary>
Version19To1122,
/// <summary>17w47a(1.13) ~ 1.15.2</summary>
Version113To1152,
/// <summary>20w20a(1.16) ~ 1.21.11</summary>
Version116To1211,
/// <summary>26.1-snapshot-6 及之后(2026 新版本号体系)</summary>
Version261Plus,
}
@@ -0,0 +1,29 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing;
/// <summary>
/// 存档解析器接口 —— 负责将 level.dat 中的 NBT 数据转换为 <see cref="SaveInfo"/>。
/// 每种格式版本对应一个实现类。
/// </summary>
public interface ISaveParser
{
/// <summary>此解析器对应的存档格式版本。</summary>
SaveFormatVersion FormatVersion { get; }
/// <summary>返回此解析器能否处理给定的 NBT 数据。</summary>
/// <param name="data">level.dat 中的 Data 复合标签。</param>
/// <param name="dataVersion">Data 中的 DataVersion 字段值,如果不存在则为 null。</param>
bool CanHandle(NbtCompound data, int? dataVersion);
/// <summary>
/// 解析 NBT 数据并返回 <see cref="SaveInfo"/>。
/// 文件系统元数据(创建时间、修改时间)由调用方传入。
/// </summary>
/// <param name="folderPath">存档文件夹的绝对路径。</param>
/// <param name="data">level.dat 中的 Data 复合标签。</param>
/// <param name="createdAt">文件夹创建时间(UTC)。</param>
/// <param name="modifiedAt">level.dat 最后修改时间(UTC)。</param>
SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt);
}
@@ -0,0 +1,84 @@
using System;
using fNbt;
using System.Numerics;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// NBT 读取工具方法,多个版本解析器共用。
/// </summary>
internal static class NbtReadHelper
{
/// <summary>尝试从 NBT 复合标签中读取 long 值。</summary>
public static long? TryGetLong(NbtCompound data, string key) =>
data.TryGet<NbtLong>(key, out var tag) ? tag!.Value : null;
/// <summary>读取最后游玩时间并转为 UTC DateTime。</summary>
public static DateTime ReadLastPlayed(NbtCompound data) =>
EpochMsToUtc(TryGetLong(data, "LastPlayed") ?? 0);
/// <summary>将 Unix 毫秒时间戳转为 UTC DateTime。</summary>
public static DateTime EpochMsToUtc(long ms) =>
DateTime.UnixEpoch.AddMilliseconds(ms);
/// <summary>读取累计游戏时间。Minecraft 以 tick 为单位(20 tick = 1 秒)。</summary>
public static TimeSpan ReadPlayTime(NbtCompound data)
{
var ticks = TryGetLong(data, "Time");
return TimeSpan.FromSeconds((ticks ?? 0) / 20.0d);
}
/// <summary>读取游戏模式。hardcore 不是独立的 GameType,而是 Survival + hardcore=1。</summary>
public static GameMode ReadGameMode(NbtCompound data, out bool isHardcore)
{
isHardcore = data.TryGet<NbtByte>("hardcore", out var hc) && hc!.Value == 1;
if (isHardcore) return GameMode.Hardcore;
var gt = data.TryGet<NbtInt>("GameType", out var gameType) ? gameType!.Value : 0;
return gt switch
{
1 => GameMode.Creative,
2 => GameMode.Adventure,
3 => GameMode.Spectator,
_ => GameMode.Survival,
};
}
/// <summary>读取出生点坐标 —— 旧版格式(SpawnX/Y/Z 三个独立 int 字段)。</summary>
public static Vector3? TryReadSpawnFromFields(NbtCompound data)
{
if (data.TryGet<NbtInt>("SpawnX", out var sx) &&
data.TryGet<NbtInt>("SpawnY", out var sy) &&
data.TryGet<NbtInt>("SpawnZ", out var sz))
return new Vector3(sx!.Value, sy!.Value, sz!.Value);
return null;
}
/// <summary>读取出生点坐标 —— 新版格式(spawn.pos int[] 数组)。</summary>
public static Vector3? TryReadSpawnFromPos(NbtCompound data)
{
if (data.TryGet<NbtCompound>("spawn", out var spawn) &&
spawn!.TryGet<NbtIntArray>("pos", out var pos) && pos!.Value.Length == 3)
return new Vector3(pos[0], pos[1], pos[2]);
return null;
}
/// <summary>读取旧版字节型难度(0=和平, 1=简单, 2=普通, 3=困难)。</summary>
public static Difficulty? ReadDifficultyByte(NbtCompound data)
{
if (data.TryGet<NbtByte>("Difficulty", out var diff))
return (Difficulty)diff!.Value;
return null;
}
/// <summary>读取 Data.Version 复合标签中的版本信息。</summary>
public static (string? name, int? id) ReadVersion(NbtCompound data)
{
if (data.TryGet<NbtCompound>("Version", out var version))
{
var name = version!.TryGet<NbtString>("Name", out var n) ? n!.Value : null;
var id = version.TryGet<NbtInt>("Id", out var i) ? i!.Value : (int?)null;
return (name, id);
}
return (null, null);
}
}
@@ -0,0 +1,38 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// Alpha ~ 1.2.5 的存档格式。
/// 特征:没有 DataVersion、没有 allowCommands、没有 Difficulty。
/// </summary>
internal sealed class Pre113SaveParser : ISaveParser
{
public SaveFormatVersion FormatVersion => SaveFormatVersion.Pre113;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion is null && !data.Contains("allowCommands");
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
return new SaveInfo
{
LevelName = data.TryGet<NbtString>("LevelName", out var ln) ? ln!.Value : "unknown",
VersionName = null,
VersionId = null,
Seed = NbtReadHelper.TryGetLong(data, "RandomSeed"),
LastPlayedUtc = NbtReadHelper.ReadLastPlayed(data),
Spawn = NbtReadHelper.TryReadSpawnFromFields(data),
GameMode = NbtReadHelper.ReadGameMode(data, out var isHardcore),
Difficulty = null,
IsDifficultyLocked = false,
IsHardcore = isHardcore,
AllowCommands = false,
PlayTime = NbtReadHelper.ReadPlayTime(data),
FolderPath = folderPath,
CreatedAt = createdAt,
ModifiedAt = modifiedAt,
};
}
}
@@ -0,0 +1,26 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 17w47a(1.13) ~ 1.15.2 的存档格式。
/// 特征:DataVersion 在 [1443, 2536) 之间,新增 DataPacks 字段。
/// </summary>
internal sealed class Version113To1152SaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version113To1152SaveParser() : this(new Version19To1122SaveParser()) { }
public Version113To1152SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version113To1152;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion.HasValue
&& dataVersion.Value >= DataVersionBoundaries._17w47a
&& dataVersion.Value < DataVersionBoundaries._20w20a;
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
=> _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
}
@@ -0,0 +1,43 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 20w20a(1.16) ~ 1.21.11 的存档格式。
/// 特征:DataVersion 在 [2536, 4774) 之间。
/// 变更:种子从 Data.RandomSeed 迁移到 Data.WorldGenSettings.seed。
/// </summary>
internal sealed class Version116To1211SaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version116To1211SaveParser() : this(new Version19To1122SaveParser()) { }
public Version116To1211SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version116To1211;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion.HasValue
&& dataVersion.Value >= DataVersionBoundaries._20w20a
&& dataVersion.Value < DataVersionBoundaries._261snapshot6;
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
return baseInfo with
{
Seed = ReadWorldGenSeed(data),
Spawn = NbtReadHelper.TryReadSpawnFromPos(data)
?? NbtReadHelper.TryReadSpawnFromFields(data),
};
}
internal static long? ReadWorldGenSeed(NbtCompound data)
{
if (data.TryGet<NbtCompound>("WorldGenSettings", out var wgs) &&
wgs!.TryGet<NbtLong>("seed", out var seed))
return seed!.Value;
return NbtReadHelper.TryGetLong(data, "RandomSeed");
}
}
@@ -0,0 +1,38 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 1.3.1 ~ 1.8.9 的存档格式。
/// 特征:没有 DataVersion,有 allowCommands。
/// </summary>
internal sealed class Version131To189SaveParser : ISaveParser
{
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version131To189;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion is null && data.Contains("allowCommands");
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
return new SaveInfo
{
LevelName = data.TryGet<NbtString>("LevelName", out var ln) ? ln!.Value : "unknown",
VersionName = null,
VersionId = null,
Seed = NbtReadHelper.TryGetLong(data, "RandomSeed"),
LastPlayedUtc = NbtReadHelper.ReadLastPlayed(data),
Spawn = NbtReadHelper.TryReadSpawnFromFields(data),
GameMode = NbtReadHelper.ReadGameMode(data, out _),
Difficulty = NbtReadHelper.ReadDifficultyByte(data),
IsDifficultyLocked = data.TryGet<NbtByte>("DifficultyLocked", out var dl) && dl!.Value == 1,
IsHardcore = data.TryGet<NbtByte>("hardcore", out var hc) && hc!.Value == 1,
AllowCommands = data.TryGet<NbtByte>("allowCommands", out var ac) && ac!.Value == 1,
PlayTime = NbtReadHelper.ReadPlayTime(data),
FolderPath = folderPath,
CreatedAt = createdAt,
ModifiedAt = modifiedAt,
};
}
}
@@ -0,0 +1,30 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 15w32a(1.9) ~ 1.12.2 的存档格式。
/// 特征:DataVersion >= 100 且 &lt; 1443,新增 DataVersion 和 Version 复合标签。
/// </summary>
internal sealed class Version19To1122SaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version19To1122SaveParser() : this(new Version131To189SaveParser()) { }
public Version19To1122SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version19To1122;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion.HasValue
&& dataVersion.Value >= DataVersionBoundaries._15w32a
&& dataVersion.Value < DataVersionBoundaries._17w47a;
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
(var versionName, var versionId) = NbtReadHelper.ReadVersion(data);
return baseInfo with { VersionName = versionName, VersionId = versionId };
}
}
@@ -0,0 +1,101 @@
using System;
using System.IO;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 26.1-snapshot-6 及之后的存档格式(2026 新版本号体系)。
/// 特征:DataVersion >= 4774 或存在 difficulty_settings 复合标签。
/// 变更:
/// - 出生点迁移到 spawn.pos int[3]
/// - 难度迁移到 difficulty_settings 复合标签(字符串型)
/// - 种子可能在外部文件 data/minecraft/world_gen_settings.dat 中
/// </summary>
internal sealed class Version261PlusSaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version261PlusSaveParser() : this(new Version19To1122SaveParser()) { }
public Version261PlusSaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version261Plus;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion >= DataVersionBoundaries._261snapshot6
|| data.Contains("difficulty_settings");
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
var seed = Version116To1211SaveParser.ReadWorldGenSeed(data)
?? ReadSeedFromExternalFile(folderPath);
var spawn = NbtReadHelper.TryReadSpawnFromPos(data)
?? NbtReadHelper.TryReadSpawnFromFields(data);
var difficulty = ReadDifficultySettings(data);
var isHardcore = ReadHardcore(data);
var isLocked = ReadLocked(data);
return baseInfo with
{
Seed = seed,
Spawn = spawn,
Difficulty = difficulty,
IsHardcore = isHardcore,
IsDifficultyLocked = isLocked,
GameMode = isHardcore ? GameMode.Hardcore : baseInfo.GameMode,
};
}
// ── difficulty_settings 复合标签解析 ──
internal static Difficulty? ReadDifficultySettings(NbtCompound data)
{
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
ds!.TryGet<NbtString>("difficulty", out var diffStr))
{
return diffStr!.Value switch
{
"peaceful" => Difficulty.Peaceful,
"easy" => Difficulty.Easy,
"normal" => Difficulty.Normal,
"hard" => Difficulty.Hard,
_ => null,
};
}
return NbtReadHelper.ReadDifficultyByte(data);
}
internal static bool ReadHardcore(NbtCompound data)
{
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
ds!.TryGet<NbtByte>("hardcore", out var hc))
return hc!.Value == 1;
return data.TryGet<NbtByte>("hardcore", out var legacyHc) && legacyHc!.Value == 1;
}
internal static bool ReadLocked(NbtCompound data)
{
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
ds!.TryGet<NbtByte>("locked", out var locked))
return locked!.Value == 1;
return data.TryGet<NbtByte>("DifficultyLocked", out var dl) && dl!.Value == 1;
}
internal static long? ReadSeedFromExternalFile(string folderPath)
{
var externalPath = Path.Combine(folderPath, "data", "minecraft", "world_gen_settings.dat");
if (!File.Exists(externalPath))
return null;
try
{
var nbtFile = new NbtFile(externalPath);
var rootData = nbtFile.RootTag.Get<NbtCompound>("data");
return rootData?.TryGet<NbtLong>("seed", out var seed) == true ? seed!.Value : null;
}
catch { return null; }
}
}
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Linq;
using fNbt;
using PCL.Core.Minecraft.Saves.Parsing.Internal;
namespace PCL.Core.Minecraft.Saves.Parsing;
/// <summary>
/// 解析器工厂 —— 按优先级遍历已注册的解析器,返回第一个能处理给定数据的解析器。
/// 默认注册顺序从高版本到低版本,确保最特化的解析器优先匹配。
/// 可通过构造函数注入自定义解析器列表。
/// </summary>
public sealed class SaveParserFactory
{
private readonly IReadOnlyList<ISaveParser> _parsers;
/// <summary>使用内置的默认解析器列表初始化(从高版本到低版本)。</summary>
public SaveParserFactory()
{
_parsers =
[
new Version261PlusSaveParser(), // >= 26.1-snapshot-6
new Version116To1211SaveParser(), // 1.16 ~ 1.21.11
new Version113To1152SaveParser(), // 1.13 ~ 1.15.2
new Version19To1122SaveParser(), // 1.9 ~ 1.12.2
new Version131To189SaveParser(), // 1.3.1 ~ 1.8.9
new Pre113SaveParser(), // Alpha ~ 1.2.5
];
}
/// <summary>使用自定义解析器列表初始化(支持 DI 注入)。解析器按传入顺序求值。</summary>
public SaveParserFactory(IEnumerable<ISaveParser> customParsers)
{
_parsers = customParsers?.ToArray() ?? [];
}
/// <summary>
/// 查找第一个能处理给定 NBT 数据的解析器。
/// </summary>
/// <param name="data">level.dat 中的 Data 复合标签。</param>
/// <param name="dataVersion">DataVersion 字段值,如果不存在则为 null。</param>
/// <returns>匹配的解析器,未找到时返回 null。</returns>
public ISaveParser? Resolve(NbtCompound data, int? dataVersion)
{
foreach (var parser in _parsers)
{
if (parser.CanHandle(data, dataVersion))
return parser;
}
return null;
}
}
@@ -0,0 +1,56 @@
using System;
using System.Numerics;
namespace PCL.Core.Minecraft.Saves;
/// <summary>
/// 存档核心数据模型 —— 不可变记录,由解析器从 level.dat 中提取。
/// 调用方通过 <see cref="SaveManager"/> 获取此对象。
/// </summary>
public sealed record SaveInfo
{
/// <summary>世界名称。</summary>
public required string LevelName { get; init; }
/// <summary>最后保存此存档的游戏版本名(如 "1.20.4")。</summary>
public string? VersionName { get; init; }
/// <summary>最后保存此存档的游戏数据版本号(对应 <c>Data.Version.Id</c>)。</summary>
public int? VersionId { get; init; }
/// <summary>世界种子。</summary>
public long? Seed { get; init; }
/// <summary>最后游玩时间(UTC)。</summary>
public DateTime LastPlayedUtc { get; init; }
/// <summary>出生点坐标 (X, Y, Z)。</summary>
public Vector3? Spawn { get; init; }
/// <summary>游戏模式。Hardcore 通过 IsHardcore 字段表示。</summary>
public GameMode GameMode { get; init; }
/// <summary>游戏难度。1.3.1 之前的存档中可能为 null。</summary>
public Difficulty? Difficulty { get; init; }
/// <summary>难度是否已锁定。</summary>
public bool IsDifficultyLocked { get; init; }
/// <summary>是否为极限模式。</summary>
public bool IsHardcore { get; init; }
/// <summary>是否允许作弊命令。</summary>
public bool AllowCommands { get; init; }
/// <summary>累计游戏时间。</summary>
public TimeSpan PlayTime { get; init; }
/// <summary>存档文件夹的绝对路径。</summary>
public required string FolderPath { get; init; }
/// <summary>存档文件夹的创建时间(UTC)。</summary>
public DateTime CreatedAt { get; init; }
/// <summary>level.dat 的最后修改时间(UTC)。</summary>
public DateTime ModifiedAt { get; init; }
}
@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using fNbt;
using PCL.Core.Logging;
using PCL.Core.Minecraft.Saves.Editing;
using PCL.Core.Minecraft.Saves.Editing.Internal;
using PCL.Core.Minecraft.Saves.Exceptions;
using PCL.Core.Minecraft.Saves.Parsing;
namespace PCL.Core.Minecraft.Saves;
/// <summary>
/// 存档管理器 —— 存档系统的统一入口。
/// 提供扫描、读取、批量读取和修改存档的功能。
/// 可通过构造函数注入自定义的解析器工厂和编辑器列表。
/// </summary>
public class SaveManager
{
private readonly SaveParserFactory _parserFactory;
private readonly IReadOnlyList<ISaveEditor> _editors;
/// <summary>
/// 创建新的存档管理器。
/// </summary>
/// <param name="parserFactory">自定义解析器工厂,为 null 时使用默认工厂。</param>
/// <param name="customEditors">自定义编辑器列表,为 null 时使用默认编辑器。</param>
public SaveManager(
SaveParserFactory? parserFactory = null,
IEnumerable<ISaveEditor>? customEditors = null)
{
_parserFactory = parserFactory ?? new SaveParserFactory();
_editors = customEditors?.ToArray() ?? [new Pre261SaveEditor(), new Version261PlusSaveEditor()];
}
/// <summary>
/// 扫描指定目录下的所有有效存档文件夹,返回按最后游玩时间降序排列的列表。
/// 不含 level.dat 的文件夹会被静默跳过。
/// </summary>
/// <param name="savesPath">存档根目录(通常为 .minecraft/saves)。</param>
/// <param name="ct">取消令牌。</param>
public async Task<IReadOnlyList<SaveInfo>> ScanSaveFoldersAsync(
string savesPath, CancellationToken ct = default)
{
if (!Directory.Exists(savesPath))
return [];
var folderPaths = Directory.GetDirectories(savesPath);
var results = new List<SaveInfo>(folderPaths.Length);
foreach (var folder in folderPaths)
{
ct.ThrowIfCancellationRequested();
try
{
var info = await LoadSaveAsync(folder, ct).ConfigureAwait(false);
if (info is not null)
results.Add(info);
}
catch (SaveNotFoundException)
{
// 非存档文件夹,静默跳过
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogWrapper.Warn(ex, "Saves", $"扫描存档文件夹失败:{folder}");
}
}
return results.OrderByDescending(s => s.LastPlayedUtc).ToList();
}
/// <summary>
/// 加载指定文件夹中的单个存档。
/// </summary>
/// <param name="folderPath">存档文件夹的绝对路径。</param>
/// <param name="ct">取消令牌。</param>
/// <exception cref="SaveNotFoundException">level.dat 缺失。</exception>
/// <exception cref="SaveCorruptedException">level.dat 存在但无法解析。</exception>
public Task<SaveInfo> LoadSaveAsync(string folderPath, CancellationToken ct = default)
{
var levelDatPath = ResolveLevelDatPath(folderPath);
if (levelDatPath is null)
throw new SaveNotFoundException(folderPath);
return LoadFromPathAsync(folderPath, levelDatPath, ct);
}
/// <summary>
/// 批量异步加载存档目录下的所有存档,每解析完一个即通过 IAsyncEnumerable 向外产出。
/// 无法加载的存档会被记录日志并跳过,不会中断整个枚举。
/// </summary>
/// <param name="savesPath">存档根目录。</param>
/// <param name="ct">取消令牌。</param>
public async IAsyncEnumerable<SaveInfo> LoadSavesAsync(
string savesPath,
[EnumeratorCancellation] CancellationToken ct = default)
{
if (!Directory.Exists(savesPath))
yield break;
var folderPaths = Directory.GetDirectories(savesPath);
foreach (var folder in folderPaths)
{
ct.ThrowIfCancellationRequested();
SaveInfo? info = null;
try
{
info = await LoadSaveAsync(folder, ct).ConfigureAwait(false);
}
catch (SaveNotFoundException)
{
// 非存档文件夹,跳过
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogWrapper.Warn(ex, "Saves", $"加载存档失败:{folder}");
}
if (info is not null)
yield return info;
}
}
/// <summary>
/// 将指定的修改应用到某个存档。
/// </summary>
/// <param name="folderPath">存档文件夹的绝对路径。</param>
/// <param name="changes">要应用的修改集合。</param>
/// <param name="ct">取消令牌。</param>
/// <returns>至少有一项修改成功写入时返回 true。</returns>
/// <exception cref="SaveNotFoundException">level.dat 缺失。</exception>
/// <exception cref="SaveCorruptedException">level.dat 解析或写入失败。</exception>
public async Task<bool> ApplyChangesAsync(
string folderPath, SaveChanges changes, CancellationToken ct = default)
{
// 无修改时直接返回,避免不必要的文件 IO
if (changes.IsEmpty)
return false;
var levelDatPath = ResolveLevelDatPath(folderPath)
?? throw new SaveNotFoundException(folderPath);
// 一次解析 level.dat,提取 Data 复合标签和 DataVersion
NbtFile nbtFile;
NbtCompound data;
try
{
nbtFile = new NbtFile();
await Task.Run(() =>
{
using var fs = new FileStream(levelDatPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
nbtFile.LoadFromStream(fs, NbtCompression.AutoDetect);
}, ct).ConfigureAwait(false);
data = nbtFile.RootTag.Get<NbtCompound>("Data")
?? throw new InvalidDataException("level.dat 中缺少 Data 复合标签");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw new SaveCorruptedException(folderPath, $"解析 level.dat 失败:'{levelDatPath}'", ex);
}
var dataVersion = ReadDataVersionFromCompound(data);
// 匹配编辑器,执行内存修改
foreach (var editor in _editors)
{
if (editor.CanHandle(dataVersion))
{
try
{
if (!editor.ApplyChanges(data, changes))
return false;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw new SaveCorruptedException(folderPath,
$"应用修改失败:'{folderPath}'", ex);
}
// 原子写入:temp → 备份 → 重命名
await WriteLevelDatAtomicallyAsync(levelDatPath, nbtFile, ct).ConfigureAwait(false);
return true;
}
}
// 找不到匹配编辑器时抛出异常,与"无修改"(返回 false)区分
throw new SaveCorruptedException(folderPath,
$"找不到匹配的存档编辑器(DataVersion: {dataVersion}");
}
/// <summary>
/// 原子写入 level.dat:先将 NBT 写入临时文件,再通过重命名完成原子替换。
/// 始终写入 level.dat(即使从 level.dat_old 回退读取)。
/// </summary>
private static async Task WriteLevelDatAtomicallyAsync(
string sourcePath, NbtFile nbtFile, CancellationToken ct)
{
var dir = Path.GetDirectoryName(sourcePath)!;
var tempPath = Path.Combine(dir, $"level{Guid.NewGuid():N}.dat");
var backupPath = Path.Combine(dir, "level.dat_old");
var targetPath = Path.Combine(dir, "level.dat");
try
{
// 1. 写入临时文件
await Task.Run(() =>
{
using var fs = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 4096, true);
nbtFile.SaveToStream(fs, NbtCompression.GZip);
}, ct).ConfigureAwait(false);
// 2. 仅当从 level.dat 读取时,才备份当前 level.dat → level.dat_old
// 若从 level.dat_old 回退读取,说明 level.dat 已损坏/不存在,跳过备份。
if (sourcePath == targetPath && File.Exists(targetPath))
{
File.Move(targetPath, backupPath, overwrite: true);
}
// 3. 重命名临时文件 → level.dat
File.Move(tempPath, targetPath);
}
catch
{
TryDelete(tempPath);
throw;
}
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort */ }
}
/// <summary>核心加载逻辑:读取 level.dat → 解析 DataVersion → 匹配解析器 → 构建 SaveInfo。</summary>
private async Task<SaveInfo> LoadFromPathAsync(
string folderPath, string levelDatPath, CancellationToken ct)
{
NbtFile nbtFile;
try
{
nbtFile = await LoadNbtFileAsync(levelDatPath, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new SaveCorruptedException(folderPath,
$"解析 level.dat 失败:'{levelDatPath}'", ex);
}
// level.dat 根标签下必须有 Data 复合标签
var data = nbtFile.RootTag.Get<NbtCompound>("Data")
?? throw new SaveCorruptedException(folderPath,
$"level.dat 中缺少 Data 复合标签:{levelDatPath}");
var dataVersion = ReadDataVersionFromCompound(data);
var createdAt = Directory.GetCreationTimeUtc(folderPath);
var modifiedAt = File.GetLastWriteTimeUtc(levelDatPath);
var parser = _parserFactory.Resolve(data, dataVersion)
?? throw new SaveCorruptedException(folderPath,
$"找不到与存档 '{folderPath}' 匹配的解析器(DataVersion: {dataVersion}");
return parser.Parse(folderPath, data, createdAt, modifiedAt);
}
/// <summary>以异步方式加载 NBT 文件,自动检测压缩格式。</summary>
private static async Task<NbtFile> LoadNbtFileAsync(string path, CancellationToken ct)
{
var nbtFile = new NbtFile();
await Task.Run(() =>
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
nbtFile.LoadFromStream(fs, NbtCompression.AutoDetect);
}, ct).ConfigureAwait(false);
return nbtFile;
}
/// <summary>
/// 确定 level.dat 的路径。
/// 优先查找 level.dat,如果不存在则查找 level.dat_old 作为备份。
/// </summary>
private static string? ResolveLevelDatPath(string folderPath)
{
var primary = Path.Combine(folderPath, "level.dat");
if (File.Exists(primary))
return primary;
var backup = Path.Combine(folderPath, "level.dat_old");
return File.Exists(backup) ? backup : null;
}
/// <summary>从 Data 复合标签中读取 DataVersion 字段。</summary>
private static int? ReadDataVersionFromCompound(NbtCompound data)
{
if (data.TryGet<NbtInt>("DataVersion", out var dv))
return dv!.Value;
return null;
}
}