上篇我们介绍了一下 level.dat 的数据组成,今天来了解一下MC的db数据吧!如果文章对你有帮助请帮我点一个赞吧!
文章发布时MC版本是【1.21.92.1】
首先我们要知道基岩版的db数据是 leveldb 库的,所以要读取它你得先准备好leveldb,可以自己去mojang的github上克隆与编译(https://github.com/Mojang/leveldb),由于我习惯使用java,所以文章用java版本的leveldb来做介绍吧,效果是一样的(https://github.com/HiveGamesOSS/leveldb-mcpe-java),可以先编写一个提取数据的程序:
/**
* 解析DB数据
*
* @throws IOException
*/
public static void undb() throws IOException {
DBFactory factory = new Iq80DBFactory();
//打开DB数据
try (DB db = factory.open(new File("file/db"), Options.newDefaultOptions());
//如果数据量较大,不建议保存到目录内,这里使用压缩包方式存储
ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("file/db.zip"))) {
//遍历DB并导出数据
for (Map.Entry<byte[], byte[]> e : db) {
//由于键名很多都是二进制的,所以我们使用16进制方式保存键名,同时用作压缩包项
String keyHex = bytes2Hex(e.getKey());
//此项对应的数据
byte[] data = e.getValue();
//创建并添加压缩包项
zos.putNextEntry(new ZipEntry(keyHex));
//写入数据
zos.write(data);
zos.closeEntry();
}
}
}
/**
* 字节转Hex
*
* @param bytes
* @return
*/
public static String bytes2Hex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02X", b));
}
return sb.toString();
} 程序运行完毕后,应该会在目录下生成 db.zip 文件,压缩包内就是你的存档数据了
在正式解析前,我们还需要先了解一些基本知识,能更好地帮助你解析数据:
一般区块数据键名前缀是(int是4字节,byte是1字节,而且是小端序的):
区块X坐标(int) + 区块Z坐标(int) + 维度类型(int 可能不存在) + 键类型(byte)
对于这部分可以参考Wiki【https://zh.minecraft.wiki/w/%E5%9F%BA%E5%B2%A9%E7%89%88%E5%AD%98%E6%A1%A3%E6%A0%BC%E5%BC%8F#%E5%8C%BA%E5%9D%97%E9%94%AE%E5%80%BC%E6%A0%BC%E5%BC%8F】
这里有一个值得注意的点是,区块坐标与我们游戏坐标是有区别的,区块坐标就是游戏坐标相应除以16

主世界坐标
比如我想定位当前游戏里的(206, 320)区块的坐标,那么它的区块坐标就是(206÷16, 320÷16)=(12, 20),那么这个区块的坐标就是x=12, z=20,值得注意的是,Y坐标是另外存储的,所以暂时不需要管他。
我们按区块格式转为16进制码,12 转为16进制是 0C,20 转换后是 14,得到【0C,14】,由于存储方式是小端int所以,应该为:【0C000000 14000000】,好的,现在我们就可以在压缩包内找一下当前相关的信息了(为了方便演示使用压缩包显示,实际使用时直接用leveldb的get方法获取对应的数据):

主世界(12,20)区块坐标包含的数据
上方图片我们选择的就是这个区块的数据了,我们继续往下看
这里代表的是所在维度,1是下界,2是末地,主世界不存在,举个例子,我在下界游戏坐标为(6, 24),将当前转为区块坐标后应为(0, 1)也就是【00000000 01000000】

下界坐标(6,24)
而下界的维度类型是1,要加上类型小端的1,它的16进制码为【01000000】,我们整理一下,那么当前位置的数据值应该为【00000000 01000000 01000000】,我们看一下在压缩包内对应的数据:

