🗂️ 数据文件格式
Sparrow Sync 将各类玩家数据分别编码成 NBT,再按类型存入独立的数据块。读取某一类数据时,可以通过索引直接找到它的块。
对接二进制数据时,会遇到两种布局。
| 数据来源 | 二进制布局 | 内容 |
|---|---|---|
导出的 .snapshot 文件、Redis 快照缓存 | 完整二进制快照 | 快照头 + Meta NBT + 独立数据帧 |
数据库的 data 字段 | 独立数据帧 | 数据帧头 + 索引 NBT + 数据块 |
数据库 data 保存整个独立数据帧,从数据帧的 format 字节开始。 它与完整快照内部的 dataFrame 使用相同布局,可以按下文的数据帧结构直接读取。
编码约定
偏移从 0 开始,单位均为字节;布局图中的偏移相对当前快照、数据帧或数据块的起点。多字节整数使用大端序,u8、u16、u32 表示无符号整数,i32 表示有符号整数。
NBT 使用无名根 Tag 编码,即项目中的 NBT.toBytes(tag, false)。Meta 和索引均为未压缩的 compound;类型数据的压缩方式记录在各自的块头中。CRC32 占 4 字节,保留完整的 32 位值。
完整二进制快照
完整快照依次写入固定的 7 字节头部、M 字节 Meta NBT,以及一个完整的独立数据帧。Meta 段始终存在,数据帧从 7 + M 开始。
formatu8格式标记,当前写入 1。
7 + M + dataFrame.lengthMeta NBT
Meta 是一份独立的 compound,字段如下。字段在 compound 内的排列顺序不固定,读取时按键取值。
| 键 | NBT 类型 | 内容 |
|---|---|---|
id | IntArrayTag,4 个整数 | 快照 UUID |
player | IntArrayTag,4 个整数 | 玩家 UUID |
ts | LongTag | 保存请求接受时刻的毫秒时间戳 |
cause | StringTag | 保存原因的枚举名,例如 COMMAND |
pinned | ByteTag | 是否豁免自动轮转,0b 为否,1b 为是 |
server | StringTag | 来源服务器标识 |
mcData | IntTag | Minecraft 数据版本 |
Meta 的长度和 CRC32 记录在前面的快照头中。Meta 结束后,直接接上数据帧的第一个字节。
独立数据帧
数据帧保存全部类型数据。前面的索引给出各块位置,后面的块区连续存放各类型的内容。设索引 NBT 长度为 I,块区起点就是数据帧起点加 9 + I。
formatu8数据帧的格式标记,当前写入 1。数据库 data 字段从这个字节开始。
9 + I + Σ(13 + P)索引如何定位数据块
索引是一个 compound,每个键都是完整的 DataKey,值为 IntTag。下面用 SNBT 展示一份索引的内容。
{"demo:first": 0, "demo:second": 33}
这里的 0 和 33 都相对块区起点。第一块从块区起点开始,第二块从其后第 33 字节开始,所以第一块总长 33 字节,其中块头占 13 字节、payload 占 20 字节。
blockBase = dataFrameStart + 9 + I
blockStart = blockBase + index[dataKey]
payloadStart = blockStart + 13
从完整快照读取时,dataFrameStart = 7 + M;从数据库 data 字节数组读取时,dataFrameStart = 0。
将索引项按偏移排序后,相邻偏移给出前一块的结束位置,最后一块延伸到数据帧末尾。compound 中的键排列顺序不决定块的物理顺序。索引只存偏移,长度和压缩算法在块头中。
DataKey 使用 namespace:value 文本,内置类型的命名空间为 sparrow_sync。帧内没有类型数据时,仍保留空 compound 索引,块区为空。
数据块与 payload
一个块对应一个 DataKey。每块都有自己的 13 字节头部和 payload,同一个数据帧中的块可以使用不同压缩算法。
compressorIdu8当前块的压缩算法编号。内置 0 = NONE、1 = DEFLATE、2 = ZSTD。
13 + PcompressorId | 算法 | payload 的内容 |
|---|---|---|
0 | NONE | 未压缩的 NBT 字节 |
1 | DEFLATE | 带 zlib 包装的 DEFLATE 压缩字节,对应 Java DeflaterOutputStream |
2 | ZSTD | Zstandard 压缩字节 |
读取时按当前块的 compressorId 处理 payload,得到 rawLength 字节的 NBT。CRC32 覆盖存储的 payload;对于压缩块,应在解压前对这些字节计算 CRC。
类型值在 NBT 中的位置
假设插件为 demo:first 提供的 Tag 是 {count: 3},块编码器会加上完整 DataKey。解压后的内容如下,示例以 SNBT 表示。
{
"demo:first": {
count: 3
}
}
根 compound 只包含当前类型这一个键。rawLength 计算整份 compound 的二进制长度,包括外层 demo:first;插件的 PlayerDataType.encode 提供的值、decode 收到的值都是内层 {count: 3}。
因此,提取类型内容的过程是先从索引定位块,读取块头并还原 payload,再从根 compound 中按完整 DataKey 取出类型 Tag。类型 Tag 的内部结构由对应插件定义。