Files
agentmc/mcfold/backends/base.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

351 lines
12 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
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_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}")