世代分派体系: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

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.

View File

@ -1,6 +1,6 @@
[17:42:09] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 113 milliseconds
[17:42:10] [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:42:10] [main/ERROR]: Failed to verify authentication
[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
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:452) ~[classes/:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:517) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[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:42:10] [main/INFO]: Setting user: mcfold_cam
[17:42:10] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7
[17:42:10] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers.
[17:42:10] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it
[17:42:11] [main/INFO]: Reloading ResourceManager: vanilla
[17:42:11] [Worker-Main-11/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[17:42:11] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed
[17:43:11] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager
[17:43:11] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play
[17:43:11] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break
[17:43:11] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music
[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
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,23 +40,24 @@ 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:523) ~[classes/:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:456) ~[classes/:?]
at McfoldRenderServer.reloadResources(McfoldRenderServer.java:588) ~[classes/:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:521) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?]
[17:43:11] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[17:43:12] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas
[17:43:12] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas
[17:43:12] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas
[17:43:12] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas
[17:43:12] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas
[17:43:12] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas
[17:43:12] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas
[17:43:12] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas
[17:43:12] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas
[17:43:12] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[17:43:12] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas
[17:43:12] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas
[17:43:12] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas
[17:43:12] [main/INFO]: [STDERR]: [mcfold] resources reloaded
[17:43:13] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[17:43:13] [main/INFO]: [STDERR]: [mcfold] boot done
[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.

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)
# ---------------- 渲染 ----------------

View File

@ -0,0 +1,56 @@
# 世代差异清单(实测,非推断)
来源:官方 `client_mappings.txt` 逐版本对比(1.20.1 / 1.20.2 / 1.21.1),
以及两侧源码对各自 client jar 的实际编译结果。
## 判据
**`net.minecraft.client.DeltaTracker` 类是否存在** ⇒ 1.21+。
这是 1.21 引入的时间抽象,也正是 `GameRenderer.render` 签名变化的根源。
比硬编码版本号稳(官方改版本号不影响判据)。
实现:`mcfold/backends/__init__.py:detect_generation()`
## 差异全表(8 处,覆盖 21 个检查点中的 13 个「完全一致」之外的部分)
| # | API | 1.20.x | 1.21+ | 处理 |
|---|---|---|---|---|
| 1 | `Minecraft.setLevel` | `(ClientLevel)` | `(ClientLevel, ReceivingLevelScreen$Reason)` | gen_1_21 传 `Reason.OTHER` |
| 2 | `GameRenderer.render` | `(float,long,boolean)` | `(DeltaTracker, boolean)` | gen_1_21 用 `mc.getTimer()` |
| 3 | 区块计数 | `countRenderedChunks()` | `countRenderedSections()` | 各世代用各自名字 |
| 4 | 同理 | `hasRenderedAllChunks()` | `hasRenderedAllSections()` | 同上 |
| 5 | `ServerPacksSource.createPackRepository` | `(Path)` | `(Path, DirectoryValidator)` | gen_1_21 构造 validator |
| 6 | `ResourceLocation` 构造 | `new ResourceLocation(s)` | `ResourceLocation.parse(s)` | 静态工厂 |
| 7 | `SharedConstants.enableDataFixerOptimizations` | 有 | **已移除** | gen_1_21 不调 |
| 8 | `User` 构造 | `(String,String sessionId,...)` | `(String,UUID,...)` | 传 UUID |
| 9 | `ChunkStatus` | `world.level.chunk.ChunkStatus` | `world.level.chunk.status.ChunkStatus` | 包路径变了 |
| 10 | `ClientPacketListener` 构造 | `(Minecraft,Screen,Connection,ServerData,GameProfile,Telemetry)` | `(Minecraft,Connection,CommonListenerCookie)` | ★ 参数个数与语义都变 |
| 11 | `GameRenderer.loadEffect` | package-private(同包可调) | **private**(同包也调不到) | gen_1_21 用反射 |
★ 关键结论:**差异集中在 11 处,而不是「每个版本一套」**。
21 个检查点里 13 个(如 `tick`/`allChanged`/`replaceWithPacketData`/
`updateViewCenter`/`getChunkSource`/`PackRepository.*`)跨世代**完全一致**。
## 为什么必须分后端(而不是一个文件里 switch)
Java 编译期绑定签名。实测:
```
世代 B 的调用对 1.20.1 jar 编译:
error: cannot find symbol class DeltaTracker
```
`DeltaTracker` 在 1.20.x 的 jar 里不存在 ⇒ 两代的调用**无法共存于一份源码**。
所以每个世代独立编译,Python 层做分派。
## 加新世代的做法(用户 2026-09-25 定向)
1. `scaffold/java/gen_<ver>/` 建目录,拷上一代源码
2. 按上表改差异点(对着 **javap 输出**改,不要凭印象写 API 名)
3. `mcfold/backends/gen_<ver>.py` 声明 `markers`(判据类)
4. `mcfold/backends/__init__.py` 的 `GENERATIONS` 加一条
5. 各自对**对应版本**的 named jar 编译
⚠️ 教训(本轮踩过):`Reason.LEVEL_LOAD`、`DirectoryValidator.NONE`
都是凭印象写出来的、实际不存在的 API;`loadEffect` 的可见性也没验证。
**改之前先 `javap`。**