下界区块坐标(0,1)数据
框选部分就是我们当前下界区块的数据,红框内就是我们的维度类型,如果是末地的话红框部分的01就改成02,而主世界不需要添加维度类型部分,可以参考上面【主世界(12,20)区块坐标包含的数据】图片
接下来我们讲一下键的类型,类型只占用1字节,代表这个键名对应的是什么数据,这部分wiki上写的比较详细,主要的类型与介绍都告诉你了,我们可以根据这些数据整理一下方便使用:
/**
* 新版的三维生物群系和海拔高度
*/
public static final int Data3D = 0x2B;
/**
* 版本,一个字节
*/
public static final int Version = 0x2C;
/**
* 生物群系和海拔高度
*/
public static final int Data2D = 0x2D;
/**
* 旧版生物群系和海拔高度
*/
public static final int Data2DLegacy = 0x2E;
/**
* 16×16×16子区块的地形
*/
public static final int SubChunkPrefix = 0x2F;
/**
* 整个区块的地形
*/
public static final int LegacyTerrain = 0x30;
/**
* 方块实体数据(小端序NBT)
*/
public static final int BlockEntity = 0x31;
/**
* 旧版实体数据(小端序NBT)
*/
public static final int Entity = 0x32;
/**
* 计划刻数据(小端序NBT)
*/
public static final int PendingTicks = 0x33;
/**
* 旧版方块额外数据
*/
public static final int LegacyBlockExtraData = 0x34;
/**
* 生物群系状态
*/
public static final int BiomeState = 0x35;
/**
* 32位小端序整数
*/
public static final int FinalizedState = 0x36;
/**
* 转换器提供的数据,用于运行时中的融合等操作
*/
public static final int ConversionData = 0x37;
/**
* 教育版独有
*/
public static final int BorderBlocks = 0x38;
/**
* 以二进制格式存储的结构生成的边界框
*/
public static final int HardcodedSpawners = 0x39;
/**
* 随机刻数据(小端序NBT)
*/
public static final int RandomTicks = 0x3A;
/**
* 其他区块记录的xxHash校验码
*/
public static final int Checksums = 0x3B;
/**
* ?生成种子
*/
public static final int GenerationSeed = 0x3C;
/**
* 不再使用
*/
public static final int GeneratedPreCavesAndCliffsBlending = 0x3D;
/**
* 不再使用
*/
public static final int BlendingBiomeHeight = 0x3E;
/**
* ?元数据哈希
*/
public static final int MetaDataHash = 0x3F;
/**
* ?混合数据
*/
public static final int BlendingData = 0x40;
/**
* 1.18.30之后新版实体存储格式下的实体摘要版本
*/
public static final int ActorDigestVersion = 0x41;
/**
* 一个字节,1.16.100为44
*/
public static final int LegacyVersion = 0x76;
/**
* 试炼密室相关内容
*/
public static final int AabbVolumes = 0x77; 现在我们已经可以获取大部分的区块数据了,比如我想获取上面下界的3D生物群系数据,3D生物群系的类型是 2B ,与区块坐标、维度类型整合一下,得到的键名就是【00000000 01000000 01000000 2B】,然后就能定位到我们的数据了

根据上面的信息,我们就可以编写一个程序方便生成对应的键名了:
/**
* 生成区块键名
*
* @param chunkX 区块X坐标
* @param chunkZ 区块Z坐标
* @param dimension 维度, 0为主世界, 1为地狱, 2为末地
* @param type 键类型
* @return
*/
public static byte[] generateChunkKey(
int chunkX,
int chunkZ,
int dimension,
int type
) throws IOException {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos)) {
//写入X区块坐标
dos.writeInt(Integer.reverseBytes(chunkX));//以小端方式写入
//写入X区块坐标
dos.writeInt(Integer.reverseBytes(chunkZ));//以小端方式写入
if (dimension != 0) {//主世界无需写入维度类型
dos.writeInt(Integer.reverseBytes(dimension));
}
//类型只有一个字节
dos.writeByte(type);
return bos.toByteArray();
}
}
/**
* 测试从leveldb中获取数据
*/
public static void getData() throws IOException {
DBFactory factory = new Iq80DBFactory();
//打开DB数据
try (DB db = factory.open(new File("file/db"), Options.newDefaultOptions())) {
//地狱区块坐标(0,1)的3D生物群系数据
byte[] data = db.get(generateChunkKey(0, 1, 1, 0x2B));
if (data != null) {
System.out.println("数据大小:" + data.length);
} else {
System.out.println("没有找到这个数据");
}
}
}
通过上面我们了解了如何定位区块数据,下面我们来介绍一下游戏是如何保存我们的方块的
我们通过类型了解到,区块方块数据的类型是 2F,那么可以找到下面的数据:

发现数据不少,而且在2F后面还有一个字节,其实那个多出来的字节对应的就是我们的y坐标,准确来说应该叫分区Y坐标,我们要知道,MC的区块其实并不是一个整体,而是由多个分区块堆叠而来,每个分区块方块数量是16*16*16=4096,00 对应的就是 0 .. 15 层的方块,FF (转为10进制是-1)对应的是 -16 .. 0 层的方块,由于这是主世界的区块,所以是从 -64 到 320 格,虽然最高格是320,但由于04分区之上都是空气,游戏会选择忽略这部分纯空气分区,减小存档占用空间,所以你看见的数据不一定会从FF到20,而地狱、末地没有负数格,所以分区会从00开始
计算分区坐标与上面的区块坐标是一样的,只需要对游戏y坐标÷16即可,我们可以编写一个程序
/**
* 生成分区块键名
*
* @param chunkX 区块X坐标
* @param chunkZ 区块Z坐标
* @param dimension 维度, 0为主世界, 1为地狱, 2为末地
* @param chunkY 分区位置
* @return
*/
public static byte[] generateSubChunkKey(
int chunkX,
int chunkZ,
int dimension,
int chunkY
) throws IOException {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
//先写入基本类型
bos.write(generateChunkKey(chunkX, chunkZ, dimension, 0x2F));
//再写入分区位置
bos.write(chunkY);
return bos.toByteArray();
}
} 当我们成功通过键名获取到方块数据后,就可以尝试对其解析了

