diff --git a/logs/2026-09-25-1.log.gz b/logs/2026-09-25-1.log.gz index 5360e81..8312f78 100644 Binary files a/logs/2026-09-25-1.log.gz and b/logs/2026-09-25-1.log.gz differ diff --git a/logs/2026-09-25-2.log.gz b/logs/2026-09-25-2.log.gz index 8312f78..80b97fc 100644 Binary files a/logs/2026-09-25-2.log.gz and b/logs/2026-09-25-2.log.gz differ diff --git a/logs/2026-09-25-3.log.gz b/logs/2026-09-25-3.log.gz index 80b97fc..a12cf7f 100644 Binary files a/logs/2026-09-25-3.log.gz and b/logs/2026-09-25-3.log.gz differ diff --git a/logs/2026-09-25-4.log.gz b/logs/2026-09-25-4.log.gz index a12cf7f..629a55a 100644 Binary files a/logs/2026-09-25-4.log.gz and b/logs/2026-09-25-4.log.gz differ diff --git a/logs/2026-09-25-5.log.gz b/logs/2026-09-25-5.log.gz index 629a55a..3ea8980 100644 Binary files a/logs/2026-09-25-5.log.gz and b/logs/2026-09-25-5.log.gz differ diff --git a/logs/2026-09-25-6.log.gz b/logs/2026-09-25-6.log.gz index 3ea8980..e0d577a 100644 Binary files a/logs/2026-09-25-6.log.gz and b/logs/2026-09-25-6.log.gz differ diff --git a/logs/2026-09-25-7.log.gz b/logs/2026-09-25-7.log.gz index e0d577a..048ec35 100644 Binary files a/logs/2026-09-25-7.log.gz and b/logs/2026-09-25-7.log.gz differ diff --git a/logs/latest.log b/logs/latest.log index 632b0f8..1bc1c54 100644 --- a/logs/latest.log +++ b/logs/latest.log @@ -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.(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. diff --git a/mcfold/__pycache__/render.cpython-312.pyc b/mcfold/__pycache__/render.cpython-312.pyc index 2679b18..a1f2aa3 100644 Binary files a/mcfold/__pycache__/render.cpython-312.pyc and b/mcfold/__pycache__/render.cpython-312.pyc differ diff --git a/mcfold/__pycache__/render_bridge.cpython-312.pyc b/mcfold/__pycache__/render_bridge.cpython-312.pyc index c2b834b..e548ea1 100644 Binary files a/mcfold/__pycache__/render_bridge.cpython-312.pyc and b/mcfold/__pycache__/render_bridge.cpython-312.pyc differ diff --git a/mcfold/backends/__init__.py b/mcfold/backends/__init__.py new file mode 100644 index 0000000..ce9ad7d --- /dev/null +++ b/mcfold/backends/__init__.py @@ -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"), + ] diff --git a/mcfold/backends/__pycache__/__init__.cpython-312.pyc b/mcfold/backends/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a9c88e7 Binary files /dev/null and b/mcfold/backends/__pycache__/__init__.cpython-312.pyc differ diff --git a/mcfold/backends/__pycache__/base.cpython-312.pyc b/mcfold/backends/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000..4be0a1e Binary files /dev/null and b/mcfold/backends/__pycache__/base.cpython-312.pyc differ diff --git a/mcfold/backends/__pycache__/gen_1_20.cpython-312.pyc b/mcfold/backends/__pycache__/gen_1_20.cpython-312.pyc new file mode 100644 index 0000000..45c7d92 Binary files /dev/null and b/mcfold/backends/__pycache__/gen_1_20.cpython-312.pyc differ diff --git a/mcfold/backends/__pycache__/gen_1_21.cpython-312.pyc b/mcfold/backends/__pycache__/gen_1_21.cpython-312.pyc new file mode 100644 index 0000000..2d368cd Binary files /dev/null and b/mcfold/backends/__pycache__/gen_1_21.cpython-312.pyc differ diff --git a/mcfold/backends/__pycache__/gen_a_1_20.cpython-312.pyc b/mcfold/backends/__pycache__/gen_a_1_20.cpython-312.pyc new file mode 100644 index 0000000..73c36c2 Binary files /dev/null and b/mcfold/backends/__pycache__/gen_a_1_20.cpython-312.pyc differ diff --git a/mcfold/backends/base.py b/mcfold/backends/base.py new file mode 100644 index 0000000..c0d95c2 --- /dev/null +++ b/mcfold/backends/base.py @@ -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}") diff --git a/mcfold/backends/gen_1_20.py b/mcfold/backends/gen_1_20.py new file mode 100644 index 0000000..4cfd320 --- /dev/null +++ b/mcfold/backends/gen_1_20.py @@ -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, + } diff --git a/mcfold/backends/gen_1_21.py b/mcfold/backends/gen_1_21.py new file mode 100644 index 0000000..3a20631 --- /dev/null +++ b/mcfold/backends/gen_1_21.py @@ -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()", + } diff --git a/mcfold/render.py b/mcfold/render.py index 26df48e..25d304a 100644 --- a/mcfold/render.py +++ b/mcfold/render.py @@ -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, ) diff --git a/mcfold/render_bridge.py b/mcfold/render_bridge.py index 571f222..3b4009d 100644 --- a/mcfold/render_bridge.py +++ b/mcfold/render_bridge.py @@ -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) # ---------------- 渲染 ---------------- diff --git a/scaffold/java/VERSION-DIFFS.md b/scaffold/java/VERSION-DIFFS.md new file mode 100644 index 0000000..6f669b5 --- /dev/null +++ b/scaffold/java/VERSION-DIFFS.md @@ -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_/` 建目录,拷上一代源码 +2. 按上表改差异点(对着 **javap 输出**改,不要凭印象写 API 名) +3. `mcfold/backends/gen_.py` 声明 `markers`(判据类) +4. `mcfold/backends/__init__.py` 的 `GENERATIONS` 加一条 +5. 各自对**对应版本**的 named jar 编译 + +⚠️ 教训(本轮踩过):`Reason.LEVEL_LOAD`、`DirectoryValidator.NONE` +都是凭印象写出来的、实际不存在的 API;`loadEffect` 的可见性也没验证。 +**改之前先 `javap`。** diff --git a/scaffold/McfoldRenderServer.java b/scaffold/java/gen_1_20/McfoldRenderServer.java similarity index 89% rename from scaffold/McfoldRenderServer.java rename to scaffold/java/gen_1_20/McfoldRenderServer.java index a10e57c..e7927f9 100644 --- a/scaffold/McfoldRenderServer.java +++ b/scaffold/java/gen_1_20/McfoldRenderServer.java @@ -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; } diff --git a/scaffold/java/gen_1_20/net/minecraft/client/renderer/McfoldBridge.java b/scaffold/java/gen_1_20/net/minecraft/client/renderer/McfoldBridge.java new file mode 100644 index 0000000..961e835 --- /dev/null +++ b/scaffold/java/gen_1_20/net/minecraft/client/renderer/McfoldBridge.java @@ -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); + } +} diff --git a/scaffold/java/gen_1_21/McfoldRenderServer.java b/scaffold/java/gen_1_21/McfoldRenderServer.java new file mode 100644 index 0000000..12263eb --- /dev/null +++ b/scaffold/java/gen_1_21/McfoldRenderServer.java @@ -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":"","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":""} + * + * 启动就绪时主动发 {"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(消费这个接口本身),不是该接口的方法签名。 + // 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 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>(); + 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 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 java.util.concurrent.CompletableFuture 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 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) 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(); + } +} diff --git a/scaffold/java/gen_1_21/net/minecraft/client/renderer/McfoldBridge.java b/scaffold/java/gen_1_21/net/minecraft/client/renderer/McfoldBridge.java new file mode 100644 index 0000000..fff0d60 --- /dev/null +++ b/scaffold/java/gen_1_21/net/minecraft/client/renderer/McfoldBridge.java @@ -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); + } + } +} diff --git a/scaffold/net/minecraft/client/renderer/McfoldBridge.java b/scaffold/net/minecraft/client/renderer/McfoldBridge.java deleted file mode 100644 index ffa9663..0000000 --- a/scaffold/net/minecraft/client/renderer/McfoldBridge.java +++ /dev/null @@ -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); - } -}