Files
agentmc/mcfold/anvil.py
JianFeeeee 391d452c85 P9:Python 包装层 —— 真实存档 → 官方渲染器 → 出图
新增两模块:
  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)
2026-09-25 17:33:41 +08:00

301 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""从 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 解析交给调用方(需注册表)