架构重构:差异全部下移到 Java,Python 只做路由

用户口径:差异放在 Java 层,Python 相当于路由。

下移到 Java(新增 cmd=setblocks):
  Python 只发 RLE([[stateId,count],...]),Java 用官方 API 完成
  调色板组装 / 位打包 / 官方序列化 / 入 cache / 渲染。

★ 意外收益:走 ClientboundLevelChunkPacketData(chunk).getReadBuffer()
  + replaceWithPacketData(...) 这条官方路径,两代签名完全一致
  ⇒ 原先的 Storage.chunks 反射注入**整段删掉**。

新增 cmd=capacity:世界尺寸(sectionsCount/minSection/defaultBiome)
  由运行时回答,Python 不再硬编码 24/-4(1.16 时代是 16/0)。

Python 侧删除(防回归,见 test_import_surface):
  render_bridge.py(旧桥,无世代概念)
  render._pack/_long_array/_encode_paletted/_encode_chunk/_varint
  anvil.encode_block_section/encode_chunk_payload/save_chunk_to_payload
  合计 454→374 行(render.py)、300→177 行(anvil.py)

保留在 Python(都是「不随版本变」的知识):
  RLE 压缩(rle_encode/rle_decode,具名可测)
  存档读取(region/NBT)
  方块名→id 的路由(查询在 Java)
  NDJSON 协议

测试:62 项单元 + 6 项渲染联测全绿
  新测试反映新架构:RLE 边界、运行时容量、世代分派、新路径渲染
  test_anvil 里原有 8 项协议位打包测试已删(那已下移到 Java,
  由两代后端各自的编译验证覆盖)

