架构重构:差异全部下移到 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:
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.
@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
137
mcfold/anvil.py
137
mcfold/anvil.py
@ -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 解析交给调用方(需注册表)
|
||||
|
||||
Binary file not shown.
@ -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}
|
||||
|
||||
161
mcfold/render.py
161
mcfold/render.py
@ -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
|
||||
|
||||
@ -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
81
scaffold/ARCHITECTURE.md
Normal 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 里。
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user