Files
agentmc/mcfold/backends/base.py
JianFeeeee 16f2e04fff 架构重构:差异全部下移到 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(分层原则 + 加新世代步骤 + 防回归清单)
2026-09-25 19:21:45 +08:00

393 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""渲染后端基类 —— 世代无关的协议与生命周期。
设计(用户 2026-09-25 定向):
Python 层做 switch 选择后端;每个世代一个**独立编译**的 Java 服务端。
原因:Java 是编译期绑定的,世代间的签名差异(DeltaTracker 等)无法写在同一份源码里。
本基类承载所有世代共用的部分:
- NDJSON 通道(子进程 + 行分隔 JSON)
- 命令集(chunk / camera / resolve / render / registry / debug / view)
- base64 载荷编解码
子类只需声明世代特征(Java 目录、判据类、API 差异说明)。
"""
from __future__ import annotations
import base64
import json
import os
import subprocess
import threading
import time
from dataclasses import dataclass, field
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):
"""后端不可用或渲染失败。"""
@dataclass
class RenderResult:
"""一次渲染的结果。
distinct_colors 是本项目实测最锐利的判据:
空屏/纯色 1-2 色 / 加载屏 ~199 / 简单地形 ~1450 / 真实存档 >10000
(non-black 会被红底加载屏骗过 —— 实测踩过)
"""
image: bytes | None = None
path: str | None = None
width: int = 0
height: int = 0
chunks_rendered: int = 0
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:
return self.distinct_colors > 400 and self.chunks_rendered > 0
class RenderBackend:
"""所有世代后端共用的实现。子类覆写类属性即可。"""
generation: str = "base"
min_mc_version: str = ""
max_mc_version: str = ""
java_dir: str = ""
markers: tuple[str, ...] = ()
main_class: str = "McfoldRenderServer"
def __init__(
self,
java: str = "java",
classpath: str | list[str] | None = None,
game_dir: str | None = None,
assets: str | None = None,
asset_index: str | None = None,
java_args: list[str] | None = None,
startup_timeout: float = 240.0,
cwd: str | None = None,
):
self.java = java
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.game_dir = game_dir
self.assets = assets
self.asset_index = asset_index
self.startup_timeout = startup_timeout
self.cwd = cwd
self.java_args = java_args or [
"-Xmx1200M", "-Xms512M", "-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._stderr_tail: list[str] = []
# ---------------- 生命周期 ----------------
@property
def running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def start(self) -> "RenderBackend":
if self.running:
return self
if not self.classpath:
raise BackendError(
f"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH,"
f"或先跑 scaffold/build.sh 编译 {self.generation} 后端。"
)
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)
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 BackendError(f"无法启动 java: {e}") from e
threading.Thread(target=self._read_loop, daemon=True).start()
threading.Thread(target=self._stderr_loop, daemon=True).start()
ready = self._await_event("ready", self.startup_timeout)
if not ready.get("_ok"):
self.stop()
tail = "".join(self._stderr_tail[-6:])
raise BackendError(
f"{self.generation} 后端启动失败:{ready.get('error') or '超时'}"
+ (f"\n{tail}" if 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:
try:
self._send_raw({"cmd": "shutdown"})
p.wait(timeout=timeout)
except subprocess.TimeoutExpired:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
except Exception:
try:
p.kill()
except Exception:
pass
finally:
self._proc = None
def __enter__(self):
return self.start()
def __exit__(self, *exc):
self.stop()
# ---------------- 数据注入 ----------------
def feed_chunk(self, cx: int, cz: int, chunk_data: bytes) -> dict:
return self._request({"cmd": "chunk", "cx": cx, "cz": cz,
"data": self._b64(chunk_data)})
def feed_chunks(self, chunks: list[tuple[int, int, bytes]]) -> dict:
return self._request({
"cmd": "chunks",
"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}
if radius is not None:
req["radius"] = radius
return self._request(req)
# ---------------- 视角与视觉 ----------------
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, **kw) -> dict:
"""视觉设置。各世代支持的项见子类 api_notes()。
世代无关的项:camera_type / render_distance / fov
原版画质(所有世代都有):graphics_mode / ambient_occlusion /
mipmap_levels / gamma / entity_shadows / clouds / particles
后处理链:post_effect(原版 PostChain,非第三方光影包)
"""
req: dict[str, Any] = {"cmd": "view"}
mapping = {
"camera_type": "cameraType", "render_distance": "renderDistance",
"fov": "fov", "graphics_mode": "graphicsMode",
"ambient_occlusion": "ambientOcclusion", "mipmap_levels": "mipmapLevels",
"gamma": "gamma", "entity_shadows": "entityShadows",
"clouds": "clouds", "particles": "particles",
"post_effect": "postEffect",
}
for py, js in mapping.items():
if py in kw and kw[py] is not None:
req[js] = kw[py]
return self._request(req)
# ---------------- 查询 ----------------
def registry_info(self) -> dict:
return self._request({"cmd": "registry"})
def lookup_state(self, state_id: int) -> dict:
return self._request({"cmd": "lookup", "stateId": state_id})
def resolve_blocks(self, specs: list[str]) -> list[int]:
"""方块名(+属性) → state id。名字→id 的权威映射在客户端注册表。"""
if not specs:
return []
return self._request({"cmd": "resolve", "blocks": specs})["ids"]
def debug(self) -> dict:
return self._request({"cmd": "debug"})
# ---------------- 渲染 ----------------
def render(self, out: str | None = None, frames: int = 60,
width: int | None = None, height: int | None = None) -> RenderResult:
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=600.0)
img = resp.pop("image", None)
cam = resp.get("camera") or (0.0, 0.0, 0.0)
if not isinstance(cam, (list, tuple)):
cam = (0.0, 0.0, 0.0)
return RenderResult(
image=base64.b64decode(img) if img 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(float(c) for c in cam[:3]),
warnings=resp.get("warnings", []),
raw=resp,
)
# ---------------- 内部 ----------------
@staticmethod
def _b64(b: bytes) -> str:
return base64.b64encode(b).decode("ascii")
def _request(self, req: dict, timeout: float = 180.0) -> dict:
if not self.running:
raise BackendError("后端未运行(先 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 BackendError(f"后端无响应({timeout}s):cmd={req.get('cmd')}")
_, resp = self._pending.pop(rid)
if not resp.get("_ok", True):
raise BackendError(resp.get("error", "未知错误"))
return resp
def _await_event(self, kind: str, timeout: float) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
if not self.running:
code = self._proc.poll() if self._proc else "?"
return {"_ok": False, "error": f"进程退出(code={code})"}
box = self._pending.pop(-1, None)
if box is not None:
ev, payload = box
if payload.get("_event") == kind:
return {"_ok": True, **payload}
self._pending[-1] = (ev, payload) # 不是目标事件,放回
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 BackendError("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
if msg.get("event") == "ready":
ev = threading.Event()
box = dict(msg); box["_event"] = "ready"
self._pending[-1] = (ev, box)
ev.set()
continue
rid = msg.get("id")
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) > 300:
del self._stderr_tail[:150]
# ---------------- 元信息 ----------------
def api_notes(self) -> dict:
"""本世代的 API 特征(子类覆写)。"""
return {}
def describe(self) -> str:
return (f"{self.generation} [{self.min_mc_version}..{self.max_mc_version}] "
f"java_dir={self.java_dir}")