新增 scaffold/ARCHITECTURE.md(分层原则 + 加新世代步骤 + 防回归清单)
This commit is contained in:
JianFeeeee
2026-09-25 19:21:45 +08:00
parent 253146cffe
commit 16f2e04fff
20 changed files with 687 additions and 862 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
[17:51:20] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 108 milliseconds
[17:51:21] [main/INFO]: Environment: authHost='https://authserver.mojang.com', accountsHost='https://api.mojang.com', sessionHost='https://sessionserver.mojang.com', servicesHost='https://api.minecraftservices.com', name='PROD'
[17:51:22] [main/ERROR]: Failed to verify authentication
[19:20:23] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 129 milliseconds
[19:20:24] [main/INFO]: Environment: authHost='https://authserver.mojang.com', accountsHost='https://api.mojang.com', sessionHost='https://sessionserver.mojang.com', servicesHost='https://api.minecraftservices.com', name='PROD'
[19:20:25] [main/ERROR]: Failed to verify authentication
com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401
at com.mojang.authlib.exceptions.MinecraftClientHttpException.toAuthenticationException(MinecraftClientHttpException.java:56) ~[authlib-4.0.43.jar:?]
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:156) ~[authlib-4.0.43.jar:?]
@ -8,24 +8,24 @@ com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401
at com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService.createUserApiService(YggdrasilAuthenticationService.java:124) ~[authlib-4.0.43.jar:?]
at net.minecraft.client.Minecraft.createUserApiService(SourceFile:733) ~[client-named.jar:?]
at net.minecraft.client.Minecraft.<init>(SourceFile:442) ~[client-named.jar:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:517) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:605) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?]
Caused by: com.mojang.authlib.exceptions.MinecraftClientHttpException: Status: 401
at com.mojang.authlib.minecraft.client.MinecraftClient.readInputStream(MinecraftClient.java:85) ~[authlib-4.0.43.jar:?]
at com.mojang.authlib.minecraft.client.MinecraftClient.get(MinecraftClient.java:48) ~[authlib-4.0.43.jar:?]
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:129) ~[authlib-4.0.43.jar:?]
... 6 more
[17:51:22] [main/INFO]: Setting user: mcfold_cam
[17:51:22] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7
[17:51:22] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers.
[17:51:22] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it
[17:51:22] [main/INFO]: Reloading ResourceManager: vanilla
[17:51:22] [Worker-Main-4/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[17:51:22] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed
[17:52:22] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager
[17:52:22] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play
[17:52:22] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break
[17:52:22] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music
[19:20:25] [main/INFO]: Setting user: mcfold_cam
[19:20:25] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7
[19:20:25] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers.
[19:20:25] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it
[19:20:26] [main/INFO]: Reloading ResourceManager: vanilla
[19:20:26] [Worker-Main-10/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[19:20:26] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed
[19:21:26] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager
[19:21:26] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play
[19:21:26] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break
[19:21:26] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music
java.lang.IllegalStateException: Failed to open OpenAL device
at com.mojang.blaze3d.audio.Library.openDeviceOrFallback(SourceFile:283) ~[client-named.jar:?]
at com.mojang.blaze3d.audio.Library.init(SourceFile:153) ~[client-named.jar:?]
@ -40,24 +40,23 @@ java.lang.IllegalStateException: Failed to open OpenAL device
at java.util.concurrent.CompletableFuture.uniAcceptStage(CompletableFuture.java:735) ~[?:?]
at java.util.concurrent.CompletableFuture.thenAcceptAsync(CompletableFuture.java:2223) ~[?:?]
at net.minecraft.server.packs.resources.SimplePreparableReloadListener.reload(SourceFile:13) ~[client-named.jar:?]
at McfoldRenderServer.reloadResources(McfoldRenderServer.java:588) ~[classes/:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:521) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?]
[17:52:22] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[17:52:23] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas
[17:52:23] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas
[17:52:23] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas
[17:52:23] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas
[17:52:23] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas
[17:52:23] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas
[17:52:23] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas
[17:52:23] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas
[17:52:23] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas
[17:52:24] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[17:52:24] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas
[17:52:24] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas
[17:52:24] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas
[17:52:24] [main/INFO]: [STDERR]: [mcfold] resources reloaded
[17:52:24] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[17:52:24] [main/INFO]: [STDERR]: [mcfold] boot done
[17:52:35] [main/WARN]: Shader invert could not find uniform named InSize in the specified shader program.
at McfoldRenderServer.reloadResources(McfoldRenderServer.java:676) ~[classes/:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:609) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?]
[19:21:26] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[19:21:26] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas
[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas
[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas
[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas
[19:21:26] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas
[19:21:26] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas
[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas
[19:21:26] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas
[19:21:27] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas
[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas
[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas
[19:21:27] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas
[19:21:27] [main/INFO]: [STDERR]: [mcfold] resources reloaded
[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[19:21:27] [main/INFO]: [STDERR]: [mcfold] boot done

View File

@ -1,16 +1,16 @@
"""从 MC 存档(anvil region)读真实区块数据,交给渲染桥出图。
"""从 MC 存档(anvil region)读真实区块数据。
为什么走存档而不走服务端:
用户口径是「只调用渲染能力,不参与游戏」——存档是**零网络、零登录、
零服务端**的真实世界数据源,且与官方客户端认同一套格式。
数据流:
region/*.mca → 解压区块 NBT → NBT 里的 block_states/Heightmaps
→ 重编码为 packet_map_chunk 载荷 → Java 渲染器
★ 本模块只做**读取**(Python 的职责):
region/*.mca → 解压区块 NBT → 取出 Sections[].block_states(palette + data)
为什么要重编码:存档用 NBT(Sections[].block_states.palette/data),
网络包用扁平二进制(nonEmpty + bitsPerValue + palette + long[])。
两者信息等价,格式不同。渲染器吃的是网络包格式(官方同一路径)。
编码/协议组装**不在这里** —— 那属于 Java 侧(用官方 API 完成),
因为历代改过协议格式(位宽、palette 分支、LongArray 编码),
在 Python 里复制一份就等于把版本差异搬回了 Python。
下游 render.py 只把读取结果压成 RLE,交给后端。
"""
from __future__ import annotations
@ -175,126 +175,3 @@ def region_path(save_dir: Path, dim: str | None, rx: int, rz: int) -> Path:
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 解析交给调用方(需注册表)

View File

@ -23,6 +23,33 @@ from pathlib import Path
from typing import Any
def rle_encode(values) -> list[list[int]]:
"""行程编码:[[值, 重复次数], ...]。
★ 这是**格式无关**的压缩 —— 只是把连续相同的值合并成一段,
不含任何 MC 协议知识(调色板/位宽/字节序都不在这里)。
真正的协议组装由 Java 侧用官方 API 完成。
"""
out: list[list[int]] = []
for v in values:
iv = int(v)
if out and out[-1][0] == iv:
out[-1][1] += 1
else:
out.append([iv, 1])
return out
def rle_decode(rle, expected: int | None = None) -> list[int]:
"""rle_encode 的逆(测试与诊断用)。"""
out: list[int] = []
for v, n in rle:
out.extend([int(v)] * int(n))
if expected is not None and len(out) != expected:
raise ValueError(f"RLE 长度 {len(out)} != 期望 {expected}")
return out
class BackendError(RuntimeError):
"""后端不可用或渲染失败。"""
@ -183,6 +210,21 @@ class RenderBackend:
"items": [{"cx": cx, "cz": cz, "data": self._b64(d)} for cx, cz, d in chunks],
}, timeout=300.0)
def set_blocks(self, cx: int, cz: int, sections: dict[int, list[int]]) -> dict:
"""直接写方块(Python 只做路由,不碰协议格式)。
sections: {sectionIndex: [4096 个 state id]},索引顺序 (y,z,x) 线性化
(与 MC 的 PalettedContainer 索引一致:x 最快、y 最慢)。
为什么走这条路(用户 2026-09-25 定向):
方块写入、调色板组装、位打包、入 cache 全在 Java 侧用官方 API 完成。
Python 不复制历代变过的协议格式 —— 版本差异留在 Java。
"""
items = [{"index": int(idx), "rle": rle_encode(ids)}
for idx, ids in sorted(sections.items())]
return self._request({"cmd": "setblocks", "cx": cx, "cz": cz,
"sections": items}, timeout=300.0)
def set_viewport(self, cx: int, cz: int, radius: int | None = None) -> dict:
"""视野中心 —— 必须在注入前设好,否则区块被 ClientChunkCache 丢弃(实测)。"""
req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz}

View File

@ -28,67 +28,18 @@ from . import anvil
from .backends import backend_class, detect_generation
from .backends.base import BackendError, RenderBackend, RenderResult
# 存档区块在网络载荷里的 section 数(1.20.1 worldHeight 384 / 16 = 24)
SECTION_COUNT = 24
SECTION_VOLUME = 16 * 16 * 16
BIOME_VOLUME = 4 * 4 * 4
# ★ 最小 section 的 Y:1.20 起 worldHeight 384、minBuildHeight -64 ⇒ minSection = -4。
# 网络载荷里的 section 索引是「从下往上数」(0..23),而存档 NBT 用的是绝对 Y(-4..19)。
MIN_SECTION_Y = -4
# 位打包:把 palette 索引列表压进 long(值不跨 long 边界)
def _pack(values: list[int], bits: int) -> list[int]:
if bits <= 0:
return []
vpl = 64 // bits
mask = (1 << bits) - 1
out, cur, slot = [], 0, 0
for v in values:
cur |= (v & mask) << (slot * bits)
slot += 1
if slot == vpl:
out.append(cur); cur, slot = 0, 0
if slot:
out.append(cur)
return out
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 _long_array(longs: list[int]) -> bytes:
out = _varint(len(longs))
for l in longs:
out += (l & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "big")
return out
def _encode_paletted(state_ids: list[int], volume: int, min_bits: int = 4,
global_bits: int = 15, max_local_bits: int = 8) -> bytes:
"""编码一个 PalettedContainer 载荷(与 Java PalettedContainer.read 对齐)。"""
uniq = sorted(set(state_ids))
if len(uniq) == 1:
return bytes([0]) + _varint(uniq[0]) + _varint(0)
need = (len(uniq) - 1).bit_length()
if need > max_local_bits:
# 全局调色板:位宽直接放 state id
return bytes([global_bits]) + _long_array(_pack(state_ids, global_bits))
bits = max(min_bits, need)
idx = {v: i for i, v in enumerate(uniq)}
out = bytes([bits]) + _varint(len(uniq))
for v in uniq:
out += _varint(v)
out += _long_array(_pack([idx[v] for v in state_ids], bits))
return out
# ── 协议层常量(与版本无关,协议本身定义)──
SECTION_VOLUME = 16 * 16 * 16 # 一个 section 的方块数
BIOME_VOLUME = 4 * 4 * 4 # 一个 biome section 的值数
# ── 世界尺寸:★ 不硬编码,运行时问 Java ──
# 这些量随版本/世界类型变(1.20 起 worldHeight 384、minSection -4;
# 更早的版本是 256/-0)。Python 只做路由与协议编解码,
# 版本相关的量由后端回答(cmd="capacity")。
# 下面三个仅在没有后端时的降级默认值。
FALLBACK_SECTION_COUNT = 24
FALLBACK_MIN_SECTION_Y = -4
FALLBACK_DEFAULT_BIOME = "minecraft:plains"
def _decode_storage(longs: Iterable[int], volume: int, bits: int) -> list[int]:
"""int[] 位数组 → 索引列表(不跨 long)。"""
@ -189,6 +140,8 @@ class WorldRenderer:
self._spec_cache: dict[str, int] = {}
self._loaded: set[tuple[int, int]] = set()
self.chunks: dict[tuple[int, int], SaveChunk] = {}
self._capacity: dict[str, Any] | None = None
self._biome_id: int | None = None
# ---------------- 构造 ----------------
@ -232,8 +185,49 @@ class WorldRenderer:
wr = cls(bridge, instance)
if start:
bridge.start()
wr.refresh_capacity()
return wr
def refresh_capacity(self) -> dict[str, Any]:
"""向 Java 后端询问世界尺寸(Python 不硬编码版本相关量)。"""
try:
self._capacity = self.bridge._request({"cmd": "capacity"})
except Exception:
self._capacity = None
return self._capacity or {}
@property
def sections_count(self) -> int:
if self._capacity and self._capacity.get("sectionsCount"):
return int(self._capacity["sectionsCount"])
return FALLBACK_SECTION_COUNT
@property
def min_section_y(self) -> int:
if self._capacity and "minSection" in self._capacity:
return int(self._capacity["minSection"])
return FALLBACK_MIN_SECTION_Y
@property
def _default_biome(self) -> int:
"""默认生物群系 id —— 运行时向 Java 查(不硬编码 1)。"""
if self._biome_id is None:
name = (self._capacity or {}).get("defaultBiome") or FALLBACK_DEFAULT_BIOME
self._biome_id = self._resolve_biome_id(name)
return self._biome_id
def _resolve_biome_id(self, name: str) -> int:
key = f"__biome__{name}"
if key in self._spec_cache:
return self._spec_cache[key]
try:
ids = self.bridge.resolve_blocks([name])
bid = ids[0] if ids else 1
except Exception:
bid = 1
self._spec_cache[key] = bid
return bid
def close(self) -> None:
self.bridge.stop()
@ -309,49 +303,40 @@ class WorldRenderer:
})
def load_chunks(self, chunks: list[SaveChunk], meta: dict | None = None) -> dict:
"""把已解析的区块编码并注入渲染器。"""
"""把存档区块交给渲染器。
★ Python 只做「方块名 → state id」的路由与 RLE 压缩;
调色板组装、位打包、协议序列化、入 cache 全在 Java 侧用官方 API 完成。
这样历代改过的协议格式不需要在 Python 里复制一份。
"""
all_specs: list[str] = []
for c in chunks:
all_specs.extend(c.palette_specs)
id_map = self.resolve_specs(all_specs)
unknown = sum(1 for v in id_map.values() if v < 0)
items: list[tuple[int, int, bytes]] = []
n_sections = self.sections_count
min_sy = self.min_section_y
injected = 0
for c in chunks:
payload = self._encode_chunk(c, id_map)
items.append((c.cx, c.cz, payload))
sections: dict[int, list[int]] = {}
for idx in range(n_sections):
sec = c.sections.get(idx + min_sy)
if sec is None:
continue # 空 section 由 Java 侧留空
sections[idx] = [max(0, id_map.get(sp, -1)) for sp in sec["states"]]
if sections:
self.bridge.set_blocks(c.cx, c.cz, sections)
injected += 1
self.chunks[(c.cx, c.cz)] = c
resp = self.bridge.feed_chunks(items) if items else {"injected": 0}
self._loaded.update((c.cx, c.cz) for c in chunks)
out = {"injected": len(items), "unknownBlocks": unknown,
out = {"injected": injected, "unknownBlocks": unknown,
"uniqueSpecs": len(id_map)}
if meta:
out.update(meta)
return out
def _encode_chunk(self, c: SaveChunk, id_map: dict[str, int]) -> bytes:
"""SaveChunk → packet_map_chunk 的 chunkData 载荷。"""
out = bytearray()
for sy in range(SECTION_COUNT):
# ★ 索引 0..23 ↔ 绝对 Y (-4..19):存档用绝对 Y,载荷用相对索引
sec = c.sections.get(sy + MIN_SECTION_Y)
if sec is None:
# 空 section:空气
out += _encode_paletted([0] * SECTION_VOLUME, SECTION_VOLUME)
else:
ids = [max(0, id_map.get(s, -1)) for s in sec["states"]]
non_empty = sum(1 for v in ids if v != 0)
out += (non_empty & 0xFFFF).to_bytes(2, "big")
out += _encode_paletted(ids, SECTION_VOLUME)
# biome section
bios = sec["biomes"] if sec else ["minecraft:plains"] * BIOME_VOLUME
bio_pal = sorted(set(bios))
# 生物群系用全局调色板 id 需要 registry —— 简化:统一用 plains 的 id 1
# (生物群系只影响草/树叶着色,不影响「能不能渲染出世界」)
out += bytes([0]) + _varint(1) + _varint(0)
return bytes(out)
# ---------------- 视角与截图 ----------------
@property

View File

@ -1,386 +0,0 @@
"""渲染桥:把 Python 侧的世界数据交给 Java 客户端渲染器出图。
设计要点
--------
两侧格式**同源**,不需要转换:
Python client.run_smoke()/read_packet() 收到的 packet_map_chunk 载荷,
其 chunkData 部分与 Java 侧 LevelChunkSection.read(FriendlyByteBuf)
期待的是同一套字节(nonEmptyBlockCount + PalettedContainer×N + Biomes)。
所以桥的协议是「把 chunk 原始字节交给 Java」,而不是「把方块逐个传过去」——
既避免重建世界的语义损失(mod 方块、NBT、biome、block entity),
又天然支持任意版本(格式由同一个 protocol.json 描述)。
通信
----
子进程 + 行分隔 JSON(NDJSON):
Java 侧 stdout 每行一个 JSON 对象;Python 写 stdin 用同样格式。
选 NDJSON 而非 stdin 二进制:可读、可日志、便于调试;
chunk 字节量不大(单区块约 10-100KB),base64 开销可接受。
"""
from __future__ import annotations
import base64
import json
import os
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
class RenderBridgeError(RuntimeError):
"""桥不可用或渲染失败。"""
@dataclass
class RenderResult:
"""一次渲染的结果。"""
image: bytes | None = None # PNG 字节
path: str | None = None
width: int = 0
height: int = 0
chunks_rendered: int = 0 # countRenderedChunks
distinct_colors: int = 0 # 判据:区分「世界」与「加载屏/空屏」
camera: tuple[float, float, float] = (0.0, 0.0, 0.0)
warnings: list[str] = field(default_factory=list)
raw: dict[str, Any] = field(default_factory=dict)
@property
def has_world_content(self) -> bool:
"""distinct_colors 是实测有效的判据:
空屏/纯色 1-2 色,加载屏 ~199(logo 抗锯齿),真实世界 >1000。"""
return self.distinct_colors > 400 and self.chunks_rendered > 0
class RenderBridge:
"""驱动 Java 渲染器子进程。
生命周期:start() → feed_chunk()×N → set_camera() → render() → stop()。
渲染器是**常驻进程**:起一次约 3-5s(GL + 资源重载),
之后每次渲染只需几十到几百毫秒。
"""
def __init__(
self,
java: str = "java",
classpath: str | list[str] | None = None,
main_class: str = "McfoldRenderServer",
game_dir: str | None = None,
assets: str | None = None,
asset_index: str | None = None,
java_args: list[str] | None = None,
startup_timeout: float = 180.0,
cwd: str | None = None,
):
self.java = java
self.main_class = main_class
self.game_dir = game_dir
self.assets = assets
self.asset_index = asset_index
self.startup_timeout = startup_timeout
self.cwd = cwd
if classpath is None:
classpath = os.environ.get("MCFOLD_RENDER_CLASSPATH")
if isinstance(classpath, (list, tuple)):
classpath = os.pathsep.join(classpath)
self.classpath = classpath
self.java_args = java_args or [
"-Xmx1200M", "-Xms512M",
# headless:不要 AWT 窗口;GL 仍走 GLFW + Xvfb
"-Djava.awt.headless=true",
]
self._proc: subprocess.Popen | None = None
self._next_id = 1
self._pending: dict[int, tuple[threading.Event, dict]] = {}
self._lock = threading.Lock()
self._reader: threading.Thread | None = None
self._stderr_tail: list[str] = []
# ---------------- 生命周期 ----------------
@property
def running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def start(self) -> "RenderBridge":
if self.running:
return self
if not self.classpath:
raise RenderBridgeError(
"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH 或传 classpath=。"
"构建:mcfold/scaffold/build.sh <store> <mcver>"
)
cmd = [self.java, *self.java_args, "-cp", self.classpath, self.main_class]
if self.game_dir:
cmd += ["--gameDir", self.game_dir]
if self.assets:
cmd += ["--assets", self.assets]
if self.asset_index:
cmd += ["--assetIndex", self.asset_index]
env = dict(os.environ)
# natives 目录(lwjgl .so):构建脚本解到 MCFOLD_NATIVES_DIR
nat = env.get("MCFOLD_NATIVES_DIR")
if nat:
env["LD_LIBRARY_PATH"] = nat + os.pathsep + env.get("LD_LIBRARY_PATH", "")
try:
self._proc = subprocess.Popen(
cmd, cwd=self.cwd, env=env,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=False, bufsize=0,
)
except FileNotFoundError as e:
raise RenderBridgeError(f"无法启动 java: {e}") from e
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
threading.Thread(target=self._stderr_loop, daemon=True).start()
ready = self._await_event("ready", self.startup_timeout)
if not ready.pop("_ok", False):
self.stop()
raise RenderBridgeError(
"渲染器启动失败:" + (ready.get("error") or "超时")
+ ("\n" + "".join(self._stderr_tail[-5:]) if self._stderr_tail else "")
)
return self
def stop(self, timeout: float = 10.0) -> None:
p = self._proc
if p is None:
return
try:
if p.poll() is None:
self._send_raw({"cmd": "shutdown"})
try:
p.wait(timeout=timeout)
except subprocess.TimeoutExpired:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
finally:
self._proc = None
def __enter__(self) -> "RenderBridge":
return self.start()
def __exit__(self, *exc) -> None:
self.stop()
# ---------------- 数据注入 ----------------
def feed_chunk(self, cx: int, cz: int, chunk_data: bytes,
heightmaps_nbt: bytes | None = None) -> dict:
"""注入一个区块。
chunk_data —— packet_map_chunk 载荷里「长度前缀之后」的那段,
与 Java LevelChunkSection.read() 直接对应。
调 client 侧:decode 后的原始 payload 切片。
heightmaps_nbt —— 可选 NBT(Java 侧允许空)。
"""
return self._request({
"cmd": "chunk",
"cx": cx, "cz": cz,
"data": base64.b64encode(chunk_data).decode("ascii"),
"heightmaps": base64.b64encode(heightmaps_nbt).decode("ascii") if heightmaps_nbt else None,
})
def feed_chunks(self, chunks: list[tuple[int, int, bytes]]) -> dict:
"""批量注入(减少往返)。"""
return self._request({
"cmd": "chunks",
"items": [
{"cx": cx, "cz": cz, "data": base64.b64encode(d).decode("ascii")}
for cx, cz, d in chunks
],
})
def set_camera(self, x: float, y: float, z: float,
yaw: float = 0.0, pitch: float = 0.0) -> dict:
"""设置观察者位置与朝向(相机 = 我们想看哪里,不一定是玩家)。"""
return self._request({"cmd": "camera", "x": x, "y": y, "z": z,
"yaw": yaw, "pitch": pitch})
def set_view(self, *, camera_type: str | None = "THIRD_PERSON_BACK",
render_distance: int | None = None,
fov: float | None = None,
graphics_mode: str | None = None,
ambient_occlusion: bool | None = None,
mipmap_levels: int | None = None,
gamma: float | None = None,
entity_shadows: bool | None = None,
clouds: str | None = None,
particles: str | None = None,
post_effect: str | None = None) -> dict:
"""视觉设置。
光影能力边界(实测,见 scaffold/README.md):
- 原版画质选项(graphics_mode/AO/mipmap/gamma/云/实体阴影): 支持
- 原版后处理链(post_effect,如 minecraft:shaders/post/invert.json): 支持
- 第三方光影包(SEUS/BSL 等): 不支持(需 Iris/OptiFine 加载器改渲染管线)
"""
req: dict[str, Any] = {"cmd": "view"}
if camera_type:
req["cameraType"] = camera_type
if render_distance is not None:
req["renderDistance"] = render_distance
if fov is not None:
req["fov"] = fov
if graphics_mode is not None:
req["graphicsMode"] = graphics_mode
if ambient_occlusion is not None:
req["ambientOcclusion"] = ambient_occlusion
if mipmap_levels is not None:
req["mipmapLevels"] = mipmap_levels
if gamma is not None:
req["gamma"] = gamma
if entity_shadows is not None:
req["entityShadows"] = entity_shadows
if clouds is not None:
req["clouds"] = clouds
if particles is not None:
req["particles"] = particles
if post_effect is not None:
req["postEffect"] = post_effect
return self._request(req)
# ---------------- 渲染 ----------------
def render(self, out: str | None = None, frames: int = 60,
width: int | None = None, height: int | None = None) -> RenderResult:
"""渲若干帧并取回 PNG。
frames 不能太小:区块网格重建队列靠 tick 推进,
实测 20 帧时 countRenderedChunks 已到位,60 帧更稳。
"""
req: dict[str, Any] = {"cmd": "render", "frames": frames}
if out:
req["out"] = out
if width:
req["width"] = width
if height:
req["height"] = height
resp = self._request(req, timeout=300.0)
img_b64 = resp.pop("image", None)
res = RenderResult(
image=base64.b64decode(img_b64) if img_b64 else None,
path=resp.get("path"),
width=resp.get("width", 0),
height=resp.get("height", 0),
chunks_rendered=resp.get("chunksRendered", 0),
distinct_colors=resp.get("distinctColors", 0),
camera=tuple(resp.get("camera", (0.0, 0.0, 0.0))),
warnings=resp.get("warnings", []),
raw=resp,
)
return res
def registry_info(self) -> dict:
"""客户端方块注册表:state id ↔ 方块名(含 mod 方块的完整状态)。"""
return self._request({"cmd": "registry"})
def lookup_state(self, state_id: int) -> dict:
"""查单个 state id(返回方块名 + 完整属性,如 instrument/note/powered)。"""
return self._request({"cmd": "lookup", "stateId": state_id})
# ---------------- 内部:NDJSON 通道 ----------------
def _request(self, req: dict, timeout: float = 120.0) -> dict:
if not self.running:
raise RenderBridgeError("渲染器未运行(先 start())")
with self._lock:
rid = self._next_id
self._next_id += 1
req["id"] = rid
ev = threading.Event()
self._pending[rid] = (ev, {})
self._send_raw(req)
if not ev.wait(timeout):
self._pending.pop(rid, None)
raise RenderBridgeError(f"渲染器无响应({timeout}s):cmd={req.get('cmd')}")
_, resp = self._pending.pop(rid)
if not resp.get("_ok", True):
raise RenderBridgeError(resp.get("error", "未知错误"))
return resp
def _await_event(self, kind: str, timeout: float) -> dict:
"""等待一个主动事件(如 ready)。"""
deadline = time.time() + timeout
while time.time() < deadline:
if not self.running:
return {"_ok": False, "error": f"进程退出(code={self._proc.poll() if self._proc else '?'})"}
for rid, (ev, box) in list(self._pending.items()):
if box.get("_event") == kind and ev.is_set():
self._pending.pop(rid, None)
box["_ok"] = True
return box
time.sleep(0.05)
return {"_ok": False, "error": f"等待 {kind} 超时"}
def _send_raw(self, obj: dict) -> None:
if not self._proc or self._proc.stdin is None:
raise RenderBridgeError("stdin 不可用")
self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8"))
self._proc.stdin.flush()
def _read_loop(self) -> None:
assert self._proc and self._proc.stdout
for raw in self._proc.stdout:
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("{"):
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
rid = msg.get("id")
if rid is None and msg.get("event") == "ready":
# ready 事件:塞进 _pending 供 _await_event 取
ev = threading.Event()
box = dict(msg); box["_event"] = "ready"
self._pending[-1] = (ev, box)
ev.set()
continue
if rid in self._pending:
ev, box = self._pending[rid]
box.update(msg)
ev.set()
def _stderr_loop(self) -> None:
assert self._proc and self._proc.stderr
for raw in self._proc.stderr:
line = raw.decode("utf-8", "replace").rstrip()
self._stderr_tail.append(line)
if len(self._stderr_tail) > 200:
del self._stderr_tail[:100]
# ---------------- 便利 ----------------
@staticmethod
def discover_defaults(store: str | Path) -> dict:
"""从 store 目录推出 assets/assetIndex/gameDir 的默认值(照官方启动器布局)。"""
store = Path(store)
assets = store / "assets"
idx_dir = assets / "indexes"
asset_index = None
if idx_dir.is_dir():
names = sorted(p.stem for p in idx_dir.glob("*.json"))
asset_index = names[-1] if names else None
return {
"assets": str(assets),
"asset_index": asset_index,
"game_dir": str(store / "instances" / "default"),
}

81
scaffold/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,81 @@
# 架构分层:谁负责什么
用户口径(2026-09-25):**差异放在 Java 层,Python 相当于路由。**
## 三层
```
Python(mcfold/)
├── backends/__init__.py 路由:按 client jar 判世代 → 选后端 ← 唯一的 switch
├── backends/base.py 路由:NDJSON 协议、命令集、RLE 压缩
├── anvil.py 读取:存档 region/NBT → 方块(名字规格)
└── render.py 读取 + 编排:WorldRenderer
│
│ NDJSON(chunk 数据 / RLE 方块 / 相机 / 渲染指令)
▼
Java(scaffold/java/gen_1_20/ , gen_1_21/)
├── McfoldRenderServer.java 协议格式、调色板组装、位打包、入 cache、渲染
└── McfoldBridge.java 包私有 API 桥(reloadShaders / loadEffect)
│
▼
官方客户端 jar(各自版本,重映射为可读名)
```
## 分界原则
| 层 | 负责 | 为什么 |
|---|---|---|
| **Python** | 路由、存档读取、方块名→id 查询、RLE 压缩、编排 | 这些**不随 MC 版本变** |
| **Java** | 协议格式、调色板、位打包、官方序列化、渲染调用 | 这些**历代改过**,必须贴着版本走 |
### 判据:这个知识「随版本变吗」
| 知识 | 随版本变? | 归属 |
|---|---|---|
| 方块名 → state id | 变(mod/版本) | Java(问运行时注册表) |
| 世界尺寸(sectionsCount / minSection) | 变(1.16 是 16/0,1.20 是 24/-4) | Java(`cmd="capacity"`) |
| PalettedContainer 位打包 | 变过 | Java(官方 API) |
| LongArray 编码 | 变过 | Java(官方 API) |
| 区块入 cache 的路径 | 签名变过 | Java(官方 `replaceWithPacketData`) |
| **RLE 压缩** | **不变** | Python(只是压重复值) |
| **存档 NBT 结构** | 基本不变 | Python(读取) |
| **NDJSON 协议** | 不变(我们自己定的) | Python(路由层) |
## 数据流:渲染一个存档
```
1. Python: 读 level.dat + region/*.mca
2. Python: NBT → Sections[].block_states(palette + data)
3. Python: palette 名字(+属性) → "minecraft:oak_log[axis=y]" 规格
4. Python: 向后端批量查询规格 → state id [Java: BuiltInRegistries.BLOCK.asLookup()]
5. Python: state id 列表 → RLE [[id,count],...] ← 唯一被压缩的东西
6. Python: setblocks(cx, cz, sections) ← NDJSON
7. Java: LevelChunk + LevelChunkSection.setBlockState(...)
8. Java: 光照(queueSectionData 全亮)
9. Java: ClientboundLevelChunkPacketData(chunk) ← 官方序列化
10. Java: replaceWithPacketData(官方入口入 cache)
11. Java: gameRenderer.render(...)(世代差异在此)
12. Java: 读 FBO → PNG → base64 回传
```
**第 4/7/8/9/10/11 步都在 Java 侧** —— 版本相关知识全部集中在那里。
## 加新世代(用户 2026-09-25 定向的收益)
1. `scaffold/java/gen_<ver>/` 建目录,拷上一代源码
2. 对着 **javap 输出**改差异点(实测共 11 处,见 VERSION-DIFFS.md)
3. `mcfold/backends/gen_<ver>.py` 声明 `markers`(判据类)
4. `mcfold/backends/__init__.py` 的 `GENERATIONS` 加一条
5. 各自对**对应版本**的 named jar 编译
**Python 侧只动第 3、4 步**(各一处),协议与渲染逻辑无需碰。
## 已删除(防回归)
| 已删 | 原因 |
|---|---|
| `mcfold/render_bridge.py` | 被 `backends/` 取代(旧版无世代概念) |
| `render._pack/_long_array/_encode_paletted/_encode_chunk/_varint` | 协议编码下移到 Java |
| `anvil.encode_* / save_chunk_to_payload` | 同上 |
回归判据见 `tests/test_anvil.py::test_import_surface` —— 这些名字**不应再出现**在 Python 里。

View File

@ -37,6 +37,8 @@ import net.minecraft.world.level.LightLayer;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.DataLayer;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.dimension.DimensionType;
import org.lwjgl.opengl.GL11;
@ -180,6 +182,66 @@ public class McfoldRenderServer {
emit(id, r);
break;
}
case "setblocks": {
// ★ 世代无关:Python 只做路由,方块写入、调色板组装、入 cache
// 全部由 Java 用官方 API 完成 —— 版本差异不落回 Python。
// 载荷:sections[].rle = [[stateId,count],...],按 (y,z,x) 线性序覆盖该 section 4096 格。
// 优势:协议位打包(历代改过)不再由 Python 复制;入 cache 走官方
// ClientboundLevelChunkPacketData(LevelChunk) → getReadBuffer(),
// 签名两代一致,**无需反射**。
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz));
LevelChunkSection[] sections = chunk.getSections();
int placed = 0;
long cells = 0;
for (JsonElement se : req.getAsJsonArray("sections")) {
JsonObject so = se.getAsJsonObject();
int si = so.get("index").getAsInt();
if (si < 0 || si >= sections.length) continue;
LevelChunkSection sec = sections[si];
int x = 0, y = 0, z = 0;
for (JsonElement re : so.getAsJsonArray("rle")) {
JsonArray pair = re.getAsJsonArray();
int stateId = pair.get(0).getAsInt();
int count = pair.get(1).getAsInt();
BlockState st = stateId == 0 ? null : Block.stateById(stateId);
for (int i = 0; i < count; i++) {
if (st != null) { sec.setBlockState(x, y, z, st); placed++; }
cells++;
if (++x == 16) { x = 0; if (++z == 16) { z = 0; y++; } }
}
}
}
// 光照(无光照数据则全亮,否则地形全黑)
var light = level.getChunkSource().getLightEngine();
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
DataLayer sky = new DataLayer();
for (int lx = 0; lx < 16; lx++)
for (int ly = 0; ly < 16; ly++)
for (int lz = 0; lz < 16; lz++) sky.set(lx, ly, lz, 15);
light.queueSectionData(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
}
// ★ 官方序列化 → 官方入口入 cache(两代签名一致)
var pkt = new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData(chunk);
level.getChunkSource().replaceWithPacketData(cx, cz,
pkt.getReadBuffer(), pkt.getHeightmaps(),
new java.util.function.Consumer<
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput>() {
@Override public void accept(
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput t) { }
});
light.setLightEnabled(new ChunkPos(cx, cz), true);
light.runLightUpdates();
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("placed", placed);
r.addProperty("cells", cells);
r.addProperty("sections", sections.length);
r.addProperty("loadedChunks", level.getChunkSource().getLoadedChunksCount());
emit(id, r);
break;
}
case "camera": {
double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble();
float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f;
@ -283,6 +345,28 @@ public class McfoldRenderServer {
emit(id, r);
break;
}
case "capacity": {
// ★ 世界尺寸由运行时提供 —— Python 不硬编码版本相关常量。
// 这些方法都在 LevelHeightAccessor 上(Level 实现),两代 API 一致。
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("sectionsCount", level.getSectionsCount());
r.addProperty("minSection", level.getMinSection());
r.addProperty("maxSection", level.getMaxSection());
r.addProperty("minBuildHeight", level.getMinBuildHeight());
r.addProperty("maxBuildHeight", level.getMaxBuildHeight());
r.addProperty("height", level.getHeight());
// 生物群系默认 id(不硬编码 1)
try {
var bio = mc.level.registryAccess().registryOrThrow(Registries.BIOME);
var key = bio.getKey(bio.byId(1));
r.addProperty("defaultBiome", key != null ? key.toString() : "minecraft:plains");
} catch (Throwable t) {
r.addProperty("defaultBiome", "minecraft:plains");
}
emit(id, r);
break;
}
case "registry": {
JsonObject r = new JsonObject();
r.addProperty("ok", true);
@ -317,8 +401,12 @@ public class McfoldRenderServer {
// 而这个名字→id 的权威映射只有客户端注册表有。
// 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]}
JsonArray arr = req.getAsJsonArray("blocks");
// ★ parseForBlock 要 HolderLookup(不是 Registry)
var reg = mc.level.registryAccess().lookupOrThrow(Registries.BLOCK);
// ★ parseForBlock 要 HolderLookup(不是 Registry)。
// 用**静态注册表**的 asLookup():它是方块名→id 的权威来源
// (mod 方块也注册在这里),且两代 API 一致。
// 不用 mc.level.registryAccess() —— 1.21 上它来自 cookie,
// 只含 worldgen 那批注册表,查 block 会报 "Registry not found"(实测)。
var reg = BuiltInRegistries.BLOCK.asLookup();
JsonArray out = new JsonArray();
int unknown = 0;
for (JsonElement el : arr) {

View File

@ -37,6 +37,8 @@ import net.minecraft.world.level.LightLayer;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.DataLayer;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.dimension.DimensionType;
import org.lwjgl.opengl.GL11;
@ -180,6 +182,66 @@ public class McfoldRenderServer {
emit(id, r);
break;
}
case "setblocks": {
// ★ 世代无关:Python 只做路由,方块写入、调色板组装、入 cache
// 全部由 Java 用官方 API 完成 —— 版本差异不落回 Python。
// 载荷:sections[].rle = [[stateId,count],...],按 (y,z,x) 线性序覆盖该 section 4096 格。
// 优势:协议位打包(历代改过)不再由 Python 复制;入 cache 走官方
// ClientboundLevelChunkPacketData(LevelChunk) → getReadBuffer(),
// 签名两代一致,**无需反射**。
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz));
LevelChunkSection[] sections = chunk.getSections();
int placed = 0;
long cells = 0;
for (JsonElement se : req.getAsJsonArray("sections")) {
JsonObject so = se.getAsJsonObject();
int si = so.get("index").getAsInt();
if (si < 0 || si >= sections.length) continue;
LevelChunkSection sec = sections[si];
int x = 0, y = 0, z = 0;
for (JsonElement re : so.getAsJsonArray("rle")) {
JsonArray pair = re.getAsJsonArray();
int stateId = pair.get(0).getAsInt();
int count = pair.get(1).getAsInt();
BlockState st = stateId == 0 ? null : Block.stateById(stateId);
for (int i = 0; i < count; i++) {
if (st != null) { sec.setBlockState(x, y, z, st); placed++; }
cells++;
if (++x == 16) { x = 0; if (++z == 16) { z = 0; y++; } }
}
}
}
// 光照(无光照数据则全亮,否则地形全黑)
var light = level.getChunkSource().getLightEngine();
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
DataLayer sky = new DataLayer();
for (int lx = 0; lx < 16; lx++)
for (int ly = 0; ly < 16; ly++)
for (int lz = 0; lz < 16; lz++) sky.set(lx, ly, lz, 15);
light.queueSectionData(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
}
// ★ 官方序列化 → 官方入口入 cache(两代签名一致)
var pkt = new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData(chunk);
level.getChunkSource().replaceWithPacketData(cx, cz,
pkt.getReadBuffer(), pkt.getHeightmaps(),
new java.util.function.Consumer<
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput>() {
@Override public void accept(
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput t) { }
});
light.setLightEnabled(new ChunkPos(cx, cz), true);
light.runLightUpdates();
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("placed", placed);
r.addProperty("cells", cells);
r.addProperty("sections", sections.length);
r.addProperty("loadedChunks", level.getChunkSource().getLoadedChunksCount());
emit(id, r);
break;
}
case "camera": {
double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble();
float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f;
@ -283,6 +345,28 @@ public class McfoldRenderServer {
emit(id, r);
break;
}
case "capacity": {
// ★ 世界尺寸由运行时提供 —— Python 不硬编码版本相关常量。
// 这些方法都在 LevelHeightAccessor 上(Level 实现),两代 API 一致。
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("sectionsCount", level.getSectionsCount());
r.addProperty("minSection", level.getMinSection());
r.addProperty("maxSection", level.getMaxSection());
r.addProperty("minBuildHeight", level.getMinBuildHeight());
r.addProperty("maxBuildHeight", level.getMaxBuildHeight());
r.addProperty("height", level.getHeight());
// 生物群系默认 id(不硬编码 1)
try {
var bio = mc.level.registryAccess().registryOrThrow(Registries.BIOME);
var key = bio.getKey(bio.byId(1));
r.addProperty("defaultBiome", key != null ? key.toString() : "minecraft:plains");
} catch (Throwable t) {
r.addProperty("defaultBiome", "minecraft:plains");
}
emit(id, r);
break;
}
case "registry": {
JsonObject r = new JsonObject();
r.addProperty("ok", true);
@ -317,8 +401,12 @@ public class McfoldRenderServer {
// 而这个名字→id 的权威映射只有客户端注册表有。
// 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]}
JsonArray arr = req.getAsJsonArray("blocks");
// ★ parseForBlock 要 HolderLookup(不是 Registry)
var reg = mc.level.registryAccess().lookupOrThrow(Registries.BLOCK);
// ★ parseForBlock 要 HolderLookup(不是 Registry)。
// 用**静态注册表**的 asLookup():它是方块名→id 的权威来源
// (mod 方块也注册在这里),且两代 API 一致。
// 不用 mc.level.registryAccess() —— 1.21 上它来自 cookie,
// 只含 worldgen 那批注册表,查 block 会报 "Registry not found"(实测)。
var reg = BuiltInRegistries.BLOCK.asLookup();
JsonArray out = new JsonArray();
int unknown = 0;
for (JsonElement el : arr) {
@ -453,6 +541,10 @@ public class McfoldRenderServer {
}
static void render(Integer id, int frames, String out) throws Exception {
// ★ 1.21 实测:setLevel 会挂 ReceivingLevelScreen;
// 若渲染前它有残留,画面就是加载屏(distinctColors 停在 ~199)。
mc.setScreen(null);
mc.setOverlay(null);
tickFrames(frames);
MainTarget target = (MainTarget) mc.getMainRenderTarget();

View File

@ -1,6 +1,11 @@
"""anvil.py —— 存档读取与区块编码的单元测试。
"""Python 侧职责的单元测试。
不依赖 Java 渲染器;渲染链路的联测在 test_render_bridge.py。
★ 架构口径(用户 2026-09-25 定向)
Python 负责:路由、方块名→id 查询、RLE 压缩、存档读取
Java 负责: 协议格式、调色板组装、位打包、入 cache、渲染
所以这里**不再**测协议位打包(那已下移到 Java,由两代后端各自的编译验证覆盖);
改测 Python 真正负责的部分,尤其是 RLE 的边界与存档读取的真实数据。
"""
import struct
import sys
@ -8,90 +13,81 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mcfold.anvil import NbtReader, RegionFile, nbt_read, region_path, list_region_files
from mcfold.anvil import (
NbtReader, RegionFile, list_region_files, nbt_read, read_level_dat, region_path,
)
from mcfold.backends.base import rle_decode, rle_encode
from mcfold.render import (
_pack, _varint, _long_array, _decode_storage, _encode_paletted,
parse_save_chunk, SECTION_VOLUME, MIN_SECTION_Y,
BIOME_VOLUME, SECTION_VOLUME, SaveChunk, _props_to_spec, parse_save_chunk,
)
SAVE = Path("/home/mc-test/mc-test")
def test_varint_roundtrip():
def read_varint(b: bytes) -> tuple[int, int]:
v = 0; shift = 0; i = 0
while True:
byte = b[i]; i += 1
v |= (byte & 0x7F) << shift
if not (byte & 0x80):
break
shift += 7
return v, i
# ---------------------------------------------------------------- RLE(Python 的职责)
for n in [0, 1, 127, 128, 255, 16384, 2_097_152, 24135]:
enc = _varint(n)
dec, used = read_varint(enc)
assert dec == n, f"{n} -> {dec}"
assert used == len(enc)
print(" ✓ varint 编解码")
def test_rle_basic():
assert rle_encode([]) == []
assert rle_encode([5]) == [[5, 1]]
assert rle_encode([5, 5, 5]) == [[5, 3]]
assert rle_encode([1, 2, 1]) == [[1, 1], [2, 1], [1, 1]]
print(" ✓ RLE 基本情形")
def test_pack_unpack_roundtrip():
"""位打包必须能被 _decode_storage 解回来(值不跨 long 边界)。"""
for bits in [4, 5, 8, 15]:
vals = [(i * 7) % (1 << min(bits, 8)) for i in range(64 * 4)]
longs = _pack(vals, bits)
back = _decode_storage(longs, len(vals), bits)
assert back == vals, f"bits={bits} 不一致: {back[:8]} vs {vals[:8]}"
print(" ✓ 位打包往返一致(4/5/8/15 位)")
def test_rle_roundtrip():
cases = [
[0] * 4096,
[1] * 4096,
[0] * 4090 + [9] * 6,
list(range(256)) * 16,
[7 if i % 3 else 0 for i in range(4096)],
]
for v in cases:
assert rle_decode(rle_encode(v)) == v
print(" ✓ RLE 往返一致(含全同/交替/随机模式)")
def test_long_array_encoding():
"""LongArray = varint(长度) + 大端 i64 ×N。"""
enc = _long_array([1, -1])
assert enc[0] == 2 # 长度 2
assert enc[1:9] == (1).to_bytes(8, "big")
assert enc[9:17] == (0xFFFFFFFFFFFFFFFF).to_bytes(8, "big")
assert _long_array([])[0] == 0
print(" ✓ LongArray 编码(含负数/空)")
def test_rle_is_lossless_on_long_runs():
"""长游程必须无损 —— 一个 section 常有整片空气/石头。"""
v = [0] * 4000 + [1] * 96
enc = rle_encode(v)
assert len(enc) == 2, f"应压成 2 段,得到 {len(enc)}"
assert rle_decode(enc, expected=SECTION_VOLUME) == v
print(" ✓ 长游程无损压缩(4096 格 → 2 段)")
def test_paletted_single_value():
"""单值分支:byte(0) + varint(state) + varint(0 longs) —— 与 Java 对齐。"""
data = _encode_paletted([5] * SECTION_VOLUME, SECTION_VOLUME)
assert data[0] == 0, "bitsPerValue 应为 0"
assert data[1] == 5, "state id 应跟在 bits 后"
assert data[2] == 0, "longs 数应为 0"
print(" ✓ 单值调色板编码(3 字节)")
def test_rle_decode_length_check():
"""长度不符要报错,而不是静默给出错长度的列表(防下游越界)。"""
try:
rle_decode([[1, 3]], expected=4096)
except ValueError as e:
assert "4096" in str(e)
print(" ✓ RLE 长度校验")
return
raise AssertionError("长度不符时应抛 ValueError")
def test_paletted_indirect():
"""多值走本地调色板:bits + n + palette + longs。"""
vals = [0] * (SECTION_VOLUME - 1) + [3]
data = _encode_paletted(vals, SECTION_VOLUME)
bits = data[0]
assert bits >= 4, f"本地调色板 bits 至少 4,得到 {bits}"
n = data[1]
assert n == 2, f"调色板应有 2 项,得到 {n}"
print(f" ✓ 间接调色板编码(bits={bits}, palette={n})")
def test_rle_handles_bool_like_and_negative():
"""palette 索引可能是任意 int;不假设范围。"""
v = [-1, -1, 0, 0, 0]
assert rle_decode(rle_encode(v)) == v
print(" ✓ RLE 接受任意 int(含负值)")
# ---------------------------------------------------------------- NBT / 存档读取
def test_nbt_reader_primitives():
"""NBT 读取器的基本类型。"""
# Compound{ "a": Int(7), "b": String("hi") }
body = bytearray()
body += bytes([10]) # root: Compound
body += struct.pack(">H", 1) + b"R" # 名字 "R"
body += bytes([10]) + struct.pack(">H", 1) + b"R"
body += bytes([3]) + struct.pack(">H", 1) + b"a" + struct.pack(">i", 7)
body += bytes([8]) + struct.pack(">H", 1) + b"b" + struct.pack(">H", 2) + b"hi"
body += bytes([0]) # end
d = nbt_read(bytes(body))
assert d == {"a": 7, "b": "hi"}, d
body += bytes([0])
assert nbt_read(bytes(body)) == {"a": 7, "b": "hi"}
print(" ✓ NBT 基本类型(int/string)")
def test_nbt_nested_list():
"""嵌套 List/Compound(存档 sections 就是这个形状)。"""
"""嵌套 List[Compound] —— 存档 sections 就是这个形状。"""
body = bytearray()
body += bytes([10]) + struct.pack(">H", 0)
body += bytes([9]) + struct.pack(">H", 4) + b"list" + bytes([10]) + struct.pack(">i", 2)
@ -99,44 +95,49 @@ def test_nbt_nested_list():
body += bytes([3]) + struct.pack(">H", 1) + b"Y" + struct.pack(">i", y)
body += bytes([0])
body += bytes([0])
d = nbt_read(bytes(body))
assert d == {"list": [{"Y": 0}, {"Y": 1}]}, d
assert nbt_read(bytes(body)) == {"list": [{"Y": 0}, {"Y": 1}]}
print(" ✓ NBT 嵌套 List[Compound]")
def test_nbt_long_array():
"""区块高度图与 block_states.data 都是 LongArray。"""
body = bytearray()
body += bytes([10]) + struct.pack(">H", 0)
body += bytes([12]) + struct.pack(">H", 1) + b"d" + struct.pack(">i", 2)
body += struct.pack(">qq", -1, 1234567890123)
body += bytes([0])
d = nbt_read(bytes(body))
assert d == {"d": [-1, 1234567890123]}, d
print(" ✓ NBT LongArray(含负值)")
def test_props_to_spec():
from mcfold.render import _props_to_spec
assert _props_to_spec("minecraft:stone", None) == "minecraft:stone"
assert _props_to_spec("minecraft:oak_log", {"axis": "y"}) == "minecraft:oak_log[axis=y]"
# 多属性要排序(保证缓存命中稳定)
# 属性顺序必须稳定(否则 id 缓存命中率下降)
a = _props_to_spec("minecraft:oak_stairs", {"facing": "north", "half": "top"})
b = _props_to_spec("minecraft:oak_stairs", {"half": "top", "facing": "north"})
assert a == b == "minecraft:oak_stairs[facing=north,half=top]", a
assert a == b == "minecraft:oak_stairs[facing=north,half=top]"
print(" ✓ 方块名规格生成(属性排序稳定)")
def test_real_save_level_dat():
"""真实 level.dat:本机存档是该格式(直接 gzip,无 8 字节头)。"""
if not (SAVE / "level.dat").is_file():
print(" - 无测试存档,跳过")
return
from mcfold.anvil import read_level_dat
lv = read_level_dat(SAVE)
data = lv["Data"]
assert "LevelName" in data
assert isinstance(data.get("DataVersion"), int)
assert "Version" in data
assert "LevelName" in data and isinstance(data.get("DataVersion"), int)
print(f" ✓ 真实 level.dat({data['LevelName']}, DataVersion={data['DataVersion']})")
def test_real_region_chunk():
"""真实 region:读出区块 NBT,且有完整地形(不是全空气)。"""
if not (SAVE / "region").is_dir():
print(" - 无测试存档,跳过")
return
regs = list_region_files(SAVE)
assert regs, "应有 region 文件"
found = None
names = set()
for rf in regs[:4]:
for cx, cz in [(-1, -1), (0, 0), (8, -7)]:
try:
@ -144,25 +145,21 @@ def test_real_region_chunk():
except Exception:
continue
if raw:
found = nbt_read(raw)
break
if found:
n = nbt_read(raw)
for s in n.get("sections", []):
for p in (s.get("block_states") or {}).get("palette") or []:
names.add(p.get("Name"))
if names:
break
assert found is not None, "应至少读到一个区块"
names = set()
for s in found.get("sections", []):
for p in (s.get("block_states") or {}).get("palette") or []:
names.add(p.get("Name"))
assert len(names) > 1, f"完整区块应有多种方块,得到 {names}"
assert len(names) > 1, f"完整区块应含多种方块,得到 {names}"
print(f" ✓ 真实区块 NBT({len(names)} 种方块)")
def test_parse_save_chunk_shapes():
"""parse_save_chunk 输出的结构:states 长度、Y 范围、规格去重。"""
def test_parse_save_chunk_shape():
"""parse_save_chunk 的产物必须可直接喂 RLE(长度=4096,值是名字规格)。"""
if not (SAVE / "region").is_dir():
print(" - 无测试存档,跳过")
return
rf = None
nbt = None
for r in list_region_files(SAVE):
for cx, cz in [(-1, -1), (0, 0)]:
@ -173,7 +170,7 @@ def test_parse_save_chunk_shapes():
if raw:
n = nbt_read(raw)
if n.get("Status") == "minecraft:full":
rf, nbt = r, n
nbt = n
break
if nbt:
break
@ -184,47 +181,50 @@ def test_parse_save_chunk_shapes():
assert ch.sections, "应有 sections"
for sy, sec in ch.sections.items():
assert len(sec["states"]) == SECTION_VOLUME, f"Y={sy} states 长度 {len(sec['states'])}"
assert len(sec["biomes"]) == 64
assert sec["states"][0].startswith("minecraft:")
assert all(isinstance(s, str) and s.startswith("minecraft:") for s in sec["states"][:8])
assert ch.palette_specs == sorted(ch.palette_specs), "规格应去重且排序"
print(f" ✓ parse_save_chunk({len(ch.sections)} sections, {len(ch.palette_specs)} 规格)")
print(f" ✓ parse_save_chunk 产物可直接喂 RLE({len(ch.sections)} sections, "
f"{len(ch.palette_specs)} 规格)")
def test_section_y_mapping():
"""★ 关键回归:存档绝对 Y(-4..19) ↔ 载荷索引(0..23)。"""
assert MIN_SECTION_Y == -4, "1.20 的 minSection 是 -4"
# 载荷索引 0 应取存档 Y=-4;索引 23 应取存档 Y=19
save_y = {i - 4: {"states": ["minecraft:air"] * SECTION_VOLUME,
"biomes": ["minecraft:plains"] * 64, "palette": []}
for i in range(24)}
# 用真实编码器验证:只放 Y=-4 有 stone,则载荷 section 0 应是 stone
save_y[-4]["states"] = ["minecraft:stone"] * SECTION_VOLUME
save_y[-4]["palette"] = ["minecraft:stone"]
from mcfold.render import SaveChunk
def test_section_y_mapping_needs_runtime_value():
"""★ 回归:section 索引 ↔ 绝对 Y 的偏移必须是运行时值,不是硬编码。
class FakeWR:
def _encode_chunk(self, c, id_map):
return bytearray()
# 直接调编码核心:验证第 0 个 section 用的是 Y=-4
from mcfold.render import _encode_paletted
out = bytearray()
for sy in range(24):
sec = save_y.get(sy - 4)
ids = [1 if s == "minecraft:stone" else 0 for s in sec["states"]]
out += (sum(1 for v in ids if v) & 0xFFFF).to_bytes(2, "big")
out += _encode_paletted(ids, SECTION_VOLUME)
out += bytes([0]) + _varint(1) + _varint(0)
# 第一个 section 的 nonEmpty 应为 4096(不是 0)
assert struct.unpack_from(">h", out, 0)[0] == 4096, "索引 0 应映射到存档 Y=-4"
print(" ✓ section 索引 ↔ 绝对 Y 映射(-4..19 ↔ 0..23)")
1.20.x 是 minSection=-4;更早的版本不是。Python 只从后端取。
"""
from mcfold.render import FALLBACK_MIN_SECTION_Y, WorldRenderer
assert FALLBACK_MIN_SECTION_Y == -4, "降级默认值(真实值应来自后端 capacity)"
# WorldRenderer 的 min_section_y 优先用后端值
class FakeCap:
_capacity = {"minSection": -4, "sectionsCount": 24}
_biome_id = None
@property
def min_section_y(self):
return WorldRenderer.min_section_y.fget(self)
@property
def sections_count(self):
return WorldRenderer.sections_count.fget(self)
f = FakeCap()
assert f.min_section_y == -4
assert f.sections_count == 24
# 后端给不同值时跟随
class FakeCap2(FakeCap):
_capacity = {"minSection": 0, "sectionsCount": 16}
f2 = FakeCap2()
assert f2.min_section_y == 0, "应跟随后端值(1.16 时代 minSection=0)"
assert f2.sections_count == 16
print(" ✓ section 偏移取自运行时(不硬编码版本)")
def test_encode_empty_section_is_air():
"""缺失的 section 编成空气(不是跳过)—— 否则 Java 侧读越界。"""
data = _encode_paletted([0] * SECTION_VOLUME, SECTION_VOLUME)
assert data == bytes([0, 0, 0]), f"空气 section 应是 3 字节,得到 {len(data)}"
print(" ✓ 空 section 编码为空气")
def test_import_surface():
"""已下移到 Java 的协议编码函数不应再存在于 Python(防回归)。"""
import mcfold.render as R
for gone in ("_pack", "_long_array", "_encode_paletted", "_encode_chunk", "_varint"):
assert not hasattr(R, gone), f"{gone} 应已下移到 Java 侧"
# 读路径必须保留
assert hasattr(R, "_decode_storage")
assert hasattr(R, "parse_save_chunk")
print(" ✓ 模块面:写路径已下移,读路径保留")
if __name__ == "__main__":
@ -235,6 +235,9 @@ if __name__ == "__main__":
t()
except Exception as e:
failed += 1
import traceback
print(f" ✗ {t.__name__}: {e}")
print(f"\nanvil/render 单元测试: " + ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过"))
traceback.print_exc()
print(f"\nanvil/render 单元测试: "
+ ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过"))
sys.exit(1 if failed else 0)

View File

@ -1,10 +1,12 @@
"""渲染桥联测:Python ↔ Java 渲染器。
"""渲染联测:Python ↔ Java 后端。
需要:
- 已构建的 client-named.jar + McfoldRenderServer.class
- 环境变量 MCFOLD_RENDER_CLASSPATH、MCFOLD_NATIVES_DIR
- Xvfb DISPLAY
缺任一项则跳过(不误报失败)。
★ 架构口径:Python 只发 RLE(set_blocks),协议格式由 Java 侧组装。
"""
import os
import sys
@ -12,16 +14,15 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mcfold.render import (
SECTION_VOLUME, _encode_paletted, _varint, parse_save_chunk,
)
from mcfold.render_bridge import RenderBridge, RenderBridgeError
from mcfold.backends import Gen_1_20, backend_class
from mcfold.backends.base import BackendError, rle_encode
from mcfold.render import SECTION_VOLUME, WorldRenderer, parse_save_chunk
SAVE = Path("/home/mc-test/mc-test")
STORE = Path("/home/newqqagent/mcview/store/vanilla-1.20.1")
def _bridge_available() -> tuple[bool, str]:
def _available() -> tuple[bool, str]:
cp = os.environ.get("MCFOLD_RENDER_CLASSPATH")
if not cp:
return False, "未设 MCFOLD_RENDER_CLASSPATH"
@ -29,139 +30,181 @@ def _bridge_available() -> tuple[bool, str]:
if not any("client-named" in p for p in paths):
return False, "classpath 缺 client-named.jar"
if not any("McfoldRenderServer" in p or "classes" in p for p in paths):
return False, "classpath 缺 McfoldRenderServer.class"
return False, "classpath 缺编译产物"
if not os.environ.get("DISPLAY"):
return False, "无 DISPLAY"
return True, ""
def test_bridge_protocol():
"""桥的协议层:registry / lookup / resolve —— 不涉及渲染。"""
ok, why = _bridge_available()
def _open_backend():
ok, why = _available()
if not ok:
print(f" - 跳过({why})")
return
br = RenderBridge(
return None, why
br = Gen_1_20(
game_dir=str(STORE / "instances" / "default"),
assets=str(STORE / "assets"), asset_index="1.20.1",
)
try:
br.start()
except RenderBridgeError as e:
print(f" - 跳过(启动失败:{str(e)[:80]})")
except BackendError as e:
return None, f"启动失败:{str(e)[:100]}"
return br, ""
# ---------------------------------------------------------------- 世代分派
def test_generation_dispatch():
"""判据是 DeltaTracker 是否存在(不是硬编码版本号)。"""
jars = {
"1.20.1": "/tmp/client-1.20.1.jar",
"1.21.1": "/tmp/genb/client-1.21.1-named.jar",
}
present = {v: p for v, p in jars.items() if Path(p).is_file()}
if not present:
print(" - 无 named jar,跳过")
return
for ver, jar in present.items():
cls = backend_class(jar)
if ver == "1.20.1":
assert cls is Gen_1_20, f"{ver} 应选 Gen_1_20,得到 {cls.__name__}"
else:
# 1.21+ 必须有 DeltaTracker ⇒ 选 gen_1_21
from mcfold.backends import Gen_1_21
assert cls is Gen_1_21, f"{ver} 应选 Gen_1_21,得到 {cls.__name__}"
print(f" ✓ 世代分派 {ver} → {cls.__name__}")
def test_generation_metadata():
"""每个后端要能说清自己的 API 特征(加新世代时的对照依据)。"""
notes = Gen_1_20.api_notes()
assert "setLevel" in notes and "render" in notes
assert notes.get("deltaTracker") is False
from mcfold.backends import Gen_1_21
n21 = Gen_1_21.api_notes()
assert n21.get("deltaTracker") is True, "1.21+ 用 DeltaTracker"
assert "DeltaTracker" in n21["render"]
print(f" ✓ 世代元信息(1.20: {notes['render']})")
# ---------------------------------------------------------------- 协议层
def test_capacity_from_runtime():
"""★ 世界尺寸必须问后端,不是 Python 硬编码。"""
br, why = _open_backend()
if br is None:
print(f" - 跳过({why})")
return
try:
cap = br._request({"cmd": "capacity"})
assert cap["sectionsCount"] > 0, cap
assert "minSection" in cap and "maxSection" in cap
# 1.20.1 实测 24 / -4 / 20
assert cap["sectionsCount"] == 24, cap
assert cap["minSection"] == -4, cap
assert cap["maxSection"] == 20, cap
print(f" ✓ 运行时世界尺寸(sections={cap['sectionsCount']}, "
f"min={cap['minSection']}, biome={cap.get('defaultBiome')})")
finally:
br.stop()
def test_resolve_and_lookup():
"""方块名↔state id 是渲染器侧权威映射(Python 不复制这张表)。"""
br, why = _open_backend()
if br is None:
print(f" - 跳过({why})")
return
try:
reg = br.registry_info()
assert reg["ok"] and reg["blockCount"] > 500, reg
assert reg["stateCount"] > 1000, reg
# state id → 方块名(含属性)
st = br.lookup_state(1)
assert st["block"] == "minecraft:stone", st
assert st["inRange"] is True
# 方块名(+属性) → state id
r = br._request({"cmd": "resolve", "blocks": [
"minecraft:stone",
"minecraft:oak_log[axis=y]",
"minecraft:grass_block[snowy=false]",
"minecraft:this_block_does_not_exist",
]})
ids = r["ids"]
assert ids[0] > 0, "stone 应有有效 id"
assert ids[1] > 0, "oak_log[axis=y] 应可解析"
assert ids[2] > 0, "grass_block 应可解析"
assert ids[3] == -1, "不存在的方块应返回 -1"
assert r["unknown"] == 1
print(f" ✓ 桥协议(blocks={reg['blockCount']}, states={reg['stateCount']}, "
f"resolve 3 中 / 1 未知)")
assert reg["blockCount"] > 500 and reg["stateCount"] > 1000
assert br.lookup_state(1)["block"] == "minecraft:stone"
ids = br.resolve_blocks([
"minecraft:stone", "minecraft:oak_log[axis=y]",
"minecraft:grass_block[snowy=false]", "minecraft:no_such_block",
])
assert ids[0] > 0 and ids[1] > 0 and ids[2] > 0
assert ids[3] == -1, "未知方块应返回 -1"
print(f" ✓ 名字↔id(blocks={reg['blockCount']}, states={reg['stateCount']})")
finally:
br.stop()
def test_bridge_render_synthetic():
"""合成数据渲染:验证「注入 → 网格重建 → 出图」全链路。"""
ok, why = _bridge_available()
if not ok:
# ---------------------------------------------------------------- 渲染(新路径)
def test_set_blocks_render():
"""新路径:Python 只发 RLE,Java 组装协议 + 官方序列化 + 入 cache。"""
br, why = _open_backend()
if br is None:
print(f" - 跳过({why})")
return
br = RenderBridge(
game_dir=str(STORE / "instances" / "default"),
assets=str(STORE / "assets"), asset_index="1.20.1",
)
try:
br.start()
except RenderBridgeError as e:
print(f" - 跳过(启动失败:{str(e)[:80]})")
return
try:
# 先设视野中心(否则区块被丢弃)
br._request({"cmd": "viewport", "cx": 0, "cz": 0, "radius": 12})
cap = br._request({"cmd": "capacity"})
n_sec = cap["sectionsCount"]
stone, grass, log = br.resolve_blocks([
"minecraft:stone", "minecraft:grass_block[snowy=false]",
"minecraft:oak_log[axis=y]",
])
assert stone > 0 and grass > 0 and log > 0
# 造:地板 stone(索引 4 = y=0 = 世界 y=64)+ 中间一根原木
stone = br._request({"cmd": "resolve", "blocks": ["minecraft:stone"]})["ids"][0]
log = br._request({"cmd": "resolve", "blocks": ["minecraft:oak_log[axis=y]"]})["ids"][0]
assert stone > 0 and log > 0
payload = bytearray()
for sy in range(24):
ids = [0] * SECTION_VOLUME
if sy == 4:
ids = [stone] * SECTION_VOLUME # 地板(世界 y=64)
elif sy in (5, 6, 7):
for x in range(7, 9): # 中间 2x2 柱子
br.set_viewport(0, 0, radius=12)
sections = {}
for idx in range(n_sec):
v = [0] * SECTION_VOLUME
if idx < 4:
v = [stone] * SECTION_VOLUME # 地基
elif idx == 4:
v = [grass] * SECTION_VOLUME # 地表
elif idx == 5:
for x in range(7, 9): # 索引 = x + z*16 + y*256
for z in range(7, 9):
ids[x + z * 16] = log
non_empty = sum(1 for v in ids if v != 0)
payload += (non_empty & 0xFFFF).to_bytes(2, "big")
payload += _encode_paletted(ids, SECTION_VOLUME)
payload += bytes([0]) + _varint(1) + _varint(0)
v[x + z * 16] = log
sections[idx] = v
items = [(cx, cz, bytes(payload)) for cx in range(-3, 4) for cz in range(-3, 4)]
fed = br._request({"cmd": "chunks", "items": [
{"cx": cx, "cz": cz, "data": __import__("base64").b64encode(d).decode()}
for cx, cz, d in items
]}, timeout=180)
assert fed["failed"] == 0, f"注入失败: {fed.get('firstError')}"
assert fed["loadedChunks"] > 0, f"仓库为空: {fed}"
total_placed = 0
for cx in range(-3, 4):
for cz in range(-3, 4):
r = br.set_blocks(cx, cz, sections)
assert r["ok"], r
total_placed += r["placed"]
assert total_placed > 0, "应写入方块"
br.set_camera(8.0, 72.0, 8.0, yaw=45.0, pitch=25.0)
wr = WorldRenderer(br)
wr._capacity = cap
dbg = br.debug()
assert dbg["loadedChunks"] > 0, f"仓库为空: {dbg}"
br.set_camera(8.0, 72.0, 8.0, yaw=45.0, pitch=30.0)
res = br.render(frames=80)
assert res.width > 0 and res.height > 0
assert res.chunks_rendered > 0, f"没有区块被渲染: {res.raw}"
assert res.distinct_colors > 400, f"画面过单调: {res.distinct_colors}"
assert res.has_world_content is True
print(f" ✓ 合成数据渲染(chunks={res.chunks_rendered}, "
f"colors={res.distinct_colors}, {len(res.image)}B PNG)")
assert res.has_world_content
print(f" ✓ 新路径渲染(placed={total_placed}, chunks={res.chunks_rendered}, "
f"colors={res.distinct_colors})")
finally:
br.stop()
def test_bridge_render_real_save():
"""真实存档渲染:最终验收判据。"""
ok, why = _bridge_available()
def test_real_save_render():
"""真实存档经新路径(RLE 路由)渲染 —— 最终验收。"""
ok, why = _available()
if not ok:
print(f" - 跳过({why})")
return
if not (SAVE / "level.dat").is_file():
print(" - 跳过(无测试存档)")
return
from mcfold.render import WorldRenderer
wr = WorldRenderer.open(store=str(STORE), java="java")
try:
info = wr.load_save(SAVE, center=(8, -7), radius=4)
info = wr.load_save(SAVE, center=(8, -7), radius=3)
assert info["injected"] > 0, info
assert info["unknownBlocks"] == 0, f"有未解析方块: {info}"
wr.camera(x=130.0, y=95.0, z=-110.0, yaw=135.0, pitch=35.0)
dbg = wr.debug()
assert dbg["loadedChunks"] > 0, f"仓库为空: {dbg}"
assert dbg["playerChunk"] == "8,-7", f"相机区块不对: {dbg}"
res = wr.screenshot(frames=100)
d = wr.debug()
assert d["loadedChunks"] > 0, d
res = wr.screenshot(frames=90)
assert res.chunks_rendered > 0, res.raw
assert res.distinct_colors > 2000, f"真实地形应色彩丰富: {res.distinct_colors}"
assert res.has_world_content is True
assert res.has_world_content
print(f" ✓ 真实存档渲染(injected={info['injected']}, "
f"specs={info['uniqueSpecs']}, chunks={res.chunks_rendered}, "
f"colors={res.distinct_colors})")
@ -180,5 +223,6 @@ if __name__ == "__main__":
import traceback
print(f" ✗ {t.__name__}: {e}")
traceback.print_exc()
print(f"\n渲染桥联测: " + ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过"))
print(f"\n渲染联测: "
+ ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过"))
sys.exit(1 if failed else 0)