世代分派体系: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 处)
This commit is contained in:
JianFeeeee
2026-09-25 18:21:08 +08:00
parent 34ed70ab53
commit 8c7d5b9d38
27 changed files with 1460 additions and 55 deletions

109
mcfold/backends/__init__.py Normal file
View File

@ -0,0 +1,109 @@
"""Java 后端分派 —— 按 MC 版本的 API 世代选择对应的渲染服务实现。
为什么用「世代」而不是「版本号」
--------------------------------
Java 是**编译期绑定**的:1.21 的 `render(DeltaTracker, boolean)` 里
DeltaTracker 在 1.20.1 的 jar 中不存在,所以两代的调用**无法写在同一份源码里**
(实测:世代 B 的调用对 1.20.1 jar 编译直接报 "cannot find symbol")。
⇒ 每个世代必须有**独立编译**的 Java 后端,各自对着自己版本的 client jar 编译。
Python 这层负责选后端 —— 这是「加 case 支持新版本」的落点。
实测世代边界(来自官方 client_mappings 逐版本对比)
--------------------------------------------------
| 方法 | 1.20.x | 1.21+ |
|---------------------|-------------------|--------------------------|
| setLevel | (ClientLevel) | (ClientLevel, Reason) |
| GameRenderer.render | (float,long,bool) | (DeltaTracker, bool) |
| 渲染计数 | countRendered-* | 同(1.20.2 起改名) |
判据:**DeltaTracker 类是否存在**(1.21 引入的时间抽象,正是签名变化的根源)。
这比硬编码版本号稳 —— 官方改版本号不影响判据。
"""
from __future__ import annotations
import zipfile
from dataclasses import dataclass
from pathlib import Path
# 世代的权威判据:这个类出现在哪个 version,就是那个世代
GEN_MARKERS = {
"gen_1_21": ["net/minecraft/client/DeltaTracker.class"],
"gen_1_20": [], # 兜底(无判据类 ⇒ 最老的世代)
}
@dataclass(frozen=True)
class Gen:
"""一个 API 世代。"""
name: str
min_version: str # 该世代起始版本(文档用)
markers: tuple[str, ...] # 判据类(任一存在即属于本世代)
backend: str # Python 后端模块名
java_dir: str # Java 源码目录名
GENERATIONS: list[Gen] = [
Gen(
name="gen_1_21",
min_version="1.21",
markers=tuple(GEN_MARKERS["gen_1_21"]),
backend="gen_1_21",
java_dir="gen_1_21",
),
]
def detect_generation(client_jar: str | Path) -> Gen | None:
"""从 client jar 判世代(看判据类是否存在)。找不到则返回 None(属最老世代)。"""
try:
with zipfile.ZipFile(client_jar) as z:
names = set(z.namelist())
except Exception:
return None
for g in GENERATIONS:
if any(m in names for m in g.markers):
return g
return None
def detect_from_store(store: str | Path) -> Gen | None:
"""从 store 目录找 client jar 并判世代。"""
store = Path(store)
for jar in sorted(store.glob("versions/*/*.jar")):
g = detect_generation(jar)
if g:
return g
return None
def backend_class(client_jar: str | Path | None = None,
generation: str | None = None):
"""选中后端类。
★ 这是「Python 层 switch」的落点:加新世代只需在下面加一条 elif。
"""
if generation is None:
generation = backend_for(client_jar) if client_jar else "gen_1_20"
if generation == "gen_1_21":
from .gen_1_21 import Gen_1_21
return Gen_1_21
from .gen_1_20 import Gen_1_20
return Gen_1_20
def backend_for(client_jar: str | Path) -> str:
"""判世代并返回后端模块名。"""
g = detect_generation(client_jar)
if g is None:
return "gen_1_20" # 无判据类 ⇒ 最老世代
return g.backend
def list_generations() -> list[tuple[str, str, str]]:
"""(世代名, 版本区间, Java 目录) —— 供诊断/文档用。"""
return [
("gen_1_20", "1.20.x(实测 1.20.1/1.20.2)", "java/gen_1_20"),
("gen_1_21", "1.21+", "java/gen_1_21"),
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

350
mcfold/backends/base.py Normal file
View File

@ -0,0 +1,350 @@
"""渲染后端基类 —— 世代无关的协议与生命周期。
设计(用户 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}")

View File

@ -0,0 +1,37 @@
"""1.20.x 渲染后端。
对应 Java 源码:scaffold/java/gen_1_20/
编译目标:1.20.x 的 client jar(重映射为可读名后)
API 特征(实测):
setLevel(ClientLevel)
GameRenderer.render(float partialTick, long nanoTime, boolean renderLevel)
LevelRenderer.countRenderedChunks()
"""
from __future__ import annotations
from .base import RenderBackend
class Gen_1_20(RenderBackend):
"""1.20.x 系列(含 1.20 / 1.20.1 / 1.20.2 / 1.20.3 / 1.20.4 / 1.20.5 / 1.20.6)。"""
generation = "gen_1_20"
# ★ 诚实标注:仅实测过 1.20.1 与 1.20.2(以及 1.20.1 的 mappings 对比)。
# 1.16~1.19 未验证 —— 不要因为「看起来同代」就宣称支持。
verified_versions = ("1.20.1", "1.20.2")
min_mc_version = "(未验证)"
max_mc_version = "1.20.6" # 1.21 起换世代(由 DeltaTracker 判据确定)
# 该世代的 Java 源码目录(相对 scaffold/java/)
java_dir = "java/gen_1_20"
# 判据类(用于自动识别)
markers: tuple[str, ...] = ()
def api_notes(self) -> dict:
return {
"setLevel": "Minecraft.setLevel(ClientLevel)",
"render": "GameRenderer.render(float, long, boolean)",
"count": "LevelRenderer.countRenderedChunks()",
"deltaTracker": False,
}

View File

@ -0,0 +1,36 @@
"""1.21+ 渲染后端。
对应 Java 源码:scaffold/java/gen_1_21/
编译目标:1.21.x 的 client jar(重映射为可读名后)
API 特征(实测,与世代 A 的差异):
setLevel(ClientLevel, ReceivingLevelScreen$Reason) ← 多一个参数
GameRenderer.render(DeltaTracker, boolean) ← 签名整个换了
Minecraft.getTimer() → DeltaTracker ← 1.21 引入
LevelRenderer.countRenderedSections() ← 1.20.2 起改名
★ 这些差异**无法**写在与 1.20 相同的源码里:
DeltaTracker 在 1.20.x 的 jar 中不存在 ⇒ 编译期即失败。
"""
from __future__ import annotations
from .base import RenderBackend
class Gen_1_21(RenderBackend):
"""1.21+ 系列。"""
generation = "gen_1_21"
min_mc_version = "1.21"
max_mc_version = ""
java_dir = "java/gen_1_21"
markers = ("net/minecraft/client/DeltaTracker.class",)
def api_notes(self) -> dict:
return {
"setLevel": "Minecraft.setLevel(ClientLevel, ReceivingLevelScreen$Reason)",
"render": "GameRenderer.render(DeltaTracker, boolean)",
"count": "LevelRenderer.countRenderedSections()",
"deltaTracker": True,
"source": "Minecraft.getTimer()",
}

View File

@ -25,7 +25,8 @@ from pathlib import Path
from typing import Any, Iterable
from . import anvil
from .render_bridge import RenderBridge, RenderBridgeError, RenderResult
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
@ -182,7 +183,7 @@ def parse_save_chunk(cx: int, cz: int, nbt: dict) -> SaveChunk:
class WorldRenderer:
"""世界 → 画面。持有一个常驻 Java 渲染器进程。"""
def __init__(self, bridge: RenderBridge, instance: Any | None = None):
def __init__(self, bridge: RenderBackend, instance: Any | None = None):
self.bridge = bridge
self.instance = instance
self._spec_cache: dict[str, int] = {}
@ -194,7 +195,8 @@ class WorldRenderer:
@classmethod
def open(cls, instance: Any = None, *, store: str | None = None,
classpath: str | None = None, start: bool = True,
java: str = "java") -> "WorldRenderer":
java: str = "java", client_jar: str | None = None,
generation: str | None = None) -> "WorldRenderer":
"""从 instance(mcfold.instance.Instance)或 store 目录打开渲染器。
classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH;
@ -218,7 +220,10 @@ class WorldRenderer:
asset_index = names[-1] if names else None
game_dir = game_dir or str(store_p / "instances" / "default")
bridge = RenderBridge(
# ★ Python 层 switch:按 client jar 的世代选后端
# (1.21+ 的 DeltaTracker 在 1.20.x jar 中不存在 ⇒ 必须独立编译的后端)
cls = backend_class(client_jar=client_jar, generation=generation)
bridge = cls(
java=java, classpath=classpath,
game_dir=game_dir, assets=assets, asset_index=asset_index,
)

View File

@ -213,14 +213,47 @@ class RenderBridge:
return self._request({"cmd": "camera", "x": x, "y": y, "z": z,
"yaw": yaw, "pitch": pitch})
def set_view(self, *, camera_type: str = "THIRD_PERSON_BACK",
def set_view(self, *, camera_type: str | None = "THIRD_PERSON_BACK",
render_distance: int | None = None,
fov: float | None = None) -> dict:
req: dict[str, Any] = {"cmd": "view", "cameraType": camera_type}
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)
# ---------------- 渲染 ----------------