世代分派体系: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:
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.
@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
109
mcfold/backends/__init__.py
Normal file
109
mcfold/backends/__init__.py
Normal 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"),
|
||||
]
|
||||
BIN
mcfold/backends/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
mcfold/backends/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/backends/__pycache__/base.cpython-312.pyc
Normal file
BIN
mcfold/backends/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/backends/__pycache__/gen_1_20.cpython-312.pyc
Normal file
BIN
mcfold/backends/__pycache__/gen_1_20.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/backends/__pycache__/gen_1_21.cpython-312.pyc
Normal file
BIN
mcfold/backends/__pycache__/gen_1_21.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/backends/__pycache__/gen_a_1_20.cpython-312.pyc
Normal file
BIN
mcfold/backends/__pycache__/gen_a_1_20.cpython-312.pyc
Normal file
Binary file not shown.
350
mcfold/backends/base.py
Normal file
350
mcfold/backends/base.py
Normal 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}")
|
||||
37
mcfold/backends/gen_1_20.py
Normal file
37
mcfold/backends/gen_1_20.py
Normal 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,
|
||||
}
|
||||
36
mcfold/backends/gen_1_21.py
Normal file
36
mcfold/backends/gen_1_21.py
Normal 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()",
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
# ---------------- 渲染 ----------------
|
||||
|
||||
56
scaffold/java/VERSION-DIFFS.md
Normal file
56
scaffold/java/VERSION-DIFFS.md
Normal 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`。**
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
676
scaffold/java/gen_1_21/McfoldRenderServer.java
Normal file
676
scaffold/java/gen_1_21/McfoldRenderServer.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user