NeoForge 数据存储完全指南
目录
1. 先建立全景图:NeoForge 里到底有哪些“存储层”
先记住一句话:存储技术不是按“会不会写”选,而是按“数据归属”选。
NeoForge/Minecraft 常见数据层可以拆成 5 层:
-
原始结构层(NBT)
-
本质是树状键值结构,读写入口非常多。
-
典型:
CompoundTag在落盘路径大量出现。
-
-
协议描述层(Codec / StreamCodec)
-
Codec负责结构规则(JSON/NBT 等)。 -
StreamCodec负责网络二进制编解码。
-
-
对象状态层(Attachment / Data Components)
-
通用宿主状态:
AttachmentType -
物品组件状态:
DataComponentType
-
-
世界持久层(SavedData / PlayerData)
-
世界/维度级:
SavedData -
玩家档案级:
PlayerDataStorage
-
-
数据驱动层(DataMap)
- 给注册表对象挂“可数据包配置”的值:
DataMapType
- 给注册表对象挂“可数据包配置”的值:
2. 方法论总框架:先问 6 个问题,再写 1 行代码
2.1 六问法(建议贴到你的项目 README)
-
归属:数据属于谁?玩家 / 实体 / 方块实体 / 区块 / 世界 / 注册表对象 / ItemStack。
-
生命周期:跨死亡吗?跨重登吗?跨维度吗?跨存档版本吗?
-
权威端:服务端权威还是客户端可预测?
-
同步边界:需要发给客户端吗?什么时候发?全量还是增量?
-
持久边界:落盘到哪里?什么时候触发 dirty?
-
演进策略:字段新增/删除时,旧档怎么读?
2.2 方案选型速查表
| 需求 | 推荐 | 关键原因 |
|---|---|---|
| 给实体/方块实体/区块/Level挂状态 | Attachment | 与宿主生命周期绑定 |
| 给 ItemStack 挂结构化数据 | Data Components | 1.21 核心体系,Patch 增量模型 |
| 存整个世界/维度全局状态 | SavedData | 标准落盘框架,data/*.dat |
| 给注册表对象(物品/方块)挂数据驱动参数 | DataMap | JSON + tag + merge/remove |
| 小规模快速原型 | 手写 NBT | 低门槛,但后期维护成本高 |
| 一套结构同时服务 JSON/NBT/网络 | Codec(+StreamCodec) | 协议统一、可演进 |
3. NBT:最底层、最直接、最容易踩坑的数据格式
NBT 适合“我现在就要把数据塞进 tag”这类场景,但也最容易出现兼容性债务。
3.1 从源码看 NBT 的真实落盘链路
-
SavedData.save(File, Provider)会把你的业务 tag 放进"data"子节点(put("data", ...))。 -
同时写入 DataVersion(
NbtUtils.addCurrentDataVersion(...))。 -
最终压缩写盘(
IOUtilities.writeNbtCompressed(...))。
结论:NBT 不是“随便写个 Map”就完事,它在存档系统里有版本语义。
3.2 手写 NBT 模板(带兼容字段)
public final class QuestProgressData {
private int version = 1;
private int kills;
private String title = "";
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("ver", version);
tag.putInt("kills", kills);
tag.putString("title", title);
return tag;
}
public void load(CompoundTag tag) {
int ver = tag.contains("ver", Tag.TAG_INT) ? tag.getInt("ver") : 0;
// 兼容旧档:旧版本没有 kills/title 时给默认值
this.kills = tag.contains("kills", Tag.TAG_INT) ? tag.getInt("kills") : 0;
this.title = tag.contains("title", Tag.TAG_STRING) ? tag.getString("title") : "";
// 预留迁移逻辑
if (ver < 1) {
// migrate...
}
this.version = 1;
}
}3.3 NBT 方法论
-
键名一旦发布就别乱改。
-
读取时永远做
contains + type防御。 -
复杂结构尽量转 Codec,减少手写分支。
-
NBT 更像“载体”,复杂系统应把“结构定义”交给 Codec。
4. Codec:把“数据结构”升级为“可复用协议”
4.1 为什么 Codec 是工程分水岭
当你只写 NBT,结构规则散落在多处;当你写 Codec,结构是集中定义的。
源码里大量使用 Codec 统一结构:
-
ItemStack.CODEC -
DataComponentPatch.CODEC -
DataMapType.codec()
4.2 典型声明式模型(record)
public record PlayerBadge(int level, String title, long updatedAt) {
public static final Codec<PlayerBadge> CODEC = RecordCodecBuilder.create(ins -> ins.group(
Codec.INT.fieldOf("level").forGetter(PlayerBadge::level),
Codec.STRING.optionalFieldOf("title", "").forGetter(PlayerBadge::title),
Codec.LONG.optionalFieldOf("updated_at", 0L).forGetter(PlayerBadge::updatedAt)
).apply(ins, PlayerBadge::new));
}4.3 Codec 与 NBT 的关系(重点)
-
Codec 定义结构。
-
NBT 只是 Codec 的一种操作上下文(
NbtOps)。
NeoForge 在附件里也是这样做的:AttachmentType.Builder.serialize(Codec) 最终使用 NbtOps 编解码。
4.4 错误处理策略
不要把 .result().get() 当常规写法。源码里也标了 TODO(AttachmentType.Builder.serialize(Codec, Predicate))。
推荐:
-
使用
getOrThrow+ 明确日志。 -
在关键路径记录数据上下文(对象 ID、字段摘要)。
5. Data Components:1.21 物品数据核心机制(重点补充)
这是你反馈里“遗漏风险最大”的模块之一,必须掌握。
5.1 先理解三件事
-
组件类型:
DataComponentType<T> -
组件集合:
DataComponentMap -
组件补丁:
DataComponentPatch
它不是“整包替换”,而是“原型 + patch”模型:
-
Item 原型组件在物品定义上。
-
具体 ItemStack 用 patch 覆盖差异(
PatchedDataComponentMap)。
5.2 持久化组件 vs 临时组件
DataComponentType.isTransient() 定义了是否可持久化(codec == null 即 transient)。
这对应构建器语义:
-
持久化:
persistent(Codec) -
网络同步:
networkSynchronized(StreamCodec)
5.3 ItemStack 如何编码组件
ItemStack.CODEC 里 components 字段使用 DataComponentPatch.CODEC。
网络路径同理:ItemStack.OPTIONAL_STREAM_CODEC 写入 count + item + patch(encode)。
5.4 Patch 的增量语义(非常关键)
DataComponentPatch 的 map 值是 Optional<?>:
-
Optional.of(v)= 设置/覆盖 -
Optional.empty()= 移除
此外删除键在文本编码上使用 ! 前缀(REMOVED_PREFIX 与 PatchKey.CODEC)。
5.5 PatchedDataComponentMap 的性能设计
PatchedDataComponentMap 采用 copy-on-write:
-
初始共享 patch 引用
-
写入前
ensureMapOwnership()
这类设计能降低复制成本,但你要知道:可变结构下的“看起来复制了”不一定马上深拷贝。
5.6 常用 API(ItemStack)
-
设置组件:
ItemStack.set(DataComponentType, T) -
移除组件:
ItemStack.remove(DataComponentType) -
应用 patch 并校验:
applyComponentsAndValidate(...)
5.7 DataComponent 与 NBT 的桥接
CustomData 提供了“组件中携带 NBT”能力:
-
CODEC(CustomData.CODEC) -
更新便捷函数(
CustomData.update(...))
结论:DataComponent 不排斥 NBT,而是把 NBT 纳入组件体系管理。
6. Data Attachment:给实体/方块实体/区块/世界外挂状态
6.1 本质
AttachmentType + IAttachmentHolder 组成通用宿主附加系统。
6.2 新手必须知道的语义差异
getData(...) 会“无则创建默认值并写入”; getExistingData(...) 才是“只读不创建”。
这会影响你的内存占用与脏数据追踪。
6.3 序列化关键点
-
全局保存键:
ATTACHMENTS_NBT_KEY -
写:
serializeAttachments(...) -
读:
deserializeAttachments(...)
每个附件以“附件注册 ID -> tag”方式写入(tag.put(ATTACHMENT_ID, serialized))。
6.4 默认复制策略
如果没自定义 copy handler,会走“序列化再反序列化”复制(defaultCopyHandler(...))。
这很稳,但对象大时可能有额外开销。
6.5 copyOnDeath 的真实含义
copyOnDeath() 只影响“玩家重生/生物转换等复制语义”,不是所有场景都自动继承。
6.6 典型注册模板
public final class ModAttachments {
public static final DeferredRegister<AttachmentType<?>> ATTACHMENTS =
DeferredRegister.create(NeoForgeRegistries.Keys.ATTACHMENT_TYPES, MODID);
public static final Supplier<AttachmentType<Integer>> KILL_COUNT = ATTACHMENTS.register("kill_count", () ->
AttachmentType.builder(() -> 0)
.serialize(Codec.INT)
.copyOnDeath()
.build()
);
}6.7 宿主脏标记纪律
AttachmentType 的类注释已经明确:
-
BlockEntity 变更后要
setChanged -
Chunk 变更后要
setUnsaved(true)
附件本身不会替你自动推断“内部字段变更”。
7. SavedData 与“已储存的数据”:世界级持久化主战场
你提到“已储存的数据”,在 NeoForge 语境里,核心就是 SavedData 体系及其变体。
7.1 SavedData 生命周期
-
实现
save(...) -
数据变更后调用
setDirty() -
存档时只有 dirty 才落盘(
if (this.isDirty()))
7.2 Factory 设计与 DataFixTypes
SavedData.Factory 支持可空 DataFixTypes(type)。
这意味着:
-
你可以选择挂 DFU 类型进行版本修复。
-
也可以不挂(null),但要自己保障兼容。
7.3 读取路径与版本升级
DimensionDataStorage.readTagFromDisk(...) 在有 dataFixType 时会执行 update(dataFixType.update(...))。
7.4 维度存储管理 API
-
创建或读取:
computeIfAbsent(...) -
仅读取:
get(...) -
手动设置缓存:
set(...)
7.5 一个高级细节:Dummy 占位
DimensionDataStorage.get(...) 用 DummySavedData.DUMMY 标记“已查过但不存在”,避免重复 IO。
这就是缓存层的小型优化策略。
7.6 PlayerDataStorage 也是“已储存数据”
玩家档并不走 SavedData,而是 PlayerDataStorage 管理 playerdata/<uuid>.dat。
关键点:
-
读取后做数据修复(
DataFixTypes.PLAYER.updateToCurrentVersion(...)) -
有损坏备份策略(
backup(...))
7.7 Level 附件如何落盘
NeoForge 用桥接实现:LevelAttachmentsSavedData
-
初始化:
init(ServerLevel) -
保存转调:
level.serializeAttachments(...) -
永远 dirty:
isDirty() -> true
8. DataMap:给注册表对象做数据驱动扩展
8.1 DataMapType 四元组
DataMapType 可看成四元组:
-
registryKey
-
id
-
codec
-
networkCodec(+mandatorySync)
同步配置见 synced(...)。
8.2 高级能力:冲突合并 + 定向移除
AdvancedDataMapType
-
合并器:
merger() -
移除器:
remover()
适合多个数据包共同改同一对象时的冲突治理。
8.3 DataMapLoader 的真实执行语义
DataMapLoader.buildDataMap(...) 里顺序非常关键:
-
replace可清空当前结果(if (entry.replace()) result.clear()) -
values逐条应用,冲突走 merger(merger.merge(...)) -
remove最后处理(entry.removals().forEach(...))
8.4 同步握手与断连条件
-
同步包:
RegistryDataMapSyncPayload -
客户端应用:
handleDataMapSync(...) -
mandatory 不匹配断开:
handleKnownDataMaps(...)
9. 网络同步与编解码:什么时候用 StreamCodec,什么时候用 Codec
9.1 简单原则
-
网络包优先
StreamCodec(二进制、稳定、快) -
配置/存档优先
Codec(结构化、易演进)
9.2 源码中的组合用法
-
DataComponentType同时有codec()与streamCodec() -
Builder 若只给
Codec,可回退生成网络 codec(ByteBufCodecs.fromCodecWithRegistries(...))
9.3 ByteBufCodecs 基础件
ByteBufCodecs 提供基础积木:
-
NBT:
TAG、COMPOUND_TAG -
常用数值:
VAR_INT
9.4 一个现实约束(DataMap)
目前 DataMap 同步仍在用 Codec -> Json 字符串 -> 网络 路线(TODO)。
这提醒我们:设计时要给协议留演进空间,别把“当前实现”当永久形态。
10. 版本迁移与兼容:旧档不炸档的工程化策略
10.1 三层兼容策略
-
字段级:
optionalFieldOf + 默认值 -
对象级:NBT 加
ver字段,读时按版本迁移 -
存档级:可接入
DataFixTypes走统一升级
10.2 SavedData 的迁移建议
如果你不使用 DFU,也至少要:
-
在 tag 中放版本号
-
在 load 里写“从老字段到新字段”的转换
-
保证缺字段时有安全默认值
10.3 DataComponent/Attachment 的迁移建议
-
DataComponent:优先通过 Codec optional 字段平滑演进 -
Attachment:如果是 Codec 序列化,等价策略可用;如果是手写 NBT,要自己做兼容分支
11. 完整架构实战:Attachment + SavedData + DataMap + Codec + NBT
场景:做“世界任务系统”
-
玩家个人任务进度:Attachment
-
世界全局任务序列:SavedData
-
不同物品任务权重:DataMap
-
结构统一:Codec
-
紧急临时字段:NBT/CustomData
11.1 分层设计
-
PlayerQuestAttachment(玩家态) -
GlobalQuestSavedData(世界态) -
ItemQuestWeightDataMap(规则态) -
QuestProgress.CODEC(协议态)
11.2 服务端权威流
-
玩家行为触发(服务端)
-
更新玩家附件
-
达成条件后更新 SavedData 并
setDirty -
客户端仅接收展示结果,不写权威状态
11.3 设计收益
-
生命周期边界清晰
-
存档边界可追踪
-
数据驱动可热更新(DataMap reload)
-
未来迁移成本可控
12. 高频故障与排错手册(实战向)
12.1 附件“读一下就多了”
原因:getData(...) 会创建默认值。
解决:只探测存在性时用 getExistingData(...)。
12.2 附件没保存
排查顺序:
-
该附件是否配置了序列化(
serialize(...)) -
serializer 是否返回 null(null = 不写入,见
write(...)) -
宿主是否正确触发脏标记(BE/Chunk)
12.3 SavedData 改了但重进丢失
99% 是忘了 setDirty()。
12.4 ItemStack 组件应用后报错
用 applyComponentsAndValidate(...) 观察回滚日志;它会在失败时恢复旧 patch(restorePatch(...))。
12.5 DataMap 客户端进不去
看 mandatory 协商路径 handleKnownDataMaps(...) 的缺失集合比较逻辑(missingOur/missingTheir)。
12.6 ItemStack 保存异常定位
NeoForge 已给出增强日志包装:DataComponentUtil.wrapEncodingExceptions(...)。
13. 给新手的 4 阶段成长路线(可执行)
阶段 A
-
做一个最小
SavedData计数器 -
验证:重启世界后值不丢
阶段 B
-
给玩家挂一个
AttachmentType<Integer> -
覆盖:登录/重生/切维度
阶段 C
-
把附件值改成
record + Codec -
做一次字段升级(新增 optional 字段)
阶段 D
-
注册一个 DataMap 给
ITEM添加权重 -
做多数据包冲突并配置 merger/remover
完成后你就具备中级数据架构能力。
14. 最终方法论总结(可复用模板)
开发前先填这张表:
-
数据归属:______
-
生命周期:______
-
权威端:______
-
同步策略:______
-
持久化位置:______
-
兼容策略:______
-
失败回滚方案:______
然后再决定:
- Attachment / DataComponent / SavedData / DataMap / NBT / Codec 的组合。
附录 A:Attachment vs SavedData vs DataMap vs DataComponent(深度对比)
| 维度 | Attachment | SavedData | DataMap | DataComponent |
|---|---|---|---|---|
| 主归属 | 宿主对象 | 世界/维度 | 注册表对象 | ItemStack/组件持有者 |
| 生命周期 | 跟宿主 | 跟存档 | 跟数据包与注册表 | 跟 ItemStack 实例 + 原型 |
| 持久化 | 可选(serializer) | 默认框架支持 | 数据包来源,可同步 | persistent codec 决定 |
| 网络 | 宿主路径决定 | 通常自定义包 | 内置协商/同步 | 内置 stream codec |
| 适配场景 | 运行态状态扩展 | 全局系统状态 | 规则配置驱动 | 物品结构化状态 |
| 迁移复杂度 | 中 | 中-高 | 中 | 中 |
选择口诀:
-
“对象态”优先 Attachment
-
“世界态”优先 SavedData
-
“规则态”优先 DataMap
-
“物品态”优先 DataComponent
-
“协议态”优先 Codec
附录 B:最小可复用代码模板集合
B1. SavedData 模板
public final class GlobalCounterData extends SavedData {
private int counter;
public static final SavedData.Factory<GlobalCounterData> FACTORY =
new SavedData.Factory<>(GlobalCounterData::new, GlobalCounterData::load);
public static GlobalCounterData load(CompoundTag tag, HolderLookup.Provider provider) {
GlobalCounterData d = new GlobalCounterData();
d.counter = tag.getInt("counter");
return d;
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider provider) {
tag.putInt("counter", counter);
return tag;
}
public int next() {
counter++;
setDirty();
return counter;
}
}B2. Attachment 模板
public record PlayerStats(int kills, int deaths) {
public static final Codec<PlayerStats> CODEC = RecordCodecBuilder.create(ins -> ins.group(
Codec.INT.optionalFieldOf("kills", 0).forGetter(PlayerStats::kills),
Codec.INT.optionalFieldOf("deaths", 0).forGetter(PlayerStats::deaths)
).apply(ins, PlayerStats::new));
}
// 注册时:AttachmentType.builder(() -> new PlayerStats(0,0)).serialize(PlayerStats.CODEC).copyOnDeath().build();B3. DataComponent 值对象模板
public record RuneData(int tier, String school) {
public static final Codec<RuneData> CODEC = RecordCodecBuilder.create(ins -> ins.group(
Codec.INT.fieldOf("tier").forGetter(RuneData::tier),
Codec.STRING.optionalFieldOf("school", "none").forGetter(RuneData::school)
).apply(ins, RuneData::new));
}
// 典型用法:stack.set(MY_RUNE_COMPONENT, new RuneData(2, "fire"));这份文档的核心不是“API 清单”,而是把你训练成: 能先画数据生命周期,再选技术组合,最后写代码的人。