Files
agentmc/mcfold/render_bridge.py
JianFeeeee 8c7d5b9d38 世代分派体系:Python 层 switch + 各世代独立编译的 Java 后端
用户定向:在 python 层写 switch,调用不同 java 后端(而非一个文件里 switch)。

新增:
  mcfold/backends/{__init__,base,gen_1_20,gen_1_21}.py  后端体系与分派
  scaffold/java/gen_1_20/  对 1.20.x client jar 编译
  scaffold/java/gen_1_21/  对 1.21.1 client jar 编译(✅ 实测编译通过)
  scaffold/java/VERSION-DIFFS.md  实测差异全表

判据:DeltaTracker 类是否存在(1.21 引入,正是 render 签名变化的根源)
  ⇒ 比硬编码版本号稳

实测差异共 11 处(21 个检查点里 13 个跨世代完全一致):
  setLevel / GameRenderer.render / countRendered* / createPackRepository /
  ResourceLocation 构造 / enableDataFixerOptimizations(移除) / User 构造 /
  ChunkStatus 包路径 / ClientPacketListener 构造(参数个数语义都变) /
  loadEffect(变 private)

为什么必须分后端:DeltaTracker 在 1.20.x jar 中不存在,
两代调用无法共存于一份源码(实测编译报 cannot find symbol)。

1.21.1 反混淆已验证(29MB named jar,DeltaTracker 3 处)
2026-09-25 18:21:08 +08:00

387 lines
15 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.

"""渲染桥:把 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"),
}