新增两模块: mcfold/anvil.py 存档读取(region/NBT)+ 区块编解码工具 mcfold/render_bridge.py 桥:常驻 Java 渲染器进程,NDJSON 协议 mcfold/render.py 对外入口:WorldRenderer(存档→画面) scaffold/McfoldRenderServer.java Java 侧服务端(常驻,NDJSON) 实测(真实存档 mc-test,DataVersion 3465=1.20.1): injected=81 unknownBlocks=0 uniqueSpecs=111 loadedChunks=81 chunksRendered=60 distinctColors=11585 画面:草地/沙滩/树/水/天空/太阳/HUD(sample-real-save-render.png) ★ 分工落在实处(附录 D 的结论变成代码): 存档 palette 是**方块名**(minecraft:oak_log[axis=y]),网络载荷要 **state id**, 名字→id 的权威映射只有客户端注册表有 ⇒ 新增 resolve 命令, Java 侧用 BlockStateParser.parseForBlock 解析(实测 111 种全中,0 unknown) 踩坑(本轮新发现): 1. level.dat 有两种布局(8字节头+zlib / 直接 gzip)—— 实测本机是后者 2. section 索引 ≠ 绝对 Y:存档用 -4..19,载荷用 0..23(少这步 → readerIndex 越界) 3. 注入前必须 set_viewport,否则 ClientChunkCache.inRange 丢弃区块 4. setPos 不会更新实体区块归属,要 setPosRaw + updateViewCenter 5. 生物群系只影响着色,用统一 plains 不影响「能渲染出世界」 6. camera 回包要用 JsonArray,字符串会被 Python tuple() 按字符拆开 性能:启动 66s(GL+资源重载),之后每帧渲染 ~1s(含 100 帧 tick)
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""从 MC 存档(anvil region)读真实区块数据,交给渲染桥出图。
|
||
|
||
为什么走存档而不走服务端:
|
||
用户口径是「只调用渲染能力,不参与游戏」——存档是**零网络、零登录、
|
||
零服务端**的真实世界数据源,且与官方客户端认同一套格式。
|
||
|
||
数据流:
|
||
region/*.mca → 解压区块 NBT → NBT 里的 block_states/Heightmaps
|
||
→ 重编码为 packet_map_chunk 载荷 → Java 渲染器
|
||
|
||
为什么要重编码:存档用 NBT(Sections[].block_states.palette/data),
|
||
网络包用扁平二进制(nonEmpty + bitsPerValue + palette + long[])。
|
||
两者信息等价,格式不同。渲染器吃的是网络包格式(官方同一路径)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import gzip
|
||
import io
|
||
import struct
|
||
import zlib
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Iterator
|
||
|
||
SECTOR = 4096
|
||
|
||
|
||
class AnvilError(RuntimeError):
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------- NBT 读取
|
||
|
||
class NbtReader:
|
||
"""极简 NBT 读取器(只读,支持存档里的全部 tag 类型)。"""
|
||
|
||
def __init__(self, data: bytes):
|
||
self.d = data
|
||
self.i = 0
|
||
|
||
def u1(self) -> int:
|
||
v = self.d[self.i]; self.i += 1; return v
|
||
|
||
def i1(self) -> int:
|
||
v = struct.unpack_from(">b", self.d, self.i)[0]; self.i += 1; return v
|
||
|
||
def i2(self) -> int:
|
||
v = struct.unpack_from(">h", self.d, self.i)[0]; self.i += 2; return v
|
||
|
||
def u2(self) -> int:
|
||
v = struct.unpack_from(">H", self.d, self.i)[0]; self.i += 2; return v
|
||
|
||
def i4(self) -> int:
|
||
v = struct.unpack_from(">i", self.d, self.i)[0]; self.i += 4; return v
|
||
|
||
def i8(self) -> int:
|
||
v = struct.unpack_from(">q", self.d, self.i)[0]; self.i += 8; return v
|
||
|
||
def f4(self) -> float:
|
||
v = struct.unpack_from(">f", self.d, self.i)[0]; self.i += 4; return v
|
||
|
||
def f8(self) -> float:
|
||
v = struct.unpack_from(">d", self.d, self.i)[0]; self.i += 8; return v
|
||
|
||
def s(self) -> str:
|
||
n = self.u2()
|
||
v = self.d[self.i:self.i + n].decode("utf-8", "replace"); self.i += n
|
||
return v
|
||
|
||
def payload(self, t: int) -> Any:
|
||
if t == 1: return self.i1()
|
||
if t == 2: return self.i2()
|
||
if t == 3: return self.i4()
|
||
if t == 4: return self.i8()
|
||
if t == 5: return self.f4()
|
||
if t == 6: return self.f8()
|
||
if t == 7:
|
||
n = self.i4(); v = self.d[self.i:self.i + n]; self.i += n; return v
|
||
if t == 8: return self.s()
|
||
if t == 9:
|
||
it = self.u1(); n = self.i4()
|
||
return [self.payload(it) for _ in range(n)]
|
||
if t == 10:
|
||
out = {}
|
||
while True:
|
||
tt = self.u1()
|
||
if tt == 0: break
|
||
nm = self.s()
|
||
out[nm] = self.payload(tt)
|
||
return out
|
||
if t == 11:
|
||
n = self.i4()
|
||
v = list(struct.unpack_from(f">{n}i", self.d, self.i)); self.i += 4 * n
|
||
return v
|
||
if t == 12:
|
||
n = self.i4()
|
||
v = list(struct.unpack_from(f">{n}q", self.d, self.i)); self.i += 8 * n
|
||
return v
|
||
raise AnvilError(f"未知 NBT tag {t} @ {self.i}")
|
||
|
||
|
||
def nbt_read(data: bytes) -> dict:
|
||
r = NbtReader(data)
|
||
t = r.u1()
|
||
if t != 10:
|
||
raise AnvilError(f"根 tag 应为 Compound(10),得到 {t}")
|
||
r.s() # 根名(通常空串)
|
||
return r.payload(10)
|
||
|
||
|
||
def read_level_dat(save_dir: Path) -> dict:
|
||
"""读 level.dat。
|
||
|
||
★ 实测两种布局都存在:
|
||
- 官方启动器/服务端:8 字节头 + zlib
|
||
- 部分整合包(本机 mc-test 实测):文件直接就是 gzip
|
||
所以先看魔数,再决定偏移。
|
||
"""
|
||
raw = (save_dir / "level.dat").read_bytes()
|
||
if raw[:2] == b"\x1f\x8b": # 直接 gzip
|
||
body = gzip.decompress(raw)
|
||
elif len(raw) > 10 and raw[8:10] == b"\x1f\x8b": # 8 字节头 + gzip
|
||
body = gzip.decompress(raw[8:])
|
||
else: # 8 字节头 + zlib
|
||
body = zlib.decompress(raw[8:])
|
||
return nbt_read(body)
|
||
|
||
|
||
# ---------------------------------------------------------------- region 读取
|
||
|
||
@dataclass
|
||
class RegionFile:
|
||
"""一个 .mca 文件:读出指定区块的原始 NBT(已解压)。"""
|
||
|
||
path: Path
|
||
_buf: bytes = b""
|
||
|
||
def __post_init__(self):
|
||
self._buf = self.path.read_bytes()
|
||
if len(self._buf) < SECTOR * 2:
|
||
raise AnvilError(f"region 文件过小: {self.path}")
|
||
|
||
def chunk_nbt(self, cx: int, cz: int) -> bytes | None:
|
||
"""cx/cz 是**区块坐标**(世界坐标 >> 4)。"""
|
||
lx, lz = cx & 31, cz & 31
|
||
idx = (lx + lz * 32) * 4
|
||
off = struct.unpack_from(">I", self._buf, idx)[0]
|
||
if off == 0:
|
||
return None
|
||
sector = (off >> 8) & 0xFFFFFF
|
||
count = off & 0xFF
|
||
if sector == 0 or count == 0:
|
||
return None
|
||
start = sector * SECTOR
|
||
length = struct.unpack_from(">I", self._buf, start)[0]
|
||
comp = self._buf[start + 4]
|
||
blob = self._buf[start + 5:start + 4 + length]
|
||
if comp == 1:
|
||
return gzip.decompress(blob)
|
||
if comp == 2:
|
||
return zlib.decompress(blob)
|
||
if comp == 3:
|
||
return blob
|
||
if comp == 4:
|
||
import lzma
|
||
return lzma.decompress(blob)
|
||
raise AnvilError(f"未知压缩类型 {comp}")
|
||
|
||
|
||
def region_path(save_dir: Path, dim: str | None, rx: int, rz: int) -> Path:
|
||
dim_part = "" if not dim else f"{dim}/"
|
||
return save_dir / f"{dim_part}region" / f"r.{rx}.{rz}.mca"
|
||
|
||
|
||
def list_region_files(save_dir: Path, dim: str | None = None) -> list[Path]:
|
||
base = save_dir / (f"{dim}/region" if dim else "region")
|
||
return sorted(base.glob("r.*.*.mca")) if base.is_dir() else []
|
||
|
||
|
||
# ---------------------------------------------------------------- 存档区块 → 网络载荷
|
||
|
||
def _pack_long_bits(values: list[int], bits: int) -> list[int]:
|
||
"""把 state id 列表按 MC 的位打包规则塞进 long(值不跨 long 边界)。"""
|
||
if bits == 0:
|
||
return []
|
||
vpl = 64 // bits
|
||
mask = (1 << bits) - 1
|
||
longs: list[int] = []
|
||
cur = 0
|
||
slot = 0
|
||
for v in values:
|
||
cur |= (v & mask) << (slot * bits)
|
||
slot += 1
|
||
if slot == vpl:
|
||
longs.append(cur); cur = 0; slot = 0
|
||
if slot:
|
||
longs.append(cur)
|
||
return longs
|
||
|
||
|
||
def _varint(v: int) -> bytes:
|
||
out = bytearray()
|
||
while True:
|
||
b = v & 0x7F; v >>= 7
|
||
if v: out.append(b | 0x80)
|
||
else: out.append(b); break
|
||
return bytes(out)
|
||
|
||
|
||
def _write_long_array(longs: list[int]) -> bytes:
|
||
"""MC 的 LongArray 编码:varint 长度 + 大端 i64 ×N"""
|
||
out = _varint(len(longs))
|
||
for l in longs:
|
||
# 视为无符号 64 位
|
||
out += struct.pack(">q", l - (1 << 64) if l >= (1 << 63) else l)
|
||
return out
|
||
|
||
|
||
def encode_block_section(non_empty: int, state_ids: list[int] | None,
|
||
palette: list[int] | None) -> bytes:
|
||
"""按网络包格式编码一个 block section。
|
||
|
||
state_ids=None 表示单值(palette 只有一个元素)。
|
||
"""
|
||
out = struct.pack(">h", non_empty)
|
||
if state_ids is None:
|
||
out += bytes([0]) + _varint(palette[0]) + _varint(0)
|
||
return out
|
||
|
||
uniq = sorted(set(state_ids))
|
||
if len(uniq) == 1:
|
||
out += bytes([0]) + _varint(uniq[0]) + _varint(0)
|
||
return out
|
||
|
||
# 本地调色板(indirect)。bits = ceil(log2(uniq)),至少 4
|
||
bits = max(4, (len(uniq) - 1).bit_length())
|
||
if bits > 8:
|
||
# 全局调色板:bits 直接放 state id
|
||
gbits = 15
|
||
out += bytes([gbits]) + _write_long_array(_pack_long_bits(state_ids, gbits))
|
||
return out
|
||
|
||
index = {v: i for i, v in enumerate(uniq)}
|
||
out += bytes([bits])
|
||
out += _varint(len(uniq))
|
||
for v in uniq:
|
||
out += _varint(v)
|
||
out += _write_long_array(_pack_long_bits([index[v] for v in state_ids], bits))
|
||
return out
|
||
|
||
|
||
def encode_chunk_payload(elevated_sections: dict[int, dict]) -> bytes:
|
||
"""把存档区块的 sections 编成 packet_map_chunk 的 chunkData 部分。
|
||
|
||
elevated_sections: {section_y_index: {"states": [4096 int], "biomes": [64 int]}}
|
||
★ 必须覆盖整个 worldHeight 的 section 数(1.20.1 = 24),缺失的补空气。
|
||
"""
|
||
num_sections = 24
|
||
out = bytearray()
|
||
for sy in range(num_sections):
|
||
sec = elevated_sections.get(sy)
|
||
out += encode_block_section(0, None, [0]) if sec is None else \
|
||
encode_block_section(sec["nonEmpty"], sec["states"], None)
|
||
# biome section:64 个值(4x4x4)
|
||
biomes = sec["biomes"] if sec else [1] * 64
|
||
uniq = sorted(set(biomes))
|
||
if len(uniq) == 1:
|
||
out += bytes([0]) + _varint(uniq[0]) + _varint(0)
|
||
else:
|
||
bits = max(1, (len(uniq) - 1).bit_length())
|
||
if bits > 3:
|
||
out += bytes([6]) + _write_long_array(_pack_long_bits(biomes, 6))
|
||
else:
|
||
idx = {v: i for i, v in enumerate(uniq)}
|
||
out += bytes([bits]) + _varint(len(uniq))
|
||
for v in uniq:
|
||
out += _varint(v)
|
||
out += _write_long_array(_pack_long_bits([idx[b] for b in biomes], bits))
|
||
return bytes(out)
|
||
|
||
|
||
def save_chunk_to_payload(chunk_nbt: dict) -> bytes | None:
|
||
"""存档区块 NBT → 网络载荷。"""
|
||
sections = chunk_nbt.get("sections")
|
||
if not isinstance(sections, list):
|
||
return None
|
||
by_y: dict[int, dict] = {}
|
||
for s in sections:
|
||
sy = s.get("Y")
|
||
if sy is None:
|
||
continue
|
||
bs = s.get("block_states")
|
||
if not isinstance(bs, dict):
|
||
continue
|
||
palette = bs.get("palette") or []
|
||
# palette 元素是 {"Name": "minecraft:stone", "Properties": {...}}
|
||
# 需要 state id —— 这要靠客户端注册表(渲染器侧提供 lookup)
|
||
data = bs.get("data")
|
||
by_y[sy] = {"palette": palette, "data": data}
|
||
return by_y # 返回中间结构;state id 解析交给调用方(需注册表)
|