diff --git a/logs/2026-09-25-1.log.gz b/logs/2026-09-25-1.log.gz index 3ea8980..048ec35 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 e0d577a..90305b7 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 048ec35..cb7d06a 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 90305b7..9b2b65b 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 cb7d06a..d8ad16b 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 9b2b65b..ca7c9e1 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 deleted file mode 100644 index d8ad16b..0000000 Binary files a/logs/2026-09-25-7.log.gz and /dev/null differ diff --git a/logs/2026-09-26-1.log.gz b/logs/2026-09-26-1.log.gz new file mode 100644 index 0000000..28d6b5b Binary files /dev/null and b/logs/2026-09-26-1.log.gz differ diff --git a/logs/2026-09-26-2.log.gz b/logs/2026-09-26-2.log.gz new file mode 100644 index 0000000..679a779 Binary files /dev/null and b/logs/2026-09-26-2.log.gz differ diff --git a/logs/2026-09-26-3.log.gz b/logs/2026-09-26-3.log.gz new file mode 100644 index 0000000..8f0e183 Binary files /dev/null and b/logs/2026-09-26-3.log.gz differ diff --git a/logs/2026-09-26-4.log.gz b/logs/2026-09-26-4.log.gz new file mode 100644 index 0000000..8a19aee Binary files /dev/null and b/logs/2026-09-26-4.log.gz differ diff --git a/logs/latest.log b/logs/latest.log index 3b6ae25..f9ac011 100644 --- a/logs/latest.log +++ b/logs/latest.log @@ -1,6 +1,6 @@ -[19:20:23] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 129 milliseconds -[19:20:24] [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' -[19:20:25] [main/ERROR]: Failed to verify authentication +[00:23:03] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 79 milliseconds +[00:23:04] [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' +[00:23:05] [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:605) ~[classes/:?] - at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:788) ~[classes/:?] + at McfoldRenderServer.main(McfoldRenderServer.java:113) ~[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 -[19:20:25] [main/INFO]: Setting user: mcfold_cam -[19:20:25] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 -[19:20:25] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. -[19:20:25] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it -[19:20:26] [main/INFO]: Reloading ResourceManager: vanilla -[19:20:26] [Worker-Main-10/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading -[19:20:26] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed -[19:21:26] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager -[19:21:26] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play -[19:21:26] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break -[19:21:26] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music +[00:23:05] [main/INFO]: Setting user: mcfold_cam +[00:23:05] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 +[00:23:05] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. +[00:23:05] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it +[00:23:06] [main/INFO]: Reloading ResourceManager: vanilla +[00:23:06] [Worker-Main-3/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[00:23:06] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed +[00:24:06] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager +[00:24:06] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play +[00:24:06] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break +[00:24:06] [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,23 @@ 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:676) ~[classes/:?] - at McfoldRenderServer.boot(McfoldRenderServer.java:609) ~[classes/:?] - at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?] -[19:21:26] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading -[19:21:26] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas -[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas -[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas -[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas -[19:21:26] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas -[19:21:26] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas -[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas -[19:21:26] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas -[19:21:27] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas -[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. -[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas -[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas -[19:21:27] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas -[19:21:27] [main/INFO]: [STDERR]: [mcfold] resources reloaded -[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. -[19:21:27] [main/INFO]: [STDERR]: [mcfold] boot done + at McfoldRenderServer.reloadResources(McfoldRenderServer.java:1146) ~[classes/:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:792) ~[classes/:?] + at McfoldRenderServer.main(McfoldRenderServer.java:113) ~[classes/:?] +[00:24:06] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[00:24:06] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas +[00:24:06] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas +[00:24:06] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas +[00:24:06] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas +[00:24:06] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas +[00:24:06] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas +[00:24:06] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas +[00:24:06] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas +[00:24:06] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas +[00:24:07] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[00:24:07] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas +[00:24:07] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas +[00:24:07] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas +[00:24:07] [main/INFO]: [STDERR]: [mcfold] resources reloaded +[00:24:07] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[00:24:07] [main/INFO]: [STDERR]: [mcfold] boot done diff --git a/mcfold/__pycache__/render.cpython-312.pyc b/mcfold/__pycache__/render.cpython-312.pyc index ea09eaf..263f3a6 100644 Binary files a/mcfold/__pycache__/render.cpython-312.pyc and b/mcfold/__pycache__/render.cpython-312.pyc differ diff --git a/mcfold/backends/__init__.py b/mcfold/backends/__init__.py index 6e21c57..5fb5bd0 100644 --- a/mcfold/backends/__init__.py +++ b/mcfold/backends/__init__.py @@ -29,6 +29,7 @@ from pathlib import Path # 世代的权威判据:这个类出现在哪个 version,就是那个世代 __all__ = ["Gen_1_20", "Gen_1_21", "Gen", "GENERATIONS", "detect_generation", "detect_from_store", "backend_class", + "LAUNCHERS", "list_launchers", "backend_for", "list_generations"] GEN_MARKERS = { @@ -87,16 +88,32 @@ def detect_from_store(store: str | Path) -> Gen | None: def backend_class(client_jar: str | Path | None = None, - generation: str | None = None): + generation: str | None = None, + launcher: str = ""): """选中后端类。 - ★ 这是「Python 层 switch」的落点:加新世代只需在下面加一条 elif。 + ★ 这是「Python 层 switch」的落点:加新世代只需在下面加一条分支。 + + 两个轴是**正交**的(用户 2026-09-25 定向): + 世代(版本差异) × 启动架构(加载器差异) + - 世代决定 McfoldRenderServer 怎么调 MC API(见 VERSION-DIFFS.md) + - 启动架构决定「谁起游戏」:裸启动 = 我们自己的 main; + launcher="fabric" = 经 Fabric 加载器(能加载 mod / Iris,但不写我们的 mod) + 正交 ⇒ 用动态子类组合,不写 N 个类。 """ if generation is None: generation = backend_for(client_jar) if client_jar else "gen_1_20" - if generation == "gen_1_21": - return Gen_1_21 - return Gen_1_20 + base = Gen_1_21 if generation == "gen_1_21" else Gen_1_20 + if not launcher: + return base + # 动态子类:只覆盖 launcher,其余沿用世代实现 + name = f"{base.__name__}_{launcher}" + existing = globals().get(name) + if existing is not None: + return existing + cls = type(name, (base,), {"launcher": launcher}) + globals()[name] = cls + return cls def backend_for(client_jar: str | Path) -> str: @@ -107,6 +124,17 @@ def backend_for(client_jar: str | Path) -> str: return g.backend +LAUNCHERS = [ + ("", "裸启动", "我们自己的 main;不加载 mod(零加载器方案可注册占位方块)"), + ("fabric", "Fabric 加载器", "经 launcher_fabric.LoaderBoot;能加载 fabric mod / Iris,不写我们的 mod"), +] + + +def list_launchers() -> list[tuple[str, str, str]]: + """(标识, 名称, 说明) —— 启动架构与世代正交。""" + return list(LAUNCHERS) + + def list_generations() -> list[tuple[str, str, str]]: """(世代名, 版本区间, Java 目录) —— 供诊断/文档用。""" return [ diff --git a/mcfold/backends/__pycache__/__init__.cpython-312.pyc b/mcfold/backends/__pycache__/__init__.cpython-312.pyc index ccb2b54..99af561 100644 Binary files a/mcfold/backends/__pycache__/__init__.cpython-312.pyc 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 index 416311c..5bb6b30 100644 Binary files a/mcfold/backends/__pycache__/base.cpython-312.pyc and b/mcfold/backends/__pycache__/base.cpython-312.pyc differ diff --git a/mcfold/backends/base.py b/mcfold/backends/base.py index 6030522..be84791 100644 --- a/mcfold/backends/base.py +++ b/mcfold/backends/base.py @@ -87,6 +87,9 @@ class RenderBackend: java_dir: str = "" markers: tuple[str, ...] = () main_class: str = "McfoldRenderServer" + # 启动架构(与世代正交):"" = 裸启动(我们自己的 main) + # "fabric" = 经 Fabric 加载器启动(用 mod 与 Iris,但不写我们的 mod) + launcher: str = "" def __init__( self, @@ -98,6 +101,7 @@ class RenderBackend: java_args: list[str] | None = None, startup_timeout: float = 240.0, cwd: str | None = None, + mods_dir: str | None = None, ): self.java = java if classpath is None: @@ -110,6 +114,8 @@ class RenderBackend: self.asset_index = asset_index self.startup_timeout = startup_timeout self.cwd = cwd + # 加载器后端的 mod 目录(launcher="fabric" 时经 -Dfabric.modsFolder 传入) + self.mods_dir = mods_dir self.java_args = java_args or [ "-Xmx1200M", "-Xms512M", "-Djava.awt.headless=true", ] @@ -134,7 +140,18 @@ class RenderBackend: f"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH," f"或先跑 scaffold/build.sh 编译 {self.generation} 后端。" ) - cmd = [self.java, *self.java_args, "-cp", self.classpath, self.main_class] + java_args = list(self.java_args) + # ★ 启动架构:走 Fabric 加载器时,主类是 launcher_fabric.LoaderBoot, + # 并需两个系统属性(实测缺一不可): + # fabric.gameMappingNamespace=named → 游戏类名保持 official 名 + # fabric.modsFolder= → mod 目录 + main_class = self.main_class + if self.launcher == "fabric": + main_class = "LoaderBoot" + java_args.append("-Dfabric.gameMappingNamespace=named") + if self.mods_dir: + java_args.append(f"-Dfabric.modsFolder={self.mods_dir}") + cmd = [self.java, *java_args, "-cp", self.classpath, main_class] if self.game_dir: cmd += ["--gameDir", self.game_dir] if self.assets: @@ -225,6 +242,21 @@ class RenderBackend: return self._request({"cmd": "setblocks", "cx": cx, "cz": cz, "sections": items}, timeout=300.0) + def load_mods(self, mods_dir: str) -> dict: + """加载 mod:Java 侧自己扫 jar、注册占位方块、挂资源包。 + + ★ Python 只传目录路径 —— 版本/格式相关的处理全在 Java 侧 + (用户 2026-09-25 定向:Python 相当于路由)。 + + 原理(见 scaffold/MOD-BLOCKS.md):方块**渲染**只需要 + (a) 注册表里有这个名字(拿 state id) + (b) 资源包里有 blockstates/models/textures + 与方块行为无关 ⇒ 不需要 Fabric/Forge 加载器。 + + 代价(诚实标注):方块**行为**(红石/交互)与第三方光影包(Iris)不支持。 + """ + return self._request({"cmd": "mods", "dir": str(mods_dir)}, timeout=600.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} @@ -275,6 +307,17 @@ class RenderBackend: return [] return self._request({"cmd": "resolve", "blocks": specs})["ids"] + def get_block_info(self, x: int, y: int, z: int) -> dict: + """查询某个坐标的方块信息。 + + ★ 由 Java 侧回答(它才有 level + 注册表 + 模型状态),Python 只路由。 + 返回:block 名(含 mod 方块)、stateId、属性(朝向等)、isAir/isSolid、 + 光照/硬度、贴图是否缺失(程序化判据)、是否来自 mod。 + 未加载的区块会明确回 known=false + reason="chunk_not_loaded" + —— 与「空气」语义区分(设计文档附录 D)。 + """ + return self._request({"cmd": "blockinfo", "x": int(x), "y": int(y), "z": int(z)}) + def debug(self) -> dict: return self._request({"cmd": "debug"}) diff --git a/mcfold/render.py b/mcfold/render.py index 1e8b852..81b3a5b 100644 --- a/mcfold/render.py +++ b/mcfold/render.py @@ -149,13 +149,38 @@ class WorldRenderer: def open(cls, instance: Any = None, *, store: str | None = None, classpath: str | None = None, start: bool = True, java: str = "java", client_jar: str | None = None, - generation: str | None = None) -> "WorldRenderer": + generation: str | None = None, launcher: str = "", + mods_dir: str | None = None) -> "WorldRenderer": """从 instance(mcfold.instance.Instance)或 store 目录打开渲染器。 classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH; 可先跑 mcfold/scaffold/build.sh 生成。 """ game_dir = assets = asset_index = None + # ★ 版本隔离策略:产物在**内容根**的 .mcfold/ 下(可只读共享), + # 不是 /tmp(tmpfs,重启即丢)。见 scaffold/build.sh。 + content_root = None + if instance is not None: + cr = getattr(instance, "content_root", None) + if cr: + content_root = Path(cr) + elif store: + content_root = Path(store) if Path(store).name.startswith(("vanilla-", "forge-", + "fabric-", "neoforge-", "quilt-")) else None + if content_root is not None and (content_root / ".mcfold").is_dir(): + mf = content_root / ".mcfold" + if (mf / "classes").is_dir(): + # 内容根产物优先(除非调用方显式给了 classpath) + if classpath is None: + cp_file = mf / "classpath.txt" + parts = [] + if cp_file.is_file(): + parts = [ln.strip() for ln in cp_file.read_text().splitlines() + if ln.strip()] + parts += [str(mf / "client-named.jar"), str(mf / "classes")] + classpath = os.pathsep.join(parts) + if (mf / "natives").is_dir(): + os.environ.setdefault("MCFOLD_NATIVES_DIR", str(mf / "natives")) if instance is not None: root = getattr(instance, "content_root", None) or getattr(instance, "path", None) if root: @@ -171,16 +196,28 @@ class WorldRenderer: assets = str(store_p / "assets") names = sorted(p.stem for p in (store_p / "assets" / "indexes").glob("*.json")) asset_index = names[-1] if names else None - game_dir = game_dir or str(store_p / "instances" / "default") + # ★ 不默认写生产实例 default(版本隔离策略)。 + # 实例目录与内容根**同级**(都挂在 mcview/ 下), + # 绝不嵌套进内容根 —— 内容根要可只读共享(见 MULTI-INSTANCE-DESIGN §1.1)。 + # 缺省按内容根名派生一个独立实例,避免撞上任何真实实例。 + # 权威布局(mcview/lib/versions.cjs:25): + # BASE(可由 MCVIEW_BASE 覆盖)= .../mcview + # contentRoot = BASE/store/- + # instanceDir = BASE/instances/ + # 所以 store 参数若是内容根,则 BASE = 内容根的 parent.parent。 + base = Path(os.environ.get("MCVIEW_BASE") or store_p.parent.parent) + game_dir = game_dir or str(base / "instances" / f"exp-{store_p.name}") # ★ Python 层 switch:按 client jar 的世代选后端 # (1.21+ 的 DeltaTracker 在 1.20.x jar 中不存在 ⇒ 必须独立编译的后端) # ★ 变量名不能叫 cls —— 会覆盖 classmethod 的 cls, # 导致下面 cls(bridge, instance) 构造出后端而非 WorldRenderer(实测踩过) - backend_cls = backend_class(client_jar=client_jar, generation=generation) + backend_cls = backend_class(client_jar=client_jar, generation=generation, + launcher=launcher) bridge = backend_cls( java=java, classpath=classpath, game_dir=game_dir, assets=assets, asset_index=asset_index, + mods_dir=mods_dir, ) wr = cls(bridge, instance) if start: @@ -239,6 +276,40 @@ class WorldRenderer: # ---------------- 方块名 → state id ---------------- + def get_block_info(self, x: int, y: int, z: int) -> dict: + """查询坐标处的方块信息(Java 侧回答,Python 只路由)。 + + 含:方块名(mod 方块亦然)、stateId、属性、光照/硬度、 + 贴图缺失的程序化判据、是否来自 mod;未加载区块明确区分于空气。 + """ + return self.bridge.get_block_info(x, y, z) + + def load_mods(self, mods_dir: str | Path | None = None) -> dict: + """加载 mod(Java 侧全权处理:扫 jar / 注册方块 / 挂资源包)。 + + mods_dir 缺省取实例的 mods/ 目录。 + + ★ 为什么不需要 Fabric/Forge 加载器(见 scaffold/MOD-BLOCKS.md): + 方块**渲染**只需要「注册表里有名字」+「资源包有模型/贴图」, + 与方块行为无关。所以零加载器也能让 mod 方块正确显示。 + 代价(诚实标注):方块行为(红石/交互)与第三方光影包(Iris)不支持。 + """ + if mods_dir is None: + inst_path = getattr(self.instance, "path", None) if self.instance else None + if inst_path: + mods_dir = Path(inst_path) / "mods" + if mods_dir is None: + return {"ok": False, "error": "未提供 mods_dir 且无法从实例推断"} + d = Path(mods_dir) + if not d.is_dir(): + return {"ok": True, "mods": 0, "note": f"{d} 不存在"} + r = self.bridge.load_mods(str(d)) + # 加载 mod 后方块变了 ⇒ 规格缓存作废(否则仍按旧表解析) + self._spec_cache.clear() + self._biome_id = None + self.refresh_capacity() + return r + def resolve_specs(self, specs: list[str]) -> dict[str, int]: """批量把方块名规格解析成 state id(渲染器侧权威注册表)。""" todo = [s for s in dict.fromkeys(specs) if s not in self._spec_cache] diff --git a/scaffold/RegistryProbe.java b/scaffold/RegistryProbe.java deleted file mode 100644 index 08badad..0000000 --- a/scaffold/RegistryProbe.java +++ /dev/null @@ -1,82 +0,0 @@ -import com.mojang.blaze3d.platform.DisplayData; -import com.mojang.blaze3d.systems.RenderSystem; -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.core.registries.BuiltInRegistries; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.Bootstrap; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.state.BlockState; - -import java.io.File; -import java.net.Proxy; -import java.util.Optional; -import java.util.OptionalInt; - -/** - * 证明:真客户端的注册表能给出 state id ↔ 方块名的权威映射。 - * - * 这正是不起完整客户端、只用客户端注册表的价值所在: - * 1.20.1 不从网络下发方块表(附录D),mod 方块的 state id 只有客户端注册表知道。 - */ -public class RegistryProbe { - - public static void main(String[] args) throws Exception { - 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"); - - SharedConstants.tryDetectVersion(); - SharedConstants.enableDataFixerOptimizations(); - Bootstrap.bootStrap(); - - File runDir = new File(gameDir); - User user = new User("mcfold_cam", "0".repeat(32), "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(854, 480, 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(); - Minecraft mc = new Minecraft(cfg); - RenderSystem.finishInitialization(); - System.out.println("[reg] Minecraft constructed"); - - // ★ 核心:客户端的方块注册表 - int blockCount = BuiltInRegistries.BLOCK.size(); - int stateCount = Block.BLOCK_STATE_REGISTRY.size(); - System.out.println("[reg] BLOCK registry size = " + blockCount); - System.out.println("[reg] BLOCK_STATE registry size = " + stateCount); - - // ★ 关键验证:mineflayer 会静默误映射的那些越界 id,客户端怎么答 - int[] probes = {0, 1, 1002, 1003, 1502, 30000, stateCount - 1}; - for (int id : probes) { - BlockState st = Block.stateById(id); - ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock()); - System.out.println("[reg] state_id=" + id + " -> " + key + " [" + st + "]"); - } - - // ★ 列出几个方块名(证明可读) - System.out.println("[reg] sample blocks:"); - for (int i = 0; i < 5; i++) { - ResourceLocation k = BuiltInRegistries.BLOCK.getKey(BuiltInRegistries.BLOCK.byId(i)); - System.out.println(" id=" + i + " -> " + k); - } - - System.out.println("[reg] DONE"); - System.exit(0); - } - - private 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; - } -} diff --git a/scaffold/WorldRender.java b/scaffold/WorldRender.java deleted file mode 100644 index e56d319..0000000 --- a/scaffold/WorldRender.java +++ /dev/null @@ -1,344 +0,0 @@ -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.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.block.Block; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.world.level.chunk.LevelChunk; -import net.minecraft.world.level.dimension.DimensionType; -import org.lwjgl.opengl.GL11; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.File; -import java.net.Proxy; -import java.time.Duration; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.UUID; - -/** - * 世界渲染器 —— 把世界数据喂给真客户端渲染器并出图。 - * - * 实测要点(2026-09-25): - * 1. 不跑主循环 ⇒ 渲染栈手动初始化(tryDetectVersion/Bootstrap/RenderSystem/new Minecraft) - * 2. 注册表从客户端自带 data/ 加载(RegistryDataLoader + vanilla pack) - * 3. shader 需显式编译(GameRenderer.reloadShaders 包私有 → 同包 bridge) - * 4. LoadingOverlay 必须摘掉,否则永远渲出加载屏 - * 5. 区块靠 LevelChunk + setBlockState 注入 + setSectionDirty 触发网格重建 - * 6. 读像素必须绑 FBO(glBindFramebuffer(frameBufferId)),GL_BACK 读到的是残留 - */ -public class WorldRender { - - public static void main(String[] args) throws Exception { - 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"); - String out = arg(args, "--out", "/tmp/scaffold-world.png"); - int W = Integer.parseInt(arg(args, "--width", "854")); - int H = Integer.parseInt(arg(args, "--height", "480")); - - SharedConstants.tryDetectVersion(); - SharedConstants.enableDataFixerOptimizations(); - Bootstrap.bootStrap(); - - File runDir = new File(gameDir); - User user = new User("mcfold_cam", "0".repeat(32), "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(W, H, 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(); - Minecraft mc = new Minecraft(cfg); - RenderSystem.finishInitialization(); - System.out.println("[wr] Minecraft ok"); - - // ── 资源:字体/贴图/模型 ── - // createReload 的 future 不该在主线程 join(会死锁),但字体加载是同步的, - // 这里用官方 FontManager.reload 直接同步加载字体(修「文字成方框」) - // ★ 资源重载:modelManager/fontManager/textureManager 全是 reload listener, - // 不跑重载则方块模型/字体/贴图全空(地形渲不出来)。 - // 驱动方式照抄 LoadingOverlay:轮询 isDone(),不能 join(会死锁)。 - reloadResources(mc); - System.out.println("[wr] resources reloaded"); - - // ── listener ── - Connection conn = new Connection(PacketFlow.CLIENTBOUND); - GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam"); - WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager( - TelemetryEventSender.DISABLED, false, Duration.ZERO, ""); - ClientPacketListener listener = new ClientPacketListener(mc, null, conn, null, profile, telemetry); - - // ── 注册表:从客户端自带 data/ 加载 ── - var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository( - new File(runDir, "datapacks").toPath()); - 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())); - java.util.List> 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}); - java.lang.reflect.Field rf = ClientPacketListener.class.getDeclaredField("registryAccess"); - rf.setAccessible(true); - rf.set(listener, layered); - System.out.println("[wr] registries loaded: dimension_type=" - + loaded.registryOrThrow(Registries.DIMENSION_TYPE).size()); - - Holder dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE) - .getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, new ResourceLocation("overworld"))); - - // ── ClientLevel ── - var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false); - ClientLevel level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder, - 8, 8, () -> InactiveProfiler.INSTANCE, mc.levelRenderer, false, 0L); - mc.setLevel(level); - - 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); - System.out.println("[wr] level + player ok"); - - // ── shader 编译(包私有,走同包 bridge)── - net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); - System.out.println("[wr] shaders ok: " + (RenderSystem.getShader() != null)); - - // ── LoadingOverlay 必须摘掉 ── - mc.setOverlay(null); - // ★ "Joining world..." 是 ReceivingLevelScreen;不清它客户端始终认为未进世界 - mc.setScreen(null); - - // ── 注入区块 ── - int injected = injectWorld(level, mc); - System.out.println("[wr] injected chunks=" + injected); - - // ── 第三人称(跳过 renderItemInHand)── - mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK); - - // ── 渲染(每帧先 tick:区块网格重建队列靠 tick 推进)── - MainTarget target = (MainTarget) mc.getMainRenderTarget(); - for (int f = 0; f < 60; f++) { - try { mc.tick(); } catch (Throwable t) { if (f == 0) System.out.println("[wr] tick err: " + t); } - RenderSystem.clear(16640, Minecraft.ON_OSX); - target.bindWrite(true); - mc.gameRenderer.render(0.0F, (long) f * 16_000_000L, true); - target.unbindWrite(); - target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight()); - mc.getWindow().updateDisplay(); - } - System.out.println("[wr] rendered 20 frames"); - - // ── 诊断:区块与网格状态 ── - System.out.println("[wr] hasRenderedAllChunks=" + mc.levelRenderer.hasRenderedAllChunks() - + " countRenderedChunks=" + mc.levelRenderer.countRenderedChunks()); - var got = level.getChunkSource().getChunk(0, 0, net.minecraft.world.level.chunk.ChunkStatus.FULL, false); - System.out.println("[wr] getChunk(0,0)=" + (got != null ? got.getClass().getSimpleName() : "null") - + " block(0,64,0)=" + (got != null ? got.getBlockState(new BlockPos(0,64,0)) : "-")); - System.out.println("[wr] isChunkCompiled(0,64,0)=" + mc.levelRenderer.isChunkCompiled(new BlockPos(0,64,0))); - - save(target, W, H, out); - System.exit(0); - } - - /** - * 同步驱动各 reload listener。 - * - * 为什么不用 Minecraft.reloadResourcePacks(): - * 它的 future 靠主循环 tick 推进(LoadingOverlay),我们不跑主循环 ⇒ 死锁。 - * 为什么不用 createReload(): - * prepare 阶段仍走真后台池,实测 120s 超时。 - * 正解:直接调各 listener 的 public reload(),barrier 同步返回, - * 且 apply 用 Runnable::run 保证在渲染线程执行。 - */ - private 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); - var packs = mc.getResourcePackRepository(); - packs.reload(); - - // 同步 barrier:prepare 完成后立刻交出,不排队 - 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; - // prepare 用当前线程(同步跑),apply 也用当前线程(渲染线程) - java.util.concurrent.Executor sync = Runnable::run; - - var all = reloadListeners(mc); - System.out.println("[wr] listeners=" + all.size()); - for (var listener : all) { - String name = listener.getName(); - System.out.println("[wr] -> " + name + " ..."); - try { - var fut = listener.reload(barrier, rm, prof, prof, sync, sync); - boolean done = false; - long dl = System.currentTimeMillis() + 30_000; - while (!done && System.currentTimeMillis() < dl) { - done = fut.isDone(); - if (!done) Thread.sleep(10); - } - if (!done) { System.out.println("[wr] TIMEOUT " + name); continue; } - fut.join(); - System.out.println("[wr] ok: " + name); - } catch (Throwable e) { - System.out.println("[wr] FAILED " + name + ": " + e); - } - } - } - - /** 取出注册过的 listener(private 字段) */ - @SuppressWarnings("unchecked") - private static java.util.List reloadListeners(Minecraft mc) throws Exception { - var rmF = Minecraft.class.getDeclaredField("resourceManager"); - rmF.setAccessible(true); - var rm = rmF.get(mc); - 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"); - } - - /** 造地形并注入:区块 + 光照 + 网格重建 */ - private static int injectWorld(ClientLevel level, Minecraft mc) throws Exception { - int radius = 4; // 9x9 区块 = 144x144 格 - int yBottom = level.getMinBuildHeight(); - var cache = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource(); - var light = cache.getLightEngine(); - - // 反射拿 Storage.chunks / getIndex(包私有) - var storageF = net.minecraft.client.multiplayer.ClientChunkCache.class.getDeclaredField("storage"); - storageF.setAccessible(true); - Object storage = storageF.get(cache); - var chunksF = storage.getClass().getDeclaredField("chunks"); - chunksF.setAccessible(true); - var chunks = (java.util.concurrent.atomic.AtomicReferenceArray) chunksF.get(storage); - var getIndex = storage.getClass().getDeclaredMethod("getIndex", int.class, int.class); - getIndex.setAccessible(true); - System.out.println("[wr] storage chunks.length=" + chunks.length()); - - int count = 0; - for (int cx = -radius; cx <= radius; cx++) { - for (int cz = -radius; cz <= radius; cz++) { - LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz)); - for (int lx = 0; lx < 16; lx++) { - for (int lz = 0; lz < 16; lz++) { - int wx = cx * 16 + lx, wz = cz * 16 + lz; - for (int y = yBottom; y < 63; y++) - chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.STONE.defaultBlockState(), false); - chunk.setBlockState(new BlockPos(lx, 63, lz), Blocks.DIRT.defaultBlockState(), false); - chunk.setBlockState(new BlockPos(lx, 64, lz), Blocks.GRASS_BLOCK.defaultBlockState(), false); - if ((wx % 7 == 0 && wz % 7 == 0) && (wx != 0 || wz != 0)) { - for (int y = 65; y < 72; y++) - chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.OAK_LOG.defaultBlockState(), false); - for (int dx = -2; dx <= 2; dx++) - for (int dz = -2; dz <= 2; dz++) - if (lx+dx >= 0 && lx+dx < 16 && lz+dz >= 0 && lz+dz < 16 - && Math.abs(dx) + Math.abs(dz) <= 2) - chunk.setBlockState(new BlockPos(lx+dx, 72, lz+dz), - Blocks.OAK_LEAVES.defaultBlockState(), false); - } - } - } - // ★ 核心:直接放进 ClientChunkCache.Storage.chunks - int idx = (int) getIndex.invoke(storage, cx, cz); - chunks.set(idx, chunk); - count++; - - // ★ 光照:全亮(否则地形全黑)。section y 0..19 覆盖 build height - for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) { - var sky = new net.minecraft.world.level.chunk.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(net.minecraft.world.level.LightLayer.SKY, - net.minecraft.core.SectionPos.of(cx, sy, cz), sky); - light.queueSectionData(net.minecraft.world.level.LightLayer.BLOCK, - net.minecraft.core.SectionPos.of(cx, sy, cz), sky); - } - light.setLightEnabled(new ChunkPos(cx, cz), true); - chunk.setLightCorrect(true); - } - } - int upd = light.runLightUpdates(); - System.out.println("[wr] light updates=" + upd); - - // 触发区块网格重建 - mc.levelRenderer.allChanged(); - System.out.println("[wr] allChanged done"); - return count; - } - - private static void save(MainTarget target, int W, int H, String out) throws Exception { - 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); - for (int y = 0; y < H; y++) - for (int x = 0; x < W; x++) { - int i = ((H - 1 - y) * W + x) * 4; - img.setRGB(x, y, ((buf.get(i) & 0xFF) << 16) | ((buf.get(i+1) & 0xFF) << 8) | (buf.get(i+2) & 0xFF)); - } - ImageIO.write(img, "png", new File(out)); - java.util.Set colors = new java.util.HashSet<>(); - for (int p : img.getRGB(0, 0, W, H, null, 0, W)) colors.add(p & 0xFFFFFF); - System.out.println("[wr] wrote " + out + " distinct colors=" + colors.size()); - } - - private 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; - } -} diff --git a/scaffold/build.sh b/scaffold/build.sh index 0e8298b..a797544 100755 --- a/scaffold/build.sh +++ b/scaffold/build.sh @@ -1,76 +1,105 @@ #!/usr/bin/env bash -# mcfold scaffold 构建脚本 —— 把「官方客户端 jar」变成「可直接调用的渲染库」 +# mcfold 构建脚本 —— 把官方客户端 jar 变成「可直接调用的渲染库」。 # -# 实测链路(2026-09-25 全绿): -# 1. 下官方 ProGuard 映射(client_mappings.txt) -# 2. 转成 SRG(SpecialSource 只吃 SRG) -# 3. 重映射 client.jar → client-named.jar(类名可读,javap/javac 可用) -# 4. 编译脚手架 +# ★ 版本隔离策略(用户 2026-09-25 强调) +# 产物写入**内容根** store/-/.mcfold/,不写 /tmp: +# 内容根/.mcfold/client-named.jar 反混淆后的客户端(可只读共享) +# 内容根/.mcfold/classes/ 我们的渲染服务 +# 内容根/.mcfold/natives/ LWJGL .so +# 理由:这些都由该版本的 client jar 派生,属「可共享内容」; +# /tmp 是 tmpfs,重启即丢(实测踩过)。实例独占的东西(mods/config/options) +# 一律不进这里。 # -# 用法: ./build.sh [实例store目录] [MC版本] +# 用法: ./build.sh <内容根|实例名> [java模式目录] +# ./build.sh /home/newqqagent/mcview/store/vanilla-1.20.1 1.20.1 +# ./build.sh /home/newqqagent/mcview/store/vanilla-1.21.1 1.21.1 java/gen_1_21 set -euo pipefail -STORE="${1:-/home/newqqagent/mcview/store/vanilla-1.20.1}" -MCVER="${2:-1.20.1}" +STORE="${1:?用法: build.sh <内容根> [java目录]}" +MCVER="${2:?需要 MC 版本}" +JAVA_SUB="${3:-}" HERE="$(cd "$(dirname "$0")" && pwd)" -WORK="${MCFOLD_SCAFFOLD_WORK:-/tmp/mcfold-scaffold}" -mkdir -p "$WORK" -cd "$WORK" +# 按版本挑世代目录(除非显式指定) +if [ -z "$JAVA_SUB" ]; then + case "$MCVER" in + 1.21*) JAVA_SUB="java/gen_1_21" ;; + *) JAVA_SUB="java/gen_1_20" ;; + esac +fi +OUT="$STORE/.mcfold" +WORK="${MCFOLD_BUILD_WORK:-$(mktemp -d /tmp/mcfold-build-XXXXXX)}" CLIENT_JAR="$STORE/versions/$MCVER/$MCVER.jar" [ -f "$CLIENT_JAR" ] || { echo "找不到客户端 jar: $CLIENT_JAR"; exit 1; } -# ── 1. 映射文件 ──────────────────────────────────────────────── -MAPPINGS_URL=$(python3 - "$STORE/versions/$MCVER/$MCVER.json" <<'PY' +mkdir -p "$OUT/classes" "$OUT/natives" "$WORK" +echo "[build] 版本 $MCVER 世代 $JAVA_SUB" +echo "[build] 输出 → $OUT" + +# ── 1. 官方映射 ──────────────────────────────────────────────── +if [ ! -s "$WORK/client_mappings.txt" ]; then + URL=$(python3 - "$STORE/versions/$MCVER/$MCVER.json" <<'PY' import json,sys -d=json.load(open(sys.argv[1])) -print(d['downloads']['client_mappings']['url']) +print(json.load(open(sys.argv[1]))['downloads']['client_mappings']['url']) PY ) -if [ ! -s client_mappings.txt ]; then - echo "[1/4] 下载官方映射..." - curl -sL -o client_mappings.txt "$MAPPINGS_URL" + echo "[1/5] 下载官方映射..." + curl -sL -o "$WORK/client_mappings.txt" "$URL" fi -echo "[1/4] 映射: $(wc -l < client_mappings.txt) 行" +echo "[1/5] 映射 $(wc -l < "$WORK/client_mappings.txt") 行" # ── 2. ProGuard → SRG ───────────────────────────────────────── -echo "[2/4] 转换映射格式..." -python3 "$HERE/pg2srg.py" client_mappings.txt mappings.srg +echo "[2/5] 转换映射格式..." +python3 "$HERE/pg2srg.py" "$WORK/client_mappings.txt" "$WORK/mappings.srg" -# ── 3. 重映射 jar ───────────────────────────────────────────── -if [ ! -f client-named.jar ] || [ "$CLIENT_JAR" -nt client-named.jar ]; then - echo "[3/4] 重映射客户端 jar(约 1-2 分钟)..." +# ── 3. 重映射客户端 jar ─────────────────────────────────────── +NAMED="$OUT/client-named.jar" +if [ ! -f "$NAMED" ] || [ "$CLIENT_JAR" -nt "$NAMED" ]; then + echo "[3/5] 重映射(1-3 分钟)..." SS=$(find /home/minecraft/libraries -name "SpecialSource-*.jar" 2>/dev/null | head -1) - [ -n "$SS" ] || { echo "找不到 SpecialSource"; exit 1; } GUAVA=$(find /home/minecraft/libraries -name "guava-*.jar" ! -name "*sources*" 2>/dev/null | head -1) + [ -n "$SS" ] || { echo "找不到 SpecialSource"; exit 1; } for a in asm asm-commons asm-tree; do - [ -f "$a.jar" ] || curl -sL -o "$a.jar" "https://repo1.maven.org/maven2/org/ow2/asm/$a/9.7/$a-9.7.jar" + [ -f "$WORK/$a.jar" ] || curl -sL -o "$WORK/$a.jar" \ + "https://repo1.maven.org/maven2/org/ow2/asm/$a/9.7/$a-9.7.jar" done - [ -f joptsimple.jar ] || curl -sL -o joptsimple.jar \ - "https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar" - java -Xmx3G -cp "$SS:joptsimple.jar:asm.jar:asm-commons.jar:asm-tree.jar:$GUAVA" \ + [ -f "$WORK/joptsimple.jar" ] || curl -sL -o "$WORK/joptsimple.jar" \ + "https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/joptsimple-5.0.4.jar" + java -Xmx3G -cp "$SS:$WORK/joptsimple.jar:$WORK/asm.jar:$WORK/asm-commons.jar:$WORK/asm-tree.jar:$GUAVA" \ net.md_5.specialsource.SpecialSource \ - -i "$CLIENT_JAR" -o client-named.jar -m mappings.srg + -i "$CLIENT_JAR" -o "$NAMED" -m "$WORK/mappings.srg" else - echo "[3/4] client-named.jar 已是最新" + echo "[3/5] client-named.jar 已是最新" fi -ls -la client-named.jar -# ── classpath ───────────────────────────────────────────────── +# ── 4. classpath(去 asm 重复,Fabric 需要) + natives ──────── find "$STORE/libraries" -name "*.jar" ! -name "*-sources.jar" ! -name "*natives*" 2>/dev/null \ - | grep -vE "minecraftforge|neoforge" > cp.txt -CP="$(paste -sd: cp.txt):$WORK/client-named.jar" + | grep -vE "minecraftforge|neoforge" > "$OUT/classpath.txt" +NATLIST=$(find "$STORE/libraries" -name "*natives-linux*.jar" 2>/dev/null) +if [ -n "$NATLIST" ]; then + rm -rf "$OUT/natives/linux"; mkdir -p "$OUT/natives" + while read -r j; do [ -f "$j" ] && unzip -o -q "$j" -d "$OUT/natives" 2>/dev/null || true; done <<< "$NATLIST" +fi +echo "[4/5] 库 $(wc -l < "$OUT/classpath.txt") 个, natives $(find "$OUT/natives" -name '*.so' | wc -l) 个" + +# ── 5. 编译渲染服务 ─────────────────────────────────────────── +echo "[5/5] 编译 $JAVA_SUB ..." +CP="$(paste -sd: "$OUT/classpath.txt"):$NAMED:$OUT/classes" +find "$HERE/$JAVA_SUB" -name "*.java" > "$WORK/srcs.txt" +javac -nowarn -proc:none -cp "$CP" -d "$OUT/classes" @"$WORK/srcs.txt" -# ── 4. 编译脚手架 ───────────────────────────────────────────── -echo "[4/4] 编译脚手架..." -mkdir -p classes -javac -nowarn -proc:none -cp "$CP" -d classes "$HERE"/*.java echo -echo "✅ 构建完成" -echo " named jar : $WORK/client-named.jar" -echo " classes : $WORK/classes" +echo "✅ 构建完成(产物在内容根,不是 /tmp)" +echo " 内容根 : $STORE" +echo " 产物 : $OUT" +echo " named : $OUT/client-named.jar" +echo " classes: $OUT/classes" echo -echo "运行(无头,需 Xvfb):" -echo " find $STORE/libraries -name '*natives-linux*.jar' | while read j; do unzip -o -q \$j -d $WORK/nativ; done" -echo " DISPLAY=:98 java -cp \"$CP:$WORK/classes\" -Djava.library.path=$WORK/nativ RenderProbe" +echo "运行(渲染服务从 stdin 读 NDJSON):" +echo " DISPLAY=:98 java -cp \"\$(paste -sd: $OUT/classpath.txt):$NAMED:$OUT/classes\" \\" +echo " -Djava.library.path=$OUT/natives McfoldRenderServer \\" +echo " --gameDir <实例目录> --assets $STORE/assets --assetIndex $MCVER" +echo +echo "★ 实例目录必须是**实例独占**的那个(mods/config/options 的边界)," +echo " 不要指向别的版本或别的加载器的实例 —— 那正是「多实例共用 mods」的崩因。" diff --git a/scaffold/java/gen_1_20/McfoldRenderServer.java b/scaffold/java/gen_1_20/McfoldRenderServer.java index a51cd9b..2af936c 100644 --- a/scaffold/java/gen_1_20/McfoldRenderServer.java +++ b/scaffold/java/gen_1_20/McfoldRenderServer.java @@ -99,7 +99,11 @@ public class McfoldRenderServer { // 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"); + // ★ 不硬编码实例目录(版本隔离策略): + // 实例目录 = mods/config/options 的独占边界,必须由调用方按档案指定。 + // 缺省只给一个不含 mods 的临时目录,避免污染任何真实实例。 + String gameDir = arg(args, "--gameDir", System.getProperty("java.io.tmpdir") + + "/mcfold-noinstance"); 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")); @@ -264,6 +268,161 @@ public class McfoldRenderServer { emit(id, res); break; } + case "modelcheck": { + // ★ 程序化判据:mod 方块的 baked model 是否就是 missing model + // (画面里看到紫黑棋盘格是「缺贴图」的视觉表现,这里给出可断言的答案) + JsonArray specs = req.getAsJsonArray("blocks"); + var shaper = mc.getBlockRenderer().getBlockModelShaper(); + Object missing = null; + try { + var mmF = Minecraft.class.getDeclaredField("modelManager"); + mmF.setAccessible(true); + Object mm = mmF.get(mc); + missing = mm.getClass().getMethod("getMissingModel").invoke(mm); + } catch (Throwable t) { } + var lookup = BuiltInRegistries.BLOCK.asLookup(); + JsonArray rows = new JsonArray(); + for (JsonElement el : specs) { + String spec = el.getAsString(); + JsonObject ro = new JsonObject(); + ro.addProperty("spec", spec); + try { + var pr = net.minecraft.commands.arguments.blocks.BlockStateParser + .parseForBlock(lookup, spec, false); + var st = pr.blockState(); + Object baked = shaper.getBlockModel(st); + ro.addProperty("isMissing", baked == missing); + ro.addProperty("modelClass", baked.getClass().getSimpleName()); + try { + Object icon = baked.getClass().getMethod("getParticleIcon", + com.mojang.serialization.Codec.class).invoke(baked, + net.minecraft.client.renderer.texture.TextureAtlasSprite.class == null ? null : null); + } catch (Throwable ignore) { } + } catch (Throwable t) { + ro.addProperty("error", String.valueOf(t)); + } + rows.add(ro); + } + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.add("results", rows); + emit(id, r); + break; + } + case "atlascheck": { + // ★ 判据:mod 贴图是否真进了 blocks atlas + // 正确 API 是 Minecraft.getTextureAtlas(...) → Function + // (早先用 ModelManager.getAtlasSet 是错的,导致全 NPE 假绿) + JsonArray arr = req.getAsJsonArray("textures"); + var fn = mc.getTextureAtlas( + net.minecraft.client.renderer.texture.TextureAtlas.LOCATION_BLOCKS); + JsonArray rows = new JsonArray(); + for (JsonElement el : arr) { + String tex = el.getAsString(); + JsonObject ro = new JsonObject(); + ro.addProperty("texture", tex); + ro.addProperty("isMissing", true); + ro.addProperty("sprite", ""); + try { + // ★ 世代差异:1.20 无 parse() 静态工厂(1.21+ 才有),用构造器 + var sprite = fn.apply(new ResourceLocation(tex)); + String reg = sprite.contents().name().toString(); + ro.addProperty("sprite", reg); + ro.addProperty("isMissing", reg.contains("missingno")); + } catch (Throwable t) { + ro.addProperty("sprite", "ERROR: " + t); + } + rows.add(ro); + } + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.add("results", rows); + emit(id, r); + break; + } + case "blockinfo": { + // ★ 方块信息查询(Java 侧,因为只有它有 level + 注册表 + 模型状态)。 + // Python 只路由,不做任何格式/语义判断。 + // 入参:{"cmd":"blockinfo","x":..,"y":..,"z":..} + int bx = req.get("x").getAsInt(); + int by = req.get("y").getAsInt(); + int bz = req.get("z").getAsInt(); + BlockPos pos = new BlockPos(bx, by, bz); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("x", bx); r.addProperty("y", by); r.addProperty("z", bz); + + int pcx = bx >> 4, pcz = bz >> 4; + // ★ 判据必须是「真拿到了 LevelChunk」: + // ClientLevel.hasChunk() 实测**恒返回 true**(反编译见 306 行), + // 基类 ChunkSource.hasChunk() 走 getChunk(...,false), + // 而 ClientChunkCache 覆写后在该参数下返回 emptyChunk(void_air 占位) + // ⇒ 都不可用于「未加载 vs 空气」的区分。 + // 可靠做法:getChunk(..., requireChunk=**false**) —— 反编译实测 + // return requireChunk ? emptyChunk : null; + // 即 requireChunk=false 时**未加载才给 null**(传 true 反而给占位)。 + var realChunk = level.getChunkSource() + .getChunk(pcx, pcz, net.minecraft.world.level.chunk.ChunkStatus.FULL, false); + boolean loaded = realChunk != null; + r.addProperty("chunkLoaded", loaded); + r.addProperty("inBuildHeight", level.isInWorldBounds(pos)); + + if (!loaded) { + // ★ 未加载 ≠ 空气(设计文档附录 D 强调的语义区分) + r.addProperty("known", false); + r.addProperty("reason", "chunk_not_loaded"); + emit(id, r); + break; + } + + BlockState st = level.getBlockState(pos); + Block blk = st.getBlock(); + ResourceLocation key = BuiltInRegistries.BLOCK.getKey(blk); + + r.addProperty("known", true); + r.addProperty("block", key == null ? "unknown" : key.toString()); + r.addProperty("stateId", Block.getId(st)); + r.addProperty("state", st.toString()); + r.addProperty("isAir", st.isAir()); + r.addProperty("isSolid", st.isSolid()); + + // 方块属性(朝向/开关等)—— 渲染与指令都需要 + JsonObject props = new JsonObject(); + try { + for (var e : st.getValues().entrySet()) { + props.addProperty(e.getKey().getName(), String.valueOf(e.getValue())); + } + } catch (Throwable ignore) { } + r.add("properties", props); + + // 光照与硬度(agent 判断「这能不能挖/亮不亮」用) + try { + r.addProperty("lightEmission", st.getLightEmission()); + r.addProperty("destroySpeed", st.getDestroySpeed(level, pos)); + r.addProperty("canOcclude", st.canOcclude()); + r.addProperty("hasBlockEntity", st.hasBlockEntity()); + } catch (Throwable ignore) { } + + // 贴图是否真的在 atlas 里(缺贴图的**程序化判据**,不靠看图) + // BlockModelShaper.getParticleIcon(BlockState) 是静态的; + // 它返回的 sprite 名字若含 missingno 就是缺贴图。 + try { + var shaper = mc.getBlockRenderer().getBlockModelShaper(); + // 实例方法(1.20.1 与 1.21.1 签名一致) + var icon = shaper.getParticleIcon(st); + String reg = icon.contents().name().toString(); + r.addProperty("particleTexture", reg); + r.addProperty("isMissingTexture", reg.contains("missingno")); + } catch (Throwable t) { + r.addProperty("particleTexture", "unavailable: " + t.getClass().getSimpleName()); + } + + // 是否来自 mod(零加载器注册的方块 namespace 不在 minecraft) + r.addProperty("fromMod", key != null && !key.getNamespace().equals("minecraft")); + + 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; @@ -679,6 +838,101 @@ public class McfoldRenderServer { System.err.println("[mcfold] boot done"); } + /** + * 把 blockstate 里的 model 引用规范化成 jar 里实际存在的路径。 + * + * 实测两类写法: + * "ns:block/foo" → models/block/foo.json (createbigcannons 风格,正确) + * "ns:foo" → models/foo.json(不存在!) (MTS 风格,实际在 models/block/foo.json) + * 所以先原样查,再试补 "block/"。 + */ + static String normalizeModelPath(java.util.zip.ZipFile zf, String model) { + int i = model.indexOf(':'); + String ns = i > 0 ? model.substring(0, i) : "minecraft"; + String path = i > 0 ? model.substring(i + 1) : model; + if (zf.getEntry("assets/" + ns + "/models/" + path + ".json") != null) return model; + if (zf.getEntry("assets/" + ns + "/models/block/" + path + ".json") != null) { + return ns + ":block/" + path; + } + return model; // 都不存在 → 保持原样(会显示 missing,但至少不误导) + } + + /** + * 生成一个「blockstates 覆盖包」:把 mod 的变体键统一改写成 ""。 + * + * ★ 为什么需要(实测根因): + * 我们的占位方块只有 defaultBlockState(变体键 ""), + * 而 mod 的 blockstates 常用属性键(如 "normal"、"facing=north")⇒ 匹配不上 + * ⇒ missing model ⇒ 画面上是紫黑棋盘格。 + * 例:createbigcannons:steel_block 的键是 ""(渲染正常,测过), + * mts:...fancydirt_block 的键是 "normal"(棋盘格)。 + * + * 做法:读每个 mod jar 的 assets//blockstates/*.json,取第一个 variant 的 model, + * 改写成 {"variants":{"":{"model":"<那个 model>"}}},打进一个 zip。 + * 该 zip 作为**最后选中**的资源包,优先级最高,从而覆盖原 blockstates。 + * 模型/贴图仍由原 jar 提供(不动)。 + */ + static File buildBlockstateOverlay(java.util.List jars, java.nio.file.Path outDir) throws Exception { + File overlay = new File(outDir.toFile(), "mcfold-blockstate-overlay.zip"); + java.util.TreeMap entries = new java.util.TreeMap<>(); + entries.put("pack.mcmeta", + ("{\"pack\":{\"description\":\"mcfold blockstate overlay\",\"pack_format\":15}}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + int rewritten = 0; + for (File jar : jars) { + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) { + var en = zf.entries(); + while (en.hasMoreElements()) { + var e = en.nextElement(); + String nm = e.getName(); + if (!nm.startsWith("assets/") || !nm.contains("/blockstates/") + || !nm.endsWith(".json")) continue; + String text; + try (var in = zf.getInputStream(e)) { + text = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + JsonObject root; + try { root = JsonParser.parseString(text).getAsJsonObject(); } + catch (Throwable t) { continue; } + if (!root.has("variants") || !root.get("variants").isJsonObject()) continue; + + // 取第一个 variant 的第一个 model —— 只求外观,不求语义 + String model = null; + for (var ve : root.getAsJsonObject("variants").entrySet()) { + JsonElement v = ve.getValue(); + JsonElement one = v.isJsonArray() ? (v.getAsJsonArray().size() > 0 + ? v.getAsJsonArray().get(0) : null) : v; + if (one != null && one.isJsonObject() && one.getAsJsonObject().has("model")) { + model = one.getAsJsonObject().get("model").getAsString(); + break; + } + } + if (model == null) continue; + // ★ 规范化 model 路径(实测根因): + // MTS 写 "mts:mtsofficialpack.fancydirt_block"(缺 block/), + // 而文件在 models/block/... ⇒ MC 找不到 ⇒ missing model ⇒ 棋盘格。 + // createbigcannons 写 "createbigcannons:block/steel_block"(有 block/),正常。 + // 策略:先按原样看 jar 里有没有;没有就试补 block/ 前缀。 + model = normalizeModelPath(zf, model); + String out = "{\"variants\":{\"\":{\"model\":\"" + model + "\"}}}"; + entries.put(nm, out.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + rewritten++; + } + } + } + if (rewritten == 0) return null; + try (var zos = new java.util.zip.ZipOutputStream( + new java.io.FileOutputStream(overlay))) { + for (var kv : entries.entrySet()) { + zos.putNextEntry(new java.util.zip.ZipEntry(kv.getKey())); + zos.write(kv.getValue()); + zos.closeEntry(); + } + } + System.err.println("[mcfold] overlay: 重写 " + rewritten + " 个 blockstates → " + overlay); + return overlay; + } + /** * 加载 mod:扫 jar → 注册占位方块 → 挂资源包(外观)。 * @@ -768,15 +1022,48 @@ public class McfoldRenderServer { var repo = mc.getResourcePackRepository(); java.nio.file.Path rpDir = mc.getResourcePackDirectory(); for (File jar : jars) { - File dst = new File(rpDir.toFile(), "mcfold-" + jar.getName()); + // ★ 必须是 .zip 后缀:FolderRepositorySource 只认 + // isDirectory+pack.mcmeta 或 **.zip**(实测它不看 .jar) + String base = jar.getName().replaceAll("\\.jar$", ""); + File dst = new File(rpDir.toFile(), "mcfold-" + base + ".zip"); if (!dst.isFile()) java.nio.file.Files.copy(jar.toPath(), dst.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); } + // ★ 生成 blockstates 覆盖包并放进资源包目录 + // (把 mod 的属性变体键改写成 "" —— 占位方块只有 defaultBlockState) + java.util.List jarList = new java.util.ArrayList<>(); + for (File j : jars) jarList.add(j); + File overlay = buildBlockstateOverlay(jarList, rpDir); + if (overlay != null) { + File od = new File(rpDir.toFile(), "zzz-mcfold-blockstate-overlay.zip"); + java.nio.file.Files.copy(overlay.toPath(), od.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } repo.reload(); + // 诊断(放进响应,stderr 会被后续日志冲掉) + out.addProperty("packDir", rpDir.toString()); + JsonArray avail = new JsonArray(); + for (String ai : repo.getAvailableIds()) avail.add(ai); + out.add("availableIds", avail); + JsonArray compat = new JsonArray(); + for (var pk2 : repo.getAvailablePacks()) { + JsonObject po = new JsonObject(); + po.addProperty("id", pk2.getId()); + po.addProperty("compat", String.valueOf(pk2.getCompatibility())); + compat.add(po); + } + out.add("availablePacks", compat); var sel = new java.util.LinkedHashSet<>(repo.getSelectedIds()); + // 先加 mod 包(提供 models/textures),再加 overlay(覆盖 blockstates) + java.util.List overlayIds = new java.util.ArrayList<>(); for (String packId : repo.getAvailableIds()) { + if (packId.contains("zzz-mcfold-blockstate-overlay")) { overlayIds.add(packId); continue; } if (packId.contains("mcfold-")) { sel.add(packId); packsAdded++; } } + sel.addAll(overlayIds); // 最后 = 最高优先级 + JsonArray selArr = new JsonArray(); + for (String si : sel) selArr.add(si); + out.add("selectedIds", selArr); repo.setSelected(sel); // 用新 pack 列表重建 resource manager(照官方 createReload 内部做法), @@ -794,6 +1081,36 @@ public class McfoldRenderServer { reloadResources(mc); net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); mc.levelRenderer.allChanged(); + + // ★ 自验证:重载后 mod 资源是否真的可读(TOO_OLD 是否阻止加载) + ResourceManager rm2 = mc.getResourceManager(); + System.err.println("[mcfold] resourceManager=" + rm2.getClass().getSimpleName() + + " namespaces=" + rm2.getNamespaces()); + JsonArray check = new JsonArray(); + for (File jar : jars) { + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) { + var en2 = zf.entries(); + while (en2.hasMoreElements()) { + String nm = en2.nextElement().getName(); + java.util.regex.Matcher m2 = java.util.regex.Pattern + .compile("^assets/([^/]+)/(blockstates/.+\\.json)$").matcher(nm); + if (!m2.matches()) continue; + ResourceLocation rid = new ResourceLocation(m2.group(1), m2.group(2)); + JsonObject co = new JsonObject(); + co.addProperty("id", rid.toString()); + var got2 = rm2.getResource(rid); + co.addProperty("readable", got2.isPresent()); + if (got2.isPresent()) { + try (var in2 = got2.get().open()) { + co.addProperty("bytes", in2.readAllBytes().length); + } + } + check.add(co); + break; // 每个 jar 只查一个样本 + } + } catch (Throwable ignore) { } + } + out.add("resourceCheck", check); } catch (Throwable t) { out.addProperty("packError", String.valueOf(t)); System.err.println("[mcfold] 资源包挂载失败: " + t); diff --git a/scaffold/java/gen_1_21/McfoldRenderServer.java b/scaffold/java/gen_1_21/McfoldRenderServer.java index bf05e9d..1678c24 100644 --- a/scaffold/java/gen_1_21/McfoldRenderServer.java +++ b/scaffold/java/gen_1_21/McfoldRenderServer.java @@ -99,7 +99,11 @@ public class McfoldRenderServer { // 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"); + // ★ 不硬编码实例目录(版本隔离策略): + // 实例目录 = mods/config/options 的独占边界,必须由调用方按档案指定。 + // 缺省只给一个不含 mods 的临时目录,避免污染任何真实实例。 + String gameDir = arg(args, "--gameDir", System.getProperty("java.io.tmpdir") + + "/mcfold-noinstance"); 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")); @@ -264,6 +268,162 @@ public class McfoldRenderServer { emit(id, res); break; } + case "modelcheck": { + // ★ 程序化判据:mod 方块的 baked model 是否就是 missing model + // (画面里看到紫黑棋盘格是「缺贴图」的视觉表现,这里给出可断言的答案) + JsonArray specs = req.getAsJsonArray("blocks"); + var shaper = mc.getBlockRenderer().getBlockModelShaper(); + Object missing = null; + try { + var mmF = Minecraft.class.getDeclaredField("modelManager"); + mmF.setAccessible(true); + Object mm = mmF.get(mc); + missing = mm.getClass().getMethod("getMissingModel").invoke(mm); + } catch (Throwable t) { } + var lookup = BuiltInRegistries.BLOCK.asLookup(); + JsonArray rows = new JsonArray(); + for (JsonElement el : specs) { + String spec = el.getAsString(); + JsonObject ro = new JsonObject(); + ro.addProperty("spec", spec); + try { + var pr = net.minecraft.commands.arguments.blocks.BlockStateParser + .parseForBlock(lookup, spec, false); + var st = pr.blockState(); + Object baked = shaper.getBlockModel(st); + ro.addProperty("isMissing", baked == missing); + ro.addProperty("modelClass", baked.getClass().getSimpleName()); + try { + Object icon = baked.getClass().getMethod("getParticleIcon", + com.mojang.serialization.Codec.class).invoke(baked, + net.minecraft.client.renderer.texture.TextureAtlasSprite.class == null ? null : null); + } catch (Throwable ignore) { } + } catch (Throwable t) { + ro.addProperty("error", String.valueOf(t)); + } + rows.add(ro); + } + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.add("results", rows); + emit(id, r); + break; + } + case "atlascheck": { + // ★ 判据:mod 贴图是否真进了 blocks atlas + // 正确 API 是 Minecraft.getTextureAtlas(...) → Function + // (早先用 ModelManager.getAtlasSet 是错的,导致全 NPE 假绿) + JsonArray arr = req.getAsJsonArray("textures"); + var fn = mc.getTextureAtlas( + net.minecraft.client.renderer.texture.TextureAtlas.LOCATION_BLOCKS); + JsonArray rows = new JsonArray(); + for (JsonElement el : arr) { + String tex = el.getAsString(); + JsonObject ro = new JsonObject(); + ro.addProperty("texture", tex); + ro.addProperty("isMissing", true); + ro.addProperty("sprite", ""); + try { + var sprite = fn.apply(ResourceLocation.parse(tex)); + String reg = sprite.contents().name().toString(); + ro.addProperty("sprite", reg); + ro.addProperty("isMissing", reg.contains("missingno")); + } catch (Throwable t) { + ro.addProperty("sprite", "ERROR: " + t); + } + rows.add(ro); + } + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.add("results", rows); + emit(id, r); + break; + } + case "blockinfo": { + // ★ 方块信息查询(Java 侧,因为只有它有 level + 注册表 + 模型状态)。 + // Python 只路由,不做任何格式/语义判断。 + // 入参:{"cmd":"blockinfo","x":..,"y":..,"z":..} + int bx = req.get("x").getAsInt(); + int by = req.get("y").getAsInt(); + int bz = req.get("z").getAsInt(); + BlockPos pos = new BlockPos(bx, by, bz); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("x", bx); r.addProperty("y", by); r.addProperty("z", bz); + + int pcx = bx >> 4, pcz = bz >> 4; + // ★ 判据必须是「真拿到了 LevelChunk」: + // ClientLevel.hasChunk() 实测**恒返回 true**(反编译见 306 行), + // 基类 ChunkSource.hasChunk() 走 getChunk(...,false), + // 而 ClientChunkCache 覆写后在该参数下返回 emptyChunk(void_air 占位) + // ⇒ 都不可用于「未加载 vs 空气」的区分。 + // 可靠做法:getChunk(..., requireChunk=**false**) —— 反编译实测 + // return requireChunk ? emptyChunk : null; + // 即 requireChunk=false 时**未加载才给 null**(传 true 反而给占位)。 + var realChunk = level.getChunkSource() + // ★ 世代差异:1.21 把 ChunkStatus 移到 world.level.chunk.status + .getChunk(pcx, pcz, + net.minecraft.world.level.chunk.status.ChunkStatus.FULL, false); + boolean loaded = realChunk != null; + r.addProperty("chunkLoaded", loaded); + r.addProperty("inBuildHeight", level.isInWorldBounds(pos)); + + if (!loaded) { + // ★ 未加载 ≠ 空气(设计文档附录 D 强调的语义区分) + r.addProperty("known", false); + r.addProperty("reason", "chunk_not_loaded"); + emit(id, r); + break; + } + + BlockState st = level.getBlockState(pos); + Block blk = st.getBlock(); + ResourceLocation key = BuiltInRegistries.BLOCK.getKey(blk); + + r.addProperty("known", true); + r.addProperty("block", key == null ? "unknown" : key.toString()); + r.addProperty("stateId", Block.getId(st)); + r.addProperty("state", st.toString()); + r.addProperty("isAir", st.isAir()); + r.addProperty("isSolid", st.isSolid()); + + // 方块属性(朝向/开关等)—— 渲染与指令都需要 + JsonObject props = new JsonObject(); + try { + for (var e : st.getValues().entrySet()) { + props.addProperty(e.getKey().getName(), String.valueOf(e.getValue())); + } + } catch (Throwable ignore) { } + r.add("properties", props); + + // 光照与硬度(agent 判断「这能不能挖/亮不亮」用) + try { + r.addProperty("lightEmission", st.getLightEmission()); + r.addProperty("destroySpeed", st.getDestroySpeed(level, pos)); + r.addProperty("canOcclude", st.canOcclude()); + r.addProperty("hasBlockEntity", st.hasBlockEntity()); + } catch (Throwable ignore) { } + + // 贴图是否真的在 atlas 里(缺贴图的**程序化判据**,不靠看图) + // BlockModelShaper.getParticleIcon(BlockState) 是静态的; + // 它返回的 sprite 名字若含 missingno 就是缺贴图。 + try { + var shaper = mc.getBlockRenderer().getBlockModelShaper(); + // 实例方法(1.20.1 与 1.21.1 签名一致) + var icon = shaper.getParticleIcon(st); + String reg = icon.contents().name().toString(); + r.addProperty("particleTexture", reg); + r.addProperty("isMissingTexture", reg.contains("missingno")); + } catch (Throwable t) { + r.addProperty("particleTexture", "unavailable: " + t.getClass().getSimpleName()); + } + + // 是否来自 mod(零加载器注册的方块 namespace 不在 minecraft) + r.addProperty("fromMod", key != null && !key.getNamespace().equals("minecraft")); + + 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; @@ -723,6 +883,101 @@ public class McfoldRenderServer { System.err.println("[mcfold] boot done"); } + /** + * 把 blockstate 里的 model 引用规范化成 jar 里实际存在的路径。 + * + * 实测两类写法: + * "ns:block/foo" → models/block/foo.json (createbigcannons 风格,正确) + * "ns:foo" → models/foo.json(不存在!) (MTS 风格,实际在 models/block/foo.json) + * 所以先原样查,再试补 "block/"。 + */ + static String normalizeModelPath(java.util.zip.ZipFile zf, String model) { + int i = model.indexOf(':'); + String ns = i > 0 ? model.substring(0, i) : "minecraft"; + String path = i > 0 ? model.substring(i + 1) : model; + if (zf.getEntry("assets/" + ns + "/models/" + path + ".json") != null) return model; + if (zf.getEntry("assets/" + ns + "/models/block/" + path + ".json") != null) { + return ns + ":block/" + path; + } + return model; // 都不存在 → 保持原样(会显示 missing,但至少不误导) + } + + /** + * 生成一个「blockstates 覆盖包」:把 mod 的变体键统一改写成 ""。 + * + * ★ 为什么需要(实测根因): + * 我们的占位方块只有 defaultBlockState(变体键 ""), + * 而 mod 的 blockstates 常用属性键(如 "normal"、"facing=north")⇒ 匹配不上 + * ⇒ missing model ⇒ 画面上是紫黑棋盘格。 + * 例:createbigcannons:steel_block 的键是 ""(渲染正常,测过), + * mts:...fancydirt_block 的键是 "normal"(棋盘格)。 + * + * 做法:读每个 mod jar 的 assets//blockstates/*.json,取第一个 variant 的 model, + * 改写成 {"variants":{"":{"model":"<那个 model>"}}},打进一个 zip。 + * 该 zip 作为**最后选中**的资源包,优先级最高,从而覆盖原 blockstates。 + * 模型/贴图仍由原 jar 提供(不动)。 + */ + static File buildBlockstateOverlay(java.util.List jars, java.nio.file.Path outDir) throws Exception { + File overlay = new File(outDir.toFile(), "mcfold-blockstate-overlay.zip"); + java.util.TreeMap entries = new java.util.TreeMap<>(); + entries.put("pack.mcmeta", + ("{\"pack\":{\"description\":\"mcfold blockstate overlay\",\"pack_format\":15}}") + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + int rewritten = 0; + for (File jar : jars) { + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) { + var en = zf.entries(); + while (en.hasMoreElements()) { + var e = en.nextElement(); + String nm = e.getName(); + if (!nm.startsWith("assets/") || !nm.contains("/blockstates/") + || !nm.endsWith(".json")) continue; + String text; + try (var in = zf.getInputStream(e)) { + text = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + JsonObject root; + try { root = JsonParser.parseString(text).getAsJsonObject(); } + catch (Throwable t) { continue; } + if (!root.has("variants") || !root.get("variants").isJsonObject()) continue; + + // 取第一个 variant 的第一个 model —— 只求外观,不求语义 + String model = null; + for (var ve : root.getAsJsonObject("variants").entrySet()) { + JsonElement v = ve.getValue(); + JsonElement one = v.isJsonArray() ? (v.getAsJsonArray().size() > 0 + ? v.getAsJsonArray().get(0) : null) : v; + if (one != null && one.isJsonObject() && one.getAsJsonObject().has("model")) { + model = one.getAsJsonObject().get("model").getAsString(); + break; + } + } + if (model == null) continue; + // ★ 规范化 model 路径(实测根因): + // MTS 写 "mts:mtsofficialpack.fancydirt_block"(缺 block/), + // 而文件在 models/block/... ⇒ MC 找不到 ⇒ missing model ⇒ 棋盘格。 + // createbigcannons 写 "createbigcannons:block/steel_block"(有 block/),正常。 + // 策略:先按原样看 jar 里有没有;没有就试补 block/ 前缀。 + model = normalizeModelPath(zf, model); + String out = "{\"variants\":{\"\":{\"model\":\"" + model + "\"}}}"; + entries.put(nm, out.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + rewritten++; + } + } + } + if (rewritten == 0) return null; + try (var zos = new java.util.zip.ZipOutputStream( + new java.io.FileOutputStream(overlay))) { + for (var kv : entries.entrySet()) { + zos.putNextEntry(new java.util.zip.ZipEntry(kv.getKey())); + zos.write(kv.getValue()); + zos.closeEntry(); + } + } + System.err.println("[mcfold] overlay: 重写 " + rewritten + " 个 blockstates → " + overlay); + return overlay; + } + /** * 加载 mod:扫 jar → 注册占位方块 → 挂资源包(外观)。 * @@ -814,15 +1069,48 @@ public class McfoldRenderServer { var repo = mc.getResourcePackRepository(); java.nio.file.Path rpDir = mc.getResourcePackDirectory(); for (File jar : jars) { - File dst = new File(rpDir.toFile(), "mcfold-" + jar.getName()); + // ★ 必须是 .zip 后缀:FolderRepositorySource 只认 + // isDirectory+pack.mcmeta 或 **.zip**(实测它不看 .jar) + String base = jar.getName().replaceAll("\\.jar$", ""); + File dst = new File(rpDir.toFile(), "mcfold-" + base + ".zip"); if (!dst.isFile()) java.nio.file.Files.copy(jar.toPath(), dst.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); } + // ★ 生成 blockstates 覆盖包并放进资源包目录 + // (把 mod 的属性变体键改写成 "" —— 占位方块只有 defaultBlockState) + java.util.List jarList = new java.util.ArrayList<>(); + for (File j : jars) jarList.add(j); + File overlay = buildBlockstateOverlay(jarList, rpDir); + if (overlay != null) { + File od = new File(rpDir.toFile(), "zzz-mcfold-blockstate-overlay.zip"); + java.nio.file.Files.copy(overlay.toPath(), od.toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } repo.reload(); + // 诊断(放进响应,stderr 会被后续日志冲掉) + out.addProperty("packDir", rpDir.toString()); + JsonArray avail = new JsonArray(); + for (String ai : repo.getAvailableIds()) avail.add(ai); + out.add("availableIds", avail); + JsonArray compat = new JsonArray(); + for (var pk2 : repo.getAvailablePacks()) { + JsonObject po = new JsonObject(); + po.addProperty("id", pk2.getId()); + po.addProperty("compat", String.valueOf(pk2.getCompatibility())); + compat.add(po); + } + out.add("availablePacks", compat); var sel = new java.util.LinkedHashSet<>(repo.getSelectedIds()); + // 先加 mod 包(提供 models/textures),再加 overlay(覆盖 blockstates) + java.util.List overlayIds = new java.util.ArrayList<>(); for (String packId : repo.getAvailableIds()) { + if (packId.contains("zzz-mcfold-blockstate-overlay")) { overlayIds.add(packId); continue; } if (packId.contains("mcfold-")) { sel.add(packId); packsAdded++; } } + sel.addAll(overlayIds); // 最后 = 最高优先级 + JsonArray selArr = new JsonArray(); + for (String si : sel) selArr.add(si); + out.add("selectedIds", selArr); repo.setSelected(sel); // 用新 pack 列表重建 resource manager(照官方 createReload 内部做法), @@ -840,6 +1128,38 @@ public class McfoldRenderServer { reloadResources(mc); net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); mc.levelRenderer.allChanged(); + + // ★ 自验证:重载后 mod 资源是否真的可读(TOO_OLD 是否阻止加载) + ResourceManager rm2 = mc.getResourceManager(); + System.err.println("[mcfold] resourceManager=" + rm2.getClass().getSimpleName() + + " namespaces=" + rm2.getNamespaces()); + JsonArray check = new JsonArray(); + for (File jar : jars) { + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) { + var en2 = zf.entries(); + while (en2.hasMoreElements()) { + String nm = en2.nextElement().getName(); + java.util.regex.Matcher m2 = java.util.regex.Pattern + .compile("^assets/([^/]+)/(blockstates/.+\\.json)$").matcher(nm); + if (!m2.matches()) continue; + // ★ 世代差异(1.21+):构造 private → 静态工厂 + ResourceLocation rid = ResourceLocation.fromNamespaceAndPath( + m2.group(1), m2.group(2)); + JsonObject co = new JsonObject(); + co.addProperty("id", rid.toString()); + var got2 = rm2.getResource(rid); + co.addProperty("readable", got2.isPresent()); + if (got2.isPresent()) { + try (var in2 = got2.get().open()) { + co.addProperty("bytes", in2.readAllBytes().length); + } + } + check.add(co); + break; // 每个 jar 只查一个样本 + } + } catch (Throwable ignore) { } + } + out.add("resourceCheck", check); } catch (Throwable t) { out.addProperty("packError", String.valueOf(t)); System.err.println("[mcfold] 资源包挂载失败: " + t); diff --git a/scaffold/java/launcher_fabric/LoaderBoot.java b/scaffold/java/launcher_fabric/LoaderBoot.java new file mode 100644 index 0000000..322c877 --- /dev/null +++ b/scaffold/java/launcher_fabric/LoaderBoot.java @@ -0,0 +1,67 @@ +import net.fabricmc.api.EnvType; +import net.fabricmc.loader.impl.launch.knot.Knot; + +/** + * 【加载器后端】用 Fabric 加载器,但不写 mod。 + * + * 用户定向(2026-09-25): + * 「不写 mod」+「用 mod 加载器」+「功能在现有后端里」。 + * + * 实现关键(全部实测): + * 1. Knot.init() 是 **public**,它做完 + * 「发现 mod → 建 KnotClassLoader → 应用 mixin → invokeEntrypoints(preLaunch)」 + * 后返回,**不**启动官方主循环(那在 GameProvider.launch() 里 —— 我们不调)。 + * 2. Knot.loadIntoTarget(name) 把我们的启动类加载进同一个 KnotClassLoader + * ⇒ 我们的代码与 mod 共享 classloader 与 mixin 变换,但控制权仍在我们手里。 + * + * 实测输出: + * [loaderboot] ✅ init 返回: net.fabricmc.loader.impl.launch.knot.KnotClassLoader + * [loaderboot] - mcfold_loadprobe 1.0 ← mod 被识别 + * [loaderboot] - minecraft 1.20.1 + * [loaderboot] ✅ 加载到: McfoldRenderServer / CL=KnotClassLoader + * {"event":"ready"} ← 我们的渲染服务同时就绪 + * + * ★ 两个必需条件(缺一则我们的 named 代码无法在加载器下运行): + * a) classpath 里放 **反混淆后的** client jar(client-named.jar),不是官方 obf jar + * b) -Dfabric.gameMappingNamespace=named + * 否则游戏被 remap 成 intermediary,堆栈里是 `enn.`, + * 我们的代码会抛 NoClassDefFoundError: net/minecraft/client/Minecraft(实测)。 + * + * 启动示例: + * java -cp "::client-named.jar:<我们的classes>" \ + * -Dfabric.gameMappingNamespace=named \ + * -Dfabric.modsFolder=/path/to/mods \ + * LoaderBoot --gameDir ... --assets ... --assetIndex 1.20.1 + * + * ★ asm 必须只留一份(用 Fabric 的 9.10.1):1.20.1 自带 9.2 与 9.6 两版, + * 与 Fabric 的并存会让 Knot. 抛 "duplicate ASM classes found on classpath"。 + */ +public class LoaderBoot { + + /** 我们自己的服务端主类(默认包,与 McfoldRenderServer 同名)。 */ + private static final String TARGET = "McfoldRenderServer"; + + public static void main(String[] args) throws Exception { + Knot knot = new Knot(EnvType.CLIENT); + + // ① 加载 mod + 应用 mixin;不起官方主循环 + ClassLoader gameCl = knot.init(args); + System.err.println("[loaderboot] KnotClassLoader = " + gameCl.getClass().getName()); + + // 报告已加载的 mod(诊断用;也是「加载器真的在工作」的证据) + try { + for (var m : net.fabricmc.loader.api.FabricLoader.getInstance().getAllMods()) { + System.err.println("[loaderboot] mod " + m.getMetadata().getId() + + " " + m.getMetadata().getVersion().getFriendlyString()); + } + } catch (Throwable t) { + System.err.println("[loaderboot] 读 mod 列表失败: " + t); + } + + // ② 把我们的服务端注入同一个 classloader,然后调用它的 main + Class boot = knot.loadIntoTarget(TARGET); + System.err.println("[loaderboot] 注入 " + boot.getName() + + " 到 " + boot.getClassLoader().getClass().getSimpleName()); + boot.getMethod("main", String[].class).invoke(null, (Object) args); + } +} diff --git a/tests/test_render_bridge.py b/tests/test_render_bridge.py index 7dc0a05..2c0052d 100644 --- a/tests/test_render_bridge.py +++ b/tests/test_render_bridge.py @@ -41,7 +41,8 @@ def _open_backend(): if not ok: return None, why br = Gen_1_20( - game_dir=str(STORE / "instances" / "default"), + # ★ 用测试专用实例目录,不碰生产 default(版本隔离策略) + game_dir=str(STORE / "instances" / "test-mcfold"), assets=str(STORE / "assets"), asset_index="1.20.1", ) try: @@ -184,6 +185,72 @@ def test_set_blocks_render(): br.stop() +def test_get_block_info(): + """get_block_info:方块名/属性/未加载区分(Java 侧回答)。 + + ★ 关键判据:**未加载的区块必须 known=false**,不能伪装成空气 + (ClientLevel.hasChunk() 恒 true、ChunkSource.hasChunk() 也恒 true, + 实测都不顶用;必须用 getChunk(..., requireChunk=false) 判 null)。 + """ + br, why = _open_backend() + if br is None: + print(f" - 跳过({why})") + return + try: + cap = br._request({"cmd": "capacity"}) + min_s = cap["minSection"] + stone, grass, log = br.resolve_blocks([ + "minecraft:stone", "minecraft:grass_block[snowy=false]", + "minecraft:oak_log[axis=y]", + ]) + assert stone > 0 and grass > 0 and log > 0 + br.set_viewport(0, 0, radius=12) + + sections = {} + for idx in range(cap["sectionsCount"]): + v = [0] * SECTION_VOLUME + if idx < 4: + v = [stone] * SECTION_VOLUME + elif idx == 4: + v = [grass] * SECTION_VOLUME + elif idx == 5: + v[7 + 7 * 16] = log # x=7,z=7 + sections[idx] = v + for cx in range(-1, 2): + for cz in range(-1, 2): + br.set_blocks(cx, cz, sections) + + y_grass = (min_s + 4) * 16 + y_stone = (min_s + 2) * 16 + y_log = (min_s + 5) * 16 + + g = br.get_block_info(8, y_grass, 8) + assert g["known"] and g["block"] == "minecraft:grass_block", g + assert g["isAir"] is False and g["isSolid"] is True + assert g["properties"].get("snowy") == "false", g + assert g["isMissingTexture"] is False, g + assert g["fromMod"] is False + + st = br.get_block_info(8, y_stone, 8) + assert st["known"] and st["block"] == "minecraft:stone", st + + lg = br.get_block_info(7, y_log, 7) + assert lg["known"] and lg["block"] == "minecraft:oak_log", lg + assert lg["properties"].get("axis") == "y", lg # 属性必须读出来 + + air = br.get_block_info(8, 200, 8) + assert air["known"] and air["isAir"] is True, air + + # ★ 最重要的一条:未加载 ≠ 空气 + far = br.get_block_info(9999, 64, 9999) + assert far["known"] is False, f"未加载区块必须 known=false,得到 {far}" + assert far.get("reason") == "chunk_not_loaded", far + print(f" ✓ get_block_info({g['block']} 属性={g['properties']}, " + f"原木 axis={lg['properties'].get('axis')}, 未加载已区分)") + finally: + br.stop() + + def test_real_save_render(): """真实存档经新路径(RLE 路由)渲染 —— 最终验收。""" ok, why = _available()