区块数据文件头
我们以主世界分区块【0A000000 0E000000 2F 00】做例子:
文件头
1byte,红色框部分,这里是这个分区数据的版本号,决定其解析的方式,当前为9
1byte,黄色框部分,这里是当前分区数据有多少层,我们都知道MC在海洋更新后新增了含水方块,如果这里为2则很可能这个区块内有含水方块,当前为1说明只有1层
1byte,橙色框部分,这里是当前分区的分区Y坐标,与我们键名最后一个字节对应,00就是0 .. 15格的方块
值得注意的是:目前分区版本可能有三种值:1、8与当前的9
如果这个值是1,那么应该是海洋更新前的区块,文件头不需要读取黄色框与橙色框部分的数据
如果这个值是8,不需要读取橙色框的数据,橙色框的数据应该是高度更新后新添加的
方块数据
黄色框有多少层就读多少次,这部分会稍微复杂一些,使用的是索引调色板方式存储,由于一个分区是16*16*16=4096,数据量还是比较多的,所以游戏采用了一种压缩的算法,减小数据占用
1byte,压缩方块版本,根据这个byte推断与读取索引数据大小,如果这里是0则不需要读取压缩方块索引
压缩后的4096方块索引数据,每个索引占用1个int,也就是4字节
1int,后面的方块NBT个数,如果压缩方块版本是0,那么也不需要读取此项,默认是1
方块NBT数据数组,数量就是上面读取到的方块NBT个数

方块索引数据
比如这里读取到的压缩方块版本是0A,也就是10,经过下面的代码计算后,得到索引数据要读取 683 个int,也就是 2732(0xAAC) 个字节
//压缩版本
byte storageVersion = dis.readByte();
int bitsPerBlock = storageVersion >> 1;
int blocksPerWord = Integer.SIZE / bitsPerBlock;
int numints = (4095 / blocksPerWord) + 1;//int个数
System.out.printf("要读取:%d个int%n", numints);
System.out.printf("占用大小:%d(0x%X)%n", numints * Integer.BYTES, numints * Integer.BYTES); 下面根据索引数来填充4096分区方块索引数组
//先读取每条索引
int[] maskValues = new int[numints];
for (int j = 0; j < numints; j++) {
maskValues[j] = Integer.reverseBytes(dis.readInt());//小端int
}
//创建此分区4096个方块索引
int[] indexes = new int[16 * 16 * 16];
int blockMask = (1 << bitsPerBlock) - 1;//掩码
//根据每条索引填充数据
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
for (int y = 0; y < 16; y++) {
//4096索引坐标
int index = (x << 8) | (z << 4) | y;
//计算在maskValues数组中的位置
int wordIndex = index / blocksPerWord;
int shift = (index % blocksPerWord) * bitsPerBlock;
//设置索引
indexes[wordIndex] = (maskValues[wordIndex] >> shift) & blockMask;
System.out.printf("(%d, %d, %d):%d%n", x, y, z, indexes[wordIndex]);
}
}
} 现在我们就成功解析了这个分区块的方块索引了,现在我们看看方块NBT:

分区方块NBT
红色框就是有多少个方块NBT,如果压缩版本是0,就不用读NBT数量,默认是1,这里的1D代表的是29,也就是当前分区有29个方块nbt,然后根据数量来读取29个方块NBT到数组内,这里为了方便,我们就用NaroX截图来预览吧:

分区00的方块NBT
好了,分区方块数据解析已经基本完成,然后按照索引与nbt一格格对应就OK了,我们看一下最终解析代码:
/**
* 解析分区块数据
*
* @param data 分区块数据
* @return
* @throws IOException
*/
public static void parseSubChunk(byte[] data) throws IOException {
try (ByteArrayInputStream bis = new ByteArrayInputStream(data);
DataInputStream dis = new DataInputStream(bis)) {
byte version = dis.readByte();
//节区个数
int subCount = 1;
switch (version) {
case 0x01 -> {
//海洋更新前
}
case 0x08 -> {
//海洋更新
subCount = dis.readByte();
System.out.println("分区个数:" + subCount);
}
case 0x09 -> {
//高度更新
subCount = dis.readByte();
int chunkY = dis.readByte();//分区Y坐标无需使用这里
System.out.println("分区个数:" + subCount);
System.out.println("分区位置:" + chunkY);
}
default -> throw new IllegalArgumentException("不支持的版本:" + version);
}
//根据节区个数解析数据
for (int i = 0; i < subCount; i++) {
//压缩版本
byte storageVersion = dis.readByte();
//创建此分区4096个方块索引
int[] indexes = new int[16 * 16 * 16];
int nbtCount = 1;//NBT个数默认为1
//存储版本是0就不需要读取方块数据
if (storageVersion != 0) {
int bitsPerBlock = storageVersion >> 1;
int blocksPerWord = Integer.SIZE / bitsPerBlock;
int numints = (4095 / blocksPerWord) + 1;//int个数
System.out.printf("要读取:%d个int%n", numints);
System.out.printf("占用大小:%d(0x%X)%n", numints * Integer.BYTES, numints * Integer.BYTES);
//先读取每条索引
int[] maskValues = new int[numints];
for (int j = 0; j < numints; j++) {
maskValues[j] = Integer.reverseBytes(dis.readInt());//小端int
}
//合并方块索引
int blockMask = (1 << bitsPerBlock) - 1;//掩码
//根据每条索引填充数据
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
for (int y = 0; y < 16; y++) {
//4096索引坐标
int index = (x << 8) | (z << 4) | y;
//计算在maskValues数组中的位置
int wordIndex = index / blocksPerWord;
int shift = (index % blocksPerWord) * bitsPerBlock;
//设置索引
indexes[index] = (maskValues[wordIndex] >> shift) & blockMask;
System.out.printf("(%d, %d, %d):%d%n", x, y, z, indexes[wordIndex]);
}
}
}
//如果storageVersion不为0则还要读取nbt个数
nbtCount = Integer.reverseBytes(dis.readInt());//小端int
}
//最后根据nbt个数读取nbt
NBT_Tag[] tags = new NBT_Tag[nbtCount];
for (int j = 0; j < nbtCount; j++) {
NBT_Tag tag = new NBT_Tag();
tag.read(dis);
tags[j] = tag;
System.out.printf("第 %d 个NBT信息:%s%n", j, tag);
}
//测试对应的索引与方块
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
for (int y = 0; y < 16; y++) {
int index = (x << 8) | (z << 4) | y;
System.out.printf("(%d, %d, %d)的方块是%s%n", x, y, z, tags[index]);
}
}
}
}
}
} 如果有第二层分区,就继续按照上面的压缩方块版本读即可,一般第二层是含水层,当然,你也能改成其他任意方块,参考movingBlock的效果,第三层游戏就不识别了,所以最多就2层,可能随着MC更新会增加吧~
如果还有疑惑也可以参考大佬开源的代码【https://github.com/reedacartwright/rbedrock/blob/main/src%2Fsubchunk.c】167行
读完后我们就可以尝试根据方块颜色生成这部分的渲染图了:

NaroX渲染图
写回方块数据
现在我们知道怎么读取方块数据了,写回就是反过来的,前面的文件头都还是比较简单,但是如何确定压缩版本呢?我们先准备好nbt数组与其对应的4096方块索引数组,然后获取nbt数组的个数,然后用下面的方法计算bitsPerBlock:
/**
* 计算需要的位数
*
* @param size 数据个数(NBT个数)
*/
public static int calcBitsPerLen(int size) {
int[] p = {1, 2, 3, 4, 5, 6, 8, 16};
int[] z = {2, 4, 8, 16, 32, 64, 256, 65536};
int i;
for (i = 0; i < 7 && z[i] < size; i++) {
// 找到最小的能容纳size的z[i]
}
return p[i];
} 然后根据bitsPerBlock计算存储版本即可
int bitsPerBlock = calcBitsPerLen(paletteSize);
byte storageVersion = (byte) ((bitsPerBlock << 1 | 0)); 将4096数组转换为存储压缩的方块索引数据:
/**
* 构建压缩索引
*
* @param values 4096索引值
* @param storageVersion 压缩版本
* @return
*/
public static int[] buildMask(int[] values, byte storageVersion) {
if (values == null) throw new IllegalArgumentException("values is null");
if (values.length != 4096) {
throw new IllegalArgumentException("values length is not 4096, but " + values.length);
}
int bitsPerBlock = storageVersion >> 1;
int blocksPerWord = Integer.SIZE / bitsPerBlock;
int numints = (4096 + blocksPerWord - 1) / blocksPerWord;
int blockMask = (1 << bitsPerBlock) - 1;//掩码
int[] maskValues = new int[numints];
for (int blockIndex = 0; blockIndex < 4096; blockIndex++) {
int wordIndex = blockIndex / blocksPerWord;
int bitOffset = (blockIndex % blocksPerWord) * bitsPerBlock;
int blockId = values[blockIndex] & blockMask;
maskValues[wordIndex] |= (blockId << bitOffset);
}
return maskValues;
} 最后按格式写入即可!这边就略过咯~
好了,本篇文章就到这里结束了,下期带大家了解如何解析群系数据