View File

@ -222,8 +222,64 @@ public class McfoldRenderServer {
if (req.has("fov")) {
mc.options.fov().set(req.get("fov").getAsInt());
}
// ── 原版画质选项(光影的基础层)──
if (req.has("graphicsMode")) {
mc.options.graphicsMode().set(net.minecraft.client.GraphicsStatus
.valueOf(req.get("graphicsMode").getAsString()));
mc.levelRenderer.allChanged(); // FANCY 会启用更多渲染层
}
if (req.has("ambientOcclusion")) {
mc.options.ambientOcclusion().set(req.get("ambientOcclusion").getAsBoolean());
mc.levelRenderer.allChanged();
}
if (req.has("mipmapLevels")) {
mc.options.mipmapLevels().set(req.get("mipmapLevels").getAsInt());
}
if (req.has("gamma")) {
mc.options.gamma().set(req.get("gamma").getAsDouble());
}
if (req.has("entityShadows")) {
mc.options.entityShadows().set(req.get("entityShadows").getAsBoolean());
}
if (req.has("clouds")) {
mc.options.cloudStatus().set(net.minecraft.client.CloudStatus
.valueOf(req.get("clouds").getAsString()));
}
if (req.has("particles")) {
mc.options.particles().set(net.minecraft.client.ParticleStatus
.valueOf(req.get("particles").getAsString()));
}
// ── 原版后处理链(PostChain 基础设施)──
if (req.has("postEffect")) {
String eff = req.get("postEffect").getAsString();
if (eff.isEmpty() || eff.equals("none")) {
mc.gameRenderer.shutdownEffect();
} else {
// loadEffect 是包私有 → 走同包 bridge
net.minecraft.client.renderer.McfoldBridge.loadEffect(
mc.gameRenderer, new ResourceLocation(eff));
}
}
// 回包带上真实生效值,便于验证
JsonObject r = new JsonObject();
r.addProperty("ok", true);
JsonObject cur = new JsonObject();
try {
java.lang.reflect.Field pf = net.minecraft.client.renderer.GameRenderer.class
.getDeclaredField("postEffect");
pf.setAccessible(true);
cur.addProperty("postEffect", pf.get(mc.gameRenderer) != null ? "active" : "none");
} catch (Throwable ignore) {
cur.addProperty("postEffect", "unknown");
}
cur.addProperty("graphicsMode", mc.options.graphicsMode().get().name());
cur.addProperty("ambientOcclusion", mc.options.ambientOcclusion().get());
cur.addProperty("mipmapLevels", mc.options.mipmapLevels().get());
cur.addProperty("gamma", mc.options.gamma().get());
cur.addProperty("entityShadows", mc.options.entityShadows().get());
cur.addProperty("clouds", mc.options.cloudStatus().get().name());
cur.addProperty("renderDistance", mc.options.getEffectiveRenderDistance());
r.add("current", cur);
emit(id, r);
break;
}

View File

@ -0,0 +1,23 @@
package net.minecraft.client.renderer;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceProvider;
/**
* 包私有成员桥。
*
* GameRenderer 里几个关键方法不是 public,官方只在客户端内部调用;
* 我们不跑主循环、不连服,所以要用同包类直接调。
* reloadShaders —— 官方由匿名内部类 GameRenderer$1(资源重载 listener)调
* loadEffect —— 官方由 onResourceManagerReload / 药水效果改
*/
public class McfoldBridge {
public static void reloadShaders(GameRenderer gr, ResourceProvider provider) {
gr.reloadShaders(provider);
}
/** 载入一个原版后处理链(如 minecraft:shaders/post/invert.json)。 */
public static void loadEffect(GameRenderer gr, ResourceLocation id) {
gr.loadEffect(id);
}
}

