基岩版存档数据研究:DB数据_解析与定位区块数据
泥土NITU
编辑于 2025年06月25日 21:11
收录于文集
共4篇

上篇我们介绍了一下 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),可以先编写一个提取数据的程序:

代码块
clike
自动换行
复制代码
/**
 * 解析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上写的比较详细,主要的类型与介绍都告诉你了,我们可以根据这些数据整理一下方便使用:

代码块
clike
自动换行
复制代码
/**
 * 新版的三维生物群系和海拔高度
 */
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】,然后就能定位到我们的数据了

根据上面的信息,我们就可以编写一个程序方便生成对应的键名了:

代码块
clike
自动换行
复制代码
/**
 * 生成区块键名
 *
 * @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即可,我们可以编写一个程序

代码块
clike
自动换行
复制代码
/**
 * 生成分区块键名
 *
 * @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) 个字节

代码块
clike
自动换行
复制代码
//压缩版本
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分区方块索引数组

代码块
clike
自动换行
复制代码
//先读取每条索引
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了,我们看一下最终解析代码:

代码块
clike
自动换行
复制代码
/**
 * 解析分区块数据
 *
 * @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:

代码块
clike
自动换行
复制代码
/**
 * 计算需要的位数
 *
 * @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计算存储版本即可

代码块
clike
自动换行
复制代码
int bitsPerBlock = calcBitsPerLen(paletteSize);
byte storageVersion = (byte) ((bitsPerBlock << 1 | 0));
复制成功

将4096数组转换为存储压缩的方块索引数据:

代码块
clike
自动换行
复制代码
/**
 * 构建压缩索引
 *
 * @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;
}
复制成功

最后按格式写入即可!这边就略过咯~


好了,本篇文章就到这里结束了,下期带大家了解如何解析群系数据