区域文件格式(region-file-format)
基本信息
| 属性 | 值 |
|---|---|
| 实现类 | cubicchunks.regionlib.lib.Region<K extends IKey<K>> |
| 接口 | cubicchunks.regionlib.api.region.IRegion<K> |
| 构造方式 | Region.<L>builder() → Builder(Region.java:213) |
| 默认扇区大小 | 512 字节(Builder.sectorSize 字段初始值) |
| 文件路径 | directory.resolve(regionKey.getName()),以 FileChannel.open(..., CREATE, READ, WRITE) 打开 |
| 线程安全 | writeValue / readValue / hasValue 是 synchronized 方法 |
这是 Minecraft Anvil 区域文件那一套扇区(sector)打包格式的通用实现:一个文件 = 一个头表 + 一堆按扇区对齐的记录。
Builder 参数
| 方法 | 默认值 | 说明 |
|---|---|---|
setDirectory(Path) |
无 | 区域文件所在目录 |
setRegionKey(RegionKey) |
无 | 同时决定文件名(RegionKey.getName()) |
setKeyProvider(IKeyProvider<K>) |
无 | 决定单区域的 key 数量与 id ↔ key 的换算 |
setSectorSize(int) |
512 |
扇区字节数 |
addHeaderEntry(IHeaderDataEntryProvider<?, K>) |
空列表 | 追加一个自定义头表字段 |
addSpecialSectorMapEntry(marker, value, specialReader, writeConflictHandler) |
空列表 | 注册一个"特殊"头表整数值(见下) |
Builder的 javadoc 明确写着"Using it is very unsafe, there are no safeguards against using it improperly. Should only be used byIRegionProviderimplementations."
build() 做了什么
entryMapBytes = Integer.BYTES + Σ 每个 header provider 的 getEntryByteCount()entryMapSectors = ceilDiv(keyProvider.getKeyCount(regionKey) * entryMapBytes, sectorSize)—— 头表本身占用的前若干扇区会被标记为已用IntPackedSectorMap.readOrCreate(file, keyCount, specialEntries)—— 文件比头表还短就填零新建RegionSectorTracker.fromFile(file, sectorMap, entryMapSectors, sectorSize)headerEntryProviders.add(0, sectorMap.headerEntryProvider())—— 扇区表自己被插到头表字段列表的第 0 位,所以扇区表永远是文件最前面的一块
头表布局(IntPackedSectorMap)
| 常量 | 值 | 来源 |
|---|---|---|
SIZE_BITS |
8 |
IntPackedSectorMap.java:41 |
OFFSET_BITS |
Integer.SIZE - SIZE_BITS = 24 |
IntPackedSectorMap.java:42 |
SIZE_MASK / MAX_SIZE |
255 |
单条目最大扇区数 |
OFFSET_MASK / MAX_OFFSET |
16777215 |
单条目最大扇区偏移 |
| 每 key 头表字节 | Integer.BYTES = 4 |
Region.Builder.build() 里的 entryMapBytes 初值 |
打包 / 解包:
packed = size | (offset << 8) // 低 8 位存扇区数,高 24 位存起始扇区
unpackOffset(x) = x >>> 8
unpackSize(x) = x & 0xFF
packed == 0 表示该 id 没有数据(getEntryLocation 直接返回 Optional.empty()),这也是 255 扇区上限之外的另一处隐含限制:size 0 + offset 0 无法被区分。
同一约束在两条路径上抛不同的异常:
| 路径 | 异常 |
|---|---|
setOffsetAndSize(...) |
UnsupportedDataException("Max supported size 255 but requested N") / ("Max supported offset 16777215 but requested N") |
packed(RegionEntryLocation) |
IllegalArgumentException("Supported entry size range is 0 to 255, but got N") / ("Supported entry offset range is 0 to 16777215, but got N") |
记录(payload)布局
每条记录 = 4 字节长度前缀 + 数据:
扇区偏移 S: int dataLength = payload.size() // 不含自身这 4 字节
S+1 字节起: payload
- 写入:
numSectors = ceilDiv(value.remaining() + Integer.BYTES, sectorSize),先writeFully(file.position(offset*sectorSize), 4 字节长度),再writeFully(file, value) - 读取:先读 4 字节
dataLength,若dataLength > sectorCount * sectorSize抛CorruptedDataException(字符串为"Expected data size max" + sectorCount * sectorSize + " but found " + dataLength,原文缺空格),再按dataLength分配并读满
单条目字节上限(由 255 扇区推出):
| 扇区大小 | numSectors 上限 255 → payload 上限 |
|---|---|
512(SaveSection2D / SaveSection3D 默认) |
255 × 512 − 4 = 130556 字节 |
4096(MinecraftSaveSection) |
255 × 4096 − 4 = 1044476 字节 |
超过上限的 key 会被 IntPackedSectorMap 拒绝写,SaveSection 再回退到 .ext 格式。
扇区分配(RegionSectorTracker)
用一个 BitSet usedSectors 记录占用,头表的前 entryMapSectors 个扇区初始化时就标记为已用。分配算法 findSectorFor(oldSector, requestedSize):
newSectorSize <= oldSectorSize→ 原地复用旧位置(oldSector.withSize(...)),不触发搬迁- 否则检查从
oldSectorOffset + oldSectorSize起的连续段是否都空闲 → 能放下就原地扩 - 都不行则
findNextFree(requestedSize):用usedSectors.nextClearBit/nextSetBit扫描出第一段长度 ≥ 请求值的空闲连续区间
updateUsedSectorsFor 先把旧区间的位清 0,再把新区间的位置 1(重叠也能正确处理)。删除 key 走 removeKey:把头表项写成 (0, 0) 并释放旧扇区。
自定义头表字段
updateHeaders(key) 按 headerEntryProviders 顺序依次追加写入,写第 n 个 provider 时偏移为 n * keyCount + key.getId() * entryByteCount:
Utils.writeFully(file.position(currentHeaderBytes * keyCount + id * prov.getEntryByteCount()), buf);
即字段是列存布局:先写该字段的全部 keyCount 项,再写下一个字段。RegionLib 自带两个 provider:
| Provider | getEntryByteCount() |
内容 |
|---|---|---|
EntryLocationHeaderEntryProvider<K> |
Integer.BYTES |
扇区表本身(Region.Builder 自动加在第 0 位);无数据时写 0 |
TimestampHeaderEntryProvider<L> |
Integer.BYTES |
(int) TimeUnit.MILLISECONDS.convert(System.currentTimeMillis(), timeUnit),只有 Minecraft 原版存档区 用到(TimeUnit.SECONDS) |
特殊值(special sector map entry)
Region.Builder.addSpecialSectorMapEntry(marker, rawValue, specialReader, writeConflictHandler) 可以把某个特定的头表整数值保留下来当"标记",读取时 IntPackedSectorMap.trySpecialValue(key) 命中就改用自定义的 reader 解析,writeSpecial(key, marker) 写入。RegionLib 自身一处都没用——RegionSectorTracker.fromFile 会跳过 sectorMap.isSpecial(loc) 的条目,冲突时由 writeConflictHandler 处理。这是留给下游 mod(Cubic Chunks 用来标记"这里是 cube 区域")的扩展点。
关闭与落盘
| 方法 | 行为 |
|---|---|
flush() |
ensureSectorSizeAligned()(文件长度不是扇区整数倍就用 ByteBuffer.allocateDirect 补零)→ file.force(false) |
close() |
flush() 后 file.close() |
writeValue(key, null) |
删除:regionSectorTracker.removeKey(key) + updateHeaders(key) |
forEachKey(cons) |
遍历 id 从 0 到 keyCount - 1,只有头表里有位置的 id 才回调 |
相关条目
- 区域缓存上限 - 同时打开多少个这样的文件
.ext回退格式 - 突破上面 255 扇区上限的办法- 多提供者回退链 - 写入失败时怎么自动落到
.ext - 存档目录与文件命名 -
regionKey.getName()怎么拼出来 - Minecraft 原版存档区 - 4096 扇区 + 时间戳头表的那个具体变体