View File

@ -0,0 +1,676 @@
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.blaze3d.pipeline.MainTarget;
import com.mojang.blaze3d.platform.DisplayData;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.PropertyMap;
import net.minecraft.SharedConstants;
import net.minecraft.client.Minecraft;
import net.minecraft.client.User;
import net.minecraft.client.main.GameConfig;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.telemetry.TelemetryEventSender;
import net.minecraft.client.telemetry.WorldSessionTelemetryManager;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.Connection;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.profiling.InactiveProfiler;
import net.minecraft.world.Difficulty;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
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.dimension.DimensionType;
import org.lwjgl.opengl.GL11;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.Proxy;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.UUID;
/**
* mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。
*
* 设计约束(用户 2026-09-25):
* - 不起完整客户端:不跑主循环、不连服、不登录、不作玩家
* - 我们的世界数据喂给官方渲染器出图
*
* 协议(stdin/stdout 各一行一个 JSON):
* → {"id":1,"cmd":"chunk","cx":0,"cz":0,"data":"<b64>","heightmaps":null}
* → {"id":2,"cmd":"camera","x":8,"y":70,"z":8,"yaw":0,"pitch":0}
* → {"id":3,"cmd":"render","frames":60,"out":"/tmp/a.png"}
* ← {"id":3,"ok":true,"width":854,"height":480,"chunksRendered":29,
* "distinctColors":1450,"image":"<b64 png>"}
*
* 启动就绪时主动发 {"event":"ready"}。
*
* ★ 关键实现点(全部实测得来,详见 scaffold/README.md):
* 1. tryDetectVersion 必须先于 Bootstrap.bootStrap
* 2. FolderData 顺序 (gameDir, resourcePackDir, assets, assetIndex),assetIndex 用落盘名
* 3. 资源必须手工同步重载(各 listener 的 public reload()),不能 join Minecraft 的
* 4. LoadingOverlay + ReceivingLevelScreen 都要清,否则渲出加载屏
* 5. 每帧 mc.tick() 才能推进区块网格重建队列
* 6. 读像素必须绑 FBO
* 7. chunk 数据走官方 replaceWithPacketData(与网络包同路径)
*/
public class McfoldRenderServer {
static final Gson GSON = new Gson();
static final PrintStream OUT = new PrintStream(
new java.io.FileOutputStream(java.io.FileDescriptor.out), true, StandardCharsets.UTF_8);
static Minecraft mc;
static ClientLevel level;
static int width = 854, height = 480;
public static void main(String[] args) throws Exception {
// stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr
System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true));
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
String assetIdx = arg(args, "--assetIndex", "1.20.1");
width = Integer.parseInt(arg(args, "--width", "854"));
height = Integer.parseInt(arg(args, "--height", "480"));
try {
boot(gameDir, assets, assetIdx);
} catch (Throwable t) {
emitError(null, "启动失败: " + t);
t.printStackTrace(System.err);
System.exit(1);
}
event("ready", null);
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line;
while ((line = in.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
JsonObject req;
try {
req = JsonParser.parseString(line).getAsJsonObject();
} catch (Exception e) {
emitError(null, "非法 JSON: " + e.getMessage());
continue;
}
Integer id = req.has("id") ? req.get("id").getAsInt() : null;
try {
handle(id, req);
} catch (Throwable t) {
emitError(id, t.toString());
t.printStackTrace(System.err);
}
}
System.exit(0);
}
// ---------------- 协议处理 ----------------
static void handle(Integer id, JsonObject req) throws Exception {
String cmd = req.get("cmd").getAsString();
switch (cmd) {
case "shutdown": {
JsonObject r = new JsonObject(); r.addProperty("ok", true);
emit(id, r);
System.exit(0);
}
case "chunk": {
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
byte[] data = decode(req.get("data").getAsString());
var resp = applyChunk(cx, cz, data);
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.add("result", resp);
emit(id, r);
break;
}
case "chunks": {
JsonArray items = req.getAsJsonArray("items");
int ok = 0, failed = 0;
String firstErr = null;
for (JsonElement el : items) {
JsonObject it = el.getAsJsonObject();
try {
applyChunk(it.get("cx").getAsInt(), it.get("cz").getAsInt(),
decode(it.get("data").getAsString()));
ok++;
} catch (Throwable t) {
failed++;
if (firstErr == null) {
firstErr = it.get("cx") + "," + it.get("cz") + ": " + t;
}
}
}
levelRendererAllChanged();
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("injected", ok);
r.addProperty("failed", failed);
if (firstErr != null) r.addProperty("firstError", firstErr);
var cache2 = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
r.addProperty("loadedChunks", cache2.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;
float pitch = req.has("pitch") ? req.get("pitch").getAsFloat() : 0f;
// ★ 必须 setPosRaw + 手动更新 chunk 追踪,否则实体仍属旧区块
mc.player.setPosRaw(x, y, z);
mc.player.xo = x; mc.player.yo = y; mc.player.zo = z;
mc.player.setYRot(yaw);
mc.player.setXRot(pitch);
mc.player.yRotO = yaw;
mc.player.xRotO = pitch;
mc.setCameraEntity(mc.player);
// 视野中心跟着相机走,否则目标区域区块被 ClientChunkCache 丢弃
level.getChunkSource().updateViewCenter((int)Math.floor(x) >> 4, (int)Math.floor(z) >> 4);
mc.levelRenderer.allChanged();
tickFrames(5);
JsonObject r = new JsonObject();
r.addProperty("ok", true);
JsonArray c = new JsonArray(); c.add(x); c.add(y); c.add(z);
r.add("camera", c);
emit(id, r);
break;
}
case "view": {
if (req.has("cameraType")) {
mc.options.setCameraType(net.minecraft.client.CameraType.valueOf(req.get("cameraType").getAsString()));
}
if (req.has("renderDistance")) {
int rd = req.get("renderDistance").getAsInt();
// ★ 两处都要设:renderDistance 是本地设置;
// serverRenderDistance 模拟服务端下发的视距上限
// (getEffectiveRenderDistance = min(两者))
mc.options.renderDistance().set(rd);
mc.options.setServerRenderDistance(rd);
// ★ 必须同步 ClientChunkCache 的 storage 半径,
// 否则区块被 inRange 丢弃(官方靠服务端包触发 updateViewRadius)
level.getChunkSource().updateViewRadius(rd);
mc.levelRenderer.allChanged();
}
if (req.has("fov")) {
mc.options.fov().set(req.get("fov").getAsInt());
}
// ── 原版画质选项(光影的基础层)──
if (req.has("graphicsMode")) {
mc.options.graphicsMode().set(net.minecraft.client.GraphicsStatus
.valueOf(req.get("graphicsMode").getAsString()));
mc.levelRenderer.allChanged(); // FANCY 会启用更多渲染层
}
if (req.has("ambientOcclusion")) {
mc.options.ambientOcclusion().set(req.get("ambientOcclusion").getAsBoolean());
mc.levelRenderer.allChanged();
}
if (req.has("mipmapLevels")) {
mc.options.mipmapLevels().set(req.get("mipmapLevels").getAsInt());
}
if (req.has("gamma")) {
mc.options.gamma().set(req.get("gamma").getAsDouble());
}
if (req.has("entityShadows")) {
mc.options.entityShadows().set(req.get("entityShadows").getAsBoolean());
}
if (req.has("clouds")) {
mc.options.cloudStatus().set(net.minecraft.client.CloudStatus
.valueOf(req.get("clouds").getAsString()));
}
if (req.has("particles")) {
mc.options.particles().set(net.minecraft.client.ParticleStatus
.valueOf(req.get("particles").getAsString()));
}
// ── 原版后处理链(PostChain 基础设施)──
if (req.has("postEffect")) {
String eff = req.get("postEffect").getAsString();
if (eff.isEmpty() || eff.equals("none")) {
mc.gameRenderer.shutdownEffect();
} else {
// loadEffect 是包私有 → 走同包 bridge
net.minecraft.client.renderer.McfoldBridge.loadEffect(
mc.gameRenderer, ResourceLocation.parse(eff));
}
}
// 回包带上真实生效值,便于验证
JsonObject r = new JsonObject();
r.addProperty("ok", true);
JsonObject cur = new JsonObject();
try {
java.lang.reflect.Field pf = net.minecraft.client.renderer.GameRenderer.class
.getDeclaredField("postEffect");
pf.setAccessible(true);
cur.addProperty("postEffect", pf.get(mc.gameRenderer) != null ? "active" : "none");
} catch (Throwable ignore) {
cur.addProperty("postEffect", "unknown");
}
cur.addProperty("graphicsMode", mc.options.graphicsMode().get().name());
cur.addProperty("ambientOcclusion", mc.options.ambientOcclusion().get());
cur.addProperty("mipmapLevels", mc.options.mipmapLevels().get());
cur.addProperty("gamma", mc.options.gamma().get());
cur.addProperty("entityShadows", mc.options.entityShadows().get());
cur.addProperty("clouds", mc.options.cloudStatus().get().name());
cur.addProperty("renderDistance", mc.options.getEffectiveRenderDistance());
r.add("current", cur);
emit(id, r);
break;
}
case "registry": {
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("blockCount", BuiltInRegistries.BLOCK.size());
r.addProperty("stateCount", Block.BLOCK_STATE_REGISTRY.size());
emit(id, r);
break;
}
case "lookup": {
int sid = req.get("stateId").getAsInt();
BlockState st = Block.stateById(sid);
ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock());
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("stateId", sid);
r.addProperty("block", key.toString());
r.addProperty("state", st.toString());
r.addProperty("inRange", sid >= 0 && sid < Block.BLOCK_STATE_REGISTRY.size());
emit(id, r);
break;
}
case "fog": {
// 关雾:fogStart/fogEnd 是 levelRenderer 内部状态;
// 更简单可靠的是加大 renderDistance + 用 BLOCK 光照
JsonObject r = new JsonObject();
r.addProperty("ok", true);
emit(id, r);
break;
}
case "resolve": {
// ★ 方块名(+属性) → state id。存档 palette 是名字,网络载荷要 id,
// 而这个名字→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);
JsonArray out = new JsonArray();
int unknown = 0;
for (JsonElement el : arr) {
String spec = el.getAsString();
int sid;
try {
var res = net.minecraft.commands.arguments.blocks.BlockStateParser
.parseForBlock(reg, spec, false);
sid = Block.getId(res.blockState());
} catch (Throwable t) {
sid = -1; unknown++;
}
out.add(sid);
}
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.add("ids", out);
r.addProperty("unknown", unknown);
r.addProperty("total", arr.size());
emit(id, r);
break;
}
case "debug": {
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("playerPos", mc.player.getX() + "," + mc.player.getY() + "," + mc.player.getZ());
int pcx = (int)Math.floor(mc.player.getX()) >> 4, pcz = (int)Math.floor(mc.player.getZ()) >> 4;
r.addProperty("playerChunk", pcx + "," + pcz);
var cc = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
r.addProperty("loadedChunks", cc.getLoadedChunksCount());
r.addProperty("chunksRendered", mc.levelRenderer.countRenderedSections());
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllSections());
var got = cc.getChunk(pcx, pcz, net.minecraft.world.level.chunk.status.ChunkStatus.FULL, false);
r.addProperty("chunkAtPlayer", got != null ? got.getClass().getSimpleName() : "null");
if (got != null) {
// 扫描玩家所在列,找最高的非空气方块
int top = Integer.MIN_VALUE;
for (int y = level.getMaxBuildHeight() - 1; y >= level.getMinBuildHeight(); y--) {
var st = got.getBlockState(new BlockPos(
(int)Math.floor(mc.player.getX()) & 15, y, (int)Math.floor(mc.player.getZ()) & 15));
if (!st.isAir()) {
top = y;
r.addProperty("topSolidY", y);
r.addProperty("topSolidBlock", BuiltInRegistries.BLOCK.getKey(st.getBlock()).toString());
r.addProperty("topSolidProps", st.toString());
break;
}
}
if (top == Integer.MIN_VALUE) r.addProperty("topSolidY", "none");
}
// viewCenter
r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get()));
emit(id, r);
break;
}
case "viewport": {
// ★ viewCenter 决定 ClientChunkCache 收哪些区块(超范围会被丢弃)
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
if (req.has("radius")) {
level.getChunkSource().updateViewRadius(req.get("radius").getAsInt());
}
level.getChunkSource().updateViewCenter(cx, cz);
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("viewCenter", cx + "," + cz);
emit(id, r);
break;
}
case "render": {
int frames = req.has("frames") ? req.get("frames").getAsInt() : 60;
String out = req.has("out") ? req.get("out").getAsString() : null;
if (req.has("width")) width = req.get("width").getAsInt();
if (req.has("height")) height = req.get("height").getAsInt();
render(id, frames, out);
break;
}
default:
emitError(id, "未知命令: " + cmd);
}
}
static JsonObject applyChunk(int cx, int cz, byte[] data) {
// ★ 官方路径:ClientChunkCache.replaceWithPacketData
// 内部:new LevelChunk + chunk.replaceWithPacketData(buf, heightmaps, consumer)
// 与网络包 handleLevelChunkWithLight 完全同一路径
FriendlyByteBuf buf = new FriendlyByteBuf(io.netty.buffer.Unpooled.wrappedBuffer(data));
var cache = level.getChunkSource();
// 显式匿名类而非 lambda:lambda 参数类型推断在此处会失败(实测)
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput beOut =
new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput() {
@Override public void accept(BlockPos bp,
net.minecraft.world.level.block.entity.BlockEntityType<?> type,
net.minecraft.nbt.CompoundTag tag) { }
};
// ★ 参数是 Consumer<BlockEntityTagOutput>(消费这个接口本身),不是该接口的方法签名。
// LevelChunk.replaceWithPacketData 内部会调 beOut.accept(...),这里直接空实现。
cache.replaceWithPacketData(cx, cz, buf, new net.minecraft.nbt.CompoundTag(), out -> {});
// 无光照数据 ⇒ 显式全亮,否则地形全黑
var light = cache.getLightEngine();
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
DataLayer sky = new DataLayer();
for (int x = 0; x < 16; x++)
for (int y = 0; y < 16; y++)
for (int z = 0; z < 16; z++) sky.set(x, y, z, 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);
}
light.setLightEnabled(new ChunkPos(cx, cz), true);
int upd = light.runLightUpdates();
JsonObject r = new JsonObject();
r.addProperty("lightUpdates", upd);
return r;
}
static void levelRendererAllChanged() {
mc.levelRenderer.allChanged();
}
/** tick + 渲染各帧(区块网格重建队列靠 tick 推进) */
static void tickFrames(int n) {
MainTarget target = (MainTarget) mc.getMainRenderTarget();
for (int f = 0; f < n; f++) {
try { mc.tick(); } catch (Throwable ignore) {}
RenderSystem.clear(16640, Minecraft.ON_OSX);
target.bindWrite(true);
// ★ 世代差异(1.21+):render(DeltaTracker, boolean)
mc.gameRenderer.render(mc.getTimer(), true);
target.unbindWrite();
target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight());
mc.getWindow().updateDisplay();
}
}
static void render(Integer id, int frames, String out) throws Exception {
tickFrames(frames);
MainTarget target = (MainTarget) mc.getMainRenderTarget();
int w = mc.getWindow().getWidth(), h = mc.getWindow().getHeight();
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, target.frameBufferId);
GL11.glReadBuffer(org.lwjgl.opengl.GL30.GL_COLOR_ATTACHMENT0);
var buf = org.lwjgl.BufferUtils.createByteBuffer(w * h * 4);
GL11.glReadPixels(0, 0, w, h, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buf);
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, 0);
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
java.util.Set<Integer> colors = new java.util.HashSet<>();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++) {
int i = ((h - 1 - y) * w + x) * 4;
int rgb = ((buf.get(i) & 0xFF) << 16) | ((buf.get(i + 1) & 0xFF) << 8) | (buf.get(i + 2) & 0xFF);
img.setRGB(x, y, rgb);
colors.add(rgb);
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageIO.write(img, "png", bos);
byte[] png = bos.toByteArray();
if (out != null) ImageIO.write(img, "png", new File(out));
JsonObject r = new JsonObject();
r.addProperty("ok", true);
r.addProperty("width", w);
r.addProperty("height", h);
r.addProperty("chunksRendered", mc.levelRenderer.countRenderedSections());
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllSections());
r.addProperty("distinctColors", colors.size());
JsonArray cam = new JsonArray();
cam.add(mc.player.getX()); cam.add(mc.player.getY()); cam.add(mc.player.getZ());
r.add("camera", cam);
if (out != null) r.addProperty("path", out);
r.addProperty("image", Base64.getEncoder().encodeToString(png));
emit(id, r);
}
// ---------------- 启动 ----------------
static void boot(String gameDir, String assets, String assetIdx) throws Exception {
SharedConstants.tryDetectVersion();
// ★ 世代差异(1.21+):enableDataFixerOptimizations() 已被移除(实测映射表无此方法)
Bootstrap.bootStrap();
File runDir = new File(gameDir);
runDir.mkdirs();
// ★ 世代差异(1.21+):User(String, UUID, String, Optional, Optional, Type)
// 1.20.x 是 (String, String sessionId, String, ...)
User user = new User("mcfold_cam", java.util.UUID.nameUUIDFromBytes("mcfold_cam".getBytes()),
"mcfold_cam", Optional.empty(), Optional.empty(), User.Type.LEGACY);
PropertyMap props = new PropertyMap();
GameConfig cfg = new GameConfig(
new GameConfig.UserData(user, props, props, Proxy.NO_PROXY),
new DisplayData(width, height, OptionalInt.empty(), OptionalInt.empty(), false),
new GameConfig.FolderData(runDir, new File(runDir, "resourcepacks"),
new File(assets), assetIdx),
new GameConfig.GameData(false, "mcfold", "release", false, false),
new GameConfig.QuickPlayData(null, null, null, null));
RenderSystem.initRenderThread();
RenderSystem.beginInitialization();
mc = new Minecraft(cfg);
RenderSystem.finishInitialization();
System.err.println("[mcfold] Minecraft constructed");
reloadResources(mc);
System.err.println("[mcfold] resources reloaded");
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
// 初始注册表:官方静态层(1.21 的 cookie 需要它,随后会被真实注册表替换)
net.minecraft.core.RegistryAccess.Frozen listenerRegistryAccess =
net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
.compositeAccess();
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
// ★ 世代差异(1.21+):ClientPacketListener(Minecraft, Connection, CommonListenerCookie)
// 1.20.x 是 (Minecraft, Screen, Connection, ServerData, GameProfile, TelemetryManager)。
// ★★ 这是本世代最大的一处变化 —— 参数个数与语义都不同。
var cookie = new net.minecraft.client.multiplayer.CommonListenerCookie(
profile,
telemetry,
listenerRegistryAccess, // 后面注册表加载后会被替换
net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS,
null, // serverBrand
null, // serverData
null, // postDisconnectScreen
java.util.Map.of(), // serverCookies
null, // chatState
false, // strictErrorHandling
java.util.Map.of(), // customReportDetails
net.minecraft.server.ServerLinks.EMPTY);
ClientPacketListener listener = new ClientPacketListener(mc, conn, cookie);
// ★ 世代差异(1.21+):createPackRepository(Path, DirectoryValidator)
// DirectoryValidator 没有 NONE 常量,构造要 PathMatcher;
// 传一个「全部拒绝」的 matcher 最安全(我们不加载外来符号链接数据包)
var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository(
new File(runDir, "datapacks").toPath(),
new net.minecraft.world.level.validation.DirectoryValidator(p -> false));
packs.reload();
ResourceManager dataRm = new net.minecraft.server.packs.resources.MultiPackResourceManager(
net.minecraft.server.packs.PackType.SERVER_DATA,
java.util.List.of(packs.getPack("vanilla").open()));
var all = new java.util.ArrayList<RegistryDataLoader.RegistryData<?>>();
all.addAll(RegistryDataLoader.WORLDGEN_REGISTRIES);
all.addAll(RegistryDataLoader.DIMENSION_REGISTRIES);
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, listener.registryAccess(), all);
var layered = net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
.replaceFrom(net.minecraft.client.multiplayer.ClientRegistryLayer.REMOTE,
new RegistryAccess.Frozen[]{loaded});
var rf = ClientPacketListener.class.getDeclaredField("registryAccess");
rf.setAccessible(true);
rf.set(listener, layered);
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, ResourceLocation.parse("minecraft:overworld")));
var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false);
level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder,
Math.max(8, mc.options.renderDistance().get()), 8,
() -> InactiveProfiler.INSTANCE, mc.levelRenderer, false, 0L);
// ★ 世代差异(1.21+):setLevel(ClientLevel, ReceivingLevelScreen$Reason)
// 1.20.x 只有一个参数。这是本文件与 gen_1_20 的差异之一。
// 枚举值实测:NETHER_PORTAL / END_PORTAL / OTHER(没有 LEVEL_LOAD)
// 我们不是经传送门进入世界 ⇒ OTHER
mc.setLevel(level, net.minecraft.client.gui.screens.ReceivingLevelScreen.Reason.OTHER);
var player = new net.minecraft.client.player.LocalPlayer(mc, level, listener,
new net.minecraft.stats.StatsCounter(), new net.minecraft.client.ClientRecipeBook(), false, false);
player.setPos(8.0, 70.0, 8.0);
mc.player = player;
mc.setCameraEntity(player);
mc.gameMode = new net.minecraft.client.multiplayer.MultiPlayerGameMode(mc, listener);
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
mc.setOverlay(null);
mc.setScreen(null);
mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
System.err.println("[mcfold] boot done");
}
/** 同步驱动各 reload listener(不能 join Minecraft 的 reloadResourcePacks:等主循环 → 死锁)*/
static void reloadResources(Minecraft mc) throws Exception {
var rmF = Minecraft.class.getDeclaredField("resourceManager");
rmF.setAccessible(true);
var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc);
mc.getResourcePackRepository().reload();
var barrier = new net.minecraft.server.packs.resources.PreparableReloadListener.PreparationBarrier() {
@Override public <T> java.util.concurrent.CompletableFuture<T> wait(T t) {
return java.util.concurrent.CompletableFuture.completedFuture(t);
}
};
var prof = InactiveProfiler.INSTANCE;
java.util.concurrent.Executor sync = Runnable::run;
for (var listener : reloadListeners(rm)) {
String name = listener.getName();
try {
var fut = listener.reload(barrier, rm, prof, prof, sync, sync);
long deadline = System.currentTimeMillis() + 60_000;
while (!fut.isDone() && System.currentTimeMillis() < deadline) Thread.sleep(10);
if (fut.isDone()) fut.join();
else System.err.println("[mcfold] TIMEOUT " + name);
} catch (Throwable e) {
System.err.println("[mcfold] FAILED " + name + ": " + e);
}
}
}
@SuppressWarnings("unchecked")
static java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener> reloadListeners(Object rm) throws Exception {
for (var f : rm.getClass().getDeclaredFields()) {
if (java.util.List.class.isAssignableFrom(f.getType())) {
f.setAccessible(true);
Object v = f.get(rm);
if (v instanceof java.util.List<?> l && !l.isEmpty()
&& l.get(0) instanceof net.minecraft.server.packs.resources.PreparableReloadListener) {
return (java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener>) l;
}
}
}
throw new IllegalStateException("reload listeners not found");
}
// ---------------- 工具 ----------------
static byte[] decode(String b64) {
return Base64.getDecoder().decode(b64);
}
static String arg(String[] a, String key, String def) {
for (int i = 0; i < a.length - 1; i++) if (a[i].equals(key)) return a[i + 1];
return def;
}
static synchronized void emit(Integer id, JsonObject obj) {
if (id != null) obj.addProperty("id", id);
OUT.println(GSON.toJson(obj));
OUT.flush();
}
static void emitError(Integer id, String msg) {
JsonObject o = new JsonObject();
o.addProperty("ok", false);
o.addProperty("error", msg);
emit(id, o);
}
static void event(String name, JsonObject payload) {
JsonObject o = new JsonObject();
o.addProperty("event", name);
if (payload != null) o.add("payload", payload);
OUT.println(GSON.toJson(o));
OUT.flush();
}
}

