package com.mapsyncer.mca; import com.mapsyncer.nbt.NbtReader; import com.mapsyncer.nbt.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.*; import java.util.zip.GZIPInputStream; import java.util.zip.InflaterInputStream; import net.jpountz.lz4.LZ4BlockInputStream; /** * MCA文件读取器 - 零依赖实现 * 解析Minecraft区域文件格式(.mca) * *

MCA文件结构:

* * *

支持的压缩类型:

* * * @see ChunkDataParser 用于解析chunk的NBT数据 * @see McaReader.ChunkLocation chunk位置信息记录 * @see McaReader.ChunkData chunk数据记录 */ public class McaReader implements AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(McaReader.class); /** * 扇区大小常量(4KB) */ private static final int SECTOR_SIZE = 4096; /** * 每个区域的chunk数量(32x32) */ private static final int CHUNKS_PER_REGION = 32; // 压缩类型常量 /** * GZIP压缩类型标识 */ private static final int COMPRESS_GZIP = 1; /** * ZLIB压缩类型标识 */ private static final int COMPRESS_ZLIB = 2; /** * 无压缩类型标识 */ private static final int COMPRESS_NONE = 3; /** * LZ4 压缩类型标识(由 {@link #decompress} 通过 lz4-java 解压) */ private static final int COMPRESS_LZ4 = 4; /** * Chunk位置信息记录 * *

包含chunk在MCA文件中的位置和元数据:

* */ public record ChunkLocation(int offsetSectors, int sectorCount, int timestamp) { /** * 判断chunk是否存在 * * @return 如果offsetSectors和sectorCount都大于0则返回true,否则返回false */ public boolean exists() { return offsetSectors > 0 && sectorCount > 0; } /** * 计算chunk数据的字节偏移量 * * @return 数据在文件中的绝对字节偏移量(offsetSectors * SECTOR_SIZE) */ public long dataOffset() { return (long) offsetSectors * SECTOR_SIZE; } } /** * Chunk数据记录 * * @param chunkX chunk在region内的局部X坐标 (0-31) * @param chunkZ chunk在region内的局部Z坐标 (0-31) * @param nbt chunk的NBT数据(Tag.Compound格式) */ public record ChunkData(int chunkX, int chunkZ, Tag.Compound nbt) {} /** * 随机访问文件对象,用于读取MCA文件 */ private final RandomAccessFile raf; private McaReader(RandomAccessFile raf) { this.raf = raf; } /** * 打开MCA文件并初始化读取器。 * * @param path MCA文件的完整路径 * @return McaReader 实例 * @throws IOException 如果文件不存在、文件太小或无法读取 */ public static McaReader open(String path) throws IOException { RandomAccessFile raf = new RandomAccessFile(path, "r"); try { if (raf.length() < SECTOR_SIZE * 2) { throw new IOException("MCA文件太小: " + raf.length() + " bytes"); } return new McaReader(raf); } catch (IOException e) { raf.close(); throw e; } } /** * 获取chunk在MCA文件中的位置信息 * *

从位置表和时间戳表读取chunk的元数据

* * @param localX chunk在region内的局部X坐标 (0-31) * @param localZ chunk在region内的局部Z坐标 (0-31) * @return ChunkLocation对象,包含偏移量、扇区数和时间戳 * @throws IOException 如果读取文件失败 */ public ChunkLocation getChunkLocation(int localX, int localZ) throws IOException { int index = (localX + localZ * CHUNKS_PER_REGION) * 4; raf.seek(index); int b0 = raf.readUnsignedByte(); int b1 = raf.readUnsignedByte(); int b2 = raf.readUnsignedByte(); int offsetSectors = (b0 << 16) | (b1 << 8) | b2; int sectorCount = raf.readUnsignedByte(); // 读取时间戳 raf.seek(SECTOR_SIZE + index); int timestamp = raf.readInt(); return new ChunkLocation(offsetSectors, sectorCount, timestamp); } /** * 读取单个chunk的NBT数据 * *

处理流程:

*
    *
  1. 获取chunk位置信息
  2. *
  3. 读取压缩的数据长度和压缩类型
  4. *
  5. 解压缩数据
  6. *
  7. 解析NBT格式
  8. *
* * @param localX chunk在region内的局部X坐标 (0-31) * @param localZ chunk在region内的局部Z坐标 (0-31) * @return chunk的NBT数据(Tag.Compound格式),如果chunk不存在或读取失败则返回null * @throws IOException 如果读取或解压缩失败 */ public Tag.Compound readChunkNbt(int localX, int localZ) throws IOException { ChunkLocation loc = getChunkLocation(localX, localZ); if (!loc.exists()) { return null; } long dataOffset = loc.dataOffset(); if (dataOffset + 5 > raf.length()) { return null; } raf.seek(dataOffset); // 读取chunk数据长度(包含压缩类型字节) int totalLength = raf.readInt(); if (totalLength <= 1) { return null; } // 读取压缩类型 int compressionType = raf.readUnsignedByte(); // 读取压缩数据 int dataLength = totalLength - 1; byte[] compressedData = new byte[dataLength]; int read = 0; while (read < dataLength) { int r = raf.read(compressedData, read, dataLength - read); if (r == -1) break; read += r; } if (read != dataLength) { return null; } // 解压缩 byte[] nbtData = decompress(compressedData, compressionType); if (nbtData == null) { return null; } // 解析NBT try (NbtReader reader = new NbtReader(new ByteArrayInputStream(nbtData))) { return reader.readDocument(); } } /** * 逐 chunk 读取并回调,不预先加载全区 NBT 到内存。 * * @param consumer 每个成功读取的 chunk 回调 * @throws IOException 如果打开或读取文件失败 */ public void forEachChunk(java.util.function.Consumer consumer) throws IOException { for (int localX = 0; localX < CHUNKS_PER_REGION; localX++) { for (int localZ = 0; localZ < CHUNKS_PER_REGION; localZ++) { try { Tag.Compound nbt = readChunkNbt(localX, localZ); if (nbt != null) { consumer.accept(new ChunkData(localX, localZ, nbt)); } } catch (IOException e) { LOGGER.warn("读取chunk ({}, {}) 失败: {}", localX, localZ, e.getMessage()); } } } } /** * 读取区域文件中所有存在的chunk * *

遍历32x32的所有chunk位置,读取每个存在的chunk数据

*

单个chunk读取失败不会中断整体读取过程,会记录警告日志

* * @return 包含所有成功读取的ChunkData对象的列表 * @throws IOException 如果打开或读取文件失败 * @deprecated 大 region 会占用大量内存;请使用 {@link #forEachChunk} 或 {@link McaRegionLoader} 的流式路径 */ @Deprecated public Iterable readAllChunks() throws IOException { java.util.List chunks = new java.util.ArrayList<>(); forEachChunk(chunks::add); return chunks; } /** * 解压缩chunk数据 * *

根据压缩类型选择相应的解压缩方法:

*
    *
  • GZIP (1): 使用GZIPInputStream解压
  • *
  • ZLIB (2): 使用InflaterInputStream解压
  • *
  • 无压缩 (3): 直接返回原始数据
  • *
  • LZ4 (4): LZ4 块压缩 (Minecraft 1.21.2+ 默认格式)
  • *
* * @param data 压缩的数据字节数组 * @param compressionType 压缩类型标识 (1-4) * @return 解压缩后的NBT数据字节数组 * @throws IOException 如果解压缩失败或压缩类型不支持 */ private byte[] decompress(byte[] data, int compressionType) throws IOException { ByteArrayInputStream bais = new ByteArrayInputStream(data); ByteArrayOutputStream baos = new ByteArrayOutputStream(); switch (compressionType) { case COMPRESS_GZIP: try (GZIPInputStream gis = new GZIPInputStream(bais)) { byte[] buf = new byte[8192]; int len; while ((len = gis.read(buf)) > 0) { baos.write(buf, 0, len); } } return baos.toByteArray(); case COMPRESS_ZLIB: try (InflaterInputStream iis = new InflaterInputStream(bais)) { byte[] buf = new byte[8192]; int len; while ((len = iis.read(buf)) > 0) { baos.write(buf, 0, len); } } return baos.toByteArray(); case COMPRESS_NONE: return data; case COMPRESS_LZ4: try (LZ4BlockInputStream lis = new LZ4BlockInputStream(bais)) { byte[] buf = new byte[8192]; int len; while ((len = lis.read(buf)) > 0) { baos.write(buf, 0, len); } } return baos.toByteArray(); default: throw new IOException("未知压缩类型: " + compressionType); } } /** * 关闭MCA文件读取器 * *

释放文件资源,实现AutoCloseable接口以支持try-with-resources语法

* * @throws IOException 如果关闭文件失败 */ @Override public void close() throws IOException { raf.close(); } }