View File

@ -0,0 +1,37 @@
package net.minecraft.client.renderer;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceProvider;
/**
* 包私有成员桥。
*
* GameRenderer 里几个关键方法不是 public,官方只在客户端内部调用;
* 我们不跑主循环、不连服,所以要用同包类直接调。
* reloadShaders —— 官方由匿名内部类 GameRenderer$1(资源重载 listener)调
* loadEffect —— 官方由 onResourceManagerReload / 药水效果改
*/
public class McfoldBridge {
public static void reloadShaders(GameRenderer gr, ResourceProvider provider) {
gr.reloadShaders(provider);
}
/**
* 载入一个原版后处理链。
*
* ★ 世代差异(1.21+):loadEffect 从 package-private 变成了 **private**
* (实测 javap:1.20.1 是 `void loadEffect(...)`,1.21 是 `private void`),
* 同包也调不到 ⇒ 只能用反射。
* 1.21 新增了 public togglePostEffect() 但那是「切换内置效果」,
* 不支持任意 post chain id,所以仍走反射。
*/
public static void loadEffect(GameRenderer gr, ResourceLocation id) {
try {
java.lang.reflect.Method m = GameRenderer.class.getDeclaredMethod("loadEffect", ResourceLocation.class);
m.setAccessible(true);
m.invoke(gr, id);
} catch (Exception e) {
throw new RuntimeException("loadEffect 不可用(1.21+ 为 private)", e);
}
}
}

View File

@ -1,14 +0,0 @@
package net.minecraft.client.renderer;
import net.minecraft.server.packs.resources.ResourceProvider;
/**
* 包私有成员桥(GameRenderer.reloadShaders 是 package-private)。
* 官方由匿名内部类 GameRenderer$1 在资源重载时调用;
* 我们不跑主循环 ⇒ 用同包类直接调用,跳过整个 reload 基础设施。
*/
public class McfoldBridge {
public static void reloadShaders(GameRenderer gr, ResourceProvider provider) {
gr.reloadShaders(provider);
}
}